Merge remote-tracking branch 'origin/main' into brennanb2025/i18n-catalog-decoupling

# Conflicts:
#	.github/workflows/pr.yml
#	package.json
#	pnpm-lock.yaml
This commit is contained in:
Brennan Benson
2026-07-29 15:13:00 -07:00
5594 changed files with 657713 additions and 96830 deletions
+6
View File
@@ -6,3 +6,9 @@
/config/scripts/run-internal-dev-setup.mjs text eol=lf
/config/scripts/verify-cli-bin.mjs text eol=lf
/config/scripts/verify-release-required-assets.mjs text eol=lf
/skill-guides/*.md text eol=lf
/skill-stubs/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
/src/cli/bundled-skill-guides.ts text eol=lf
# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
/resources/plugins/** text eol=lf
@@ -0,0 +1,68 @@
name: Install Node dependencies
description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
inputs:
native-runtime:
description: Native runtime to prepare after the script-free install (none, node, or electron).
required: false
default: none
runs:
using: composite
steps:
# setup-node needs pnpm on PATH to locate and restore its store.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Validate native runtime
shell: bash
env:
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
run: |
case "$NATIVE_RUNTIME" in
none|node|electron) ;;
*)
echo "::error::native-runtime must be none, node, or electron"
exit 2
;;
esac
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
- name: Use external node-gyp
if: inputs.native-runtime != 'none'
shell: bash
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Prepare dependency install
shell: bash
run: |
if [ -e node_modules ]; then
ls -ld node_modules
rm -rf node_modules
fi
- name: Install dependencies
shell: bash
run: |
pnpm install \
--no-frozen-lockfile \
--prefer-frozen-lockfile=false \
--ignore-scripts
git diff --exit-code package.json pnpm-lock.yaml
- name: Prepare native runtime
if: inputs.native-runtime != 'none'
shell: bash
env:
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
+11 -1
View File
@@ -6,6 +6,8 @@ on:
- '.github/workflows/computer-e2e.yml'
- 'config/electron-builder.config.cjs'
- 'config/scripts/build-computer-macos.mjs'
- 'config/scripts/build-windows-cli-launcher.mjs'
- 'config/scripts/build-windows-cli-launcher.test.mjs'
- 'config/scripts/computer-e2e-workflow.test.mjs'
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
@@ -22,12 +24,15 @@ on:
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
- 'native/computer-use-windows/**'
- 'native/windows-cli-launcher/**'
- 'skills/computer-use/SKILL.md'
- 'src/cli/**'
- 'src/main/computer/**'
- 'src/main/runtime/rpc/dispatcher.ts'
- 'src/main/runtime/rpc/errors.ts'
- 'src/main/runtime/rpc/methods/computer*.ts'
- 'src/main/ssh/ssh-remote-cli-launcher.ts'
- 'src/main/ssh/ssh-remote-cli-launcher.test.ts'
- 'src/shared/computer-use-*.ts'
- 'tests/e2e/computer-linux.e2e.ts'
- 'tests/e2e/computer-mac.e2e.ts'
@@ -70,9 +75,14 @@ jobs:
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
# is no root config) and falls back to the 5s default timeout with no
# Windows worker cap, so the real csc.exe launcher-compile tests time out
# on hosted Windows. Use the shared config so this job matches pnpm test.
- run: >-
pnpm vitest run
pnpm vitest run --config config/vitest.config.ts
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/computer-use-skill-guidance.test.mjs
config/scripts/computer-use-smoke.test.mjs
+17 -2
View File
@@ -1,5 +1,11 @@
name: E2E
run-name: E2E ${{ inputs.ref || github.ref }}
# Why: checkout + artifact upload only; callers can only further restrict.
permissions:
contents: read
on:
workflow_call:
inputs:
@@ -76,7 +82,7 @@ jobs:
name: e2e ${{ matrix.shard_name }}
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -178,7 +184,9 @@ jobs:
name: ssh docker watcher isolation
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
# Why 35: the terminal parking + retention specs below add two more
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
timeout-minutes: 35
steps:
- name: Checkout
@@ -221,6 +229,13 @@ jobs:
- name: Run Docker SSH watcher isolation E2E
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
# Why always(): these specs gate the C1 terminal parking/retention budget
# over a real SSH host and run in no other lane, so a watcher-isolation
# failure above must not silently skip them.
- name: Run Docker SSH terminal parking + retention E2E
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
- name: Upload watcher isolation traces
if: failure()
uses: actions/upload-artifact@v7
@@ -9,6 +9,8 @@ on:
- 'tests/e2e/fixtures/terminal-emoji-table.md'
- 'tests/e2e/helpers/**'
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
- 'src/renderer/src/lib/pane-manager/**'
workflow_dispatch:
+1 -1
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
bump_patch_version:
description: 'Bump the iOS marketing version patch number before release. Tick this after a version has shipped to the App Store (the release fails fast if the current version''s train is already closed).'
description: 'Use the first open iOS patch version after the checked-in version, skipping versions already closed on the App Store.'
required: false
default: false
type: boolean
+9 -1
View File
@@ -30,6 +30,11 @@ jobs:
with:
node-version-file: package.json
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
@@ -51,11 +56,14 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Typecheck
run: npx tsc --noEmit
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Test iOS release version resolution
run: ruby fastlane/ios_release_version_test.rb
- name: Lint
run: pnpm lint
+293 -91
View File
@@ -8,79 +8,67 @@ on:
- reopened
- ready_for_review
concurrency:
group: pr-checks-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
static_analysis:
name: static analysis
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
fetch-depth: 0
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: pnpm's bundled node-gyp ships gyp_main.py without execute
# permission, which breaks native module builds (e.g. node-pty's
# postinstall) with "/bin/sh: gyp_main.py: Permission denied".
# Pin the fallback to the lockfile's node-gyp version so CI stays
# reproducible while forcing pnpm to bypass its broken bundled copy.
# Gate on runner.os == 'Linux' to match release.yml — the
# npm-global path layout this step assumes is POSIX-shaped, and the
# failure has only been observed on Linux runners. Today this job
# pins runs-on: ubuntu-latest so the guard is a no-op, but it
# prevents a silent break if a Windows/macOS matrix is added later.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Prepare dependency install
run: |
if [ -e node_modules ]; then
ls -ld node_modules
rm -rf node_modules
fi
- name: Install dependencies
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
# runners while creating the root node_modules. Use the normal resolver
# path, then verify package metadata stayed unchanged.
run: |
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
git diff --exit-code package.json pnpm-lock.yaml
- uses: ./.github/actions/install-node-dependencies
- name: Lint
run: pnpm exec oxlint --format github
- name: Enforce focused code-quality plugins
run: pnpm run audit:code-quality:native
- name: Enforce type-aware code-quality baseline
run: pnpm run audit:code-quality:type-aware
- name: Enforce changed-code quality
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
- name: Enforce React Doctor on changed lines
run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}"
- name: Check Zustand selector fan-out budget
run: pnpm run check:zustand-selector-fanout
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
- name: Enforce max-lines ratchet
run: pnpm run check:max-lines-ratchet
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
- name: Verify skill freshness manifest
run: pnpm run verify:skill-bundle-manifest
- name: Verify localization catalog
run: pnpm run verify:localization-catalog
# Why: extraction writes sorted evidence to an isolated temporary path,
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
- name: Verify localization extraction
run: pnpm run verify:localization-extraction
- name: Check styled scrollbars
run: pnpm check:styled-scrollbars
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
# ratchet forbids ADDING a new suppression (inline disable or mobile max
# bump). Existing oversized files are grandfathered in
# config/max-lines-baseline.txt, which may only shrink — new bypasses fail
# here with a clear message instead of silently growing the debt.
- name: Enforce max-lines ratchet (no new bypasses)
run: pnpm run check:max-lines-ratchet
- name: Verify localization coverage
run: pnpm run verify:localization-coverage
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
@@ -105,55 +93,269 @@ jobs:
- name: Verify macOS entitlements
run: pnpm verify:macos-entitlements
- name: Typecheck
run: pnpm typecheck
typecheck:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- run: pnpm typecheck
git_compatibility:
name: Git compatibility
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
# Why: real old Git diagnostics differ from mocked errors. Keep the
# fallback predicates executable across the baseline, transition, and
# current command shapes so a newly added flag cannot silently regress.
- name: Verify Git binary compatibility matrix
run: |
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
source="$RUNNER_TEMP/git-2.25.5"
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
| sha256sum --check
mkdir -p "$source"
tar -xzf "$archive" -C "$source" --strip-components=1
make -C "$source" -j2 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
pids=()
(
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
source="$RUNNER_TEMP/git-2.25.5"
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
| sha256sum --check
mkdir -p "$source"
tar -xzf "$archive" -C "$source" --strip-components=1
make -C "$source" -j"$(nproc)" \
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
) &
pids+=("$!")
for spec in \
"alpine/git:edge-2.38.1|2.38.1" \
"alpine/git:v2.49.1|2.49.1"; do
image="${spec%%|*}"
version="${spec#*|}"
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
(
image="${spec%%|*}"
version="${spec#*|}"
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
) &
pids+=("$!")
done
# Why: postinstall rebuilds better-sqlite3 for Electron's ABI via
# @electron/rebuild, but vitest runs under system Node.js. Rebuild
# it for Node so orchestration tests can load the native module.
- name: Rebuild better-sqlite3 for Node
run: pnpm rebuild better-sqlite3
status=0
for pid in "${pids[@]}"; do
wait "$pid" || status=1
done
exit "$status"
shell_contracts:
name: shell contracts
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install zsh
run: sudo apt-get update && sudo apt-get install -y zsh
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Test real shell contracts
run: |
pnpm exec vitest run --config config/vitest.config.ts \
src/main/daemon/shell-ready.test.ts \
src/main/daemon/node-pty-fd-leak.test.ts \
src/main/providers/local-pty-shell-ready.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/shared/posix-command-path-lookup.test.ts
test:
name: tests ${{ matrix.shard }}/${{ strategy.job-total }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
# 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
- name: Test shard
run: |
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--shard=${{ matrix.shard }}/${{ strategy.job-total }}
- name: Build unpacked app
run: pnpm build:unpack
package:
name: package
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~/.cache/electron
~/.cache/electron-builder
key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-linux-
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- name: Build package inputs
run: |
status=0
pnpm run build:cli || status=1
scripts=(build:relay build:electron-vite:parallel)
pids=()
for script in "${scripts[@]}"; do
pnpm run "$script" &
pids+=("$!")
done
for pid in "${pids[@]}"; do
wait "$pid" || status=1
done
exit "$status"
- name: Project web client from renderer build
run: pnpm run build:web-from-renderer
- name: Build native components
run: pnpm run build:native
- name: Package unpacked app
env:
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
# Why: the packaged CLI runs outside Electron's asar integration, so
# bare runtime imports must be present in the package itself. Run from a
# temp copy so Node cannot mask missing package deps with repo node_modules.
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
# Why: regression specs under tests/e2e/** used to merge green without ever
# running — e2e.yml only fired on schedule/release (#10518). Path-filter so
# ordinary PRs stay light; any E2E suite change still gets a full shard run.
e2e-paths:
name: detect e2e path changes
runs-on: ubuntu-latest
if: github.event.pull_request.draft != true
# Why: detector only needs to read the checkout; do not inherit repo defaults.
permissions:
contents: read
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- name: Filter E2E-relevant paths
id: filter
run: |
set -euo pipefail
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# Why: capture first so a failed git diff does not look like "no matches"
# (pipeline status in `if` is not aborted by set -e). Merge-base limits the
# list to files this PR introduced, not base-branch drift.
CHANGED="$(git diff --name-only --merge-base "$BASE" "$HEAD")"
# Why: tests/playwright.config.ts sits beside tests/e2e/, not inside it, so
# it needs its own pattern — a bare `playwright.` prefix matches no tracked
# file and would silently skip E2E when the runner config changes.
if printf '%s\n' "$CHANGED" | grep -E '^(tests/e2e/|tests/playwright\.|\.github/workflows/e2e\.yml$)' >/dev/null; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "E2E path changes detected"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "No E2E path changes"
fi
e2e:
name: e2e
needs: e2e-paths
if: needs.e2e-paths.outputs.should_run == 'true'
# Why: reusable e2e.yml only checkouts, builds, and uploads artifacts.
permissions:
contents: read
uses: ./.github/workflows/e2e.yml
verify:
if: always()
needs:
- static_analysis
- typecheck
- git_compatibility
- shell_contracts
- test
- package
runs-on: ubuntu-latest
steps:
# Why: e2e is deliberately absent from needs. The suite is currently red on
# main (every scheduled run), so gating merges on it would block any PR that
# touches tests/e2e/** — including the ones fixing the suite. Until it is
# green the job runs and reports for E2E-path PRs without blocking. To flip
# it on: add `e2e` to needs, add E2E to the env below, and require
# `"$E2E" = success || skipped` after the loop — skipped is the normal
# result for a path-filtered job and must keep passing, so it has to be
# checked outside the loop or it would excuse the jobs above.
- name: Require successful checks
env:
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
TYPECHECK: ${{ needs.typecheck.result }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
TEST: ${{ needs.test.result }}
PACKAGE: ${{ needs.package.result }}
run: |
for result in \
"$STATIC_ANALYSIS" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$PACKAGE"; do
if [ "$result" != "success" ]; then
exit 1
fi
done
+374 -28
View File
@@ -42,7 +42,12 @@ on:
default: false
type: boolean
version_suffix:
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only.
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). Applies to kind=rc, or to an explicit version that is a bare X.Y.Z-rc.N.
required: false
type: string
default: ''
version:
description: Exact version to cut (e.g. 1.4.155 or 1.4.155-rc.4), bypassing kind-based computation. Use to leapfrog a deleted/rolled-back stable that regressed the release list. Must be greater than the latest published stable, and an -rc.N must be above the highest RC already cut for its own base.
required: false
type: string
default: ''
@@ -68,6 +73,28 @@ jobs:
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }}
steps:
# Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs):
# this job runs with contents:write and secret scope, so avoid executing
# any external (mutable @v1) action here. Surfaces every
# workflow_dispatch input as a table for audit; the resolved commit /
# branch / tag enrichment is written later in "Resolve ref SHA".
# Inputs are passed as JSON via env and parsed by jq as data — never
# interpolated into the shell — to avoid injection from dispatch values.
- name: Summarize workflow inputs
if: github.event_name == 'workflow_dispatch'
env:
INPUTS_JSON: ${{ toJSON(inputs) }}
run: |
{
echo "## Workflow inputs"
echo ""
echo "| Input | Value |"
echo "| --- | --- |"
# Values are data from env JSON; wrap in backticks for readability.
# Newlines collapsed so a multi-line input cannot break the table.
jq -r '(. // {}) | to_entries[] | "| `\(.key)` | `\(.value | tostring | gsub("\n"; " "))` |"' <<<"$INPUTS_JSON"
} >> "$GITHUB_STEP_SUMMARY"
- name: Checkout ref
uses: actions/checkout@v6
with:
@@ -86,8 +113,17 @@ jobs:
- name: Resolve ref SHA
id: resolve
env:
# Why: keep the caller's ref as data (env) so we can label it in the
# summary without shell-interpolating a dispatch-controlled string
# into the script body.
INPUT_REF: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
REPO: ${{ github.repository }}
SERVER_URL: ${{ github.server_url }}
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
short_sha="$(git rev-parse --short=12 HEAD)"
echo "sha=$sha" >>"$GITHUB_OUTPUT"
# Why: only push the version-bump commit back to main when the
@@ -101,6 +137,97 @@ jobs:
echo "push_main=false" >>"$GITHUB_OUTPUT"
fi
# Always surface the resolved commit in the job summary, plus any
# branches/tags that currently point at it (clickable). The raw
# `ref` input alone is ambiguous (branch vs tag vs SHA); for SHA
# inputs it also hides the human-readable names operators need
# when auditing RC cuts.
input_ref="${INPUT_REF:-main}"
repo_url="${SERVER_URL}/${REPO}"
branches="$(
git for-each-ref --format='%(refname:short)' --points-at="$sha" 'refs/remotes/origin/*' \
| sed 's|^origin/||' \
| grep -vx 'HEAD' \
| sort -u \
|| true
)"
tags="$(
git for-each-ref --format='%(refname:short)' --points-at="$sha" 'refs/tags/*' \
| sort -u \
|| true
)"
# Build comma-separated markdown links. Branch/tag names go in the
# URL path as-is (slashes must stay literal for GitHub tree URLs).
linkify_names() {
local url_kind="$1"
local names="$2"
if [[ -z "${names//[$'\t\r\n']/}" ]]; then
printf '_none_'
return
fi
local first=1
while IFS= read -r name; do
[[ -z "$name" ]] && continue
local path_name url
path_name="${name// /%20}"
case "$url_kind" in
branch) url="${repo_url}/tree/${path_name}" ;;
tag) url="${repo_url}/releases/tag/${path_name}" ;;
*) url="${repo_url}" ;;
esac
if [[ "$first" -eq 1 ]]; then
first=0
else
printf ', '
fi
printf '[`%s`](%s)' "$name" "$url"
done <<<"$names"
}
branch_md="$(linkify_names branch "$branches")"
tag_md="$(linkify_names tag "$tags")"
# When no branch tip matches (historical SHA cuts), fall back to
# name-rev so the summary still shows something like `main~3`.
contains_md="_none_"
if [[ "$branch_md" == "_none_" ]]; then
approx="$(git name-rev --name-only --no-undefined --refs='refs/remotes/origin/*' "$sha" 2>/dev/null || true)"
if [[ -n "$approx" ]]; then
# name-rev prints remotes/origin/<branch>[~N]; strip to branch[~N].
approx="${approx#remotes/origin/}"
approx="${approx#origin/}"
contains_md="\`${approx}\`"
fi
fi
input_kind="ref"
if git rev-parse -q --verify "refs/remotes/origin/${input_ref}" >/dev/null 2>&1; then
input_kind="branch"
elif git rev-parse -q --verify "refs/tags/${input_ref}" >/dev/null 2>&1; then
input_kind="tag"
elif [[ "$input_ref" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
input_kind="sha"
fi
{
echo "## Resolved source"
echo ""
echo "Every cut resolves to a commit. Branch/tag rows list refs whose tip is that commit."
echo ""
echo "| Field | Value |"
echo "| --- | --- |"
echo "| Input ref | \`${input_ref}\` (${input_kind}) |"
echo "| Commit | [\`${short_sha}\`](${repo_url}/commit/${sha}) |"
echo "| Branches at commit | ${branch_md} |"
echo "| Tags at commit | ${tag_md} |"
if [[ "$branch_md" == "_none_" ]]; then
echo "| Also on | ${contains_md} |"
fi
echo ""
} >> "$GITHUB_STEP_SUMMARY"
- name: Compute RC slot
id: slot
run: |
@@ -198,6 +325,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }}
EXPLICIT_VERSION: ${{ github.event_name == 'schedule' && '' || inputs.version }}
run: |
set -euo pipefail
@@ -260,6 +388,23 @@ jobs:
node config/scripts/release-rc-history.mjs "$1"
}
require_valid_version_suffix() {
# Why a dot-appended identifier (rc.N.perf): it sorts just
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
# branch builds never outrank the main RC series and cannot
# hijack the update channel; clients find them by matching the
# identifier ("perf") in the prerelease components.
# Why the numeric alternation rather than plain [0-9A-Za-z]+:
# semver forbids a leading zero on an all-digit identifier, and
# `npm version` silently renormalizes rc.4.01 to rc.4.1 while the
# tag step keeps the literal input — so the shipped package.json
# version and its own release tag would name different releases.
if [[ ! "$1" =~ ^(0|[1-9][0-9]*|[0-9A-Za-z]*[A-Za-z][0-9A-Za-z]*)$ ]]; then
echo "::error::version_suffix (or the trailing .identifier in version) must be alphanumeric with no leading zero on an all-digit identifier, got: $1" >&2
exit 1
fi
}
current_package_stable() {
node -e '
const { version } = require("./package.json");
@@ -336,7 +481,14 @@ jobs:
# floor for the current ref so the next cut cannot reuse an older
# stable number just because the public release was nuked.
if semver_gt "$package_stable" "$latest_stable"; then
if [[ "$KIND" != "rc" ]]; then
# Skip floor-tag recovery when an explicit version is requested:
# recover_unpublished_tag can exit 0, which would recover the
# package-floor tag instead of cutting the requested version —
# defeating the very rollback scenario the override exists for.
# We still raise latest_stable to the floor below so the explicit
# version is gated against it; the collision recovery for the
# requested tag runs later.
if [[ "$KIND" != "rc" && -z "${EXPLICIT_VERSION:-}" ]]; then
package_tag="v$package_stable"
if git rev-parse "$package_tag" >/dev/null 2>&1; then
recover_unpublished_tag "$package_tag" "current ref stable tag is newer than latest published stable" || true
@@ -348,6 +500,98 @@ jobs:
fi
fi
# Explicit version override (manual dispatch only).
#
# Why: kind-based math derives the next number from the latest
# *published* stable. When a shipped stable is deleted (e.g. a
# rolled-back 1.4.154), the release list regresses to the prior
# stable, so a kind cut recomputes a number at or below the nuked one
# and strands every client that already installed the deleted build.
# The package.json floor above only recovers this when the deleted
# version's bump commit is on the ref being cut, which a hotfix cut
# from an older RC ref does not carry. An explicit version lets a
# human assert the exact target (e.g. leapfrog to 1.4.155); the
# updater-safety gate and tag-collision recovery below still apply.
new=""
if [[ -n "${EXPLICIT_VERSION:-}" ]]; then
explicit="${EXPLICIT_VERSION#v}"
# Why the optional trailing identifier: it lets an operator name a
# suffixed side-branch RC (X.Y.Z-rc.N.perf) directly, the same shape
# the rc path cuts. Note this only ever admits one *above* the
# series head — the gate below refuses a suffixed rc at or below it
# just like a bare one, so this is a second spelling of
# `version=X.Y.Z-rc.N` + `version_suffix`, not a way back into a
# series that already shipped.
# Why rc.(0|[1-9][0-9]{0,8}): the `-le` below compares with bash's
# machine-width integers, so both ends of that range fall *open* on
# exactly the RCs this gate must catch. A leading zero (rc.08) is an
# invalid octal literal, and the failed test makes the `if` false.
# Past INTMAX the literal wraps two's-complement, so whether it
# reads as above or below the published rc depends on the value:
# rc.99999999999999999999 wraps to 7766279631452241919 and sails
# through. The cut then lands a tag that pins highest_rc_for_base
# at 1e20 forever, and every later cut wraps to a *lower* rc that
# sorts below it, so the fleet never updates again. Nine digits is
# far above any real series and exact in bash math either way.
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.(0|[1-9][0-9]{0,8})(\.[0-9A-Za-z]+)?)?$ ]]; then
echo "::error::version must be X.Y.Z, X.Y.Z-rc.N, or X.Y.Z-rc.N.suffix, got: $EXPLICIT_VERSION" >&2
exit 1
fi
# Why route the embedded identifier through the same validator the
# kind path uses: the regex above only checks shape, and rc.4.01
# is a shape-valid identifier that is not valid semver.
if [[ "$explicit" == *-rc.*.* ]]; then
require_valid_version_suffix "${explicit##*.}"
fi
# Same updater-safety gate the kind path enforces: stable line must
# strictly increase over the latest published stable (prerelease
# identifiers ignored for the comparison).
if ! semver_gt "$explicit" "$latest_stable"; then
echo "::error::Refusing explicit version $explicit: not greater than latest stable $latest_stable." >&2
exit 1
fi
# Why a second gate for prereleases: semver_gt compares through
# strip_pre(), so the stable-line check reads 1.4.156-rc.0 as
# 1.4.156 and waves it past a 1.4.155 stable even when rc.0..rc.3
# already shipped — republishing an RC *below* what clients run,
# the same regression class as the rc.4 cut that orphaned live
# daemons. Anchor on the same rc history the kind path uses so the
# override can only ever advance the series it targets.
if [[ "$explicit" == *-rc.* ]]; then
explicit_base="${explicit%%-*}"
explicit_rc="${explicit#*-rc.}"
explicit_rc="${explicit_rc%%.*}"
highest_explicit_rc="$(highest_rc_for_base "$explicit_base")"
if [[ -n "$highest_explicit_rc" && "$explicit_rc" -le "$highest_explicit_rc" ]]; then
# Why the remedy is spelled this narrowly: kind=rc derives its
# base from bump(latest_stable, patch), so it can only resume a
# series on that base. A minor/major series (1.5.0-rc.N) exists
# only because this override created it, and pointing an
# operator at kind=rc there would cut an unrelated release.
echo "::error::Refusing explicit version $explicit: rc.$explicit_rc is not above rc.$highest_explicit_rc, the highest already cut for $explicit_base. Request rc.$((highest_explicit_rc + 1)) or higher. If you are resuming an unpublished tag and $explicit_base is the next patch after latest stable $latest_stable, dispatch kind=rc instead, which recovers that tag when it was cut from the ref you dispatch; otherwise cut rc.$((highest_explicit_rc + 1)) and leave the unpublished tag alone." >&2
exit 1
fi
fi
new="$explicit"
# Why here too: the suffix append below lives in the kind path the
# override skips, so an operator passing both inputs used to get
# their suffix silently dropped. Only a bare rc can take one — a
# stable X.Y.Z.perf is not valid semver, and re-suffixing an
# already-suffixed rc would produce rc.N.perf.perf.
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
# Same bounded rc pattern as the shape check above, so the two
# cannot drift apart under a later edit.
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.(0|[1-9][0-9]{0,8})$ ]]; then
echo "::error::version_suffix applies only to a bare X.Y.Z-rc.N version, got: $explicit" >&2
exit 1
fi
require_valid_version_suffix "$VERSION_SUFFIX"
new="${new}.${VERSION_SUFFIX}"
fi
echo "Explicit version override: $new"
fi
if [[ -z "$new" ]]; then
case "$KIND" in
rc)
# Why: RCs always stabilize the *next* patch after whatever
@@ -373,15 +617,7 @@ jobs:
new="${base}-rc.$((highest_rc + 1))"
fi
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
# Why a dot-appended identifier (rc.N.perf): it sorts just
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
# branch builds never outrank the main RC series and cannot
# hijack the update channel; clients find them by matching the
# identifier ("perf") in the prerelease components.
if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then
echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2
exit 1
fi
require_valid_version_suffix "$VERSION_SUFFIX"
new="${new}.${VERSION_SUFFIX}"
fi
;;
@@ -425,6 +661,7 @@ jobs:
exit 1
;;
esac
fi
# Orphan-tag recovery.
#
@@ -472,7 +709,19 @@ jobs:
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
# assumptions and any lifecycle scripts that would run on bump).
npm version "$VERSION" --no-git-tag-version --allow-same-version
git add package.json
# Why: the cut is the only point where committed skill bytes become a
# released revision. Without this row the ledger never advances, so the
# next skill change rebuilds the revision this tag ships over different
# bytes and every install of it stops matching a known snapshot.
# --release is provenance-only: it fails if the content-addressed
# artifacts do not already match this ref and writes just the mapping
# row, so the version commit stays skill-independent. Node built-ins
# only, so this needs no install.
if ! node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION"; then
echo "::error::Refusing to record release provenance for v$VERSION: the committed skill artifacts do not match this ref. Land a regeneration on main, then re-run the cut." >&2
exit 1
fi
git add package.json resources/skills/release-mapping.json
commit_message="release: v$VERSION"
if [[ "$EVENT_NAME" == "schedule" ]]; then
commit_message="$commit_message [rc-slot:$SLOT]"
@@ -485,6 +734,22 @@ jobs:
else
git commit -m "$commit_message"
fi
# Why: a lint that greps this file cannot see a path built from an env
# var, a composite action, or concatenation, and `git commit` has forms
# (-a, -i, --only, a pathspec) that commit the working tree rather than
# the index. Assert what the commit actually carries, so the tag can
# only ever ship the version bump and the provenance row, no matter
# which step staged what or how the commit was spelled.
# -F because the allowlist is literal: unanchored, `.` would match any
# character and quietly admit a path like `packageXjson`.
# -m --first-parent: plain diff-tree prints NOTHING for a merge commit,
# which would make this guard pass silently rather than fail closed.
committed="$(git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD |
grep -vxF -e 'package.json' -e 'resources/skills/release-mapping.json' || true)"
if [[ -n "$committed" ]]; then
echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package.json and the skill release-mapping row may ship in a version commit." >&2
exit 1
fi
git tag -a "v$VERSION" -m "v$VERSION"
echo "tag=v$VERSION" >>"$GITHUB_OUTPUT"
echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
@@ -512,7 +777,7 @@ jobs:
echo "## Release E2E Signal"
echo ""
echo "- Terminal rendering golden is release-blocking."
echo "- Full E2E is diagnostic/non-blocking release evidence."
echo "- Full E2E runs separately after publication and cannot change the release result."
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
echo ""
echo "Publishing behavior is controlled by the existing job dependencies; this summary does not change release gating."
@@ -542,15 +807,6 @@ jobs:
node config/scripts/create-draft-release.mjs "$TAG"
# Why: tag-scoped E2E gives release visibility, but the suite is flaky enough
# that publish-release must not depend on it.
e2e:
needs: cut
if: needs.cut.outputs.should_release == 'true'
uses: ./.github/workflows/e2e.yml
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
terminal-rendering-golden:
needs: cut
if: needs.cut.outputs.should_release == 'true'
@@ -1234,7 +1490,6 @@ jobs:
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
}
# ── End Windows inner-binary signing ───────────────────────────────
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
id: upload-unsigned-windows-installer
@@ -1326,7 +1581,8 @@ jobs:
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { throw "blockmap generation failed with exit code $LASTEXITCODE" }
$installer = Get-Item 'dist/orca-windows-setup.exe'
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
@@ -1378,8 +1634,41 @@ jobs:
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
run: |
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
# Why: a fail-open gate that writes nothing is indistinguishable from a
# gate that passed. Always leave a verdict in the evidence artifact and
# the job summary so a silent degradation is visible (#6487).
# Why best-effort: while warn-only, a disk-full or permission error
# writing the verdict must not become the thing that fails the release.
function Add-GateEvidence([string]$line) {
try {
Add-Content -Path 'inner-signing-evidence.txt' -Value "`n$line" -ErrorAction Stop
} catch {
Write-Host "::warning::Could not append to the inner-signing evidence file: $_"
}
}
function Add-GateSummary([string]$verdict) {
if (-not $env:GITHUB_STEP_SUMMARY) { return }
try {
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "**Windows inner-binary signing:** $verdict" -ErrorAction Stop
} catch {
Write-Host "::warning::Could not write inner-signing verdict to the job summary: $_"
}
}
function Write-GateVerdict([string]$verdict) {
try {
Set-Content -Path 'inner-signing-evidence.txt' -Value $verdict -ErrorAction Stop
} catch {
Write-Host "::warning::Could not persist inner-signing verdict: $_"
}
Add-GateSummary $verdict
}
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
Write-GateVerdict "NOT VERIFIED — $message"
if ($required) { throw $message }
Write-Host "::warning::$message"
exit 0
@@ -1388,13 +1677,28 @@ jobs:
# Why try/catch: while the gate is warn-only, even an unexpected
# script error (extraction hiccup, missing file) must not block
# the release — only the flip to required makes failures fatal.
# Why tracked separately: a required-mode signature failure must not be
# rewritten as ERRORED by the catch below, which would replace the
# per-file report with an exception string and lose the diagnostics.
$policyFailure = $null
try {
$report = New-Object System.Collections.Generic.List[string]
$failures = New-Object System.Collections.Generic.List[string]
# Why: verify the files a user actually gets on disk, not the build
# tree — 7z parses the NSIS exe directly as its embedded payload.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
# Resolve 7za via app-builder-lib; electron-builder 26.9+ dropped the
# bundled 7zip-bin package the old hardcoded path relied on (#6487).
$7zaOutput = node config/scripts/resolve-7za-path.mjs
$7zaExitCode = $LASTEXITCODE
if ($7zaExitCode -ne 0) {
throw "The 7za resolver exited with code $7zaExitCode for the inner-binary evidence gate."
}
$7za = ($7zaOutput | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) {
throw "The 7za resolver returned an invalid path for the inner-binary evidence gate: $7za"
}
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
@@ -1428,16 +1732,32 @@ jobs:
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::warning::$_" }
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
if ($required) { throw $message }
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
# Why assigned before any I/O: a write that throws here would reach
# the catch with $policyFailure still null, so a required-mode
# signature failure would be re-reported as ERRORED and the per-file
# report overwritten — the exact masking the hoist exists to prevent.
if ($required) {
$policyFailure = $message
} else {
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
}
Add-GateEvidence "VERDICT: FAILED — $message"
Add-GateSummary "FAILED — $message"
} else {
Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
$ok = "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
Add-GateEvidence "VERDICT: PASSED — $ok"
Add-GateSummary "PASSED — $ok"
Write-Host $ok
}
} catch {
Write-GateVerdict "ERRORED — $_"
if ($required) { throw }
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
}
# Outside the catch so the FAILED evidence report survives intact.
if ($policyFailure) { throw $policyFailure }
- name: Upload Windows inner signing evidence
if: always() && matrix.platform == 'win'
uses: actions/upload-artifact@v7
@@ -1601,6 +1921,32 @@ jobs:
--prerelease="$prerelease" \
--repo "$GITHUB_REPOSITORY"
post-release-e2e:
needs:
- cut
- publish-release
if: ${{ needs.cut.outputs.tag != '' }}
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Dispatch tag-scoped E2E
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
run: |
for attempt in 1 2 3; do
if gh workflow run e2e.yml \
--repo "$GITHUB_REPOSITORY" \
--ref "$TAG" \
--raw-field "ref=refs/tags/$TAG"; then
echo "Dispatched post-release E2E for $TAG."
exit 0
fi
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
done
echo "::warning::Failed to dispatch post-release E2E for $TAG after 3 attempts."
homebrew-bump-published-rc-draft:
needs:
- cut
@@ -0,0 +1,50 @@
name: Skill update round trip
on:
pull_request:
paths:
- 'skills/**'
- 'resources/skills/**'
- 'config/scripts/verify-skill-update-roundtrip.mjs'
- 'src/main/skills/skill-freshness-eligibility.ts'
- 'src/shared/skill-freshness.ts'
- '.github/workflows/skill-update-roundtrip.yml'
merge_group:
push:
branches:
- main
jobs:
roundtrip:
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
shape: [symlink, copy]
autocrlf: ['false', 'true']
skills-cli: ['1.5.17']
include:
- os: ubuntu-latest
shape: symlink
autocrlf: 'false'
skills-cli: latest
continue-on-error: ${{ matrix.skills-cli == 'latest' }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Verify targeted update convergence and copy behavior
env:
GITHUB_TOKEN: ${{ github.token }}
SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }}
run: >-
node config/scripts/verify-skill-update-roundtrip.mjs
--cli=${{ matrix.skills-cli }}
--autocrlf=${{ matrix.autocrlf }}
--shape=${{ matrix.shape }}
@@ -0,0 +1,199 @@
name: Windows Crash-Survival E2E
# Why: proves the detached daemon-host relocation makes terminal sessions SURVIVE
# a CRASH of Orca's main process (GitHub #7742), the companion guarantee to the
# update-survival harness. Builds an (unsigned) installer FROM THIS BRANCH,
# silent-installs it, opens a terminal, force-kills ONLY the app main (no
# tree-kill), and asserts the daemon + shell stay alive, a relaunch adopts the
# same daemon and shell, and post-crash input causes no pwsh FailFast. Targeted
# pull requests keep it as a durable regression gate. A CI runner is the only
# safe place to install — see the relocation post-mortem.
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- '.github/workflows/win-crash-survival-e2e.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'electron.vite.config.ts'
- 'build-plugins/**'
- 'config/electron-builder.config.cjs'
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'config/scripts/verify-packaged-plugin-resources.cjs'
- 'native/**'
# Byte-hashed at package time, and CRLF-sensitive on Windows.
- '.gitattributes'
- 'resources/plugins/**'
- 'resources/win32/**'
- 'src/main/daemon/**'
- 'src/main/index.ts'
- 'src/main/ipc/pty*.ts'
- 'src/main/persistence.ts'
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/main/startup/first-window-startup-services.ts'
- 'src/main/window/attach-main-window-services.ts'
- 'src/preload/**'
- 'src/renderer/src/App.tsx'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/hooks/useIpcEvents.ts'
- 'src/renderer/src/lib/pane-manager/**'
- 'src/renderer/src/lib/session-write-subscriber.ts'
- 'src/renderer/src/lib/workspace-session-host-persistence.ts'
- 'src/renderer/src/store/slices/terminals.ts'
- 'src/shared/pty-session-id-format.ts'
- 'tools/win-crash-survival-e2e/**'
- 'tools/win-update-e2e/**'
# Why: source tests and benchmarks do not change the packaged artifact;
# their own verify jobs cover them without spending a Windows build slot.
- '!src/**/*.test.*'
- '!src/**/*.bench.*'
workflow_dispatch:
inputs:
expect:
description: Expected outcome profile
required: true
type: choice
default: survival
options:
- survival
- orphaned
permissions:
contents: read
concurrency:
group: win-crash-survival-e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
crash-survival:
name: crash-survival (packaged build)
runs-on: windows-2022
timeout-minutes: 50
env:
EXPECT: ${{ inputs.expect || 'survival' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# This job only builds and runs the harness locally; it never pushes.
persist-credentials: false
# Why: setup-node can restore pnpm's content-addressed store only after
# the pnpm binary exists, avoiding repeat dependency downloads per run.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: cache the built installer by production build inputs, excluding
# tests/reliability metadata so harness-only edits skip electron-builder.
# The daemon relocation lives under src/, so product changes still rebuild.
- name: Cache branch installer
id: cache-installer
uses: actions/cache@v4
with:
path: dist/orca-windows-setup.exe
key: >-
crash-survival-installer-${{ hashFiles(
'src/**',
'!src/**/*.test.*',
'!src/**/*.bench.*',
'config/**',
'!config/**/*.test.*',
'!config/reliability-gates.jsonc',
'!config/max-lines-baseline.txt',
'!config/vitest.config.ts',
'build-plugins/**',
'native/**',
'resources/**',
'electron.vite.config.ts',
'tsconfig.json',
'vite.web.config.ts',
'.npmrc',
'package.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml'
) }}
# Why: production edits miss the installer cache by design, but Electron
# and NSIS downloads are lockfile-owned and need not be fetched again.
- name: Cache electron-builder downloads
if: steps.cache-installer.outputs.cache-hit != 'true'
uses: actions/cache@v5
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: crash-survival-electron-builder-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
crash-survival-electron-builder-
- name: Build Windows installer (unsigned)
if: steps.cache-installer.outputs.cache-hit != 'true'
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=electron
pnpm run build:desktop
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
# Why: silent-install the branch build so the crash harness has a packaged
# Orca.exe to drive. A clean CI runner installs to the default per-user
# location (%LOCALAPPDATA%\Programs\Orca), which the harness auto-locates.
- name: Silent-install branch build
shell: pwsh
run: |
$exe = "dist/orca-windows-setup.exe"
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
Start-Process -FilePath $exe -ArgumentList '/S' -Wait
$installed = Join-Path $env:LOCALAPPDATA 'Programs/Orca/Orca.exe'
if (-not (Test-Path $installed)) {
$installed = Join-Path $env:LOCALAPPDATA 'Programs/orca/Orca.exe'
}
if (-not (Test-Path $installed)) { throw "Installed Orca.exe not found after silent install" }
"ORCA_EXE=$installed" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Why: crash ONLY the app main and assert the relocated daemon + its shell
# survive, then that relaunch adopts them and input causes no pwsh FailFast.
- name: Run crash-survival harness
id: harness
shell: pwsh
env:
ORCA_E2E_DIAG_DIR: artifacts/diag
run: |
New-Item -ItemType Directory -Force artifacts | Out-Null
$log = "artifacts/crash-survival-output.log"
node tools/win-crash-survival-e2e/run.mjs `
--expect "$env:EXPECT" `
--exe-path "$env:ORCA_EXE" `
--soak-seconds 8 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
- name: Upload crash-survival output
if: always()
uses: actions/upload-artifact@v7
with:
name: win-crash-survival-output
path: |
artifacts/crash-survival-output.log
artifacts/diag/**
retention-days: 7
if-no-files-found: warn
+1
View File
@@ -128,6 +128,7 @@ jobs:
node tools/win-update-e2e/run.mjs `
--from "$env:FROM_EXE" `
--to "$env:TO_EXE" `
--require-distinct-artifacts `
--expect "$env:EXPECT" `
--soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
@@ -271,7 +271,7 @@ jobs:
throw 'Signed Windows installer was not returned by SignPath.'
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$installer = Get-Item 'dist/orca-windows-setup.exe'
@@ -325,7 +325,16 @@ jobs:
# Extract the signed installer and verify the files a user actually
# gets on disk — including the exact file from issue #7785.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
# electron-builder 26.9+ removed the bundled 7zip-bin package (#6487).
$7zaOutput = node config/scripts/resolve-7za-path.mjs
$7zaExitCode = $LASTEXITCODE
if ($7zaExitCode -ne 0) {
throw "The 7za resolver exited with code $7zaExitCode for the signing rehearsal."
}
$7za = ($7zaOutput | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) {
throw "The 7za resolver returned an invalid path for the signing rehearsal: $7za"
}
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
@@ -0,0 +1,107 @@
name: Windows terminal restart E2E
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- '.github/workflows/windows-terminal-restart-e2e.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'electron.vite.config.ts'
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'tests/playwright.config.ts'
- 'tests/e2e/global-setup.ts'
- 'tests/e2e/global-teardown.ts'
- 'tests/e2e/helpers/**'
- 'tests/e2e/restart-restore-terminal-input.spec.ts'
- 'tests/e2e/restored-terminal-input-readiness.unit.test.ts'
- 'tests/e2e/terminal-probe-input-sequence.ts'
- 'tests/e2e/terminal-probe-input-sequence.unit.test.ts'
- 'tests/e2e/terminal-restart-persistence.spec.ts'
- 'src/main/daemon/**'
- 'src/main/ipc/pty*.ts'
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/main/pty-descendant-termination.ts'
- 'src/main/windows-process-tree-kill.ts'
- 'src/main/windows-pty-root-identity.ts'
- 'src/preload/**'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/lib/pane-manager/**'
- 'src/shared/process-table-snapshot.ts'
- 'src/shared/pty-session-id-format.ts'
workflow_dispatch:
inputs:
ref:
description: Ref to check out
required: false
type: string
permissions:
contents: read
concurrency:
group: windows-terminal-restart-e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
windows-terminal-restart:
name: Windows terminal restart regressions
runs-on: windows-2022
timeout-minutes: 30
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
# Why: pnpm must exist before setup-node resolves its dependency cache.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Electron app for Windows E2E
run: pnpm exec electron-vite build --mode e2e
- name: Run Windows terminal restart regressions
env:
SKIP_BUILD: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
ORCA_REQUIRE_WINDOWS_TERMINAL_RESTART_E2E: '1'
# Why: pnpm forwards a literal `--` to Playwright, which makes the
# grep and worker flags positional filters instead of CLI options.
run: >-
pnpm run test:e2e
tests/e2e/restart-restore-terminal-input.spec.ts
tests/e2e/terminal-restart-persistence.spec.ts
--grep "clean restart with a live daemon session|cold-restored pane accepts typing|daemon snapshot relaunch preserves"
--workers=1
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: windows-terminal-restart-playwright-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore
+12 -10
View File
@@ -77,20 +77,22 @@ design-docs/
# Machine-local agent hook endpoint files may contain auth tokens.
/agent-hooks/
# Local-only design/planning docs (not checked in).
# Durable docs must live in one of the allow-listed locations below.
# Local-only design/planning docs (not checked in), including most of docs/reference/.
# Durable docs that should be tracked must live in one of the allow-listed
# locations below (assets, readme, STYLEGUIDE, mobile terminal shortcut bar,
# and the tracked reference docs linked from AGENTS.md / README.md).
docs/**
!docs/
!docs/assets/
!docs/assets/**
!docs/readme/
!docs/readme/**
!docs/reference/
!docs/reference/**
# Keep generated React perf scan ledgers local; durable docs still use docs/reference.
docs/reference/react-performance-audit.md
!docs/STYLEGUIDE.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
# Stably CLI (only docs/ are tracked)
.stably/*
@@ -100,9 +102,9 @@ docs/reference/react-performance-audit.md
validation-screenshots/
.stably-browser
# PR verification evidence screenshots are referenced from notes but should not
# be committed.
notes/artifacts/
# Local scratch notes and PR evidence screenshots (not part of the product).
/notes/
/pr-evidence/
# Playwright
test-results/
@@ -123,5 +125,5 @@ src/renderer/src/i18n/locales/.ko-catalog-cache.json
src/renderer/src/i18n/locales/.ja-catalog-cache.json
src/renderer/src/i18n/locales/.es-catalog-cache.json
# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md
# Bench result JSONs are working artifacts
tools/benchmarks/results/terminal-pipeline-*.json
+49
View File
@@ -1,10 +1,33 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "react", "react-hooks", "react-perf", "unicorn"],
"jsPlugins": [
{
"name": "mobile-pairing",
"specifier": "./config/oxlint-plugins/mobile-pairing-qrcode-import.mjs"
},
{
"name": "app-store-performance",
"specifier": "./config/oxlint-plugins/app-store-performance.mjs"
},
{
"name": "quadratic-buffer-concat",
"specifier": "./config/oxlint-plugins/quadratic-buffer-concat.mjs"
},
{
"name": "renderer-scrollbar-style",
"specifier": "./config/oxlint-plugins/renderer-scrollbar-style.mjs"
}
],
"categories": {
"correctness": "error"
},
"rules": {
"app-store-performance/require-selector": "error",
"app-store-performance/no-identity-selector": "error",
"app-store-performance/no-fresh-selector-result": "error",
"no-fallthrough": "error",
"quadratic-buffer-concat/no-loop-carried-concat": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
@@ -49,6 +72,19 @@
],
"curly": "error",
"no-unneeded-ternary": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@linear/sdk",
"allowTypeImports": true,
"message": "Value-importing @linear/sdk hoists its ~2.6MB CJS bundle into the eager top-level require block and defeats the lazy loader. Use `import type` plus loadLinearSdk() from src/main/linear/linear-sdk.ts."
}
]
}
],
"mobile-pairing/no-eager-qrcode-import": "error",
"no-useless-return": "error",
"prefer-template": "error",
"unicorn/consistent-empty-array-spread": "error",
@@ -74,6 +110,19 @@
"unicorn/throw-new-error": "error"
},
"overrides": [
{
"files": ["src/renderer/src/**/*.{ts,tsx}"],
"rules": {
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "error"
}
},
{
"files": ["**/*.test.*", "**/*.spec.*", "**/*-benchmark.*"],
"rules": {
"quadratic-buffer-concat/no-loop-carried-concat": "off",
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "off"
}
},
{
"files": ["**/*.ts"],
"rules": {
+14 -11
View File
@@ -1,23 +1,23 @@
# AGENTS.md
## Design System
# Design System
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
## Code Comments: Document the "Why", Briefly
When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does.
Keep comments short — one or two lines. Capture only the non-obvious reason (safety constraint, compatibility shim, design-doc rule). Don't restate what the code does, narrate the mechanism, cite design-doc sections verbatim, or explain adjacent API choices unless they're the point.
# Style
## Concise/Brief Non-obviosu comments ONLY
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
* DO: BE CONCISE. 1 LINE if possible
## Lint Rules: Do Not Disable Max Lines
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`.
NEVER add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`.
## File and Module Naming
Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuff` for files, folders, or modules. They carry zero info and tend to become dumping grounds. Name files after what they _actually_ contain — prefer the concrete domain concept (e.g. `tab-group-state.ts`, `terminal-orphan-cleanup.ts`) over the generic role (`tabs-helpers.ts`, `terminal-utils.ts`). If you find yourself reaching for `helpers`, the file probably has more than one responsibility and should be split, or there's a better name hiding in the code that describes what the functions operate on.
## Type Declarations: Prefer `.ts` Over `.d.ts`
# Considerations
## Worktree Safety
Always use the primary working directory (the worktree) for all file reads and edits. Never follow absolute paths from subagent results that point to the main repo.
@@ -29,11 +29,16 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
## SSH Use Case
All changes must consider the SSH use case. Don't assume local-only execution.
## Folder Workspace Use Case
All changes must consider folder workspaces as well as git worktrees. Don't assume every workspace is a git worktree.
## Git Binary Compatibility
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
@@ -53,5 +58,3 @@ Source-control and review changes must consider GitLab and other supported git p
## GitHub CLI Usage
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
## Type Declarations: Prefer `.ts` Over `.d.ts`
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -3,16 +3,16 @@
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub stars" /></a>
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&amp;label=%E2%98%85&amp;color=08C" alt="GitHub stars" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="docs/assets/readme-downloads.svg" alt="Total downloads across all releases" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="License" />
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="License: MIT" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Join the Orca Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Follow Orca on X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Supported platforms: macOS, Windows, and Linux" />
</p>
<p align="center">
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
<sub><a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a> · <a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.fr.md">Français</a> · <a href="docs/readme/README.pt.md">Português</a></sub>
</p>
<p align="center">
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [Download APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
---
@@ -238,10 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group.
- **WeChat:** If group 5 is full, you can join group 6.
<img src="docs/assets/wechat-qr.png" alt="WeChat QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr-backup.jpg" alt="Backup WeChat QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr.jpg" alt="WeChat group 5 QR code for the Orca community" width="160" style="margin-right: 16px;" />
<img src="docs/assets/wechat-qr-group6.jpg" alt="WeChat group 6 QR code for the Orca community" width="160" />
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
@@ -261,6 +261,9 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
<img src="docs/assets/star-history.png" alt="GitHub star history chart for stablyai/orca" width="880" />
</p>
## Signed Builds
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
## License
Orca is free and open source under the [MIT License](LICENSE).
+18 -3
View File
@@ -1,6 +1,10 @@
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup'
import type { Plugin, Rollup } from 'vite'
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
type OutputBundle = Rollup.OutputBundle
type OutputChunk = Rollup.OutputChunk
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
// chunk gained `require("electron")` (an import edge added in #7642), and the
@@ -17,8 +21,10 @@ import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'main-thread-hang-watchdog-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls'
'agent-hooks/managed-agent-hook-controls',
'codex/codex-app-server-grant-entry'
] as const
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
@@ -96,6 +102,8 @@ function smokeLoadDaemonEntry(outputDir: string): void {
}
export function createPlainNodeEntryGuardPlugin(): Plugin {
let daemonOutputDir: string | undefined
return {
name: 'orca-plain-node-entry-guard',
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
@@ -123,7 +131,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
}
if (entryByName.has('daemon-entry') && options.dir) {
smokeLoadDaemonEntry(options.dir)
daemonOutputDir = options.dir
}
},
closeBundle() {
if (daemonOutputDir) {
const outputDir = daemonOutputDir
daemonOutputDir = undefined
smokeLoadDaemonEntry(outputDir)
}
}
}
+38
View File
@@ -0,0 +1,38 @@
ARG BASE_IMAGE=ubuntu:24.04
FROM ${BASE_IMAGE}
ARG LIBASOUND_PACKAGE=libasound2t64
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
dbus-x11 \
jq \
libatk-bridge2.0-0 \
libatspi2.0-0 \
"${LIBASOUND_PACKAGE}" \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxss1 \
procps \
util-linux \
xauth \
xvfb \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash orca
COPY config/docker/headless-pairing/run-appimage-case.sh /usr/local/bin/run-appimage-case
ENTRYPOINT ["/usr/local/bin/run-appimage-case"]
@@ -0,0 +1,31 @@
FROM node:24-bookworm
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
git \
libasound2 \
libatk-bridge2.0-0 \
libatspi2.0-0 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
libsecret-1-dev \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxss1 \
pkg-config \
python3 \
rpm \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable \
&& corepack prepare pnpm@10.24.0 --activate
WORKDIR /workspace
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
case_name=${1:?launch case is required}
appimage=${ORCA_TEST_APPIMAGE:-/artifacts/squashfs-root/AppRun}
timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-12}
pairing_address=${ORCA_PAIRING_ADDRESS:-127.0.0.1}
port=${ORCA_SERVE_PORT:-0}
state_dir="/tmp/orca-${case_name}"
if ((EUID == 0)); then
mkdir -p "$state_dir/config" "$state_dir/cache"
chown -R orca:orca "$state_dir"
# Why: packaged serve should exercise the same unprivileged account required by production systemd guidance.
exec runuser --user orca --preserve-environment -- "$0" "$@"
fi
mkdir -p "$state_dir/config" "$state_dir/cache"
if [[ "$appimage" == *.AppImage ]]; then
is_appimage=1
launcher=("$appimage" --appimage-extract-and-run)
else
is_appimage=0
launcher=("$appimage")
fi
app_args=("${launcher[@]}")
if [[ ${ORCA_TEST_NO_SANDBOX:-1} == 1 ]]; then
app_args+=(--no-sandbox)
fi
app_args+=(serve --port "$port" --pairing-address "$pairing_address")
if [[ ${ORCA_READY_JSON:-0} == 1 ]]; then
app_args+=(--json)
fi
if [[ ${ORCA_NO_PAIRING:-0} == 1 ]]; then
app_args+=(--no-pairing)
fi
case "$case_name" in
direct)
command=("${app_args[@]}")
;;
xvfb)
command=(xvfb-run -a "${app_args[@]}")
;;
dbus-xvfb)
command=(dbus-run-session -- xvfb-run -a "${app_args[@]}")
;;
journal)
command=(setsid --wait xvfb-run -a "${app_args[@]}")
;;
*)
echo "Unknown launch case: $case_name" >&2
exit 64
;;
esac
export HOME="$state_dir"
export XDG_CONFIG_HOME="$state_dir/config"
export XDG_CACHE_HOME="$state_dir/cache"
if [[ $is_appimage == 0 ]]; then
export APPDIR=${ORCA_TEST_APPDIR:-"$(dirname "$appimage")"}
fi
if [[ ${ORCA_KEEP_RUNNING:-0} == 1 ]]; then
exec "${command[@]}"
fi
set +e
timeout --signal=TERM --kill-after=2s "${timeout_seconds}s" "${command[@]}"
status=$?
set -e
if ((status == 124)); then
echo "STARTUP_TIMEOUT: no ready contract after ${timeout_seconds}s" >&2
fi
exit "$status"
+80 -6
View File
@@ -11,13 +11,24 @@ const {
prunePackagedRuntimeNodeModules,
verifyPackagedMainRuntimeDeps
} = require('./packaged-runtime-node-modules.cjs')
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const appId = 'com.stablyai.orca'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
to: 'onboarding/feature-wall'
}
// Why: freshness detection needs immutable identity metadata from this exact
// app build, but never needs the skill package bytes or a runtime network read.
const skillFreshnessResources = {
from: 'resources/skills',
to: 'skills'
}
// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged
// apps. Keeping relay assets as extraResources makes them real directories
// instead of paths hidden inside app.asar.
@@ -25,13 +36,17 @@ const relayExtraResource = {
from: 'out/relay',
to: 'relay'
}
// Why: bundled plugins are immutable install inputs and must remain ordinary
// directories so the startup bootstrap can verify and publish exact bytes.
const bundledPluginResources = {
from: 'resources/plugins/launch',
to: 'plugins/launch'
}
// Why: the main bundle, packaged CLI, SSH paths, and speech worker all execute
// from package directories where pnpm's symlink farm is absent. Copy the exact
// runtime dependency closure to Resources/node_modules so bare require() calls
// do not fall through to a developer checkout's node_modules.
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()
const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources]
const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources]
const macSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-darwin-${arch}',
to: 'node_modules/sherpa-onnx-darwin-${arch}'
@@ -47,8 +62,9 @@ const winSpeechNativeResource = {
/** @type {import('electron-builder').Configuration} */
module.exports = {
appId: 'com.stablyai.orca',
appId,
productName: 'Orca',
...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}),
directories: {
buildResources: 'resources/build'
},
@@ -62,17 +78,42 @@ module.exports = {
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
// Why: guide/stub authoring sources are compiled into runtime artifacts; shipping
// either source tree would duplicate content without a runtime consumer.
'!skill-guides{,/**/*}',
'!skill-stubs{,/**/*}',
'!tests{,/**/*}',
// Why: examples/ is plugin authoring documentation with no runtime consumer —
// bundled plugins ship via extraResources from resources/plugins/launch/. It also
// carries hostile-panel, the adversarial fixture the containment tests point at,
// which must never reach a user's install.
'!examples{,/**/*}',
// Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE);
// it is gitignored, but exclude it defensively so a stray local capture at
// package time never bloats app.asar.
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
'!out/**/*.test.js',
// Why: Vite's manifest is only used to project the paired web client.
'!out/renderer/.vite{,/**/*}',
'!electron.vite.config.{js,ts,mjs,cjs}',
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
'!tsconfig.json',
// Why: feature-wall media is copied via extraResources so runtime can read
// it from process.resourcesPath; exclude the source copy from app.asar.
'!resources/onboarding/feature-wall/**'
'!resources/onboarding/feature-wall/**',
'!resources/skills/**',
// Why: bundled plugins ship via extraResources to resources/plugins/launch;
// packing the source tree into app.asar would duplicate those exact bytes.
'!resources/plugins/launch/**',
// Why: the Windows CLI shim ships via extraResources to resources/bin/orca.cmd
// (beside the native resources/bin/orca.exe). Packing the source tree into
// app.asar too lets asarUnpack:['resources/**'] extract a second copy at
// app.asar.unpacked/resources/win32/bin/orca.cmd with no adjacent orca.exe,
// which fails to launch the CLI (#7351).
'!resources/win32{,/**/*}'
],
// Why: the CLI entry-point lives in out/cli/ but imports shared modules
// from out/shared/ and local hook mutators from out/main/. These paths must be
@@ -109,8 +150,10 @@ module.exports = {
'out/main/hermes/**',
'out/main/win32-utils.js',
'out/main/daemon-entry.js',
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/main-thread-hang-watchdog-entry.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
@@ -120,6 +163,12 @@ module.exports = {
'node_modules/sherpa-onnx*/**'
],
afterPack: async (context) => {
// Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node
// requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902).
// Fail packaging if any bundled native binary exceeds the supported floor.
if (context.electronPlatformName === 'linux') {
verifyLinuxGlibcFloor(context.appOutDir)
}
const resourcesDir =
context.electronPlatformName === 'darwin'
? join(
@@ -132,6 +181,25 @@ module.exports = {
if (!existsSync(resourcesDir)) {
return
}
if (context.electronPlatformName === 'darwin') {
const architectureByEnum = { 1: 'x64', 3: 'arm64' }
const architecture = architectureByEnum[context.arch]
if (!architecture) {
throw new Error(`Unsupported local-build compatibility architecture: ${context.arch}`)
}
const version = context.packager.appInfo.version
let commit = process.env.ORCA_BUILD_COMMIT || process.env.GITHUB_SHA || 'unknown'
if (commit === 'unknown') {
try {
commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
} catch {
// Source archives can still produce a signed build with an explicit version.
}
}
writeMacBuildCompatibility(resourcesDir, { version, commit, architecture })
}
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
verifyPackagedMainRuntimeDeps(resourcesDir)
// Why: boot the packaged daemon-entry under plain Node, but only for the
@@ -152,6 +220,9 @@ module.exports = {
`[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})`
)
}
// Why: inspect electron-builder's real output so a broken extraResources
// mapping fails packaging before bundled content reaches users.
verifyPackagedPluginResources(resourcesDir)
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
for (const filename of readdirSync(resourcesDir)) {
@@ -180,6 +251,7 @@ module.exports = {
},
extraResources: [
...commonExtraResources,
...createPackagedRuntimeNodeModuleResources('win32'),
winSpeechNativeResource,
{
from: 'resources/win32/bin/orca.cmd',
@@ -243,6 +315,7 @@ module.exports = {
notarize: isMacRelease,
extraResources: [
...commonExtraResources,
...createPackagedRuntimeNodeModuleResources('darwin'),
macSpeechNativeResource,
{
from: 'resources/darwin/bin/orca',
@@ -306,6 +379,7 @@ module.exports = {
},
extraResources: [
...commonExtraResources,
...createPackagedRuntimeNodeModuleResources('linux'),
linuxSpeechNativeResource,
{
from: 'resources/linux/bin/orca-ide',
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'electron-vite'
import { electronViteConfig } from '../electron.vite.config'
const target = process.env.ORCA_ELECTRON_VITE_TARGET
const configByTarget = {
main: { main: electronViteConfig.main },
preload: { preload: electronViteConfig.preload },
renderer: { renderer: electronViteConfig.renderer }
}
if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) {
throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? '<unset>'}`)
}
export default defineConfig(configByTarget[target as keyof typeof configByTarget])
+72 -1
View File
@@ -1 +1,72 @@
[]
[
{
"filePath": "src/renderer/src/lib/agent-background-session-test-state.ts",
"kind": "object-property:title",
"text": "Terminal 1",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
"kind": "object-property:keywords",
"text": "语言",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
"kind": "object-property:keywords",
"text": "語言",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
"kind": "object-property:keywords",
"text": "언어",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
"kind": "object-property:keywords",
"text": "言語",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
"kind": "object-property:keywords",
"text": "Idioma",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
"kind": "object-property:keywords",
"text": "Ghostty",
"dynamic": false,
"count": 2
},
{
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
"kind": "object-property:keywords",
"text": "ghostty",
"dynamic": false,
"count": 2
},
{
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
"kind": "object-property:keywords",
"text": "Ghostty",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
"kind": "object-property:keywords",
"text": "ghostty",
"dynamic": false,
"count": 1
}
]
-1
View File
@@ -280,7 +280,6 @@ inline src/renderer/src/lib/pane-manager/pane-manager.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
inline src/renderer/src/lib/terminal-links.ts
inline src/renderer/src/lib/worktree-activation.test.ts
inline src/renderer/src/lib/worktree-activation.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
+20
View File
@@ -13,6 +13,26 @@
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!include "getProcessInfo.nsh"
Var pid
!macro customCheckAppRunning
${if} ${isUpdated}
; Why: an A-era packaged CLI may still hold Orca.exe while its exact native launcher waits.
nsExec::Exec `"$PowerShellPath" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { $$ErrorActionPreference='Stop'; $$app=[IO.Path]::GetFullPath($$args[0]); $$launcher=[IO.Path]::GetFullPath($$args[1]); $$comparison=[StringComparison]::OrdinalIgnoreCase; $$children=@(Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$_.ExecutablePath), $$app, $$comparison) }); $$launcherProofs=@(); foreach ($$child in $$children) { $$parent=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $$child.ParentProcessId) -ErrorAction SilentlyContinue; if ($$parent -and $$parent.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$parent.ExecutablePath), $$launcher, $$comparison)) { $$launcherProofs += [pscustomobject]@{ ProcessId=[int]$$parent.ProcessId; CreationDate=[string]$$parent.CreationDate } }; Stop-Process -Id $$child.ProcessId -Force -ErrorAction SilentlyContinue }; $$launcherDeadline=[DateTime]::UtcNow.AddSeconds(30); do { $$remaining=@($$launcherProofs | Where-Object { $$proof=$$_; $$current=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $$proof.ProcessId) -ErrorAction SilentlyContinue; $$current -and $$current.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$current.ExecutablePath), $$launcher, $$comparison) -and [String]::Equals([string]$$current.CreationDate, $$proof.CreationDate, [StringComparison]::Ordinal) }); if ($$remaining.Count -eq 0) { break }; Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $$launcherDeadline); if ($$remaining.Count -ne 0) { exit 20 }; $$lockDeadline=[DateTime]::UtcNow.AddSeconds(30); $$released=$$false; do { try { $$stream=[IO.File]::Open($$app, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None); $$stream.Dispose(); $$released=$$true } catch { Start-Sleep -Milliseconds 100 } } while (-not $$released -and [DateTime]::UtcNow -lt $$lockDeadline); if (-not $$released) { exit 21 } }" "$INSTDIR\Orca.exe" "$INSTDIR\resources\bin\orca.exe"`
Pop $0
${if} $0 != 0
DetailPrint "Orca update release proof failed with status $0."
SetErrorLevel 2
Quit
${endIf}
${else}
!insertmacro IS_POWERSHELL_AVAILABLE
!insertmacro _CHECK_APP_RUNNING
${endIf}
!macroend
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
@@ -0,0 +1,48 @@
{
"$schema": "../node_modules/oxlint/configuration_schema.json",
"plugins": ["import", "jsx-a11y", "react-hooks", "vitest"],
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"rules": {
"import/export": "warn",
"import/named": "warn",
"import/namespace": "warn",
"import/no-cycle": ["warn", { "maxDepth": 3 }],
"import/no-duplicates": "warn",
"import/no-self-import": "warn"
},
"overrides": [
{
"files": ["src/renderer/src/**/*.{ts,tsx}"],
"rules": {
"jsx-a11y/alt-text": "warn",
"jsx-a11y/anchor-has-content": "warn",
"jsx-a11y/aria-props": "warn",
"jsx-a11y/aria-role": "warn",
"jsx-a11y/click-events-have-key-events": "warn"
}
},
{
"files": ["**/*.{test,spec}.{ts,tsx}", "tests/**/*.{ts,tsx}"],
"rules": {
"vitest/no-conditional-tests": "warn",
"vitest/no-focused-tests": "warn",
"vitest/no-identical-title": "warn"
}
},
{
"files": ["mobile/**/*.{ts,tsx}"],
"rules": {
"react-hooks/exhaustive-deps": "warn"
}
}
],
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
}
@@ -11,9 +11,21 @@
"nursery": "off"
},
"rules": {
"typescript/await-thenable": "warn",
"typescript/restrict-plus-operands": "warn",
"typescript/restrict-template-expressions": "warn",
"typescript/switch-exhaustiveness-check": [
"error",
{ "allowDefaultCaseForExhaustiveSwitch": false }
]
}
},
"overrides": [
{
"files": ["**/*.test.*", "**/*.spec.*"],
"rules": {
"typescript/await-thenable": "off"
}
}
],
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
}
@@ -0,0 +1,244 @@
const ALLOCATING_METHODS = new Set([
'filter',
'flat',
'flatMap',
'map',
'toReversed',
'toSorted',
'toSpliced',
'with'
])
function identifierName(node) {
return node?.type === 'Identifier' ? node.name : null
}
function propertyName(node) {
if (node?.type !== 'MemberExpression') {
return null
}
if (!node.computed) {
return identifierName(node.property)
}
return node.property?.type === 'Literal' && typeof node.property.value === 'string'
? node.property.value
: null
}
function returnedExpressions(selector) {
if (selector?.type !== 'ArrowFunctionExpression' && selector?.type !== 'FunctionExpression') {
return []
}
if (selector.body.type !== 'BlockStatement') {
return [selector.body]
}
const expressions = []
const visit = (node) => {
if (!node || typeof node !== 'object') {
return
}
if (
node !== selector.body &&
['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression'].includes(node.type)
) {
return
}
if (node.type === 'ReturnStatement') {
if (node.argument) {
expressions.push(node.argument)
}
return
}
for (const [key, child] of Object.entries(node)) {
if (key === 'parent') {
continue
}
if (Array.isArray(child)) {
child.forEach(visit)
} else {
visit(child)
}
}
}
visit(selector.body)
return expressions
}
function unwrapShallowSelector(selector, shallowHooks) {
if (
selector?.type === 'CallExpression' &&
selector.callee.type === 'Identifier' &&
shallowHooks.has(selector.callee.name)
) {
return { selector: selector.arguments[0], shallow: true }
}
return { selector, shallow: false }
}
function isIdentitySelector(selector) {
if (selector?.type !== 'ArrowFunctionExpression' && selector?.type !== 'FunctionExpression') {
return false
}
const parameter = selector.params[0]
if (parameter?.type !== 'Identifier') {
return false
}
return returnedExpressions(selector).some(
(expression) => expression.type === 'Identifier' && expression.name === parameter.name
)
}
function isAllocatingExpression(expression) {
if (expression?.type === 'ConditionalExpression') {
return (
isAllocatingExpression(expression.consequent) || isAllocatingExpression(expression.alternate)
)
}
if (expression?.type === 'LogicalExpression') {
return isAllocatingExpression(expression.left) || isAllocatingExpression(expression.right)
}
if (
expression?.type === 'ArrayExpression' ||
expression?.type === 'ObjectExpression' ||
expression?.type === 'NewExpression'
) {
return true
}
if (expression?.type !== 'CallExpression') {
return false
}
const method = propertyName(expression.callee)
if (method && ALLOCATING_METHODS.has(method)) {
return true
}
const callee = expression.callee
return (
callee.type === 'MemberExpression' &&
identifierName(callee.object) === 'Object' &&
['assign', 'create', 'entries', 'fromEntries', 'keys', 'values'].includes(propertyName(callee))
)
}
function importedLocalName(specifier, importedName) {
if (specifier.type !== 'ImportSpecifier' || identifierName(specifier.imported) !== importedName) {
return null
}
return identifierName(specifier.local)
}
function createRuleState() {
return {
appStoreHooks: new Set(),
shallowHooks: new Set()
}
}
function recordImports(node, state) {
if (node.source?.value === 'zustand/react/shallow') {
for (const specifier of node.specifiers) {
const localName = importedLocalName(specifier, 'useShallow')
if (localName) {
state.shallowHooks.add(localName)
}
}
}
for (const specifier of node.specifiers) {
const localName = importedLocalName(specifier, 'useAppStore')
if (localName) {
state.appStoreHooks.add(localName)
}
}
}
function isAppStoreCall(node, state) {
return (
node.callee.type === 'Identifier' &&
state.appStoreHooks.has(node.callee.name) &&
node.optional !== true
)
}
function requireSelectorRule() {
const state = createRuleState()
return {
ImportDeclaration(node) {
recordImports(node, state)
},
CallExpression(node) {
if (isAppStoreCall(node, state) && node.arguments.length === 0) {
this.report({
node,
message:
'Pass a selector to useAppStore so the component does not rerender for every store write.'
})
}
}
}
}
function noIdentitySelectorRule() {
const state = createRuleState()
return {
ImportDeclaration(node) {
recordImports(node, state)
},
CallExpression(node) {
if (!isAppStoreCall(node, state)) {
return
}
const { selector } = unwrapShallowSelector(node.arguments[0], state.shallowHooks)
if (isIdentitySelector(selector)) {
this.report({
node: selector,
message:
'Select the smallest required fields instead of subscribing to the entire app store.'
})
}
}
}
}
function noFreshSelectorResultRule() {
const state = createRuleState()
return {
ImportDeclaration(node) {
recordImports(node, state)
},
CallExpression(node) {
if (!isAppStoreCall(node, state)) {
return
}
const { selector, shallow } = unwrapShallowSelector(node.arguments[0], state.shallowHooks)
if (shallow) {
return
}
const freshResult = returnedExpressions(selector).find(isAllocatingExpression)
if (freshResult) {
this.report({
node: freshResult,
message:
'This selector returns a fresh reference on every store write; select a stable field, cache the result, or use useShallow.'
})
}
}
}
}
function bindContext(createVisitors) {
return (context) => {
const visitors = createVisitors()
for (const [nodeType, visit] of Object.entries(visitors)) {
visitors[nodeType] = visit.bind(context)
}
return visitors
}
}
export default {
meta: { name: 'app-store-performance' },
rules: {
'require-selector': { create: bindContext(requireSelectorRule) },
'no-identity-selector': { create: bindContext(noIdentitySelectorRule) },
'no-fresh-selector-result': { create: bindContext(noFreshSelectorResultRule) }
}
}
@@ -0,0 +1,29 @@
const MESSAGE =
"qrcode is only reachable from mobile pairing. Use `import type` plus `await import('qrcode')` so startup does not parse its bundle."
function hasRuntimeImport(node) {
if (node.importKind === 'type') {
return false
}
return (
node.specifiers.length === 0 ||
node.specifiers.some((specifier) => specifier.importKind !== 'type')
)
}
export default {
meta: { name: 'mobile-pairing' },
rules: {
'no-eager-qrcode-import': {
create(context) {
return {
ImportDeclaration(node) {
if (node.source?.value === 'qrcode' && hasRuntimeImport(node)) {
context.report({ node, message: MESSAGE })
}
}
}
}
}
}
}
@@ -0,0 +1,266 @@
const LOOP_TYPES = new Set([
'ForStatement',
'ForInStatement',
'ForOfStatement',
'WhileStatement',
'DoWhileStatement'
])
const ASSIGNMENT_OPERATORS = new Set(['=', '+=', '??=', '||=', '&&='])
const EXPRESSION_WRAPPERS = new Set([
'ChainExpression',
'TSAsExpression',
'TSNonNullExpression',
'TSSatisfiesExpression',
'TypeCastExpression'
])
function normalizeReferenceText(text) {
return text.replaceAll(/\s+/g, '')
}
function sourceText(context, node) {
return context.sourceCode.getText(node)
}
function memberPropertyName(node) {
if (node?.type !== 'MemberExpression') {
return null
}
if (!node.computed && node.property.type === 'Identifier') {
return node.property.name
}
return node.property.type === 'Literal' && typeof node.property.value === 'string'
? node.property.value
: null
}
function rootReferenceText(context, node) {
if (node?.type === 'Identifier') {
return node.name
}
if (node?.type === 'MemberExpression') {
return node.object.type === 'ThisExpression'
? normalizeReferenceText(sourceText(context, node))
: rootReferenceText(context, node.object)
}
if (node?.type === 'CallExpression') {
return rootReferenceText(context, node.callee)
}
if (EXPRESSION_WRAPPERS.has(node?.type)) {
return rootReferenceText(context, node.expression)
}
return null
}
function isBufferConcatCall(node) {
return (
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'Buffer' &&
memberPropertyName(node.callee) === 'concat' &&
node.arguments[0]?.type === 'ArrayExpression'
)
}
function enclosingLoop(node) {
for (let current = node.parent; current; current = current.parent) {
if (LOOP_TYPES.has(current.type)) {
return current
}
}
return null
}
function nodeStart(node) {
return node.start ?? node.range?.[0] ?? 0
}
function nodeEnd(node) {
return node.end ?? node.range?.[1] ?? 0
}
function isDeclaredInsideLoop(declarationStart, loop) {
if (declarationStart < nodeStart(loop) || declarationStart >= nodeEnd(loop)) {
return false
}
if (loop.type !== 'ForStatement' || !loop.init) {
return true
}
return declarationStart < nodeStart(loop.init) || declarationStart >= nodeEnd(loop.init)
}
function collectBindingNames(pattern, names) {
if (!pattern) {
return
}
if (pattern.type === 'Identifier') {
names.push(pattern.name)
} else if (pattern.type === 'RestElement') {
collectBindingNames(pattern.argument, names)
} else if (pattern.type === 'AssignmentPattern') {
collectBindingNames(pattern.left, names)
} else if (pattern.type === 'ObjectPattern') {
for (const property of pattern.properties) {
collectBindingNames(
property.type === 'RestElement' ? property.argument : property.value,
names
)
}
} else if (pattern.type === 'ArrayPattern') {
for (const element of pattern.elements) {
collectBindingNames(element, names)
}
}
}
function visitChildren(node, visit) {
for (const [key, child] of Object.entries(node)) {
if (['parent', 'loc', 'range'].includes(key)) {
continue
}
if (Array.isArray(child)) {
for (const item of child) {
if (item?.type) {
visit(item)
}
}
} else if (child?.type) {
visit(child)
}
}
}
function collectAssignedRoots(context, loop) {
const assigned = new Set()
const visit = (node) => {
if (node.type === 'AssignmentExpression' && ASSIGNMENT_OPERATORS.has(node.operator)) {
const root = rootReferenceText(context, node.left)
if (root) {
assigned.add(root)
}
}
visitChildren(node, visit)
}
visit(loop.body)
return assigned
}
function assignmentTargetOf(context, call) {
let node = call
let parent = node.parent
while (
parent &&
(EXPRESSION_WRAPPERS.has(parent.type) ||
(parent.type === 'ConditionalExpression' && parent.test !== node))
) {
node = parent
parent = parent.parent
}
if (parent?.type !== 'AssignmentExpression' || parent.operator !== '=' || parent.right !== node) {
return null
}
return {
text: normalizeReferenceText(sourceText(context, parent.left)),
root: rootReferenceText(context, parent.left)
}
}
function concatOperands(context, call) {
return call.arguments[0].elements.filter(Boolean).map((element) => {
const spread = element.type === 'SpreadElement'
const expression = spread ? element.argument : element
return {
spread,
text: normalizeReferenceText(sourceText(context, expression)),
root: rootReferenceText(context, expression)
}
})
}
function isLoopCarried(root, loop, declarations) {
const starts = declarations.get(root)
return !starts || !starts.some((start) => isDeclaredInsideLoop(start, loop))
}
function quadraticAccumulator(context, call, loop, declarations, assignedRoots) {
const operands = concatOperands(context, call)
const target = assignmentTargetOf(context, call)
const selfOperand = target
? operands.find((operand) => operand.text === target.text || operand.root === target.root)
: null
if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) {
return target.text
}
for (const operand of operands) {
if (
!operand.spread &&
operand.root &&
assignedRoots.has(operand.root) &&
isLoopCarried(operand.root, loop, declarations)
) {
return operand.root
}
}
return null
}
function createRule(context) {
const declarations = new Map()
const assignedRootsByLoop = new WeakMap()
const recordBindings = (pattern, owner) => {
const names = []
collectBindingNames(pattern, names)
for (const name of names) {
const starts = declarations.get(name) ?? []
starts.push(nodeStart(owner))
declarations.set(name, starts)
}
}
const recordParameters = (node) => {
for (const parameter of node.params) {
recordBindings(parameter, parameter)
}
}
return {
VariableDeclarator(node) {
recordBindings(node.id, node)
},
FunctionDeclaration: recordParameters,
FunctionExpression: recordParameters,
ArrowFunctionExpression: recordParameters,
CatchClause(node) {
recordBindings(node.param, node.param)
},
CallExpression(node) {
if (!isBufferConcatCall(node)) {
return
}
const loop = enclosingLoop(node)
if (!loop) {
return
}
let assignedRoots = assignedRootsByLoop.get(loop)
if (!assignedRoots) {
assignedRoots = collectAssignedRoots(context, loop)
assignedRootsByLoop.set(loop, assignedRoots)
}
const accumulator = quadraticAccumulator(context, node, loop, declarations, assignedRoots)
if (accumulator) {
context.report({
node,
message: `Buffer.concat rebuilds loop-carried ${accumulator}; collect chunks and concatenate once after the loop.`
})
}
}
}
}
export default {
meta: { name: 'quadratic-buffer-concat' },
rules: {
'no-loop-carried-concat': { create: createRule }
}
}
@@ -0,0 +1,292 @@
const STYLED_SCROLLBAR_CLASSES = new Set([
'scrollbar-sleek',
'scrollbar-editor',
'worktree-sidebar-scrollbar'
])
const VERTICAL_SCROLL_CLASSES = new Set([
'overflow-auto',
'overflow-scroll',
'overflow-y-auto',
'overflow-y-scroll'
])
const VERTICAL_SCROLL_STYLE_VALUES = new Set(['auto', 'scroll'])
function withoutImportantModifier(className) {
const withoutPrefix = className.startsWith('!') ? className.slice(1) : className
return withoutPrefix.endsWith('!') ? withoutPrefix.slice(0, -1) : withoutPrefix
}
export function plainClassName(token) {
const normalizedToken = token.startsWith('!') ? token.slice(1) : token
const parts = []
let bracketDepth = 0
let currentPart = ''
for (const char of normalizedToken) {
if (char === '[') {
bracketDepth += 1
} else if (char === ']') {
bracketDepth = Math.max(0, bracketDepth - 1)
}
if (char === ':' && bracketDepth === 0) {
parts.push(currentPart)
currentPart = ''
} else {
currentPart += char
}
}
parts.push(currentPart)
return withoutImportantModifier(parts.at(-1) ?? '')
}
function classTokenParts(token) {
const variants = []
let bracketDepth = 0
let currentPart = ''
for (const char of token.startsWith('!') ? token.slice(1) : token) {
if (char === '[') {
bracketDepth += 1
} else if (char === ']') {
bracketDepth = Math.max(0, bracketDepth - 1)
}
if (char === ':' && bracketDepth === 0) {
variants.push(currentPart)
currentPart = ''
} else {
currentPart += char
}
}
return { className: withoutImportantModifier(currentPart), variants: variants.filter(Boolean) }
}
function classTokens(text) {
return text.split(/\s+/).filter(Boolean).map(classTokenParts)
}
function sameVariants(left, right) {
return left.length === right.length && left.every((variant, index) => variant === right[index])
}
function literalHasScrollbarForVertical(text, verticalToken) {
return classTokens(text).some(
(candidate) =>
STYLED_SCROLLBAR_CLASSES.has(candidate.className) &&
(candidate.variants.length === 0 || sameVariants(candidate.variants, verticalToken.variants))
)
}
function uncoveredVerticalClass(text) {
return classTokens(text).find(
(token) =>
VERTICAL_SCROLL_CLASSES.has(token.className) && !literalHasScrollbarForVertical(text, token)
)
}
function stringLiteralTexts(node) {
if (node?.type === 'Literal' && typeof node.value === 'string') {
return [node.value]
}
if (node?.type !== 'TemplateLiteral') {
return []
}
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw)
}
function visitChildren(node, visit) {
for (const [key, child] of Object.entries(node)) {
if (['parent', 'loc', 'range'].includes(key)) {
continue
}
if (Array.isArray(child)) {
for (const item of child) {
if (item?.type) {
visit(item)
}
}
} else if (child?.type) {
visit(child)
}
}
}
function collectClassLiteralReports(node) {
const reports = []
const visit = (current) => {
for (const text of stringLiteralTexts(current)) {
const uncovered = uncoveredVerticalClass(text)
if (uncovered) {
reports.push({ node: current, detail: uncovered.className })
}
}
visitChildren(current, visit)
}
visit(node)
return reports
}
function expressionHasStyledScrollbarLiteral(node) {
let found = false
const visit = (current) => {
if (found || current.type === 'ConditionalExpression' || current.type === 'LogicalExpression') {
return
}
found = stringLiteralTexts(current).some((text) =>
classTokens(text).some((token) => STYLED_SCROLLBAR_CLASSES.has(token.className))
)
if (!found) {
visitChildren(current, visit)
}
}
visit(node)
return found
}
function propertyName(node) {
if (node?.type !== 'Property') {
return null
}
if (!node.computed && node.key.type === 'Identifier') {
return node.key.name
}
return node.key.type === 'Literal' && typeof node.key.value === 'string' ? node.key.value : null
}
function styleValueIsVerticalScroll(name, value) {
const parts = value.trim().toLowerCase().split(/\s+/).filter(Boolean)
if (parts.length === 0) {
return false
}
if (name === 'overflowY' || name === 'overflow-y') {
return VERTICAL_SCROLL_STYLE_VALUES.has(parts[0])
}
if (name !== 'overflow') {
return false
}
return VERTICAL_SCROLL_STYLE_VALUES.has(parts.length > 1 ? parts[1] : parts[0])
}
function collectStyleReports(node) {
const reports = []
const visit = (current) => {
if (current.type === 'Property') {
const name = propertyName(current)
for (const value of name ? stringLiteralTexts(current.value) : []) {
if (styleValueIsVerticalScroll(name, value)) {
reports.push({ node: current, detail: 'inline vertical scroll' })
}
}
visit(current.value)
return
}
visitChildren(current, visit)
}
visit(node)
return reports
}
function unwrapExpression(node) {
if (
['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ChainExpression'].includes(
node?.type
)
) {
return unwrapExpression(node.expression)
}
return node
}
function spreadPropExpressions(expression, propName) {
const node = unwrapExpression(expression)
if (!node) {
return []
}
if (node.type === 'ConditionalExpression') {
return [
...spreadPropExpressions(node.consequent, propName),
...spreadPropExpressions(node.alternate, propName)
]
}
if (node.type === 'LogicalExpression' || node.type === 'BinaryExpression') {
return [
...spreadPropExpressions(node.left, propName),
...spreadPropExpressions(node.right, propName)
]
}
if (node.type !== 'ObjectExpression') {
return []
}
return node.properties.flatMap((property) => {
if (property.type === 'SpreadElement') {
return spreadPropExpressions(property.argument, propName)
}
return propertyName(property) === propName ? [property.value] : []
})
}
function jsxAttributeExpression(attribute) {
if (attribute.value?.type === 'Literal') {
return attribute.value
}
return attribute.value?.type === 'JSXExpressionContainer' ? attribute.value.expression : null
}
function jsxElementReports(node) {
let classExpression = null
const styleExpressions = []
for (const attribute of node.attributes) {
if (attribute.type === 'JSXSpreadAttribute') {
const spreadClassExpression = spreadPropExpressions(attribute.argument, 'className').at(-1)
if (spreadClassExpression) {
classExpression = spreadClassExpression
}
styleExpressions.push(...spreadPropExpressions(attribute.argument, 'style'))
} else if (attribute.name?.name === 'className') {
classExpression = jsxAttributeExpression(attribute)
} else if (attribute.name?.name === 'style') {
const expression = jsxAttributeExpression(attribute)
if (expression) {
styleExpressions.push(expression)
}
}
}
const reports = classExpression ? collectClassLiteralReports(classExpression) : []
if (!classExpression || !expressionHasStyledScrollbarLiteral(classExpression)) {
for (const expression of styleExpressions) {
reports.push(...collectStyleReports(expression))
}
}
return reports
}
function bindContext(createVisitors) {
return (context) => {
const visitors = createVisitors()
for (const [nodeType, visit] of Object.entries(visitors)) {
visitors[nodeType] = visit.bind(context)
}
return visitors
}
}
export default {
meta: { name: 'renderer-scrollbar-style' },
rules: {
'require-styled-vertical-scrollbar': {
create: bindContext(() => ({
JSXOpeningElement(node) {
for (const report of jsxElementReports(node)) {
this.report({
node: report.node,
message: `Vertical scroll container (${report.detail}) must use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.`
})
}
}
}))
}
}
}
+10 -1
View File
@@ -12,9 +12,18 @@
},
"jsPlugins": [{ "name": "react-doctor", "specifier": "oxlint-plugin-react-doctor" }],
"rules": {
"react-doctor/effect-needs-cleanup": "warn",
"react-doctor/no-array-index-as-key": "warn",
"react-doctor/no-adjust-state-on-prop-change": "warn",
"react-doctor/no-create-store-in-render": "warn",
"react-doctor/no-derived-state-effect": "warn",
"react-doctor/no-initialize-state": "warn"
"react-doctor/no-initialize-state": "warn",
"react-doctor/no-side-effect-in-state-updater-function": "warn",
"react-doctor/no-unstable-nested-components": "warn",
"react-doctor/zustand-no-fresh-selector-result": "warn",
"react-doctor/zustand-no-get-during-initialization": "warn",
"react-doctor/zustand-no-mutating-state": "warn",
"react-doctor/zustand-no-whole-store-destructure": "warn"
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
}
+10 -4
View File
@@ -31,6 +31,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'yaml',
'zod'
]
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['windows-native-registry']
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
darwin: 'darwin-',
@@ -128,7 +129,7 @@ function isKnownOmittedServeSimDependency(packageName, fromDir) {
}
}
function collectPackagedRuntimePackages() {
function collectPackagedRuntimePackages(electronPlatformName = process.platform) {
const packages = new Map()
const visit = (packageName, fromDir = projectDir) => {
if (packageName === 'electron' || packages.has(packageName)) {
@@ -156,7 +157,12 @@ function collectPackagedRuntimePackages() {
}
}
for (const packageName of PACKAGED_RUNTIME_PACKAGE_ROOTS) {
// Why: cross-builds must select native dependencies from the artifact target, not the build host.
const packageRoots = [
...PACKAGED_RUNTIME_PACKAGE_ROOTS,
...(electronPlatformName === 'win32' ? WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS : [])
]
for (const packageName of packageRoots) {
visit(packageName)
}
@@ -183,8 +189,8 @@ function collectPackagedRuntimePackages() {
return [...packages.entries()].sort(([left], [right]) => left.localeCompare(right))
}
function createPackagedRuntimeNodeModuleResources() {
return collectPackagedRuntimePackages().map(([packageName, packageDir]) => ({
function createPackagedRuntimeNodeModuleResources(electronPlatformName = process.platform) {
return collectPackagedRuntimePackages(electronPlatformName).map(([packageName, packageDir]) => ({
from: packageDir,
to: join('node_modules', ...packageName.split('/'))
}))
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+80 -35
View File
@@ -1,5 +1,5 @@
diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc3500acacae 100644
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -5,9 +5,6 @@
@@ -12,6 +12,23 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc35
'msvs_settings': {
'VCCLCompilerTool': {
'AdditionalOptions': [
@@ -88,6 +85,16 @@
'libraries!': [
'-lutil'
]
+ }],
+ # Orca: pair with the .symver pins in pty.cc. Force the real
+ # libutil.so.1/libpthread.so.0 into DT_NEEDED (gcc's default
+ # --as-needed drops them because the pinned symbols resolve from
+ # libc's compat aliases at build time) so openpty/forkpty/
+ # pthread_sigmask still resolve on Ubuntu 20.04 (glibc 2.31).
+ ['OS=="linux"', {
+ 'ldflags': [
+ '-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed'
+ ]
}]
]
}
diff --git a/deps/winpty/src/winpty.gyp b/deps/winpty/src/winpty.gyp
index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8360130ef 100644
--- a/deps/winpty/src/winpty.gyp
@@ -54,6 +71,27 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8
'msvs_settings': {
# Specify this setting here to override a setting from somewhere
# else, such as node's common.gypi.
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index 8c4fca9022a6d6f015bca87f61625cde2278f428..0a01730616488119aa21ef441cf3c441e02a974c 100644
--- a/lib/conpty_console_list_agent.js
+++ b/lib/conpty_console_list_agent.js
@@ -10,7 +10,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
\ No newline at end of file
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
--- a/lib/unixTerminal.js
@@ -73,31 +111,12 @@ 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
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd8eeb4a79 100644
--- a/src/conpty_console_list_agent.ts
+++ b/src/conpty_console_list_agent.ts
@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils';
@@ -10,6 +10,12 @@ import { loadNativeModule } from './utils';
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
const shellPid = parseInt(process.argv[2], 10);
-const consoleProcessList = getConsoleProcessList(shellPid);
@@ -111,7 +130,7 @@ index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc
process.send!({ consoleProcessList });
process.exit(0);
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d15c4dd44 100644
--- a/src/unix/pty.cc
+++ b/src/unix/pty.cc
@@ -23,7 +23,9 @@
@@ -124,7 +143,33 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
#include <thread>
#include <sys/types.h>
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
@@ -47,6 +49,25 @@
#include <termios.h>
#endif
+/* Orca: glibc 2.32-2.34 relocated pthread_sigmask/openpty/forkpty into libc
+ * under new symbol versions, so building on a newer glibc produces references
+ * (GLIBC_2.32/2.34) absent on Ubuntu 20.04 (glibc 2.31) and the app fails to
+ * launch. Pin these to the pre-merge version glibc still ships as a compat
+ * alias; the binding.gyp ldflags force libutil/libpthread into DT_NEEDED so
+ * those aliases are actually loaded on the target. */
+#if defined(__linux__)
+# if defined(__x86_64__)
+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.2.5"
+# elif defined(__aarch64__)
+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.17"
+# endif
+# ifdef ORCA_GLIBC_COMPAT_VERSION
+__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);
+__asm__(".symver forkpty,forkpty@" ORCA_GLIBC_COMPAT_VERSION);
+__asm__(".symver pthread_sigmask,pthread_sigmask@" ORCA_GLIBC_COMPAT_VERSION);
+# endif
+#endif
+
/* Some platforms name VWERASE and VDISCARD differently */
#if !defined(VWERASE) && defined(VWERSE)
#define VWERASE VWERSE
@@ -237,13 +258,23 @@ pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
@@ -149,7 +194,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
#endif
struct DelBuf {
@@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
argv[i + 3] = strdup(arg.c_str());
}
@@ -165,7 +210,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) {
@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
@@ -241,25 +286,25 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
@@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env,
@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env,
POSIX_SPAWN_SETSID;
*master = posix_openpt(O_RDWR);
if (*master == -1) {
- return;
+ pty_set_spawn_error(err, "posix_openpt", errno);
+ goto done;
+ }
+
+ res = grantpt(*master);
+ if (res == -1) {
+ pty_set_spawn_error(err, "grantpt", errno);
+ goto done;
}
- int res = grantpt(*master) || unlockpt(*master);
+ res = unlockpt(*master);
+ res = grantpt(*master);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "grantpt", errno);
+ goto done;
+ }
+
+ res = unlockpt(*master);
+ if (res == -1) {
+ pty_set_spawn_error(err, "unlockpt", errno);
+ goto done;
}
@@ -0,0 +1,71 @@
const { createHash } = require('node:crypto')
const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs')
const { join, resolve } = require('node:path')
const EXPECTED_NODE_PTY_VERSION = '1.1.0'
const ORIGINAL_SOURCE_SHA256 = '0d010879bb6680a0253d44363183d53e631f42972594eb6dcb1fb842c8c85e52'
const PATCHED_SOURCE_SHA256 = '84df20cfe711a88d2bef35078615c58a6ce14f39348a4aef40e852b854dcd857'
const ORIGINAL_BODY = 'var consoleProcessList = getConsoleProcessList(shellPid);'
const PATCHED_BODY = `var consoleProcessList;
try {
consoleProcessList = getConsoleProcessList(shellPid);
}
catch (_a) {
// Why: AttachConsole can fail without a Win32 console; use node-pty's timeout fallback immediately.
consoleProcessList = [shellPid];
}`
function inspectNodePtyConsoleListAgent(relayDir = process.cwd()) {
const nodePtyDir = resolve(relayDir, 'node_modules', 'node-pty')
const packageJsonPath = join(nodePtyDir, 'package.json')
const agentPath = join(nodePtyDir, 'lib', 'conpty_console_list_agent.js')
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
if (packageJson.version !== EXPECTED_NODE_PTY_VERSION) {
throw new Error(
`Refusing to patch node-pty ${packageJson.version}; expected ${EXPECTED_NODE_PTY_VERSION}`
)
}
const source = readFileSync(agentPath, 'utf8')
return { agentPath, source }
}
function assertPatchedNodePtyConsoleListAgent(relayDir = process.cwd()) {
const inspected = inspectNodePtyConsoleListAgent(relayDir)
if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) {
throw new Error('node-pty ConPTY console-list fallback is not installed')
}
}
function patchNodePtyConsoleListAgent(relayDir = process.cwd()) {
const inspected = inspectNodePtyConsoleListAgent(relayDir)
const sourceHash = sourceSha256(inspected.source)
if (sourceHash === PATCHED_SOURCE_SHA256) {
return
}
if (sourceHash !== ORIGINAL_SOURCE_SHA256) {
throw new Error('Refusing to patch unexpected node-pty console-list agent source')
}
const patchedSource = inspected.source.replace(ORIGINAL_BODY, PATCHED_BODY)
const temporaryPath = `${inspected.agentPath}.orca-patch-${process.pid}`
// Why: a terminated remote install must leave either known source version recoverable on reconnect.
try {
writeFileSync(temporaryPath, patchedSource)
renameSync(temporaryPath, inspected.agentPath)
} finally {
rmSync(temporaryPath, { force: true })
}
assertPatchedNodePtyConsoleListAgent(relayDir)
}
function sourceSha256(source) {
return createHash('sha256').update(source).digest('hex')
}
if (require.main === module) {
patchNodePtyConsoleListAgent()
}
module.exports = {
assertPatchedNodePtyConsoleListAgent,
patchNodePtyConsoleListAgent
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,220 @@
#!/usr/bin/env node
// Benchmark: cost of validating an agent-status payload on every hook event.
//
// 13 of the 15 per-source normalizers in agent-hook-listener.ts built a plain
// object, JSON.stringify'd it, and handed the string to parseAgentStatusPayload,
// which runs assertJsonTextStructureWithinLimits (a per-character scan of the
// WHOLE serialized string) plus JSON.parse — only to reach the same
// normalizeAgentStatusObject the object path calls directly. Claude and Codex
// were already converted, with a comment calling the round trip "pure overhead
// on this hot per-hook path"; the other 13 were not.
//
// Why the gap widens with payload size: the direct path's field normalizer stops
// at the field cap (`normalized.length < maxLength`), so it is O(cap). The round
// trip is O(input) — and the input is bounded only by the 1 MB hook request
// limit, since tool_response text is passed through uncapped for most sources.
//
// Amplifier this models in the second table: resolveToolState stores the raw
// value in lastToolByPaneKey and inherits it until a turn reset, so one large
// tool result is re-serialized and re-scanned on every later event of the turn.
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const TYPES_SOURCE = readFileSync(
fileURLToPath(new URL('../../src/shared/agent-status-types.ts', import.meta.url)),
'utf8'
)
function readMirroredConstant(name) {
const match = TYPES_SOURCE.match(new RegExp(`${name}\\s*=\\s*([0-9_]+)`))
if (!match) {
throw new Error(`agent-status-types.ts no longer defines ${name}; re-sync this benchmark.`)
}
return Number(match[1].replaceAll('_', ''))
}
// Read the cap the direct path clamps at, so a drifted value fails loudly here
// instead of quietly changing what this benchmark claims.
const ASSISTANT_MESSAGE_CAP = readMirroredConstant('AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH')
const ITERATIONS = Number.parseInt(process.env.ORCA_HOOK_NORM_BENCH_ITERATIONS ?? '400', 10)
const WARMUP = Number.parseInt(process.env.ORCA_HOOK_NORM_BENCH_WARMUP ?? '200', 10)
for (const [name, value] of [
['ORCA_HOOK_NORM_BENCH_ITERATIONS', ITERATIONS],
['ORCA_HOOK_NORM_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
const STRUCTURAL_TOKENS = 4096
const NESTING_DEPTH = 16
// Mirror of assertJsonTextStructureWithinLimits — the per-character scan the
// round trip pays before JSON.parse even starts.
function scanJsonStructure(content) {
let structuralTokens = 0
let depth = 0
let inString = false
let escaped = false
for (let index = 0; index < content.length; index += 1) {
const character = content[index]
if (inString) {
if (escaped) {
escaped = false
} else if (character === '\\') {
escaped = true
} else if (character === '"') {
inString = false
}
continue
}
if (character === '"') {
inString = true
continue
}
if (
character !== '{' &&
character !== '}' &&
character !== '[' &&
character !== ']' &&
character !== ',' &&
character !== ':'
) {
continue
}
structuralTokens += 1
if (structuralTokens > STRUCTURAL_TOKENS) {
throw new Error('structuralTokens')
}
if (character === '{' || character === '[') {
depth += 1
if (depth > NESTING_DEPTH) {
throw new Error('nestingDepth')
}
} else if (character === '}' || character === ']') {
depth = Math.max(0, depth - 1)
}
}
}
// Mirror of the field normalizer's bounded walk: it stops consuming at the cap.
function normalizeField(value, maxLength) {
if (typeof value !== 'string') {
return undefined
}
let normalized = ''
let newlineRun = 0
for (let index = 0; index < value.length && normalized.length < maxLength; index += 1) {
const code = value.charCodeAt(index)
if (code === 13 || code === 10 || code === 0x2028 || code === 0x2029) {
if (code === 13 && value.charCodeAt(index + 1) === 10) {
index += 1
}
if (newlineRun < 2) {
normalized += '\n'
}
newlineRun += 1
continue
}
newlineRun = 0
normalized += value[index]
}
return normalized
}
function normalizeObject(payload) {
return {
state: payload.state,
prompt: normalizeField(payload.prompt, ASSISTANT_MESSAGE_CAP),
agentType: payload.agentType,
toolName: normalizeField(payload.toolName, ASSISTANT_MESSAGE_CAP),
toolInput: normalizeField(payload.toolInput, ASSISTANT_MESSAGE_CAP),
lastAssistantMessage: normalizeField(payload.lastAssistantMessage, ASSISTANT_MESSAGE_CAP)
}
}
// Pre-fix: serialize, scan every character, parse, then normalize.
function validateViaRoundTrip(payload) {
const json = JSON.stringify(payload)
scanJsonStructure(json)
return normalizeObject(JSON.parse(json))
}
// Post-fix: normalize the object that is already in hand.
function validateDirect(payload) {
return normalizeObject(payload)
}
function makePayload(messageBytes) {
return {
state: 'working',
prompt: 'do the thing',
agentType: 'grok',
toolName: 'shell_command',
toolInput: 'ls -la',
lastAssistantMessage: 'x'.repeat(messageBytes)
}
}
function measure(fn, payload) {
for (let index = 0; index < WARMUP; index += 1) {
fn(payload)
}
const samples = []
for (let round = 0; round < 5; round += 1) {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
fn(payload)
}
samples.push((performance.now() - start) / ITERATIONS)
}
samples.sort((a, b) => a - b)
return samples[2]
}
const rows = []
for (const kb of [4, 16, 64, 256]) {
const payload = makePayload(kb * 1024)
const before = validateViaRoundTrip(payload)
const after = validateDirect(payload)
if (JSON.stringify(before) !== JSON.stringify(after)) {
throw new Error(`normalizer mismatch at ${kb} KB`)
}
rows.push({
label: `${kb} KB`,
beforeUs: measure(validateViaRoundTrip, payload) * 1000,
afterUs: measure(validateDirect, payload) * 1000
})
}
const pad = (value, width) => String(value).padStart(width)
console.log('Agent-status payload validation, per hook event')
console.log(
`field cap=${ASSISTANT_MESSAGE_CAP} iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`
)
console.log(
`${pad('payload', 9)} ${pad('round trip', 12)} ${pad('direct', 10)} ${pad('speedup', 9)}`
)
for (const row of rows) {
console.log(
`${pad(row.label, 9)} ${pad(`${row.beforeUs.toFixed(1)} us`, 12)} ${pad(`${row.afterUs.toFixed(1)} us`, 10)} ${pad(`${(row.beforeUs / row.afterUs).toFixed(1)}x`, 9)}`
)
}
// A single large tool result is inherited across the turn, so every later event
// re-pays the round trip on bytes that were already validated once.
const TURN_EVENTS = 20
const inherited = makePayload(200 * 1024)
const beforeTurnMs = (measure(validateViaRoundTrip, inherited) * TURN_EVENTS).toFixed(2)
const afterTurnMs = (measure(validateDirect, inherited) * TURN_EVENTS).toFixed(2)
console.log(
`\nOne 200 KB tool result, inherited across ${TURN_EVENTS} later events in the same turn:` +
`\n round trip ${beforeTurnMs} ms total direct ${afterTurnMs} ms total`
)
console.log(
'\nThe direct path is flat because the field normalizer stops at the cap; the\nround trip is linear in the raw payload, which is bounded only by the 1 MB\nhook request limit.'
)
@@ -0,0 +1,55 @@
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs'
const pluginPath = path.resolve('config/oxlint-plugins/app-store-performance.mjs')
function lintSource(source) {
return runOxlintPluginOnSource({
pluginName: 'app-store-performance',
pluginPath,
source,
rules: {
'app-store-performance/require-selector': 'warn',
'app-store-performance/no-identity-selector': 'warn',
'app-store-performance/no-fresh-selector-result': 'warn'
}
})
}
describe('app store performance Oxlint plugin', () => {
it('reports whole-store and fresh-reference subscriptions', () => {
const diagnostics = lintSource(`
import { useAppStore as useStore } from '@/store'
const WholeStore = () => useStore()
const Identity = () => useStore((state) => state)
const Fresh = () => useStore((state) => ({ active: state.active }))
const Conditional = () => useStore((state) => state.active ? state.items : [])
const Nested = () => useStore((state) => {
if (state.active) return state.items.filter(Boolean)
return state.items
})
`)
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
'app-store-performance(require-selector)',
'app-store-performance(no-identity-selector)',
'app-store-performance(no-fresh-selector-result)',
'app-store-performance(no-fresh-selector-result)',
'app-store-performance(no-fresh-selector-result)'
])
})
it('allows focused, cached, and useShallow selectors', () => {
const diagnostics = lintSource(`
import { useAppStore } from '@/store'
import { useShallow as shallow } from 'zustand/react/shallow'
const selectActive = (state) => state.active
const Focused = () => useAppStore(selectActive)
const Cached = () => useAppStore((state) => state.cachedProjection)
const Shallow = () => useAppStore(shallow((state) => ({ active: state.active })))
`)
expect(diagnostics).toEqual([])
})
})
@@ -0,0 +1,190 @@
#!/usr/bin/env node
// Benchmark: the head-of-chain reads in getBranchCompare (src/main/git/status.ts).
//
// Four spawns ran strictly in series before any compare work started: branch
// --show-current, the base-ref probe, rev-parse HEAD, and rev-parse <base>. compareRef is
// display-only metadata and HEAD's oid does not depend on the base ref, so the first three
// can overlap. The probe oid also replaces the fourth spawn when it proves refs/heads/*;
// remote-tracking refs require a raw rev-parse because they may store annotated tags.
//
// This spawns the real git binary against this repo, so it measures actual process-launch
// cost rather than a model of it. Over SSH these are host-local spawns inside the relay,
// so the saving applies to remote spawn time, not to network round trips.
//
// Both arms are compared for identical resolved values before timing.
//
// Run with: node config/scripts/branch-compare-head-benchmark.mjs
import { execFile } from 'node:child_process'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
import { readBranchCompareHead } from '../../src/shared/git-branch-compare-head.ts'
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
const ITERATIONS = Number(process.env.ORCA_BRANCH_COMPARE_BENCH_ITERATIONS ?? '8')
const WARMUP = Number(process.env.ORCA_BRANCH_COMPARE_BENCH_WARMUP ?? '2')
const ROUNDS = 6
for (const [name, value] of [
['ORCA_BRANCH_COMPARE_BENCH_ITERATIONS', ITERATIONS],
['ORCA_BRANCH_COMPARE_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function git(args) {
return new Promise((resolve, reject) => {
execFile('git', args, { cwd: REPO_ROOT, maxBuffer: 64 * 1024 * 1024 }, (error, stdout) =>
error ? reject(error) : resolve(stdout.trim())
)
})
}
async function probeOid(qualifiedRef) {
try {
const out = await git(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`])
return out.length > 0 ? out : null
} catch {
return null
}
}
// Pre-fix: serial chain, and the probe's oid discarded then re-resolved.
async function readSerial(baseRef) {
const compareRef = (await git(['branch', '--show-current']).catch(() => '')) || 'HEAD'
let resolvedBaseRef = baseRef
if (!baseRef.startsWith('refs/')) {
const candidates = baseRef.includes('/')
? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`]
: [`refs/heads/${baseRef}`]
for (const candidate of candidates) {
if ((await probeOid(candidate)) !== null) {
resolvedBaseRef = candidate
break
}
}
}
const headOid = await git(['rev-parse', '--verify', '--end-of-options', 'HEAD'])
const baseOid = await git(['rev-parse', '--verify', '--end-of-options', resolvedBaseRef])
return { compareRef, resolvedBaseRef, headOid, baseOid }
}
// Production head reader: overlaps independent reads and reuses only safe probe oids.
async function readConcurrent(baseRef) {
const reusableProbedOidByRef = new Map()
const resolveBaseRef = async () => {
if (baseRef.startsWith('refs/')) {
return baseRef
}
const candidates = baseRef.includes('/')
? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`]
: [`refs/heads/${baseRef}`]
for (const candidate of candidates) {
const oid = await probeOid(candidate)
if (oid !== null) {
if (candidate.startsWith('refs/heads/')) {
reusableProbedOidByRef.set(candidate, oid)
}
return candidate
}
}
return baseRef
}
const result = await readBranchCompareHead({
readCompareRef: () =>
git(['branch', '--show-current'])
.then((out) => out || 'HEAD')
.catch(() => 'HEAD'),
resolveBaseRef,
readHeadOid: () => git(['rev-parse', '--verify', '--end-of-options', 'HEAD']),
readBaseOid: (resolvedBaseRef) => {
const reusableOid = reusableProbedOidByRef.get(resolvedBaseRef)
return reusableOid === undefined
? git(['rev-parse', '--verify', '--end-of-options', resolvedBaseRef])
: Promise.resolve(reusableOid)
}
})
if (!result.headOidResult.ok) {
throw result.headOidResult.error
}
if (!result.baseOidResult.ok) {
throw result.baseOidResult.error
}
return {
compareRef: result.compareRef,
resolvedBaseRef: result.resolvedBaseRef,
headOid: result.headOidResult.oid,
baseOid: result.baseOidResult.oid
}
}
function median(samples) {
const sorted = [...samples].sort((a, b) => a - b)
const mid = sorted.length / 2
return (sorted[mid - 1] + sorted[mid]) / 2
}
async function timeArm(read, baseRef) {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
await read(baseRef)
}
return (performance.now() - start) / ITERATIONS
}
// Arms alternate which one leads so within-round drift cannot favour either.
async function measure(baseRef) {
for (let index = 0; index < WARMUP; index += 1) {
await readSerial(baseRef)
await readConcurrent(baseRef)
}
const serialSamples = []
const concurrentSamples = []
for (let round = 0; round < ROUNDS; round += 1) {
if (round % 2 === 0) {
serialSamples.push(await timeArm(readSerial, baseRef))
concurrentSamples.push(await timeArm(readConcurrent, baseRef))
} else {
concurrentSamples.push(await timeArm(readConcurrent, baseRef))
serialSamples.push(await timeArm(readSerial, baseRef))
}
}
return { serialMs: median(serialSamples), concurrentMs: median(concurrentSamples) }
}
const pad = (value, width) => String(value).padStart(width)
console.log('getBranchCompare head-of-chain reads, per call. Lower is better.')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`)
console.log(
`${pad('base ref', 30)} ${pad('serial', 11)} ${pad('concurrent', 11)} ${pad('speedup', 9)}`
)
// A short remote label is the common case (Orca's base picker emits `origin/main`); the
// already-qualified ref skips the probe entirely, so only the concurrency half applies.
const upstream = await git(['rev-parse', '--abbrev-ref', 'HEAD@{upstream}']).catch(() => null)
const baseRefs = ['origin/main', 'refs/remotes/origin/main', 'main']
if (upstream && !baseRefs.includes(upstream)) {
baseRefs.push(upstream)
}
for (const baseRef of baseRefs) {
const serial = await readSerial(baseRef)
const concurrent = await readConcurrent(baseRef)
if (JSON.stringify(serial) !== JSON.stringify(concurrent)) {
throw new Error(
`resolved values differ for ${baseRef}:\n serial ${JSON.stringify(serial)}\n concurrent ${JSON.stringify(concurrent)}`
)
}
if (!serial.headOid) {
throw new Error(`fixture resolved no HEAD oid for ${baseRef}`)
}
const { serialMs, concurrentMs } = await measure(baseRef)
console.log(
`${pad(baseRef, 30)} ${pad(`${serialMs.toFixed(1)} ms`, 11)} ${pad(`${concurrentMs.toFixed(1)} ms`, 11)} ${pad(`${(serialMs / concurrentMs).toFixed(2)}x`, 9)}`
)
}
console.log(
'\nThe already-qualified refs/... row skips the probe by design, so it only shows the\nconcurrency half. This times the native/WSL head-of-chain reads, not the whole compare;\nthe relay path has separate production-concurrency coverage.'
)
+46
View File
@@ -0,0 +1,46 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
export function createLocalBuildVersion(baseVersion, timestamp, commit) {
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(baseVersion)) {
throw new Error(`Package version is not valid semver: ${baseVersion}`)
}
if (!Number.isSafeInteger(timestamp) || timestamp <= 0) {
throw new Error('Local build timestamp is invalid.')
}
const sanitizedCommit = commit.replace(/[^0-9A-Za-z-]/g, '').slice(0, 12)
if (!sanitizedCommit) {
throw new Error('Git commit identity is empty.')
}
const suffix = `local.${timestamp}.${sanitizedCommit}`
return baseVersion.includes('-') ? `${baseVersion}.${suffix}` : `${baseVersion}-${suffix}`
}
export function getLocalBuildIdentity() {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
return {
commit,
version: createLocalBuildVersion(packageJson.version, Date.now(), commit)
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getLocalBuildIdentity()
console.log(`[build:mac] local update version ${identity.version}`)
execFileSync(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
['exec', 'electron-builder', '--config', 'config/electron-builder.config.cjs', '--mac'],
{
env: {
...process.env,
ORCA_BUILD_COMMIT: identity.commit,
ORCA_LOCAL_BUILD_VERSION: identity.version
},
stdio: 'inherit'
}
)
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { createLocalBuildVersion } from './build-mac-local.mjs'
describe('createLocalBuildVersion', () => {
it('creates unique valid prerelease versions without changing the release base', () => {
expect(createLocalBuildVersion('1.4.159-rc.0', 123456, 'abc123')).toBe(
'1.4.159-rc.0.local.123456.abc123'
)
expect(createLocalBuildVersion('1.4.159', 123456, 'abc123')).toBe('1.4.159-local.123456.abc123')
})
it('sanitizes commit identifiers', () => {
expect(createLocalBuildVersion('1.0.0', 1, 'abc/def')).toBe('1.0.0-local.1.abcdef')
})
})
+50 -6
View File
@@ -10,7 +10,7 @@
*/
import { build } from 'esbuild'
import { createHash } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const __dirname = import.meta.dirname
@@ -18,6 +18,21 @@ const __dirname = import.meta.dirname
const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const MANAGED_HOOK_RUNTIME_ENTRY = join(
ROOT,
'src',
'main',
'agent-hooks',
'managed-hook-runtime.ts'
)
const JSONC_PARSER_ESM_ENTRY = join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs'
const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
ROOT,
'config',
'relay-assets',
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
)
const PLATFORMS = [
'linux-x64',
@@ -51,6 +66,13 @@ for (const platform of PLATFORMS) {
}
})
if (platform.startsWith('win32-')) {
copyFileSync(
NODE_PTY_CONSOLE_LIST_PATCH_SOURCE,
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
)
}
await build({
entryPoints: [WATCHER_ENTRY],
bundle: true,
@@ -66,17 +88,39 @@ for (const platform of PLATFORMS) {
}
})
await build({
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'managed-hook-runtime.js'),
// Why: jsonc-parser's default UMD build keeps relative dynamic requires
// that break after bundling; its ESM entry is equivalent and self-contained.
alias: { 'jsonc-parser': JSONC_PARSER_ESM_ENTRY },
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
// Why: include a content hash so the deploy check detects code changes
// even when RELAY_VERSION hasn't been bumped. Hash both process artifacts
// so a watcher-only change always deploys beside the matching relay host.
// even when RELAY_VERSION hasn't been bumped. Hash every executable module
// so a companion-only change always deploys beside the matching relay host.
const relayContent = readFileSync(join(outDir, 'relay.js'))
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
const hash = createHash('sha256')
.update(relayContent)
.update(watcherContent)
.digest('hex')
.slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
.update(managedHookRuntimeContent)
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
if (platform.startsWith('win32-')) {
hash.update(readFileSync(join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)))
}
const contentHash = hash.digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${contentHash}`)
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
}
@@ -5,7 +5,11 @@ import { existsSync, mkdirSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
if (process.platform !== 'win32') {
process.exit(0)
// Why: electron-builder treats a skipped native build like success and can
// continue toward a Windows package whose declared orca.exe does not exist.
throw new Error(
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
)
}
const repoRoot = resolve(import.meta.dirname, '../..')
@@ -4,10 +4,33 @@ import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
const itWindows = process.platform === 'win32' ? it : it.skip
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
// keep the larger allowance scoped to the real compiler integration test.
function itWindows(name, test) {
const runner = process.platform === 'win32' ? it : it.skip
runner(name, { timeout: 15_000 }, test)
}
describe('Windows CLI launcher', () => {
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
try {
const result = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', join(outputRoot, 'orca.exe')],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('Windows CLI launcher')
expect(result.stderr).toContain('Windows host')
} finally {
rmSync(outputRoot, { recursive: true, force: true })
}
})
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
try {
@@ -0,0 +1,222 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/
export const OXLINT_SCANS = [
{
// Why: no --config, so Oxlint keeps discovering nested configs. Pinning the root
// config would apply root rules to mobile/, whose .oxlintrc.json turns them off.
label: 'code quality',
args: ['--report-unused-disable-directives-severity', 'warn']
},
{
label: 'type-aware code quality',
args: ['--type-aware', '--config', 'config/oxlint-code-quality-type-aware.json']
},
{
label: 'React Doctor',
args: ['--config', 'config/oxlint-react-doctor.json']
}
]
export function parseAddedLineRanges(diff) {
const ranges = []
const hunkPattern = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
for (const line of diff.split(/\r?\n/)) {
const match = hunkPattern.exec(line)
if (!match) {
continue
}
const start = Number.parseInt(match[1], 10)
const count = match[2] === undefined ? 1 : Number.parseInt(match[2], 10)
if (count > 0) {
ranges.push({ start, end: start + count - 1 })
}
}
return ranges
}
export function overlapsAddedLines(startLine, endLine, ranges) {
return ranges.some((range) => startLine <= range.end && endLine >= range.start)
}
function runGit(root, args, options = {}) {
return execFileSync('git', args, {
cwd: root,
encoding: options.encoding ?? 'utf8',
maxBuffer: 64 * 1024 * 1024
})
}
function splitNullDelimited(output) {
return output.split('\0').filter(Boolean)
}
function resolveBase(root, requestedBase) {
for (const candidate of [
requestedBase,
process.env.ORCA_CODE_QUALITY_BASE,
'origin/main',
'main'
]) {
if (!candidate) {
continue
}
const result = spawnSync('git', ['rev-parse', '--verify', `${candidate}^{commit}`], {
cwd: root,
stdio: 'ignore'
})
if (result.status === 0) {
return candidate
}
}
throw new Error('Pass the pull request base SHA or make origin/main available locally.')
}
export function collectAddedLineRanges(root, requestedBase) {
const base = resolveBase(root, requestedBase)
const mergeBase = runGit(root, ['merge-base', base, 'HEAD']).trim()
const comparisonBase = resolvePullRequestDiffBase(root, mergeBase)
const changedFiles = splitNullDelimited(
runGit(root, ['diff', '--name-only', '-z', '--diff-filter=ACMRTUB', comparisonBase, '--'])
)
const untrackedFiles = splitNullDelimited(
runGit(root, ['ls-files', '--others', '--exclude-standard', '-z'])
)
const rangesByFile = new Map()
for (const file of changedFiles) {
if (!SOURCE_FILE_PATTERN.test(file) || !existsSync(path.join(root, file))) {
continue
}
const diff = runGit(root, ['diff', '--unified=0', '--no-color', comparisonBase, '--', file])
const ranges = parseAddedLineRanges(diff)
if (ranges.length > 0) {
rangesByFile.set(file, ranges)
}
}
for (const file of untrackedFiles) {
const absolutePath = path.join(root, file)
if (!SOURCE_FILE_PATTERN.test(file) || !existsSync(absolutePath)) {
continue
}
const lineCount = readFileSync(absolutePath, 'utf8').split(/\r?\n/).length
rangesByFile.set(file, [{ start: 1, end: lineCount }])
}
return { base, comparisonBase, rangesByFile }
}
function parseOxlintOutput(stdout, label) {
const start = stdout.indexOf('{')
const end = stdout.lastIndexOf('}')
if (start === -1 || end === -1) {
throw new Error(`${label} did not return Oxlint JSON output.`)
}
return JSON.parse(stdout.slice(start, end + 1))
}
function normalizedDiagnosticPath(root, filename) {
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
return path.relative(root, absolutePath).split(path.sep).join('/')
}
function diagnosticLineRange(root, filename, span) {
const startLine = span.line
if (!Number.isInteger(startLine)) {
return null
}
if (!Number.isInteger(span.offset) || !Number.isInteger(span.length) || span.length === 0) {
return { start: startLine, end: startLine }
}
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
const source = readFileSync(absolutePath)
const highlighted = source.subarray(span.offset, span.offset + span.length).toString('utf8')
return { start: startLine, end: startLine + (highlighted.match(/\n/g)?.length ?? 0) }
}
export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = process.cwd()) {
const file = normalizedDiagnosticPath(root, diagnostic.filename)
const ranges = rangesByFile.get(file)
if (!ranges) {
return false
}
return (diagnostic.labels ?? []).some((label) => {
const lineRange = diagnosticLineRange(root, diagnostic.filename, label.span)
return lineRange !== null && overlapsAddedLines(lineRange.start, lineRange.end, ranges)
})
}
function annotationValue(value) {
return String(value).replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')
}
function printDiagnostic(diagnostic, root) {
const file = normalizedDiagnosticPath(root, diagnostic.filename)
const line = diagnostic.labels?.[0]?.span?.line ?? 1
const code = diagnostic.code ?? 'oxlint'
console.error(
`::error file=${annotationValue(file)},line=${line},title=${annotationValue(code)}::${annotationValue(diagnostic.message)}`
)
console.error(`${file}:${line} ${code}: ${diagnostic.message}`)
}
function runOxlintScan(root, scan, files) {
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(pnpm, ['exec', 'oxlint', ...scan.args, '--format', 'json', ...files], {
cwd: root,
encoding: 'utf8',
maxBuffer: 128 * 1024 * 1024
})
if (result.error) {
throw result.error
}
if (!result.stdout.trim()) {
process.stderr.write(result.stderr)
throw new Error(`${scan.label} failed before producing diagnostics.`)
}
return parseOxlintOutput(result.stdout, scan.label).diagnostics ?? []
}
export function main(
root = process.cwd(),
requestedBase = process.argv.slice(2).find((argument) => argument !== '--')
) {
const { base, comparisonBase, rangesByFile } = collectAddedLineRanges(root, requestedBase)
const files = [...rangesByFile.keys()]
if (files.length === 0) {
console.log(`Changed-code quality gate: no changed JavaScript or TypeScript since ${base}.`)
return 0
}
let failures = 0
for (const scan of OXLINT_SCANS) {
const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) =>
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root)
)
for (const diagnostic of diagnostics) {
printDiagnostic(diagnostic, root)
}
failures += diagnostics.length
console.log(
`${scan.label}: ${diagnostics.length} new finding(s) across ${files.length} changed file(s).`
)
}
if (failures > 0) {
console.error(
`Changed-code quality gate failed with ${failures} finding(s) since ${comparisonBase.slice(0, 12)}.`
)
return 1
}
console.log(`Changed-code quality gate passed since ${comparisonBase.slice(0, 12)}.`)
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(main())
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import {
OXLINT_SCANS,
diagnosticTouchesAddedLines,
overlapsAddedLines,
parseAddedLineRanges
} from './check-changed-code-quality.mjs'
describe('changed-code quality line matching', () => {
it('parses added and replaced hunk ranges while ignoring deletions', () => {
const ranges = parseAddedLineRanges(
['@@ -10,2 +10,3 @@', '@@ -20 +21 @@', '@@ -40,4 +42,0 @@', '@@ -50 +48,2 @@'].join('\n')
)
expect(ranges).toEqual([
{ start: 10, end: 12 },
{ start: 21, end: 21 },
{ start: 48, end: 49 }
])
})
it('matches diagnostics that overlap any added line', () => {
const ranges = [
{ start: 5, end: 7 },
{ start: 12, end: 12 }
]
expect(overlapsAddedLines(3, 5, ranges)).toBe(true)
expect(overlapsAddedLines(8, 11, ranges)).toBe(false)
expect(overlapsAddedLines(12, 14, ranges)).toBe(true)
})
it('normalizes absolute diagnostic paths before matching', () => {
const root = process.cwd()
const file = 'config/scripts/check-changed-code-quality.test.mjs'
const diagnostic = {
filename: `${root}/${file}`,
labels: [{ span: { line: 24 } }]
}
expect(
diagnosticTouchesAddedLines(diagnostic, new Map([[file, [{ start: 24, end: 24 }]]]), root)
).toBe(true)
})
// Why: pinning --config disables nested-config discovery, so root rules that
// mobile/.oxlintrc.json turns off would fail the gate on mobile files.
it('lets the untyped scan discover nested configs instead of pinning the root config', () => {
const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'code quality')
expect(scan.args).not.toContain('--config')
expect(scan.args).not.toContain('--disable-nested-config')
})
})
@@ -0,0 +1,35 @@
import { spawnSync } from 'node:child_process'
import process from 'node:process'
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
const requestedBase =
process.argv.slice(2).find((argument) => argument !== '--') ??
process.env.ORCA_CODE_QUALITY_BASE ??
'origin/main'
const base = resolvePullRequestDiffBase(process.cwd(), requestedBase)
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(
pnpm,
[
'dlx',
'react-doctor@0.9.1',
'.',
'--yes',
'--scope',
'lines',
'--base',
base,
'--include-untracked',
'--no-dead-code',
'--no-supply-chain',
'--no-telemetry',
'--blocking',
'error'
],
{ stdio: 'inherit' }
)
if (result.error) {
throw result.error
}
process.exit(result.status ?? 1)
@@ -1,87 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
import { reportUnstyledScrollbars } from './styled-scrollbars/styled-scrollbar-jsx-check.mjs'
export {
plainClassName,
reportUnstyledScrollbars
} from './styled-scrollbars/styled-scrollbar-jsx-check.mjs'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__'])
export function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
if (relative.includes('.test.') || relative.includes('.spec.')) {
return true
}
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
}
async function collectSourceFiles(root, dir) {
const entries = await fs.readdir(dir, { withFileTypes: true })
const files = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (!SKIP_PATH_PARTS.has(entry.name)) {
files.push(...(await collectSourceFiles(root, fullPath)))
}
} else if (
entry.isFile() &&
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
!isSkippedFile(root, fullPath)
) {
files.push(fullPath)
}
}
return files
}
async function collectUnstyledScrollbarReports(root) {
const sourceRoot = path.join(root, 'src', 'renderer', 'src')
const files = await collectSourceFiles(root, sourceRoot)
const reports = []
for (const filePath of files) {
const sourceText = await fs.readFile(filePath, 'utf8')
reports.push(...reportUnstyledScrollbars(filePath, sourceText))
}
return reports
}
function formatReports(root, reports) {
return reports
.map(
(report) =>
`${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.text.replace(/\s+/g, ' ')}`
)
.join('\n')
}
export async function main(root = process.cwd()) {
const reports = await collectUnstyledScrollbarReports(root)
if (reports.length === 0) {
return 0
}
console.error('Renderer vertical scroll containers must use an Orca scrollbar style.')
console.error('Put the scrollbar class in the same class literal as the vertical overflow class.')
console.error('Use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.')
console.error('')
console.error(formatReports(root, reports))
return 1
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}
@@ -1,199 +0,0 @@
import { describe, expect, it } from 'vitest'
import { plainClassName, reportUnstyledScrollbars } from './check-styled-scrollbars.mjs'
describe('check-styled-scrollbars', () => {
it('reports renderer vertical scroll containers without an Orca scrollbar style', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="max-h-64 overflow-y-auto" /> }'
)
expect(reports).toHaveLength(1)
})
it('accepts obvious styled vertical scroll containers', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="max-h-64 overflow-auto scrollbar-sleek" /> }'
)
expect(reports).toHaveLength(0)
})
it('does not accept nonexistent scrollbar classes as Orca scrollbar styles', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="max-h-64 overflow-auto scrollbar-none" /> }'
)
expect(reports).toHaveLength(1)
})
it('fails closed when a separate class composer argument supplies the scrollbar style', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className={cn('max-h-64 overflow-y-auto', 'scrollbar-sleek')} /> }"
)
expect(reports).toHaveLength(1)
})
it('accepts static class composer arguments when the same literal is styled', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className={cn('max-h-64 overflow-y-auto scrollbar-sleek')} /> }"
)
expect(reports).toHaveLength(0)
})
it('fails closed when a scrollbar class is only conditionally present', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example({ enabled }) { return <div className={cn('overflow-y-auto', enabled && 'scrollbar-sleek')} /> }"
)
expect(reports).toHaveLength(1)
})
it('accepts conditional branches when overflow and scrollbar live in the same class literal', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example({ enabled }) { return <div className={cn(enabled && 'overflow-y-auto scrollbar-sleek')} /> }"
)
expect(reports).toHaveLength(0)
})
it('reports vertical scroll inside arbitrary wrappers when the literal is unstyled', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className={identity('overflow-y-auto')} /> }"
)
expect(reports).toHaveLength(1)
})
it('does not require a vertical scrollbar style for horizontal-only overflow', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <pre className="max-w-full overflow-x-auto" /> }'
)
expect(reports).toHaveLength(0)
})
it('does not let responsive scrollbar variants satisfy unconditional overflow', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="overflow-y-auto md:scrollbar-sleek" /> }'
)
expect(reports).toHaveLength(1)
})
it('accepts matching responsive overflow and scrollbar variants', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="md:overflow-y-auto md:scrollbar-sleek" /> }'
)
expect(reports).toHaveLength(0)
})
it('accepts unconditional scrollbar styles for responsive overflow', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="md:overflow-y-auto scrollbar-sleek" /> }'
)
expect(reports).toHaveLength(0)
})
it('reports inline vertical overflow without an Orca scrollbar class', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div style={{ overflowY: 'auto' }} /> }"
)
expect(reports).toHaveLength(1)
})
it('accepts inline vertical overflow with a stable Orca scrollbar class', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="scrollbar-editor" style={{ overflow: \'auto\' }} /> }'
)
expect(reports).toHaveLength(0)
})
it('reports inline vertical overflow when the scrollbar class is conditional or short-circuited', () => {
for (const classNameExpression of [
"enabled && 'scrollbar-sleek'",
"enabled ? 'scrollbar-sleek' : undefined",
"enabled || 'scrollbar-sleek'",
"enabled ?? 'scrollbar-sleek'"
]) {
const reports = reportUnstyledScrollbars(
'Example.tsx',
`export function Example({ enabled }) { return <div className={${classNameExpression}} style={{ overflowY: 'auto' }} /> }`
)
expect(reports, classNameExpression).toHaveLength(1)
}
})
it('reports logical inline style spreads without an Orca scrollbar class', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example({ open }) { return <div style={{ ...(open && { overflowY: 'auto' }) }} /> }"
)
expect(reports).toHaveLength(1)
})
it('reports JSX spread className props with unstyled vertical overflow', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div {...{ className: 'overflow-y-auto' }} /> }"
)
expect(reports).toHaveLength(1)
})
it('accepts JSX spread className props when the same literal is styled', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div {...{ className: 'overflow-y-auto scrollbar-sleek' }} /> }"
)
expect(reports).toHaveLength(0)
})
it('uses later spread className props over earlier explicit className props', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="scrollbar-sleek" {...{ className: \'overflow-y-auto\' }} /> }'
)
expect(reports).toHaveLength(1)
})
it('supports variant helper className config', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className={buttonVariants({ className: 'overflow-y-auto scrollbar-sleek' })} /> }"
)
expect(reports).toHaveLength(0)
})
it('normalizes Tailwind variants and important prefixes before matching', () => {
expect(plainClassName('md:overflow-y-auto')).toBe('overflow-y-auto')
expect(plainClassName('[&:hover]:overflow-y-auto')).toBe('overflow-y-auto')
expect(plainClassName('md:!scrollbar-editor')).toBe('scrollbar-editor')
expect(plainClassName('!scrollbar-editor')).toBe('scrollbar-editor')
})
})
@@ -0,0 +1,342 @@
import { spawn } from 'node:child_process'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { buildWindowsCommandInvocation } from '../../src/main/claude-accounts/windows-command-invocation.ts'
const strategy = process.argv[2]
if (!['baseline', 'candidate', 'explicit-cmd'].includes(strategy)) {
throw new Error(
'Usage: node config/scripts/claude-account-windows-spawn-repro.mjs <baseline|candidate|explicit-cmd>'
)
}
if (process.platform !== 'win32') {
throw new Error('This reproduction requires a physical Windows host.')
}
const expectedArgs = [
'',
'two words',
'amp&ersand',
'pipe|value',
'less<value',
'greater>value',
'caret^value',
'trailing\\',
'two-trailing\\\\',
'(parentheses)',
'100%',
'%ORCA_ARG_TRAP%',
'bang!value',
'한글-λ'
]
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-claude-spawn-'))
const reportedDir = join(tempRoot, 'Profile with spaces 한글')
const reportedCapturePath = join(reportedDir, 'capture.json')
const reportedPidPath = join(reportedDir, 'pids.json')
const reportedShimPath = join(reportedDir, 'claude fixture.cmd')
const reportedFixturePath = join(reportedDir, 'capture-child.cjs')
const fixtureDir = join(tempRoot, 'Profile space & ^ (paren) %ORCA_PATH_TRAP% !bang! 한글')
const capturePath = join(fixtureDir, 'capture.json')
const pidPath = join(fixtureDir, 'pids.json')
const shimPath = join(fixtureDir, 'claude fixture.cmd')
const fixturePath = join(fixtureDir, 'capture-child.cjs')
const fixtureEnv = {
...process.env,
CLAUDE_CONFIG_DIR: join(fixtureDir, 'config space & ^ (paren) %ORCA_ENV_LITERAL% !bang! 한글'),
ORCA_ARG_TRAP: 'EXPANDED_ARG',
ORCA_PATH_TRAP: 'EXPANDED_PATH',
ORCA_FIXTURE_CAPTURE: capturePath,
ORCA_FIXTURE_PIDS: pidPath,
ORCA_FIXTURE_NODE: process.execPath
}
const reportedEnv = {
...fixtureEnv,
CLAUDE_CONFIG_DIR: join(reportedDir, 'config with spaces 한글'),
ORCA_FIXTURE_CAPTURE: reportedCapturePath,
ORCA_FIXTURE_PIDS: reportedPidPath
}
function quoteForCandidate(value) {
return `"${value.replace(/"/g, '""')}"`
}
function launch(args, command = shimPath, env = fixtureEnv) {
if (strategy === 'baseline') {
return spawn(command, args, { cwd: tempRoot, env, shell: true, windowsHide: true })
}
if (strategy === 'candidate') {
return spawn(quoteForCandidate(command), args, {
cwd: tempRoot,
env,
shell: true,
windowsHide: true
})
}
const invocation = buildWindowsCommandInvocation(command, args)
return spawn(invocation.command, invocation.args, {
cwd: tempRoot,
env,
shell: false,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
windowsHide: true
})
}
function collect(child) {
return new Promise((resolve) => {
let stdout = ''
let stderr = ''
child.stdout?.on('data', (chunk) => (stdout += chunk.toString()))
child.stderr?.on('data', (chunk) => (stderr += chunk.toString()))
child.on('error', (error) => resolve({ code: null, stdout, stderr, error: error.message }))
child.on('close', (code) => resolve({ code, stdout, stderr, error: null }))
})
}
async function waitForFile(path, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
return JSON.parse(await readFile(path, 'utf8'))
} catch {
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
throw new Error(`Timed out waiting for fixture output: ${path}`)
}
async function taskExists(pid) {
const result = await collect(
spawn('tasklist.exe', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], {
windowsHide: true
})
)
if (result.error || result.code !== 0) {
throw new Error(`tasklist failed for PID ${pid}: ${result.error ?? result.stderr}`)
}
return result.stdout.includes(`"${pid}"`)
}
async function killTree(pid) {
const result = await collect(
spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { windowsHide: true })
)
if (result.error || result.code !== 0) {
throw new Error(`taskkill failed for PID ${pid}: ${result.error ?? result.stderr}`)
}
}
async function waitForTreeExit(pids, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs
let alive = {}
do {
alive = Object.fromEntries(
await Promise.all(
Object.entries(pids).map(async ([name, pid]) => [name, await taskExists(pid)])
)
)
if (!Object.values(alive).some(Boolean)) {
return alive
}
await new Promise((resolve) => setTimeout(resolve, 50))
} while (Date.now() < deadline)
return alive
}
const results = {
strategy,
reportedPath: null,
pathMatrix: {},
argvMatrix: {},
hostilePathAndArgv: null,
error: null,
cancellation: null
}
const fixtureSource =
`const { spawn } = require('node:child_process')\n` +
`const { writeFileSync } = require('node:fs')\n` +
`if (process.argv[2] === '--exit-error') { process.stderr.write('fixture error: 한글 & ^ % !\\n'); process.exit(23) }\n` +
`if (process.argv[2] === '--linger') {\n` +
` const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { windowsHide: true })\n` +
` writeFileSync(process.env.ORCA_FIXTURE_PIDS, JSON.stringify({ child: process.pid, grandchild: grandchild.pid }))\n` +
` setInterval(() => {}, 1000)\n` +
`} else {\n` +
` writeFileSync(process.env.ORCA_FIXTURE_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), configDir: process.env.CLAUDE_CONFIG_DIR }))\n` +
`}\n`
const shimSource = '@echo off\r\n"%ORCA_FIXTURE_NODE%" "%~dp0capture-child.cjs" %*\r\n'
let lingeringShellPid = null
try {
await mkdir(fixtureDir, { recursive: true })
await mkdir(reportedDir, { recursive: true })
await writeFile(fixturePath, fixtureSource, 'utf8')
await writeFile(shimPath, shimSource, 'utf8')
await writeFile(reportedFixturePath, fixtureSource, 'utf8')
await writeFile(reportedShimPath, shimSource, 'utf8')
const reportedArgs = ['auth', 'status', '--json']
const reportedRun = await collect(launch(reportedArgs, reportedShimPath, reportedEnv))
let reportedCapture = null
try {
reportedCapture = await waitForFile(reportedCapturePath, 1_000)
} catch {}
results.reportedPath = {
...reportedRun,
actual: reportedCapture,
expected: { argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR },
pass:
reportedRun.code === 0 &&
JSON.stringify(reportedCapture) ===
JSON.stringify({ argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR })
}
for (const [name, segment] of Object.entries({
spaces: 'profile space',
ampersand: 'profile&name',
caret: 'profile^name',
parentheses: 'profile(name)',
percent: 'profile%ORCA_PATH_TRAP%',
bang: 'profile!name',
unicode: 'profile-한글-λ'
})) {
const directory = join(tempRoot, segment)
const captureFile = join(directory, 'capture.json')
const command = join(directory, 'claude fixture.cmd')
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'capture-child.cjs'), fixtureSource, 'utf8')
await writeFile(command, shimSource, 'utf8')
const env = {
...fixtureEnv,
CLAUDE_CONFIG_DIR: join(directory, 'config'),
ORCA_FIXTURE_CAPTURE: captureFile
}
const run = await collect(launch(reportedArgs, command, env))
let actual = null
try {
actual = await waitForFile(captureFile, 500)
} catch {}
results.pathMatrix[name] = {
code: run.code,
stderr: run.stderr.trim(),
actual: actual?.argv ?? null,
pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(reportedArgs)
}
}
for (const [name, value] of Object.entries({
empty: '',
spaces: 'two words',
ampersand: 'amp&ersand',
pipe: 'pipe|value',
lessThan: 'less<value',
greaterThan: 'greater>value',
caret: 'caret^value',
trailingBackslash: 'trailing\\',
twoTrailingBackslashes: 'two-trailing\\\\',
parentheses: '(parentheses)',
percent: '%ORCA_ARG_TRAP%',
bang: 'bang!value',
unicode: '한글-λ'
})) {
const args = ['prefix', value, 'suffix']
const captureFile = join(reportedDir, `capture-${name}.json`)
const env = { ...reportedEnv, ORCA_FIXTURE_CAPTURE: captureFile }
const run = await collect(launch(args, reportedShimPath, env))
let actual = null
try {
actual = await waitForFile(captureFile, 500)
} catch {}
results.argvMatrix[name] = {
code: run.code,
stderr: run.stderr.trim(),
actual: actual?.argv ?? null,
pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(args)
}
}
const argvRun = await collect(launch(expectedArgs))
let capture = null
try {
capture = await waitForFile(capturePath, 1_000)
} catch {}
results.hostilePathAndArgv = {
...argvRun,
actual: capture,
expected: { argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR },
pass:
argvRun.code === 0 &&
JSON.stringify(capture) ===
JSON.stringify({ argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR })
}
results.error = await collect(launch(['--exit-error'], reportedShimPath, reportedEnv))
results.error.pass =
results.error.code === 23 && results.error.stderr.includes('fixture error: 한글 & ^ % !')
const lingering = launch(['--linger'], reportedShimPath, reportedEnv)
lingeringShellPid = lingering.pid
const lingeringResult = collect(lingering)
try {
const pids = await waitForFile(reportedPidPath)
await killTree(lingering.pid)
const alive = await waitForTreeExit({
shell: lingering.pid,
child: pids.child,
grandchild: pids.grandchild
})
results.cancellation = {
shell: lingering.pid,
...pids,
alive,
pass: !Object.values(alive).some(Boolean)
}
} catch (error) {
if (await taskExists(lingering.pid)) {
await killTree(lingering.pid)
}
const launchResult = await Promise.race([
lingeringResult,
new Promise((resolve) =>
setTimeout(
() => resolve({ code: null, error: 'fixture did not exit after cleanup' }),
5_000
)
)
])
results.cancellation = {
shell: lingering.pid,
launchResult,
pass: false,
error: error instanceof Error ? error.message : String(error)
}
}
} finally {
if (lingeringShellPid && (await taskExists(lingeringShellPid))) {
await killTree(lingeringShellPid)
}
try {
let pids
try {
pids = JSON.parse(await readFile(reportedPidPath, 'utf8'))
} catch {
pids = JSON.parse(await readFile(pidPath, 'utf8'))
}
for (const pid of [pids.child, pids.grandchild]) {
if (await taskExists(pid)) {
await killTree(pid)
}
}
} catch {}
await rm(tempRoot, { recursive: true, force: true })
}
console.log(JSON.stringify(results, null, 2))
process.exitCode =
results.reportedPath?.pass &&
Object.values(results.pathMatrix).every((result) => result.pass) &&
Object.values(results.argvMatrix).every((result) => result.pass) &&
results.hostilePathAndArgv?.pass &&
results.error?.pass &&
results.cancellation?.pass
? 0
: 1
@@ -0,0 +1,189 @@
#!/usr/bin/env node
// Benchmark: the event-loop yield in the Claude usage scanner's batch loops.
//
// scanner.ts yielded with `setTimeout(resolve, 0)`, which Node clamps to ~1ms. The
// loops yield once per FILE_SCAN_BATCH_SIZE files across two passes, so a machine with
// thousands of transcripts spent seconds parked on timers doing no work. setImmediate
// yields on the same tick's check phase with no clamp. The sibling scanner
// (src/main/codex-usage/scanner.ts) already used setImmediate.
//
// The yield exists to keep the main process responsive during a scan, so this also
// measures worst-case latency for a concurrent task -- a "faster" yield that starved
// other work would be a regression, not a win.
//
// Run with: node config/scripts/claude-usage-yield-benchmark.mjs
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
const REPO_ROOT = new URL('../..', import.meta.url)
const ROUNDS = Number(process.env.ORCA_YIELD_BENCH_ROUNDS ?? '10')
// Why re-read the source: the claim is that the scanner yields once per batch across
// two loops. If the batch size or the yield sites change, these numbers stop meaning
// what the header says, so fail loudly instead of reporting a stale ratio.
const SCANNER_SOURCE = readFileSync(new URL('src/main/claude-usage/scanner.ts', REPO_ROOT), 'utf8')
const batchMatch = SCANNER_SOURCE.match(/const FILE_SCAN_BATCH_SIZE = (\d+)/)
if (!batchMatch) {
throw new Error('FILE_SCAN_BATCH_SIZE not found; this benchmark is stale')
}
const FILE_SCAN_BATCH_SIZE = Number(batchMatch[1])
const YIELD_SITES = (SCANNER_SOURCE.match(/await yieldToEventLoop\(\)/g) ?? []).length
if (YIELD_SITES === 0) {
throw new Error('no yieldToEventLoop call sites found; this benchmark is stale')
}
// Match the call, not the word: a comment mentioning setImmediate would satisfy a
// bare substring check even after the yield reverted to setTimeout.
if (!/setImmediate\(resolve\)/.test(SCANNER_SOURCE)) {
throw new Error('scanner no longer yields with setImmediate; this benchmark is stale')
}
// Real transcript count drives the yield count, so read it rather than assume one.
function countClaudeTranscripts() {
const root = join(homedir(), '.claude', 'projects')
let count = 0
const stack = [root]
while (stack.length > 0) {
const dir = stack.pop()
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (entry.isDirectory()) {
stack.push(join(dir, entry.name))
} else if (entry.name.endsWith('.jsonl')) {
count += 1
}
}
}
return count
}
const transcriptCount = (() => {
try {
statSync(join(homedir(), '.claude', 'projects'))
return countClaudeTranscripts()
} catch {
return 0
}
})()
const FALLBACK_TRANSCRIPTS = 7500
const effectiveTranscripts = transcriptCount > 0 ? transcriptCount : FALLBACK_TRANSCRIPTS
// Each pass walks ceil(files / batch) batches and yields after every batch except the
// last, so a pass yields batchesPerPass - 1 times.
const BATCHES_PER_PASS = Math.max(1, Math.ceil(effectiveTranscripts / FILE_SCAN_BATCH_SIZE))
const YIELDS_PER_SCAN = Math.max(1, (BATCHES_PER_PASS - 1) * YIELD_SITES)
const yieldWithTimeout = () => new Promise((resolve) => setTimeout(resolve, 0))
const yieldWithImmediate = () => new Promise((resolve) => setImmediate(resolve))
// Mirrors the scanner's shape: a little synchronous work per batch, then a yield.
async function runBatchLoop(doYield, batches) {
let sink = 0
for (let batch = 0; batch < batches; batch += 1) {
for (let file = 0; file < FILE_SCAN_BATCH_SIZE; file += 1) {
sink += (batch * 31 + file) % 7
}
if (batch + 1 < batches) {
await doYield()
}
}
return sink
}
async function timeArm(doYield, batches) {
const start = performance.now()
const sink = await runBatchLoop(doYield, batches)
const elapsed = performance.now() - start
if (sink === -1) {
throw new Error('unreachable')
}
return elapsed
}
function median(samples) {
const sorted = [...samples].sort((a, b) => a - b)
const mid = sorted.length / 2
return (sorted[mid - 1] + sorted[mid]) / 2
}
// Arms alternate which one leads so within-round drift cannot favour either.
async function measure(batches) {
await runBatchLoop(yieldWithTimeout, Math.min(batches, 50))
await runBatchLoop(yieldWithImmediate, Math.min(batches, 50))
const timeoutSamples = []
const immediateSamples = []
for (let round = 0; round < ROUNDS; round += 1) {
if (round % 2 === 0) {
timeoutSamples.push(await timeArm(yieldWithTimeout, batches))
immediateSamples.push(await timeArm(yieldWithImmediate, batches))
} else {
immediateSamples.push(await timeArm(yieldWithImmediate, batches))
timeoutSamples.push(await timeArm(yieldWithTimeout, batches))
}
}
return { timeoutMs: median(timeoutSamples), immediateMs: median(immediateSamples) }
}
// The yield exists for responsiveness, so measure what a concurrent task actually sees.
async function measureConcurrentLatency(doYield, batches) {
let worstLatencyMs = 0
let stop = false
const probe = (async () => {
while (!stop) {
const tick = performance.now()
await new Promise((resolve) => setImmediate(resolve))
worstLatencyMs = Math.max(worstLatencyMs, performance.now() - tick)
}
})()
const start = performance.now()
await runBatchLoop(doYield, batches)
const scanMs = performance.now() - start
stop = true
await probe
return { scanMs, worstLatencyMs }
}
const pad = (value, width) => String(value).padStart(width)
console.log('Claude usage scanner event-loop yield. Lower is better.')
console.log(
`transcripts=${transcriptCount > 0 ? transcriptCount : `${FALLBACK_TRANSCRIPTS} (none found; synthetic)`} batch=${FILE_SCAN_BATCH_SIZE} sites=${YIELD_SITES} -> ~${YIELDS_PER_SCAN} yields/scan`
)
console.log(
`${pad('yields', 8)} ${pad('setTimeout(0)', 14)} ${pad('setImmediate', 13)} ${pad('speedup', 9)} ${pad('saved', 11)}`
)
for (const yields of [100, 500, YIELDS_PER_SCAN]) {
// runBatchLoop yields batches - 1 times, so ask for one more batch than yields.
const batches = yields + 1
const { timeoutMs, immediateMs } = await measure(batches)
// Why report the absolute saving too: the setImmediate arm is small enough that
// background load moves the RATIO a lot while the removed wall time barely budges.
console.log(
`${pad(yields, 8)} ${pad(`${timeoutMs.toFixed(1)} ms`, 14)} ${pad(`${immediateMs.toFixed(1)} ms`, 13)} ${pad(`${(timeoutMs / immediateMs).toFixed(1)}x`, 9)} ${pad(`${(timeoutMs - immediateMs).toFixed(0)} ms`, 11)}`
)
}
console.log('\nResponsiveness (the reason the yield exists) at a full scan:')
const timeoutLatency = await measureConcurrentLatency(yieldWithTimeout, YIELDS_PER_SCAN + 1)
const immediateLatency = await measureConcurrentLatency(yieldWithImmediate, YIELDS_PER_SCAN + 1)
console.log(
` setTimeout(0): scan ${timeoutLatency.scanMs.toFixed(0)} ms, worst concurrent wait ${timeoutLatency.worstLatencyMs.toFixed(2)} ms`
)
console.log(
` setImmediate : scan ${immediateLatency.scanMs.toFixed(0)} ms, worst concurrent wait ${immediateLatency.worstLatencyMs.toFixed(2)} ms`
)
if (immediateLatency.worstLatencyMs > timeoutLatency.worstLatencyMs) {
console.log(
'\n NOTE: setImmediate showed a WORSE concurrent wait here. The yield exists for\n responsiveness, so that would be a regression even though the scan is faster.'
)
}
console.log(
'\nRead the SAVED column, not the ratio. The setImmediate arm is small enough that\nbackground load swings the ratio (32x-81x observed across runs on a loaded machine)\nwhile the removed wall time stays at ~4.2-5.1 s. The saving is wall-clock the main\nprocess spent parked on timer clamps, not CPU work removed. It is paid on every\nUsage-pane scan and every forced automation rescan.'
)
@@ -0,0 +1,283 @@
#!/usr/bin/env node
// Benchmark: CLI process startup with the RuntimeClient module graph deferred.
//
// src/cli/index.ts used to value-import RuntimeClient at module scope, and five
// modules that load on every invocation (args, flags, dispatch, format,
// selectors) pulled RuntimeClientError from the ./runtime-client barrel. Either
// edge alone drags in the whole client graph: zod (via shared/pairing ->
// shared/mobile-relay-pairing-offer), ws + tweetnacl (via websocket-transport),
// plus the environment store and secure-file stack.
//
// The fix repoints those five at ./runtime/types (zero children) and loads the
// client through `await import()` after flag validation, so --help, `help
// <cmd>` and every command/flag error return without ever touching it.
//
// Both arms are REAL tsc emits of real source: the baseline arm restores the
// seven touched files from a git rev and compiles that. Each sample is a FRESH
// process (module-graph cost is a once-per-process cost; timing it in-process
// would measure a warm require cache).
//
// Arms alternate lead across an even number of rounds and report per-arm
// medians. Byte-for-byte output equality is checked BEFORE timing.
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const REPO = fileURLToPath(new URL('../..', import.meta.url))
const ROUNDS = Number(process.env.ORCA_CLI_DEFER_BENCH_ROUNDS ?? '30')
const WARMUP = Number(process.env.ORCA_CLI_DEFER_BENCH_WARMUP ?? '3')
for (const [name, value] of [
['ORCA_CLI_DEFER_BENCH_ROUNDS', ROUNDS],
['ORCA_CLI_DEFER_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
if (ROUNDS % 2 !== 0) {
// Why: arms alternate which one leads; an odd count biases one arm.
throw new Error(`ORCA_CLI_DEFER_BENCH_ROUNDS must be even, received ${ROUNDS}`)
}
const TOUCHED = [
'src/cli/args.ts',
'src/cli/dispatch.ts',
'src/cli/flags.ts',
'src/cli/format.ts',
'src/cli/index.ts',
'src/cli/runtime/client.ts',
'src/cli/selectors.ts'
]
// Why: if the deferral is reverted or reshaped, both arms would compile to the
// same thing and this would quietly report 1.00x forever. Re-read the real call
// forms out of the source. Matching the CALL form (not a bare identifier) so a
// comment that merely names the function cannot satisfy the check.
function assertMarkersFresh() {
const checks = [
['src/cli/index.ts', "await import('./runtime-client.js')"],
['src/cli/index.ts', 'await loadRuntimeClientClass()'],
['src/cli/index.ts', "import type { RuntimeClient } from './runtime-client'"],
['src/cli/runtime/client.ts', "await import('./websocket-transport.js')"],
['src/cli/runtime/client.ts', 'await loadSendWebSocketRequest()'],
['src/cli/args.ts', "import { RuntimeClientError } from './runtime/types'"],
['src/cli/flags.ts', "import { RuntimeClientError } from './runtime/types'"],
['src/cli/dispatch.ts', "import { RuntimeClientError } from './runtime/types'"],
['src/cli/selectors.ts', "import { RuntimeClientError } from './runtime/types'"],
['src/cli/format.ts', "} from './runtime/types'"]
]
for (const [file, marker] of checks) {
if (!readFileSync(join(REPO, file), 'utf8').includes(marker)) {
throw new Error(
`${file} no longer contains \`${marker}\` — cli-runtime-client-deferral-benchmark.mjs is stale`
)
}
}
}
function buildArm(label, baselineRev) {
// Why: a build under /tmp cannot resolve the repo's node_modules, so the
// output has to live inside the repo.
const outDir = join(REPO, `.bench-out-${label}`)
rmSync(outDir, { recursive: true, force: true })
const restore = []
try {
if (baselineRev) {
for (const file of TOUCHED) {
const path = join(REPO, file)
restore.push([path, readFileSync(path)])
writeFileSync(
path,
execFileSync('git', ['show', `${baselineRev}:${file}`], {
cwd: REPO,
maxBuffer: 64 * 1024 * 1024
})
)
}
}
execFileSync(
'npx',
[
'tsc',
'-p',
'config/tsconfig.cli.json',
'--outDir',
outDir,
'--composite',
'false',
'--incremental',
'false'
],
{ cwd: REPO, stdio: 'inherit' }
)
} finally {
for (const [path, contents] of restore) {
writeFileSync(path, contents)
}
}
return join(outDir, 'cli/index.js')
}
function run(entry, argv, env) {
const result = spawnSync(process.execPath, [entry, ...argv], {
cwd: REPO,
env: { ...process.env, ...env },
encoding: 'buffer'
})
if (result.error) {
throw result.error
}
return {
status: result.status,
stdout: result.stdout.toString('utf8'),
stderr: result.stderr.toString('utf8')
}
}
// Counts the eager CommonJS module graph of a built entry point by hooking
// Module._load in a child process. This is the quantity the change moves.
function countEagerModules(entry) {
const probe = `
const Module = require('module')
const original = Module._load
const seen = new Set()
Module._load = function (request, parent, isMain) {
try { seen.add(Module._resolveFilename(request, parent, isMain)) } catch { seen.add(request) }
return original.apply(this, arguments)
}
require(${JSON.stringify(entry)})
const all = [...seen]
process.stdout.write(JSON.stringify({
total: all.length,
nodeModules: all.filter((p) => p.includes('node_modules')).length
}))
`
const result = spawnSync(process.execPath, ['-e', probe], { cwd: REPO, encoding: 'utf8' })
if (result.status !== 0) {
throw new Error(`module probe failed: ${result.stderr}`)
}
return JSON.parse(result.stdout)
}
const median = (values) => {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}
const baselineIndex = process.argv.indexOf('--baseline')
const baselineRev = baselineIndex === -1 ? 'HEAD' : process.argv[baselineIndex + 1]
assertMarkersFresh()
const userDataPath = mkdtempSync(join(REPO, '.bench-userdata-'))
try {
console.log(`Building eager baseline (${baselineRev}) …`)
const eagerEntry = buildArm('eager', baselineRev)
console.log('Building deferred (working tree) …')
const deferredEntry = buildArm('deferred', null)
const eagerGraph = countEagerModules(eagerEntry)
const deferredGraph = countEagerModules(deferredEntry)
console.log(
`\nEager modules at process load: ${eagerGraph.total} -> ${deferredGraph.total} ` +
`(node_modules ${eagerGraph.nodeModules} -> ${deferredGraph.nodeModules})`
)
if (deferredGraph.total >= eagerGraph.total) {
throw new Error(
'deferred arm loads no fewer modules — the fixture does not exercise the change'
)
}
// Each case is (label, argv, env). The runtime-dependent ones point at an
// empty user-data dir so both arms get the same deterministic answer.
const isolated = { ORCA_USER_DATA_PATH: userDataPath }
/** @type {Array<[string, string[], Record<string, string>]>} */
const cases = [
['orca --help', ['--help'], {}],
['orca help worktree', ['help', 'worktree'], {}],
['orca (no args)', [], {}],
['unknown command', ['no-such-command'], {}],
['unknown flag', ['worktree', 'list', '--nope'], {}],
['orca agent-context --json', ['agent-context', '--json'], {}],
['orca status --json', ['status', '--json'], isolated],
['orca worktree list --json', ['worktree', 'list', '--json'], isolated]
]
// Why: a semantically broken arm that prints nothing would look fastest.
// Compare bytes and exit codes BEFORE timing anything.
for (const [label, argv, env] of cases) {
const before = run(eagerEntry, argv, env)
const after = run(deferredEntry, argv, env)
if (
before.status !== after.status ||
before.stdout !== after.stdout ||
before.stderr !== after.stderr
) {
throw new Error(`arms disagree for "${label}" — refusing to report a timing`)
}
if (before.stdout.length + before.stderr.length === 0) {
throw new Error(`"${label}" produced no output on either arm; it proves nothing`)
}
}
const pad = (value, width) => String(value).padStart(width)
console.log('\nFresh process per sample, wall clock. Lower is better.')
console.log(`rounds=${ROUNDS} warmup=${WARMUP} (per-arm median, arms alternate lead)`)
console.log(`${pad('case', 26)} ${pad('eager', 10)} ${pad('deferred', 10)} ${pad('speedup', 9)}`)
// Accumulated so V8 cannot treat the spawn loop as dead code.
let consumed = 0
for (const [label, argv, env] of cases) {
for (let index = 0; index < WARMUP; index += 1) {
consumed += run(eagerEntry, argv, env).stdout.length
consumed += run(deferredEntry, argv, env).stdout.length
}
const samples = { eager: [], deferred: [] }
for (let round = 0; round < ROUNDS; round += 1) {
// Alternate which arm leads so a drifting machine load cannot be
// attributed to one arm.
const order =
round % 2 === 0
? [
['eager', eagerEntry],
['deferred', deferredEntry]
]
: [
['deferred', deferredEntry],
['eager', eagerEntry]
]
for (const [arm, entry] of order) {
const started = performance.now()
const result = run(entry, argv, env)
samples[arm].push(performance.now() - started)
consumed += result.stdout.length
}
}
const eagerMs = median(samples.eager)
const deferredMs = median(samples.deferred)
console.log(
`${pad(label, 26)} ${pad(`${eagerMs.toFixed(1)} ms`, 10)} ${pad(`${deferredMs.toFixed(1)} ms`, 10)} ${pad(`${(eagerMs / deferredMs).toFixed(2)}x`, 9)}`
)
}
if (consumed === 0) {
throw new Error('no output consumed — the timing loop was optimised away')
}
console.log(
'\nThe help and error rows are the ones the change targets: they return\n' +
'before any client construction, so they drop the whole graph. `status` and\n' +
'`worktree list` still construct a client, so they only save the eager parse\n' +
'of the parts the local path never uses (ws/tweetnacl via websocket-transport).'
)
} finally {
rmSync(userDataPath, { recursive: true, force: true })
for (const label of ['eager', 'deferred']) {
rmSync(join(REPO, `.bench-out-${label}`), { recursive: true, force: true })
}
}
@@ -0,0 +1,485 @@
#!/usr/bin/env node
// Equivalence check for deferring the RuntimeClient module graph in the CLI.
//
// Builds the CLI twice with the REAL tsc emit — once from the working tree and
// once with the seven touched files restored from git HEAD~ (the pre-deferral
// implementation) — then compares stdout, stderr and exit code BYTE FOR BYTE
// across a matrix of invocations.
//
// Why a script and not a vitest case: this compiles two full CLI trees. It is
// the artifact that proves the refactor is behaviour-preserving; the fast
// invariants (class identity, no eager barrel import) live in
// src/cli/runtime-client-deferral.test.ts and run in the normal suite.
//
// Usage: node config/scripts/cli-runtime-client-deferral-equivalence.mjs [--baseline <rev>]
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO = fileURLToPath(new URL('../..', import.meta.url))
// The files this change touches. Restoring exactly these from the baseline rev
// reconstructs the old implementation without disturbing anything else.
const TOUCHED = [
'src/cli/args.ts',
'src/cli/dispatch.ts',
'src/cli/flags.ts',
'src/cli/format.ts',
'src/cli/index.ts',
'src/cli/runtime/client.ts',
'src/cli/selectors.ts'
]
// Why: if the deferral is ever reverted or reshaped, the "old" arm would
// silently become identical to the new one and every case would pass
// vacuously. Re-read the real call form out of the source and fail loudly.
function assertMarkersFresh() {
const index = readFileSync(join(REPO, 'src/cli/index.ts'), 'utf8')
const client = readFileSync(join(REPO, 'src/cli/runtime/client.ts'), 'utf8')
const checks = [
['src/cli/index.ts', index, 'await loadRuntimeClientClass()'],
['src/cli/index.ts', index, "await import('./runtime-client.js')"],
['src/cli/index.ts', index, "import type { RuntimeClient } from './runtime-client'"],
['src/cli/runtime/client.ts', client, 'await loadSendWebSocketRequest()'],
['src/cli/runtime/client.ts', client, "await import('./websocket-transport.js')"]
]
for (const [file, source, marker] of checks) {
// Match the call form, not a bare word: a comment naming the function must
// not satisfy the check.
if (!source.includes(marker)) {
throw new Error(
`${file} no longer contains \`${marker}\` — cli-runtime-client-deferral-equivalence.mjs is stale`
)
}
}
const repointed = ['src/cli/args.ts', 'src/cli/flags.ts', 'src/cli/dispatch.ts']
for (const file of repointed) {
const source = readFileSync(join(REPO, file), 'utf8')
if (!source.includes("import { RuntimeClientError } from './runtime/types'")) {
throw new Error(`${file} no longer repoints RuntimeClientError at ./runtime/types — stale`)
}
}
}
function buildTree(label, baselineRev) {
// Why: builds under /tmp cannot resolve the repo's node_modules, so the
// output dir has to live inside the repo.
const outDir = join(REPO, `.equiv-out-${label}`)
rmSync(outDir, { recursive: true, force: true })
const restored = []
try {
if (baselineRev) {
for (const file of TOUCHED) {
const path = join(REPO, file)
restored.push([path, readFileSync(path)])
const old = execFileSync('git', ['show', `${baselineRev}:${file}`], {
cwd: REPO,
maxBuffer: 64 * 1024 * 1024
})
writeFileSync(path, old)
}
}
execFileSync(
'npx',
[
'tsc',
'-p',
'config/tsconfig.cli.json',
'--outDir',
outDir,
'--composite',
'false',
'--incremental',
'false'
],
{ cwd: REPO, stdio: 'inherit' }
)
} finally {
for (const [path, contents] of restored) {
writeFileSync(path, contents)
}
}
return join(outDir, 'cli/index.js')
}
// Why: every case is a one-shot CLI invocation that must exit on its own. An
// unbounded spawnSync turns "this argv reached a blocking command" into an
// indefinite stall — a guard that can hang instead of failing is not a guard.
const RUN_TIMEOUT_MS = 30_000
function run(entry, argv, env) {
const result = spawnSync(process.execPath, [entry, ...argv], {
cwd: REPO,
env: { ...process.env, ...env },
encoding: 'buffer',
timeout: RUN_TIMEOUT_MS,
killSignal: 'SIGKILL'
})
if (result.error) {
if (result.error.code === 'ETIMEDOUT') {
throw new Error(
`orca ${argv.join(' ')} did not exit within ${RUN_TIMEOUT_MS} ms — it reached a blocking command`
)
}
throw result.error
}
return {
status: result.status,
stdout: result.stdout.toString('utf8'),
stderr: result.stderr.toString('utf8')
}
}
// Every case must produce identical bytes on both arms. The runtime-dependent
// ones (status/worktree list) are pointed at an empty user-data dir so the
// answer is deterministic "not running" rather than whatever the dev machine
// happens to be doing.
function buildCases(isolatedUserData) {
const isolated = { ORCA_USER_DATA_PATH: isolatedUserData }
const cases = [
// Paths that must never load the runtime client at all.
[[], {}],
[['--help'], {}],
[['help'], {}],
[['help', 'worktree'], {}],
[['help', 'browser'], {}],
[['worktree', '--help'], {}],
[['help', 'no-such-command'], {}],
[['no-such-command'], {}],
[['no-such-command'], { ORCA_PAIRING_CODE: 'garbage' }],
[['wrktree', 'list'], {}],
[['agent-context'], {}],
[['agent-context', '--json'], {}],
// Flag validation must still fire before any runtime lookup.
[['worktree', 'list', '--nonexistent-flag'], {}],
[['worktree', 'list', '--nonexistent-flag', '--json'], {}],
[['browser', 'snapshot', '--nonexistent-flag'], {}],
// resolveRemotePairing throws from the RuntimeClient CONSTRUCTOR.
[['status', '--pairing-code', 'x', '--environment', 'y'], isolated],
[['status', '--pairing-code', 'x', '--environment', 'y', '--json'], isolated],
[['status', '--pairing-code', 'not-a-pairing-code'], isolated],
[['status', '--pairing-code', 'not-a-pairing-code', '--json'], isolated],
[['status', '--pairing-code', 'orca://pair?code=zzzz'], isolated],
[['status', '--environment', 'no-such-environment'], isolated],
[['status', '--environment', 'no-such-environment', '--json'], isolated],
[['worktree', 'list', '--environment', 'no-such-environment', '--json'], isolated],
// The env-var fallback must stay live for non-suppressed commands...
[['status', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
[['status', '--json'], { ...isolated, ORCA_REMOTE_PAIRING: 'not-a-pairing-code' }],
[['status', '--json'], { ...isolated, ORCA_ENVIRONMENT: 'no-such-environment' }],
// ...and must stay suppressed for the local-only command groups.
//
// NOTE: the only commands that both live in a suppressed group AND touch
// ctx.client are `agent hooks on|off`, which rewrite the user's real agent
// hook configuration in ~/.claude and friends — far outside
// ORCA_USER_DATA_PATH. They are deliberately NOT invoked here. The
// null-vs-undefined suppression they would exercise is covered
// side-effect-free by the constructor-argument assertions in
// src/cli/runtime-client-deferral.test.ts instead.
[['environment', 'list', '--json'], { ...isolated, ORCA_ENVIRONMENT: 'no-such-environment' }],
[['environment', 'list', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
[['agent-context', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
// Runtime-unavailable reporting (RuntimeClientError formatting).
[['status'], isolated],
[['status', '--json'], isolated],
[['worktree', 'list', '--json'], isolated],
[['terminal', 'list', '--json'], isolated]
]
// Fuzz: random argv drawn from real command tokens, flags and hostile
// strings. Seeded so a failure is reproducible. Every token here must be
// safe to actually execute — see UNSAFE_TOKENS, which is cross-checked
// against this list before a single case runs.
const tokens = [
'worktree',
'list',
'status',
'browser',
'snapshot',
'terminal',
'environment',
'agent',
'agent-context',
'vm',
'--json',
'--help',
'--pairing-code',
'--environment',
'--worktree',
'orca://pair?code=!!!',
'',
'-',
'--',
'--=',
'a'.repeat(300),
'näme-ünicode',
'💥',
'../..',
'x\ty'
]
let seed = 0x9e3779b9
const next = () => {
seed ^= seed << 13
seed ^= seed >>> 17
seed ^= seed << 5
return (seed >>> 0) / 0x100000000
}
// Why: check the draw POOL, not just the 400 cases it happens to produce.
// `serve` sat in this array for the whole review because the per-case scan
// never named it, and the cases that drew it only survived by accident.
assertTokensSafe(tokens, 'fuzz token pool')
assertFuzzPoolDeclaredReadOnly(tokens)
for (let index = 0; index < 400; index += 1) {
const length = 1 + Math.floor(next() * 4)
const argv = []
for (let part = 0; part < length; part += 1) {
argv.push(tokens[Math.floor(next() * tokens.length)])
}
cases.push([argv, isolated])
}
for (const [argv] of cases) {
assertTokensSafe(argv, `argv ${JSON.stringify(argv)}`)
}
return cases
}
// Why: this script shells out to the REAL CLI with the developer's own HOME and
// PATH, so an argv that reaches the wrong verb does real damage. Two classes:
//
// 1. FOREGROUND — `orca serve` runs Orca until Ctrl+C and `orca open` /
// `claude-teams` spawn processes that outlive the case. A blocking case
// does not fail the run, it stalls it, which is worse than a mismatch.
// 2. MUTATING — writes outside ORCA_USER_DATA_PATH (`agent hooks off` parks
// the real ~/.claude hooks) or drives real browser/desktop input.
//
// Group tokens whose subcommands split read/write (`capture`, `intercept`,
// `label`, `relation`) are denied wholesale: the fuzzer cannot tell them apart.
const FOREGROUND_TOKENS = ['serve', 'open', 'claude-teams', 'exec', 'eval', 'launch', 'attach']
const MUTATING_TOKENS = [
// persistent config and registry state
'on',
'off',
'hooks',
'create',
'remove',
'rm',
'delete',
'add',
'edit',
'set',
'set-value',
'set-base-ref',
'setup-clone',
'setup-create',
'setup-update',
'setup-delete',
'setup-existing-folder',
'install',
'uninstall',
'reinstall',
'update',
'clone',
'apply',
'write',
'reset',
'clear',
'enable',
'disable',
'use-default',
'permissions',
// process and pane lifecycle
'run',
'run-stop',
'start',
'stop',
'kill',
'shutdown',
'close',
'split',
'switch',
'rename',
'focus',
// orchestration writes
'send',
'reply',
'dispatch',
'task-create',
'task-update',
'gate-create',
'gate-resolve',
// issue-tracker writes
'save-issue',
'comment',
'label',
'relation',
'assignee',
'priority',
'estimate',
'due-date',
// browser / computer / emulator input and navigation
'goto',
'back',
'forward',
'reload',
'click',
'dblclick',
'hover',
'fill',
'type',
'type-text',
'select',
'select-all',
'uncheck',
'keypress',
'press-key',
'inserttext',
'hotkey',
'paste-text',
'perform-secondary-action',
'drag',
'scroll',
'scrollintoview',
'wheel',
'move',
'up',
'down',
'tap',
'gesture',
'button',
'rotate',
'upload',
'download',
'dismiss',
'accept',
'highlight',
'viewport',
'geolocation',
'headers',
'credentials',
'offline',
'media',
'device',
'capture',
'intercept'
]
const UNSAFE_TOKENS = new Map([
...FOREGROUND_TOKENS.map((token) => [token, 'runs in the foreground or spawns a process']),
...MUTATING_TOKENS.map((token) => [token, 'can write outside ORCA_USER_DATA_PATH'])
])
// Why: the deny list only catches verbs someone already thought of — `serve`
// sat in the fuzz pool for the whole review because nobody added it. The pool
// is therefore ALSO checked against this allowlist, so a token added to the
// pool fails closed until it is consciously declared read-only here.
const READ_ONLY_FUZZ_TOKENS = new Set([
// command tokens: every path they can form is a list/show or a parse error
'agent',
'agent-context',
'browser',
'environment',
'list',
'snapshot',
'status',
'terminal',
'vm',
'worktree',
// global flags and hostile strings, which reach no handler at all
'--json',
'--help',
'--pairing-code',
'--environment',
'--worktree',
'orca://pair?code=!!!',
'',
'-',
'--',
'--=',
'a'.repeat(300),
'näme-ünicode',
'💥',
'../..',
'x\ty'
])
function assertTokensSafe(tokens, context) {
for (const token of tokens) {
const reason = UNSAFE_TOKENS.get(token)
if (reason) {
throw new Error(`Refusing to run ${context}: "${token}" ${reason}`)
}
}
}
// Why: a token on neither list (or, worse, on both) means the two lists have
// drifted apart. Fail before any case runs rather than sampling and hoping.
function assertFuzzPoolDeclaredReadOnly(tokens) {
for (const token of tokens) {
if (!READ_ONLY_FUZZ_TOKENS.has(token)) {
throw new Error(
`Fuzz token ${JSON.stringify(token)} is not declared in READ_ONLY_FUZZ_TOKENS — declare it read-only or drop it`
)
}
}
for (const token of READ_ONLY_FUZZ_TOKENS) {
if (UNSAFE_TOKENS.has(token)) {
throw new Error(
`Token ${JSON.stringify(token)} is declared both read-only and unsafe — the two lists disagree`
)
}
}
}
const baselineIndex = process.argv.indexOf('--baseline')
const baselineRev = baselineIndex === -1 ? 'HEAD' : process.argv[baselineIndex + 1]
assertMarkersFresh()
const isolatedUserData = mkdtempSync(join(REPO, '.equiv-userdata-'))
mkdirSync(join(isolatedUserData, 'empty'), { recursive: true })
let oldEntry
let newEntry
try {
// Why: build the case list (and run its safety guards) BEFORE the two tsc
// compiles, so an unsafe token fails in a second instead of two minutes in.
const cases = buildCases(isolatedUserData)
console.log(`Building baseline (${baselineRev}) …`)
oldEntry = buildTree('old', baselineRev)
console.log('Building working tree …')
newEntry = buildTree('new', null)
console.log(`Comparing ${cases.length} invocations byte for byte …`)
let mismatches = 0
for (const [argv, env] of cases) {
const before = run(oldEntry, argv, env)
const after = run(newEntry, argv, env)
if (
before.status !== after.status ||
before.stdout !== after.stdout ||
before.stderr !== after.stderr
) {
mismatches += 1
console.error(`\nMISMATCH argv=${JSON.stringify(argv)} env=${JSON.stringify(env)}`)
console.error(` exit before=${before.status} after=${after.status}`)
if (before.stdout !== after.stdout) {
console.error(` stdout before=${JSON.stringify(before.stdout.slice(0, 400))}`)
console.error(` after =${JSON.stringify(after.stdout.slice(0, 400))}`)
}
if (before.stderr !== after.stderr) {
console.error(` stderr before=${JSON.stringify(before.stderr.slice(0, 400))}`)
console.error(` after =${JSON.stringify(after.stderr.slice(0, 400))}`)
}
}
}
if (mismatches > 0) {
throw new Error(`${mismatches} of ${cases.length} invocations differ`)
}
console.log(`\nAll ${cases.length} invocations byte-identical (stdout, stderr, exit code).`)
} finally {
rmSync(isolatedUserData, { recursive: true, force: true })
for (const label of ['old', 'new']) {
rmSync(resolve(REPO, `.equiv-out-${label}`), { recursive: true, force: true })
}
}
@@ -0,0 +1,244 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { lstat, readdir } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
const DEFAULT_INTERVAL_MS = 250
function entryType(stat) {
if (stat.isDirectory()) {
return 'directory'
}
if (stat.isFile()) {
return 'file'
}
if (stat.isSymbolicLink()) {
return 'symlink'
}
if (stat.isSocket()) {
return 'socket'
}
return 'other'
}
async function readEntry(absolutePath, relativePath, entries) {
const stat = await lstat(absolutePath)
const type = entryType(stat)
entries.push({
path: relativePath || '.',
type,
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
ino: stat.ino
})
// Why: primary-home symlinks may point at user data outside .codex. The
// tripwire observes the link itself without traversing its target.
if (type !== 'directory') {
return
}
const children = await readdir(absolutePath)
children.sort((left, right) => left.localeCompare(right))
for (const child of children) {
await readEntry(
path.join(absolutePath, child),
relativePath ? path.join(relativePath, child) : child,
entries
)
}
}
export async function snapshotCodexHome(codexHome) {
const resolvedHome = path.resolve(codexHome)
const entries = []
try {
await readEntry(resolvedHome, '', entries)
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error
}
entries.push({ path: '.', type: 'missing' })
}
const digest = createHash('sha256').update(JSON.stringify(entries)).digest('hex')
return { codexHome: resolvedHome, digest, entryCount: entries.length, entries }
}
export function describeCodexHomeChange(before, after) {
if (before.digest === after.digest) {
return null
}
const beforeByPath = new Map(before.entries.map((entry) => [entry.path, entry]))
const afterByPath = new Map(after.entries.map((entry) => [entry.path, entry]))
const changedPaths = [...new Set([...beforeByPath.keys(), ...afterByPath.keys()])]
.filter(
(entryPath) =>
JSON.stringify(beforeByPath.get(entryPath)) !== JSON.stringify(afterByPath.get(entryPath))
)
.sort((left, right) => left.localeCompare(right))
return {
beforeDigest: before.digest,
afterDigest: after.digest,
changedPaths
}
}
// Why: with the real-home flag ON, system-default spawn sites deliberately
// delete CODEX_HOME so native codex resolves the user's real ~/.codex — and on
// Windows the binary ignores the USERPROFILE sandbox, so its own volatile
// runtime churn (root sqlite/WAL/SHM, tmp/, log/) is designed behavior no env
// sandbox can contain. Everything else — auth.json, config.toml,
// .credentials.json, hooks.json, sessions/, any unknown path — stays a hard
// containment violation.
const DESIGNED_SYSTEM_DEFAULT_VOLATILE_PATTERN =
/^(?:[^\\/]+\.sqlite(?:-wal|-shm|-journal)?|tmp(?:[\\/].*)?|log(?:[\\/].*)?)$/i
export function classifyCodexHomeTripwireEvent(event) {
if (event.scanError || !Array.isArray(event.changedPaths)) {
return 'violation'
}
// Why: the root directory's own mtime churns whenever codex adds/removes a
// direct child (e.g. a WAL file); it carries no information beyond the
// substantive paths, so it never decides the classification by itself.
const substantive = event.changedPaths.filter((entryPath) => entryPath !== '.')
if (substantive.length === 0) {
return 'designed-system-default'
}
return substantive.every((entryPath) => DESIGNED_SYSTEM_DEFAULT_VOLATILE_PATTERN.test(entryPath))
? 'designed-system-default'
: 'violation'
}
export async function startCodexPrimaryHomeTripwire(options = {}) {
const primaryHome = path.resolve(options.primaryHome ?? os.homedir())
const codexHome = path.join(primaryHome, '.codex')
const intervalMs = Math.max(25, Number(options.intervalMs ?? DEFAULT_INTERVAL_MS))
const initialSnapshot = await snapshotCodexHome(codexHome)
let currentSnapshot = initialSnapshot
let timer = null
let scanInFlight = false
let stopped = false
const events = []
const scan = async () => {
if (stopped || scanInFlight) {
return
}
scanInFlight = true
try {
const nextSnapshot = await snapshotCodexHome(codexHome)
const change = describeCodexHomeChange(currentSnapshot, nextSnapshot)
if (change) {
const event = { detectedAt: new Date().toISOString(), ...change }
events.push(event)
currentSnapshot = nextSnapshot
await options.onChange?.(event)
}
} catch (error) {
const event = {
detectedAt: new Date().toISOString(),
scanError: error instanceof Error ? error.message : String(error),
changedPaths: []
}
events.push(event)
await options.onChange?.(event)
} finally {
scanInFlight = false
}
}
timer = setInterval(() => void scan(), intervalMs)
// Why: this safety monitor must remain active even when it is the CLI's only open handle.
// Callers explicitly stop it after their validation window closes.
return {
codexHome,
initialSnapshot,
getStatus: () => ({ clean: events.length === 0, events: [...events] }),
scan,
async stop() {
if (timer) {
clearInterval(timer)
}
while (scanInFlight) {
await new Promise((resolve) => setTimeout(resolve, 5))
}
await scan()
stopped = true
return { clean: events.length === 0, events: [...events] }
}
}
}
function parseArgs(argv) {
const options = { primaryHome: os.homedir(), intervalMs: DEFAULT_INTERVAL_MS, once: false }
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--primary-home') {
const value = argv[index + 1]
if (!value) {
throw new Error('Missing value for --primary-home')
}
options.primaryHome = value
index += 1
} else if (arg === '--interval-ms') {
const value = Number(argv[index + 1])
if (!Number.isFinite(value) || value < 25) {
throw new Error('Invalid --interval-ms')
}
options.intervalMs = value
index += 1
} else if (arg === '--once') {
options.once = true
} else if (arg === '--help') {
console.log(
'Usage: node config/scripts/codex-primary-home-tripwire.mjs [--primary-home <path>] [--interval-ms <n>] [--once]'
)
process.exit(0)
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
return options
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (options.once) {
const snapshot = await snapshotCodexHome(path.join(options.primaryHome, '.codex'))
console.log(JSON.stringify({ ...snapshot, entries: undefined }, null, 2))
return
}
let failed = false
const tripwire = await startCodexPrimaryHomeTripwire({
...options,
onChange: (event) => {
failed = true
console.error('\u001b[31;1m[CODEX HOME TRIPWIRE] PRIMARY ~/.codex CHANGED\u001b[0m')
console.error(JSON.stringify(event, null, 2))
}
})
console.log(
`[CODEX HOME TRIPWIRE] Watching ${tripwire.codexHome}; press Ctrl+C to stop. No file contents are read.`
)
const stop = async () => {
const status = await tripwire.stop()
console.log(
`[CODEX HOME TRIPWIRE] ${status.clean ? 'PASS: no changes' : 'FAIL: changes detected'}`
)
process.exit(failed || !status.clean ? 2 : 0)
}
process.once('SIGINT', () => void stop())
process.once('SIGTERM', () => void stop())
}
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null
if (invokedPath === import.meta.url) {
main().catch((error) => {
console.error(error)
process.exit(1)
})
}
@@ -0,0 +1,178 @@
import { execFileSync, spawn } from 'node:child_process'
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const cleanupPaths: string[] = []
const tripwireScriptPath = path.resolve('config/scripts/codex-primary-home-tripwire.mjs')
const tripwireModuleUrl = pathToFileURL(tripwireScriptPath).href
// Why: native Windows process startup can exceed two seconds under a loaded test worker.
const STANDALONE_START_TIMEOUT_MS = 10_000
afterEach(async () => {
await Promise.all(
cleanupPaths.splice(0).map((cleanupPath) => rm(cleanupPath, { recursive: true }))
)
})
async function createPrimaryHome(): Promise<string> {
const primaryHome = await mkdtemp(path.join(os.tmpdir(), 'orca-codex-tripwire-'))
cleanupPaths.push(primaryHome)
await mkdir(path.join(primaryHome, '.codex'))
return primaryHome
}
describe('Codex primary-home tripwire', () => {
it('keeps the standalone monitor alive until it is stopped', async () => {
const primaryHome = await createPrimaryHome()
const child = spawn(process.execPath, [tripwireScriptPath, '--primary-home', primaryHome], {
stdio: ['ignore', 'pipe', 'pipe']
})
try {
await waitForStdout(child, '[CODEX HOME TRIPWIRE] Watching')
await new Promise((resolve) => setTimeout(resolve, 100))
expect(child.exitCode).toBeNull()
} finally {
child.kill()
}
})
it('detects a write without reading file contents', async () => {
const primaryHome = await createPrimaryHome()
const status = runTripwireModule<{
clean: boolean
events: { changedPaths: string[] }[]
}>(
`
import path from 'node:path'
import { writeFile } from 'node:fs/promises'
const { startCodexPrimaryHomeTripwire } = await import(process.argv[1])
const tripwire = await startCodexPrimaryHomeTripwire({
primaryHome: process.argv[2],
intervalMs: 25
})
await writeFile(path.join(process.argv[2], '.codex', 'hooks.json'), '{"secret":"not-read"}\\n')
await tripwire.scan()
console.log(JSON.stringify(await tripwire.stop()))
`,
[primaryHome]
)
expect(status.clean).toBe(false)
expect(status.events[0].changedPaths).toContain('hooks.json')
expect(JSON.stringify(status)).not.toContain('not-read')
})
// Why: ordinary Windows CI tokens cannot create file symlinks without Developer Mode.
it.skipIf(process.platform === 'win32')(
'does not follow symlinks outside the watched home',
async () => {
const primaryHome = await createPrimaryHome()
const externalRoot = await mkdtemp(path.join(os.tmpdir(), 'orca-codex-tripwire-external-'))
cleanupPaths.push(externalRoot)
const externalFile = path.join(externalRoot, 'credential.txt')
await writeFile(externalFile, 'before')
await symlink(externalFile, path.join(primaryHome, '.codex', 'linked-credential'))
const codexHome = path.join(primaryHome, '.codex')
const before = snapshotCodexHome(codexHome)
await writeFile(externalFile, 'after-with-a-different-size')
const after = snapshotCodexHome(codexHome)
expect(after.digest).toBe(before.digest)
}
)
})
function runTripwireModule<T>(source: string, args: string[]): T {
const stdout = execFileSync(
process.execPath,
['--input-type=module', '--eval', source, tripwireModuleUrl, ...args],
{ encoding: 'utf8' }
)
return JSON.parse(stdout.trim()) as T
}
function snapshotCodexHome(codexHome: string): { digest: string } {
return runTripwireModule<{ digest: string }>(
`
const { snapshotCodexHome } = await import(process.argv[1])
console.log(JSON.stringify(await snapshotCodexHome(process.argv[2])))
`,
[codexHome]
)
}
function waitForStdout(child: ReturnType<typeof spawn>, expectedText: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out waiting for ${expectedText}`)),
STANDALONE_START_TIMEOUT_MS
)
let output = ''
const stdout = child.stdout
if (!stdout) {
clearTimeout(timeout)
reject(new Error('Tripwire child stdout is unavailable'))
return
}
stdout.setEncoding('utf8')
stdout.on('data', (chunk) => {
output += chunk
if (output.includes(expectedText)) {
clearTimeout(timeout)
resolve()
}
})
child.once('exit', (code) => {
clearTimeout(timeout)
reject(new Error(`Tripwire exited early with code ${code}`))
})
})
}
describe('lane-aware tripwire event classification', () => {
it('separates designed system-default churn from containment violations', () => {
const verdicts = runTripwireModule<string[]>(
`
const { classifyCodexHomeTripwireEvent } = await import(process.argv[1])
const cases = [
{ changedPaths: ['.', 'goals_1.sqlite', 'logs_2.sqlite-wal', 'state_5.sqlite-shm', 'memories_1.sqlite-journal', 'tmp', 'tmp/arg0', 'tmp\\\\arg0', 'log', 'log/codex-tui.log'] },
{ changedPaths: ['.'] },
{ changedPaths: ['.', 'auth.json'] },
{ changedPaths: ['auth.json'] },
{ changedPaths: ['config.toml'] },
{ changedPaths: ['.credentials.json'] },
{ changedPaths: ['hooks.json'] },
{ changedPaths: ['sessions/2026/07/rollout-x.jsonl'] },
{ changedPaths: ['skills/SKILL.md'] },
{ changedPaths: ['unknown-file.bin'] },
{ changedPaths: ['goals_1.sqlite', 'auth.json'] },
{ changedPaths: ['sessions/index.sqlite-wal'] },
{ scanError: 'boom', changedPaths: [] },
{}
]
console.log(JSON.stringify(cases.map((tripwireEvent) => classifyCodexHomeTripwireEvent(tripwireEvent))))
`,
[]
)
expect(verdicts).toEqual([
'designed-system-default',
'designed-system-default',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation'
])
})
})
@@ -0,0 +1,186 @@
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
const GRACEFUL_CLOSE_TIMEOUT_MS = 10_000
const PROCESS_EXIT_TIMEOUT_MS = 5_000
const FORCE_KILL_WAIT_MS = 2_000
function delay(ms) {
// Why: after Electron dies, this may be the standalone harness's only active
// handle; keeping it referenced ensures daemon cleanup reaches completion.
return new Promise((resolve) => setTimeout(resolve, ms))
}
function hasExited(childProcess) {
return childProcess.exitCode !== null || childProcess.signalCode !== null
}
function waitForExit(childProcess, timeoutMs) {
if (hasExited(childProcess)) {
return Promise.resolve(true)
}
return new Promise((resolve) => {
let settled = false
const finish = (exited) => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
childProcess.off('exit', onExit)
childProcess.off('close', onExit)
resolve(exited)
}
const onExit = () => finish(true)
const timeout = setTimeout(() => finish(false), timeoutMs)
childProcess.once('exit', onExit)
childProcess.once('close', onExit)
})
}
async function withTimeout(promise, timeoutMs, message) {
let timeout = null
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error(message)), timeoutMs)
})
])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
}
function readPosixDescendantPids(rootPid) {
try {
const output = execFileSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8' })
const childrenByParent = new Map()
for (const line of output.split('\n')) {
const [pidText, ppidText] = line.trim().split(/\s+/)
const pid = Number(pidText)
const ppid = Number(ppidText)
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) {
continue
}
const children = childrenByParent.get(ppid) ?? []
children.push(pid)
childrenByParent.set(ppid, children)
}
const descendants = []
const stack = [...(childrenByParent.get(rootPid) ?? [])]
while (stack.length > 0) {
const pid = stack.pop()
if (!pid) {
continue
}
descendants.push(pid)
stack.push(...(childrenByParent.get(pid) ?? []))
}
return descendants
} catch {
return []
}
}
function killPid(pid, signal) {
try {
process.kill(pid, signal)
} catch {
/* already dead or inaccessible */
}
}
async function forceKillPidTree(pid) {
if (!pid) {
return
}
if (process.platform === 'win32') {
try {
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
/* already dead or taskkill unavailable */
}
return
}
// Why: a detached validation daemon can outlive Electron and retain access
// to the disposable credential root unless the whole tree is captured first.
const pids = [...readPosixDescendantPids(pid), pid]
for (const targetPid of [...pids].toReversed()) {
killPid(targetPid, 'SIGTERM')
}
await delay(FORCE_KILL_WAIT_MS)
for (const targetPid of [...pids].toReversed()) {
killPid(targetPid, 'SIGKILL')
}
}
export async function closeValidationElectronApp(app) {
if (!app) {
return
}
const childProcess = app.process()
try {
await withTimeout(
app.close(),
GRACEFUL_CLOSE_TIMEOUT_MS,
'Timed out closing validation Electron app'
)
if (childProcess) {
const exited = await waitForExit(childProcess, PROCESS_EXIT_TIMEOUT_MS)
if (!exited) {
await forceKillPidTree(childProcess.pid)
await waitForExit(childProcess, PROCESS_EXIT_TIMEOUT_MS)
}
}
} catch {
if (childProcess && !hasExited(childProcess)) {
await forceKillPidTree(childProcess.pid)
await waitForExit(childProcess, PROCESS_EXIT_TIMEOUT_MS)
}
}
}
function readValidationDaemonPids(userDataDir) {
const daemonDir = path.join(userDataDir, 'daemon')
if (!existsSync(daemonDir)) {
return []
}
const pids = []
for (const entry of readdirSync(daemonDir)) {
if (!entry.endsWith('.pid')) {
continue
}
try {
const raw = readFileSync(path.join(daemonDir, entry), 'utf8').trim()
const parsed = JSON.parse(raw)
if (typeof parsed.pid === 'number' && Number.isInteger(parsed.pid)) {
pids.push(parsed.pid)
}
} catch {
const pid = Number(readFileSync(path.join(daemonDir, entry), 'utf8').trim())
if (Number.isInteger(pid)) {
pids.push(pid)
}
}
}
return pids
}
export async function cleanupValidationDaemons(userDataDir) {
// Why: Orca intentionally keeps its daemon warm after app quit, but this
// harness must never leave a process holding disposable account credentials.
for (const pid of readValidationDaemonPids(userDataDir)) {
await forceKillPidTree(pid)
}
}
@@ -0,0 +1,427 @@
#!/usr/bin/env node
// Benchmark: cost of resolving a Command Code turn prompt from the transcript,
// paid on EVERY command-code hook event (PreToolUse/PostToolUse fire once per
// tool call, so many per second during an active agent turn).
//
// Before the fix, readLastCommandCodeUserPromptEntryFromTranscript() read up to
// TRANSCRIPT_MAX_SCAN_BYTES (4 MB) synchronously, decoded it all to a JS string,
// and JSON-parsed EVERY line to the end of the buffer to find the LAST user
// entry — so cost grew with the transcript, which only grows as a session runs.
//
// The fix scans backward from EOF in TRANSCRIPT_CHUNK_BYTES blocks and returns
// on the first user line, the shape the sibling readLastTextFromTranscriptOnce
// already used. The answer sits near EOF in a real session (the current turn's
// prompt precedes only this turn's output), so the scan reads one or two blocks
// instead of the whole file.
//
// Both implementations are mirrored here: node cannot import the .ts source,
// matching the other benchmarks in this directory. Constants are re-read from
// the real module so a drifted cap fails loudly instead of measuring dead code.
import {
closeSync,
mkdtempSync,
openSync,
readFileSync,
readSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const LISTENER_SOURCE = readFileSync(
fileURLToPath(new URL('../../src/shared/agent-hook-listener.ts', import.meta.url)),
'utf8'
)
function readMirroredConstant(name) {
const match = LISTENER_SOURCE.match(new RegExp(`const ${name} = ([^\\n]+)`))
if (!match) {
throw new Error(`agent-hook-listener.ts no longer defines ${name}; re-sync this benchmark.`)
}
const value = Number(new Function(`return (${match[1]})`)())
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} did not resolve to a positive integer`)
}
return value
}
const TRANSCRIPT_CHUNK_BYTES = readMirroredConstant('TRANSCRIPT_CHUNK_BYTES')
const TRANSCRIPT_MAX_SCAN_BYTES = readMirroredConstant('TRANSCRIPT_MAX_SCAN_BYTES')
const EMPTY_REGION = Buffer.alloc(0)
const ITERATIONS = Number.parseInt(process.env.ORCA_CC_SCAN_BENCH_ITERATIONS ?? '150', 10)
const WARMUP = Number.parseInt(process.env.ORCA_CC_SCAN_BENCH_WARMUP ?? '20', 10)
for (const [name, value] of [
['ORCA_CC_SCAN_BENCH_ITERATIONS', ITERATIONS],
['ORCA_CC_SCAN_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
// Mirror of parseAgentHookJson: the real reader scans a line's structure before
// parsing it, on BOTH sides of this comparison. Omitting it made the pre-fix
// column ~9x too fast and invented a regression that does not exist.
const HOOK_STRUCTURAL_TOKENS = 128 * 1024
const HOOK_NESTING_DEPTH = 64
function assertJsonStructure(content) {
let structuralTokens = 0
let depth = 0
let inString = false
let escaped = false
for (let index = 0; index < content.length; index += 1) {
const character = content[index]
if (inString) {
if (escaped) {
escaped = false
} else if (character === '\\') {
escaped = true
} else if (character === '"') {
inString = false
}
continue
}
if (character === '"') {
inString = true
continue
}
if (
character !== '{' &&
character !== '}' &&
character !== '[' &&
character !== ']' &&
character !== ',' &&
character !== ':'
) {
continue
}
structuralTokens += 1
if (structuralTokens > HOOK_STRUCTURAL_TOKENS) {
throw new Error('structuralTokens')
}
if (character === '{' || character === '[') {
depth += 1
if (depth > HOOK_NESTING_DEPTH) {
throw new Error('nestingDepth')
}
} else if (character === '}' || character === ']') {
depth = Math.max(0, depth - 1)
}
}
}
function extractUserPrompt(line) {
let entry
try {
assertJsonStructure(line)
entry = JSON.parse(line)
} catch {
return undefined
}
if (typeof entry !== 'object' || entry === null || entry.role !== 'user') {
return undefined
}
const content = entry.content
if (typeof content === 'string' && content.trim().length > 0) {
return content
}
if (Array.isArray(content)) {
for (const part of content) {
if (typeof part === 'object' && part !== null) {
const text = part.text
if (typeof text === 'string' && text.trim().length > 0) {
return text
}
}
}
}
return undefined
}
// Pre-fix: read the capped window, then parse every line to the end.
function readForward(path) {
const size = statSync(path).size
if (size <= 0) {
return undefined
}
const bytesToRead = Math.min(size, TRANSCRIPT_MAX_SCAN_BYTES)
const position = size - bytesToRead
const fd = openSync(path, 'r')
try {
const buffer = Buffer.alloc(bytesToRead)
let filled = 0
while (filled < bytesToRead) {
const n = readSync(fd, buffer, filled, bytesToRead - filled, position + filled)
if (n === 0) {
break
}
filled += n
}
let text = buffer.subarray(0, filled).toString('utf8')
if (position > 0) {
const firstNewline = text.indexOf('\n')
text = firstNewline === -1 ? '' : text.slice(firstNewline + 1)
}
let last
for (const line of text.split('\n')) {
const prompt = extractUserPrompt(line.trim())
if (prompt !== undefined) {
last = prompt
}
}
return last
} finally {
closeSync(fd)
}
}
function findLastPromptInRegion(region) {
let lineEnd = region.length
for (let index = region.length - 1; index >= -1; index--) {
if (index >= 0 && region[index] !== 0x0a) {
continue
}
const lineStart = index + 1
if (lineEnd > lineStart) {
const prompt = extractUserPrompt(region.subarray(lineStart, lineEnd).toString('utf8').trim())
if (prompt !== undefined) {
return prompt
}
}
lineEnd = index
}
return undefined
}
// Post-fix: walk backward from EOF, return on the first user line. The carry is
// a chunk list, not a re-joined buffer, so one oversized line stays linear.
function readBackward(path) {
const size = statSync(path).size
if (size <= 0) {
return undefined
}
const fd = openSync(path, 'r')
try {
let carryChunks = []
let bytesRead = 0
let scanEnd = size
while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) {
const chunkSize = Math.min(
scanEnd,
TRANSCRIPT_CHUNK_BYTES,
TRANSCRIPT_MAX_SCAN_BYTES - bytesRead
)
const position = scanEnd - chunkSize
const buffer = Buffer.alloc(chunkSize)
let filled = 0
while (filled < chunkSize) {
const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled)
if (n === 0) {
break
}
filled += n
}
if (filled < chunkSize) {
break
}
bytesRead += filled
scanEnd = position
const firstNewline = buffer.indexOf(0x0a)
const atStart = position === 0
let completeRegion
if (atStart) {
completeRegion = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
carryChunks = []
} else if (firstNewline === -1) {
completeRegion = EMPTY_REGION
carryChunks.unshift(buffer)
} else {
const afterNewline = buffer.subarray(firstNewline + 1)
completeRegion =
carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks])
carryChunks = [buffer.subarray(0, firstNewline)]
}
if (completeRegion.length > 0) {
const found = findLastPromptInRegion(completeRegion)
if (found !== undefined) {
return found
}
}
}
return undefined
} finally {
closeSync(fd)
}
}
// A real session: many completed turns, then THIS turn's prompt, then the tool
// output produced since. The prompt therefore sits near EOF.
function writeTranscript(path, priorTurns) {
const lines = []
for (let index = 0; index < priorTurns; index += 1) {
lines.push(
JSON.stringify({ role: 'user', content: [{ type: 'text', text: `older turn ${index}` }] })
)
lines.push(
JSON.stringify({
role: 'assistant',
content: [{ type: 'text', text: `${'assistant output '.repeat(30)}${index}` }]
})
)
}
lines.push(
JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] })
)
for (let index = 0; index < 40; index += 1) {
lines.push(
JSON.stringify({
role: 'assistant',
content: [{ type: 'text', text: `${'current turn output '.repeat(30)}${index}` }]
})
)
}
writeFileSync(path, `${lines.join('\n')}\n`)
}
// A turn already in progress: `trailingBytes` of tool output sits between the
// prompt and EOF, which is what the backward scan has to read past.
function writeTranscriptWithTrailing(path, priorTurns, trailingBytes) {
const lines = []
for (let index = 0; index < priorTurns; index += 1) {
lines.push(
JSON.stringify({ role: 'user', content: [{ type: 'text', text: `older turn ${index}` }] })
)
lines.push(
JSON.stringify({
role: 'assistant',
content: [{ type: 'text', text: `${'assistant output '.repeat(30)}${index}` }]
})
)
}
lines.push(
JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] })
)
let written = 0
let index = 0
while (written < trailingBytes) {
const line = JSON.stringify({
role: 'assistant',
content: [{ type: 'text', text: `${'current turn output '.repeat(30)}${index}` }]
})
lines.push(line)
written += line.length + 1
index += 1
}
writeFileSync(path, `${lines.join('\n')}\n`)
}
// One tool result larger than many read blocks — the shape with no newline for
// the backward scan to stop on.
function writeTranscriptWithHugeLine(path, lineBytes) {
const lines = [
JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] }),
JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'x'.repeat(lineBytes) }] })
]
writeFileSync(path, `${lines.join('\n')}\n`)
}
function measure(fn, path) {
for (let index = 0; index < WARMUP; index += 1) {
fn(path)
}
const samples = []
for (let round = 0; round < 3; round += 1) {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
fn(path)
}
samples.push((performance.now() - start) / ITERATIONS)
}
samples.sort((a, b) => a - b)
return samples[1]
}
const dir = mkdtempSync(join(tmpdir(), 'orca-cc-transcript-bench-'))
try {
const rows = []
for (const priorTurns of [250, 1000, 3000, 6000]) {
const path = join(dir, `transcript-${priorTurns}.jsonl`)
writeTranscript(path, priorTurns)
const forward = readForward(path)
const backward = readBackward(path)
if (forward !== backward) {
throw new Error(`prompt mismatch at ${priorTurns} prior turns: ${forward} vs ${backward}`)
}
if (backward !== 'the current prompt') {
throw new Error(`benchmark fixture resolved the wrong prompt: ${backward}`)
}
rows.push({
sizeMb: statSync(path).size / (1024 * 1024),
beforeMs: measure(readForward, path),
afterMs: measure(readBackward, path)
})
}
const pad = (value, width) => String(value).padStart(width)
console.log('Command Code transcript prompt read, per hook event')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 3 rounds)`)
console.log(
`${pad('size', 9)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('speedup', 9)}`
)
for (const row of rows) {
console.log(
`${pad(`${row.sizeMb.toFixed(2)} MB`, 9)} ${pad(row.beforeMs.toFixed(3), 11)} ${pad(row.afterMs.toFixed(3), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(0)}x`, 9)}`
)
}
console.log(
'\nThe old cost grows with the transcript; the new cost is flat because the\ncurrent turn’s prompt sits near EOF and the scan stops at the first hit.'
)
// Worst cases, reported even where the ratio is below 1x. The new cost scales
// with bytes-AFTER the prompt, so a long turn (many tool calls since the ask)
// and a single oversized tool result are where the win decays or inverts.
const worst = []
for (const trailingKb of [32, 256, 1024, 3072]) {
const path = join(dir, `trailing-${trailingKb}.jsonl`)
writeTranscriptWithTrailing(path, 1500, trailingKb * 1024)
if (readForward(path) !== readBackward(path)) {
throw new Error(`prompt mismatch at trailing ${trailingKb} KB`)
}
worst.push({
label: `${(trailingKb / 1024).toFixed(2)} MB after prompt`,
beforeMs: measure(readForward, path),
afterMs: measure(readBackward, path)
})
}
const hugePath = join(dir, 'huge-line.jsonl')
writeTranscriptWithHugeLine(hugePath, 3 * 1024 * 1024)
if (readForward(hugePath) !== readBackward(hugePath)) {
throw new Error('prompt mismatch on the oversized-line fixture')
}
worst.push({
label: '3 MB single line',
beforeMs: measure(readForward, hugePath),
afterMs: measure(readBackward, hugePath)
})
console.log('\nWorst cases (win decays as a turn progresses; <1x means slower):')
console.log(
`${pad('case', 22)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('ratio', 9)}`
)
for (const row of worst) {
console.log(
`${pad(row.label, 22)} ${pad(row.beforeMs.toFixed(3), 11)} ${pad(row.afterMs.toFixed(3), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(2)}x`, 9)}`
)
}
console.log(
'\nThe win shrinks toward parity as output accumulates after the prompt, since\nthe backward scan has to read past all of it. The single-line row is the floor:\nno newline to stop on, so the scan reads the line in blocks and joins once where\nthe old code issued one flat read. Both sides pay the same per-line structure\nscan, and the carry is a chunk list, so cost stays linear either way.'
)
} finally {
rmSync(dir, { recursive: true, force: true })
}
@@ -3,11 +3,15 @@ import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
const skillPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
// Why: computer-use now ships a hybrid discovery stub, so its version-sensitive command
// guidance lives in the authoritative guide source — assert that content there. The
// installable stub projection is checked separately below.
const guidePath = join(projectDir, 'skill-guides', 'computer-use.md')
const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
describe('computer-use skill guidance', () => {
it('keeps web-app targeting on the computer-use surface', () => {
const skill = readFileSync(skillPath, 'utf8')
const skill = readFileSync(guidePath, 'utf8')
expect(skill).toContain('Use this skill for desktop UI through `orca computer`')
expect(skill).toContain('operate the desktop browser app/window that contains the page')
@@ -19,7 +23,7 @@ describe('computer-use skill guidance', () => {
})
it('warns agents to verify browser-hosted form focus before drafting text', () => {
const skill = readFileSync(skillPath, 'utf8')
const skill = readFileSync(guidePath, 'utf8')
expect(skill).toContain('For browser-hosted forms such as Gmail compose')
expect(skill).toContain('verify the focused UI element after each field action')
@@ -27,7 +31,7 @@ describe('computer-use skill guidance', () => {
})
it('warns agents about occluded Linux and Windows screenshots', () => {
const skill = readFileSync(skillPath, 'utf8')
const skill = readFileSync(guidePath, 'utf8')
expect(skill).toContain('On Linux and Windows')
expect(skill).toContain('use `--restore-window` so another window does not cover')
@@ -35,9 +39,50 @@ describe('computer-use skill guidance', () => {
})
it('points JSON users to the public accessibility-tree field', () => {
const skill = readFileSync(skillPath, 'utf8')
const skill = readFileSync(guidePath, 'utf8')
expect(skill).toContain('`result.snapshot.treeText`')
expect(skill).not.toContain('`result.elements`')
})
})
describe('computer-use install stub', () => {
it('points at the version-matched guide and preserves the safe resolver', () => {
const stub = readFileSync(stubPath, 'utf8')
expect(stub).toContain('discovery stub')
expect(stub).toContain('ORCA skills get computer-use')
// The safe CLI-resolution contract must survive in the stub, never a bare `orca`.
expect(stub).toContain('ORCA_CLI_COMMAND')
expect(stub).toContain('orca-dev')
expect(stub).toContain('orca-ide')
expect(stub).toContain('GNOME Orca screen reader')
expect(stub).not.toMatch(/^orca /mu)
})
it('gives older binaries a bounded fallback instead of a dead end', () => {
const stub = readFileSync(stubPath, 'utf8').replace(/\s+/gu, ' ')
expect(stub).toContain('explicitly reports that `skills get` is an unknown command')
expect(stub).toContain('do not invent commands')
expect(stub).toContain('ask the user rather than guessing')
})
it('drops the changing command reference from the installable file', () => {
const stub = readFileSync(stubPath, 'utf8')
const guide = readFileSync(guidePath, 'utf8')
// Version-sensitive command detail lives in the binary-served guide now, not here.
expect(stub).not.toContain('result.snapshot.treeText')
expect(stub).not.toContain('--restore-window')
expect(stub.length).toBeLessThan(guide.length)
})
it('keeps the routing frontmatter identical to the guide', () => {
const frontmatter = (text) => /^---\n[\s\S]*?\n---\n/u.exec(text)[0]
expect(frontmatter(readFileSync(stubPath, 'utf8'))).toBe(
frontmatter(readFileSync(guidePath, 'utf8'))
)
})
})
+3 -2
View File
@@ -36,10 +36,11 @@ function log(message) {
// Why: the daemon rejects a hello whose protocol version differs, so read the
// current version from source rather than hardcoding a number that can drift.
function readProtocolVersion() {
const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
const protocolSourcePath = 'src/main/daemon/daemon-protocol-version.ts'
const source = readFileSync(join(projectDir, protocolSourcePath), 'utf8')
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
if (!match) {
throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts')
throw new Error(`could not read PROTOCOL_VERSION from ${protocolSourcePath}`)
}
return Number(match[1])
}
@@ -0,0 +1,40 @@
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
function escapeWindowsBatchValue(value) {
// Why: cmd.exe expands %NAME% even inside quotes, so literal path percent signs must be doubled.
return value.replaceAll('%', '%%')
}
export function prepareDevCliTerminalWrappers({
repoRoot,
userDataPath,
electronExecutable,
platform = process.platform
}) {
const binDir = path.join(repoRoot, 'out', 'bin')
const userDataBinDir = path.join(userDataPath, 'cli', 'bin')
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
mkdirSync(binDir, { recursive: true })
mkdirSync(userDataBinDir, { recursive: true })
if (platform === 'win32') {
const wrapperContent = `@echo off\r\nset "ORCA_USER_DATA_PATH=${escapeWindowsBatchValue(userDataPath)}"\r\nset "ORCA_DEV_CLI_INVOCATION=1"\r\nset "ORCA_APP_EXECUTABLE=${escapeWindowsBatchValue(electronExecutable)}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${escapeWindowsBatchValue(cliPath)}" %*\r\n`
for (const targetDir of [binDir, userDataBinDir]) {
for (const commandName of ['orca-dev.cmd', 'orca.cmd']) {
writeFileSync(path.join(targetDir, commandName), wrapperContent, 'utf8')
}
}
} else {
const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_DEV_CLI_INVOCATION=1\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronExecutable)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`
for (const targetDir of [binDir, userDataBinDir]) {
for (const commandName of ['orca-dev', 'orca']) {
const wrapperPath = path.join(targetDir, commandName)
writeFileSync(wrapperPath, wrapperContent, 'utf8')
chmodSync(wrapperPath, 0o755)
}
}
}
return { binDir, userDataBinDir }
}
@@ -0,0 +1,70 @@
import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
describe('dev CLI terminal wrappers', () => {
it('writes profile-scoped Windows wrappers for worker terminals', () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-'))
const userDataPath = path.join(root, 'profile')
prepareDevCliTerminalWrappers({
repoRoot: root,
userDataPath,
electronExecutable: path.join(root, 'electron.exe'),
platform: 'win32'
})
const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev.cmd'), 'utf8')
expect(wrapper).toContain(`set "ORCA_USER_DATA_PATH=${userDataPath}"`)
expect(wrapper).toContain('set "ORCA_DEV_CLI_INVOCATION=1"')
expect(wrapper).toContain(`node "${path.join(root, 'out', 'cli', 'index.js')}" %*`)
expect(readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper)
expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev.cmd'), 'utf8')).toBe(wrapper)
expect(readFileSync(path.join(root, 'out', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper)
})
it('escapes literal percent signs in every Windows batch path', () => {
const root = path.join(mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-')), '%repo%')
const userDataPath = path.join(root, '%profile%')
const electronExecutable = path.join(root, '%electron%', 'electron.exe')
prepareDevCliTerminalWrappers({
repoRoot: root,
userDataPath,
electronExecutable,
platform: 'win32'
})
const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev.cmd'), 'utf8')
expect(wrapper).toContain(`set "ORCA_USER_DATA_PATH=${userDataPath.replaceAll('%', '%%')}"`)
expect(wrapper).toContain(
`set "ORCA_APP_EXECUTABLE=${electronExecutable.replaceAll('%', '%%')}"`
)
expect(wrapper).toContain(
`node "${path.join(root, 'out', 'cli', 'index.js').replaceAll('%', '%%')}" %*`
)
expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev.cmd'), 'utf8')).toBe(wrapper)
expect(readFileSync(path.join(root, 'out', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper)
})
it('writes executable-style POSIX wrappers with the same profile identity', () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-'))
const userDataPath = path.join(root, 'profile')
prepareDevCliTerminalWrappers({
repoRoot: root,
userDataPath,
electronExecutable: path.join(root, 'electron'),
platform: 'linux'
})
const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev'), 'utf8')
expect(wrapper).toContain(`export ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}`)
expect(wrapper).toContain('export ORCA_DEV_CLI_INVOCATION=1')
expect(wrapper).toContain(
`exec node ${JSON.stringify(path.join(root, 'out', 'cli', 'index.js'))}`
)
expect(readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca'), 'utf8')).toBe(wrapper)
expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev'), 'utf8')).toBe(wrapper)
expect(readFileSync(path.join(root, 'out', 'bin', 'orca'), 'utf8')).toBe(wrapper)
})
})
+137 -3
View File
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
const {
createPackagedRuntimeNodeModuleResources,
@@ -19,6 +20,12 @@ const {
} = require('../packaged-runtime-node-modules.cjs')
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
expect(electronBuilderConfig.appId).toBe(
require('../../src/shared/local-build-compatibility-contract.json').appId
)
})
it('excludes repo-only source trees from app.asar', () => {
expect(electronBuilderConfig.files).toEqual(
expect.arrayContaining([
@@ -28,15 +35,57 @@ describe('electron-builder config', () => {
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
'!skill-guides{,/**/*}',
'!skill-stubs{,/**/*}',
'!resources/skills/**',
'!tests{,/**/*}',
'!examples{,/**/*}',
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js'
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
'!out/**/*.test.js',
'!resources/plugins/launch/**'
])
)
})
// Why: `files` is an all-negation list, so electron-builder's default `**/*` packs
// anything without an explicit `!` entry — examples/ landed without one and shipped
// hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar.
// Drive the real matcher: pinning the pattern string cannot prove it excludes the tree.
it('keeps plugin authoring examples out of app.asar', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
// copyFiles() prepends this itself once the pattern list is all-negation.
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
for (const authoringOnly of [
'examples/plugins/hostile-panel/panel.html',
'examples/plugins/hostile-panel/orca-plugin.json',
'examples/plugins/hello-orca/main.mjs',
'examples/plugins/hello-orca/orca-plugin.json'
]) {
expect(packs(authoringOnly)).toBe(false)
}
// The negation stays anchored at the app root, so nested `examples` segments still ship.
expect(packs('out/main/examples/index.js')).toBe(true)
})
it('keeps runtime resources available through extraResources', () => {
const bundledPluginResources = expect.objectContaining({
from: 'resources/plugins/launch',
to: 'plugins/launch'
})
for (const platform of ['mac', 'linux', 'win']) {
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
from: 'resources/skills',
to: 'skills'
})
expect(electronBuilderConfig[platform].extraResources).toEqual(
expect.arrayContaining([bundledPluginResources])
)
}
expect(electronBuilderConfig.mac.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -67,6 +116,26 @@ describe('electron-builder config', () => {
)
})
// Why: the Windows CLI shim is delivered only via extraResources to
// resources/bin/orca.cmd (beside the native resources/bin/orca.exe). If the
// source tree is also packed into app.asar it gets extracted by
// asarUnpack:['resources/**'] to app.asar.unpacked/resources/win32/bin/orca.cmd,
// a duplicate with no adjacent orca.exe that fails to launch (#7351).
it('keeps the Windows CLI shim source tree out of app.asar', () => {
expect(electronBuilderConfig.files).toEqual(
expect.arrayContaining(['!resources/win32{,/**/*}'])
)
// Regression guard: the working shim must still ship via extraResources.
expect(electronBuilderConfig.win.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'resources/win32/bin/orca.cmd',
to: 'bin/orca.cmd'
})
])
)
})
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
// from Contents/Resources, so the helper must ship in Contents/MacOS (#7929).
it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => {
@@ -97,6 +166,14 @@ describe('electron-builder config', () => {
)
})
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
it('unpacks the forked main-thread hang-watchdog entry', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
)
})
it('uses the multi-size icon source for Linux packages', () => {
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
})
@@ -135,6 +212,58 @@ describe('electron-builder config', () => {
}
})
it('overrides packaged semver only for local macOS builds', () => {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = process.env.ORCA_LOCAL_BUILD_VERSION
const originalMacRelease = process.env.ORCA_MAC_RELEASE
try {
delete require.cache[configPath]
delete process.env.ORCA_MAC_RELEASE
process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-rc.0.local.123.abc'
expect(require('../electron-builder.config.cjs').extraMetadata).toEqual({
version: '1.4.159-rc.0.local.123.abc'
})
} finally {
if (originalMacRelease === undefined) {
delete process.env.ORCA_MAC_RELEASE
} else {
process.env.ORCA_MAC_RELEASE = originalMacRelease
}
if (original === undefined) {
delete process.env.ORCA_LOCAL_BUILD_VERSION
} else {
process.env.ORCA_LOCAL_BUILD_VERSION = original
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
})
it('never applies local semver to release packaging', () => {
const configPath = require.resolve('../electron-builder.config.cjs')
const originalLocalVersion = process.env.ORCA_LOCAL_BUILD_VERSION
const originalMacRelease = process.env.ORCA_MAC_RELEASE
try {
delete require.cache[configPath]
process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-local.123.abc'
process.env.ORCA_MAC_RELEASE = '1'
expect(require('../electron-builder.config.cjs').extraMetadata).toBeUndefined()
} finally {
if (originalLocalVersion === undefined) {
delete process.env.ORCA_LOCAL_BUILD_VERSION
} else {
process.env.ORCA_LOCAL_BUILD_VERSION = originalLocalVersion
}
if (originalMacRelease === undefined) {
delete process.env.ORCA_MAC_RELEASE
} else {
process.env.ORCA_MAC_RELEASE = originalMacRelease
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
})
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)
@@ -356,6 +485,11 @@ describe('electron-builder config', () => {
const resourcesDir = join(root, 'linux-unpacked', 'resources')
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
await cp(
join(process.cwd(), 'resources', 'plugins', 'launch'),
join(resourcesDir, 'plugins', 'launch'),
{ recursive: true }
)
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
// Why: afterPack now fails hard when the unpacked daemon entry is
// missing, so the fixture must carry one like a real package layout.
@@ -7,8 +7,8 @@ function electronBuilderNativeRebuild(context) {
return runElectronBuilderNativeRebuild(context)
}
function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
const args = buildNativeRebuildArgs(context)
function runElectronBuilderNativeRebuild(context, runner = execFileSync, runtime = {}) {
const args = buildNativeRebuildArgs(context, runtime)
if (readPlatformName(context?.platform) === 'win32') {
runner(process.execPath, ['config/scripts/build-windows-cli-launcher.mjs'], {
cwd: projectDir,
@@ -25,15 +25,22 @@ function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
return false
}
function buildNativeRebuildArgs(context) {
function buildNativeRebuildArgs(
context,
{ environment = process.env, hostPlatform = process.platform, hostArch = process.arch } = {}
) {
const platform = readPlatformName(context?.platform)
const arch = readArchName(context?.arch)
const canReusePreparedRuntime =
environment.ORCA_REUSE_PREPARED_NATIVE_RUNTIME === '1' &&
platform === hostPlatform &&
arch === hostArch
return [
'config/scripts/rebuild-native-deps.mjs',
`--platform=${platform}`,
`--arch=${arch}`,
'--force'
...(canReusePreparedRuntime ? [] : ['--force'])
]
}
@@ -42,6 +42,33 @@ describe('electron-builder native rebuild hook', () => {
])
})
it('reuses a prepared native runtime only for the host target', () => {
const runtime = {
environment: { ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' },
hostPlatform: 'linux',
hostArch: 'x64'
}
expect(
buildNativeRebuildArgs(
{
platform: { nodeName: 'linux' },
arch: 'x64'
},
runtime
)
).toEqual(['config/scripts/rebuild-native-deps.mjs', '--platform=linux', '--arch=x64'])
expect(
buildNativeRebuildArgs(
{
platform: { nodeName: 'linux' },
arch: 'arm64'
},
runtime
)
).toContain('--force')
})
it('builds the native CLI launcher before packaging Windows resources', () => {
const calls = []
const result = runElectronBuilderNativeRebuild(
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { electronViteConfig } from '../../electron.vite.config'
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
describe('Electron Vite output contract', () => {
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
const output = electronViteConfig.main?.build?.rollupOptions?.output
if (!output || Array.isArray(output)) {
throw new Error('Expected one main-process output')
}
expect(output.format).toBe('cjs')
expect(output.entryFileNames).toBe('[name].js')
expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js')
})
it('externalizes packaged dependencies but bundles the daemon xterm graph', () => {
const external = electronViteConfig.main?.build?.rollupOptions?.external
if (typeof external !== 'function') {
throw new Error('Expected main-process external predicate')
}
expect(external('node-pty', undefined, false)).toBe(true)
expect(external('@parcel/watcher', undefined, false)).toBe(true)
expect(external('electron', undefined, false)).toBe(true)
expect(external('node:fs', undefined, false)).toBe(true)
expect(external('@xterm/headless', undefined, false)).toBe(false)
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
})
it('isolates renderer entry side effects behind strict facades', () => {
expect(electronViteConfig.renderer?.build?.rollupOptions?.preserveEntrySignatures).toBe(
'strict'
)
})
it('rejects prototype properties as build targets', () => {
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
})
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
expect(devRunner).toContain('const helperBundleId = `${bundleId}.helper`')
expect(devRunner).toContain("'Electron Helper.app',")
expect(devRunner).toContain(
"setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)"
)
})
})
+26 -2
View File
@@ -11,7 +11,11 @@ const scriptPath = import.meta.filename
const projectDir = resolve(import.meta.dirname, '../..')
const runtime = readRuntimeArg()
const NATIVE_MODULES = ['node-pty']
const NATIVE_MODULES = [
'node-pty',
...(process.platform === 'win32' ? ['windows-native-registry'] : [])
]
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
const CHILD_CHECK_FLAG = '--check-only'
if (process.argv.includes(CHILD_CHECK_FLAG)) {
@@ -240,6 +244,12 @@ function collectNativeModuleFailures() {
}
function loadNativeModule(moduleName) {
if (moduleName === 'windows-native-registry') {
const registry = require(moduleName)
// Why: the package defers loading its .node addon until the first registry call.
registry.getRegistryKey(registry.HK.CU, 'Environment')
return
}
if (moduleName === 'node-pty') {
loadNodePtyNativeModule()
return
@@ -256,13 +266,27 @@ function loadNodePtyNativeModule() {
// Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
const native = loadNativeModule(nativeName)
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native.dir)) {
assertNodePtyWindowsConptyRuntime(native?.dir)
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) {
throw new Error(
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
)
}
}
function assertNodePtyWindowsConptyRuntime(nativeDir) {
if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) {
return
}
const runtimeDir = resolve(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty')
const missingFile = NODE_PTY_CONPTY_RUNTIME_FILES.find(
(filename) => !existsSync(resolve(runtimeDir, filename))
)
if (missingFile) {
throw new Error(`node-pty ConPTY runtime file is missing: ${resolve(runtimeDir, missingFile)}`)
}
}
function getNodePtyNativeModuleName() {
if (process.platform !== 'win32') {
return 'pty'
@@ -193,6 +193,7 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
}
`
)
writeFakeWindowsRegistry(projectDir)
}
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
@@ -214,6 +215,19 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
}
`
)
writeFakeWindowsRegistry(projectDir)
}
function writeFakeWindowsRegistry(projectDir) {
if (process.platform !== 'win32') {
return
}
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
mkdirSync(registryDir, { recursive: true })
writeFileSync(
join(registryDir, 'index.js'),
'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
)
}
function writeNodePtyPatchFile(projectDir) {
@@ -0,0 +1,277 @@
import { constants } from 'node:fs'
import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { parse } from 'yaml'
const SCRIPT_DIR = import.meta.dirname
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
const CANONICAL_GUIDE_NAMES = [
'computer-use',
'linear-tickets',
'orca-cli',
'orca-emulator',
'orca-emulator-android',
'orca-linear',
'orca-per-workspace-env',
'orchestration'
]
// Why: old discovery stubs can outlive a rename indefinitely, so aliases are
// a compatibility ledger: add entries for renames, but never remove them.
const GUIDE_ALIASES = {
'computer-use': [],
'linear-tickets': [],
'orca-cli': [],
'orca-emulator': [],
'orca-emulator-android': [],
'orca-linear': [],
'orca-per-workspace-env': [],
orchestration: []
}
// Why: a stubbed topic ships a hybrid discovery stub as its installable projection while
// `orca skills get <topic>` still serves the full version-matched guide from the binary.
// Migrating a topic here is effectively one-way — earlier fat installs rely on the stub
// landing to converge — so entries are added as skills convert, never removed. The stub
// body lives in skill-stubs/<topic>.md; the projection reuses the guide's own frontmatter.
const STUB_TOPICS = [
'computer-use',
'linear-tickets',
'orca-cli',
'orca-emulator',
'orca-emulator-android',
'orca-linear',
'orca-per-workspace-env',
'orchestration'
]
function normalizeMarkdown(markdown) {
return markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
function parseFrontmatter(markdown, sourcePath) {
const normalized = normalizeMarkdown(markdown)
const match = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/.exec(normalized)
if (!match) {
throw new Error(`Guide source has no YAML frontmatter: ${sourcePath}`)
}
let values
try {
values = parse(match[1])
} catch (error) {
throw new Error(
`Guide source has invalid YAML frontmatter: ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`
)
}
if (
!values ||
typeof values !== 'object' ||
typeof values.name !== 'string' ||
typeof values.description !== 'string'
) {
throw new Error(`Guide source must declare name and description: ${sourcePath}`)
}
return {
name: values.name,
description: values.description.replace(/\s+/g, ' ').trim()
}
}
function frontmatterBlock(markdown, sourcePath) {
const normalized = normalizeMarkdown(markdown)
const match = /^---[ \t]*\n[\s\S]*?\n---[ \t]*\n/.exec(normalized)
if (!match) {
throw new Error(`Guide source has no YAML frontmatter block: ${sourcePath}`)
}
return match[0]
}
// Why: the stub's routing frontmatter (name + description) must stay byte-identical to the
// guide's — it is the unchanged discovery surface — so we reuse the guide's own block and
// replace only the body. Body normalized to LF with exactly one trailing newline.
function composeStubProjection(guideMarkdown, stubBody, sourcePath) {
const block = frontmatterBlock(guideMarkdown, sourcePath)
const body = normalizeMarkdown(stubBody).replace(/^\n+/, '').replace(/\n*$/, '\n')
return `${block}\n${body}`
}
function constantName(name) {
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
}
function serializeEmbeddedModule(guides) {
const markdownConstants = guides
.map(
(guide) =>
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
)
.join('\n\n')
const guideEntries = guides
.map((guide) => {
const markdownConstant = constantName(guide.name)
return [
' {',
` name: ${JSON.stringify(guide.name)},`,
` description: ${JSON.stringify(guide.description)},`,
` markdown: ${markdownConstant},`,
` fullMarkdown: ${markdownConstant},`,
` aliases: ${JSON.stringify(guide.aliases)}`,
' }'
].join('\n')
})
.join(',\n')
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n}\n\n${markdownConstants}\n\n// Why: no current guide has bundled reference documents, so --full is byte-identical for now.\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
}
function assertAliasContract(guides) {
const canonicalNames = new Set(guides.map((guide) => guide.name))
const seenAliases = new Set()
for (const guide of guides) {
for (const alias of guide.aliases) {
if (canonicalNames.has(alias)) {
throw new Error(`Guide alias collides with canonical name: ${alias}`)
}
if (seenAliases.has(alias)) {
throw new Error(`Guide alias is assigned more than once: ${alias}`)
}
seenAliases.add(alias)
}
}
}
async function assertStubSourcesMatchTopics(repoRoot) {
const stubRoot = path.join(repoRoot, 'skill-stubs')
let names = []
try {
names = (await readdir(stubRoot, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => entry.name.slice(0, -3))
} catch (error) {
// Why: a repo state with no stubbed topics yet has no skill-stubs directory at all.
if (error.code !== 'ENOENT') {
throw error
}
}
const found = names.sort((left, right) => left.localeCompare(right, 'en'))
const expected = [...STUB_TOPICS].sort((left, right) => left.localeCompare(right, 'en'))
if (JSON.stringify(found) !== JSON.stringify(expected)) {
throw new Error(
`skill-stubs sources must match STUB_TOPICS.\nExpected: ${expected.join(', ') || '(none)'}\nFound: ${found.join(', ') || '(none)'}`
)
}
for (const name of STUB_TOPICS) {
if (!CANONICAL_GUIDE_NAMES.includes(name)) {
throw new Error(`Stub topic is not a canonical guide: ${name}`)
}
}
}
async function buildArtifacts(repoRoot = REPO_ROOT) {
const guideRoot = path.join(repoRoot, 'skill-guides')
const sourceFiles = (await readdir(guideRoot, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => entry.name.slice(0, -3))
.sort((left, right) => left.localeCompare(right, 'en'))
const expectedNames = [...CANONICAL_GUIDE_NAMES].sort((left, right) =>
left.localeCompare(right, 'en')
)
if (JSON.stringify(sourceFiles) !== JSON.stringify(expectedNames)) {
throw new Error(
`Guide sources must match the canonical topic list.\nExpected: ${expectedNames.join(', ')}\nFound: ${sourceFiles.join(', ')}`
)
}
await assertStubSourcesMatchTopics(repoRoot)
const stubTopics = new Set(STUB_TOPICS)
const guides = []
const projections = []
for (const name of expectedNames) {
const sourcePath = path.join(guideRoot, `${name}.md`)
// Why: Git may render text with native EOLs despite repository policy; the
// embedded guide and generated projection must have one platform-neutral identity.
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
const frontmatter = parseFrontmatter(markdown, path.relative(repoRoot, sourcePath))
if (frontmatter.name !== name) {
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
}
const aliases = GUIDE_ALIASES[name]
// Why: the embedded table always carries the full guide (served by `skills get`);
// only the installable projection thins to a stub once a topic is in STUB_TOPICS.
guides.push({ name, description: frontmatter.description, markdown, aliases })
const stubPath = path.join(repoRoot, 'skill-stubs', `${name}.md`)
const content = stubTopics.has(name)
? composeStubProjection(markdown, await readFile(stubPath, 'utf8'), `skill-stubs/${name}.md`)
: markdown
projections.push({
path: path.join(repoRoot, 'skills', name, 'SKILL.md'),
content
})
}
assertAliasContract(guides)
return [
{
path: path.join(repoRoot, 'src', 'cli', 'bundled-skill-guides.ts'),
content: serializeEmbeddedModule(guides)
},
...projections
]
}
async function writeArtifacts(artifacts) {
for (const artifact of artifacts) {
await mkdir(path.dirname(artifact.path), { recursive: true })
await writeFile(artifact.path, artifact.content, 'utf8')
}
}
async function verifyArtifacts(artifacts, repoRoot = REPO_ROOT) {
const stale = []
for (const artifact of artifacts) {
try {
await access(artifact.path, constants.R_OK)
if ((await readFile(artifact.path, 'utf8')) !== artifact.content) {
stale.push(artifact.path)
}
} catch {
stale.push(artifact.path)
}
}
if (stale.length > 0) {
throw new Error(
`Generated bundled skill guides are stale:\n${stale
.map((filePath) => path.relative(repoRoot, filePath))
.join('\n')}\nRun node config/scripts/generate-bundled-skill-guides.mjs --write.`
)
}
}
async function main() {
const artifacts = await buildArtifacts()
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}
export {
CANONICAL_GUIDE_NAMES,
GUIDE_ALIASES,
STUB_TOPICS,
assertAliasContract,
buildArtifacts,
composeStubProjection,
frontmatterBlock,
normalizeMarkdown,
parseFrontmatter,
serializeEmbeddedModule,
verifyArtifacts,
writeArtifacts
}
@@ -0,0 +1,203 @@
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
import {
CANONICAL_GUIDE_NAMES,
GUIDE_ALIASES,
STUB_TOPICS,
assertAliasContract,
buildArtifacts,
frontmatterBlock,
normalizeMarkdown,
parseFrontmatter,
verifyArtifacts,
writeArtifacts
} from './generate-bundled-skill-guides.mjs'
const projectDir = path.resolve(import.meta.dirname, '..', '..')
const temporaryDirectories = []
async function createFixture() {
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
temporaryDirectories.push(root)
await Promise.all([
cp(path.join(projectDir, 'skill-guides'), path.join(root, 'skill-guides'), {
recursive: true
}),
cp(path.join(projectDir, 'skill-stubs'), path.join(root, 'skill-stubs'), {
recursive: true
}),
cp(path.join(projectDir, 'skills'), path.join(root, 'skills'), { recursive: true }),
mkdir(path.join(root, 'src', 'cli'), { recursive: true })
])
return root
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('bundled skill guide generator', () => {
it('keeps every fat (non-stub) projection byte-identical to its authoritative source', async () => {
for (const name of CANONICAL_GUIDE_NAMES) {
if (STUB_TOPICS.includes(name)) {
continue
}
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`))
const projection = await readFile(path.join(projectDir, 'skills', name, 'SKILL.md'))
expect(projection, name).toEqual(source)
}
})
it('projects stub topics as hybrid discovery stubs that reuse the guide frontmatter', async () => {
expect(STUB_TOPICS.length).toBeGreaterThan(0)
for (const name of STUB_TOPICS) {
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`), 'utf8')
const projection = await readFile(path.join(projectDir, 'skills', name, 'SKILL.md'), 'utf8')
// The routing frontmatter is the unchanged discovery surface.
expect(projection.startsWith(frontmatterBlock(source, `${name}.md`))).toBe(true)
// The stub is a thin hybrid pointer, not the full guide.
expect(projection).not.toEqual(source)
expect(projection.length).toBeLessThan(source.length)
expect(projection).toContain('discovery stub')
expect(projection).toContain(`skills get ${name}`)
}
})
it('keeps pre-guide fallback useful and read-only for every converted domain', async () => {
const expectedFallbackCommands = {
'computer-use': ['ORCA computer capabilities --json', 'ORCA computer list-apps --json'],
'linear-tickets': ['ORCA linear --help', 'ORCA linear issue --current --full --json'],
'orca-emulator': ['ORCA emulator list --json'],
'orca-emulator-android': ['ORCA emulator devices --json'],
'orca-linear': ['ORCA linear --help', 'ORCA linear issue --current --full --json'],
'orca-per-workspace-env': ['ORCA vm recipe doctor <recipe-id> --repo-path <repo> --json'],
orchestration: ['ORCA orchestration task-list --json', 'ORCA terminal list --json']
}
for (const [name, commands] of Object.entries(expectedFallbackCommands)) {
const stub = await readFile(path.join(projectDir, 'skill-stubs', `${name}.md`), 'utf8')
const fallback = stub.split('## If an older Orca does not recognize `skills get`')[1]
expect(fallback, name).toBeDefined()
for (const command of commands) {
expect(fallback, name).toContain(command)
}
expect(fallback, name).not.toContain('ORCA worktree ps --json')
}
})
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
)
for (const guide of BUNDLED_SKILL_GUIDES) {
const source = await readFile(
path.join(projectDir, 'skill-guides', `${guide.name}.md`),
'utf8'
)
const frontmatter = parseFrontmatter(source, `${guide.name}.md`)
expect(guide.description).toBe(frontmatter.description)
expect(guide.markdown).toBe(source)
expect(guide.fullMarkdown).toBe(source)
expect(guide.aliases).toEqual(GUIDE_ALIASES[guide.name])
}
})
it('keeps CLI guide examples safe across shells and Linux command names', async () => {
for (const name of ['orca-cli', 'computer-use', 'orca-emulator', 'orca-emulator-android']) {
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`), 'utf8')
expect(source).toContain('ORCA_CLI_COMMAND')
expect(source).toContain('orca-dev')
expect(source).toContain('orca-ide')
expect(source).toContain('PowerShell')
expect(source).toContain('cmd.exe')
expect(source).toMatch(/^ORCA .+--json$/mu)
// Why: bare command lines can launch GNOME Orca, while shell variables make
// the same guide unusable from PowerShell and cmd.exe.
expect(source).not.toMatch(/^orca /mu)
expect(source).not.toMatch(/\$ORCA(?:_|\b)/u)
}
})
it('builds deterministic artifacts and verifies the checked-in outputs', async () => {
const first = await buildArtifacts(projectDir)
const second = await buildArtifacts(projectDir)
expect(second).toEqual(first)
await expect(verifyArtifacts(first, projectDir)).resolves.toBeUndefined()
})
it('generates platform-identical output from CRLF guide sources', async () => {
const expected = await buildArtifacts(projectDir)
const root = await createFixture()
for (const name of CANONICAL_GUIDE_NAMES) {
const sourcePath = path.join(root, 'skill-guides', `${name}.md`)
const source = await readFile(sourcePath, 'utf8')
await writeFile(sourcePath, source.replaceAll('\n', '\r\n'))
}
for (const name of STUB_TOPICS) {
const stubPath = path.join(root, 'skill-stubs', `${name}.md`)
const stubSource = await readFile(stubPath, 'utf8')
await writeFile(stubPath, stubSource.replaceAll('\n', '\r\n'))
}
const actual = await buildArtifacts(root)
expect(actual.map((artifact) => artifact.content)).toEqual(
expected.map((artifact) => artifact.content)
)
})
it('pins guide sources, projections, and embedded output to LF in Git', async () => {
const attributes = await readFile(path.join(projectDir, '.gitattributes'), 'utf8')
expect(normalizeMarkdown(attributes)).toContain('/skill-guides/*.md text eol=lf\n')
expect(normalizeMarkdown(attributes)).toContain('/skill-stubs/*.md text eol=lf\n')
expect(normalizeMarkdown(attributes)).toContain('/skills/*/SKILL.md text eol=lf\n')
expect(normalizeMarkdown(attributes)).toContain(
'/src/cli/bundled-skill-guides.ts text eol=lf\n'
)
})
it('reports stale outputs and write mode repairs all projections', async () => {
const root = await createFixture()
const artifacts = await buildArtifacts(root)
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow(
'src/cli/bundled-skill-guides.ts'
)
await writeArtifacts(artifacts)
await expect(verifyArtifacts(artifacts, root)).resolves.toBeUndefined()
await writeFile(path.join(root, 'skills', 'computer-use', 'SKILL.md'), 'stale\n')
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow('skills/computer-use/SKILL.md')
})
it('rejects mismatched source names and ambiguous aliases', async () => {
const root = await createFixture()
await writeFile(
path.join(root, 'skill-guides', 'computer-use.md'),
'---\nname: wrong\ndescription: present\n---\n'
)
await expect(buildArtifacts(root)).rejects.toThrow('declares mismatched name wrong')
expect(() =>
assertAliasContract([
{ name: 'first', aliases: ['legacy'] },
{ name: 'second', aliases: ['legacy'] }
])
).toThrow('assigned more than once')
expect(() =>
assertAliasContract([
{ name: 'first', aliases: ['second'] },
{ name: 'second', aliases: [] }
])
).toThrow('collides with canonical name')
})
})
@@ -0,0 +1,689 @@
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { constants } from 'node:fs'
import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { isDeepStrictEqual } from 'node:util'
// Why: the three artifacts version independently — bumping one shape must not
// rewrite the others or bypass the registry's schema-gated append-only guard.
const CURRENT_MANIFEST_SCHEMA_VERSION = 2
const SNAPSHOT_REGISTRY_SCHEMA_VERSION = 1
const RELEASE_MAPPING_SCHEMA_VERSION = 1
const SCRIPT_DIR = import.meta.dirname
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
const SKILLS_ROOT = path.join(REPO_ROOT, 'skills')
const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills')
const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json')
const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json')
const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json')
// Why: the manifest and registry are content-addressed — they describe skill
// bytes. The mapping is provenance: which already-committed revision a tag
// shipped. A release cut may append the second without regenerating the first.
const CONTENT_ADDRESSED_PATHS = [CURRENT_MANIFEST_PATH, SNAPSHOT_REGISTRY_PATH]
const ALL_ARTIFACT_PATHS = [...CONTENT_ADDRESSED_PATHS, RELEASE_MAPPING_PATH]
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function compareCodeUnits(left, right) {
return left === right ? 0 : left < right ? -1 : 1
}
function gitObjectSha(kind, bytes) {
return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest()
}
function normalizeText(bytes) {
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
}
function classifyFile(bytes) {
if (bytes.includes(0)) {
return 'binary'
}
try {
normalizeText(bytes)
return 'text'
} catch {
return 'binary'
}
}
function assertSafeRelativePath(relativePath) {
if (
path.isAbsolute(relativePath) ||
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`)
) {
throw new Error(`Unsafe skill package path: ${relativePath}`)
}
}
function describeFile(manifestPath, bytes, executable) {
const classification = classifyFile(bytes)
const exactSha256 = sha256(bytes)
const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null
return {
path: manifestPath,
size: bytes.length,
executable,
classification,
exactSha256,
textNormalizedSha256,
identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256,
gitBlobSha: gitObjectSha('blob', bytes).toString('hex')
}
}
function gitTreeSha(entries) {
const root = { directories: new Map(), files: [] }
for (const entry of entries) {
const parts = entry.path.split('/')
const filename = parts.pop()
let directory = root
for (const part of parts) {
let child = directory.directories.get(part)
if (!child) {
child = { directories: new Map(), files: [] }
directory.directories.set(part, child)
}
directory = child
}
directory.files.push({ filename, ...entry })
}
function hashDirectory(directory) {
const children = [
...[...directory.directories].map(([name, child]) => ({
mode: '40000',
name,
hash: hashDirectory(child)
})),
...directory.files.map((file) => ({
mode: file.executable ? '100755' : '100644',
name: file.filename,
hash: Buffer.from(file.gitBlobSha, 'hex')
}))
].sort((left, right) => {
const leftName = left.mode === '40000' ? `${left.name}/` : left.name
const rightName = right.mode === '40000' ? `${right.name}/` : right.name
return Buffer.from(leftName).compare(Buffer.from(rightName))
})
const body = Buffer.concat(
children.map(({ mode, name, hash }) =>
Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash])
)
)
return gitObjectSha('tree', body)
}
return hashDirectory(root).toString('hex')
}
async function collectPackageFiles(packageRoot) {
const files = []
const caseFoldedPaths = new Map()
async function visit(directory) {
const entries = await readdir(directory, { withFileTypes: true })
// Why: build-time Node and packaged Electron may ship different ICU data;
// package identity order must use the same locale-independent comparison.
entries.sort((left, right) => compareCodeUnits(left.name, right.name))
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name)
const relativePath = path.relative(packageRoot, absolutePath)
assertSafeRelativePath(relativePath)
const manifestPath = relativePath.split(path.sep).join('/')
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(foldedPath)
if (collision && collision !== manifestPath) {
throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const fileStat = await lstat(absolutePath)
if (fileStat.isSymbolicLink()) {
throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`)
}
if (fileStat.isDirectory()) {
await visit(absolutePath)
continue
}
if (!fileStat.isFile()) {
throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`)
}
// Why: Windows observation cannot see execute bits, so an executable file in
// a shipped skill would misclassify every pristine Windows install as unrecognized.
if ((fileStat.mode & 0o111) !== 0) {
throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`)
}
files.push(describeFile(manifestPath, await readFile(absolutePath), false))
}
}
await visit(packageRoot)
return sortManifestFiles(files)
}
function collectGitSkillTreeEntries(treeSha) {
const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha])
.toString('utf8')
.split('\0')
.filter(Boolean)
const packages = new Map()
for (const line of output) {
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line)
if (!match) {
throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`)
}
const [, mode, type, objectSha, sourcePath] = match
const separator = sourcePath.indexOf('/')
if (separator <= 0 || separator === sourcePath.length - 1) {
throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`)
}
const name = sourcePath.slice(0, separator)
const manifestPath = sourcePath.slice(separator + 1)
const entries = packages.get(name) ?? []
entries.push({ mode, type, objectSha, manifestPath })
packages.set(name, entries)
}
return packages
}
function readGitBlobs(objectShas) {
const uniqueShas = [...new Set(objectShas)]
if (uniqueShas.length === 0) {
return new Map()
}
// Why: released history spans hundreds of tags. Batch mode avoids a Git
// subprocess per historical file while remaining available on Git 2.25.
const output = execFileSync('git', ['cat-file', '--batch'], {
input: `${uniqueShas.join('\n')}\n`,
maxBuffer: 64 * 1024 * 1024
})
const blobs = new Map()
let offset = 0
for (const requestedSha of uniqueShas) {
const headerEnd = output.indexOf(10, offset)
if (headerEnd < 0) {
throw new Error(`Missing git cat-file header for ${requestedSha}`)
}
const header = output.subarray(offset, headerEnd).toString('utf8')
const match = /^([a-f0-9]+) blob (\d+)$/.exec(header)
if (!match || match[1] !== requestedSha) {
throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`)
}
const size = Number(match[2])
const contentStart = headerEnd + 1
const contentEnd = contentStart + size
if (contentEnd >= output.length || output[contentEnd] !== 10) {
throw new Error(`Truncated git blob for ${requestedSha}`)
}
blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd)))
offset = contentEnd + 1
}
return blobs
}
function collectGitPackageFiles(treeSha, name, entries, blobs) {
const caseFoldedPaths = new Map()
const files = entries.map(({ mode, type, objectSha, manifestPath }) => {
if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) {
throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`)
}
assertSafeRelativePath(manifestPath)
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(foldedPath)
if (collision && collision !== manifestPath) {
throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const bytes = blobs.get(objectSha)
if (!bytes) {
throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`)
}
return describeFile(manifestPath, bytes, mode === '100755')
})
// Why: git ls-tree emits git byte-order, not the canonical walk order.
return sortManifestFiles(files)
}
// Why: snapshot matching compares files by array index, so every producer —
// working-tree walk, git history, and runtime observation — must emit one
// canonical order. This mirrors the sorted depth-first filesystem walk.
function compareManifestPaths(left, right) {
const leftParts = left.split('/')
const rightParts = right.split('/')
const shared = Math.min(leftParts.length, rightParts.length)
for (let index = 0; index < shared; index += 1) {
const order = compareCodeUnits(leftParts[index], rightParts[index])
if (order !== 0) {
return order
}
}
return leftParts.length - rightParts.length
}
function sortManifestFiles(files) {
return [...files].sort((left, right) => compareManifestPaths(left.path, right.path))
}
function packageDigest(files) {
return sha256(
Buffer.from(
JSON.stringify(
files.map((file) => ({
path: file.path,
executable: file.executable,
classification: file.classification,
identitySha256: file.identitySha256
}))
),
'utf8'
)
)
}
function releaseTags() {
return execFileSync(
'git',
['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'],
{ encoding: 'utf8' }
)
.split('\n')
.filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag))
}
function skillsTreeShasAtRefs(refs) {
if (refs.length === 0) {
return []
}
const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], {
input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`,
encoding: 'utf8'
})
const lines = output.trimEnd().split('\n')
if (lines.length !== refs.length) {
throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`)
}
return lines.map((line, index) => {
if (line.endsWith(' missing')) {
return null
}
const match = /^([a-f0-9]+) tree$/.exec(line)
if (!match) {
throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`)
}
return match[1]
})
}
function buildReleasedHistory() {
const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} }
const mapping = { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] }
const tags = releaseTags()
const treeShas = skillsTreeShasAtRefs(tags)
const distinctTreeShas = [...new Set(treeShas.filter(Boolean))]
const packagesByTree = new Map(
distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)])
)
const blobs = readGitBlobs(
[...packagesByTree.values()].flatMap((packages) =>
[...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha))
)
)
let previousSkillsTreeSha = null
for (const [index, tag] of tags.entries()) {
const skillsTreeSha = treeShas[index]
if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) {
continue
}
previousSkillsTreeSha = skillsTreeSha
const revisions = {}
const packages = packagesByTree.get(skillsTreeSha)
if (!packages) {
throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`)
}
for (const name of [...packages.keys()].sort(compareCodeUnits)) {
const entries = packages.get(name)
const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs)
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
continue
}
const digest = packageDigest(filesWithGitHashes)
const snapshots = registry.skills[name] ?? []
const latest = snapshots.at(-1)
if (!latest || latest.packageDigest !== digest) {
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
snapshots.push({
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files
})
registry.skills[name] = snapshots
}
revisions[name] = snapshots.at(-1).releaseRevision
}
if (Object.keys(revisions).length > 0) {
mapping.releases.push({ appVersion: tag.slice(1), skills: revisions })
}
}
return { registry, mapping }
}
// Why: released history is authoritative committed data, advanced only at
// release cut. Seeding generation from the committed registry + mapping makes
// ordinary verify/regeneration a pure function of working-tree bytes, so it
// never walks git tags — the root cause of recurring lint drift when a clone
// holds stray, deleted, or fork tags the committed artifacts predate.
function releasedHistoryFromCommitted(committedRegistry, committedMapping) {
const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} }
const releasedSnapshotCounts = {}
const mapping =
committedMapping && committedMapping.schemaVersion === RELEASE_MAPPING_SCHEMA_VERSION
? structuredClone(committedMapping)
: { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] }
if (committedRegistry && committedRegistry.schemaVersion === SNAPSHOT_REGISTRY_SCHEMA_VERSION) {
const mappedCounts = releasedSnapshotCountsFromMapping(mapping)
for (const [name, snapshots] of Object.entries(committedRegistry.skills ?? {})) {
// The committed registry carries at most one unreleased tail beyond the
// revisions named by the mapping; drop it and recompute it from bytes.
const releasedCount = mappedCounts?.[name] ?? Math.max(0, snapshots.length - 1)
registry.skills[name] = snapshots.slice(0, releasedCount)
releasedSnapshotCounts[name] = releasedCount
}
}
return { registry, mapping, releasedSnapshotCounts }
}
// Why: disaster recovery only. Reconstruct released history from the immutable
// release tags when the committed ledger must be rebuilt from scratch. Kept off
// the verify/regenerate path — walking tags there is what coupled lint to the
// executing clone's tag state and broke it on version bumps, new tags, and
// stray/deleted local tags.
function releasedHistoryFromTags() {
const { registry, mapping } = buildReleasedHistory()
const releasedSnapshotCounts = Object.fromEntries(
Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length])
)
return { registry, mapping, releasedSnapshotCounts }
}
// Why: release cut is the single authoritative point where working-tree bytes
// become an immutable released revision. Append one mapping row for the version,
// mirroring the historical dedupe where consecutive identical skill trees share
// the earliest release's row.
function appendReleaseRow(artifacts, version) {
const appVersion = version.startsWith('v') ? version.slice(1) : version
const currentRevisions = {}
for (const skill of artifacts.currentManifest.skills) {
currentRevisions[skill.name] = skill.releaseRevision
}
const releases = artifacts.releaseMapping.releases
const last = releases.at(-1)
if (last && isDeepStrictEqual(last.skills, currentRevisions)) {
return
}
// Why: a cut that pushed the version bump to main but died before pushing the
// tag is re-cut at the same version. If skills changed in between, appending
// would leave two rows claiming this version and the stale one would name
// revisions that tag never ships — overwrite, since the tag ships these bytes.
if (last?.appVersion === appVersion) {
releases[releases.length - 1] = { appVersion, skills: currentRevisions }
return
}
// Why: an earlier row means re-cutting an already-shipped version, which the
// cut workflow refuses upstream. Fail rather than corrupt shipped provenance.
if (releases.some((release) => release.appVersion === appVersion)) {
throw new Error(`Release mapping already has a row for ${appVersion}.`)
}
releases.push({ appVersion, skills: currentRevisions })
}
async function buildArtifacts(releasedHistory) {
const { registry, mapping, releasedSnapshotCounts } = releasedHistory
const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort(compareCodeUnits)
const currentSkills = []
for (const name of skillDirectories) {
const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name))
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
throw new Error(`Skill package ${name} has no top-level SKILL.md`)
}
const digest = packageDigest(filesWithGitHashes)
const snapshots = registry.skills[name] ?? []
const latest = snapshots.at(-1)
let snapshot = latest
if (!latest || latest.packageDigest !== digest) {
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
snapshot = {
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files
}
snapshots.push(snapshot)
registry.skills[name] = snapshots
}
currentSkills.push({
name,
sourcePath: `skills/${name}`,
...snapshot
})
}
return {
currentManifest: {
schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION,
skills: currentSkills
},
snapshotRegistry: registry,
releaseMapping: mapping,
releasedSnapshotCounts
}
}
// Why: released snapshots are the detection ground truth for existing installs,
// so a generation-logic change must not rewrite them silently. Only the one
// unreleased working-tree append per skill may change between runs.
function releasedSnapshotCountsFromMapping(releaseMapping) {
if (!releaseMapping || releaseMapping.schemaVersion !== RELEASE_MAPPING_SCHEMA_VERSION) {
return null
}
const counts = {}
for (const release of releaseMapping.releases ?? []) {
for (const [name, revision] of Object.entries(release.skills ?? {})) {
counts[name] = Math.max(counts[name] ?? 0, revision)
}
}
return counts
}
function assertReleasedHistoryPreserved(committedRegistry, artifacts, committedReleaseMapping) {
if (!committedRegistry || committedRegistry.schemaVersion !== SNAPSHOT_REGISTRY_SCHEMA_VERSION) {
return
}
const committedReleasedCounts = releasedSnapshotCountsFromMapping(committedReleaseMapping)
for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) {
const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0
const regenerated = artifacts.snapshotRegistry.skills[name] ?? []
const minimumReleasedCount =
committedReleasedCounts?.[name] ?? Math.max(0, committedSnapshots.length - 1)
if (releasedCount < minimumReleasedCount) {
throw new Error(
`Released snapshot history is incomplete for ${name}. ` +
'Fetch all release tags before regenerating skill artifacts.'
)
}
const protectedCount = committedReleasedCounts
? (committedReleasedCounts[name] ?? 0)
: Math.min(committedSnapshots.length, releasedCount)
for (let index = 0; index < protectedCount; index += 1) {
const committed = committedSnapshots[index]
const rebuilt = regenerated[index]
if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) {
throw new Error(
`Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` +
'Released snapshots are append-only; a deliberate identity migration must update this check.'
)
}
}
}
}
async function readCommittedRegistry() {
try {
return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8'))
} catch {
return null
}
}
async function readCommittedReleaseMapping() {
try {
return JSON.parse(await readFile(RELEASE_MAPPING_PATH, 'utf8'))
} catch {
return null
}
}
function serialized(value) {
return `${JSON.stringify(value, null, 2)}\n`
}
async function writeArtifacts(artifacts, paths = ALL_ARTIFACT_PATHS) {
const values = new Map([
[CURRENT_MANIFEST_PATH, artifacts.currentManifest],
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry],
[RELEASE_MAPPING_PATH, artifacts.releaseMapping]
])
await mkdir(OUTPUT_ROOT, { recursive: true })
await Promise.all(paths.map((filePath) => writeFile(filePath, serialized(values.get(filePath)))))
}
// Why: cutting a release tag adds a trailing mapping row on every checkout at
// once, before any branch can regenerate. A trailing row whose revisions all
// equal the current manifest is provably redundant until the next real
// regeneration (installs of those bytes classify as current and are labeled by
// the running build), so verify must not fail branches over it.
function isToleratedReleaseMappingPrefix(committedText, artifacts) {
let committed
try {
committed = JSON.parse(committedText)
} catch {
return false
}
const derived = artifacts.releaseMapping
const committedCount = Array.isArray(committed?.releases) ? committed.releases.length : -1
if (committedCount < 0 || committedCount >= derived.releases.length) {
return false
}
const prefix = {
schemaVersion: derived.schemaVersion,
releases: derived.releases.slice(0, committedCount)
}
if (committedText !== serialized(prefix)) {
return false
}
const currentRevisions = Object.fromEntries(
artifacts.currentManifest.skills.map((skill) => [skill.name, skill.releaseRevision])
)
return derived.releases
.slice(committedCount)
.every((release) => isDeepStrictEqual(release.skills, currentRevisions))
}
async function verifyArtifacts(artifacts, paths = ALL_ARTIFACT_PATHS) {
const expected = [
[CURRENT_MANIFEST_PATH, artifacts.currentManifest, null],
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry, null],
[RELEASE_MAPPING_PATH, artifacts.releaseMapping, isToleratedReleaseMappingPrefix]
].filter(([filePath]) => paths.includes(filePath))
const stale = []
for (const [filePath, value, tolerated] of expected) {
try {
await access(filePath, constants.R_OK)
const committedText = await readFile(filePath, 'utf8')
if (committedText !== serialized(value) && !tolerated?.(committedText, artifacts)) {
stale.push(filePath)
}
} catch {
stale.push(filePath)
}
}
if (stale.length > 0) {
throw new Error(
`Generated skill artifacts are stale:\n${stale
.map((filePath) => path.relative(REPO_ROOT, filePath))
.join('\n')}\nRun pnpm generate:skill-bundle-manifest.`
)
}
}
async function main() {
const argv = process.argv.slice(2)
const rebuildFromTags = argv.includes('--rebuild-from-tags')
const releaseIndex = argv.indexOf('--release')
const releaseVersion = releaseIndex >= 0 ? argv[releaseIndex + 1] : null
if (releaseIndex >= 0 && !releaseVersion) {
throw new Error('--release requires a version argument, e.g. --release 1.4.160')
}
const committedRegistry = await readCommittedRegistry()
const committedMapping = await readCommittedReleaseMapping()
const releasedHistory = rebuildFromTags
? releasedHistoryFromTags()
: releasedHistoryFromCommitted(committedRegistry, committedMapping)
const artifacts = await buildArtifacts(releasedHistory)
if (releaseVersion) {
// Why: the row names revisions the committed registry must already contain,
// so proving those artifacts match this ref is what lets the cut record
// provenance without regenerating them. If regeneration disagrees with what
// is committed, the row would name a revision this tag does not ship.
await verifyArtifacts(artifacts, CONTENT_ADDRESSED_PATHS)
appendReleaseRow(artifacts, releaseVersion)
}
// Why: released snapshots are append-only. The committed registry/mapping are
// read fresh here so the artifacts (which may have appended a release row) can
// never alias what we validate against. This mapping must stay the PRE-append
// one to match artifacts.releasedSnapshotCounts, which seeding fixed before the
// row existed; the post-append mapping names one more revision than the counts
// do, which reads as incomplete history and would throw on every cut.
assertReleasedHistoryPreserved(committedRegistry, artifacts, committedMapping)
const shouldWrite = releaseVersion !== null || argv.includes('--write')
const writePaths = releaseVersion ? [RELEASE_MAPPING_PATH] : ALL_ARTIFACT_PATHS
await (shouldWrite ? writeArtifacts(artifacts, writePaths) : verifyArtifacts(artifacts))
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}
export {
appendReleaseRow,
assertReleasedHistoryPreserved,
buildArtifacts,
buildReleasedHistory,
classifyFile,
collectPackageFiles,
describeFile,
gitTreeSha,
isToleratedReleaseMappingPrefix,
normalizeText,
packageDigest,
releasedHistoryFromCommitted,
sortManifestFiles,
verifyArtifacts,
writeArtifacts
}
@@ -0,0 +1,506 @@
import { execFileSync } from 'node:child_process'
import {
chmod,
copyFile,
mkdir,
mkdtemp,
readFile,
realpath,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import {
appendReleaseRow,
assertReleasedHistoryPreserved,
classifyFile,
collectPackageFiles,
describeFile,
gitTreeSha,
isToleratedReleaseMappingPrefix,
normalizeText,
packageDigest,
releasedHistoryFromCommitted,
sortManifestFiles
} from './generate-skill-bundle-manifest.mjs'
const temporaryDirectories = []
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
async function createPackage() {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-'))
temporaryDirectories.push(directory)
return directory
}
// Why: the generator resolves its repo root from its own location, so a copy of
// the script inside a throwaway tree exercises the real CLI — including which
// artifacts each mode is allowed to write — without touching resources/skills.
async function createReleaseSandbox() {
// Node resolves the entry point through symlinks, so the script's own
// repo-root check only matches when the sandbox path is already resolved.
const root = await realpath(await createPackage())
const skillRoot = path.join(root, 'skills', 'demo')
const script = path.join(root, 'config', 'scripts', 'generate-skill-bundle-manifest.mjs')
await mkdir(path.dirname(script), { recursive: true })
await mkdir(skillRoot, { recursive: true })
await copyFile(path.join(import.meta.dirname, 'generate-skill-bundle-manifest.mjs'), script)
await writeFile(path.join(skillRoot, 'SKILL.md'), 'demo skill\n')
return {
generate: (...args) => execFileSync(process.execPath, [script, ...args], { stdio: 'pipe' }),
read: (name) => readFile(path.join(root, 'resources', 'skills', name), 'utf8'),
editSkill: (body) => writeFile(path.join(skillRoot, 'SKILL.md'), body)
}
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('skill bundle manifest generator', () => {
it('folds platform line endings for text identity', () => {
const lf = Buffer.from('first\nsecond\n')
const crlf = Buffer.from('first\r\nsecond\r\n')
expect(classifyFile(lf)).toBe('text')
expect(normalizeText(crlf)).toEqual(lf)
})
it('classifies null-containing and invalid UTF-8 content as binary', () => {
expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary')
expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary')
})
it('uses normalized text identity but exact executable identity', () => {
const skillFile = describeFile('SKILL.md', Buffer.from('line one\r\nline two\r\n'), false)
const executable = describeFile('run.sh', Buffer.from('#!/bin/sh\r\necho ok\r\n'), true)
expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256)
expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256)
expect(executable.exactSha256).not.toBe(executable.textNormalizedSha256)
expect(executable.identitySha256).toBe(executable.exactSha256)
expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/)
})
it('orders git-history files identically to the filesystem walk', async () => {
const packageRoot = await createPackage()
await mkdir(path.join(packageRoot, 'sub'))
for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) {
await writeFile(path.join(packageRoot, name), `${name}\n`)
}
const walked = await collectPackageFiles(packageRoot)
// Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based
// snapshot matching requires history and observation to share one order.
const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) =>
walked.find((file) => file.path === manifestPath)
)
expect(sortManifestFiles(gitOrdered)).toEqual(walked)
expect(packageDigest(sortManifestFiles(gitOrdered))).toBe(packageDigest(walked))
expect(walked.map((file) => file.path)).toEqual([
'Zebra.md',
'apple.md',
'sub/inner.txt',
'sub.md'
])
})
it('rejects rewrites of released snapshots and allows floating-tail replacement', () => {
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
const artifacts = {
releasedSnapshotCounts: { 'orca-cli': 2 },
snapshotRegistry: {
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] }
}
}
expect(() =>
assertReleasedHistoryPreserved(
{ schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } },
artifacts
)
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] }
},
artifacts
)
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] }
},
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: {
'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }]
}
},
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: {
'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')]
}
},
{ ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } }
)
).toThrow('Released snapshot history is incomplete for orca-cli')
expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow()
})
it('protects only revisions named by the committed release mapping', () => {
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
const committedRegistry = {
schemaVersion: 1,
skills: {
'linear-tickets': [snapshot(1, 'released'), snapshot(2, 'unreleased-tail')]
}
}
const artifacts = {
releasedSnapshotCounts: { 'linear-tickets': 2 },
snapshotRegistry: {
schemaVersion: 1,
skills: { 'linear-tickets': [snapshot(1, 'released'), snapshot(2, 'new-release')] }
}
}
expect(() =>
assertReleasedHistoryPreserved(committedRegistry, artifacts, {
schemaVersion: 1,
releases: [{ appVersion: '1.0.0', skills: { 'linear-tickets': 1 } }]
})
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(committedRegistry, artifacts, {
schemaVersion: 1,
releases: [{ appVersion: '1.0.0', skills: { 'linear-tickets': 2 } }]
})
).toThrow('Released snapshot history changed for linear-tickets at revision 2')
})
it('tolerates only redundant trailing release-mapping rows', () => {
const serialized = (value) => `${JSON.stringify(value, null, 2)}\n`
const rows = [
{ appVersion: '1.0.0', skills: { 'orca-cli': 1 } },
{ appVersion: '1.1.0', skills: { 'orca-cli': 2 } }
]
const artifacts = {
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 2 }] },
releaseMapping: { schemaVersion: 1, releases: rows }
}
const committedPrefix = serialized({ schemaVersion: 1, releases: [rows[0]] })
// A just-cut tag whose bytes equal the working tree may lag in the mapping.
expect(isToleratedReleaseMappingPrefix(committedPrefix, artifacts)).toBe(true)
// The committed file matching the derived mapping is byte-equality's job, not tolerance.
expect(isToleratedReleaseMappingPrefix(serialized(artifacts.releaseMapping), artifacts)).toBe(
false
)
// A trailing row for bytes the committed artifacts do not describe is a real gap.
expect(
isToleratedReleaseMappingPrefix(committedPrefix, {
...artifacts,
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 3 }] }
})
).toBe(false)
expect(
isToleratedReleaseMappingPrefix(committedPrefix, {
...artifacts,
currentManifest: {
skills: [
{ name: 'orca-cli', releaseRevision: 2 },
{ name: 'orca-linear', releaseRevision: 1 }
]
}
})
).toBe(false)
// Rewritten earlier rows never pass, with or without trailing rows.
expect(
isToleratedReleaseMappingPrefix(
serialized({
schemaVersion: 1,
releases: [{ appVersion: '0.9.0', skills: { 'orca-cli': 1 } }]
}),
artifacts
)
).toBe(false)
expect(isToleratedReleaseMappingPrefix('not json', artifacts)).toBe(false)
expect(isToleratedReleaseMappingPrefix(serialized({ schemaVersion: 1 }), artifacts)).toBe(false)
})
it('seeds released history from the committed ledger and drops the floating tail', () => {
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
const committedRegistry = {
schemaVersion: 1,
skills: {
// released revs 1..2 named by the mapping, plus an unreleased tail at 3
'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'unreleased')],
// no mapping row -> fall back to all-but-tail
'orca-linear': [snapshot(1, 'ccc'), snapshot(2, 'tail')]
}
}
const committedMapping = {
schemaVersion: 1,
releases: [{ appVersion: '1.0.0', skills: { 'orca-cli': 2 } }]
}
const seeded = releasedHistoryFromCommitted(committedRegistry, committedMapping)
// The unreleased tail is dropped; only mapping-named revisions survive.
expect(seeded.registry.skills['orca-cli']).toEqual([snapshot(1, 'aaa'), snapshot(2, 'bbb')])
expect(seeded.registry.skills['orca-linear']).toEqual([snapshot(1, 'ccc')])
expect(seeded.releasedSnapshotCounts).toEqual({ 'orca-cli': 2, 'orca-linear': 1 })
// The seed clones the mapping so a later release append cannot alias committed state.
expect(seeded.mapping).toEqual(committedMapping)
expect(seeded.mapping).not.toBe(committedMapping)
})
it('returns an empty ledger when no committed artifacts exist', () => {
const seeded = releasedHistoryFromCommitted(null, null)
expect(seeded.registry.skills).toEqual({})
expect(seeded.releasedSnapshotCounts).toEqual({})
expect(seeded.mapping.releases).toEqual([])
})
it('appends one release row, stripping the v-prefix and deduping identical tails', () => {
const artifacts = {
currentManifest: {
skills: [
{ name: 'orca-cli', releaseRevision: 36 },
{ name: 'orca-linear', releaseRevision: 8 }
]
},
releaseMapping: {
schemaVersion: 1,
releases: [{ appVersion: '1.4.151', skills: { 'orca-cli': 35, 'orca-linear': 8 } }]
}
}
appendReleaseRow(artifacts, 'v1.4.160')
expect(artifacts.releaseMapping.releases.at(-1)).toEqual({
appVersion: '1.4.160',
skills: { 'orca-cli': 36, 'orca-linear': 8 }
})
// A second release over identical revisions adds no row.
appendReleaseRow(artifacts, '1.4.161')
expect(artifacts.releaseMapping.releases).toHaveLength(2)
})
it('overwrites the trailing row when a failed cut is re-cut at the same version', () => {
const artifacts = {
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 37 }] },
releaseMapping: {
schemaVersion: 1,
releases: [
{ appVersion: '1.4.151', skills: { 'orca-cli': 35 } },
// The failed cut already pushed this row to main at revision 36.
{ appVersion: '1.4.160', skills: { 'orca-cli': 36 } }
]
}
}
appendReleaseRow(artifacts, '1.4.160')
// One row per version: the tag ships revision 37, so 36 must not linger.
expect(artifacts.releaseMapping.releases).toEqual([
{ appVersion: '1.4.151', skills: { 'orca-cli': 35 } },
{ appVersion: '1.4.160', skills: { 'orca-cli': 37 } }
])
})
it('refuses to rewrite an already-shipped version behind the trailing row', () => {
const artifacts = {
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 37 }] },
releaseMapping: {
schemaVersion: 1,
releases: [
{ appVersion: '1.4.151', skills: { 'orca-cli': 35 } },
{ appVersion: '1.4.160', skills: { 'orca-cli': 36 } }
]
}
}
expect(() => appendReleaseRow(artifacts, '1.4.151')).toThrow(/already has a row for 1\.4\.151/)
})
it('records a release without regenerating the content-addressed artifacts', async () => {
const sandbox = await createReleaseSandbox()
sandbox.generate('--write')
const [manifest, registry] = await Promise.all([
sandbox.read('current-manifest.json'),
sandbox.read('snapshot-registry.json')
])
sandbox.generate('--release', 'v1.4.156')
// The cut records provenance for bytes that are already committed, so a
// version-only cut can never rewrite a shipped identity.
expect(JSON.parse(await sandbox.read('release-mapping.json')).releases).toEqual([
{ appVersion: '1.4.156', skills: { demo: 1 } }
])
expect(await sandbox.read('current-manifest.json')).toBe(manifest)
expect(await sandbox.read('snapshot-registry.json')).toBe(registry)
// Bytes that changed since the last regeneration would make the row name a
// revision this tag does not ship — refuse rather than record it.
await sandbox.editSkill('edited after the last regeneration\n')
expect(() => sandbox.generate('--release', '1.4.157')).toThrow(
/Generated skill artifacts are stale/
)
expect(JSON.parse(await sandbox.read('release-mapping.json')).releases).toHaveLength(1)
})
it('freezes a revision once a release records it, and only until then', async () => {
const sandbox = await createReleaseSandbox()
const demoSnapshots = async () =>
JSON.parse(await sandbox.read('snapshot-registry.json')).skills.demo
sandbox.generate('--write')
const unreleased = (await demoSnapshots())[0].packageDigest
// Nothing has shipped revision 1 yet, so re-deriving it over new bytes is
// correct: the tail floats until a release names it.
await sandbox.editSkill('about to ship\n')
sandbox.generate('--write')
const shipped = await demoSnapshots()
expect(shipped).toHaveLength(1)
expect(shipped[0].packageDigest).not.toBe(unreleased)
sandbox.generate('--release', '1.4.156')
// The cut named revision 1, so the next change appends revision 2 instead of
// rebuilding revision 1. Installs carrying the shipped digest keep matching a
// known snapshot — without the ledger row they would match nothing.
await sandbox.editSkill('changed again after the cut\n')
sandbox.generate('--write')
const frozen = await demoSnapshots()
expect(frozen).toHaveLength(2)
expect(frozen[0]).toEqual(shipped[0])
expect(frozen[1].releaseRevision).toBe(2)
})
it.runIf(process.platform !== 'win32')(
'rejects executable files in shipped skill packages',
async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n')
await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n')
await chmod(path.join(packageRoot, 'run.sh'), 0o755)
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
'Executable file is not allowed in a shipped skill: run.sh'
)
}
)
it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
await writeFile(path.join(packageRoot, 'Readme.md'), 'one')
await writeFile(path.join(packageRoot, 'README.md'), 'two')
await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths')
})
it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
await symlink('SKILL.md', path.join(packageRoot, 'linked.md'))
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
'Symlink is not allowed in a shipped skill'
)
})
it('computes the same Git tree identity as Git', async () => {
const packageRoot = path.resolve('skills', 'orca-cli')
const files = await collectPackageFiles(packageRoot)
const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], {
encoding: 'utf8'
})
.trim()
.split(/\s+/)[2]
expect(gitTreeSha(files)).toBe(expected)
})
it('matches Git when a directory and file share a name prefix', async () => {
const packageRoot = await createPackage()
await mkdir(path.join(packageRoot, 'sub'))
await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n')
await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n')
const files = await collectPackageFiles(packageRoot)
execFileSync('git', ['init', '--quiet'], { cwd: packageRoot })
execFileSync('git', ['add', '-A'], { cwd: packageRoot })
const expected = execFileSync('git', ['write-tree'], {
cwd: packageRoot,
encoding: 'utf8'
}).trim()
expect(gitTreeSha(files)).toBe(expected)
})
// Why: every step in the cut job shares one workspace and one index, so any of
// them can stage the content-addressed artifacts and the bump step's own commit
// then carries them into the tag. Grepping the workflow cannot see a path built
// from an env var, a composite action, or concatenation, so the cut asserts its
// own index before committing; this test pins that guard and adds a tripwire
// for the literal spellings.
it('keeps the whole release-cut job off skill regeneration', async () => {
const workflow = parse(
await readFile(path.join(REPO_ROOT, '.github/workflows/release-cut.yml'), 'utf8')
)
const runSteps = workflow.jobs.cut.steps
.filter((step) => typeof step.run === 'string')
.map((step) => ({ name: step.name ?? '(unnamed)', run: step.run.replace(/^\s*#.*$/gm, '') }))
const bumpStep = runSteps.find((step) => step.name === 'Bump package.json and tag')
// The load-bearing check: whatever staged it and however the commit was
// spelled, only these two paths may ship. Asserted on the commit rather than
// the index because `git commit -a/-i/--only/<pathspec>` bypasses the index.
// -F is part of the contract; without it `.` admits a path like packageXjson.
// Flags pinned, not just the command: a `--diff-filter` slipped in here would
// silence modifications, and dropping -m makes a merge commit report nothing.
expect(bumpStep.run).toMatch(
/git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD\s*\|\s*grep -vxF -e 'package\.json' -e 'resources\/skills\/release-mapping\.json'/
)
expect(bumpStep.run.indexOf('grep -vxF')).toBeLessThan(bumpStep.run.indexOf('git tag'))
// ...and that it aborts. A guard degraded to a warning still reads as covered.
// The exit must be inside the guard's own block, not borrowed from a later one.
expect(bumpStep.run).toMatch(
/if \[\[ -n "\$committed" \]\]; then(?:(?!\bfi\b)[\s\S])*exit 1[\s\S]*?fi/
)
// Tripwire only. A step that merely READS this directory may be added here;
// one that writes or stages it must not, and the guard above will reject it.
expect(runSteps.filter((s) => /resources[/\\]skills/.test(s.run)).map((s) => s.name)).toEqual([
'Bump package.json and tag'
])
for (const step of runSteps) {
expect(step.run, step.name).not.toMatch(/--write|generate:skill-bundle-manifest/)
}
})
})
@@ -0,0 +1,18 @@
// Regenerate the electron-updater `.blockmap` for a (re-signed) Windows installer.
// electron-builder 26 dropped the app-builder-bin Go binary; blockmap generation
// now lives in app-builder-lib's pure-JS `buildBlockMap`. Mirrors createBlockmap's
// "gzip" format for the standalone NSIS installer blockmap.
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const [input, output] = process.argv.slice(2)
if (!input || !output) {
console.error('usage: generate-windows-blockmap.mjs <input.exe> <output.exe.blockmap>')
process.exit(1)
}
const { buildBlockMap } = require('app-builder-lib/out/targets/blockmap/blockmap')
const info = await buildBlockMap(input, 'gzip', output)
console.log(`blockmap written: ${output} (installer sha512=${info.sha512}, size=${info.size})`)
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
describe('Git binary compatibility PR gate', () => {
it('runs the real-binary contract at each compatibility boundary', () => {
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const step = workflow.jobs.verify.steps.find(
const step = workflow.jobs.git_compatibility.steps.find(
(candidate) => candidate.name === 'Verify Git binary compatibility matrix'
)
@@ -16,5 +16,8 @@ describe('Git binary compatibility PR gate', () => {
expect(step?.run).toContain('alpine/git:v2.49.1|2.49.1')
expect(step?.run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"')
expect(step?.run).toContain('src/shared/git-binary-compatibility.test.ts')
expect(step?.run).toContain('-j"$(nproc)"')
expect(step?.run).toContain('pids+=("$!")')
expect(step?.run).toContain('wait "$pid" || status=1')
})
})
@@ -0,0 +1,128 @@
#!/usr/bin/env node
// Benchmark: latency of loading one file diff, which reads two git blobs.
//
// The diff loaders in src/main/git/status.ts awaited their two sides in series,
// so the second `git show` could not start until the first had fully returned.
// The two reads are independent, so that serialization was pure added latency on
// every diff the review panel opens.
//
// This spawns the real `git` binary against this repo, so it measures actual
// process-launch and read cost rather than a model of it. Over SSH each diff is
// one relay RPC and the two spawns run host-local inside the relay, so the same
// relative saving applies to remote-host spawn time, not to network round trips.
import { execFile } from 'node:child_process'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
const ITERATIONS = Number(process.env.ORCA_DIFF_BLOB_BENCH_ITERATIONS ?? '10')
const WARMUP = Number(process.env.ORCA_DIFF_BLOB_BENCH_WARMUP ?? '3')
for (const [name, value] of [
['ORCA_DIFF_BLOB_BENCH_ITERATIONS', ITERATIONS],
['ORCA_DIFF_BLOB_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function git(args) {
return new Promise((resolve, reject) => {
execFile('git', args, { cwd: REPO_ROOT, maxBuffer: 256 * 1024 * 1024 }, (error, stdout) =>
error ? reject(error) : resolve(stdout)
)
})
}
// Pre-fix: await one side, then the other.
async function readSequential(leftRef, rightRef, filePath) {
const left = await git(['show', '--end-of-options', `${leftRef}:${filePath}`])
const right = await git(['show', '--end-of-options', `${rightRef}:${filePath}`])
return left.length + right.length
}
// Post-fix: issue both, await together.
async function readConcurrent(leftRef, rightRef, filePath) {
const [left, right] = await Promise.all([
git(['show', '--end-of-options', `${leftRef}:${filePath}`]),
git(['show', '--end-of-options', `${rightRef}:${filePath}`])
])
return left.length + right.length
}
// Why interleaved: running one strategy's whole batch before the other's lets
// cache warming, CPU-frequency drift, and background load correlate with the
// strategy being measured. Alternating per iteration and taking medians keeps
// that drift common to both arms.
async function measureInterleaved(leftRef, rightRef, filePath) {
for (let index = 0; index < WARMUP; index += 1) {
await readSequential(leftRef, rightRef, filePath)
await readConcurrent(leftRef, rightRef, filePath)
}
const sequentialSamples = []
const concurrentSamples = []
for (let index = 0; index < ITERATIONS; index += 1) {
// Alternate which arm goes first so neither systematically pays a cold cache.
const sequentialFirst = index % 2 === 0
for (const runSequential of sequentialFirst ? [true, false] : [false, true]) {
const start = performance.now()
await (runSequential ? readSequential : readConcurrent)(leftRef, rightRef, filePath)
;(runSequential ? sequentialSamples : concurrentSamples).push(performance.now() - start)
}
}
const median = (samples) => {
const sorted = [...samples].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
return { sequential: median(sequentialSamples), concurrent: median(concurrentSamples) }
}
const head = (await git(['rev-parse', 'HEAD'])).trim()
const parent = `${head}~1`
// Files that exist on both sides, spanning small to large so the fixed spawn
// cost and the size-dependent read cost are both represented.
const CANDIDATES = [
'src/main/git/status.ts',
'src/shared/agent-hook-listener.ts',
'src/renderer/src/components/TaskPage.tsx'
]
const files = []
for (const filePath of CANDIDATES) {
try {
await git(['cat-file', '-e', `${parent}:${filePath}`])
await git(['cat-file', '-e', `${head}:${filePath}`])
files.push(filePath)
} catch {
// Skip a path that does not exist on both sides in this checkout.
}
}
if (files.length === 0) {
throw new Error('no benchmark file exists at both HEAD and HEAD~1 in this checkout')
}
const pad = (value, width) => String(value).padStart(width)
console.log('One file diff = two git blob reads. Lower is better.')
console.log(
`iterations=${ITERATIONS} warmup=${WARMUP} (interleaved, medians) head=${head.slice(0, 9)}`
)
console.log(
`${pad('file', 26)} ${pad('sequential', 12)} ${pad('concurrent', 12)} ${pad('speedup', 9)} ${pad('saved', 10)}`
)
for (const filePath of files) {
const sequentialBytes = await readSequential(parent, head, filePath)
const concurrentBytes = await readConcurrent(parent, head, filePath)
if (sequentialBytes !== concurrentBytes) {
throw new Error(`byte mismatch for ${filePath}`)
}
const { sequential, concurrent } = await measureInterleaved(parent, head, filePath)
console.log(
`${pad(filePath.split('/').pop(), 26)} ${pad(`${sequential.toFixed(1)} ms`, 12)} ${pad(`${concurrent.toFixed(1)} ms`, 12)} ${pad(`${(sequential / concurrent).toFixed(2)}x`, 9)} ${pad(`${(sequential - concurrent).toFixed(1)} ms`, 10)}`
)
}
console.log(
'\nThe saving is per diff opened, and is dominated by process launch rather than\nfile size — which is why it holds roughly constant across these files.'
)
@@ -0,0 +1,23 @@
import { execFileSync } from 'node:child_process'
import process from 'node:process'
export function selectPullRequestDiffBase(requestedBase, headParents, eventName) {
if (eventName === 'pull_request' && headParents.length >= 2) {
return headParents[0]
}
return requestedBase
}
export function resolvePullRequestDiffBase(
root,
requestedBase,
eventName = process.env.GITHUB_EVENT_NAME
) {
const [, ...headParents] = execFileSync('git', ['rev-list', '--parents', '-n', '1', 'HEAD'], {
cwd: root,
encoding: 'utf8'
})
.trim()
.split(/\s+/)
return selectPullRequestDiffBase(requestedBase, headParents, eventName)
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { selectPullRequestDiffBase } from './git-pull-request-diff-base.mjs'
describe('pull request diff base selection', () => {
it('uses the merge commit first parent for pull request checkouts', () => {
expect(
selectPullRequestDiffBase('event-base', ['current-base', 'pull-request-head'], 'pull_request')
).toBe('current-base')
})
it('keeps the requested base outside synthetic pull request merges', () => {
expect(selectPullRequestDiffBase('requested-base', ['parent'], 'pull_request')).toBe(
'requested-base'
)
expect(selectPullRequestDiffBase('requested-base', ['parent', 'other'], 'push')).toBe(
'requested-base'
)
})
})
@@ -0,0 +1,178 @@
#!/usr/bin/env node
// Benchmark: the per-id worktree/tab lookups in session hydration and terminal reconnect.
//
// Four sites in store/slices/terminals.ts re-flattened worktreesByRepo (or tabsByWorktree)
// and linearly searched it once per loop iteration -- O(rows x ids) for O(rows + ids)
// distinct work. The fix builds one first-wins index per loop.
//
// This runs on the renderer's synchronous cold-start path and gates workspaceSessionReady,
// which blocks terminal pane mounting, so the cost is paid before the first frame.
//
// Both arms produce the resolved rows and are compared for equality before timing, so an
// index that resolved differently could not be reported as a win.
//
// Run with: node config/scripts/hydrate-worktree-lookup-benchmark.mjs
import { performance } from 'node:perf_hooks'
const ITERATIONS = Number(process.env.ORCA_HYDRATE_BENCH_ITERATIONS ?? '60')
const WARMUP = Number(process.env.ORCA_HYDRATE_BENCH_WARMUP ?? '10')
const ROUNDS = 6
for (const [name, value] of [
['ORCA_HYDRATE_BENCH_ITERATIONS', ITERATIONS],
['ORCA_HYDRATE_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
// Pre-fix: re-flatten and linear-search per id.
function resolveByFlatten(worktreesByRepo, ids) {
const resolved = []
for (const id of ids) {
const worktree = Object.values(worktreesByRepo)
.flat()
.find((entry) => entry.id === id)
resolved.push(worktree ? worktree.repoId : null)
}
return resolved
}
// Post-fix: mirrors buildWorktreeByIdIndex in store/slices/worktree-by-id-index.ts.
function resolveByIndex(worktreesByRepo, ids) {
const index = new Map()
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
if (!index.has(worktree.id)) {
index.set(worktree.id, worktree)
}
}
}
const resolved = []
for (const id of ids) {
const worktree = index.get(id)
resolved.push(worktree ? worktree.repoId : null)
}
return resolved
}
function makeStore(repoCount, worktreesPerRepo) {
const worktreesByRepo = {}
for (let repo = 0; repo < repoCount; repo += 1) {
const repoId = `repo-${repo}`
worktreesByRepo[repoId] = Array.from({ length: worktreesPerRepo }, (_value, index) => ({
id: `${repoId}/wt-${index}`,
repoId,
path: `/Users/dev/worktrees/${repoId}/wt-${index}`,
branch: `feature/branch-${index}`
}))
}
// Why a deliberate duplicate: `.find()` is first-wins, so an index that overwrote on
// collision would resolve a different repo. Without a collision in the fixture that
// difference is unobservable and the equality check below would pass a broken index.
if (repoCount > 1) {
const [firstRepo, secondRepo] = Object.keys(worktreesByRepo)
worktreesByRepo[secondRepo] = [
{ ...worktreesByRepo[firstRepo][0], repoId: secondRepo },
...worktreesByRepo[secondRepo]
]
}
return worktreesByRepo
}
// Why a miss fraction: SSH worktrees are absent from worktreesByRepo at cold start, so
// the real workload includes ids that scan the whole list without matching -- the worst
// case for the linear arm, and the one the code comments call out explicitly.
function makeIds(worktreesByRepo, count) {
const all = Object.values(worktreesByRepo).flat()
const ids = Array.from({ length: count }, (_value, index) =>
index % 7 === 0 ? `absent/wt-${index}` : all[(index * 31) % all.length].id
)
// Always look up the duplicated id, so first-wins is exercised, not just present.
ids[1] = all[0].id
return ids
}
function timeArm(resolve, worktreesByRepo, ids) {
let sink = 0
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
// Consume the result so V8 cannot drop the call as dead.
sink += resolve(worktreesByRepo, ids).length
}
const elapsed = (performance.now() - start) / ITERATIONS
if (sink === -1) {
throw new Error('unreachable')
}
return elapsed
}
function median(samples) {
const sorted = [...samples].sort((a, b) => a - b)
const mid = sorted.length / 2
return (sorted[mid - 1] + sorted[mid]) / 2
}
// Arms alternate which one leads so within-round drift cannot favour either.
function measure(worktreesByRepo, ids) {
for (let index = 0; index < WARMUP; index += 1) {
resolveByFlatten(worktreesByRepo, ids)
resolveByIndex(worktreesByRepo, ids)
}
const flattenSamples = []
const indexSamples = []
for (let round = 0; round < ROUNDS; round += 1) {
if (round % 2 === 0) {
flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids))
indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids))
} else {
indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids))
flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids))
}
}
return { flattenMs: median(flattenSamples), indexMs: median(indexSamples) }
}
const pad = (value, width) => String(value).padStart(width)
console.log('Session-hydration worktree lookup, per cold start. Lower is better.')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`)
console.log(
`${pad('repos', 6)} ${pad('worktrees', 10)} ${pad('ids', 5)} ${pad('flatten', 11)} ${pad('indexed', 11)} ${pad('speedup', 9)}`
)
// Row shapes are synthetic, sized against a real orca-data.json on a heavy machine
// (10 repos / 423 worktrees / 188 pending reconnect). They are not that dataset: the
// generator spreads worktrees evenly and injects one duplicate id, so treat the counts
// as "about this scale", not a replay.
for (const [repoCount, worktreesPerRepo, idCount] of [
[1, 5, 5],
[3, 20, 20],
[10, 42, 188],
[10, 100, 400]
]) {
const worktreesByRepo = makeStore(repoCount, worktreesPerRepo)
const ids = makeIds(worktreesByRepo, idCount)
const flattenResult = resolveByFlatten(worktreesByRepo, ids)
const indexResult = resolveByIndex(worktreesByRepo, ids)
if (JSON.stringify(flattenResult) !== JSON.stringify(indexResult)) {
throw new Error(`resolution differs at ${repoCount} repos x ${worktreesPerRepo} worktrees`)
}
if (!flattenResult.some((value) => value !== null)) {
throw new Error(`fixture resolved nothing at ${repoCount} repos`)
}
if (!flattenResult.some((value) => value === null)) {
throw new Error(`fixture had no absent ids at ${repoCount} repos`)
}
// Count the generated rows rather than multiplying: makeStore injects a duplicate
// id for multi-repo cases, so the product would misreport the fixture by one.
const worktreeCount = Object.values(worktreesByRepo).reduce((sum, rows) => sum + rows.length, 0)
const { flattenMs, indexMs } = measure(worktreesByRepo, ids)
console.log(
`${pad(repoCount, 6)} ${pad(worktreeCount, 10)} ${pad(idCount, 5)} ${pad(`${flattenMs.toFixed(4)} ms`, 11)} ${pad(`${indexMs.toFixed(4)} ms`, 11)} ${pad(`${(flattenMs / indexMs).toFixed(1)}x`, 9)}`
)
}
console.log(
'\nFixtures are synthetic at real-world scale, not a replay of a real session.\nThis times one of the four lookup sites. A one-repo session sees almost nothing;\nthe win scales with worktrees x pending ids, and lands on the cold-start path that\ngates terminal pane mounting.'
)
@@ -24,6 +24,20 @@ const { downloadArtifact } = electronRequire('@electron/get')
const targetPlatform = getElectronTargetPlatform()
const targetArch = getElectronTargetArch()
const platformPath = getElectronPlatformPath(targetPlatform)
const transientDownloadErrorCodes = new Set([
'EAI_AGAIN',
'ECONNREFUSED',
'ECONNRESET',
'ENETDOWN',
'ENETRESET',
'ENETUNREACH',
'ENOTFOUND',
'EPIPE',
'ETIMEDOUT',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_HEADERS_TIMEOUT',
'UND_ERR_SOCKET'
])
try {
// Why: Electron's own install.js can exit 0 while an async extract promise is
@@ -111,7 +125,7 @@ async function installElectronPackageBinary() {
const extractDir = join(tempDir, 'extract')
try {
const zipPath = await downloadArtifact({
const downloadOptions = {
version: electronVersion,
artifactName: 'electron',
platform: targetPlatform,
@@ -120,7 +134,8 @@ async function installElectronPackageBinary() {
force: true,
tempDirectory: tempDir,
...(shouldUseRemoteChecksums() ? {} : { checksums: electronRequire('./checksums.json') })
})
}
const zipPath = await downloadElectronArtifactWithRetry(downloadOptions)
// Why: CI has observed partial extracts directly under node_modules/electron
// that leave only dist/locales. Verify in temp before replacing package dist.
@@ -145,6 +160,82 @@ async function installElectronPackageBinary() {
}
}
async function downloadElectronArtifactWithRetry(downloadOptions) {
const retryDelays = getDownloadRetryDelays()
for (let attempt = 0; ; attempt += 1) {
try {
return await downloadArtifact(downloadOptions)
} catch (error) {
const retryDelay = retryDelays[attempt]
if (retryDelay === undefined || !isTransientDownloadError(error)) {
throw error
}
console.warn(
`[electron-package] Transient Electron download failure (${formatDownloadError(error)}); ` +
`retrying in ${retryDelay}ms (${attempt + 2}/${retryDelays.length + 1}).`
)
rmSync(downloadOptions.cacheRoot, { recursive: true, force: true })
await new Promise((resolveDelay) => setTimeout(resolveDelay, retryDelay))
}
}
}
function getDownloadRetryDelays() {
const configured = process.env.ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS
if (!configured) {
return [1_000, 3_000]
}
const delays = configured.split(',').map(Number)
if (delays.some((delay) => !Number.isSafeInteger(delay) || delay < 0)) {
throw new Error('ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS must contain non-negative integers')
}
return delays
}
function isTransientDownloadError(error) {
for (const candidate of getErrorChain(error)) {
if (transientDownloadErrorCodes.has(candidate?.code)) {
return true
}
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
if (
statusCode === 408 ||
statusCode === 425 ||
statusCode === 429 ||
(statusCode >= 500 && statusCode < 600)
) {
return true
}
}
return false
}
function getErrorChain(error) {
const errors = []
let candidate = error
while (candidate && errors.length < 5) {
errors.push(candidate)
candidate = candidate.cause
}
return errors
}
function formatDownloadError(error) {
for (const candidate of getErrorChain(error)) {
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
if (statusCode) {
return `HTTP ${statusCode}`
}
if (candidate?.code) {
return candidate.code
}
}
return error instanceof Error ? error.message : String(error)
}
function extractElectronArchive(zipPath, extractDir) {
mkdirSync(extractDir, { recursive: true })
// Why: extract-zip/Electron install.js can leave Node 24 with an unsettled
@@ -93,6 +93,102 @@ describe('install-electron-package-binary', () => {
}
})
it('retries transient Electron download failures', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, {
downloadFailures: 1,
downloadErrorCode: 'ECONNRESET'
})
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
})
expect(result.status, result.stderr).toBe(0)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(2)
expect(result.stderr).toContain('Transient Electron download failure (ECONNRESET)')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('fails after exhausting transient Electron download retries', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, {
downloadFailures: 5,
downloadErrorCode: 'ECONNRESET'
})
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
})
expect(result.status).toBe(1)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(3)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('rejects invalid Electron download retry delays before downloading', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,nope'
})
expect(result.status).toBe(1)
expect(result.stderr).toContain(
'ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS must contain non-negative integers'
)
expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('does not retry permanent Electron download failures', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, {
downloadFailures: 3,
downloadErrorCode: 'EACCES'
})
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
})
expect(result.status).toBe(1)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(1)
expect(result.stderr).not.toContain('Transient Electron download failure')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('fails instead of silently accepting a partial Electron extract', () => {
const projectDir = mkTempProject()
@@ -181,7 +277,10 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
)
}
function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) {
function writeFakeElectronGet(
projectDir,
{ downloadNeverSettles = false, downloadFailures = 0, downloadErrorCode = 'ECONNRESET' } = {}
) {
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
mkdirSync(getDir, { recursive: true })
writeFileSync(
@@ -189,7 +288,9 @@ function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {})
`
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
const { join } = require('node:path')
let downloadAttempt = 0
exports.downloadArtifact = async function downloadArtifact(details) {
downloadAttempt += 1
appendFileSync(
'electron-get.log',
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + '\\n'
@@ -197,6 +298,12 @@ exports.downloadArtifact = async function downloadArtifact(details) {
if (${JSON.stringify(downloadNeverSettles)}) {
return new Promise(() => {})
}
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
const cause = Object.assign(new Error('download failed'), {
code: ${JSON.stringify(downloadErrorCode)}
})
throw Object.assign(new TypeError('fetch failed'), { cause })
}
mkdirSync(details.cacheRoot, { recursive: true })
const artifactPath = join(details.cacheRoot, 'electron.zip')
writeFileSync(artifactPath, 'fake zip')
@@ -200,7 +200,7 @@ export async function setupTerminal(page, repoPath, logPhase) {
return pane?.container?.dataset?.ptyId ?? null
})
)
logPhase('setup.pty-bound', `ptyId=${ptyId}`)
logPhase('setup.pty-bound', `ptyId=${String(ptyId)}`)
return ptyId
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
// The theme-picker count row renders "Showing {count}" immediately followed by one of these
// fragments, so translations must keep a leading separator or the numbers fuse ("표시 중 3030 중").
describe('locale-count-fragment-separator', () => {
it('keeps a slash between shown and total theme counts in CJK locales', () => {
const brokenByLocale = { ko: '{{value0}} 중', ja: '{{value0}}の', zh: '{{value0}} 的' }
for (const [locale, localeValue] of Object.entries(brokenByLocale)) {
expect(
repairTranslatedValue({
key: 'auto.components.settings.SettingsFormControls.cb330ef7f8',
enValue: ' of {{value0}}',
localeValue,
locale
})
).toBe('/{{value0}}')
}
})
it('keeps a leading space before the search-match fragment in CJK locales', () => {
const cases = [
['ko', '"{{value0}}"과(와) 일치', ' "{{value0}}"과(와) 일치'],
['ja', '「{{value0}}」に一致', ' 「{{value0}}」に一致'],
['zh', '匹配“{{value0}}”', ' 匹配“{{value0}}”']
]
for (const [locale, localeValue, repaired] of cases) {
expect(
repairTranslatedValue({
key: 'auto.components.settings.SettingsFormControls.c822571b2e',
enValue: ' matching "{{value0}}"',
localeValue,
locale
})
).toBe(repaired)
}
})
})
@@ -19,6 +19,18 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
zh: '集成',
ja: '連携'
},
// Search-match fragment concatenated flush after the visible theme count; MT dropped the
// en leading space that separates it from the count.
'auto.components.settings.SettingsFormControls.c822571b2e': {
zh: ' 匹配“{{value0}}”',
ja: ' 「{{value0}}」に一致'
},
// Total-count fragment on the same row; without the separator "Showing 30 of 30"
// rendered as "表示中 3030の" / "显示中 3030 的".
'auto.components.settings.SettingsFormControls.cb330ef7f8': {
zh: '/{{value0}}',
ja: '/{{value0}}'
},
'auto.components.settings.TasksPane.6b23a34f6d': {
zh: 'Jira',
ja: 'Jira'
@@ -1,5 +1,6 @@
import { CROSS_LOCALE_KEY_OVERRIDES } from './locale-cross-locale-key-overrides.mjs'
import { KO_KEY_OVERRIDES } from './locale-ko-key-overrides.mjs'
import { MACOS_TCC_KEY_OVERRIDES } from './locale-macos-tcc-key-overrides.mjs'
export function mergeLocaleKeyOverrides(base) {
const merged = { ...base }
@@ -10,5 +11,8 @@ export function mergeLocaleKeyOverrides(base) {
// KO split overrides can share keys with zh/ja repairs; merge per locale.
merged[key] = { ...merged[key], ...overrides }
}
for (const [key, overrides] of Object.entries(MACOS_TCC_KEY_OVERRIDES)) {
merged[key] = { ...merged[key], ...overrides }
}
return merged
}
+5 -5
View File
@@ -2231,9 +2231,6 @@
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
"ko": "마이크"
},
"auto.components.settings.DeveloperPermissionsPane.7ca17b62c8": {
"ko": "프로젝트, 워크트리 또는 심볼릭 링크된 파일이 macOS 보호 폴더에 닿을 때 권장됩니다."
},
"auto.components.settings.DeveloperPermissionsPane.e119f0d66b": {
"ko": "자동화"
},
@@ -2667,7 +2664,10 @@
"ko": "SSH를 통해 기존 머신의 파일, terminals, Git, 워크스페이스를 사용합니다."
},
"auto.components.settings.SettingsFormControls.c822571b2e": {
"ko": "\"{{value0}}\"과(와) 일치"
"ko": " \"{{value0}}\"과(와) 일치"
},
"auto.components.settings.SettingsFormControls.cb330ef7f8": {
"ko": "/{{value0}}"
},
"auto.components.settings.SettingsFormControls.fbb428db98": {
"ko": "선택됨:"
@@ -2811,7 +2811,7 @@
"ko": "{{value0}} 메가바이트"
},
"auto.components.settings.TerminalPane.6e6480a7df": {
"ko": "terminal의 프로그램(tmux, Neovim, fzf, SSH)이 시스템 클립보드에 복사할 수 있게 합니다."
"ko": "terminal의 프로그램(Zellij, tmux, Neovim, fzf, Grok, SSH)이 시스템 클립보드에 복사할 수 있게 합니다."
},
"auto.components.settings.TerminalPane.8eefeaa3da": {
"ko": "마우스를 따라 포커스 이동"
@@ -0,0 +1,18 @@
export const MACOS_TCC_KEY_OVERRIDES = {
'auto.hooks.useMacosTccPromptNotice.description': {
es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.',
ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。',
ko: '에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.',
zh: 'macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。'
},
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': {
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.',
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。',
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.',
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。'
},
'auto.components.settings.DeveloperPermissionsPane.c566bca278': {
ko: '전체 디스크 접근 권한',
zh: '完全磁盘访问权限'
}
}
@@ -0,0 +1,19 @@
const LOCALIZED_PROSE_TERM_KEYS = new Set([
'auto.hooks.useMacosTccPromptNotice.description',
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
])
const LOCALIZABLE_PROSE_TERMS = new Set([
'Agent',
'Agents',
'agent',
'agents',
'Terminal',
'Terminals',
'terminal',
'terminals'
])
export function isLocalizedProseTermContext(term, key) {
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
}
@@ -1,4 +1,5 @@
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
@@ -382,6 +383,9 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
if (isScreenCursorContext(brand, enValue, key)) {
continue
}
if (isLocalizedProseTermContext(brand, key)) {
continue
}
if (includesPreservedLatinTerm(result, brand)) {
continue
}
@@ -42,6 +42,8 @@ export const ZH_VALUE_OVERRIDES = {
'Open the PR details to view current reviewers.': '打开 PR 详情以查看当前评审人。',
'Review comment added.': '已添加评审评论。',
'Loading labels': '加载标签',
// Why: MT rendered the "Pin Tab" action as "引脚标签" (noun reading of "pin"); pair it with 取消固定标签.
'Pin Tab': '固定标签',
Approved: '已批准',
Strike: '删除线',
Bold: '粗体',
@@ -0,0 +1,34 @@
const { writeFileSync } = require('node:fs')
const { join } = require('node:path')
const compatibilityContract = require('../../src/shared/local-build-compatibility-contract.json')
const MAC_BUILD_COMPATIBILITY_FILENAME = 'orca-local-build.json'
function createMacBuildCompatibility({ version, commit, architecture }) {
if (architecture !== 'arm64' && architecture !== 'x64') {
throw new Error(`Unsupported macOS build architecture: ${architecture}`)
}
return {
...compatibilityContract,
buildId: `${version}-${commit}-${architecture}`,
version,
commit,
platform: 'darwin',
architecture
}
}
function writeMacBuildCompatibility(resourcesDir, identity) {
const compatibility = createMacBuildCompatibility(identity)
writeFileSync(
join(resourcesDir, MAC_BUILD_COMPATIBILITY_FILENAME),
`${JSON.stringify(compatibility, null, 2)}\n`,
'utf8'
)
}
module.exports = {
MAC_BUILD_COMPATIBILITY_FILENAME,
createMacBuildCompatibility,
writeMacBuildCompatibility
}
@@ -0,0 +1,36 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const { createMacBuildCompatibility } = require('./mac-build-compatibility.cjs')
describe('mac build compatibility metadata', () => {
it('binds version, commit, and architecture into the packaged contract', () => {
expect(
createMacBuildCompatibility({
version: '1.2.3-local.1',
commit: 'abc123',
architecture: 'arm64'
})
).toMatchObject({
formatVersion: 1,
appId: 'com.stablyai.orca',
buildId: '1.2.3-local.1-abc123-arm64',
version: '1.2.3-local.1',
commit: 'abc123',
stateSchemaVersion: 1,
platform: 'darwin',
architecture: 'arm64'
})
})
it('rejects unsupported architecture metadata', () => {
expect(() =>
createMacBuildCompatibility({
version: '1.2.3',
commit: 'abc123',
architecture: 'universal'
})
).toThrow('Unsupported macOS build architecture')
})
})
@@ -0,0 +1,50 @@
import fs from 'node:fs'
import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
const LOCALES = ['es', 'ja', 'ko', 'zh']
const KEYS = [
'auto.hooks.useMacosTccPromptNotice.title',
'auto.hooks.useMacosTccPromptNotice.description',
'auto.hooks.useMacosTccPromptNotice.openSettings',
'auto.hooks.useMacosTccPromptNotice.dismiss',
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
'auto.components.settings.DeveloperPermissionsPane.c566bca278'
]
function readCatalog(locale) {
return JSON.parse(
fs.readFileSync(
new URL(`../../src/renderer/src/i18n/locales/${locale}.json`, import.meta.url),
'utf8'
)
)
}
function getValue(catalog, key) {
return key.split('.').reduce((value, part) => value[part], catalog)
}
describe('macOS TCC prompt localization', () => {
it('survives the canonical catalog repair policy', () => {
const english = readCatalog('en')
for (const locale of LOCALES) {
const catalog = readCatalog(locale)
for (const key of KEYS) {
const enValue = getValue(english, key)
const localeValue = getValue(catalog, key)
expect(repairTranslatedValue({ key, enValue, localeValue, locale })).toBe(localeValue)
}
}
})
it('uses the macOS Full Disk Access labels in Korean and Chinese', () => {
expect(
getValue(readCatalog('ko'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
).toBe('전체 디스크 접근 권한')
expect(
getValue(readCatalog('zh'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
).toBe('完全磁盘访问权限')
})
})
@@ -0,0 +1,192 @@
#!/usr/bin/env node
// Benchmark: cost of the mobile agent-status projection per store mutation.
//
// buildRuntimeMobileAgentStatusProjection runs on the App.tsx global store
// subscriber. setAgentStatus replaces one entry and re-spreads
// agentStatusByPaneKey, which defeats the reference-equality skip gate, so before
// the fix EVERY live agent was re-serialized on EVERY status ping — each carrying
// a prompt, a 20-entry stateHistory, toolInput, and an 8 KB-capped
// lastAssistantMessage.
//
// The fix memoizes each row's JSON by entry identity, mirroring the
// cachedTabsProjection pattern already in the same file, so a ping re-serializes
// only the agent that actually changed.
//
// The bucket width is re-read from the real module so a drifted constant fails
// loudly here instead of quietly changing what this benchmark measures.
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const GRAPH_SOURCE = readFileSync(
fileURLToPath(new URL('../../src/renderer/src/runtime/sync-runtime-graph.ts', import.meta.url)),
'utf8'
)
const bucketMatch = GRAPH_SOURCE.match(/AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = ([0-9_]+)/)
if (!bucketMatch) {
throw new Error(
'sync-runtime-graph.ts no longer defines the updatedAt bucket; re-sync this benchmark.'
)
}
const BUCKET_MS = Number(bucketMatch[1].replaceAll('_', ''))
const ITERATIONS = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_ITERATIONS ?? '400', 10)
const WARMUP = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_WARMUP ?? '60', 10)
for (const [name, value] of [
['ORCA_AGENT_PROJECTION_BENCH_ITERATIONS', ITERATIONS],
['ORCA_AGENT_PROJECTION_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function toRow(paneKey, entry) {
return {
paneKey,
entryPaneKey: entry.paneKey,
state: entry.state,
prompt: entry.prompt,
updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS),
stateStartedAt: entry.stateStartedAt,
agentType: entry.agentType ?? null,
terminalTitle: entry.terminalTitle ?? null,
stateHistory: entry.stateHistory.map((history) => ({
state: history.state,
prompt: history.prompt,
startedAt: history.startedAt,
interrupted: history.interrupted ?? null
})),
toolName: entry.toolName ?? null,
toolInput: entry.toolInput ?? null,
interactivePrompt: entry.interactivePrompt ?? null,
lastAssistantMessage: entry.lastAssistantMessage ?? null,
interrupted: entry.interrupted ?? null
}
}
function serializeEntry(paneKey, entry) {
return JSON.stringify(toRow(paneKey, entry))
}
// Pre-fix: build plain rows and stringify the array once — no per-row roundtrip,
// which the original never paid and which would inflate the reported speedup.
function buildFull(map) {
return JSON.stringify(
Object.entries(map)
.sort(([a], [b]) => a.localeCompare(b))
.map(([paneKey, entry]) => toRow(paneKey, entry))
)
}
// Post-fix: reuse each row's JSON while its entry object is unchanged.
function makeCachedBuilder() {
let cache = null
return (map) => {
if (cache?.source === map) {
return cache.projection
}
const previous = cache?.entries
const entries = new Map()
const parts = []
for (const [paneKey, entry] of Object.entries(map).sort(([a], [b]) => a.localeCompare(b))) {
const prior = previous?.get(paneKey)
const row =
prior?.entry === entry ? prior : { entry, projection: serializeEntry(paneKey, entry) }
entries.set(paneKey, row)
parts.push(row.projection)
}
const projection = `[${parts.join(',')}]`
cache = { source: map, entries, projection }
return projection
}
}
// A live agent as the store actually holds it.
function makeEntry(index, updatedAt) {
return {
paneKey: `tab-${index}:leaf-0`,
state: 'working',
prompt: 'implement the feature and run the tests '.repeat(4),
updatedAt,
stateStartedAt: 1740000000000,
agentType: 'claude',
terminalTitle: `agent ${index}`,
stateHistory: Array.from({ length: 20 }, (_value, step) => ({
state: 'working',
prompt: `step ${step} of the current turn`,
startedAt: 1740000000000 + step,
interrupted: null
})),
toolName: 'shell_command',
toolInput: 'rg --line-number "pattern" src/ '.repeat(8),
interactivePrompt: null,
// The cap the store applies to assistant text.
lastAssistantMessage: 'x'.repeat(8000),
interrupted: null
}
}
function makeMap(agents) {
const map = {}
for (let index = 0; index < agents; index += 1) {
map[`tab-${index}:leaf-0`] = makeEntry(index, 1740000000000 + index * BUCKET_MS)
}
return map
}
// One status ping: one entry replaced, the map re-spread, every other entry
// reference-identical — exactly what setAgentStatus produces.
function ping(map, round) {
return {
...map,
'tab-0:leaf-0': makeEntry(0, 1740000000000 + BUCKET_MS * (round + 1))
}
}
function measure(build, map) {
let current = map
for (let index = 0; index < WARMUP; index += 1) {
current = ping(current, index)
build(current)
}
const samples = []
for (let round = 0; round < 5; round += 1) {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
current = ping(current, index)
build(current)
}
samples.push((performance.now() - start) / ITERATIONS)
}
samples.sort((a, b) => a - b)
return samples[2]
}
const pad = (value, width) => String(value).padStart(width)
console.log('Mobile agent-status projection, per status ping (one agent changed)')
console.log(`bucket=${BUCKET_MS}ms iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
console.log(`${pad('agents', 8)} ${pad('full', 11)} ${pad('cached', 11)} ${pad('speedup', 9)}`)
for (const agents of [3, 8, 20, 40]) {
const map = makeMap(agents)
const cachedBuilder = makeCachedBuilder()
if (buildFull(map) !== cachedBuilder(map)) {
throw new Error(`projection mismatch at ${agents} agents`)
}
// Why also after a ping: the cold call reuses nothing, so a stale-row bug would
// only surface once the cache is actually exercised.
const pinged = ping(map, 0)
if (buildFull(pinged) !== cachedBuilder(pinged)) {
throw new Error(`projection mismatch after a ping at ${agents} agents`)
}
const full = measure(buildFull, map)
const cached = measure(makeCachedBuilder(), map)
console.log(
`${pad(agents, 8)} ${pad(`${full.toFixed(4)} ms`, 11)} ${pad(`${cached.toFixed(4)} ms`, 11)} ${pad(`${(full / cached).toFixed(1)}x`, 9)}`
)
}
console.log(
'\nThis runs on the global store subscriber, so the cost is paid per status ping\nand scales with the number of agents running in parallel — the workload this\napp exists for.'
)
@@ -0,0 +1,47 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
const pluginPath = path.resolve('config/oxlint-plugins/mobile-pairing-qrcode-import.mjs')
const oxlintPath = path.resolve(
process.platform === 'win32' ? 'node_modules/.bin/oxlint.cmd' : 'node_modules/.bin/oxlint'
)
function lintSource(source) {
const directory = mkdtempSync(path.join(tmpdir(), 'orca-qrcode-import-lint-'))
const sourcePath = path.join(directory, 'sample.ts')
const configPath = path.join(directory, 'oxlint.json')
writeFileSync(sourcePath, source)
writeFileSync(
configPath,
JSON.stringify({
categories: { correctness: 'off' },
jsPlugins: [{ name: 'mobile-pairing', specifier: pluginPath }],
rules: { 'mobile-pairing/no-eager-qrcode-import': 'error' }
})
)
const result = spawnSync(oxlintPath, ['--config', configPath, '--format', 'json', sourcePath], {
encoding: 'utf8'
})
if (result.error) {
throw result.error
}
return JSON.parse(result.stdout).diagnostics
}
describe('mobile pairing qrcode import rule', () => {
it('rejects eager runtime imports', () => {
const diagnostics = lintSource("import QRCode from 'qrcode'\nvoid QRCode")
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
'mobile-pairing(no-eager-qrcode-import)'
])
})
it('allows type-only and lazy imports', () => {
expect(lintSource("import type QRCode from 'qrcode'\nlet qr: typeof QRCode")).toEqual([])
expect(lintSource("const QRCode = await import('qrcode')\nvoid QRCode")).toEqual([])
})
})
@@ -0,0 +1,82 @@
import { createRequire } from 'node:module'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
assertPatchedNodePtyConsoleListAgent,
patchNodePtyConsoleListAgent
} = require('../relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs')
const projectDir = resolve(import.meta.dirname, '..', '..')
const cleanupDirs = []
afterEach(() => {
for (const dir of cleanupDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
describe('Windows SSH relay node-pty console-list patch', () => {
it('installs and verifies the fallback idempotently', () => {
const fixture = writeNodePtyFixture('1.1.0', publishedAgentSource())
patchNodePtyConsoleListAgent(fixture.root)
const once = readFileSync(fixture.agentPath, 'utf8')
expect(once).toContain('consoleProcessList = [shellPid];')
expect(existsSync(`${fixture.agentPath}.orca-patch-${process.pid}`)).toBe(false)
expect(() => assertPatchedNodePtyConsoleListAgent(fixture.root)).not.toThrow()
patchNodePtyConsoleListAgent(fixture.root)
expect(readFileSync(fixture.agentPath, 'utf8')).toBe(once)
})
it('refuses a different package version or unexpected agent source', () => {
const wrongVersion = writeNodePtyFixture('1.2.0-beta.11', publishedAgentSource())
expect(() => patchNodePtyConsoleListAgent(wrongVersion.root)).toThrow('expected 1.1.0')
const drifted = writeNodePtyFixture('1.1.0', `${publishedAgentSource()}\n// drift`)
expect(() => patchNodePtyConsoleListAgent(drifted.root)).toThrow('unexpected node-pty')
const tamperedPatch = writeNodePtyFixture('1.1.0', publishedAgentSource())
patchNodePtyConsoleListAgent(tamperedPatch.root)
writeFileSync(
tamperedPatch.agentPath,
`${readFileSync(tamperedPatch.agentPath, 'utf8')}\n// drift`
)
expect(() => assertPatchedNodePtyConsoleListAgent(tamperedPatch.root)).toThrow('not installed')
})
})
function writeNodePtyFixture(version, agentSource) {
const root = mkdtempSync(join(projectDir, '.node-pty-console-list-patch-test-'))
cleanupDirs.push(root)
const nodePtyDir = join(root, 'node_modules', 'node-pty')
const libDir = join(nodePtyDir, 'lib')
const agentPath = join(libDir, 'conpty_console_list_agent.js')
mkdirSync(libDir, { recursive: true })
writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version }))
writeFileSync(agentPath, agentSource)
return { root, agentPath }
}
function publishedAgentSource() {
return [
'"use strict";',
'/**',
' * Copyright (c) 2019, Microsoft Corporation (MIT License).',
' *',
' * This module fetches the console process list for a particular PID. It must be',
' * called from a different process (child_process.fork) as there can only be a',
' * single console attached to a process.',
' */',
'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);',
'process.send({ consoleProcessList: consoleProcessList });',
'process.exit(0);',
'//# sourceMappingURL=conpty_console_list_agent.js.map'
].join('\n')
}
@@ -3,11 +3,17 @@ import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
const skillPath = join(projectDir, 'skills', 'orca-cli', 'SKILL.md')
const orchestrationSkillPath = join(projectDir, 'skills', 'orchestration', 'SKILL.md')
const emulatorSkillPath = join(projectDir, 'skills', 'orca-emulator', 'SKILL.md')
// Why: orca-cli now ships a hybrid discovery stub, so its version-sensitive command
// guidance lives in the authoritative guide source — assert that content there. The
// installable stub projection is checked separately below.
const guidePath = join(projectDir, 'skill-guides', 'orca-cli.md')
const stubPath = join(projectDir, 'skills', 'orca-cli', 'SKILL.md')
// Why: orchestration and orca-emulator also ship hybrid stubs now, so their version-sensitive
// command guidance lives in the guide sources — read the cross-guide worktree-id contract there.
const orchestrationSkillPath = join(projectDir, 'skill-guides', 'orchestration.md')
const emulatorSkillPath = join(projectDir, 'skill-guides', 'orca-emulator.md')
function readSkill(path = skillPath) {
function readSkill(path = guidePath) {
return readFileSync(path, 'utf8')
}
@@ -40,7 +46,7 @@ describe('orca CLI skill guidance', () => {
'`task-create` is also forbidden because it records coordinator-owned tracking state'
)
expect(skill).toContain(
'orca worktree create --name <task-name> --no-parent --agent codex --prompt'
'ORCA worktree create --name <task-name> --no-parent --agent codex --prompt'
)
expect(skill).toContain('codex --model gpt-5.5 -c model_reasoning_effort="xhigh"')
expect(skill).toContain('wait only for TUI readiness if needed to avoid losing input')
@@ -99,3 +105,51 @@ describe('orca CLI skill guidance', () => {
expect(skill).not.toContain('live_sk_')
})
})
describe('orca CLI install stub', () => {
it('points at the version-matched guide and preserves the safe resolver', () => {
const stub = readSkill(stubPath)
expect(stub).toContain('discovery stub')
expect(stub).toContain('ORCA skills get orca-cli')
// The safe CLI-resolution contract must survive in the stub, never a bare `orca`.
expect(stub).toContain('ORCA_CLI_COMMAND')
expect(stub).toContain('orca-dev')
expect(stub).toContain('orca-ide')
expect(stub).toContain('GNOME Orca screen reader')
expect(stub).not.toMatch(/^orca /mu)
})
it('gives older binaries a bounded fallback instead of a dead end', () => {
const stub = readSkill(stubPath).replace(/\s+/gu, ' ')
expect(stub).toContain('explicitly reports that `skills get` is an unknown command')
expect(stub).toContain('do not invent commands')
expect(stub).toContain('ask the user rather than guessing')
})
it('does not mistake resolution or execution failures for an older binary', () => {
const stub = readSkill(stubPath).replace(/\s+/gu, ' ')
// Falling through can silently pair a version-matched guide with the wrong Orca build.
expect(stub).toContain('report its exact error and stop')
expect(stub).toContain('Do not fall through to another executable')
expect(stub).toContain('Another failure is not proof of an older binary')
})
it('drops the changing command reference from the installable file', () => {
const stub = readSkill(stubPath)
// Version-sensitive command detail lives in the binary-served guide now, not here.
expect(stub).not.toContain('Prefer agent-first create for agent workers')
expect(stub).not.toContain('--parent-worktree')
expect(stub).not.toContain('ORCA automations create')
expect(stub.length).toBeLessThan(readSkill(guidePath).length)
})
it('keeps the routing frontmatter identical to the guide', () => {
const frontmatter = (text) => /^---\n[\s\S]*?\n---\n/u.exec(text)[0]
expect(frontmatter(readSkill(stubPath))).toBe(frontmatter(readSkill(guidePath)))
})
})

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