Merge main into brennanb2025/native-chat-map-switch

Resolves the dashboard conflicts between the slideout inspector drawer and
main's Agent Map:

- Board renders AgentDashboardInspectorDrawer; the map keeps its docked
  terminal panel. AgentTerminalDialog had no remaining caller, so the module
  is renamed AgentTerminalPanel and the dialog wrapper dropped; its frame
  tests now run against the panel.
- Both sides extracted the same label/bucket helpers. Kept main's
  (dashboard-card-labels, dashboard-card-bucket) and deleted the branch's
  duplicate dashboard-card-display-fields / dashboard-card-host-kind — main's
  host-kind resolver also handles folder workspaces.
- dashboardCardNativeChatMetadata now contributes only viewMode/sessionId/
  transcriptPath; hostKind comes from the map's workspace metadata.
- The drawer's reveal args pick up main's executionHostId, and its header uses
  dashboardCardDisplayState so acknowledged completions read Idle.
- Kept main's removal of agentStatusByPaneKey from the bridge's watch list
  (hook status is relayed, not rebuilt); added unifiedTabsByWorktree.
This commit is contained in:
Brennan Benson
2026-08-07 11:56:23 -07:00
2151 changed files with 192575 additions and 21291 deletions
+84 -9
View File
@@ -33,6 +33,13 @@ name: Adhoc macOS Dev Build
# Contents: Read and write. The secret names below are historical — one App, one
# private key, both dev-channel repos — and rotating it stays a single operation.
# Provision with `bash config/scripts/setup-hourly-release-token.sh`.
#
# The requested ref is vetted before checkout: it must be a branch or tag of this
# repo, or a commit reachable from one. PR refs are refused outright — this
# workflow runs the checked-out code next to MAC_CERTS and the notary password,
# so "just build that community PR" must not become a way to hand fork code the
# release identity. A branch here always belongs to someone with write access,
# which is the same trust the dispatch button itself already requires.
on:
workflow_dispatch:
@@ -45,7 +52,7 @@ on:
# than the one the workflow file itself is read from — normally leave the
# picker on main and name your branch here, so a stale copy of this
# workflow on an old branch is not what runs.
description: 'Branch, tag, or SHA to build (default: the branch selected above)'
description: 'Branch, tag, or SHA to build — must live in stablyai/orca; PR refs are refused (default: the branch selected above)'
required: false
default: ''
type: string
@@ -77,6 +84,14 @@ env:
jobs:
build-adhoc-mac:
if: github.repository == 'stablyai/orca'
# Why an environment: it gives the signing/notary/App secrets somewhere to
# live that a stale copy of this workflow on an old branch cannot reach.
# Referencing it is a no-op until repo settings give it teeth; the intended
# follow-up is to move MAC_CERTS, MAC_CERTS_PASSWORD, APPLE_ID,
# APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID, HOURLY_RELEASE_APP_ID, and
# HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch
# policy to main so only main's copy of this file can read them.
environment: adhoc-mac-build
runs-on: blacksmith-6vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
@@ -85,6 +100,57 @@ jobs:
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
# Why vet before checkout: everything after this step runs the checked-out
# code with release signing credentials in reach. Branches and tags of this
# repo are the intended audience; refs/pull/* would smuggle in fork code,
# and a raw SHA is only accepted when some branch or tag of this repo can
# actually reach it. Resolving to a pinned SHA here also means the commit
# that was vetted is the commit that gets checked out — a push to the
# branch between the two steps cannot swap it.
- name: Vet the requested ref
id: vetted
shell: bash
env:
REQUESTED_REF: ${{ inputs.ref || github.ref_name }}
REPO_URL: https://github.com/${{ github.repository }}
run: |
set -euo pipefail
case "$REQUESTED_REF" in
refs/pull/*|pull/*)
echo "::error::Refusing to build PR ref '$REQUESTED_REF': this workflow signs with release credentials, so it only builds branches, tags, or commits of stablyai/orca. Push the code to a branch of this repo instead."
exit 1
;;
esac
# Bare: a work-tree repo refuses to fetch over its own checked-out
# branch. tree:0 keeps the fetch to the commit graph — no trees, no
# blobs — so this stays cheap next to the build it fronts.
scratch="$RUNNER_TEMP/vet-requested-ref"
git init -q --bare "$scratch"
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
# Branch first to keep actions/checkout's old tie-break: bare
# rev-parse would prefer the tag when a branch shares its name.
sha=""
for cand in "refs/heads/$REQUESTED_REF" "refs/tags/$REQUESTED_REF" "$REQUESTED_REF"; do
if sha="$(git -C "$scratch" rev-parse --verify --quiet "$cand^{commit}")"; then
break
fi
sha=""
done
if [[ -z "$sha" ]]; then
echo "::error::'$REQUESTED_REF' does not resolve to a branch, tag, or commit of stablyai/orca."
exit 1
fi
# The object resolving locally is not proof a branch or tag reaches
# it: a partial clone can lazily fetch a bare SHA on demand, and
# GitHub serves PR-only commits by SHA. Reachability is the actual
# trust test.
if [[ -z "$(git -C "$scratch" for-each-ref --contains "$sha" refs/heads refs/tags | head -1)" ]]; then
echo "::error::Commit $REQUESTED_REF is not reachable from any branch or tag of stablyai/orca; refusing to build it with release credentials."
exit 1
fi
echo "Vetted $REQUESTED_REF -> $sha"
echo "sha=$sha" >>"$GITHUB_OUTPUT"
- name: Checkout the requested ref
uses: actions/checkout@v6
with:
@@ -92,7 +158,7 @@ jobs:
# build code that has not landed, and the workflow definition itself
# always comes from the dispatch ref — naming the branch here instead
# applies main's current copy of this file to an arbitrary branch.
ref: ${{ inputs.ref || github.ref_name }}
ref: ${{ steps.vetted.outputs.sha }}
fetch-depth: 0
# This job only reads stablyai/orca and never pushes; every write goes
# to the adhoc repo through a minted App token passed by env. Not
@@ -146,17 +212,26 @@ jobs:
env:
REF: ${{ inputs.ref || github.ref_name }}
LABEL: ${{ inputs.label }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Why this check: the version script is read from the branch being built,
# Why this check: the version scripts are read from the branch being built,
# not from main, so a branch cut before the adhoc channel landed has no
# copy of it. Say that plainly instead of failing with a module-not-found.
if [[ ! -f config/scripts/adhoc-build-version.mjs ]]; then
echo "::error::$REF has no config/scripts/adhoc-build-version.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
# copy of them. Say that plainly instead of failing with a module-not-found.
for script in adhoc-build-version dev-channel-base-version; do
if [[ ! -f "config/scripts/$script.mjs" ]]; then
echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
done
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
ORCA_ADHOC_LABEL="${LABEL:-$REF}" node config/scripts/adhoc-build-version.mjs \
# Why the main repo's tags: package.json on a branch is as stale as the
# main it forked from, and stable patches never merge back into it.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \
node config/scripts/adhoc-build-version.mjs \
>"$RUNNER_TEMP/adhoc-identity.txt"
if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then
echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow."
+7 -1
View File
@@ -20,13 +20,14 @@ on:
- 'config/scripts/computer-use-smoke.mjs'
- 'config/scripts/computer-use-smoke.test.mjs'
- 'config/scripts/daemon-boot-smoke.mjs'
- 'config/scripts/daemon-endpoint-handover-smoke.mjs'
- 'config/scripts/windows-daemon-workspace-close-repro.mjs'
- 'config/scripts/verify-computer-native.mjs'
# Why: the native-smoke job boots the built terminal daemon under plain
# Node, so any change to the daemon bundle graph or the main build must
# re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI).
- 'electron.vite.config.ts'
- 'build-plugins/**'
- 'config/build-plugins/**'
- 'src/main/daemon/**'
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
@@ -135,6 +136,11 @@ jobs:
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
- name: Daemon boot smoke
run: node config/scripts/daemon-boot-smoke.mjs
# Why: a daemon that lost its endpoint name used to delete the replacement's
# socket on exit, stranding a live daemon that hosts PTYs nothing can reach.
# Only two real processes racing the same endpoint reproduce it.
- name: Daemon endpoint handover smoke
run: node config/scripts/daemon-endpoint-handover-smoke.mjs
# Why: workspace removal overlaps graceful renderer teardown with the
# forced main-process sweep; exercise that exact pair against real ConPTY.
- name: Windows daemon workspace-close repro
+20 -11
View File
@@ -179,24 +179,33 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Why read the highest number off existing titles rather than counting
# releases: the prune step trims to HOURLY_RETAIN_COUNT, so a count would
# roll backwards after three days and reissue numbers already in use. The
# maximum is always among the retained builds, because pruning drops the
# oldest. `capture(...)?` swallows the non-match on legacy titles, which
# were the raw tag, so the series simply starts at 01.
# Existing titles, which carry the build number this series continues
# from. The script picks the number, because it restarts per base version
# and only the script knows which base this build resolved to.
#
# Why drafts count here but not in the freshness check: that check asks
# "did this commit ship", where a draft is a no. This one asks "is the
# number free", where a stranded draft still holds one.
last_number="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name,isDraft \
--jq '[.[] | (.name // "") | capture(" • (?<n>[0-9]+) • ")? | .n | tonumber] | max // 0')"
build_number=$(( last_number + 1 ))
echo "Hourly build number $build_number (previous high: $last_number)"
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \
--jq '.[].name // empty')"
# Why the main repo's tags decide the base version rather than
# package.json: main's version only moves on `release:` commits, and
# stable patches are cut from release branches that never merge back, so
# package.json can sit several patches behind what users are running. A
# separate token because GH_TOKEN above is the App's, scoped to the
# hourly repo. Empty on failure — the script then falls back to
# package.json, which is stale but never wrong enough to fail a build.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
echo "Highest published tag seen: $(head -1 <<<"$published")"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \
node config/scripts/hourly-build-version.mjs \
>"$RUNNER_TEMP/hourly-identity.txt"
grep -E '^(version|build_number)=' "$RUNNER_TEMP/hourly-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
# branch that edits this step while main still has the old script yields
+24
View File
@@ -233,8 +233,32 @@ jobs:
--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 \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
cross-version-wire:
name: cross-version wire compatibility
runs-on: ubuntu-latest
steps:
# Why fetch-depth 0: the harness extracts the newest release tag to skew
# current code against it. The default shallow clone has no tags, which is
# why this cannot ride along in the sharded `test` job.
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
# A path filter that matches nothing exits 1 ("No test files found"), so this
# lane cannot report success while running zero tests.
- name: Old/new client and server terminal journey
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
package:
name: package
runs-on: ubuntu-latest
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
'!config/reliability-gates.jsonc',
'!config/max-lines-baseline.txt',
'!config/vitest.config.ts',
'build-plugins/**',
'config/build-plugins/**',
'native/**',
'resources/**',
'electron.vite.config.ts',
+5
View File
@@ -100,6 +100,8 @@ docs/**
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/windows-setup-shell.md
# Stably CLI (only docs/ are tracked)
.stably/*
@@ -134,3 +136,6 @@ src/renderer/src/i18n/locales/.es-catalog-cache.json
# Bench result JSONs are working artifacts
tests/tools/benchmarks/results/terminal-pipeline-*.json
# Old release trees the cross-version wire harness extracts on demand
tests/e2e/.cross-version-checkouts/
+1 -1
View File
@@ -150,5 +150,5 @@
}
}
],
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"]
}
+5
View File
@@ -29,6 +29,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
## SSH Use Case
@@ -39,6 +40,10 @@ All changes must consider the SSH use case. Don't assume local-only execution.
All changes must consider folder workspaces as well as git worktrees. Don't assume every workspace is a git worktree.
## Remote Wire Compatibility
Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change.
## Git Binary Compatibility
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
@@ -1,4 +1,4 @@
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../src/main/startup/bootstrap-fatal-exit-guard'
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
export const BOOTSTRAP_FATAL_LOG_ENV_VAR = 'ORCA_BOOTSTRAP_FATAL_LOG'
export const BOOTSTRAP_FATAL_LOG_FILE_NAME = 'bootstrap-fatal.log'
const BOOTSTRAP_FATAL_LOG_MAX_BYTES = 262_144
@@ -26,6 +26,23 @@ const PLAIN_NODE_ENTRY_NAMES = [
'codex/codex-app-server-grant-entry'
] as const
// Entries executed as worker threads of the main process. Electron's module is
// not registered on worker threads, so require("electron") throws
// "Cannot find module 'electron'" there too (verified on Electron 43) and kills
// the worker at startup. These carry hand-written "must stay electron-free"
// comments, which is convention, not enforcement — and the port-scan worker in
// particular sits one import away from a client module that deliberately does
// require electron.
const WORKER_THREAD_ENTRY_NAMES = [
'stt-worker',
'warp-theme-parser-worker',
'session-scanner-opencode-sqlite-worker-entry',
'main-thread-hang-watchdog-entry',
'port-scan-command-worker-entry'
] as const
type EntryRuntime = 'plain-Node process' | 'worker thread'
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
function collectReachableChunks(
@@ -56,13 +73,14 @@ function collectReachableChunks(
function assertNoElectronRequire(
entryName: string,
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
byFileName: Map<string, OutputChunk>,
runtime: EntryRuntime = 'plain-Node process'
): void {
for (const chunk of collectReachableChunks(entry, byFileName)) {
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
throw new Error(
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
)
@@ -125,7 +143,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName)
assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process')
}
}
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName, 'worker thread')
}
}
+7 -2
View File
@@ -10,6 +10,7 @@
"src/main/speech/stt-worker.ts",
"src/main/warp-themes/warp-theme-parser-worker.ts",
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
"src/main/ports/port-scan-command-worker-entry.ts",
"src/main/ipc/parcel-watcher-process-entry.ts",
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
"src/main/codex/codex-app-server-grant-entry.ts",
@@ -23,7 +24,7 @@
"src/relay/relay.ts",
"src/relay/wsl-agent-hook-relay.ts",
"config/scripts/**/*.{mjs,cjs,js,ts}",
"build-plugins/**/*.ts",
"config/build-plugins/**/*.ts",
"electron.vite.config.ts",
"vite.web.config.ts",
"config/vitest.config.ts",
@@ -33,7 +34,11 @@
"tests/**/*.{ts,tsx,mjs}",
"**/*.test.{ts,tsx}"
],
"project": ["src/**/*.{ts,tsx}", "config/scripts/**/*.{mjs,cjs,js,ts}", "build-plugins/**/*.ts"],
"project": [
"src/**/*.{ts,tsx}",
"config/scripts/**/*.{mjs,cjs,js,ts}",
"config/build-plugins/**/*.ts"
],
"ignore": ["mobile/**", "out/**", "dist/**", "node_modules/**", "resources/**"],
"ignoreDependencies": ["electron", "@types/*"],
"ignoreExportsUsedInFile": true,
@@ -6,6 +6,13 @@
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/sidebar/WorktreeCard.tsx",
"kind": "jsx-attribute:label",
"text": "sidebar",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
"kind": "object-property:keywords",
+71 -20
View File
@@ -44,6 +44,14 @@ const PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM = {
linux: 'watcher-linux',
win32: 'watcher-win32'
}
const ELECTRON_ARCHITECTURE_BY_ENUM = {
0: 'ia32',
1: 'x64',
2: 'arm',
3: 'arm64',
4: 'universal'
}
const PACKAGED_NATIVE_ARCHITECTURES = new Set(['ia32', 'x64', 'arm', 'arm64'])
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
@@ -170,7 +178,7 @@ function collectPackagedRuntimePackages(electronPlatformName = process.platform)
// optionalDependency (e.g. @parcel/watcher-linux-x64-glibc) that the
// dependencies graph above never reaches. Include the ones installed for the
// build's supported architectures; afterPack pruning trims non-target
// platforms. Without this the packaged main bundle's import of
// platform/architecture variants. Without this the packaged main bundle's import of
// '@parcel/watcher' resolves at runtime but throws loading its binary.
const parcelWatcherDir = packages.get('@parcel/watcher')
if (parcelWatcherDir) {
@@ -246,13 +254,44 @@ function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/a
}
function normalizeNodePtyWindowsArch(electronArch) {
if (electronArch === 'x64' || electronArch === 1) {
return 'x64'
const architecture = normalizeElectronArchitecture(electronArch)
if (architecture !== 'x64' && architecture !== 'arm64') {
throw new Error(`Unsupported packaged node-pty Windows architecture: ${architecture}`)
}
if (electronArch === 'arm64' || electronArch === 3) {
return 'arm64'
return architecture
}
function normalizeElectronArchitecture(electronArch) {
const architecture =
typeof electronArch === 'number'
? ELECTRON_ARCHITECTURE_BY_ENUM[electronArch]
: electronArch === 'armv7l'
? 'arm'
: electronArch
if (!PACKAGED_NATIVE_ARCHITECTURES.has(architecture)) {
throw new Error(`Unsupported packaged runtime architecture: ${String(electronArch)}`)
}
return architecture
}
function pruneNodePtyNativeDirectories(directory, platformPrefix, electronArch, allowsSuffix) {
if (!existsSync(directory)) {
return
}
const architecture = normalizeElectronArchitecture(electronArch)
const targetPrefix = `${platformPrefix}${architecture}`
const platformPrefixes = Object.values(NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM)
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (!entry.isDirectory() || !platformPrefixes.some((prefix) => entry.name.startsWith(prefix))) {
continue
}
const matchesTarget =
entry.name.startsWith(platformPrefix) &&
(entry.name === targetPrefix || (allowsSuffix && entry.name.startsWith(`${targetPrefix}-`)))
if (!matchesTarget) {
rmSync(join(directory, entry.name), { recursive: true, force: true })
}
}
return process.arch === 'arm64' ? 'arm64' : 'x64'
}
function findNodePtyConptySourceDir(nodePtyDir, windowsArch) {
@@ -308,14 +347,19 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
if (allowedPrebuildPrefix) {
const prebuildsDir = join(nodePtyDir, 'prebuilds')
if (existsSync(prebuildsDir)) {
for (const entry of readdirSync(prebuildsDir, { withFileTypes: true })) {
if (entry.isDirectory() && !entry.name.startsWith(allowedPrebuildPrefix)) {
rmSync(join(prebuildsDir, entry.name), { recursive: true, force: true })
}
}
}
pruneNodePtyNativeDirectories(
join(nodePtyDir, 'prebuilds'),
allowedPrebuildPrefix,
electronArch,
false
)
// Why: sequential cross-arch rebuilds accumulate ABI-tagged outputs here.
pruneNodePtyNativeDirectories(
join(nodePtyDir, 'bin'),
allowedPrebuildPrefix,
electronArch,
true
)
}
if (electronPlatformName === 'win32') {
@@ -328,7 +372,7 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
}
}
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName, electronArch) {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
if (!existsSync(parcelDir)) {
return
@@ -336,9 +380,11 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
// Why: we package every installed @parcel/watcher-<platform> optional
// subpackage (supportedArchitectures fetches all), but each build only needs
// its own platform's binary. Keep the core package and the matching platform
// subpackages; drop the rest so a Linux serve doesn't ship macOS/Windows .node.
// its own platform/architecture binaries. Keep the core package and matching
// native variants; drop the rest.
const keepPrefix = PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM[electronPlatformName]
const architecture = normalizeElectronArchitecture(electronArch)
const targetPrefix = keepPrefix ? `${keepPrefix}-${architecture}` : null
for (const entry of readdirSync(parcelDir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === 'watcher') {
continue
@@ -348,7 +394,11 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
if (!entry.name.startsWith('watcher-')) {
continue
}
if (keepPrefix && entry.name.startsWith(keepPrefix)) {
if (
keepPrefix &&
entry.name.startsWith(keepPrefix) &&
(entry.name === targetPrefix || entry.name.startsWith(`${targetPrefix}-`))
) {
continue
}
rmSync(join(parcelDir, entry.name), { recursive: true, force: true })
@@ -395,8 +445,9 @@ function prunePackagedZodSources(resourcesDir) {
}
function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName, electronArch) {
prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
prunePackagedParcelWatcher(resourcesDir, electronPlatformName)
const architecture = normalizeElectronArchitecture(electronArch)
prunePackagedNodePty(resourcesDir, electronPlatformName, architecture)
prunePackagedParcelWatcher(resourcesDir, electronPlatformName, architecture)
prunePackagedRuntimeTypeDeclarations(resourcesDir)
prunePackagedSherpaOnnx(resourcesDir, electronPlatformName)
prunePackagedZodSources(resourcesDir)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** Long enough to name a feature, short enough that a picker row stays readable. */
export const ADHOC_LABEL_MAX_LENGTH = 32
@@ -67,7 +71,7 @@ export function normalizeAdhocLabel(label) {
}
/**
* `1.4.163 • wasm-terminal • 08-01 14:25 • abc1234` — the human-facing release
* `1.4.163 • wasm-terminal • Aug 1, 2:25PM • abc1234` — the human-facing release
* title, shown verbatim in both the GitHub releases list and the build picker.
*
* Why the label sits where hourly puts its build number: several adhoc builds
@@ -83,12 +87,13 @@ export function formatAdhocReleaseName(version, label, commit, date) {
].join(' • ')
}
export function getAdhocBuildIdentity(now = new Date(), label = '') {
export function getAdhocBuildIdentity(now = new Date(), label = '', publishedVersions = []) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const version = createAdhocBuildVersion(packageJson.version, now)
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions)
const version = createAdhocBuildVersion(base, now)
return {
commit,
version,
@@ -98,7 +103,11 @@ export function getAdhocBuildIdentity(now = new Date(), label = '') {
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getAdhocBuildIdentity(new Date(), process.env.ORCA_ADHOC_LABEL ?? '')
const identity = getAdhocBuildIdentity(
new Date(),
process.env.ORCA_ADHOC_LABEL ?? '',
readPublishedVersionsFromEnv()
)
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n`
+4 -4
View File
@@ -88,20 +88,20 @@ describe('formatAdhocReleaseName', () => {
formatAdhocReleaseName('1.4.163-adhoc.x', label, commit, new Date(iso))
it('renders version, label, Pacific timestamp, and short sha', () => {
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • 07-31 13:54 • e698241')
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • Jul 31, 1:54PM • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
expect(name('2026-01-15T02:30:00Z')).toContain(' 01-14 18:30 ')
expect(name('2026-07-31T07:00:00Z')).toContain(' 07-31 00:00 ')
expect(name('2026-01-15T02:30:00Z')).toContain(' Jan 14, 6:30PM ')
expect(name('2026-07-31T07:00:00Z')).toContain(' Jul 31, 12:00AM ')
})
it('sanitizes the label before it reaches the title', () => {
expect(name('2026-07-31T20:54:00Z', 'refs/heads/fix • now')).toBe(
'1.4.163 • fix-now • 07-31 13:54 • e698241'
'1.4.163 • fix-now • Jul 31, 1:54PM • e698241'
)
})
+11 -2
View File
@@ -60,6 +60,14 @@ const USER_VISIBLE_OBJECT_METHODS = new Set([
'warning'
])
const USER_VISIBLE_OBJECT_NAMES = new Set(['toast'])
// Why: only comparison operands are code, not copy. Bailing on every non-`+`
// operator hid whole subtrees behind `cond && <JSX/>` guards and `?? 'fallback'`.
const COPY_PRESERVING_BINARY_OPERATORS = new Set([
ts.SyntaxKind.PlusToken,
ts.SyntaxKind.QuestionQuestionToken,
ts.SyntaxKind.BarBarToken,
ts.SyntaxKind.AmpersandAmpersandToken
])
function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
@@ -226,7 +234,7 @@ function isRenderedJsxExpression(node) {
continue
}
if (ts.isBinaryExpression(current)) {
if (current.operatorToken.kind !== ts.SyntaxKind.PlusToken) {
if (!COPY_PRESERVING_BINARY_OPERATORS.has(current.operatorToken.kind)) {
return false
}
current = current.parent
@@ -318,7 +326,8 @@ function classifyStringNode(node) {
findAncestor(
node,
(ancestor) =>
ts.isBinaryExpression(ancestor) && ancestor.operatorToken.kind !== ts.SyntaxKind.PlusToken
ts.isBinaryExpression(ancestor) &&
!COPY_PRESERVING_BINARY_OPERATORS.has(ancestor.operatorToken.kind)
)
) {
return undefined
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { collectLocalizationCandidates } from './audit-localization-coverage.mjs'
const ROOT = process.cwd()
function candidates(fileName, source) {
return collectLocalizationCandidates(`${ROOT}/src/renderer/src/${fileName}`, source, ROOT)
}
describe('localization coverage candidates', () => {
it('sees copy guarded by a nullish or logical fallback', () => {
const reports = candidates(
'Sample.tsx',
`export function Sample({ label, connecting }) {
return <span>{label ?? (connecting ? 'Connecting…' : 'Idle')}</span>
}`
)
expect(reports.map((report) => report.text)).toEqual(['Connecting…', 'Idle'])
})
it('sees copy nested inside a conditional JSX guard', () => {
const reports = candidates(
'Sample.tsx',
`export function Sample({ show }) {
return <div>{show && <button aria-label="Retry the sync" />}</div>
}`
)
expect(reports.map((report) => report.text)).toEqual(['Retry the sync'])
})
it('ignores literals used as comparison operands', () => {
const reports = candidates(
'Sample.tsx',
`export function Sample({ phase }) {
return <span>{phase === 'workspace conflict' ? phase : null}</span>
}`
)
expect(reports).toEqual([])
})
})
@@ -1,4 +1,12 @@
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import {
copyFileSync,
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'
@@ -6,6 +14,21 @@ import { describe, expect, it } from 'vitest'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
const WINDOWS_LOCK_CODES = ['EBUSY', 'ENOTEMPTY', 'EPERM']
// Why: Windows releases the image handle on a just-executed exe (and finishes the
// AV scan of the freshly compiled one) after the process exits, so tearing down the
// fixture races those locks. Retry, then leave the temp tree rather than reporting a
// teardown lock as a launcher failure.
function removeFixtureTree(path) {
try {
rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
} catch (error) {
if (process.platform !== 'win32' || !WINDOWS_LOCK_CODES.includes(error?.code)) {
throw error
}
}
}
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
// keep the larger allowance scoped to the real compiler integration test.
function itWindows(name, test) {
@@ -27,10 +50,24 @@ describe('Windows CLI launcher', () => {
expect(result.stderr).toContain('Windows CLI launcher')
expect(result.stderr).toContain('Windows host')
} finally {
rmSync(outputRoot, { recursive: true, force: true })
removeFixtureTree(outputRoot)
}
})
itCrossHost('never materializes the child environment block from ProcessStartInfo', () => {
// Why: both ProcessStartInfo env properties copy the process block into a case-insensitive
// dictionary that throws when the inherited block holds PATH and Path (stablyai/orca#12046).
const source = readFileSync(
join(projectRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs'),
'utf8'
)
const code = source.replace(/^\s*\/\/.*$/gm, '')
expect(code).not.toContain('EnvironmentVariables')
expect(code).not.toContain('startInfo.Environment')
expect(code).toContain('Environment.SetEnvironmentVariable')
})
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
try {
@@ -86,7 +123,73 @@ describe('Windows CLI launcher', () => {
orcaNodeOptions: '--no-warnings'
})
} finally {
rmSync(appRoot, { recursive: true, force: true })
removeFixtureTree(appRoot)
}
})
itWindows('survives an inherited environment block containing PATH and Path', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher '))
try {
const resourcesPath = join(appRoot, 'resources')
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
const outputPath = join(appRoot, 'child-result.json')
const harnessSourcePath = join(
projectRoot,
'config',
'scripts',
'fixtures',
'DuplicatePathProcessLauncher.cs'
)
const harnessPath = join(appRoot, 'DuplicatePathLauncher.exe')
mkdirSync(dirname(launcherPath), { recursive: true })
mkdirSync(dirname(cliPath), { recursive: true })
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
writeFileSync(
cliPath,
`require('node:fs').writeFileSync(process.env.ORCA_TEST_OUTPUT, JSON.stringify({
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
pathKeys: Object.keys(process.env).filter((key) => key.toLowerCase() === 'path')
}))\n`,
'utf8'
)
const build = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
const compiler = findFrameworkCompiler()
expect(compiler).not.toBeNull()
const compileHarness = spawnSync(
compiler,
['/nologo', '/target:exe', `/out:${harnessPath}`, harnessSourcePath],
{ encoding: 'utf8' }
)
expect(compileHarness.status, `${compileHarness.stdout}\n${compileHarness.stderr}`).toBe(0)
const launch = spawnSync(harnessPath, [launcherPath, outputPath], { encoding: 'utf8' })
expect(launch.status, `${launch.stdout}\n${launch.stderr}`).toBe(0)
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
electronRunAsNode: '1',
pathKeys: ['PATH', 'Path']
})
} finally {
removeFixtureTree(appRoot)
}
})
})
function findFrameworkCompiler() {
const windowsDirectory = process.env.WINDIR ?? process.env.SystemRoot
if (!windowsDirectory) {
return null
}
return (
[
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
].find((candidate) => existsSync(candidate)) ?? null
)
}
@@ -219,7 +219,7 @@ describe('computer-use e2e workflow', () => {
'config/scripts/daemon-boot-smoke.mjs',
'config/scripts/windows-daemon-workspace-close-repro.mjs',
'electron.vite.config.ts',
'build-plugins/**',
'config/build-plugins/**',
'src/main/daemon/**'
])
)
@@ -28,7 +28,11 @@ describe('computer-use modifier safety', () => {
)
expect(mouseInput).toContain('event.flags = flags')
expect(clickInput.match(/flags: flags/gu)).toHaveLength(3)
// Every click event flows through the shared delivery plan and carries
// the modifier flags on the mouse event itself.
expect(clickInput).toContain('SyntheticMouseClickDelivery.deliver(')
expect(clickInput).toContain('currentSyntheticClickRecipient(')
expect(clickInput).toContain('event.flags = flags')
expect(clickInput).not.toContain('down: true')
})
+105 -35
View File
@@ -18,7 +18,7 @@
import { fork } from 'node:child_process'
import { connect } from 'node:net'
import { randomUUID } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
@@ -54,30 +54,27 @@ function makeSocketPath(userDataDir) {
return join(userDataDir, 'daemon.sock')
}
// Best-effort end-to-end PTY check over the daemon's own control-socket RPC.
// Connects a single control socket, completes the hello handshake, and calls
// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves
// true on success, false on any failure — never throws.
function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
return new Promise((resolveCheck) => {
function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) {
return new Promise((resolveRpc, rejectRpc) => {
let settled = false
let buffer = ''
const socket = connect(socketPath)
const finish = (ok, reason) => {
const finish = (error, response) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
socket.destroy()
if (!ok && reason) {
log(`PTY spawn health check skipped (best-effort): ${reason}`)
if (error) {
rejectRpc(error)
} else {
resolveRpc(response)
}
resolveCheck(ok)
}
const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS)
const timer = setTimeout(() => finish(new Error(`${request.type} timed out`)), timeoutMs)
socket.on('error', (err) => finish(false, err.message))
socket.on('error', (error) => finish(error))
socket.on('connect', () => {
const token = readFileSync(tokenPath, 'utf8').trim()
socket.write(
@@ -100,19 +97,19 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
try {
msg = JSON.parse(line)
} catch {
finish(false, 'invalid response line')
finish(new Error('invalid response line'))
return
}
if (msg.type === 'hello') {
if (!msg.ok) {
finish(false, `hello rejected: ${msg.error ?? 'unknown'}`)
finish(new Error(`hello rejected: ${msg.error ?? 'unknown'}`))
return
}
socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`)
} else if (msg.id === 'health-1') {
socket.write(`${JSON.stringify(request)}\n`)
} else if (msg.id === request.id) {
finish(
msg.ok === true,
msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed')
msg.ok === true ? undefined : new Error(msg.error ?? `${request.type} failed`),
msg
)
return
}
@@ -122,28 +119,61 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
})
}
// Best-effort: constrained CI runners can make node-pty spawn flaky.
async function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
try {
await runDaemonRpc(
socketPath,
tokenPath,
protocolVersion,
{ id: 'health-1', type: 'ptySpawnHealth' },
PTY_HEALTH_TIMEOUT_MS
)
return true
} catch (error) {
log(`PTY spawn health check skipped (best-effort): ${error.message}`)
return false
}
}
async function main() {
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
const socketPath = makeSocketPath(userDataDir)
const tokenPath = join(userDataDir, 'daemon.token')
const pidPath = join(userDataDir, 'daemon.pid')
const launchNonce = randomUUID()
const protocolVersion = readProtocolVersion()
log(`forking ${entryPath} under plain Node (${process.execPath})`)
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
// CI, and this is exactly the runtime where a leaked `require("electron")`
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
})
const child = fork(
entryPath,
[
'--socket',
socketPath,
'--token',
tokenPath,
'--pid-record',
pidPath,
'--launch-nonce',
launchNonce,
'--entry-path',
entryPath,
'--app-version',
'daemon-boot-smoke'
],
{
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
// CI, and this is exactly the runtime where a leaked `require("electron")`
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
}
)
let stderr = ''
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8')
})
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk)
})
const cleanup = () => {
if (child.exitCode === null && child.signalCode === null && child.pid) {
@@ -185,6 +215,25 @@ async function main() {
})
})
log('daemon signaled ready')
const pidRecord = JSON.parse(readFileSync(pidPath, 'utf8'))
if (
pidRecord.pid !== child.pid ||
pidRecord.launchNonce !== launchNonce ||
pidRecord.entryPath !== entryPath ||
pidRecord.appVersion !== 'daemon-boot-smoke'
) {
throw new Error('daemon readiness did not publish the expected PID ownership record')
}
log('PID ownership record matches the ready daemon')
if (!existsSync(socketPath)) {
throw new Error('daemon did not publish its endpoint at the canonical socket path')
}
log('endpoint published at the canonical socket path')
// Production releases startup-only handles after ready; they can pin the child on Windows.
// Diagnostics past this point therefore carry the tail captured up to readiness only.
child.stderr?.destroy()
stderr += '[boot-smoke] stderr released at readiness, mirroring production\n'
child.disconnect()
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
if (ptyHealthy) {
@@ -193,18 +242,39 @@ async function main() {
await new Promise((resolveExit, rejectExit) => {
const timer = setTimeout(() => {
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`))
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of shutdown RPC`))
}, SHUTDOWN_TIMEOUT_MS)
child.on('exit', (code, signal) => {
clearTimeout(timer)
log(`daemon exited after signal (code=${code}, signal=${signal})`)
log(`daemon exited after shutdown RPC (code=${code}, signal=${signal})`)
resolveExit()
})
// Why: SIGTERM is the graceful stop on POSIX (the daemon handles it);
// Windows has no POSIX signal delivery, so Node maps this to process
// termination. Either way the hard assertion is "it stops, no hang".
child.kill('SIGTERM')
void runDaemonRpc(
socketPath,
tokenPath,
protocolVersion,
{
id: 'shutdown-1',
type: 'shutdown',
payload: { killSessions: false }
},
SHUTDOWN_TIMEOUT_MS
).catch((error) => {
clearTimeout(timer)
rejectExit(error)
})
})
if (existsSync(pidPath)) {
throw new Error('daemon left its PID ownership record behind after shutdown')
}
if (process.platform !== 'win32' && existsSync(socketPath)) {
throw new Error('daemon left its endpoint socket behind after shutdown')
}
// The private bind name is consumed by the publish; nothing may linger in the runtime dir.
const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b'))
if (leaked.length > 0) {
throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`)
}
log('PASS: daemon booted, served, and shut down under plain Node')
} finally {
@@ -0,0 +1,164 @@
/**
* Endpoint handover smoke — guards the split-brain failure with real daemon processes.
*
* The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still
* alive used to delete the *replacement's* socket when it finally exited, because libuv
* unlinks the pathname a server bound to with no ownership check. The replacement stayed
* alive hosting PTYs that nothing could reach — terminals that acknowledge input and never
* run it, and that a user cannot fix by restarting the app.
*
* Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur.
*
* Usage: node config/scripts/daemon-endpoint-handover-smoke.mjs
*/
import { fork } from 'node:child_process'
import { connect } from 'node:net'
import { randomUUID } from 'node:crypto'
import { existsSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const repoRoot = resolve(import.meta.dirname, '..', '..')
const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js')
const READY_TIMEOUT_MS = 20_000
const EXIT_TIMEOUT_MS = 15_000
const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`)
function bootDaemon(tag, dir, socketPath) {
const tokenPath = join(dir, `${tag}.token`)
const pidPath = join(dir, `${tag}.pid`)
const child = fork(
entryPath,
[
'--socket',
socketPath,
'--token',
tokenPath,
'--pid-record',
pidPath,
'--launch-nonce',
randomUUID(),
'--entry-path',
entryPath,
'--app-version',
'endpoint-handover-smoke'
],
{
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: dir }
}
)
let stderr = ''
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8')
})
return new Promise((resolveReady, rejectReady) => {
const timer = setTimeout(
() => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)),
READY_TIMEOUT_MS
)
child.on('message', (msg) => {
if (msg && typeof msg === 'object' && msg.type === 'ready') {
clearTimeout(timer)
resolveReady({ child, tokenPath, pidPath })
}
})
child.on('exit', (code) => {
clearTimeout(timer)
rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`))
})
})
}
function isReachable(socketPath) {
return new Promise((resolveReachable) => {
const socket = connect({ path: socketPath })
socket.on('connect', () => {
socket.destroy()
resolveReachable(true)
})
socket.on('error', () => resolveReachable(false))
})
}
function killAndWait(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve()
}
return new Promise((resolveExit, rejectExit) => {
const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS)
child.on('exit', () => {
clearTimeout(timer)
resolveExit()
})
child.kill('SIGTERM')
})
}
async function main() {
if (process.platform === 'win32') {
log('SKIP: named pipes are not filesystem entries, so endpoint handover cannot occur')
return
}
if (!existsSync(entryPath)) {
throw new Error(`missing ${entryPath} — run \`pnpm build\` first`)
}
const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-'))
const socketPath = join(dir, 'daemon.sock')
let replaced
let replacement
try {
replaced = await bootDaemon('replaced', dir, socketPath)
const replacedInode = statSync(socketPath).ino
log('daemon A published the endpoint')
// Reclaim the endpoint name the way daemon replacement does, while A is still alive.
unlinkSync(socketPath)
replacement = await bootDaemon('replacement', dir, socketPath)
const replacementInode = statSync(socketPath).ino
if (replacedInode === replacementInode) {
throw new Error('daemon B did not publish a distinct endpoint')
}
if (!(await isReachable(socketPath))) {
throw new Error('daemon B is not reachable through the canonical endpoint')
}
log('daemon B took over the endpoint and is reachable')
// A exits long after losing the endpoint. This is the step that used to break B.
await killAndWait(replaced.child)
await new Promise((r) => setTimeout(r, 300))
if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) {
throw new Error("daemon A's late exit deleted daemon B's endpoint")
}
if (!(await isReachable(socketPath))) {
throw new Error('daemon B became unreachable after daemon A exited')
}
if (!existsSync(replacement.pidPath)) {
throw new Error("daemon A's late exit removed daemon B's ownership record")
}
if (existsSync(replaced.pidPath)) {
throw new Error('daemon A left its own ownership record behind')
}
log('PASS: the endpoint owner and the session host stayed the same daemon')
} finally {
for (const daemon of [replaced, replacement]) {
if (daemon && daemon.child.exitCode === null && daemon.child.signalCode === null) {
try {
daemon.child.kill('SIGKILL')
} catch {
// already gone
}
}
}
rmSync(dir, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(`[endpoint-handover-smoke] FAIL: ${error.message}`)
process.exit(1)
})
@@ -0,0 +1,66 @@
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
function parseVersionTriple(value) {
const match = SEMVER.exec(String(value ?? '').trim())
if (!match) {
return null
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] ?? null
}
}
function compareTriples(a, b) {
return a.major - b.major || a.minor - b.minor || a.patch - b.patch
}
/**
* The base `X.Y.Z` an hourly or adhoc build should carry.
*
* Why not package.json alone: main's version only moves on `release:` commits, and
* stable patches are cut from release branches that never merge back. On
* 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and
* 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while
* carrying code newer than 1.4.167, and sorted *below* the stable their user was
* already running. Published tags are the only honest answer to "what number is
* taken"; package.json is a floor, not a source of truth.
*/
export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) {
const fromPackage = parseVersionTriple(packageVersion)
if (!fromPackage) {
throw new Error(`Package version is not valid semver: ${packageVersion}`)
}
// Unparseable tags are skipped rather than fatal: the main repo carries old tags
// that predate the current scheme, and one of them must not fail every build.
const published = publishedVersions.map(parseVersionTriple).filter(Boolean)
let base = fromPackage
if (published.length > 0) {
const highest = published.reduce((best, entry) =>
compareTriples(entry, best) > 0 ? entry : best
)
// A shipped stable owns its number, so the next dev build belongs on the patch
// above it. A bare prerelease does not — rc.1 of 1.4.168 means 1.4.168 is still
// the version being worked toward, which is exactly what main is building.
const shipped = published.some(
(entry) => !entry.prerelease && compareTriples(entry, highest) === 0
)
const next = shipped ? { ...highest, patch: highest.patch + 1 } : highest
if (compareTriples(next, base) > 0) {
base = next
}
}
return `${base.major}.${base.minor}.${base.patch}`
}
/** Tag list the workflow reads out of the main repo, newline separated. */
export function readPublishedVersionsFromEnv(value = process.env.ORCA_PUBLISHED_VERSIONS) {
return String(value ?? '')
.split(/\s+/)
.filter(Boolean)
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
describe('dev channel base version', () => {
it('falls back to package.json when no tags are supplied', () => {
expect(resolveDevChannelBaseVersion('1.4.168-rc.1')).toBe('1.4.168')
expect(resolveDevChannelBaseVersion('1.4.168', [])).toBe('1.4.168')
})
// The bug this exists for: main sat at 1.4.165-rc.0 for twenty hours while three
// stables shipped, so hourlies claimed a version their users had already passed.
it('climbs past stables that shipped while main stood still', () => {
expect(
resolveDevChannelBaseVersion('1.4.165-rc.0', [
'v1.4.165',
'v1.4.166',
'v1.4.167',
'v1.4.165-rc.0'
])
).toBe('1.4.168')
})
// Why +1 on a stable but not on a prerelease: 1.4.167 is spent, so the next dev
// build is 1.4.168. But 1.4.168-rc.1 means 1.4.168 is still being built toward,
// which is what main holds — claiming 1.4.169 would jump a release nobody cut.
it('takes the patch above a shipped stable and holds at an open prerelease', () => {
expect(resolveDevChannelBaseVersion('1.4.167', ['v1.4.167'])).toBe('1.4.168')
expect(resolveDevChannelBaseVersion('1.4.168-rc.1', ['v1.4.168-rc.1'])).toBe('1.4.168')
})
// Why max and not most-recent: a hotfix on an old line published today would
// otherwise drag every subsequent hourly backwards.
it('reads the highest tag, not the last one listed', () => {
expect(resolveDevChannelBaseVersion('1.4.160', ['v1.4.167', 'v1.3.99', 'v1.4.120'])).toBe(
'1.4.168'
)
})
it('treats package.json as a floor when it leads the tags', () => {
expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0')
})
// Why skipped rather than fatal: the main repo carries legacy tags, and one
// unparseable entry must not fail every hourly build.
it('ignores tags it cannot parse', () => {
expect(resolveDevChannelBaseVersion('1.4.160', ['nightly', '', 'v1.4.167', 'latest'])).toBe(
'1.4.168'
)
})
it('rejects a package version that is not semver', () => {
expect(() => resolveDevChannelBaseVersion('not-a-version')).toThrow(/not valid semver/)
})
it('carries major and minor rollovers through the bump', () => {
expect(resolveDevChannelBaseVersion('1.4.0', ['v2.0.0'])).toBe('2.0.1')
})
it('splits an env tag list on any whitespace', () => {
expect(readPublishedVersionsFromEnv('v1.4.167\nv1.4.166\n')).toEqual(['v1.4.167', 'v1.4.166'])
expect(readPublishedVersionsFromEnv('')).toEqual([])
expect(readPublishedVersionsFromEnv(undefined)).toEqual([])
})
})
+30 -25
View File
@@ -420,40 +420,42 @@ describe('electron-builder config', () => {
expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
})
it('prunes non-target node-pty prebuilds from packaged runtime resources', async () => {
it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
try {
const prebuildsDir = join(resourcesDir, 'node_modules', 'node-pty', 'prebuilds')
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
const prebuildsDir = join(nodePtyDir, 'prebuilds')
const binDir = join(nodePtyDir, 'bin')
await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party', 'conpty'), {
recursive: true
})
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps', 'winpty'), {
await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true })
await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true })
await mkdir(join(nodePtyDir, 'third_party', 'conpty'), {
recursive: true
})
await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true })
prunePackagedNodePty(resourcesDir, 'darwin')
prunePackagedNodePty(resourcesDir, 'darwin', 3)
await expect(readdir(prebuildsDir).then((entries) => entries.sort())).resolves.toEqual([
'darwin-arm64',
'darwin-x64'
])
await expect(
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party'))
).resolves.toEqual([])
await expect(
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps'))
).resolves.toEqual([])
await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64'])
await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148'])
await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([])
await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([])
expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow(
'Unsupported packaged runtime architecture: 4'
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
for (const arch of ['x64', 'arm64']) {
for (const [arch, electronArch] of [
['x64', 1],
['arm64', 3]
]) {
const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`))
try {
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
@@ -472,7 +474,7 @@ describe('electron-builder config', () => {
)
}
prunePackagedNodePty(resourcesDir, 'win32', arch)
prunePackagedNodePty(resourcesDir, 'win32', electronArch)
await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe(
`dll payload ${arch}`
@@ -500,7 +502,7 @@ describe('electron-builder config', () => {
).toBe(true)
})
it('prunes non-target @parcel/watcher platform subpackages from packaged runtime resources', async () => {
it('prunes non-target @parcel/watcher architecture subpackages', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
try {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
@@ -511,13 +513,15 @@ describe('electron-builder config', () => {
await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true })
prunePackagedParcelWatcher(resourcesDir, 'linux')
prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64')
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'watcher',
'watcher-linux-arm64-glibc',
'watcher-linux-x64-glibc'
'watcher-linux-arm64-glibc'
])
expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow(
'Unsupported packaged runtime architecture: universal'
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
@@ -533,7 +537,7 @@ describe('electron-builder config', () => {
// A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage.
await mkdir(join(parcelDir, 'transformer-js'), { recursive: true })
prunePackagedParcelWatcher(resourcesDir, 'linux')
prunePackagedParcelWatcher(resourcesDir, 'linux', 1)
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'transformer-js',
@@ -653,7 +657,8 @@ describe('electron-builder config', () => {
await electronBuilderConfig.afterPack({
appOutDir: join(root, 'linux-unpacked'),
electronPlatformName: 'linux'
electronPlatformName: 'linux',
arch: 1
})
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
@@ -10,7 +10,7 @@ import {
BOOTSTRAP_FATAL_LOG_ENV_VAR,
BOOTSTRAP_FATAL_LOG_FILE_NAME,
createBootstrapFatalExitBanner
} from '../../build-plugins/bootstrap-fatal-exit-banner'
} from '../build-plugins/bootstrap-fatal-exit-banner'
import { electronViteConfig } from '../../electron.vite.config'
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
@@ -79,7 +79,7 @@ describe('Electron Vite output contract', () => {
expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js')
})
it('externalizes packaged dependencies but bundles the daemon xterm graph', () => {
it('externalizes packaged dependencies but bundles self-contained main dependencies', () => {
const external = electronViteConfig.main?.build?.rollupOptions?.external
if (typeof external !== 'function') {
throw new Error('Expected main-process external predicate')
@@ -91,7 +91,9 @@ describe('Electron Vite output contract', () => {
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)
expect(external('psl', undefined, false)).toBe(false)
expect(external('zod', undefined, false)).toBe(false)
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl')
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
})
@@ -0,0 +1,123 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
internal static class DuplicatePathProcessLauncher
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct StartupInfo
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct ProcessInformation
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CreateProcess(
string applicationName,
StringBuilder commandLine,
IntPtr processAttributes,
IntPtr threadAttributes,
bool inheritHandles,
uint creationFlags,
IntPtr environment,
string currentDirectory,
ref StartupInfo startupInfo,
out ProcessInformation processInformation
);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
[DllImport("kernel32.dll")]
private static extern bool CloseHandle(IntPtr handle);
private static int Main(string[] args)
{
List<string> entries = new List<string>();
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
{
if (!String.Equals((string)entry.Key, "PATH", StringComparison.OrdinalIgnoreCase))
{
entries.Add((string)entry.Key + "=" + (string)entry.Value);
}
}
entries.Add("PATH=C:\\live");
entries.Add("Path=C:\\shadowed");
entries.Add("ORCA_TEST_OUTPUT=" + args[1]);
IntPtr environment = Marshal.StringToHGlobalUni(String.Join("\0", entries.ToArray()) + "\0\0");
StartupInfo startupInfo = new StartupInfo();
startupInfo.cb = Marshal.SizeOf(startupInfo);
ProcessInformation processInformation;
try
{
bool started = CreateProcess(
args[0],
new StringBuilder("\"" + args[0] + "\""),
IntPtr.Zero,
IntPtr.Zero,
false,
0x00000400,
environment,
null,
ref startupInfo,
out processInformation
);
if (!started)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
finally
{
Marshal.FreeHGlobal(environment);
}
try
{
WaitForSingleObject(processInformation.hProcess, 0xffffffff);
uint exitCode;
if (!GetExitCodeProcess(processInformation.hProcess, out exitCode))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
return (int)exitCode;
}
finally
{
CloseHandle(processInformation.hThread);
CloseHandle(processInformation.hProcess);
}
}
}
@@ -0,0 +1,72 @@
/** @vitest-environment happy-dom */
import { describe, expect, it } from 'vitest'
import {
installHappyDomMutationObserverRetention,
retainedMutationCallbackCount
} from './happy-dom-mutation-observer-retention'
function readListenerCallbacks(target: Node): unknown[] {
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
(candidate) => candidate.description === 'mutationListeners'
)
const listeners = listenersSymbol
? (target as unknown as Record<symbol, unknown>)[listenersSymbol]
: []
if (!Array.isArray(listeners)) {
return []
}
return listeners.map((listener: { callback?: { deref: () => unknown } }) =>
listener.callback?.deref()
)
}
describe('happy-dom MutationObserver retention', () => {
it('pins the internal callback that happy-dom only holds weakly', () => {
expect(installHappyDomMutationObserverRetention()).toBe(true)
const target = document.createElement('div')
document.body.append(target)
const observer = new MutationObserver(() => {})
observer.observe(target, { childList: true, subtree: true })
const callbacks = readListenerCallbacks(target)
expect(callbacks.length).toBe(1)
expect(callbacks[0]).toBeTypeOf('function')
expect(retainedMutationCallbackCount(observer)).toBe(1)
observer.disconnect()
expect(retainedMutationCallbackCount(observer)).toBe(0)
target.remove()
})
it('keeps delivering records after the weak callback would have been collected', async () => {
installHappyDomMutationObserverRetention()
const target = document.createElement('div')
document.body.append(target)
let deliveries = 0
const observer = new MutationObserver(() => {
deliveries += 1
})
observer.observe(target, { childList: true, subtree: true })
target.replaceChildren(document.createElement('div'))
await Promise.resolve()
expect(deliveries).toBe(1)
const collectGarbage = (globalThis as { gc?: () => void }).gc
if (collectGarbage) {
for (let round = 0; round < 5; round += 1) {
await new Promise((resolve) => setTimeout(resolve, 1))
collectGarbage()
}
await new Promise((resolve) => setTimeout(resolve, 1))
}
target.replaceChildren(document.createElement('span'))
await Promise.resolve()
expect(deliveries).toBe(2)
observer.disconnect()
target.remove()
})
})
@@ -0,0 +1,81 @@
// Why: happy-dom stores each MutationObserver's internal callback in a WeakRef, so any GC pause
// under parallel test load silently and permanently stops a still-connected observer. Browsers
// keep that callback reachable for as long as the observer observes; mirror that lifetime here so
// DOM-driven tests never lose mutation records mid-run.
type HappyDomMutationListener = {
callback?: { deref: () => unknown }
}
type PatchableMutationObserver = {
observe: (target: Node, options?: MutationObserverInit) => void
disconnect: () => void
}
const MUTATION_LISTENERS_SYMBOL_DESCRIPTION = 'mutationListeners'
const RETENTION_INSTALLED = Symbol.for('orca.happyDomMutationObserverRetention')
const retainedCallbacks = new WeakMap<object, Set<unknown>>()
function readMutationListeners(target: Node): HappyDomMutationListener[] {
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
(candidate) => candidate.description === MUTATION_LISTENERS_SYMBOL_DESCRIPTION
)
if (!listenersSymbol) {
return []
}
const listeners = (target as unknown as Record<symbol, unknown>)[listenersSymbol]
return Array.isArray(listeners) ? (listeners as HappyDomMutationListener[]) : []
}
/** Number of internal callbacks pinned for `observer`; drops to 0 once it disconnects. */
export function retainedMutationCallbackCount(observer: MutationObserver): number {
return retainedCallbacks.get(observer)?.size ?? 0
}
export function installHappyDomMutationObserverRetention(): boolean {
const observerClass = (globalThis as { MutationObserver?: typeof MutationObserver })
.MutationObserver
if (!observerClass) {
return false
}
const prototype = observerClass.prototype as unknown as PatchableMutationObserver &
Record<symbol, unknown>
if (prototype[RETENTION_INSTALLED] === true) {
return true
}
const observe = prototype.observe
const disconnect = prototype.disconnect
prototype.observe = function patchedObserve(
this: object,
target: Node,
options?: MutationObserverInit
): void {
const existing = new Set(readMutationListeners(target))
observe.call(this as unknown as PatchableMutationObserver, target, options)
const pinned = retainedCallbacks.get(this) ?? new Set<unknown>()
for (const listener of readMutationListeners(target)) {
if (existing.has(listener)) {
continue
}
const callback = listener.callback?.deref()
if (callback) {
pinned.add(callback)
}
}
if (pinned.size > 0) {
retainedCallbacks.set(this, pinned)
}
}
prototype.disconnect = function patchedDisconnect(this: object): void {
disconnect.call(this as unknown as PatchableMutationObserver)
retainedCallbacks.delete(this)
}
prototype[RETENTION_INSTALLED] = true
return true
}
installHappyDomMutationObserverRetention()
+53 -8
View File
@@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
@@ -30,8 +34,34 @@ export function createHourlyBuildVersion(baseVersion, date) {
}
/**
* `1.4.163 • 01 • 07-31 13:54 • e698241` — the human-facing release title, shown
* verbatim in both the GitHub releases list and the in-app build picker.
* The next build number for `baseVersion`, counting from the titles of existing
* hourly releases.
*
* Why the series restarts at 01 on every base version: the number answers "which
* build of 1.4.163 is this", so a counter shared across versions makes it
* meaningless — 1.4.164 would open at 38 for no reason a reader can see.
*
* Why the maximum rather than a count: the prune step trims to
* HOURLY_RETAIN_COUNT, so a count would roll backwards and reissue a number
* already in use. Titles that predate this naming simply do not match, which is
* how the first build of a version lands on 01.
*/
export function nextHourlyBuildNumber(baseVersion, releaseNames = []) {
const prefix = `${baseVersion} • `
const highest = releaseNames.reduce((max, entry) => {
const name = String(entry ?? '')
if (!name.startsWith(prefix)) {
return max
}
const match = /^(\d+) • /.exec(name.slice(prefix.length))
return match ? Math.max(max, Number(match[1])) : max
}, 0)
return highest + 1
}
/**
* `1.4.163 • 01 • Jul 31, 1:54PM • e698241` — the human-facing release title,
* shown verbatim in both the GitHub releases list and the in-app build picker.
*/
export function formatHourlyReleaseName(version, buildNumber, commit, date) {
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
@@ -45,20 +75,35 @@ export function formatHourlyReleaseName(version, buildNumber, commit, date) {
].join(' • ')
}
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1) {
// Why the number is derived here rather than passed in: it counts builds of the
// base version, and the base is only known once the published tags have been
// resolved just above. Computing it outside meant numbering against whatever
// version the caller guessed.
export function getHourlyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const version = createHourlyBuildVersion(packageJson.version, now)
return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) }
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? [])
const version = createHourlyBuildVersion(base, now)
const buildNumber = nextHourlyBuildNumber(base, releaseNames ?? [])
return {
commit,
version,
buildNumber,
name: formatHourlyReleaseName(version, buildNumber, commit, now)
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1')
const identity = getHourlyBuildIdentity(new Date(), buildNumber)
const identity = getHourlyBuildIdentity(new Date(), {
publishedVersions: readPublishedVersionsFromEnv(),
// Titles are newline separated and contain spaces, so this cannot reuse the
// whitespace split the version list gets.
releaseNames: (process.env.ORCA_HOURLY_RELEASE_NAMES ?? '').split('\n').filter(Boolean)
})
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n`
`version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n`
)
}
+58 -8
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { createHourlyBuildVersion, formatHourlyReleaseName } from './hourly-build-version.mjs'
import {
createHourlyBuildVersion,
formatHourlyReleaseName,
nextHourlyBuildNumber
} from './hourly-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createHourlyBuildVersion', () => {
@@ -34,21 +38,28 @@ describe('formatHourlyReleaseName', () => {
formatHourlyReleaseName('1.4.163-hourly.x', buildNumber, commit, new Date(iso))
it('renders version, number, Pacific timestamp, and short sha', () => {
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • 07-31 13:54 • e698241')
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • Jul 31, 1:54PM • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • 01-14 18:30 • e698241')
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • 07-31 00:00 • e698241')
expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • Jan 14, 6:30PM • e698241')
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • Jul 31, 12:00AM • e698241')
})
// Why: hour12: false renders midnight as "24" on some ICU builds, which would
// read as an hour that does not exist and sort oddly beside 00:xx.
it('renders midnight as 00, never 24', () => {
expect(name('2026-07-31T07:00:00Z')).toContain(' 00:00 ')
// Why pinned: 12-hour clocks are where off-by-twelve bugs live, and midnight in
// particular renders as 0:00 or 24:00 on a formatter set up carelessly.
it('renders both noon and midnight as 12', () => {
expect(name('2026-07-31T07:00:00Z')).toContain('12:00AM')
expect(name('2026-07-31T19:00:00Z')).toContain('12:00PM')
})
// Recent ICU puts U+202F before AM/PM; the title must carry no separator at all.
it('joins the meridiem with no whitespace of any kind', () => {
expect(name('2026-07-31T20:54:00Z')).toMatch(/\d:\d{2}(AM|PM) •/)
expect(name('2026-07-31T20:54:00Z')).not.toMatch(/\s[AP]M/u)
})
it('pads to two digits and grows past them', () => {
@@ -70,3 +81,42 @@ describe('formatHourlyReleaseName', () => {
)
})
})
describe('nextHourlyBuildNumber', () => {
const titles = [
'1.4.163 • 01 • Jul 31, 1:54PM • e698241',
'1.4.163 • 02 • Jul 31, 2:54PM • aaaaaaa',
'1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'
]
it('continues the series for the version being built', () => {
expect(nextHourlyBuildNumber('1.4.163', titles)).toBe(38)
})
// The point of the change: 1.4.164 opens its own series at 01 rather than
// inheriting 1.4.163's count, which said nothing about 1.4.164.
it('restarts at 1 when the base version moves', () => {
expect(nextHourlyBuildNumber('1.4.164', titles)).toBe(1)
expect(
nextHourlyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 8:00AM • ccccccc'])
).toBe(2)
})
// Why max and not count: pruning trims to HOURLY_RETAIN_COUNT, so counting
// would roll backwards and reissue a number already used.
it('takes the highest number, not the count', () => {
expect(nextHourlyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 1:54PM • e698241'])).toBe(10)
})
it('starts at 1 with no history at all', () => {
expect(nextHourlyBuildNumber('1.4.163')).toBe(1)
expect(nextHourlyBuildNumber('1.4.163', [])).toBe(1)
})
// Legacy titles were the raw tag, and a prefix match must not treat 1.4.16 as
// a prefix of 1.4.163's series.
it('ignores titles that are not this version', () => {
expect(nextHourlyBuildNumber('1.4.16', ['1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'])).toBe(1)
expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1)
})
})
@@ -0,0 +1,38 @@
export class BoundedLiveFreezeHistory {
#entries = []
#limit
#nextIndex = 0
#totalCount = 0
constructor(limit) {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error(`History limit must be a positive integer, got ${limit}`)
}
this.#limit = limit
}
add(entry) {
this.#totalCount += 1
if (this.#entries.length < this.#limit) {
this.#entries.push(entry)
return
}
this.#entries[this.#nextIndex] = entry
this.#nextIndex = (this.#nextIndex + 1) % this.#limit
}
get retainedCount() {
return this.#entries.length
}
get totalCount() {
return this.#totalCount
}
values() {
if (this.#entries.length < this.#limit || this.#nextIndex === 0) {
return [...this.#entries]
}
return [...this.#entries.slice(this.#nextIndex), ...this.#entries.slice(0, this.#nextIndex)]
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
describe('BoundedLiveFreezeHistory', () => {
it('retains the newest entries in insertion order and counts the full run', () => {
const history = new BoundedLiveFreezeHistory(3)
for (let value = 1; value <= 7; value += 1) {
history.add(value)
}
expect(history.values()).toEqual([5, 6, 7])
expect(history.retainedCount).toBe(3)
expect(history.totalCount).toBe(7)
})
it('rejects invalid retention limits', () => {
expect(() => new BoundedLiveFreezeHistory(0)).toThrow('positive integer')
expect(() => new BoundedLiveFreezeHistory(1.5)).toThrow('positive integer')
})
})
@@ -0,0 +1,262 @@
/**
* Pure metrics helpers for the live remote bulk-open freeze harness.
* Kept separate so unit tests can drive the same code the repro uses.
*/
export const DEFAULT_SOFT_MS = 2000
export const DEFAULT_HARD_MS = 5000
export function readFreezeNumberEnv(name, fallback) {
const raw = process.env[name]
if (raw == null || raw.trim() === '') {
return fallback
}
const value = Number(raw)
if (!Number.isFinite(value)) {
throw new Error(`Invalid ${name}: expected a finite number, got ${JSON.stringify(raw)}`)
}
return value
}
export function extractTerminalHandle(result) {
if (!result || typeof result !== 'object') {
return null
}
const candidates = [
result.handle,
result.terminalHandle,
result.agentTerminalHandle,
typeof result.terminal === 'string' ? result.terminal : result.terminal?.handle,
result.startupTerminal?.handle,
result.tab?.terminal,
result.tab?.handle
]
for (const value of candidates) {
if (typeof value === 'string' && value.startsWith('term_')) {
return value
}
}
for (const value of Object.values(result)) {
if (typeof value === 'string' && value.startsWith('term_')) {
return value
}
if (value && typeof value === 'object') {
for (const nested of Object.values(value)) {
if (typeof nested === 'string' && nested.startsWith('term_')) {
return nested
}
}
}
}
return null
}
export function worktreeSelector(wt) {
if (typeof wt?.id === 'string' && wt.id.length > 0) {
return `id:${wt.id}`
}
if (typeof wt?.path === 'string' && wt.path.length > 0) {
return `path:${wt.path}`
}
return null
}
/**
* Peak stall across individual switch latency and concurrent batch wall.
* Hard freeze when peak >= hardMs (default 5000).
*/
export function evaluateFreezeSignals({
maxSwitchMs = 0,
maxBatchWallMs = 0,
statusProbeMs = 0,
memoryProbeMs = null,
softMs = DEFAULT_SOFT_MS,
hardMs = DEFAULT_HARD_MS
}) {
const peakLatencyMs = Math.max(maxSwitchMs, maxBatchWallMs)
const softFreeze =
peakLatencyMs >= softMs ||
statusProbeMs >= softMs ||
(memoryProbeMs != null && memoryProbeMs >= softMs)
const hardFreeze =
peakLatencyMs >= hardMs ||
statusProbeMs >= hardMs ||
(memoryProbeMs != null && memoryProbeMs >= hardMs)
return { peakLatencyMs, softFreeze, hardFreeze }
}
export function shouldCapSwitchTargets(maxSwitchTargets) {
return Number.isFinite(maxSwitchTargets) && maxSwitchTargets > 0
}
export function applySwitchTargetCap(targets, maxSwitchTargets) {
if (!shouldCapSwitchTargets(maxSwitchTargets)) {
return targets
}
return targets.slice(0, maxSwitchTargets)
}
/** Scenarios that model real user recovery, not concurrent CLI pileup. */
export const REALISTIC_SCENARIOS = [
'idle-backlog-open',
'idle-backlog-reconnect-open',
'restart-proxy',
/** Idle + flood + reconnect storm overlapped with concurrent open fan-out. */
'lockup-storm'
]
/**
* Permanent lockup: app/host stops making progress — not a single recovered timeout.
* Distinct from multi-second hard stall that still recovers (status answers, most opens ok).
*/
export function evaluatePermanentLockup({
timedOutOps = 0,
statusHangMs = 0,
consecutiveSwitchFailures = 0,
openFailed = 0,
openTotal = 0,
permanentTimeoutMs = 60_000,
/** Fraction of opens that must fail to count as lockup without status hang. */
failRateThreshold = 0.25,
minTimedOutOps = 3
}) {
const failRate = openTotal > 0 ? openFailed / openTotal : 0
const permanentLockup =
statusHangMs >= permanentTimeoutMs ||
timedOutOps >= minTimedOutOps ||
consecutiveSwitchFailures >= 5 ||
(openTotal >= 8 && failRate >= failRateThreshold)
return {
permanentLockup,
timedOutOps,
statusHangMs,
consecutiveSwitchFailures,
failRate,
recoveredHardStallCandidate:
!permanentLockup && timedOutOps < minTimedOutOps && statusHangMs < permanentTimeoutMs
}
}
/**
* Peak across open latencies + optional reconnect-refresh wall + probes.
* Used by the naturalistic harness (no parallel switch amp).
*/
export function evaluateRealisticFreezeSignals({
maxOpenMs = 0,
firstOpenMs = 0,
reconnectRefreshMs = 0,
statusProbeMs = 0,
memoryProbeMs = null,
softMs = DEFAULT_SOFT_MS,
hardMs = DEFAULT_HARD_MS
}) {
const peakLatencyMs = Math.max(maxOpenMs, firstOpenMs, reconnectRefreshMs)
return evaluateFreezeSignals({
maxSwitchMs: peakLatencyMs,
maxBatchWallMs: 0,
statusProbeMs,
memoryProbeMs,
softMs,
hardMs
})
}
export function humanPaceDelayMs(baseMs, jitterMs = 0) {
const base = Math.max(0, baseMs)
const jitter = Math.max(0, jitterMs)
if (jitter === 0) {
return base
}
return base + Math.floor(Math.random() * (jitter + 1))
}
/** Full-app forever freeze: host RPC dead for a continuous window, not a recovered stall. */
export const DEFAULT_FOREVER_WINDOW_MS = 30_000
export const DEFAULT_STATUS_SLOW_MS = 15_000
/**
* Analyze mid-storm status samples for a continuous unhealthy window.
* Sample: { tMs, ms, ok, hang }
*/
export function evaluateFullAppFreeze({
statusSamples = [],
statusSummary = {},
foreverWindowMs = DEFAULT_FOREVER_WINDOW_MS,
statusSlowMs = DEFAULT_STATUS_SLOW_MS,
killOnlyRecovery = false
}) {
const infrastructureErrors = statusSamples.filter((sample) => sample.infrastructureError)
const infrastructureErrorCount = Math.max(
infrastructureErrors.length,
statusSummary.infrastructureErrorCount ?? 0
)
const maxStatusMs = Math.max(
0,
...statusSamples.map((s) => s.ms || 0),
statusSummary.maxStatusMs ?? 0
)
if (killOnlyRecovery) {
return {
foreverUiLockupObserved: true,
longestUnhealthyWindowMs: foreverWindowMs,
maxStatusMs,
unhealthySampleCount: statusSummary.sampleCount ?? statusSamples.length,
infrastructureErrorCount,
reason: 'kill-only recovery documented'
}
}
const unhealthy = statusSamples.map((s) => {
const hang = !s.infrastructureError && (Boolean(s.hang) || s.ok === false)
const slow = !s.infrastructureError && (s.ms || 0) >= statusSlowMs
return { ...s, unhealthy: hang || slow }
})
let longest = statusSummary.longestUnhealthyWindowMs ?? 0
let runStart = null
for (const s of unhealthy) {
if (s.unhealthy) {
runStart ??= s.tMs ?? 0
const end = (s.tMs ?? 0) + (s.ms || 0)
longest = Math.max(longest, end - runStart)
} else {
runStart = null
}
}
// If timestamps missing, fall back to consecutive unhealthy count * assumed interval.
if (longest === 0 && unhealthy.some((s) => s.unhealthy)) {
let run = 0
for (const s of unhealthy) {
if (s.unhealthy) {
run += 1
longest = Math.max(longest, run)
} else {
run = 0
}
}
// Without wall clock, consecutive count alone is not a ms window.
longest = 0
}
const foreverUiLockupObserved = longest >= foreverWindowMs
const unhealthySampleCount = Math.max(
unhealthy.filter((s) => s.unhealthy).length,
statusSummary.unhealthySampleCount ?? 0
)
return {
foreverUiLockupObserved,
longestUnhealthyWindowMs: longest,
maxStatusMs,
unhealthySampleCount,
infrastructureErrorCount,
reason: foreverUiLockupObserved
? `status unhealthy ≥${foreverWindowMs}ms continuous`
: infrastructureErrorCount > 0
? `status watchdog infrastructure errors: ${infrastructureErrorCount}`
: maxStatusMs >= statusSlowMs
? `status slow peak ${maxStatusMs}ms but no ≥${foreverWindowMs}ms window`
: 'status remained healthy through storm'
}
}
@@ -0,0 +1,239 @@
import { describe, expect, it } from 'vitest'
import {
applySwitchTargetCap,
evaluateFreezeSignals,
evaluateFullAppFreeze,
evaluatePermanentLockup,
evaluateRealisticFreezeSignals,
extractTerminalHandle,
humanPaceDelayMs,
readFreezeNumberEnv,
REALISTIC_SCENARIOS,
shouldCapSwitchTargets,
worktreeSelector
} from './live-remote-bulk-open-freeze-metrics.mjs'
describe('live-remote-bulk-open-freeze-metrics', () => {
it('extracts term_ handles from nested create payloads', () => {
expect(extractTerminalHandle({ handle: 'term_abc' })).toBe('term_abc')
expect(extractTerminalHandle({ terminal: { handle: 'term_nested' } })).toBe('term_nested')
expect(extractTerminalHandle({ tab: { terminal: 'term_tab' } })).toBe('term_tab')
expect(extractTerminalHandle({ startupTerminal: { handle: 'term_start' } })).toBe('term_start')
expect(extractTerminalHandle({ junk: { deep: 'term_deep' } })).toBe('term_deep')
expect(extractTerminalHandle({ handle: 'not-a-term' })).toBeNull()
expect(extractTerminalHandle(null)).toBeNull()
})
it('builds worktree selectors from id/path', () => {
expect(
worktreeSelector({ id: 'repo::C:/Users/neil/orca/orca', path: 'C:/Users/neil/orca/orca' })
).toBe('id:repo::C:/Users/neil/orca/orca')
expect(worktreeSelector({ path: '/tmp/x' })).toBe('path:/tmp/x')
expect(worktreeSelector({})).toBeNull()
})
it('does not cap switch targets when max is 0 (regression for Math.max(2,0) bug)', () => {
expect(shouldCapSwitchTargets(0)).toBe(false)
expect(shouldCapSwitchTargets(-1)).toBe(false)
expect(shouldCapSwitchTargets(2)).toBe(true)
const many = Array.from({ length: 111 }, (_, i) => `term_${i}`)
expect(applySwitchTargetCap(many, 0)).toHaveLength(111)
expect(applySwitchTargetCap(many, 2)).toHaveLength(2)
})
it('classifies hard freeze at >=5000ms peak (individual or batch wall)', () => {
expect(evaluateFreezeSignals({ maxSwitchMs: 3874, maxBatchWallMs: 3874 }).hardFreeze).toBe(
false
)
expect(evaluateFreezeSignals({ maxSwitchMs: 3874, maxBatchWallMs: 3874 }).softFreeze).toBe(true)
const hardIndividual = evaluateFreezeSignals({ maxSwitchMs: 19954, maxBatchWallMs: 1000 })
expect(hardIndividual.hardFreeze).toBe(true)
expect(hardIndividual.peakLatencyMs).toBe(19954)
const hardBatch = evaluateFreezeSignals({ maxSwitchMs: 900, maxBatchWallMs: 20201 })
expect(hardBatch.hardFreeze).toBe(true)
expect(hardBatch.peakLatencyMs).toBe(20201)
})
it('evaluates naturalistic peaks without requiring parallel batch amp', () => {
expect(REALISTIC_SCENARIOS).toContain('idle-backlog-open')
expect(REALISTIC_SCENARIOS).toContain('idle-backlog-reconnect-open')
expect(REALISTIC_SCENARIOS).toContain('lockup-storm')
const soft = evaluateRealisticFreezeSignals({
maxOpenMs: 3200,
firstOpenMs: 2800,
reconnectRefreshMs: 900
})
expect(soft.softFreeze).toBe(true)
expect(soft.hardFreeze).toBe(false)
expect(soft.peakLatencyMs).toBe(3200)
const hardFromReconnect = evaluateRealisticFreezeSignals({
maxOpenMs: 800,
firstOpenMs: 700,
reconnectRefreshMs: 6200
})
expect(hardFromReconnect.hardFreeze).toBe(true)
expect(hardFromReconnect.peakLatencyMs).toBe(6200)
})
it('flags full-app freeze only for continuous unhealthy status window ≥30s', () => {
const healthy = evaluateFullAppFreeze({
statusSamples: [
{ tMs: 0, ms: 150, ok: true },
{ tMs: 2000, ms: 180, ok: true },
{ tMs: 4000, ms: 140, ok: true }
],
foreverWindowMs: 30_000,
statusSlowMs: 15_000
})
expect(healthy.foreverUiLockupObserved).toBe(false)
const forever = evaluateFullAppFreeze({
statusSamples: [
{ tMs: 0, ms: 16_000, ok: true },
{ tMs: 16_000, ms: 16_000, ok: true },
{ tMs: 32_000, ms: 16_000, ok: false, hang: true }
],
foreverWindowMs: 30_000,
statusSlowMs: 15_000
})
expect(forever.foreverUiLockupObserved).toBe(true)
expect(forever.longestUnhealthyWindowMs).toBeGreaterThanOrEqual(30_000)
expect(
evaluateFullAppFreeze({ statusSamples: [], killOnlyRecovery: true }).foreverUiLockupObserved
).toBe(true)
})
it('does not classify watchdog infrastructure errors as an app freeze', () => {
const result = evaluateFullAppFreeze({
statusSamples: Array.from({ length: 25 }, (_, index) => ({
tMs: index * 1500,
ms: 1,
ok: false,
infrastructureError: true,
error: 'spawn ENOENT'
}))
})
expect(result.foreverUiLockupObserved).toBe(false)
expect(result.unhealthySampleCount).toBe(0)
expect(result.infrastructureErrorCount).toBe(25)
})
it('preserves full-run watchdog peaks after sample retention rotates', () => {
const result = evaluateFullAppFreeze({
statusSamples: [{ tMs: 60_000, ms: 100, ok: true }],
statusSummary: {
sampleCount: 40,
maxStatusMs: 16_000,
unhealthySampleCount: 3,
infrastructureErrorCount: 2,
longestUnhealthyWindowMs: 32_000
},
foreverWindowMs: 30_000,
statusSlowMs: 15_000
})
expect(result.foreverUiLockupObserved).toBe(true)
expect(result.longestUnhealthyWindowMs).toBe(32_000)
expect(result.maxStatusMs).toBe(16_000)
expect(result.unhealthySampleCount).toBe(3)
expect(result.infrastructureErrorCount).toBe(2)
})
it('rejects invalid numeric environment values', () => {
process.env.ORCA_FREEZE_TEST_NUMBER = 'not-a-number'
expect(() => readFreezeNumberEnv('ORCA_FREEZE_TEST_NUMBER', 5)).toThrow(
'Invalid ORCA_FREEZE_TEST_NUMBER'
)
delete process.env.ORCA_FREEZE_TEST_NUMBER
expect(readFreezeNumberEnv('ORCA_FREEZE_TEST_NUMBER', 5)).toBe(5)
})
it('distinguishes recovered hard stall from permanent lockup', () => {
// Single reveal timeout with healthy status is NOT permanent app lockup.
expect(
evaluatePermanentLockup({
timedOutOps: 1,
statusHangMs: 0,
consecutiveSwitchFailures: 1,
openFailed: 1,
openTotal: 64
}).permanentLockup
).toBe(false)
expect(
evaluatePermanentLockup({
timedOutOps: 3,
statusHangMs: 0,
consecutiveSwitchFailures: 0,
openFailed: 3,
openTotal: 64
}).permanentLockup
).toBe(true)
expect(
evaluatePermanentLockup({
timedOutOps: 0,
statusHangMs: 60_000,
consecutiveSwitchFailures: 0,
permanentTimeoutMs: 60_000
}).permanentLockup
).toBe(true)
expect(
evaluatePermanentLockup({
timedOutOps: 0,
statusHangMs: 0,
consecutiveSwitchFailures: 5
}).permanentLockup
).toBe(true)
expect(
evaluatePermanentLockup({
timedOutOps: 0,
openFailed: 20,
openTotal: 40
}).permanentLockup
).toBe(true)
})
it('human pace delay stays within base+jitter', () => {
for (let i = 0; i < 20; i += 1) {
const d = humanPaceDelayMs(250, 150)
expect(d).toBeGreaterThanOrEqual(250)
expect(d).toBeLessThanOrEqual(400)
}
expect(humanPaceDelayMs(100, 0)).toBe(100)
})
it('reads the real hard-freeze lab report when present', async () => {
const { readdirSync, readFileSync, existsSync } = await import('node:fs')
const { resolve } = await import('node:path')
const reportDir = resolve(process.cwd(), 'test-results/freeze-repro')
if (!existsSync(reportDir)) {
// Local clones without lab artifacts still pass pure metrics tests above.
return
}
const reportName = readdirSync(reportDir).find(
(name) => name.startsWith('live-bulk-open-freeze-') && name.endsWith('.json')
)
if (reportName == null) {
return
}
const report = JSON.parse(readFileSync(resolve(reportDir, reportName), 'utf8'))
const evaluated = evaluateFreezeSignals({
maxSwitchMs: report.maxSwitchMs,
maxBatchWallMs: report.maxBatchWallMs ?? 0,
statusProbeMs: report.statusProbeMs ?? 0,
memoryProbeMs: report.memoryProbeMs,
softMs: report.softMs,
hardMs: report.hardMs
})
expect(evaluated.hardFreeze).toBe(report.hardFreeze)
expect(evaluated.peakLatencyMs).toBeGreaterThanOrEqual(5000)
expect(typeof report.environment).toBe('string')
expect(report.environment.length).toBeGreaterThan(0)
expect(report.switchTargets).toBeGreaterThan(50)
expect(report.parallel).toBeGreaterThanOrEqual(8)
})
})
@@ -0,0 +1,358 @@
#!/usr/bin/env node
/**
* Live freeze repro against a running Orca desktop + paired remote runtime.
*
* Models bulk-open of remote sessions under multi-worktree load.
*
* Usage:
* node config/scripts/live-remote-bulk-open-freeze-repro.mjs
* ORCA_FREEZE_ENV=paired-remote ORCA_FREEZE_CREATE=12 ORCA_FREEZE_SWITCH_PASSES=5 \
* ORCA_FREEZE_PARALLEL=8 node config/scripts/live-remote-bulk-open-freeze-repro.mjs
*/
import { spawnSync } from 'node:child_process'
import { mkdirSync, writeFileSync, copyFileSync } from 'node:fs'
import path from 'node:path'
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
import {
applySwitchTargetCap,
DEFAULT_HARD_MS,
DEFAULT_SOFT_MS,
evaluateFreezeSignals,
extractTerminalHandle,
readFreezeNumberEnv,
shouldCapSwitchTargets,
worktreeSelector
} from './live-remote-bulk-open-freeze-metrics.mjs'
import { createOrcaRpc } from './live-remote-freeze-rpc.mjs'
const root = path.resolve(import.meta.dirname, '../..')
const reportDir = path.join(root, 'test-results', 'freeze-repro')
const envName = process.env.ORCA_FREEZE_ENV || 'paired-remote'
const createCount = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_CREATE', 0))
const switchPasses = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_SWITCH_PASSES', 3))
const parallel = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_PARALLEL', 1))
// 0 = no cap (use all live terminals). Only positive env values limit targets.
const maxSwitchTargets = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_MAX_SWITCH_TARGETS', 0))
const softMs = readFreezeNumberEnv('ORCA_FREEZE_SOFT_MS', DEFAULT_SOFT_MS)
const hardMs = readFreezeNumberEnv('ORCA_FREEZE_HARD_MS', DEFAULT_HARD_MS)
const createWorktreeSpan = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_CREATE_WT_SPAN', 16))
const preFloodMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PRE_FLOOD_MS', 3000))
const scratchDir = process.env.ORCA_FREEZE_SCRATCH || ''
const { orcaJsonSync, orcaJsonAsync } = createOrcaRpc({ envName })
async function mapPool(items, concurrency, worker) {
const results = Array.from({ length: items.length })
let next = 0
async function run() {
while (next < items.length) {
const index = next
next += 1
results[index] = await worker(items[index], index)
}
}
const runners = Array.from({ length: Math.min(concurrency, items.length) }, () => run())
await Promise.all(runners)
return results
}
function sampleOrcaIfPossible() {
if (process.platform !== 'darwin') {
return null
}
try {
const status = orcaJsonSync(['status'], { local: true }).result
const pid = status?.app?.pid
if (!pid) {
return null
}
const out = path.join(reportDir, `orca-sample-${Date.now()}.txt`)
const sampled = spawnSync('sample', [String(pid), '5', '-file', out], {
timeout: 20_000,
stdio: 'ignore'
})
return sampled.status === 0 ? out : null
} catch {
return null
}
}
function floodCommand(marker) {
// Continuous 2KB frames @ ~8ms — agent-like remote output.
const script =
"const m=process.argv[1];process.stdout.write('READY:'+m+'\\n');let f=0;const c='A'.repeat(2048);setInterval(()=>{f++;process.stdout.write('BG:'+m+':'+f+':'+c+'\\n')},8);process.stdin.resume()"
return `node -e ${JSON.stringify(script)} ${JSON.stringify(marker)}`
}
async function main() {
mkdirSync(reportDir, { recursive: true })
const notes = []
const timings = new BoundedLiveFreezeHistory(120)
const amplificationSteps = []
console.log(
`[live-freeze] env=${envName} create=${createCount} passes=${switchPasses} parallel=${parallel}`
)
const status = orcaJsonSync(['status'])
notes.push(
`remote version=${status.result?.runtime?.appVersion} state=${status.result?.runtime?.state}`
)
const local = orcaJsonSync(['status'], { local: true })
notes.push(`local version=${local.result?.runtime?.appVersion} pid=${local.result?.app?.pid}`)
const worktrees = orcaJsonSync(['worktree', 'list']).result
const wtList = worktrees?.worktrees || worktrees?.items || worktrees || []
if (!Array.isArray(wtList) || wtList.length === 0) {
throw new Error(`No worktrees on environment ${envName}`)
}
notes.push(`remote worktrees=${wtList.length}`)
amplificationSteps.push(`baseline worktrees=${wtList.length}`)
const targets = wtList.slice(0, Math.min(createWorktreeSpan, wtList.length))
const created = []
// Parallel flood-terminal creates across many worktrees.
if (createCount > 0) {
amplificationSteps.push(`create=${createCount} parallel=${Math.min(parallel, createCount)}`)
const createJobs = Array.from({ length: createCount }, (_, i) => i)
await mapPool(createJobs, Math.min(parallel, createCount), async (i) => {
const wt = targets[i % targets.length]
const selector = worktreeSelector(wt)
if (!selector) {
notes.push(`create ${i} skipped: no selector`)
return
}
const marker = `LIVE_BULK_${Date.now()}_${i}`
try {
const createdTerm = await orcaJsonAsync(
[
'terminal',
'create',
'--worktree',
selector,
'--title',
`freeze-repro-${i}`,
'--command',
floodCommand(marker)
],
{ timeoutMs: 180_000 }
)
timings.add({ op: 'terminal.create', ms: createdTerm.elapsedMs, ok: true, index: i })
const handle = extractTerminalHandle(createdTerm.result)
if (handle) {
created.push({ handle, marker, worktree: selector })
console.log(
`[live-freeze] created ${handle} on ${selector} in ${createdTerm.elapsedMs.toFixed(0)}ms`
)
} else {
notes.push(
`create ${i} missing handle: ${JSON.stringify(createdTerm.result).slice(0, 400)}`
)
}
} catch (error) {
timings.add({ op: 'terminal.create', ms: null, ok: false, error: String(error), index: i })
notes.push(`create ${i} failed: ${String(error).slice(0, 300)}`)
console.warn(`[live-freeze] create failed: ${String(error)}`)
}
})
}
if (preFloodMs > 0 && created.length > 0) {
amplificationSteps.push(`preFloodMs=${preFloodMs}`)
await new Promise((r) => setTimeout(r, preFloodMs))
}
let live = []
try {
const listed = orcaJsonSync(['terminal', 'list'])
const terms = listed.result?.terminals || []
live = terms
.filter(
(t) => typeof t.handle === 'string' && t.handle.startsWith('term_') && t.connected !== false
)
.map((t) => ({ handle: t.handle, title: t.title, worktreeId: t.worktreeId }))
notes.push(`live terminals listed=${live.length}`)
} catch (error) {
notes.push(`terminal list failed: ${String(error).slice(0, 200)}`)
}
let switchTargets = [...created.map((c) => c.handle), ...live.map((t) => t.handle)].filter(
(v, i, a) => typeof v === 'string' && a.indexOf(v) === i
)
if (shouldCapSwitchTargets(maxSwitchTargets) && switchTargets.length > maxSwitchTargets) {
switchTargets = applySwitchTargetCap(switchTargets, maxSwitchTargets)
amplificationSteps.push(`capped switchTargets=${maxSwitchTargets}`)
}
if (switchTargets.length < 2) {
throw new Error(
`Need ≥2 terminals to bulk-switch; got ${switchTargets.length}. notes=${notes.join('; ')}`
)
}
amplificationSteps.push(
`switchTargets=${switchTargets.length} passes=${switchPasses} parallel=${parallel}`
)
console.log(
`[live-freeze] bulk-switching ${switchTargets.length} terminals × ${switchPasses} passes (parallel=${parallel})`
)
let maxSwitchMs = 0
let maxBatchWallMs = 0
let sumSwitchMs = 0
let switchCount = 0
const switchStarted = performance.now()
for (let pass = 0; pass < switchPasses; pass += 1) {
// Chunk targets into concurrent batches — piles load onto client/UI path.
for (let offset = 0; offset < switchTargets.length; offset += parallel) {
const batch = switchTargets.slice(offset, offset + parallel)
const batchStarted = performance.now()
const batchResults = await Promise.all(
batch.map(async (handle) => {
try {
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
timeoutMs: 90_000
})
return { handle, ms: sw.elapsedMs, ok: true }
} catch (error) {
return { handle, error: String(error), ok: false }
}
})
)
const batchWall = performance.now() - batchStarted
maxBatchWallMs = Math.max(maxBatchWallMs, batchWall)
for (const item of batchResults) {
if (item.ok) {
maxSwitchMs = Math.max(maxSwitchMs, item.ms)
sumSwitchMs += item.ms
switchCount += 1
timings.add({ op: 'terminal.switch', handle: item.handle, ms: item.ms, batchWall })
if (item.ms >= softMs) {
console.warn(`[live-freeze] SOFT lag on switch ${item.handle}: ${item.ms.toFixed(0)}ms`)
}
if (item.ms >= hardMs) {
console.warn(`[live-freeze] HARD lag on switch ${item.handle}: ${item.ms.toFixed(0)}ms`)
}
} else {
timings.add({ op: 'terminal.switch', handle: item.handle, error: item.error })
notes.push(`switch ${item.handle} failed: ${String(item.error).slice(0, 200)}`)
}
}
if (batchWall >= softMs) {
console.warn(`[live-freeze] SOFT batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`)
}
if (batchWall >= hardMs) {
console.warn(`[live-freeze] HARD batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`)
}
}
}
const bulkWallMs = performance.now() - switchStarted
const avgSwitchMs = switchCount ? sumSwitchMs / switchCount : 0
const statusProbe = orcaJsonSync(['status'], { local: true })
let memoryProbeMs = null
try {
const mem = orcaJsonSync(['diagnostics', 'memory'], { local: true, timeoutMs: 120_000 })
memoryProbeMs = mem.elapsedMs
notes.push(`memory diagnostic ms=${mem.elapsedMs.toFixed(0)}`)
} catch (error) {
notes.push(`memory diagnostic failed: ${String(error).slice(0, 200)}`)
}
const { peakLatencyMs, softFreeze, hardFreeze } = evaluateFreezeSignals({
maxSwitchMs,
maxBatchWallMs,
statusProbeMs: statusProbe.elapsedMs,
memoryProbeMs,
softMs,
hardMs
})
let samplePath = null
if (softFreeze || hardFreeze) {
samplePath = sampleOrcaIfPossible()
if (samplePath) {
notes.push(`sample=${samplePath}`)
} else {
notes.push('sample unavailable')
}
}
const report = {
topology: 'live-paired-remote',
environment: envName,
localVersion: local.result?.runtime?.appVersion,
remoteVersion: status.result?.runtime?.appVersion,
remoteWorktreeCount: wtList.length,
createdTerminals: created.length,
switchTargets: switchTargets.length,
switchPasses,
parallel,
maxSwitchMs,
maxBatchWallMs,
peakLatencyMs,
avgSwitchMs,
bulkWallMs,
statusProbeMs: statusProbe.elapsedMs,
memoryProbeMs,
softFreeze,
hardFreeze,
softMs,
hardMs,
amplificationSteps,
notes,
timingCount: timings.totalCount,
timings: timings.values()
}
const outPath = path.join(reportDir, `live-bulk-open-freeze-${envName}.json`)
writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`)
// Also write a stamped peak report so amplification runs don't overwrite history.
const stamped = path.join(reportDir, `live-bulk-open-freeze-${envName}-peak-${Date.now()}.json`)
writeFileSync(stamped, `${JSON.stringify(report, null, 2)}\n`)
console.log(`[live-freeze] report ${outPath}`)
console.log(`[live-freeze] stamped ${stamped}`)
console.log(JSON.stringify(report, null, 2))
if (scratchDir) {
try {
mkdirSync(scratchDir, { recursive: true })
copyFileSync(outPath, path.join(scratchDir, 'live-bulk-open-freeze-report.json'))
writeFileSync(
path.join(scratchDir, 'live-freeze-amplify-summary.json'),
`${JSON.stringify(
{
peakLatencyMs,
hardFreeze,
softFreeze,
amplificationSteps,
stamped
},
null,
2
)}\n`
)
} catch (error) {
notes.push(`scratch copy failed: ${String(error).slice(0, 200)}`)
}
}
if (hardFreeze) {
process.exitCode = 2
console.error('[live-freeze] HARD FREEZE SIGNAL')
} else if (softFreeze) {
process.exitCode = 1
console.error('[live-freeze] SOFT FREEZE SIGNAL')
} else {
console.log('[live-freeze] no freeze signal under thresholds')
}
}
main().catch((error) => {
console.error('[live-freeze] failed', error)
process.exit(3)
})
+241
View File
@@ -0,0 +1,241 @@
import { spawn, spawnSync } from 'node:child_process'
import path from 'node:path'
export const MAX_ORCA_RPC_OUTPUT_BYTES = 20 * 1024 * 1024
export function appendOrcaRpcOutput(output, chunk, bytes, limit = MAX_ORCA_RPC_OUTPUT_BYTES) {
const nextBytes = bytes + Buffer.byteLength(chunk)
return {
output: nextBytes > limit ? output : output + chunk,
bytes: nextBytes,
exceeded: nextBytes > limit
}
}
export function resolveOrcaCliCommand({ env = process.env, platform = process.platform } = {}) {
if (env.ORCA_CLI_COMMAND?.trim()) {
return env.ORCA_CLI_COMMAND.trim()
}
if (env.ORCA_DEV_REPO_ROOT) {
return 'orca-dev'
}
return platform === 'linux' ? 'orca-ide' : 'orca'
}
export function resolveOrcaCliInvocation({
env = process.env,
platform = process.platform,
nodeExecutable = process.execPath
} = {}) {
const command = resolveOrcaCliCommand({ env, platform })
const commandName = platform === 'win32' ? path.win32.basename(command).toLowerCase() : command
if (
platform === 'win32' &&
env.ORCA_DEV_REPO_ROOT &&
(commandName === 'orca-dev' || commandName === 'orca-dev.cmd')
) {
const defaultUserDataPath = path.win32.join(
env.APPDATA ?? path.win32.join(env.USERPROFILE ?? '', 'AppData', 'Roaming'),
'orca-dev'
)
return {
command: nodeExecutable,
prefixArgs: [path.win32.join(env.ORCA_DEV_REPO_ROOT, 'out', 'cli', 'index.js')],
env: {
...env,
ORCA_USER_DATA_PATH:
env.ORCA_USER_DATA_PATH ?? env.ORCA_DEV_USER_DATA_PATH ?? defaultUserDataPath,
ORCA_DEV_CLI_INVOCATION: '1',
ORCA_APP_EXECUTABLE:
env.ORCA_APP_EXECUTABLE ??
path.win32.join(
env.ORCA_DEV_REPO_ROOT,
'node_modules',
'electron',
'dist',
'electron.exe'
),
ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1'
}
}
}
return { command, prefixArgs: [] }
}
export function createOrcaRpc({
envName,
cliCommand,
env = process.env,
platform = process.platform
}) {
const cliInvocation = cliCommand
? { command: cliCommand, prefixArgs: [] }
: resolveOrcaCliInvocation({ env, platform })
const commandLabel = cliCommand ?? resolveOrcaCliCommand({ env, platform })
const commandArgs = (args, local) => [
...cliInvocation.prefixArgs,
...args,
...(local ? [] : ['--environment', envName]),
'--json'
]
function orcaJsonSync(args, opts = {}) {
const started = performance.now()
const result = spawnSync(cliInvocation.command, commandArgs(args, opts.local), {
encoding: 'utf8',
env: cliInvocation.env,
maxBuffer: MAX_ORCA_RPC_OUTPUT_BYTES,
timeout: opts.timeoutMs ?? 120_000
})
const elapsedMs = performance.now() - started
if (result.error) {
throw new Error(`${commandLabel} ${args.join(' ')} failed to start: ${String(result.error)}`)
}
if (result.status !== 0) {
throw new Error(
`${commandLabel} ${args.join(' ')} failed (${result.status}): ${result.stderr || result.stdout}`
)
}
const parsed = JSON.parse(result.stdout)
if (parsed.ok === false) {
throw new Error(`${commandLabel} ${args.join(' ')} ok=false: ${JSON.stringify(parsed)}`)
}
return { parsed, elapsedMs, result: parsed.result }
}
function orcaJsonAsync(args, opts = {}) {
const started = performance.now()
return new Promise((resolve, reject) => {
const child = spawn(cliInvocation.command, commandArgs(args, opts.local), {
env: cliInvocation.env,
stdio: ['ignore', 'pipe', 'pipe']
})
let stdout = ''
let stderr = ''
let outputBytes = 0
let settled = false
let timer
const fail = (error) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
reject(error)
}
const append = (stream, chunk) => {
if (settled) {
return stream
}
const appended = appendOrcaRpcOutput(stream, chunk, outputBytes)
outputBytes = appended.bytes
if (appended.exceeded) {
child.kill('SIGKILL')
fail(new Error(`${commandLabel} ${args.join(' ')} exceeded 20 MiB output limit`))
return stream
}
return appended.output
}
timer = setTimeout(() => {
child.kill('SIGKILL')
fail(
new Error(
`${commandLabel} ${args.join(' ')} timed out after ${opts.timeoutMs ?? 120_000}ms`
)
)
}, opts.timeoutMs ?? 120_000)
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => {
stdout = append(stdout, chunk)
})
child.stderr.on('data', (chunk) => {
stderr = append(stderr, chunk)
})
child.on('error', fail)
child.on('close', (code) => {
if (settled) {
return
}
clearTimeout(timer)
const elapsedMs = performance.now() - started
if (code !== 0) {
fail(
new Error(
`${commandLabel} ${args.join(' ')} failed (${code}): ${stderr || stdout}`.slice(
0,
800
)
)
)
return
}
try {
const parsed = JSON.parse(stdout)
if (parsed.ok === false) {
fail(
new Error(
`${commandLabel} ${args.join(' ')} ok=false: ${JSON.stringify(parsed)}`.slice(
0,
800
)
)
)
return
}
settled = true
resolve({ parsed, elapsedMs, result: parsed.result })
} catch (error) {
fail(
new Error(
`${commandLabel} parse failed: ${String(error)}; stdout=${stdout.slice(0, 400)}`
)
)
}
})
})
}
async function runReconnectRefreshStorm(notes) {
const started = performance.now()
const jobs = [
() => orcaJsonAsync(['status'], { timeoutMs: 90_000 }),
() => orcaJsonAsync(['worktree', 'list'], { timeoutMs: 120_000 }),
() => orcaJsonAsync(['terminal', 'list'], { timeoutMs: 120_000 }),
() => orcaJsonAsync(['status'], { local: true, timeoutMs: 60_000 }),
() => orcaJsonAsync(['worktree', 'list'], { timeoutMs: 120_000 }),
() => orcaJsonAsync(['terminal', 'list'], { timeoutMs: 120_000 })
]
const results = await Promise.all(
jobs.map(async (job, index) => {
try {
const result = await job()
return { index, ok: true, ms: result.elapsedMs }
} catch (error) {
notes.push(`reconnect-refresh job ${index} failed: ${String(error).slice(0, 200)}`)
return { index, ok: false, ms: null, error: String(error) }
}
})
)
const wallMs = performance.now() - started
const maxJobMs = Math.max(0, ...results.map((result) => result.ms || 0))
notes.push(
`reconnect-refresh wall=${wallMs.toFixed(0)}ms maxJob=${maxJobMs.toFixed(0)}ms ok=${results.filter((result) => result.ok).length}/${results.length}`
)
return { wallMs, maxJobMs, results }
}
async function runRestartProxy(notes) {
const started = performance.now()
try {
const opened = await orcaJsonAsync(['open'], { local: true, timeoutMs: 120_000 })
notes.push(`orca open ms=${opened.elapsedMs.toFixed(0)}`)
} catch (error) {
notes.push(`orca open failed: ${String(error).slice(0, 200)}`)
}
const storm = await runReconnectRefreshStorm(notes)
return { wallMs: performance.now() - started, storm }
}
return { orcaJsonSync, orcaJsonAsync, runReconnectRefreshStorm, runRestartProxy }
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
appendOrcaRpcOutput,
resolveOrcaCliCommand,
resolveOrcaCliInvocation
} from './live-remote-freeze-rpc.mjs'
describe('live remote freeze RPC', () => {
it('resolves the Orca CLI for managed, dev, Linux, and default runtimes', () => {
expect(resolveOrcaCliCommand({ env: { ORCA_CLI_COMMAND: 'custom-orca' } })).toBe('custom-orca')
expect(resolveOrcaCliCommand({ env: { ORCA_DEV_REPO_ROOT: '/repo' } })).toBe('orca-dev')
expect(resolveOrcaCliCommand({ env: {}, platform: 'linux' })).toBe('orca-ide')
expect(resolveOrcaCliCommand({ env: {}, platform: 'win32' })).toBe('orca')
})
it('bypasses the Windows dev cmd shim with the built Node CLI', () => {
const invocation = resolveOrcaCliInvocation({
env: {
APPDATA: 'C:\\Users\\dev\\AppData\\Roaming',
ORCA_CLI_COMMAND: 'C:\\repo\\out\\bin\\orca-dev.cmd',
ORCA_DEV_REPO_ROOT: 'C:\\repo'
},
platform: 'win32',
nodeExecutable: 'C:\\Program Files\\nodejs\\node.exe'
})
expect(invocation).toMatchObject({
command: 'C:\\Program Files\\nodejs\\node.exe',
prefixArgs: ['C:\\repo\\out\\cli\\index.js'],
env: {
ORCA_USER_DATA_PATH: 'C:\\Users\\dev\\AppData\\Roaming\\orca-dev',
ORCA_DEV_CLI_INVOCATION: '1',
ORCA_APP_EXECUTABLE: 'C:\\repo\\node_modules\\electron\\dist\\electron.exe',
ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1'
}
})
})
it('caps combined asynchronous output before retaining the overflow chunk', () => {
const first = appendOrcaRpcOutput('', '1234', 0, 5)
expect(first).toEqual({ output: '1234', bytes: 4, exceeded: false })
const overflow = appendOrcaRpcOutput(first.output, '67', first.bytes, 5)
expect(overflow).toEqual({ output: '1234', bytes: 6, exceeded: true })
})
})
@@ -0,0 +1,645 @@
#!/usr/bin/env node
/**
* Naturalistic freeze repro — idle/reconnect recovery stories on large remotes.
*
* Unlike the bulk parallel-switch amplifier, this models:
* 1) agents streaming on remote while user is idle (backlog builds)
* 2) user returns and opens sessions one-by-one (or after reconnect refresh)
*
* Scenarios:
* idle-backlog-open — idle with flood, then human-paced sequential open
* idle-backlog-reconnect-open — same + wake-like metadata refresh storm, then open
* restart-proxy — idle, then orca open + status/list storm + open
* (does NOT kill the desktop; proxies restore work)
*
* Usage:
* ORCA_FREEZE_ENV=paired-remote ORCA_FREEZE_SCENARIO=idle-backlog-open \
* node config/scripts/live-remote-realistic-freeze-repro.mjs
*
* pnpm run repro:live-remote-realistic-freeze
*/
import { spawnSync } from 'node:child_process'
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { createOrcaRpc } from './live-remote-freeze-rpc.mjs'
import { startStatusWatchdog } from './live-remote-status-watchdog.mjs'
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
import {
DEFAULT_FOREVER_WINDOW_MS,
DEFAULT_HARD_MS,
DEFAULT_SOFT_MS,
DEFAULT_STATUS_SLOW_MS,
evaluateFullAppFreeze,
evaluatePermanentLockup,
evaluateRealisticFreezeSignals,
extractTerminalHandle,
humanPaceDelayMs,
readFreezeNumberEnv,
REALISTIC_SCENARIOS,
worktreeSelector
} from './live-remote-bulk-open-freeze-metrics.mjs'
const root = path.resolve(import.meta.dirname, '../..')
const reportDir = path.join(root, 'test-results', 'freeze-repro')
const envName = process.env.ORCA_FREEZE_ENV || 'paired-remote'
const scenario = process.env.ORCA_FREEZE_SCENARIO || 'idle-backlog-open'
const createCount = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_CREATE', 0))
const openCount = Math.max(2, readFreezeNumberEnv('ORCA_FREEZE_OPEN_COUNT', 20))
const idleMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_IDLE_MS', 45_000))
const paceMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PACE_MS', 250))
const paceJitterMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PACE_JITTER_MS', 150))
const createWorktreeSpan = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_CREATE_WT_SPAN', 12))
const softMs = readFreezeNumberEnv('ORCA_FREEZE_SOFT_MS', DEFAULT_SOFT_MS)
const hardMs = readFreezeNumberEnv('ORCA_FREEZE_HARD_MS', DEFAULT_HARD_MS)
/** Concurrent opens during lockup-storm (wake refresh overlaps fan-out). */
const stormParallel = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_STORM_PARALLEL', 16))
/** Kill a switch if it exceeds this — counts toward permanent lockup. */
const opTimeoutMs = Math.max(10_000, readFreezeNumberEnv('ORCA_FREEZE_OP_TIMEOUT_MS', 60_000))
const permanentTimeoutMs = Math.max(15_000, readFreezeNumberEnv('ORCA_FREEZE_PERMANENT_MS', 60_000))
const foreverWindowMs = Math.max(
10_000,
readFreezeNumberEnv('ORCA_FREEZE_FOREVER_WINDOW_MS', DEFAULT_FOREVER_WINDOW_MS)
)
const statusSlowMs = Math.max(
5_000,
readFreezeNumberEnv('ORCA_FREEZE_STATUS_SLOW_MS', DEFAULT_STATUS_SLOW_MS)
)
const watchdogIntervalMs = Math.max(
500,
readFreezeNumberEnv('ORCA_FREEZE_WATCHDOG_INTERVAL_MS', 1500)
)
const scratchDir = process.env.ORCA_FREEZE_SCRATCH || ''
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
const rpc = createOrcaRpc({ envName })
const { orcaJsonSync, orcaJsonAsync, runReconnectRefreshStorm, runRestartProxy } = rpc
async function mapPool(items, concurrency, worker) {
const results = Array.from({ length: items.length })
let next = 0
async function run() {
while (next < items.length) {
const index = next
next += 1
results[index] = await worker(items[index], index)
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, Math.max(items.length, 1)) }, () => run())
)
return results
}
function floodCommand(marker) {
const script =
"const m=process.argv[1];process.stdout.write('READY:'+m+'\n');let f=0;const c='A'.repeat(2048);setInterval(()=>{f++;process.stdout.write('BG:'+m+':'+f+':'+c+'\n')},8);process.stdin.resume()"
return `node -e ${JSON.stringify(script)} ${JSON.stringify(marker)}`
}
function sampleOrcaIfPossible() {
if (process.platform !== 'darwin') {
return null
}
try {
const status = orcaJsonSync(['status'], { local: true }).result
const pid = status?.app?.pid
if (!pid) {
return null
}
const out = path.join(reportDir, `orca-sample-realistic-${Date.now()}.txt`)
const sampled = spawnSync('sample', [String(pid), '5', '-file', out], {
timeout: 20_000,
stdio: 'ignore'
})
return sampled.status === 0 ? out : null
} catch {
return null
}
}
function listLiveTerminalHandles() {
const listed = orcaJsonSync(['terminal', 'list'])
const terms = listed.result?.terminals || []
return terms
.filter((t) => typeof t.handle === 'string' && t.handle.startsWith('term_'))
.map((t) => ({
handle: t.handle,
title: t.title,
worktreeId: t.worktreeId,
connected: t.connected
}))
}
async function main() {
if (!REALISTIC_SCENARIOS.includes(scenario)) {
throw new Error(
`Unknown ORCA_FREEZE_SCENARIO=${scenario}. Expected one of: ${REALISTIC_SCENARIOS.join(', ')}`
)
}
mkdirSync(reportDir, { recursive: true })
const notes = []
const phases = []
const openTimings = new BoundedLiveFreezeHistory(100)
console.log(
`[realistic-freeze] scenario=${scenario} env=${envName} create=${createCount} idleMs=${idleMs} openCount=${openCount} paceMs=${paceMs}`
)
const local = orcaJsonSync(['status'], { local: true })
const remote = orcaJsonSync(['status'])
notes.push(
`local version=${local.result?.runtime?.appVersion} pid=${local.result?.app?.pid}`,
`remote version=${remote.result?.runtime?.appVersion} state=${remote.result?.runtime?.state}`
)
const worktrees = orcaJsonSync(['worktree', 'list']).result
const wtList = worktrees?.worktrees || worktrees?.items || worktrees || []
if (!Array.isArray(wtList) || wtList.length === 0) {
throw new Error(`No worktrees on environment ${envName}`)
}
notes.push(`remote worktrees=${wtList.length}`)
phases.push({ phase: 'baseline', worktrees: wtList.length })
// --- Phase: seed flood terminals (agent-like backlog sources) ---
const created = []
if (createCount > 0) {
const targets = wtList.slice(0, Math.min(createWorktreeSpan, wtList.length))
await mapPool(
Array.from({ length: createCount }, (_, i) => i),
Math.min(4, createCount),
async (i) => {
const wt = targets[i % targets.length]
const selector = worktreeSelector(wt)
if (!selector) {
return
}
const marker = `REALISTIC_${Date.now()}_${i}`
try {
const createdTerm = await orcaJsonAsync(
[
'terminal',
'create',
'--worktree',
selector,
'--title',
`realistic-freeze-${i}`,
'--command',
floodCommand(marker)
],
{ timeoutMs: 180_000 }
)
const handle = extractTerminalHandle(createdTerm.result)
if (handle) {
created.push({ handle, marker, worktree: selector })
console.log(
`[realistic-freeze] flood terminal ${handle} (${createdTerm.elapsedMs.toFixed(0)}ms)`
)
} else {
notes.push(
`create ${i} missing handle: ${JSON.stringify(createdTerm.result).slice(0, 300)}`
)
}
} catch (error) {
notes.push(`create ${i} failed: ${String(error).slice(0, 250)}`)
console.warn(`[realistic-freeze] create failed: ${String(error)}`)
}
}
)
phases.push({ phase: 'seed-flood', created: created.length })
}
// Prefer created floods for open pass; fill with existing live terminals.
let live = []
try {
live = listLiveTerminalHandles()
notes.push(`live terminals listed=${live.length}`)
} catch (error) {
notes.push(`terminal list failed: ${String(error).slice(0, 200)}`)
}
const openTargets = [...created.map((c) => c.handle), ...live.map((t) => t.handle)].filter(
(v, i, a) => typeof v === 'string' && a.indexOf(v) === i
)
if (openTargets.length < 2) {
throw new Error(`Need ≥2 terminals; got ${openTargets.length}. ${notes.join('; ')}`)
}
const openList = openTargets.slice(0, Math.min(openCount, openTargets.length))
// --- Phase: park — leave one session focused, rest accumulate flood while "away" ---
try {
const parkHandle = openList[0]
const parked = await orcaJsonAsync(['terminal', 'switch', '--terminal', parkHandle], {
timeoutMs: 60_000
})
notes.push(`park switch ms=${parked.elapsedMs.toFixed(0)} handle=${parkHandle}`)
} catch (error) {
notes.push(`park switch failed: ${String(error).slice(0, 200)}`)
}
console.log(`[realistic-freeze] idle ${idleMs}ms while remotes stream (user away / asleep)`)
const idleStarted = performance.now()
await sleep(idleMs)
phases.push({ phase: 'idle', idleMs, actualMs: performance.now() - idleStarted })
// --- Phase: recovery trigger ---
let reconnectRefreshMs = 0
let timedOutOps = 0
let consecutiveSwitchFailures = 0
let maxConsecutiveSwitchFailures = 0
if (scenario === 'idle-backlog-reconnect-open' || scenario === 'lockup-storm') {
console.log(
'[realistic-freeze] wake/reconnect proxy: parallel status/worktree/terminal refresh'
)
const storm = await runReconnectRefreshStorm(notes)
reconnectRefreshMs = Math.max(storm.wallMs, storm.maxJobMs)
phases.push({
phase: 'reconnect-refresh',
wallMs: storm.wallMs,
maxJobMs: storm.maxJobMs
})
} else if (scenario === 'restart-proxy') {
console.log('[realistic-freeze] restart proxy: orca open + refresh storm (no process kill)')
const restart = await runRestartProxy(notes)
reconnectRefreshMs = Math.max(restart.wallMs, restart.storm.wallMs, restart.storm.maxJobMs)
phases.push({
phase: 'restart-proxy',
wallMs: restart.wallMs,
reconnectWallMs: restart.storm.wallMs
})
}
// --- Phase: open sessions ---
// lockup-storm: overlap a second reconnect storm with concurrent switch fan-out
// (models wake + bulk session restore, not human serial clicks).
let maxOpenMs = 0
let firstOpenMs = 0
let sumOpenMs = 0
let openOk = 0
let maxBatchWallMs = 0
const openStarted = performance.now()
let statusWatch = null
if (scenario === 'lockup-storm') {
console.log(
`[realistic-freeze] LOCKUP STORM: concurrent open parallel=${stormParallel} + overlapping reconnect refresh (timeout=${opTimeoutMs}ms); mid-storm status watchdog every ${watchdogIntervalMs}ms`
)
statusWatch = startStatusWatchdog({
intervalMs: watchdogIntervalMs,
timeoutMs: Math.min(permanentTimeoutMs, foreverWindowMs),
statusSlowMs
})
// Fire reconnect storm again concurrently with first open wave.
const overlapStormPromise = runReconnectRefreshStorm(notes)
for (let offset = 0; offset < openList.length; offset += stormParallel) {
const batch = openList.slice(offset, offset + stormParallel)
const batchStarted = performance.now()
const batchResults = await Promise.all(
batch.map(async (handle, batchIndex) => {
const index = offset + batchIndex
try {
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
timeoutMs: opTimeoutMs
})
return { handle, index, ms: sw.elapsedMs, ok: true, timedOut: false }
} catch (error) {
const msg = String(error)
const timedOut = /timed out/i.test(msg)
return { handle, index, error: msg, ok: false, timedOut }
}
})
)
const batchWall = performance.now() - batchStarted
maxBatchWallMs = Math.max(maxBatchWallMs, batchWall)
for (const item of batchResults) {
if (item.ok) {
openOk += 1
sumOpenMs += item.ms
maxOpenMs = Math.max(maxOpenMs, item.ms)
if (item.index === 0 || firstOpenMs === 0) {
firstOpenMs = item.ms
}
consecutiveSwitchFailures = 0
openTimings.add({
handle: item.handle,
ms: item.ms,
index: item.index,
batchWall
})
if (item.ms >= hardMs) {
console.warn(
`[realistic-freeze] HARD open #${item.index} ${item.handle}: ${item.ms.toFixed(0)}ms`
)
}
} else {
if (item.timedOut) {
timedOutOps += 1
}
consecutiveSwitchFailures += 1
maxConsecutiveSwitchFailures = Math.max(
maxConsecutiveSwitchFailures,
consecutiveSwitchFailures
)
openTimings.add({
handle: item.handle,
error: item.error,
index: item.index,
timedOut: item.timedOut
})
notes.push(
`open ${item.handle} failed${item.timedOut ? ' (TIMEOUT)' : ''}: ${String(item.error).slice(0, 160)}`
)
console.warn(
`[realistic-freeze] open FAIL #${item.index}${item.timedOut ? ' TIMEOUT' : ''}: ${item.handle}`
)
}
}
if (batchWall >= hardMs) {
console.warn(
`[realistic-freeze] HARD batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`
)
}
}
try {
const overlap = await overlapStormPromise
reconnectRefreshMs = Math.max(reconnectRefreshMs, overlap.wallMs, overlap.maxJobMs)
phases.push({
phase: 'overlap-reconnect-refresh',
wallMs: overlap.wallMs,
maxJobMs: overlap.maxJobMs
})
} catch (error) {
notes.push(`overlap reconnect failed: ${String(error).slice(0, 200)}`)
}
phases.push({
phase: 'lockup-storm-open',
count: openList.length,
ok: openOk,
maxOpenMs,
firstOpenMs,
maxBatchWallMs,
timedOutOps,
parallel: stormParallel
})
} else {
console.log(
`[realistic-freeze] human-paced open of ${openList.length} sessions (pace≈${paceMs}ms + jitter)`
)
for (let i = 0; i < openList.length; i += 1) {
const handle = openList[i]
try {
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
timeoutMs: opTimeoutMs
})
openOk += 1
sumOpenMs += sw.elapsedMs
maxOpenMs = Math.max(maxOpenMs, sw.elapsedMs)
if (i === 0) {
firstOpenMs = sw.elapsedMs
}
consecutiveSwitchFailures = 0
openTimings.add({ handle, ms: sw.elapsedMs, index: i })
if (sw.elapsedMs >= softMs) {
console.warn(`[realistic-freeze] SOFT open #${i} ${handle}: ${sw.elapsedMs.toFixed(0)}ms`)
}
if (sw.elapsedMs >= hardMs) {
console.warn(`[realistic-freeze] HARD open #${i} ${handle}: ${sw.elapsedMs.toFixed(0)}ms`)
}
} catch (error) {
const msg = String(error)
const timedOut = /timed out/i.test(msg)
if (timedOut) {
timedOutOps += 1
}
consecutiveSwitchFailures += 1
maxConsecutiveSwitchFailures = Math.max(
maxConsecutiveSwitchFailures,
consecutiveSwitchFailures
)
openTimings.add({ handle, error: msg, index: i, timedOut })
notes.push(`open ${handle} failed${timedOut ? ' (TIMEOUT)' : ''}: ${msg.slice(0, 200)}`)
}
if (i < openList.length - 1) {
await sleep(humanPaceDelayMs(paceMs, paceJitterMs))
}
}
phases.push({
phase: 'human-paced-open',
count: openList.length,
ok: openOk,
maxOpenMs,
firstOpenMs,
openWallMs: performance.now() - openStarted
})
}
const openWallMs = performance.now() - openStarted
let midStormWatch = {
samples: [],
durationMs: 0,
sampleCount: 0,
maxStatusMs: 0,
unhealthySampleCount: 0,
infrastructureErrorCount: 0,
longestUnhealthyWindowMs: 0
}
if (statusWatch) {
midStormWatch = await statusWatch.stop()
notes.push(
`mid-storm status samples=${midStormWatch.sampleCount} durationMs=${midStormWatch.durationMs.toFixed(0)}`
)
phases.push({
phase: 'mid-storm-status-watchdog',
samples: midStormWatch.sampleCount,
durationMs: midStormWatch.durationMs,
maxStatusMs: midStormWatch.maxStatusMs
})
}
// Post-storm health: does local status still answer?
let statusProbeMs = null
let statusHangMs = 0
const statusStarted = performance.now()
try {
const statusProbe = await orcaJsonAsync(['status'], {
local: true,
timeoutMs: permanentTimeoutMs
})
statusProbeMs = statusProbe.elapsedMs
} catch (error) {
statusHangMs = performance.now() - statusStarted
notes.push(
`status probe FAILED after ${statusHangMs.toFixed(0)}ms: ${String(error).slice(0, 200)}`
)
console.error(`[realistic-freeze] status probe failed — possible permanent lockup`)
}
let memoryProbeMs = null
try {
const mem = await orcaJsonAsync(['diagnostics', 'memory'], {
local: true,
timeoutMs: permanentTimeoutMs
})
memoryProbeMs = mem.elapsedMs
notes.push(`memory diagnostic ms=${mem.elapsedMs.toFixed(0)}`)
} catch (error) {
notes.push(`memory diagnostic failed: ${String(error).slice(0, 200)}`)
}
const peakForSignals = Math.max(maxOpenMs, firstOpenMs, maxBatchWallMs)
const signals = evaluateRealisticFreezeSignals({
maxOpenMs: peakForSignals,
firstOpenMs,
reconnectRefreshMs,
statusProbeMs: statusProbeMs ?? 0,
memoryProbeMs,
softMs,
hardMs
})
const lockup = evaluatePermanentLockup({
timedOutOps,
statusHangMs,
consecutiveSwitchFailures: maxConsecutiveSwitchFailures,
openFailed: openList.length - openOk,
openTotal: openList.length,
permanentTimeoutMs
})
const fullApp = evaluateFullAppFreeze({
statusSamples: midStormWatch.samples,
statusSummary: midStormWatch,
foreverWindowMs,
statusSlowMs
})
const watchdogInfrastructureErrorCount = midStormWatch.infrastructureErrorCount
if (statusHangMs >= foreverWindowMs) {
fullApp.foreverUiLockupObserved = true
fullApp.longestUnhealthyWindowMs = Math.max(fullApp.longestUnhealthyWindowMs, statusHangMs)
fullApp.reason = `post-storm status hang ${statusHangMs.toFixed(0)}ms`
}
const recoveredHardStall = signals.hardFreeze && !fullApp.foreverUiLockupObserved && openOk > 0
let samplePath = null
if (signals.softFreeze || signals.hardFreeze || fullApp.foreverUiLockupObserved) {
samplePath = sampleOrcaIfPossible()
if (samplePath) {
notes.push(`sample=${samplePath}`)
} else {
notes.push('sample unavailable')
}
}
const storyByScenario = {
'idle-backlog-open': 'User away while remotes stream; returns and opens sessions one-by-one.',
'idle-backlog-reconnect-open':
'User away; wake-like reconnect metadata storm; then opens sessions.',
'restart-proxy': 'User away; restart-proxy discovery; then opens sessions.',
'lockup-storm':
'Idle flood + reconnect refresh + concurrent open + mid-storm status watchdog (full-app freeze bar).'
}
const report = {
topology: 'live-paired-remote-realistic',
scenario,
story: storyByScenario[scenario] || scenario,
environment: envName,
localVersion: local.result?.runtime?.appVersion,
remoteVersion: remote.result?.runtime?.appVersion,
remoteWorktreeCount: wtList.length,
createdFloodTerminals: created.length,
openTargets: openList.length,
idleMs,
paceMs,
paceJitterMs,
stormParallel: scenario === 'lockup-storm' ? stormParallel : 1,
firstOpenMs,
maxOpenMs,
maxBatchWallMs,
avgOpenMs: openOk ? sumOpenMs / openOk : 0,
openWallMs,
openOk,
openFailed: openList.length - openOk,
reconnectRefreshMs,
peakLatencyMs: Math.max(signals.peakLatencyMs, maxBatchWallMs),
statusProbeMs,
statusHangMs,
memoryProbeMs,
softFreeze: signals.softFreeze,
hardFreeze: signals.hardFreeze,
recoveredHardStall,
permanentLockup: lockup.permanentLockup,
foreverUiLockupObserved: fullApp.foreverUiLockupObserved,
foreverFreeze: fullApp,
midStormStatusSamples: midStormWatch.samples,
midStormStatusSampleCount: midStormWatch.sampleCount,
watchdogInfrastructureErrorCount,
timedOutOps,
maxConsecutiveSwitchFailures,
softMs,
hardMs,
foreverWindowMs,
statusSlowMs,
permanentTimeoutMs,
opTimeoutMs,
phases,
notes,
openTimingCount: openTimings.totalCount,
openTimings: openTimings.values()
}
const outPath = path.join(reportDir, `live-realistic-freeze-${envName}-${scenario}.json`)
const stamped = path.join(
reportDir,
`live-realistic-freeze-${envName}-${scenario}-peak-${Date.now()}.json`
)
writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`)
writeFileSync(stamped, `${JSON.stringify(report, null, 2)}\n`)
console.log(`[realistic-freeze] report ${outPath}`)
console.log(JSON.stringify(report, null, 2))
if (scratchDir) {
try {
mkdirSync(scratchDir, { recursive: true })
copyFileSync(outPath, path.join(scratchDir, 'live-realistic-freeze-report.json'))
} catch (error) {
console.warn(`[realistic-freeze] scratch copy failed: ${String(error)}`)
}
}
if (watchdogInfrastructureErrorCount > 0) {
process.exitCode = 3
console.error('[realistic-freeze] WATCHDOG INFRASTRUCTURE FAILURE')
} else if (fullApp.foreverUiLockupObserved) {
process.exitCode = 5
console.error('[realistic-freeze] FULL-APP FOREVER FREEZE (status unhealthy ≥ forever window)')
} else if (lockup.permanentLockup) {
process.exitCode = 4
console.error(
'[realistic-freeze] PERMANENT LOCKUP HEURISTIC (timeouts/fail-rate) — check foreverUiLockupObserved'
)
} else if (signals.hardFreeze) {
process.exitCode = 2
console.error(
'[realistic-freeze] HARD FREEZE SIGNAL (recovered multi-second stall — not forever lockup)'
)
} else if (signals.softFreeze) {
process.exitCode = 1
console.error('[realistic-freeze] SOFT FREEZE SIGNAL')
} else {
console.log('[realistic-freeze] no freeze signal under thresholds')
}
}
main().catch((error) => {
console.error('[realistic-freeze] failed', error)
process.exit(3)
})
@@ -0,0 +1,147 @@
/**
* Mid-storm host health samples for forever-freeze detection.
* Polls `orca status --json` on an interval while a load storm runs.
*/
import { spawn } from 'node:child_process'
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
import { resolveOrcaCliInvocation } from './live-remote-freeze-rpc.mjs'
/**
* @param {{ intervalMs?: number, timeoutMs?: number, cliCommand?: string, sampleHistoryLimit?: number, statusSlowMs?: number }} opts
*/
export function startStatusWatchdog(opts = {}) {
const intervalMs = opts.intervalMs ?? 2000
const timeoutMs = opts.timeoutMs ?? 30_000
const cliInvocation = opts.cliCommand
? { command: opts.cliCommand, prefixArgs: [] }
: resolveOrcaCliInvocation()
const samples = new BoundedLiveFreezeHistory(opts.sampleHistoryLimit ?? 240)
const statusSlowMs = opts.statusSlowMs ?? 15_000
let stopped = false
let inFlight = false
let infrastructureErrorCount = 0
let longestUnhealthyWindowMs = 0
let maxStatusMs = 0
let runStartMs = null
let unhealthySampleCount = 0
const startedAt = performance.now()
const record = (sample) => {
samples.add(sample)
maxStatusMs = Math.max(maxStatusMs, sample.ms || 0)
if (sample.infrastructureError) {
infrastructureErrorCount += 1
}
const unhealthy =
!sample.infrastructureError &&
(Boolean(sample.hang) || sample.ok === false || (sample.ms || 0) >= statusSlowMs)
if (!unhealthy) {
runStartMs = null
return
}
unhealthySampleCount += 1
runStartMs ??= sample.tMs ?? 0
longestUnhealthyWindowMs = Math.max(
longestUnhealthyWindowMs,
(sample.tMs ?? 0) + (sample.ms || 0) - runStartMs
)
}
const summary = () => ({
sampleCount: samples.totalCount,
maxStatusMs,
unhealthySampleCount,
infrastructureErrorCount,
longestUnhealthyWindowMs
})
const probe = () =>
new Promise((resolve) => {
const t0 = performance.now()
const child = spawn(
cliInvocation.command,
[...cliInvocation.prefixArgs, 'status', '--json'],
{
env: cliInvocation.env,
stdio: ['ignore', 'pipe', 'pipe']
}
)
let settled = false
const finish = (result) => {
if (settled) {
return
}
settled = true
resolve(result)
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
finish({
tMs: t0 - startedAt,
ms: performance.now() - t0,
ok: false,
hang: true
})
}, timeoutMs)
child.stdout.on('data', () => {})
child.stderr.on('data', () => {})
child.on('error', (error) => {
clearTimeout(timer)
finish({
tMs: t0 - startedAt,
ms: performance.now() - t0,
ok: false,
hang: false,
infrastructureError: true,
error: String(error)
})
})
child.on('close', (code) => {
clearTimeout(timer)
finish({
tMs: t0 - startedAt,
ms: performance.now() - t0,
ok: code === 0,
hang: false
})
})
})
const tick = async ({ force = false } = {}) => {
if ((!force && stopped) || inFlight) {
return
}
inFlight = true
try {
const sample = await probe()
record(sample)
} finally {
inFlight = false
}
}
const interval = setInterval(() => {
void tick()
}, intervalMs)
void tick()
return {
stop: async () => {
stopped = true
clearInterval(interval)
// Wait for in-flight probe, then force one final sample.
const deadline = performance.now() + timeoutMs + 1000
while (inFlight && performance.now() < deadline) {
await new Promise((r) => setTimeout(r, 20))
}
await tick({ force: true })
return {
samples: samples.values(),
...summary(),
durationMs: performance.now() - startedAt
}
},
getSamples: () => samples.values(),
getSummary: summary
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { startStatusWatchdog } from './live-remote-status-watchdog.mjs'
describe('startStatusWatchdog', () => {
it('collects status samples and stops cleanly', async () => {
// Real path: actually invokes `orca status --json` (must be available in CI/dev with Orca or fail soft).
const watch = startStatusWatchdog({ intervalMs: 50, timeoutMs: 5_000 })
await new Promise((r) => setTimeout(r, 180))
const result = await watch.stop()
expect(result.samples.length).toBeGreaterThanOrEqual(1)
expect(result.durationMs).toBeGreaterThan(0)
for (const s of result.samples) {
expect(typeof s.ms).toBe('number')
expect(typeof s.ok).toBe('boolean')
expect(typeof s.hang).toBe('boolean')
}
})
it('marks CLI spawn failures as infrastructure errors', async () => {
const watch = startStatusWatchdog({
intervalMs: 50,
timeoutMs: 1000,
cliCommand: 'orca-freeze-watchdog-missing-command'
})
const result = await watch.stop()
expect(result.samples.length).toBeGreaterThanOrEqual(1)
expect(result.samples.every((sample) => sample.infrastructureError === true)).toBe(true)
expect(result.samples.every((sample) => sample.hang === false)).toBe(true)
})
it('bounds retained samples while preserving full-run counters', async () => {
const watch = startStatusWatchdog({
intervalMs: 50,
timeoutMs: 1000,
cliCommand: 'orca-freeze-watchdog-missing-command',
sampleHistoryLimit: 1
})
const result = await watch.stop()
expect(result.samples).toHaveLength(1)
expect(result.sampleCount).toBeGreaterThan(result.samples.length)
expect(result.infrastructureErrorCount).toBe(result.sampleCount)
expect(result.maxStatusMs).toBeGreaterThanOrEqual(0)
})
})
@@ -134,5 +134,30 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
'auto.hooks.useSettingsNavigationMetadata.40d80bad8a': {
zh: '测试版',
ja: 'ベータ'
},
// Issue/PR state picker; it sits beside 已关闭, so 进行中 is a different state.
'auto.components.PullRequestPage.7b8f6bf6d8': {
zh: '开放'
},
// This "Open" is the button that opens the config file, not an issue state.
'auto.components.settings.McpConfigFileRow.e720c139cd': {
zh: '打开'
},
// Terminal cursor-color group: the on-screen cursor, not the Cursor editor.
'auto.components.settings.TerminalWindowSection.c9e1fdf42f': {
ko: '커서',
zh: '光标'
},
'auto.components.onboarding.ThemeStep.ab2a583a97': {
ko: '커서',
zh: '光标'
},
// Tailwind swatch labels: bare 天空 (the sky), 锌 and 空 (the metal, the sky) are not colors.
'auto.components.sidebar.workspace.status.6437a8c253': {
zh: '天蓝',
ja: '空色'
},
'auto.components.sidebar.workspace.status.caabd5ca85': {
zh: '锌灰'
}
}
+102
View File
@@ -0,0 +1,102 @@
// Ordinary UI vocabulary that reads like a brand but is not one. Every locale should translate
// these, so the policy must neither pin the whole value to English nor revert the translation
// inside a sentence. The product-name homonyms (Continue.dev, the agent entries) stay Latin
// through ENGLISH_ONLY_KEY_PREFIXES, which matches on the catalog key rather than the word.
// Renderings a locale is expected to use. They are translations, never machine-translation
// errors, so a brand revert must leave them alone. Nonsense forms that share the same English
// term (zh 回购 "repurchase agreement", ja 端子 "electrical connector", es "Comprometerse")
// stay in BRAND_MISTRANSLATIONS and keep getting reverted.
const GENERIC_TERM_FAMILIES = [
{
terms: ['Agent', 'Agents', 'agent', 'agents'],
renderings: {
ko: ['에이전트'],
ja: ['エージェント'],
zh: ['代理', '智能体'],
es: ['Agente', 'agente', 'Agentes', 'agentes']
}
},
{
terms: ['Commit', 'Commits', 'commit', 'commits'],
renderings: {
ko: ['커밋'],
ja: ['コミット'],
zh: ['提交'],
es: [
'Confirmación',
'confirmación',
'Confirmaciones',
'confirmaciones',
'Confirmar',
'confirmar'
]
}
},
{
terms: ['Continue'],
renderings: {
ko: ['계속하다', '계속'],
ja: ['続ける', '続行'],
zh: ['继续'],
es: ['Continuar', 'continuar']
}
},
{
terms: ['Repo', 'Repos', 'repo', 'repos'],
renderings: {
ko: ['저장소', '레포'],
ja: ['リポジトリ', 'リポ'],
zh: ['存储库', '仓库'],
es: ['Repositorio', 'repositorio', 'Repositorios', 'repositorios']
}
},
{
terms: ['Terminal', 'Terminals', 'terminal', 'terminals'],
renderings: {
ko: ['터미널'],
ja: ['ターミナル'],
zh: ['终端'],
es: ['Terminales', 'terminales']
}
}
]
export const LOCALIZABLE_GENERIC_TERMS = new Set(
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms)
)
const RENDERINGS_BY_TERM = new Map(
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms.map((term) => [term, family.renderings]))
)
export function isLocalizableGenericTerm(term) {
return LOCALIZABLE_GENERIC_TERMS.has(term)
}
export function isCanonicalGenericRendering(term, locale, form) {
return RENDERINGS_BY_TERM.get(term)?.[locale]?.includes(form) ?? false
}
export function canonicalGenericRenderings(locale) {
return GENERIC_TERM_FAMILIES.flatMap((family) =>
(family.renderings[locale] ?? []).map((form) => ({ form, terms: family.terms }))
)
}
function spansOf(value, needle) {
const spans = []
for (let at = value.indexOf(needle); at !== -1; at = value.indexOf(needle, at + 1)) {
spans.push([at, at + needle.length])
}
return spans
}
// Why: zh 终端子进程 ("terminal sub-process") contains 端子, so a blind revert of the
// electrical-connector mistranslation would cut a valid word in half.
export function overlapsCanonicalRendering(term, locale, value, start, end) {
const renderings = RENDERINGS_BY_TERM.get(term)?.[locale] ?? []
return renderings.some((form) =>
spansOf(value, form).some(([from, to]) => start < to && from < end)
)
}
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import { LOCALIZABLE_GENERIC_TERMS } from './locale-generic-ui-terms.mjs'
import { NEVER_TRANSLATE_VALUES, repairTranslatedValue } from './locale-translation-policy.mjs'
// Why: #12113 — repair-locale-catalog rewrote ~2000 translated values back to English because
// generic UI words (agent, terminal, commit, repo, Continue) were treated as brands. These pin
// the boundary: generic words stay translated, real brand names stay Latin.
describe('locale generic UI terms', () => {
it('keeps a translated generic term when the whole value is that term', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.AppearancePane.terminalTitle',
enValue: 'Terminal',
localeValue: '터미널',
locale: 'ko'
})
).toBe('터미널')
expect(
repairTranslatedValue({
key: 'auto.components.settings.QuickCommandsPane.4ccc63da87',
enValue: 'Agent',
localeValue: '에이전트',
locale: 'ko'
})
).toBe('에이전트')
expect(
repairTranslatedValue({
key: 'auto.components.mobile.MobileHero.a8fb43cf1c',
enValue: 'Continue',
localeValue: '继续',
locale: 'zh'
})
).toBe('继续')
})
it('keeps a translated generic term inside a sentence', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.EditorFontSection.matchTerminal',
enValue: 'Match terminal font',
localeValue: '터미널 글꼴과 동일',
locale: 'ko'
})
).toBe('터미널 글꼴과 동일')
expect(
repairTranslatedValue({
key: 'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel',
enValue: 'Toggle Agent Dashboard',
localeValue: '에이전트 대시보드 전환',
locale: 'ko'
})
).toBe('에이전트 대시보드 전환')
expect(
repairTranslatedValue({
key: 'components.agentSessionContinuation.dialogTitle',
enValue: 'Continue in New Session',
localeValue: '新しいセッションで続ける',
locale: 'ja'
})
// 新しい → 新規 is an unrelated phrase fix; 続ける is what must survive.
).toBe('新規セッションで続ける')
})
it('still reverts real brand names that machine translation localized', () => {
expect(
repairTranslatedValue({
key: 'auto.stats.StatsPane.7d26110cea',
enValue: 'Codex',
localeValue: '사본',
locale: 'ko'
})
).toBe('Codex')
expect(
repairTranslatedValue({
key: 'auto.components.settings.appearance.search.9ae151b26b',
enValue: 'linear',
localeValue: '线性',
locale: 'zh'
})
).toBe('Linear')
expect(
repairTranslatedValue({
key: 'auto.components.settings.NetworkPane.tailscale',
enValue: 'Connect over Tailscale',
localeValue: '通过尾鳞连接',
locale: 'zh'
})
).toBe('通过 Tailscale 连接')
})
it('still reverts nonsense renderings of a generic term', () => {
// 端子 is an electrical connector, 回购 a repurchase agreement, Comprometerse "to pledge".
expect(
repairTranslatedValue({
key: 'auto.components.agent.AgentCombobox.986f946354',
enValue: 'Blank Terminal',
localeValue: '空白端子',
locale: 'zh'
})
).toBe('空白 Terminal')
expect(
repairTranslatedValue({
key: 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
enValue: 'Commit',
localeValue: 'Comprometerse',
locale: 'es'
})
).toBe('Commit')
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
enValue: 'Markdown',
localeValue: '가격 인하',
locale: 'ko'
})
).toBe('Markdown')
})
it('does not cut a valid word that contains a nonsense rendering', () => {
// 终端子进程 is "terminal sub-process" — 端子 sits inside 终端 + 子.
expect(
repairTranslatedValue({
key: 'auto.components.settings.AdvancedNetworkSettingsSection.823e0f15b1',
enValue: 'Proxy URL for Orca network requests and local terminal subprocesses.',
localeValue: '用于 Orca 网络请求和本地终端子进程的代理 URL。',
locale: 'zh'
})
).toBe('用于 Orca 网络请求和本地终端子进程的代理 URL。')
})
it('still fills in a translation when the catalog holds English', () => {
expect(
repairTranslatedValue({
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
enValue: 'Terminal 1',
localeValue: 'Terminal 1',
locale: 'ko'
})
).toBe('터미널 1')
expect(
repairTranslatedValue({
key: 'auto.store.slices.worktrees.889487d8bb',
enValue: 'Dismiss',
localeValue: '해고하다',
locale: 'ko'
})
).toBe('닫기')
})
it('keeps agent catalog product names in English by key, not by word', () => {
expect(
repairTranslatedValue({
key: 'auto.lib.agent.catalog.9e2a9bb87b',
enValue: 'Continue',
localeValue: '继续',
locale: 'zh'
})
).toBe('Continue')
expect(
repairTranslatedValue({
key: 'auto.lib.agent.catalog.760bc6883d',
enValue: 'Codex',
localeValue: '사본',
locale: 'ko'
})
).toBe('Codex')
})
it('does not list a generic term as never-translatable', () => {
const pinned = [...LOCALIZABLE_GENERIC_TERMS].filter((term) => NEVER_TRANSLATE_VALUES.has(term))
expect(pinned).toEqual([])
})
})
+2 -2
View File
@@ -94,7 +94,7 @@ export const JA_VALUE_OVERRIDES = {
'Launch plan': '起動プラン',
'Launch agent': 'エージェントを起動',
'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動',
Play: 'Play',
Play: '再生',
Action: '操作',
Actions: '操作',
action: '操作',
@@ -135,7 +135,7 @@ export const JA_VALUE_OVERRIDES = {
'Pick a base branch below': '以下のベースブランチを選択',
'Choose floating workspace directory': 'フローティング ワークスペース ディレクトリを選択',
'Local project, Git repo, or folder with many repos':
'ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー',
'ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
'Enter passphrase': 'パスフレーズを入力',
'Enter password': 'パスワードを入力',
'Enter the passphrase for': 'のパスフレーズを入力',
+6 -5
View File
@@ -162,8 +162,8 @@ const BASE_LOCALE_KEY_OVERRIDES = {
ja: 'クローズ'
},
'auto.components.GitHubItemDialog.dc1ca081a8': {
ko: '진행 중',
zh: '进行中',
ko: '열림',
zh: '开放',
ja: 'オープン'
},
'auto.components.tab.bar.TabBarCreateEntry.b27864279e': {
@@ -176,10 +176,11 @@ const BASE_LOCALE_KEY_OVERRIDES = {
zh: '打开 Linear 任务',
ja: 'Linear タスクを開く'
},
// Onboarding pill beside Orca Mobile: "New" marks a new feature, not a create action.
'auto.components.sidebar.SidebarNav.c86d83b5c3': {
ko: '새로 만들기',
zh: '新建',
ja: '新規'
ko: '신규',
zh: '新功能',
ja: '新機能'
},
'auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b': {
ko: 'Discord',
+45 -3
View File
@@ -752,6 +752,21 @@
"auto.components.contextual.tours.ContextualTourProgressDots.dcd6e6b03e": {
"ko": "{{value1}}단계 중 {{value0}}단계"
},
"auto.components.contextual.tours.ContextualTourOverlaySurface.complete": {
"ko": "완료"
},
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body": {
"ko": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요."
},
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title": {
"ko": "자동화란 무엇인가요?"
},
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body": {
"ko": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다."
},
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title": {
"ko": "결과 확인"
},
"auto.components.crash.report.CrashReportDialog.88fea8e84e": {
"ko": "보내지 않음"
},
@@ -1868,6 +1883,9 @@
"auto.components.right.sidebar.SourceControl.3a231c845b": {
"ko": "알 수 없음"
},
"auto.components.right.sidebar.SourceControl.6d7f2a47e5": {
"ko": "폴더의 변경 사항 취소"
},
"auto.components.right.sidebar.SourceControl.812cb992ee": {
"ko": "{{value0}}에서 열기"
},
@@ -1880,6 +1898,9 @@
"auto.components.right.sidebar.SourceControl.9bb062a886": {
"ko": "commit 되지 않음"
},
"auto.components.right.sidebar.SourceControl.a5e5a11090": {
"ko": "모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다."
},
"auto.components.right.sidebar.SourceControl.aaf1451654": {
"ko": "초안 {{value0}} 만들기"
},
@@ -1994,6 +2015,9 @@
"auto.components.right.sidebar.source.control.discard.confirmation.2ae5a785b3": {
"ko": "스테이징되지 않은 변경 사항을 모두 삭제하시겠습니까?"
},
"auto.components.right.sidebar.source.control.discard.confirmation.40e9357b2a": {
"ko": "이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다."
},
"auto.components.right.sidebar.source.control.discard.confirmation.5ddd8cac7f": {
"ko": "스테이징된 변경 사항을 모두 삭제하시겠습니까?"
},
@@ -2015,6 +2039,9 @@
"auto.components.right.sidebar.source.control.primary.action.1d47e850cf": {
"ko": "연결된 리뷰 브랜치에 업데이트 푸시"
},
"auto.components.right.sidebar.source.control.primary.action.5a477d80cb": {
"ko": "모든 변경 사항 스테이징"
},
"auto.components.right.sidebar.source.control.primary.action.a5d1f7b036": {
"ko": "{{value0}}을(를) 만들기 전에 commits을 게시하세요."
},
@@ -2162,6 +2189,9 @@
"auto.components.settings.AutoRenameBranchFromWorkSetting.a4fa380b67": {
"ko": "{assistantMessage}"
},
"auto.components.settings.AutoRenameBranchFromWorkSetting.d9b65054ef": {
"ko": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다."
},
"auto.components.settings.AutoRenameBranchPromptEditor.ebb942a2ec": {
"ko": "fix-login-flow"
},
@@ -2228,6 +2258,9 @@
"auto.components.settings.DefaultWindowsProjectRuntimeSetting.wslUnavailable": {
"ko": "WSL을 사용할 수 없습니다. WSL을 상속하는 프로젝트는 복구가 필요합니다."
},
"auto.components.settings.DevToolsPane.orcaCloudDescription": {
"ko": "자사 클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다."
},
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
"ko": "마이크"
},
@@ -2255,6 +2288,12 @@
"auto.components.settings.ExperimentalPane.newWorktreeCardStyle.description": {
"ko": "업데이트된 워크트리 카드 레이아웃, 메타데이터 배치, 카드 표시 메뉴 옵션 및 상태 표시를 미리 봅니다."
},
"auto.components.settings.EphemeralVmsPane.recipes": {
"ko": "레시피"
},
"auto.components.settings.EphemeralVmsPane.whatTitle": {
"ko": "이 스킬로 함께 하는 작업"
},
"auto.components.settings.GeneralEditorSettingsSection.45c6e85c4d": {
"ko": "편집기"
},
@@ -2727,7 +2766,7 @@
"ko": "이 SSH 대상의 원격 릴레이를 강제로 중지합니다. 이 대상의 활성 원격 terminals과 포트 포워딩이 종료됩니다."
},
"auto.components.settings.SshTargetForm.137e88ce8d": {
"ko": "연결 해제 후 릴레이가 terminals을 활성 상태로 유지하는 기간입니다. 기본값: 10800(3시간). 최대:"
"ko": "Orca가 이 호스트에서 연결 해제된 뒤에도 원격 터미널은 계속 실행됩니다."
},
"auto.components.settings.SshTargetForm.2ee9bcd2e8": {
"ko": "server, deploy@server:2222, ssh://server"
@@ -3710,6 +3749,9 @@
"auto.components.settings.ssh.search.f9493b80c0": {
"ko": "서버"
},
"auto.components.settings.source.control.action.recipe.options.commitMessage": {
"ko": "스테이징된 변경 사항에서 commit 메시지를 생성합니다."
},
"auto.components.settings.task.tracker.integration.cards.1a12e33fe5": {
"ko": "Linear 액세스 추가"
},
@@ -4683,7 +4725,7 @@
"ko": "중지 중"
},
"auto.components.status.bar.WorkspaceSpaceCompactPanel.8ff597593d": {
"ko": "Space"
"ko": "저장 공간"
},
"auto.components.status.bar.WorkspaceSpaceCompactPanel.9be86c46a0": {
"ko": "확보 가능"
@@ -4953,7 +4995,7 @@
"ko": "선택한 리포지토리와 일치하는 비활성 워크스페이스가 없습니다."
},
"auto.components.workspace.space.WorkspaceSpacePage.45f6302dbc": {
"ko": "Space"
"ko": "저장 공간"
},
"auto.components.workspace.space.WorkspaceSpacePage.8d0048e1cb": {
"ko": "워크스페이스 디스크 사용량과 회수 가능한 워크트리 저장 공간."
+1 -1
View File
@@ -209,7 +209,7 @@ export const KO_VALUE_OVERRIDES = {
'Install the Orca skill so agents know to use the Orca CLI.':
'에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.',
'Local project, Git repo, or folder with many repos':
'로컬 프로젝트, Git repo 또는 repos가 많은 폴더',
'로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings > Integrations.':
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 연동에 있습니다.',
'changed since you last approved. Re-review before it runs':
+4 -3
View File
@@ -371,9 +371,10 @@ export const LOCALE_PHRASE_FIXES = {
{ pattern: /指挥进展/g, replacement: 'Conductor 进度', whenEnIncludes: 'Conductor Progress' },
{ pattern: /指挥评论/g, replacement: 'Conductor 评审', whenEnIncludes: 'Conductor Review' },
{ pattern: /指挥完成/g, replacement: 'Conductor 完成', whenEnIncludes: 'Conductor Done' },
{ pattern: /琥珀色/g, replacement: 'Amber', whenEnIncludes: 'Amber' },
{ pattern: /蓝色的/g, replacement: 'Blue', whenEnIncludes: 'Blue' },
{ pattern: /中性的/g, replacement: 'Neutral', whenEnIncludes: 'Neutral' },
// Swatch labels want the bare color noun, not the adjectival 的 form — and not English.
{ pattern: /琥珀色/g, replacement: '琥珀', whenEnIncludes: 'Amber' },
{ pattern: /蓝色的/g, replacement: '蓝色', whenEnIncludes: 'Blue' },
{ pattern: /中性的/g, replacement: '中性', whenEnIncludes: 'Neutral' },
{ pattern: /破坏性的/g, replacement: 'destructive', whenEnIncludes: 'destructive' },
{ pattern: /注解/g, replacement: '批注', whenEnIncludes: 'Annotation' },
...ZH_PHRASE_FIXES_ROUND5
@@ -1,19 +0,0 @@
const LOCALIZED_PROSE_TERM_KEYS = new Set([
'auto.hooks.useMacosTccPromptNotice.description',
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
])
const LOCALIZABLE_PROSE_TERMS = new Set([
'Agent',
'Agents',
'agent',
'agents',
'Terminal',
'Terminals',
'terminal',
'terminals'
])
export function isLocalizedProseTermContext(term, key) {
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { repairCatalog } from './locale-translation-policy.mjs'
// Regression: en.json routinely carries keys a locale catalog has not been bootstrapped with yet
// (~190 per locale at the time of writing), which crashed the whole repair run before it did any work.
describe('repairCatalog with un-bootstrapped keys', () => {
const enCatalog = {
auto: {
lib: { agent: { catalog: { '760bc6883d': 'Codex' } } },
components: { untranslated: 'Continue', nested: { alsoMissing: 'Refresh' } }
}
}
const translatedOnly = () => ({ auto: { lib: { agent: { catalog: { '760bc6883d': '사본' } } } } })
it('skips leaves the locale catalog is missing instead of throwing', () => {
for (const locale of ['ko', 'ja', 'zh', 'es']) {
const localeCatalog = translatedOnly()
expect(() => repairCatalog(enCatalog, localeCatalog, locale), locale).not.toThrow()
expect(localeCatalog.auto.components, locale).toBeUndefined()
}
})
it('still repairs the leaves that are present', () => {
const localeCatalog = translatedOnly()
expect(repairCatalog(enCatalog, localeCatalog, 'ko')).toBe(1)
expect(localeCatalog.auto.lib.agent.catalog['760bc6883d']).toBe('Codex')
})
})
@@ -45,7 +45,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '에이전트가 Orca CLI 사용 방법을 알 수 있도록 Orca 기술을 설치합니다.',
locale: 'ko'
})
).toBe('agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
).toBe('에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
expect(
repairTranslatedValue({
key: 'auto.components.editor.MarkdownPreview.322afab6ff',
@@ -92,7 +92,8 @@ describe('locale-translation-policy ko round 5', () => {
).toBe('파이프라인')
})
it('keeps protected workflow terms in English', () => {
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Korean.
it('keeps brand names English and generic workflow terms translated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
@@ -100,7 +101,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '터미널 1',
locale: 'ko'
})
).toBe('Terminal 1')
).toBe('터미널 1')
expect(
repairTranslatedValue({
key: 'auto.components.skills.SkillsPage.38e0951c3a',
@@ -108,7 +109,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '에이전트 스킬',
locale: 'ko'
})
).toBe('Agent 스킬')
).toBe('에이전트 스킬')
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
@@ -124,7 +125,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '푸시되지 않은 커밋',
locale: 'ko'
})
).toBe('푸시되지 않은 commits')
).toBe('푸시되지 않은 커밋')
expect(
repairTranslatedValue({
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
@@ -132,7 +133,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '레포',
locale: 'ko'
})
).toBe('Repo')
).toBe('레포')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
@@ -140,7 +141,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
locale: 'ko'
})
).toBe('로컬 프로젝트, Git repo 또는 repos가 많은 폴더')
).toBe('로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더')
})
it('re-glues Korean particles after Latin terms without gluing content words', () => {
@@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
describe('locale-translation-policy es round 5', () => {
it('keeps protected workflow terms in English', () => {
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Spanish.
it('keeps brand names English and generic workflow terms translated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
@@ -43,7 +44,7 @@ describe('locale-translation-policy es round 5', () => {
localeValue: 'mensaje de confirmación',
locale: 'es'
})
).toBe('mensaje de Commit')
).toBe('mensaje de confirmación')
expect(
repairTranslatedValue({
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
@@ -51,7 +52,7 @@ describe('locale-translation-policy es round 5', () => {
localeValue: 'repositorio',
locale: 'es'
})
).toBe('Repo')
).toBe('repositorio')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
@@ -59,6 +60,6 @@ describe('locale-translation-policy es round 5', () => {
localeValue: 'Proyecto local, repositorio de Git o carpeta con muchos repositorios',
locale: 'es'
})
).toBe('Proyecto local, repo de Git o carpeta con muchos repos')
).toBe('Proyecto local, repositorio de Git o carpeta con muchos repositorios')
})
})
@@ -27,7 +27,7 @@ describe('locale-translation-policy ja round 5', () => {
localeValue: '遊ぶ',
locale: 'ja'
})
).toBe('Play')
).toBe('再生')
expect(
repairTranslatedValue({
key: 'auto.components.TaskPage.8396825a14',
@@ -102,7 +102,8 @@ describe('locale-translation-policy ja round 5', () => {
).toBe('まずプロジェクトを追加')
})
it('keeps protected workflow terms in English', () => {
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Japanese.
it('keeps brand names English and generic workflow terms translated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3',
@@ -118,7 +119,7 @@ describe('locale-translation-policy ja round 5', () => {
localeValue: 'エージェントのスキル',
locale: 'ja'
})
).toBe('Agent のスキル')
).toBe('エージェントのスキル')
expect(
repairTranslatedValue({
key: 'auto.components.tab.bar.TabBar.3d5d6c960d',
@@ -134,7 +135,7 @@ describe('locale-translation-policy ja round 5', () => {
localeValue: 'コミット',
locale: 'ja'
})
).toBe('commits')
).toBe('コミット')
expect(
repairTranslatedValue({
key: 'auto.components.mobile.slides.WorktreeListSlide.22971156df',
@@ -142,7 +143,7 @@ describe('locale-translation-policy ja round 5', () => {
localeValue: 'リポ',
locale: 'ja'
})
).toBe('Repo')
).toBe('リポ')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
@@ -151,6 +152,6 @@ describe('locale-translation-policy ja round 5', () => {
'ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
locale: 'ja'
})
).toBe('ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー')
).toBe('ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー')
})
})
+30 -22
View File
@@ -1,5 +1,8 @@
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
import {
isCanonicalGenericRendering,
overlapsCanonicalRendering
} from './locale-generic-ui-terms.mjs'
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
@@ -17,9 +20,10 @@ const OPEN_IN_APP_CATALOG_PREFIX = 'auto.lib.open.in.app.catalog.'
// Why: product names and agent labels stay Latin — MT reads them as common words (Codex→copy, Gemini→zodiac).
export const ENGLISH_ONLY_KEY_PREFIXES = [AGENT_CATALOG_PREFIX, OPEN_IN_APP_CATALOG_PREFIX]
// Only genuine brand, product, and code tokens belong here. Ordinary UI words that happen to
// name a product (agent, terminal, commit, repo, Continue) live in locale-generic-ui-terms.mjs
// and are translated; their product sense is pinned by ENGLISH_ONLY_KEY_PREFIXES instead.
export const NEVER_TRANSLATE_VALUES = new Set([
'Agent',
'Agents',
'Aider',
'Amp',
'Android',
@@ -33,7 +37,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'Codebuff',
'Codex',
'Command Code',
'Continue',
'Cursor',
'Droid',
'Devin',
@@ -60,30 +63,16 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'Pi',
'PostHog',
'Qwen Code',
'Repo',
'Repos',
'Rovo Dev',
'Commit',
'Commits',
'Markdown',
'Terminal',
'Terminals',
'VS Code',
'Warp',
'Zed',
'agent',
'agents',
'android',
'codex',
'commit',
'commits',
'gemini',
'claude',
'markdown',
'repo',
'repos',
'terminal',
'terminals',
'gh',
'idle',
'anthropic',
@@ -368,6 +357,18 @@ function includesPreservedLatinTerm(value, term) {
return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value)
}
function replaceMistranslatedForm(value, wrong, brand, locale) {
let result = ''
let cursor = 0
for (let at = value.indexOf(wrong); at !== -1; at = value.indexOf(wrong, cursor)) {
const end = at + wrong.length
result += value.slice(cursor, at)
result += overlapsCanonicalRendering(brand, locale, value, at, end) ? wrong : brand
cursor = end
}
return result + value.slice(cursor)
}
function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
let result = localeValue
const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {}
@@ -383,9 +384,6 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
if (isScreenCursorContext(brand, enValue, key)) {
continue
}
if (isLocalizedProseTermContext(brand, key)) {
continue
}
if (includesPreservedLatinTerm(result, brand)) {
continue
}
@@ -393,11 +391,16 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
if (!result.includes(wrong)) {
continue
}
// Why: #12113 — a generic term's correct translation is not a mistranslation; reverting it
// rewrote ~2000 translated values back to English on every repair run.
if (isCanonicalGenericRendering(brand, locale, wrong)) {
continue
}
// Why: "Copy identifier" legitimately uses 사본/复制 — only swap when English names the brand.
if (brand === 'Codex' && /\bCopy\b/i.test(enValue)) {
continue
}
result = result.replaceAll(wrong, brand)
result = replaceMistranslatedForm(result, wrong, brand, locale)
}
}
@@ -548,6 +551,11 @@ export function repairCatalog(enCatalog, localeCatalog, locale) {
for (const leaf of leaves) {
const current = leaf.key.split('.').reduce((cursor, part) => cursor?.[part], localeCatalog)
// Why: en.json carries keys the locale catalog has not been bootstrapped with yet; repair only
// rewrites values that already exist, so skip instead of crashing on undefined.
if (typeof current !== 'string') {
continue
}
const next = repairTranslatedValue({
key: leaf.key,
enValue: leaf.value,
@@ -80,7 +80,7 @@ describe('locale-translation-policy', () => {
localeValue: '壊れた小切手に対して AI エージェントを開始しました。',
locale: 'ja'
})
).toBe('失敗したチェックに対して AI agent を開始しました。')
).toBe('失敗したチェックに対して AI エージェントを開始しました。')
expect(
repairTranslatedValue({
key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
@@ -88,7 +88,7 @@ describe('locale-translation-policy', () => {
localeValue: '電話機からターミナルとエージェントを制御します。',
locale: 'ja'
})
).toBe('スマートフォンから terminals と agents を操作')
).toBe('スマートフォンからターミナルとエージェントを操作')
expect(
repairTranslatedValue({
key: 'auto.components.GitHubItemDialog.934add88b6',
@@ -267,7 +267,7 @@ describe('locale-translation-policy', () => {
localeValue: '未已检测代理',
locale: 'zh'
})
).toBe('未检测到 agents')
).toBe('未检测到代理')
expect(
repairTranslatedValue({
key: 'auto.components.skills.SkillsPage.38e0951c3a',
@@ -275,7 +275,7 @@ describe('locale-translation-policy', () => {
localeValue: '代理技巧',
locale: 'zh'
})
).toBe('Agent 技能')
).toBe('代理技能')
expect(
repairTranslatedValue({
key: 'auto.components.settings.appearance.search.9ae151b26b',
@@ -358,7 +358,7 @@ describe('locale-translation-policy', () => {
localeValue: 'コミットするものは何もありません。 PR はすでに統合されています。',
locale: 'ja'
})
).toBe('commit するものはありません。PR はすでにマージされています。')
).toBe('コミットするものはありません。PR はすでにマージされています。')
expect(
repairTranslatedValue({
key: 'auto.components.settings.integrations.search.581844769a',
@@ -546,7 +546,7 @@ describe('locale-translation-policy', () => {
localeValue: '玩',
locale: 'zh'
})
).toBe('Play')
).toBe('播放')
expect(
repairTranslatedValue({
key: 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e',
@@ -86,7 +86,9 @@ describe('locale-translation-policy zh round 5', () => {
).toBe('集成')
})
it('keeps Terminal as a product surface term', () => {
// Why: #12113 — 终端 is the correct rendering and survives; only the 端子 ("electrical
// connector") mistranslation still reverts to Latin.
it('keeps terminal translated but reverts the 端子 mistranslation', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.Settings.3de4bbb841',
@@ -94,7 +96,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '终端',
locale: 'zh'
})
).toBe('Terminal')
).toBe('终端')
expect(
repairTranslatedValue({
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
@@ -102,7 +104,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '终端 1',
locale: 'zh'
})
).toBe('Terminal 1')
).toBe('终端 1')
expect(
repairTranslatedValue({
key: 'auto.components.agent.AgentCombobox.986f946354',
@@ -118,7 +120,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '分体式端子右',
locale: 'zh'
})
).toBe('向右拆分 Terminal')
).toBe('向右拆分终端')
expect(
repairTranslatedValue({
key: 'auto.components.settings.TerminalAppearanceSection.abcb4dd019',
@@ -126,7 +128,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '终端Cursor',
locale: 'zh'
})
).toBe('Terminal Cursor')
).toBe('终端 Cursor')
expect(
repairTranslatedValue({
key: 'auto.components.terminal.FloatingTerminalPanel.3215fc73e9',
@@ -137,7 +139,8 @@ describe('locale-translation-policy zh round 5', () => {
).toBe('新 Terminal')
})
it('keeps workflow terms in English', () => {
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Chinese.
it('keeps brand names English and generic workflow terms translated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.SidebarNav.9c95e1ce91',
@@ -145,7 +148,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '代理',
locale: 'zh'
})
).toBe('Agents')
).toBe('代理')
expect(
repairTranslatedValue({
key: 'auto.components.GitHubItemDialog.28986b3747',
@@ -153,7 +156,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '已启动 AI 代理处理失败的检查。',
locale: 'zh'
})
).toBe('已启动 AI agent 处理失败的检查。')
).toBe('已启动 AI 代理处理失败的检查。')
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
@@ -177,7 +180,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '次提交',
locale: 'zh'
})
).toBe('commits')
).toBe('次提交')
expect(
repairTranslatedValue({
key: 'auto.store.slices.worktrees.d1d78a7baa',
@@ -187,7 +190,7 @@ describe('locale-translation-policy zh round 5', () => {
'Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。',
locale: 'zh'
})
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地 commits。')
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。')
})
it('does not confuse proxy copy with Agent terminology', () => {
@@ -234,7 +237,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '本地项目、Git 存储库或包含多个存储库的文件夹',
locale: 'zh'
})
).toBe('本地项目、Git repo 或包含多个 repos 的文件夹')
).toBe('本地项目、Git 仓库或包含多个仓库的文件夹')
})
it('keeps product, provider, code, and shell tokens untranslated', () => {
+3 -2
View File
@@ -8,7 +8,7 @@ export const LOCALE_VALUE_OVERRIDES = {
'OpenCode Go': 'OpenCode Go',
'Open in Cursor': 'Abrir en Cursor',
'Local project, Git repo, or folder with many repos':
'Proyecto local, repo de Git o carpeta con muchos repos'
'Proyecto local, repositorio de Git o carpeta con muchos repositorios'
},
ko: {
Save: '저장',
@@ -305,7 +305,8 @@ export const LOCALE_VALUE_OVERRIDES = {
teams: '团队',
passing: '通过',
'opened this issue': '创建了此议题',
Open: '进行中',
// No blanket "Open" override: it is a verb on buttons (打开) and a state next to 已关闭 (开放),
// so a single value-wide mapping is wrong for half the call sites. Per-key catalog values decide.
'Join Discord': '加入 Discord',
'Pull request merged': '拉取请求已合并',
'Agent Skills': '代理技能',
+17 -16
View File
@@ -67,8 +67,8 @@ export const ZH_VALUE_OVERRIDES = {
Inline: '内联',
thread: '帖子串',
destructive: 'destructive',
sheet: 'sheet',
page: 'page',
sheet: '面板',
page: '页面',
Annotation: '批注',
'check #': '检查 #',
by: '由',
@@ -92,16 +92,18 @@ export const ZH_VALUE_OVERRIDES = {
'仓库操作方案。在此仓库自定义之前,将使用全局设置。',
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.':
'为源代码管理的提交、拉取请求、分支命名和修复操作添加操作方案。',
Play: 'Play',
Flag: 'Flag',
Zinc: 'Zinc',
Rose: 'Rose',
Emerald: 'Emerald',
Amber: 'Amber',
Violet: 'Violet',
Sky: 'Sky',
Blue: 'Blue',
Neutral: 'Neutral',
// Workspace status icon and color swatch labels; siblings below are translated, so English
// pins here would leave the picker half-Chinese on the next catalog regeneration.
Play: '播放',
Flag: '标记',
Zinc: '锌灰',
Rose: '玫瑰',
Emerald: '翡翠',
Amber: '琥珀',
Violet: '紫罗兰',
Sky: '天蓝',
Blue: '蓝色',
Neutral: '中性',
Dashed: '虚线',
Dot: '圆点',
Circle: '圆形',
@@ -201,8 +203,7 @@ export const ZH_VALUE_OVERRIDES = {
'每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。',
'Show Linear in the Tasks source picker and sidebar shortcuts.':
'在任务源选择器和侧边栏快捷方式中显示 Linear。',
'Local project, Git repo, or folder with many repos':
'本地项目、Git repo 或包含多个 repos 的文件夹',
'Local project, Git repo, or folder with many repos': '本地项目、Git 仓库或包含多个仓库的文件夹',
'Staged Changes': '已暂存的更改',
Changes: '更改',
'Untracked Files': '未跟踪文件',
@@ -210,8 +211,8 @@ export const ZH_VALUE_OVERRIDES = {
'Split Down': '向下拆分',
'Split Left': '向左拆分',
'Split Right': '向右拆分',
'Split Terminal Down': '向下拆分 Terminal',
'Split Terminal Right': '向右拆分 Terminal',
'Split Terminal Down': '向下拆分终端',
'Split Terminal Right': '向右拆分终端',
'Optional account switching for Claude while preserving shared chat context.':
'Claude 的可选账户切换,同时保留共享聊天上下文。',
'Countdown timer showing time until prompt cache expires (Claude agents).':
@@ -227,12 +227,89 @@ describe('orchestration skill guidance', () => {
expect(agentGuidance).toContain('After sending `worker_done`, end your turn')
expect(agentGuidance).toContain('idle at the agent prompt')
expect(agentGuidance).toContain('Do not poll or keep calling `orca orchestration check`')
expect(agentGuidance).toContain(
'do not start more work, poll, or attempt to close the terminal yourself'
)
expect(agentGuidance).toContain('fresh preamble + TASK block delivered as new terminal input')
expect(skill).not.toContain('post-completion polling messages')
expect(skill).not.toContain('every 2 minutes')
})
it('makes settled worker terminal release an explicit coordinator step', () => {
const skill = readSkill()
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
const agentGuidance = getSection(skill, 'Agent Guidance')
const nextAction = getSection(skill, 'Next Action')
expect(workerLoop).toContain(
'# Process every message. For each accepted worker_done that is not immediately reused:\n' +
'orca orchestration worker-release --dispatch <dispatch_id> --json'
)
expect(workerLoop).toContain(
'Acknowledge only after every message and required release decision is handled'
)
expect(workerLoop).toContain(
'read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`'
)
expect(workerLoop).toContain(
'orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca ' +
'transfers cleanup ownership to the new Dispatch'
)
expect(workerLoop).toContain(
'Run `worker-release` after both succeeded and failed `worker_done` reports unless the user ' +
'explicitly asked to keep that worker live.'
)
expect(workerLoop).toContain('Release is post-completion cleanup, not cancellation')
expect(workerLoop).toContain('orca orchestration worker-retain --dispatch <dispatch_id> --json')
expect(workerLoop).toContain(
'the same Dispatch can be passed to `worker-release`, which clears the requested retention'
)
expect(agentGuidance).toContain(
'Coordinators must account for every settled worker terminal before waiting again or ending ' +
'the turn'
)
expect(agentGuidance).toContain('released workers remain readable through `worker-read`')
expect(nextAction).toContain(
'After every accepted `worker_done`, either transfer the exact terminal to an immediate ' +
'follow-up Dispatch or run `worker-release` before the next wait.'
)
})
it('documents per-invocation model and effort for supervised workers', () => {
const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop')
expect(workerLoop).toContain('opaque provider model id with `--model`')
expect(workerLoop).toContain('`--effort` requires `--model`')
expect(workerLoop).toContain('neither option can combine with `--terminal`')
expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json')
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
})
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
const skill = readSkill()
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
const agentGuidance = getSection(skill, 'Agent Guidance')
// The prohibition sentence is the guard the negative patterns below rely on.
expect(workerLoop).toContain(
'Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, ' +
'escalation, or rejected/stale `worker_done`.'
)
expect(workerLoop).toContain(
'do not substitute `terminal close`; follow the exact recovery action in the receipt'
)
expect(skill).not.toMatch(
/release[^.]*\bon (?:a |the )?(?:tui-?idle|idle|timeout|heartbeat|question|escalation)\b/iu
)
expect(skill).not.toMatch(
/\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu
)
expect(agentGuidance).toContain(
'do not start more work, poll, or attempt to close the terminal yourself'
)
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
})
it('documents @grok in the Messaging group address list', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
+89 -1
View File
@@ -3,7 +3,7 @@ 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'
import { createPlainNodeEntryGuardPlugin } from '../build-plugins/plain-node-entry-guard'
let outputDir: string | undefined
@@ -85,3 +85,91 @@ describe('plain Node entry guard', () => {
)
})
})
// Why (#11161): Electron's module is not registered on worker threads either —
// require("electron") throws "Cannot find module 'electron'" inside a
// main-process worker and kills it at startup. The worker entries carried only
// hand-written "must stay electron-free" comments, and the port-scan worker sits
// one import away from a client that deliberately does require electron.
describe('worker thread entry guard', () => {
function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void {
const hook = plugin.writeBundle
if (typeof hook !== 'function') {
throw new Error('Expected writeBundle hook')
}
hook.call(
{ meta: { watchMode: false } } as never,
{ dir: createOutputDir() } as Rollup.NormalizedOutputOptions,
bundle
)
}
function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk {
return {
type: 'chunk',
code,
dynamicImports: [],
fileName: `${name}.js`,
imports,
isEntry: true,
name
} as Rollup.OutputChunk
}
it('rejects an Electron require reachable from a worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'port-scan-command-worker-entry.js': workerChunk(
'port-scan-command-worker-entry',
'require("electron")'
)
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron')
})
it('names the worker-thread runtime so the failure is actionable', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'stt-worker.js': workerChunk('stt-worker', 'require("electron")')
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread')
})
// The real risk is transitive: a worker entry importing a shared chunk that
// reaches the electron-requiring client, not a direct import anyone would spot.
it('follows shared chunks out of a worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'session-scanner-opencode-sqlite-worker-entry.js': workerChunk(
'session-scanner-opencode-sqlite-worker-entry',
'require("./chunks/shared.js")',
['chunks/shared.js']
),
'chunks/shared.js': {
type: 'chunk',
code: 'require("electron")',
dynamicImports: [],
fileName: 'chunks/shared.js',
imports: [],
isEntry: false,
name: 'shared'
} as Rollup.OutputChunk
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js')
})
it('passes a clean worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'warp-theme-parser-worker.js': workerChunk(
'warp-theme-parser-worker',
'require("node:worker_threads")'
)
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow()
})
})
+125 -26
View File
@@ -1,5 +1,6 @@
import { build } from 'esbuild'
import { spawn } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
@@ -9,6 +10,12 @@ import { dirname, join, resolve } from 'node:path'
const WAIT_TIMEOUT_MS = 20_000
const require = createRequire(import.meta.url)
// Why: endpoint credentials are 32–256 base64url chars; openClient requires an authenticated
// transport, and #12746 admits pty.data only after a consumer grant.
function writeEndpointCredential(path) {
return writeFile(path, randomBytes(32).toString('base64url'), 'utf8')
}
function withTimeout(promise, label, stderr) {
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => {
@@ -68,14 +75,59 @@ async function loadProtocol(bundleDir) {
return require(outfile)
}
function attachProcessStreams(proc) {
let stderr = ''
proc.stderr.on('data', (chunk) => {
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
})
return {
stderr: () => stderr
}
}
function waitForStdoutSentinel(proc, protocol, stderr) {
let stdoutBuffer = Buffer.alloc(0)
return withTimeout(
new Promise((resolvePromise, rejectPromise) => {
let settled = false
const onData = (chunk) => {
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
const index = stdoutBuffer.indexOf(sentinel)
if (index < 0) {
return
}
settled = true
proc.stdout.off('data', onData)
proc.off('exit', onExit)
resolvePromise(stdoutBuffer.subarray(index + sentinel.length))
}
const onExit = (code, signal) => {
if (settled) {
return
}
settled = true
proc.stdout.off('data', onData)
rejectPromise(
new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`)
)
}
proc.stdout.on('data', onData)
proc.once('exit', onExit)
}),
'relay sentinel',
stderr
)
}
function createRelayClient(entryPath, args, env, protocol) {
const proc = spawn(process.execPath, [entryPath, ...args], {
cwd: dirname(entryPath),
env,
stdio: ['pipe', 'pipe', 'pipe']
})
const streams = attachProcessStreams(proc)
const messages = []
let stderr = ''
let nextSequence = 1
let stdoutBuffer = Buffer.alloc(0)
let ready = false
@@ -88,9 +140,6 @@ function createRelayClient(entryPath, args, env, protocol) {
messages.push(protocol.parseJsonRpcMessage(frame.payload))
}
})
proc.stderr.on('data', (chunk) => {
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
})
proc.stdout.on('data', (chunk) => {
if (ready) {
decoder.feed(chunk)
@@ -111,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) {
})
const waitForMessage = (startIndex, predicate, label) =>
pollUntil(
() => messages.slice(startIndex).find(predicate),
label,
() => stderr
)
pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr)
const request = async (method, params = {}) => {
const id = nextSequence++
@@ -141,7 +186,7 @@ function createRelayClient(entryPath, args, env, protocol) {
proc,
request,
notify,
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr),
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', streams.stderr),
messageCount: () => messages.length,
waitForNotification: (startIndex, method, predicate = () => true) =>
waitForMessage(
@@ -149,7 +194,20 @@ function createRelayClient(entryPath, args, env, protocol) {
(message) => message.method === method && predicate(message.params ?? {}),
`${method} notification`
),
stderr: () => stderr
stderr: streams.stderr
}
}
async function stopProcess(proc, stderr, label) {
if (!proc || proc.exitCode !== null || proc.signalCode !== null) {
return
}
proc.kill('SIGTERM')
try {
await withTimeout(waitForExit(proc), `${label} shutdown`, stderr)
} catch {
proc.kill('SIGKILL')
await withTimeout(waitForExit(proc), `forced ${label} shutdown`, stderr)
}
}
@@ -184,24 +242,64 @@ async function main() {
}
let tempRoot
let daemon
let daemonStreams
let relay
try {
tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-'))
const watchRoot = await realpath(tempRoot)
const pidFile = join(tempRoot, 'watcher.pid')
const credentialFile = join(tempRoot, 'endpoint.credential')
const protocol = await loadProtocol(tempRoot)
const socketPath =
process.platform === 'win32'
? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}`
: join(tempRoot, 'relay.sock')
await writeEndpointCredential(credentialFile)
// Why detached + --connect: the daemon primary stdio is unproved, so it cannot open a consumer
// session; only an endpoint-credential socket client is admitted for pty.data after #12746.
daemon = spawn(
process.execPath,
[
relayEntry,
'--detached',
'--grace-time',
'0',
'--sock-path',
socketPath,
'--endpoint-dir',
join(tempRoot, 'agent-hooks'),
'--credential-file',
credentialFile
],
{
cwd: dirname(relayEntry),
env: { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
stdio: ['ignore', 'pipe', 'pipe']
}
)
daemonStreams = attachProcessStreams(daemon)
await waitForStdoutSentinel(daemon, protocol, daemonStreams.stderr)
relay = createRelayClient(
relayEntry,
['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')],
{ ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
['--connect', '--sock-path', socketPath, '--credential-file', credentialFile],
process.env,
protocol
)
await relay.sentinelReceived
// Why no outputFlowControl: legacy owner grant admits plain pty.data without delivery tokens.
const grant = await relay.request('pty.openClient', {
protocolVersion: 1,
clientInstanceId: `relay-watcher-fault-${process.pid}`,
requestedRole: 'session-owner'
})
if (grant?.role !== 'session-owner') {
throw new Error(`expected session-owner grant, got ${JSON.stringify(grant)}`)
}
const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot })
const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}`
let startIndex = relay.messageCount()
@@ -213,7 +311,11 @@ async function main() {
)
await relay.request('fs.watch', { rootPath: watchRoot })
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr)
const firstWatcherPid = await waitForWatcherPid(
pidFile,
undefined,
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
)
const beforePath = join(watchRoot, 'before.txt')
startIndex = relay.messageCount()
await writeFile(beforePath, 'before')
@@ -224,7 +326,11 @@ async function main() {
const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
startIndex = relay.messageCount()
process.kill(firstWatcherPid, faultSignal)
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr)
const replacementWatcherPid = await waitForWatcherPid(
pidFile,
firstWatcherPid,
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
)
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
Array.isArray(params.events)
? params.events.some(
@@ -234,7 +340,7 @@ async function main() {
)
const status = await relay.request('relay.status')
if (status.pid !== relay.proc.pid) {
if (status.pid !== daemon.pid) {
throw new Error('relay.status did not come from the original surviving relay process')
}
const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}`
@@ -257,7 +363,7 @@ async function main() {
await relay.request('pty.shutdown', { id: spawned.id })
console.log(
JSON.stringify({
relayPid: relay.proc.pid,
relayPid: daemon.pid,
killedWatcherPid: firstWatcherPid,
replacementWatcherPid,
faultSignal,
@@ -268,15 +374,8 @@ async function main() {
})
)
} finally {
if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) {
relay.proc.kill('SIGTERM')
try {
await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr)
} catch {
relay.proc.kill('SIGKILL')
await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr)
}
}
await stopProcess(relay?.proc, relay?.stderr ?? (() => ''), 'connect bridge')
await stopProcess(daemon, daemonStreams?.stderr ?? (() => ''), 'relay daemon')
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true })
}
+9 -7
View File
@@ -1,7 +1,7 @@
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
/**
* `07-31 13:54` — the timestamp segment of a dev build's release title, shown
* `Jul 31, 8:10PM` — the timestamp segment of a dev build's release title, shown
* verbatim in both the GitHub releases list and the in-app build picker.
*
* Why Pacific while the tag's own stamp stays UTC: that stamp is a sort key, and
@@ -16,15 +16,17 @@ export function formatReleaseTitleTimestamp(date) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: RELEASE_NAME_TIME_ZONE,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
// Why h23 rather than hour12: false: some ICU builds render midnight as 24.
hourCycle: 'h23'
hour12: true
})
.formatToParts(date)
.map((part) => [part.type, part.value])
)
return `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
// Assembled from parts rather than by string-editing the formatted output:
// recent ICU separates the time from AM/PM with U+202F, not a plain space, so
// a naive replace(' ', '') leaves the gap on some runtimes and not others.
return `${parts.month} ${parts.day}, ${parts.hour}:${parts.minute}${parts.dayPeriod.toUpperCase()}`
}
@@ -35,7 +35,6 @@ const RESTRICTED_ENV_KEYS = [
'HOMEPATH',
'CODEX_HOME',
'ORCA_CODEX_HOME',
'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME',
'ORCA_E2E_HOME_DIR',
'ORCA_E2E_USER_DATA_DIR',
'ORCA_USER_DATA_PATH',
@@ -69,7 +68,7 @@ async function resolveRealPath(candidate) {
}
}
export function createValidationEnv(inheritedEnv, layout, options = {}) {
export function createValidationEnv(inheritedEnv, layout) {
const env = { ...inheritedEnv }
for (const key of RESTRICTED_ENV_KEYS) {
delete env[key]
@@ -81,12 +80,7 @@ export function createValidationEnv(inheritedEnv, layout, options = {}) {
NODE_ENV: 'development',
ORCA_E2E_HOME_DIR: layout.homeDir,
ORCA_E2E_USER_DATA_DIR: layout.userDataDir,
ORCA_USER_DATA_PATH: layout.userDataDir,
// Why: flag OFF pins every codex spawn to an explicit managed CODEX_HOME,
// so native codex never resolves the OS profile — the only Windows
// configuration where strict zero-event containment is reachable. It also
// exercises the emergency kill-switch lane users fall back to.
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: options.systemDefaultRealHome === 'off' ? '0' : '1'
ORCA_USER_DATA_PATH: layout.userDataDir
}
}
@@ -268,8 +262,7 @@ function parseArgs(argv) {
primaryHome: os.homedir(),
configTemplate: null,
tempParent: null,
laneAwareContainment: false,
systemDefaultRealHome: 'on'
laneAwareContainment: false
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
@@ -299,14 +292,8 @@ function parseArgs(argv) {
options.skipBuild = true
} else if (arg === '--keep') {
options.keep = true
} else if (arg === '--system-default-real-home') {
const value = readValue()
if (value !== 'on' && value !== 'off') {
throw new Error('--system-default-real-home must be "on" or "off"')
}
options.systemDefaultRealHome = value
} else if (arg === '--lane-aware-containment') {
// Why: on Windows the flag-ON system-default lane cannot be env-sandboxed
// Why: on Windows the system-default real-home lane cannot be env-sandboxed
// (native codex ignores USERPROFILE), so strict zero-event containment is
// structurally unreachable there. This mode records codex's designed
// volatile churn without aborting while every other real-home write stays
@@ -314,7 +301,7 @@ function parseArgs(argv) {
options.laneAwareContainment = true
} else if (arg === '--help') {
console.log(
'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template <path>] [--temp-parent <dir>] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--system-default-real-home on|off] [--report <path>]'
'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template <path>] [--temp-parent <dir>] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--report <path>]'
)
process.exit(0)
} else {
@@ -480,7 +467,7 @@ async function main() {
const reportPath =
options.reportPath ??
path.join(os.tmpdir(), `orca-codex-real-account-${options.scenario}-${Date.now()}.json`)
const launchEnv = createValidationEnv(process.env, layout, options)
const launchEnv = createValidationEnv(process.env, layout)
let app = null
let tripwire = null
const abortController = new AbortController()
@@ -46,29 +46,9 @@ describe('Codex real-account validation harness', () => {
expect(env.CODEX_HOME).toBeUndefined()
expect(env.ORCA_CODEX_HOME).toBeUndefined()
expect(env.ZDOTDIR).toBeUndefined()
expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('1')
expect(env.SAFE_VALUE).toBe('preserved')
})
it('pins the real-home flag off when the system-default lane is disabled', async () => {
const primaryHome = path.join(os.tmpdir(), 'orca-primary-home-sentinel')
const { layout, env } = runValidationModule<{
layout: { tempRoot: string }
env: Record<string, string | undefined>
}>(
`
const { createValidationEnv, createValidationLayout } = await import(process.argv[1])
const layout = await createValidationLayout({ primaryHome: process.argv[2] })
const env = createValidationEnv({}, layout, { systemDefaultRealHome: 'off' })
console.log(JSON.stringify({ layout, env }))
`,
[primaryHome]
)
cleanupPaths.push(layout.tempRoot)
expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('0')
})
it('records only fingerprints for system-default and managed auth', async () => {
const { layout, snapshot } = runValidationModule<{
layout: { tempRoot: string }
@@ -483,7 +483,6 @@ async function main() {
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_E2E_HOME_DIR: isolatedHome,
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
}
})
@@ -0,0 +1,43 @@
import { spawnSync } from 'node:child_process'
const extraArgs = process.argv.slice(2)
const pnpmEntry = process.env.npm_execpath
if (!pnpmEntry) {
throw new Error('npm_execpath is required; run this harness through pnpm')
}
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1'
}
const runtime = spawnSync(process.execPath, [pnpmEntry, 'run', 'ensure:electron-runtime'], {
stdio: 'inherit',
env
})
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
const result = spawnSync(
process.execPath,
[
pnpmEntry,
'exec',
'playwright',
'test',
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
{
stdio: 'inherit',
env
}
)
process.exit(result.status ?? 1)
@@ -55,7 +55,6 @@ Object.assign(childEnv, {
ORCA_DEV_USER_DATA_PATH: profileDir,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
...(process.platform === 'linux'
? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' }
: {})
@@ -39,14 +39,23 @@ else
# Why the features are off: this repo holds releases and nothing else. Leaving
# issues open invites bug reports against a branch build in a repo nobody
# watches, where they are simply lost.
#
# Why --add-readme in a repo with no source: publishing a release creates a tag,
# and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in.
gh repo create "$ADHOC_REPO" \
--public \
--description "Adhoc macOS dev builds of Orca, cut from unlanded branches. Not a source repo." \
--add-readme \
--disable-issues \
--disable-wiki ||
fail "Could not create $ADHOC_REPO."
fi
# Also checked outside the create branch: a repo made before --add-readme is here.
if ! gh api "repos/$ADHOC_REPO/commits" --jq 'length' >/dev/null 2>&1; then
fail "$ADHOC_REPO has no commits — releases cannot be tagged. Add any file to it first."
fi
echo
echo "Granting $APP_SLUG access to $ADHOC_REPO..."
@@ -0,0 +1,274 @@
#!/usr/bin/env node
// Benchmark: the renderer-side scans that run over a whole reattach payload.
//
// 1. hasCursorAgentReattachPayloadScreenSignal — compares the old hand-rolled char-by-char
// CSI strip against the shipped shape (256KB tail + shared CSI_SEQUENCE_PATTERN). Every
// variant is asserted to agree with the baseline before it is timed.
// 2. TerminalKittyKeyboardModeTracker.scanReplay — measured to justify leaving it alone, and
// to record that porting the daemon mouse mirror's includes() pre-filter makes it slower.
//
// Payloads are generated deterministically (LCG, no Math.random) so runs compare.
//
// Run with: node config/scripts/terminal-reattach-payload-scan-benchmark.mjs
import { performance } from 'node:perf_hooks'
import { CSI_SEQUENCE_PATTERN } from '../../src/shared/ansi-escape-sequences.ts'
import { TerminalKittyKeyboardModeTracker } from '../../src/shared/terminal-kitty-keyboard-mode-tracker.ts'
const ROUNDS = Number(process.env.ORCA_REATTACH_SCAN_BENCH_ROUNDS ?? '7')
const MIN_ITERATION_MS = 120
// --- payload generation -----------------------------------------------------
function makeRng(seed) {
let state = seed >>> 0
return () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0
return state / 0x100000000
}
}
const WORDS = [
'src',
'renderer',
'components',
'terminal',
'pane',
'connection',
'reattach',
'payload',
'snapshot',
'daemon',
'passed',
'failed',
'warning',
'building',
'index.ts',
'1.24s',
'ok'
]
// Heavily SGR-colored scrollback, ~120 cols/line — what a serialized daemon snapshot
// of a colorized TUI/build log looks like.
function buildColoredScrollback(targetBytes, seed) {
const rng = makeRng(seed)
const lines = []
let size = 0
while (size < targetBytes) {
let line = ''
let visible = 0
while (visible < 118) {
const word = WORDS[Math.floor(rng() * WORDS.length)]
const color = 16 + Math.floor(rng() * 200)
line += `\x1b[38;5;${color}m${word}\x1b[0m `
visible += word.length + 1
}
lines.push(line)
size += line.length + 1
}
return lines.join('\r\n')
}
// Live TUIs also emit cursor moves / erases; keep some of those in the mix.
function withCursorTraffic(body, seed) {
const rng = makeRng(seed)
return body
.split('\r\n')
.map((line, i) => `\x1b[${(i % 40) + 1};1H\x1b[K${line}${rng() < 0.1 ? '\x1b[?25l' : ''}`)
.join('\r\n')
}
const CURSOR_AGENT_SCREEN = [
'\x1b[38;5;39mCursor Agent\x1b[0m',
'',
' \x1b[2mReady\x1b[0m',
'',
'\x1b[38;5;245m→ \x1b[0m'
].join('\r\n')
function buildPayloads() {
const base200k = buildColoredScrollback(200 * 1024, 1)
const base2m = buildColoredScrollback(2 * 1024 * 1024, 2)
const relayTail = buildColoredScrollback(100 * 1024, 3)
return [
{ name: '200KB snapshot, header hit (tail)', data: `${base200k}\r\n${CURSOR_AGENT_SCREEN}` },
{ name: '200KB snapshot, no header (miss)', data: base200k },
{ name: '2MB snapshot, header hit (tail)', data: `${base2m}\r\n${CURSOR_AGENT_SCREEN}` },
{ name: '2MB snapshot, no header (miss)', data: base2m },
{ name: '100KB relay tail, cursor traffic', data: withCursorTraffic(relayTail, 4) }
]
}
// --- strip variants ---------------------------------------------------------
// Baseline: verbatim copy of pty-connection.ts:448 (not exported).
function stripBaseline(data) {
let normalized = ''
let index = 0
while (index < data.length) {
if (data.charCodeAt(index) === 0x1b && data[index + 1] === '[') {
index += 2
while (index < data.length) {
const code = data.charCodeAt(index)
index += 1
if (code >= 0x40 && code <= 0x7e) {
break
}
}
continue
}
normalized += data[index]
index += 1
}
return normalized
}
const stripRegex = (data) => data.replace(CSI_SEQUENCE_PATTERN, '')
const HEADER = 'Cursor Agent'
const TAIL_CHARS = 5000
// Mirrors CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS in pty-connection.ts.
const SUFFIX_RAW_CHARS = 256 * 1024
const signalBaseline = (data) => hasSignal(stripBaseline(data))
const signalRegex = (data) => hasSignal(stripRegex(data))
// The shipped shape.
const signalSuffixRegex = (data) =>
hasSignal(stripRegex(data.length > SUFFIX_RAW_CHARS ? data.slice(-SUFFIX_RAW_CHARS) : data))
function hasSignal(normalized) {
const headerIndex = normalized.lastIndexOf(HEADER)
if (headerIndex === -1) {
return false
}
return normalized.slice(headerIndex + HEADER.length, headerIndex + TAIL_CHARS).includes(`${'→'} `)
}
// --- timing -----------------------------------------------------------------
function median(values) {
const sorted = [...values].sort((a, b) => a - b)
const mid = sorted.length >> 1
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
function timeMsPerCall(fn, arg) {
let iterations = 1
while (true) {
const start = performance.now()
for (let i = 0; i < iterations; i++) {
globalThis.__sink = fn(arg)
}
const elapsed = performance.now() - start
if (elapsed >= MIN_ITERATION_MS || iterations >= 1 << 22) {
return elapsed / iterations
}
iterations *= Math.max(2, Math.ceil(MIN_ITERATION_MS / Math.max(elapsed, 0.01)))
}
}
function measure(fn, arg) {
timeMsPerCall(fn, arg) // warmup / JIT
const samples = []
for (let round = 0; round < ROUNDS; round++) {
samples.push(timeMsPerCall(fn, arg))
}
return median(samples)
}
const pad = (value, width) => String(value).padStart(width)
const kb = (data) => `${(data.length / 1024).toFixed(0)}KB`
// --- run --------------------------------------------------------------------
const payloads = buildPayloads()
console.log('Reattach payload scans, ms/call (median of %d rounds). Lower is better.\n', ROUNDS)
console.log('== hasCursorAgentReattachPayloadScreenSignal ==')
console.log(
`${pad('payload', 36)} ${pad('size', 8)} ${pad('char-loop', 11)} ${pad('regex', 10)} ${pad('suffix+regex', 13)} ${pad('speedup', 9)}`
)
for (const { name, data } of payloads) {
const expected = signalBaseline(data)
for (const [label, fn] of Object.entries({
regex: signalRegex,
'suffix+regex': signalSuffixRegex
})) {
if (fn(data) !== expected) {
throw new Error(`${label} disagrees with baseline on "${name}"`)
}
}
const base = measure(signalBaseline, data)
const re = measure(signalRegex, data)
const suffix = measure(signalSuffixRegex, data)
console.log(
`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(base.toFixed(3), 11)} ${pad(re.toFixed(3), 10)} ${pad(suffix.toFixed(3), 13)} ${pad(`${(base / suffix).toFixed(0)}x`, 9)}`
)
}
console.log('\n== TerminalKittyKeyboardModeTracker.scanReplay ==')
console.log(
`${pad('payload', 36)} ${pad('size', 8)} ${pad('scanReplay', 11)} ${pad('+includes gate', 15)}`
)
const kittyScan = (data) => {
const tracker = new TerminalKittyKeyboardModeTracker()
tracker.scanReplay(data)
return tracker.flags
}
// Mirrors src/main/daemon/terminal-mouse-mode-mirror.ts:41-47.
const kittyGated = (data) => {
if (!data.includes('\x1b[?') && !data.includes('\x1bc') && !data.includes('\x9b')) {
return 0
}
return kittyScan(data)
}
for (const { name, data } of payloads) {
console.log(
`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(measure(kittyScan, data).toFixed(3), 11)} ${pad(measure(kittyGated, data).toFixed(3), 15)}`
)
}
// The gate can only pay off where no introducer exists: live plain-output chunks, not
// snapshots (which carry ?1049h/?25l by construction).
const liveChunk = buildColoredScrollback(4 * 1024, 5).replaceAll('\x1b', '')
const introducerRows = [
['4KB live chunk, no escapes', liveChunk],
['4KB live chunk, \\x1b[?25l present', `${liveChunk}\x1b[?25l`]
]
console.log(
`\n${pad('payload', 36)} ${pad('size', 8)} ${pad('scanReplay', 11)} ${pad('+includes gate', 15)}`
)
for (const [name, data] of introducerRows) {
console.log(
`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(measure(kittyScan, data).toFixed(3), 11)} ${pad(measure(kittyGated, data).toFixed(3), 15)}`
)
}
console.log(
'\nSnapshot/replay payloads always contain \\x1b[?, so the includes() gate never fires on\nthe reattach path — it only helps the live per-chunk path.'
)
// Reference point: xterm parses the same bytes right after these scans run, so its cost
// is the yardstick for whether the scans are worth cutting.
const xtermHeadless = await import('@xterm/headless')
const Terminal = xtermHeadless.Terminal ?? xtermHeadless.default.Terminal
const writeToXterm = (data) =>
new Promise((resolve) => {
const terminal = new Terminal({ cols: 120, rows: 40, scrollback: 5000, allowProposedApi: true })
const start = performance.now()
terminal.write(data, () => {
const elapsed = performance.now() - start
terminal.dispose()
resolve(elapsed)
})
})
console.log(`\n== xterm headless parse of the same payload (reference) ==`)
console.log(`${pad('payload', 36)} ${pad('size', 8)} ${pad('xterm write', 11)}`)
for (const { name, data } of payloads) {
await writeToXterm(data)
const samples = []
for (let round = 0; round < ROUNDS; round++) {
samples.push(await writeToXterm(data))
}
console.log(`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(median(samples).toFixed(3), 11)}`)
}
@@ -219,7 +219,6 @@ async function runValidation(mode) {
ORCA_DEV_USER_DATA_PATH: userDataPath,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
ELECTRON_ENABLE_LOGGING: '1',
ELECTRON_ENABLE_STACK_DUMPING: '1',
ELECTRON_OZONE_PLATFORM_HINT: 'wayland',
+62 -1
View File
@@ -6,6 +6,9 @@ import process from 'node:process'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
import { canonicalGenericRenderings } from './locale-generic-ui-terms.mjs'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain'])
@@ -282,9 +285,51 @@ function applyMissingEnglishEntries(catalog, missing) {
return changed
}
// Why: #12113 — parity checks pass while repair-locale-catalog rewrites translated generic terms
// back to English, so drift only surfaces when someone regenerates the catalog.
export function collectGenericTermRegressions(enEntries, localeEntries, localeName) {
const renderings = canonicalGenericRenderings(localeName)
if (renderings.length === 0) {
return []
}
const regressions = []
for (const [key, enValue] of enEntries) {
const localeValue = localeEntries.get(key)
if (typeof enValue !== 'string' || typeof localeValue !== 'string') {
continue
}
const repaired = repairTranslatedValue({ key, enValue, localeValue, locale: localeName })
if (repaired === localeValue) {
continue
}
// Why: {{agent}} is an interpolation name, not English copy the reader sees.
const repairedCopy = repaired.replace(PLACEHOLDER_RE, '')
for (const { form, terms } of renderings) {
if (!localeValue.includes(form) || repaired.includes(form)) {
continue
}
if (
terms.some((term) => new RegExp(`(^|[^A-Za-z])${term}($|[^A-Za-z])`).test(repairedCopy))
) {
regressions.push({ key, form, localeValue, repaired })
break
}
}
}
return regressions
}
function formatGenericTermRegressions(regressions) {
return regressions
.map((entry) => `${entry.key}: ${entry.form} -> English (${entry.repaired})`)
.join('\n')
}
function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
const genericTermRegressions = collectGenericTermRegressions(enEntries, localeEntries, localeName)
// Why: feature PRs own English declarations; absent target leaves deliberately
// use i18next's existing English fallback until a localization PR supplies them.
@@ -292,7 +337,11 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
`${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.`
)
if (extraInLocale.length > 0 || interpolationMismatches.length > 0) {
if (
extraInLocale.length > 0 ||
interpolationMismatches.length > 0 ||
genericTermRegressions.length > 0
) {
console.error(`Locale catalog validation failed for ${localeName}.json.`)
if (extraInLocale.length > 0) {
console.error('')
@@ -310,6 +359,18 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
console.error(`...and ${interpolationMismatches.length - 20} more interpolation mismatches`)
}
}
if (genericTermRegressions.length > 0) {
console.error('')
console.error(
'repair-locale-catalog would rewrite these translated terms back to English.',
'Treat the term as generic in config/scripts/locale-generic-ui-terms.mjs',
'instead of listing its translation as a mistranslation.'
)
console.error(formatGenericTermRegressions(genericTermRegressions.slice(0, 20)))
if (genericTermRegressions.length > 20) {
console.error(`...and ${genericTermRegressions.length - 20} more generic term regressions`)
}
}
return 1
}
@@ -4,7 +4,10 @@ import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
import {
collectGenericTermRegressions,
main as verifyLocalizationCatalog
} from './verify-localization-catalog.mjs'
function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
@@ -123,4 +126,47 @@ describe('verify-localization-catalog', () => {
report.mockRestore()
}
})
// Why: #12113 — parity checks passed while the repair policy rewrote translated terms to English.
it('flags catalog values the repair policy would rewrite back to English', () => {
const enEntries = new Map([['auto.example.commitLabel', 'Commit message']])
expect(
collectGenericTermRegressions(
enEntries,
new Map([['auto.example.commitLabel', 'mensaje de confirmación']]),
'es'
)
).toEqual([])
// A locale whose committed value is the English term is stable, not a regression.
expect(
collectGenericTermRegressions(
enEntries,
new Map([['auto.example.commitLabel', 'mensaje de Commit']]),
'es'
)
).toEqual([])
// 'Comprometerse' is a real mistranslation, so reverting it to Latin is expected.
expect(
collectGenericTermRegressions(
enEntries,
new Map([['auto.example.commitLabel', 'mensaje de Comprometerse']]),
'es'
)
).toEqual([])
})
it('ignores interpolation names when looking for English rewrites', () => {
expect(
collectGenericTermRegressions(
new Map([
['components.agentSessionContinuation.originalAgent', 'Original agent: {{agent}}']
]),
new Map([['components.agentSessionContinuation.originalAgent', '原智能体:{{agent}}']]),
'zh'
)
).toEqual([])
})
})
@@ -63,7 +63,6 @@ export function launchDevApp({ cdpPort, userDataDir }) {
ORCA_DEV_USER_DATA_PATH: userDataDir,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
ORCA_SKIP_DEV_WEB_PREPARE: '1',
ORCA_STARTUP_DIAGNOSTICS: '1',
REMOTE_DEBUGGING_PORT: String(cdpPort),
+4
View File
@@ -63,6 +63,7 @@
"../src/main/droid/hook-service.ts",
"../src/main/gemini/hook-service.ts",
"../src/main/grok/hook-service.ts",
"../src/main/grok/windows-grok-hook-script.ts",
"../src/main/devin/hook-settings.ts",
"../src/main/devin/hook-service.ts",
"../src/main/devin/hook-config-json.ts",
@@ -72,6 +73,9 @@
"../src/main/openclaude/hook-service.ts",
"../src/main/rolling-file-backup.ts",
"../src/main/startup/hydrate-shell-path.ts",
// Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this
// project's serve spec; the module has no imports, so listing it pulls in nothing else.
"../src/main/startup/serve-mode-argv.ts",
"../src/main/runtime/runtime-metadata.ts",
"../src/main/win32-utils.ts"
],
+1 -1
View File
@@ -2,7 +2,7 @@
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"../electron.vite.config.*",
"../build-plugins/**/*",
"./build-plugins/**/*",
"../src/main/**/*",
"../src/renderer/src/lib/skill-freshness-display-status.ts",
"../src/preload/**/*",
+7 -1
View File
@@ -14,7 +14,13 @@
"../src/main/ipc/worktree-path-comparison.ts",
"../src/main/wsl-distro-list-output.ts",
"../src/main/wsl-distro-retry.ts",
"../src/main/wsl.ts"
"../src/main/wsl.ts",
"../src/main/startup/serve-desktop-activation.ts",
"../src/main/startup/serve-mode-argv.ts",
"../src/main/startup/single-instance-lock.ts",
"../src/main/startup/startup-diagnostics.ts",
"../src/main/window/focus-existing-window.ts",
"../src/main/window/macos-app-activation.ts"
],
"compilerOptions": {
"paths": {
+2
View File
@@ -17,6 +17,8 @@ export default defineConfig({
environment: 'node',
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
execArgv: ['--no-experimental-webstorage'],
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
include: [
'src/**/*.test.ts',
'src/**/*.test.tsx',
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 13m">
<title>downloads: 13m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 16m">
<title>downloads: 16m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<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="90" y="15" fill="#010101" fill-opacity=".3">13m</text>
<text x="90" y="14">13m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">16m</text>
<text x="90" y="14">16m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+442
View File
@@ -0,0 +1,442 @@
# Mobile Relay UX — Investigation Findings & Fix Plan
Scope: phone-side presentation/state-machine issues behind three reported symptoms on Android over
the cloud relay. The relay protocol and server-side assignment are healthy; nothing here changes
desktop or relay-server code. All file references are in `mobile/` of this worktree.
## 1. Symptom → root-cause summary
| # | Symptom | Root cause (verified) |
|---|---------|----------------------|
| S1 | Resume lands on an empty "Host" page, grey dot | Bare cross-stack `router.push` into a cold nested host navigator resolves to the host index route **without the `hostId` param**; every screen below then runs with `hostId: undefined` |
| S2 | Tapping a healthy relay host shows grey 1–2s before green | Every screen focus funnels into the network-handoff recovery path, which **suspends the healthy relay session** (publishes `disconnected`) and re-dials; the re-dial is invisible because `migrateTo` binds new-session state only after authentication |
| S3 | Relay-forced pairing looks dead ~5–10s | The pairing relay path has **no log sink** (only direct-path entries reach the "Pairing log"), and post-pairing the app dials the unreachable LAN endpoint for up to 12s before relay recovery is even eligible |
## 2. Verified end-to-end causal chains
### S1 — Resume dead-ends on the host index page
1. Home renders the Resume card only once `hostStates[lastVisited.hostId] === 'connected'`
(`app/index.tsx:488`); over relay that is seconds after the host list paints, and the card
inserts **above** the Tasks card in the same footer (`app/index.tsx:733-780`) — a layout shift
under the thumb.
2. Tap → bare `router.push(createMobileSessionHref(...))` (`app/index.tsx:740-746`) targeting
`/h/[hostId]/session/[worktreeId]`.
3. With the `h` group cold (cold start, or host never visited this session), Expo Router resolves
the push to the host stack's **index route with no `hostId` param**. This exact failure mode is
documented twice in-repo ("cold Expo deep links resolve to index" —
`src/transport/host-edit-navigation.ts:52`, `src/tasks/mobile-task-navigation.ts:90`) and is the
root cause named by PR #12001.
4. `app/h/_layout.tsx:64` reads `hostId` via `useGlobalSearchParams` → `undefined`.
`HostProtocolGate` gets `hostId: undefined`; `useHostClient(undefined)` returns
`state: 'disconnected'` (`src/transport/client-context.tsx:344`) → **grey dot**.
5. The host index screen renders the fallback title `'Host'` (`app/h/[hostId]/index.tsx:821`), and
every fetch no-ops on `!client || connState !== 'connected'`
(`app/h/[hostId]/index.tsx:298,364,418,520`) → **empty list**. The Filter/Recent/Repo chips are
static toolbar UI, so the page looks "real" but dead.
6. "Sometimes": a warm host stack resolves the same push correctly, so the bug is intermittent by
navigation history.
Corrections to the preliminary sweep: the empty page is primarily the missing `hostId` param, not
the connection-gated fetches or the cold 30s worktree cache (those matter only when landing *with*
a valid `hostId`, e.g. the mistap-strand case). Also, the Resume target "validation" is weaker than
it looks: `getCachedWorktrees` is seeded from the persisted home snapshot at hydration
(`app/index.tsx:264-277`) and the 30s TTL is stamped at seed time (`src/cache/worktree-cache.ts:20`),
so a worktree deleted while the phone was off still passes until a live `worktree.ps` overwrites it.
**Fix**: PR #12001 ("open the Resume workspace through a mounted host stack") routes Resume through
the same mount-then-replace mechanism Tasks uses, extracted to `src/navigation/host-stack-navigation.ts`.
Reviewed and validated per Jinwoo; **merged to main as `7948e46db855`** after final validation
(see §4, F0). Residual S1 items it does not cover: bare notification/accounts/deep-link pushes (F4),
catalog validation + not-found bounce (F7), Resume-card layout shift (F8), gate unmount hazard (F9).
### S2 — grey blink when focusing a healthy relay host
1. Every focus of the host screen fires `notifyForeground()`
(`app/h/[hostId]/index.tsx:512-517`, deliberately empty deps).
2. `openHostLogicalClient` wraps that into `endpointLifecycle.setForeground(true)`
(`src/transport/host-logical-client.ts:31-33`); the lifecycle forwards without dedupe
(`src/transport/mobile-endpoint-lifecycle.ts:62-64`).
3. `MobileEndpointSupervisor.setForeground(true)` computes `wasForeground = true` and calls
`RelayReconnectController.handleForeground` (`src/transport/mobile-endpoint-supervisor.ts:115-119`).
4. `handleForeground` with `wasForeground && state === 'connected'` **suspends the healthy session**
(`src/transport/mobile-relay-reconnect-controller.ts:53-60`). `suspendActiveRelay` early-returns
unless the active path is `'relay'` (`:77-84`) — which is why LAN hosts never blink.
5. `suspendActiveSession` closes the physical session, disposes all subscriptions, and publishes
`'disconnected'` (`src/transport/stable-logical-rpc-client.ts:164-180`) → grey dot
(`src/components/StatusDot.tsx:11`), worktree queries blocked.
6. `onRetry()` → `recoverRelay()` → `openRelay` + `migrateTo`. During the dial the logical state
**stays 'disconnected'**: `migrateTo` only binds the new session's state after
`waitForAuthenticated` resolves (`src/transport/stable-logical-rpc-client.ts:188,213`), and the
dialing session's own `connecting`/`handshaking` publishes (`src/transport/mobile-relay-rpc-session.ts:43,74`)
fire with no listeners attached. Grey persists the full 1–2s (happy path has no artificial
delays; any failure adds ≥250ms full-jitter backoff, `src/transport/mobile-relay-retry-delays.ts:3-5`).
7. `migrateTo` completes → `'connected'` → green; subscriptions replay; gated fetches rerun.
Second trigger for the same path: OS network-revival nudges call `notifyForeground()` on every live
client (`src/transport/client-context.tsx:286-292`, `src/transport/connection-revival-triggers.ts`)
— any Wi-Fi↔cellular transition or came-online event grey-blinks every connected relay host.
Design context: the suspend-on-repeat-foreground is pinned by the supervisor test as the
network-handoff half-open case (`src/transport/mobile-endpoint-supervisor.test.ts:~160-197`). The
asymmetry is that **direct sockets probe instead of tearing down** — `notifyForeground` on a
connected direct client runs an activity probe that detects a half-open socket in ≤8s
(`src/transport/rpc-client.ts:1119-1124`) — while relay sessions have a no-op `notifyForeground`
(`src/transport/mobile-relay-rpc-session.ts:106`) and the supervisor's only tool is
suspend-then-redial. There is also an in-repo make-before-break precedent: lease rotation calls
`recoverRelay(forceReplacement = true)` and migrates a **live** session with zero visible blink
(`src/transport/mobile-endpoint-supervisor.ts:56-59,146,249`).
Divergent mount defaults (secondary): home renders `hostStates[id] ?? 'connecting'` (amber,
`app/index.tsx:707`) while `getState()`/`useHostClient` return `'disconnected'` (grey) for a
missing store entry (`src/transport/client-context.tsx:221,344`) — so host screens flash grey
during the async client acquire (Keychain read) that home never shows.
### S3 — silent 5–10s relay-forced pairing
Pairing phase:
1. `pair-confirm.tsx` / `pair-scan.tsx` pass `connectOptions.onLog` into `startPreProfilePairing`
(`app/pair-confirm.tsx:91-99`, `app/pair-scan.tsx:135-143`).
2. The coordinator threads it **only to the direct candidate**
(`src/transport/pre-profile-pairing-coordinator.ts:152-157`). The relay candidate
(`:161-187`) gets nothing: `connectMobileRelayForPairing` has no log parameter at all
(`src/transport/mobile-relay-physical-client.ts:22-30`), nor do the director resolution,
journal writes, or the recovery loop in `src/transport/pairing-relay-candidate.ts`.
3. With LAN unreachable, the visible "Pairing log" shows only the direct dial stalling toward its
12s connect timeout while the relay path does the real work silently: cell WebSocket + E2EE
handshake + `pairing.provisionRelay` + `pairing.getEndpoints` + credential-bundle write
(`pre-profile-pairing-coordinator.ts:206-233`). The error copy even says "see log below for
where it stalled" (`app/pair-confirm.tsx:138`) — the log cannot show it.
4. Un-logged waits in the relay recovery loop: each of up to 3 attempts wraps a 5s director
resolution (`src/transport/mobile-relay-invite-director.ts:16`) plus full-jitter sleeps capped
at 100/200/400ms (`src/transport/pairing-relay-candidate.ts:58-59,70-71`) — worst case ~15s of
silence. (Correction: the preliminary "~3×2s of backoff" was wrong; the sleeps are small, the
director resolves dominate.) The relay E2EE layer itself has **no timers**: a pairing relay
request is unbounded except the screen's 25s cap (`app/pair-confirm.tsx:27`).
5. Pairing logs also never reach `connectionLogStore` (single producer:
`src/transport/client-context.tsx:132`), so the Connection Log screen shows nothing about a
pairing that just failed.
Post-pairing phase:
6. `pair-confirm` calls `closeHost(hostId)` then replaces to `/h/<id>`
(`app/pair-confirm.tsx:118-123`).
7. The destination re-acquires a client asynchronously (grey `'disconnected'` default during the
Keychain read, `src/transport/client-context.tsx:221`), then dials the **LAN endpoint first**
(`src/transport/host-logical-client.ts:12`) — amber for up to `CONNECT_TIMEOUT_MS = 12s`
(`src/transport/rpc-client.ts:126`) on a black-holed LAN.
8. Relay recovery cannot start earlier: `needsRecovery` treats `connecting`/`handshaking` as live
progress (`src/transport/mobile-relay-reconnect-controller.ts:73-75`), checked at supervisor
start and on every retry (`src/transport/mobile-endpoint-supervisor.ts:106,146`).
9. When the direct dial finally fails, the relay dial runs invisibly (same `migrateTo` mechanism
as S2) → green. Worst case with a director resolution failure and grace-credential retry:
~29–58s under the old session's labels.
10. The "Orca Relay" path label only renders once `state === 'connected'`
(`src/components/MobileHostCard.tsx:23,47`) — the user learns the phone is using relay only
after the wait ends, and `classifyConnection` has no relay-aware branch
(`src/transport/connection-health.ts:45-100`).
## 3. Anti-pattern sweep
### (a) Uncoordinated deep pushes into `/h` from outside the host stack
Coordinated today (mount-then-replace): host edit (`src/transport/host-edit-navigation.ts`) and
Tasks (`src/tasks/mobile-task-navigation.ts`). Note host-edit's predicate is weaker — it checks the
root route only and can fire its `replace` while the nested stack is still gated/unmounted; Tasks
proves the nested stack exists (`mountedHostStack`, `mobile-task-navigation.ts:53-70`).
Bare pushes remaining (host stack plausibly cold at each):
| Call site | Target | Cold scenario |
|---|---|---|
| `app/_layout.tsx:127` via `src/notifications/notification-routing.ts:58,64` | `/h/<id>/session/<wt>` or `/h/<id>` | **Coldest path** — `getLastNotificationResponse()` after launch from a killed app, plus the warm listener |
| `app/index.tsx:740-746` (Resume) | `/h/[hostId]/session/[worktreeId]` | Fixed by PR #12001 |
| `app/index.tsx:835` (Account-usage card) | `/h/<id>/accounts` | Home is the root route |
| `orca://` deep links (scheme in `app.json:9`, no linking config) | any `/h/...` | Default filesystem linking, zero coordination; `app/_layout.tsx:52-58` only intercepts pairing codes |
| `app/h/[hostId]/history/[worktreeId].tsx:15`, `pr/[worktreeId].tsx:16` | redirect to source-control | A cold deep link to these hits the same cold-navigator resolution first |
Shallow index-only pushes (`app/index.tsx:723,803`, onboarding/pair flows) don't need coordination.
No `<Link>`, `navigationRef`, or `router.navigate` anywhere in `mobile/`.
Related hazard: `HostProtocolGate` **unmounts the mounted HostStack mid-connect** for a first-visit
host — stack mounts while `connecting`, is replaced by a spinner when `status.get` goes in flight
(`statusPending` true only when connected: `src/transport/host-status-gates.ts:111`), then remounts
(`src/components/HostProtocolGate.tsx:34-44`). A deep navigation that resolved into the first mount
can be destroyed by the gate cycle.
### (b) Surfaces that render grey 'disconnected' during expected transients
Store defaults: every read API on the canonical store defaults to `'disconnected'` for a missing
entry — `getState` (`src/transport/client-context.tsx:221`), `useHostClient` seed/re-seed/unbound
fallback (`:343-345,377,393`). Exactly one call site defaults to amber instead: the home screen's
`hostStates[id] ?? 'connecting'` (`app/index.tsx:707,903`), whose reconciliation effect also
refuses to write `'disconnected'` for a never-tracked host (`:378-394`) — home already solved
locally what every other surface gets wrong.
The grey window is not one frame: `openEntry` awaits `loadHosts()` (a Keychain/SecureStore pass)
**before** inserting the store entry (`client-context.tsx:87-161`, insert at `:153`), so a cold
start or deep link into `/h/[hostId]` shows grey for the whole Keychain latency. The physical
client is not the cause — it already reports `'connecting'` synchronously by the time `connect()`
returns (`rpc-client.ts:302,967`). Additionally, `forceReconnect` deletes the entry then awaits the
async reopen (`client-context.tsx:201-218`), so **every Retry button drives the UI grey before
amber**.
Surfaces that show grey / "disconnected" copy for a healthy host during these transients (all via
`useHostClient`): host header dot (`app/h/[hostId]/index.tsx:819`); host toolbar + FAB disabled
(`:850-860,941-1000,1063-1078,1209`); the workspace list body renders **nothing at all** for
`disconnected` — `selectHostWorkspaceListState` falls through to `null`, not even a spinner
(`src/worktree/host-workspace-list-state.ts:17-24`); tasks header dot + "Connect to a host" empty
state (`app/h/[hostId]/tasks.tsx:8681,8661-8663`); session dot (no `verdict` prop at all,
`session/[worktreeId].tsx:4434`) and the literal "Disconnected" chip (`:4246-4255`); native-chat
composer lock (`src/session/MobileNativeChatView.tsx:427-432`); source-control / git-history /
diff-review / file-explorer / agent-history "Waiting for desktop…" states; the connection-log
screen prints the raw enum (`app/connection-log.tsx:113-117`); home's Resume/Accounts/Tasks/Quick
Action gates all read `=== 'connected'`; voice settings goes fully inert
(`app/voice-settings.tsx:50-55`). Counter-examples that behave well: home host card, the accounts
screen ("Connecting to {host}…" + cached snapshot, `app/h/[hostId]/accounts.tsx:366-370`).
Deliberate transients that publish `'disconnected'` while healthy work proceeds: relay suspend on
focus/network nudges (S2); background suspend (`src/transport/mobile-endpoint-supervisor.ts:123` —
correct per billing, but state stays grey through the entire foreground re-dial rather than
flipping to `'connecting'`); post-migration cleanup (`:251-253`); `closeHost` during the
pair-confirm handoff (`src/transport/client-context.tsx:83`); three open-failure paths
(`:107,114,135`).
Destructive companion pattern — state flips don't just recolor, they **wipe loaded data**:
`host-status-gates.ts:32,100-112` wipes cached host capabilities on every disconnect;
`tasks.tsx:2774-2800` resets the whole screen's hydration and force-closes ~15 sheets;
`session/[worktreeId].tsx:2043,2432-2439,3713-3716` clears diff comments/capability flags/agent
lists; the PR sidebar hides entirely (`src/session/use-mobile-pr-branch-context.ts:59-66` →
`use-mobile-pr-sidebar-controller.ts:113-118`); git history blanks rows on the **reconnect** branch
(`src/source-control/MobileGitHistoryList.tsx:63-68`); the repo cache survives disconnect but is
wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333`); the worktree cache is read
only at mount/hostId change (`app/h/[hostId]/index.tsx:129,330`), never on reconnect, so a >30s
entry means an empty remount.
Same bug class in a second enum: `workspaceSshStatusLabel` defaults a `null` SSH status to
"Disconnected" (`src/tasks/workspace-ssh-gate.ts:14-37`, rendered in `NewWorktreeModal.tsx:863`
and `tasks.tsx:10970`).
### (c) Invisible relay establishment phases
- `connectMobileRelayRpcSession` (normal relay connects) has **no onLog** — the entire relay
session lifecycle emits nothing (`src/transport/mobile-relay-rpc-session.ts:30-39`); the
supervisor logs only coarse post-hoc lines (`mobile-endpoint-supervisor.ts:185-188,261`).
- `migrateTo` structurally discards the dialing session's `connecting`/`handshaking` states
(`src/transport/stable-logical-rpc-client.ts:182-223,267-299`).
- Direct→relay upgrade path has no sink at all (`src/transport/mobile-endpoint-lifecycle.ts:49-58`,
`mobile-relay-direct-upgrade-controller.ts:19`).
- Pairing relay path fully silent (S3 above); pairing logs never reach `connectionLogStore`.
- Path label ("Orca Relay") gated on `connected` (`src/components/MobileHostCard.tsx:47`);
`classifyConnection` collapses `connecting`/`handshaking`/`reconnecting` and has no relay branch.
- Regression suite for connect-label stalls exists for the direct path only
(`src/transport/cellular-connecting-label-stall.test.ts`); no relay equivalent.
## 4. Fix plan
Ordered by felt-flakiness-removed per unit risk. All fixes are phone-local; none change the wire
protocol, so every old/new phone × old/new desktop pairing keeps working unless noted.
### F0 (S1, quick win) — land PR #12001 ✅ MERGED
Squash-merged to main as `7948e46db855` (2026-08-04) after validation: CI fully green; drift check
against current main clean (only overlap, #12575, touches different regions of `app/index.tsx` and
auto-merges); full mobile suite (411 files, 3110 tests) passed on a local merge of main into the PR
branch. The PR routes Resume through the shared mount-then-replace mechanism
(`src/navigation/host-stack-navigation.ts`) and adds a source-guard test against reintroducing the
bare push. This branch has since been fast-forwarded onto that merge, and F4 builds on the
extracted module.
Backward compat: navigation-only, none.
Residuals tracked as F4/F7/F8/F9.
### F1 (S2, quick win) — stop suspending a healthy relay on focus ✅ IMPLEMENTED (this branch)
Approach: split the nudge reasons that today all funnel into `setForeground(true)`:
- Screen-focus nudge (`app/h/[hostId]/index.tsx:515`): must not suspend. For the relay path, either
no-op (state changes already drive the UI) or run a cheap liveness probe (an RPC with a short
budget) and only enter recovery on failure — mirroring the direct path's activity probe.
- Network-change / app-resume nudges: keep half-open protection, but **verify by replacement**
instead of break-before-make: call the existing `recoverRelay(forceReplacement = true)` path
(proven by lease rotation) so `migrateTo` swaps sessions with the dot staying green; only if the
replacement dial fails, fall back to `suspendActiveRelay` so a genuinely dead link stops lying
green and the retry loop re-arms (plain `recoverRelay` early-returns while the stale state is
still `'connected'`, so the fallback suspend is required for convergence).
Files: `src/transport/mobile-relay-reconnect-controller.ts` (`handleForeground`),
`src/transport/mobile-endpoint-supervisor.ts` (thread a nudge reason; failure-path suspend),
`src/transport/mobile-endpoint-lifecycle.ts`, `src/transport/host-logical-client.ts` (reason-tagged
`notifyForeground`), optionally `src/transport/rpc-client.ts` type for the reason parameter.
Risk: PEER_DROPPED/LIMIT_EXCEEDED churn if replacement dials overlap — reuse the existing
`shouldDefer` cooldown; billed duplicate socket for the overlap window (lease rotation already
accepts this). Half-open regression risk is covered by the fallback suspend.
Tests: split `mobile-endpoint-supervisor.test.ts:~160-197` into (focus nudge → no suspend, dot
stays green) and (network handoff → replacement dial; failure → suspend + cooldown). Keep the
background-suspend test unchanged.
### F2 (S2/S3, quick win) — unify mount defaults to 'connecting' ✅ IMPLEMENTED (this branch)
Approach: `getState(hostId)` returns `'connecting'` when the host is known (primed profile or
pending open) and no entry exists yet; `'disconnected'` only for unknown/closed hosts. Aligns every
host screen with home's `?? 'connecting'`. Two companion changes in the same class:
- `forceReconnect` should notify `'connecting'` (or insert a placeholder entry) instead of leaving
the deleted-entry window grey (`src/transport/client-context.tsx:201-218`) — every Retry button
currently drives the UI grey before amber.
- Optionally have `openEntry` insert a `'connecting'` placeholder before the Keychain read so the
cold-start gap (`client-context.tsx:87-153`) is amber too.
**Required interaction fix**: `app/h/[hostId]/index.tsx:738-741` falls back to
`lastKnownWorktrees` only for `disconnected | reconnecting | auth-failed`; `connecting`/
`handshaking` fall through to the live (empty on fresh mount) array. Flipping the default without
extending that predicate would silently disable the stale-list fallback and blank the list —
extend it to every not-connected state (or key it on "no live fetch has succeeded this mount").
Files: `src/transport/client-context.tsx` (`getState`, `useHostClient`, `forceReconnect`,
`openEntry`), `app/h/[hostId]/index.tsx` (fallback predicate),
`src/worktree/host-workspace-list-state.ts` (render a spinner for the not-connected states instead
of `null`).
Risk: a permanently unreachable host now shows amber briefly before the verdict system escalates —
acceptable; `classifyConnection` already owns escalation. Audit the §3(b) "wipe" sites for any that
key on `'disconnected'` specifically.
Tests: `client-context.test.ts` known-vs-unknown host defaults + forceReconnect state sequence;
host screen test for the stale-list fallback under `'connecting'`.
### F3 (S3, quick win) — give the pairing relay path a log sink ✅ IMPLEMENTED (this branch)
Approach: add an optional `onLog` to `connectMobileRelayForPairing`,
`createRecoveringPairingRelayCandidate`, and `resolvePairingInviteThroughDirector`; thread
`connectOptions.onLog` from the coordinator to the relay candidate; emit phase lines ("relay:
resolving director…", "relay: cell connected", "relay: E2EE handshake…", "relay: authenticated",
"relay: installing credential…"). Optionally also append pairing logs into `connectionLogStore`
under the resolved host id so the Connection Log screen has a record post-pairing.
Files: `src/transport/mobile-relay-physical-client.ts`, `pairing-relay-candidate.ts`,
`mobile-relay-invite-director.ts`, `pre-profile-pairing-coordinator.ts`.
Risk: none (additive, phone-local). Old desktops: unaffected — logging only.
Tests: coordinator test asserting relay-path log entries arrive through `connectOptions.onLog`;
extend `pairing-relay-candidate.test.ts` for per-attempt lines.
### F4 (S1 class, quick win after F0) — coordinate the remaining bare deep pushes
Approach: route notification taps (`app/_layout.tsx:127` + `src/notifications/notification-routing.ts`)
and the Account-usage card (`app/index.tsx:835`) through `src/navigation/host-stack-navigation.ts`
once #12001 lands; migrate host-edit onto the same stricter mechanism (#12001's own noted
follow-up). `orca://` deep links can follow later via a route-level guard.
Risk: notification cold-start ordering (push before root nav ready) — the mechanism already
tolerates that by waiting for state commits.
Tests: reuse the `host-stack-navigation.test.ts` harness for a notification-shaped target.
### F5 (S2/S3, deeper) — make relay dials visible through `migrateTo`
Approach: while the logical client is `suspended`/`'disconnected'`, have `migrateTo` forward the
dialing session's state publishes (`connecting`/`handshaking`) to `publishState`, unbinding on
success (normal bind takes over) or failure (restore `'disconnected'`). Guard: never downgrade a
still-`'connected'` previous session (make-before-break migrations must stay green). Follow-on UI:
show the path being dialed ("Connecting via Orca Relay…") by exposing the pending path, and let
`MobileHostCard`/`classifyConnection` render it while not yet connected.
Files: `src/transport/stable-logical-rpc-client.ts` (+ its test), `src/transport/connection-health.ts`,
`src/components/MobileHostCard.tsx`, `src/transport/mobile-connection-path-label.ts`.
Risk: state-ordering regressions in the pinned stable-client and connecting-label suites; keep the
forwarding strictly gated on suspended/disconnected.
Tests: add a relay-path analog of `cellular-connecting-label-stall.test.ts`; stable-client cases:
forwarded states during suspended dial, no forwarding during live-session replacement, failure
restores `'disconnected'`.
### F6 (S3, deeper) — happy-eyeballs relay start post-pairing
Approach: when a relay credential bundle exists and the direct dial has not authenticated within a
short grace (2–3s), start the relay dial in parallel instead of waiting for the 12s direct failure;
first authenticated path wins via the existing `migrateTo`/hysteresis machinery. Scope initially to
the first connect after pairing (or hosts whose last success was relay) to avoid pointless relay
sockets on healthy LANs.
Files: `src/transport/mobile-endpoint-supervisor.ts` (start/needsRecovery gating), possibly a
phone-local `HostProfile` hint field (no protocol impact; old desktops never present `relay`, so
the path is naturally guarded).
Risk: racing direct is exactly what `needsRecovery`'s design avoids — needs the mutex
(`operationInFlight`) audit and dwell/hysteresis respect; billed relay data on LANs if scoped too
broadly.
Tests: supervisor fake-timer cases: black-holed LAN converges in ~3-5s; healthy LAN never opens a
relay socket; relay loser closed after direct wins.
### F7 (S1, deeper) — catalog-validate resume targets + not-found bounce
Approach: (1) on Resume tap with the host connected, validate the target against the freshest
`worktree.ps` result (not the snapshot-seeded cache); if absent, open the host index instead.
(2) In the session screen, once connected and the catalog is known, bounce unknown `worktreeId`s
(exempting `folder:` and floating-workspace sentinels, `app/h/[hostId]/session/[worktreeId].tsx:852-854`)
to the host index with a notice. (3) Use the validating reader in
`src/worktree/last-visited-worktree-repo.ts` on home instead of the raw `JSON.parse`
(`app/index.tsx:315-322`), and import the storage-key constant at both literal call sites.
Risk: false bounces during slow catalog loads — only bounce on a *confirmed* fresh catalog miss.
Tests: repo tests for the validating reader on home; session-screen bounce cases incl. sentinel
exemptions.
### F8 (S1 aggravator, cheap) — stop the Resume/Tasks layout shift
Approach: reserve the Resume card's slot (fixed-height placeholder or render-below-Tasks) so its
late arrival cannot move the Tasks card under the thumb; alternatively render the card immediately
from the snapshot in a disabled state until the host connects.
Files: `app/index.tsx` footer.
Risk: none.
Tests: render test asserting footer order/height stability across `resumeWorktree` arrival.
### F9 (S1 class, deeper) — HostProtocolGate should not unmount a mounted stack
Approach: once the HostStack has mounted for a host, keep it mounted and overlay the pending
spinner instead of replacing children, preserving in-flight nested navigation; keep the hard
replace only for the `blocked` verdict.
Files: `src/components/HostProtocolGate.tsx`.
Risk: the gate exists so child routes don't call too-new RPCs while compatibility is unknown — an
overlay must still block interaction until resolved; verify child mount effects don't fire gated
RPCs pre-verdict before choosing overlay vs. current behavior.
Tests: gate test asserting no unmount across `statusPending` for an already-mounted host.
### F10 (S2 class, deeper) — stop wiping loaded data on transient state flips
Approach: audit the §3(b) destructive-clear sites and make each preserve data across a
not-`'connected'` blip, clearing only on host change or explicit sign-out. Top offenders by felt
impact: git history blanking rows on the reconnect branch
(`src/source-control/MobileGitHistoryList.tsx:63-68` — refetch without `setRows(null)`); the repo
cache wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333` — keep last-good on
error); the diff-review "ready-state preserved" branch that is dead code because it sits after the
early return (`src/session/use-mobile-diff-review-controller.ts:85-89`); host capability wipe
(`src/transport/host-status-gates.ts:32`); the tasks-screen full re-hydration
(`app/h/[hostId]/tasks.tsx:2774-2800`); worktree-cache re-read on reconnect, not only at mount
(`app/h/[hostId]/index.tsx:129,330`).
Risk: showing stale data as if live — pair each preservation with the existing staleness verdicts
rather than inventing new indicators. The in-repo reference pattern is
`src/worktree/home-worktree-info.ts:27-47`: counts older than a 10min TTL render as
"Last known: N worktrees" instead of being dropped, and `markHomeWorktreeCatalogUnavailable`
preserves proven counts across a failed refresh, flagging only `staleCounts`.
Tests: per-surface "data survives disconnect→reconnect" cases; F1 largely removes the *trigger*
(suspend blips), so this is hardening, not the primary fix.
## 5. Backward compatibility (old/new phone × old/new desktop)
- Every fix above is phone-app-local; no RPC methods, close codes, credential formats, or pairing
steps change. Old phones against any desktop are untouched (they don't have the code).
- New phone + old desktop without relay support: `host.relay` is absent → F1/F5/F6 relay paths
never activate; pairing keeps the existing `method_not_found` downgrade
(`src/transport/pre-profile-pairing-coordinator.ts:210-217`); F3 logging is inert (no relay
candidate is created).
- New phone + old desktop with relay: all paths use existing RPCs (`status.get`,
`pairing.provisionRelay`, resume confirm) — no new calls introduced. F1's replacement dial reuses
the same resume-credential flow lease rotation already exercises against production desktops.
- F6's profile hint (if added) is a phone-local persisted field; absent values behave as today.
## 6. Constants appendix (verified)
| Constant | Value | Where |
|---|---|---|
| Direct connect timeout | 12s | `src/transport/rpc-client.ts:126` |
| Direct handshake timeout | 5s | `rpc-client.ts:127` |
| Direct reconnect ladder | 0.5→60s, give up 12, trickle 90s | `rpc-client.ts:113-117` |
| `migrateTo` auth timeout | 12s | `src/transport/stable-logical-rpc-client.ts:182` |
| Relay backoff | 250ms floor, 500ms base, 30s ceiling, full jitter | `src/transport/mobile-relay-retry-delays.ts:3-5` |
| Host-offline relay retry | 5–15s | `mobile-relay-retry-delays.ts:7-8` |
| Gate reprobe cadence | 60s→15min | `mobile-relay-retry-delays.ts:13-14` |
| Director resolve timeout | 5s (invite & resume) | `mobile-relay-invite-director.ts:16`, `mobile-relay-resume-director.ts:21` |
| Pairing relay recovery | ≤3 attempts × (5s director + ≤100/200/400ms jitter) | `src/transport/pairing-relay-candidate.ts:42,58-71` |
| Pairing overall cap | 25s | `app/pair-confirm.tsx:27` |
| Relay E2EE layer timers | none | `mobile-relay-e2ee-link.ts`, `mobile-e2ee-v2-*.ts` |
| Worktree cache TTL | 30s from write/seed | `src/cache/worktree-cache.ts:12` |
| Direct activity probe (foreground) | detects half-open ≤8s | `rpc-client.ts:1119-1124` |
+43 -1
View File
@@ -155,6 +155,8 @@ display `:99` when no display exists:
Description=Orca runtime server
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
@@ -165,6 +167,7 @@ ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.
StandardOutput=journal
StandardError=journal
Restart=on-failure
RestartPreventExitStatus=3
RestartSec=5
[Install]
@@ -174,6 +177,19 @@ WantedBy=multi-user.target
Replace `100.64.1.20` with the LAN, Tailscale, tunnel, or public hostname that
clients should use.
Exit status `3` means another process already owns this userData profile, so
`RestartPreventExitStatus=3` stops the unit instead of retrying a launch that
cannot succeed. Any other permanent startup fault is capped at 5 starts per
5 minutes; systemd's defaults (10s window, 5 starts) can never trip at
`RestartSec=5`, which is how one bad launch could restart thousands of times.
The start limit counts operator-initiated starts too, so once it trips systemd
refuses a plain `systemctl start` until the 5-minute window rolls over. Run
`sudo systemctl reset-failed orca-serve.service` first to clear it — the
[Upgrade](#upgrade-steps) and [Roll back](#roll-back) scripts already do.
On systemd older than 230 those two directives are spelled
`StartLimitInterval=`/`StartLimitBurst=` and belong in `[Service]`; Ubuntu
20.04, Orca's oldest supported base, ships systemd 245.
Enable the service:
```bash
@@ -228,6 +244,8 @@ Then add the display dependency to the Orca service:
Description=Orca runtime server
After=network-online.target orca-xvfb.service
Wants=network-online.target orca-xvfb.service
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
@@ -237,6 +255,7 @@ Environment=DISPLAY=:99
Environment=LIBGL_ALWAYS_SOFTWARE=1
ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20
Restart=on-failure
RestartPreventExitStatus=3
RestartSec=5
[Install]
@@ -415,6 +434,8 @@ recover_failed_upgrade() {
sudo rm -f /opt/orca/orca-linux.AppImage.recovering \
/opt/orca/VERSION.recovering
if ((recovery_ok)); then
# A tripped StartLimitBurst refuses a plain start
sudo systemctl reset-failed orca-serve.service || true
sudo systemctl start orca-serve.service || true
else
echo 'Upgrade recovery failed; service remains stopped' >&2
@@ -482,6 +503,8 @@ sudo mv "$ORCA_ROLLBACK_NEW" "$ORCA_ROLLBACK"
ORCA_BINARY_PROMOTED=1
sudo mv -f /opt/orca/orca-linux.AppImage.new /opt/orca/orca-linux.AppImage
sudo mv -f /opt/orca/VERSION.new /opt/orca/VERSION
# Clears a start-limit hit left by the version being replaced
sudo systemctl reset-failed orca-serve.service
sudo systemctl start orca-serve.service
ORCA_SERVICE_STOPPED=0
trap - EXIT
@@ -614,6 +637,8 @@ restart_after_rollback_error() {
fi
fi
if ((recovery_ok)); then
# A tripped StartLimitBurst refuses a plain start
sudo systemctl reset-failed orca-serve.service || true
sudo systemctl start orca-serve.service || true
else
echo 'Rollback recovery failed; service remains stopped' >&2
@@ -714,6 +739,8 @@ if ((ORCA_ROLLBACK_HAS_VERSION)); then
else
sudo rm -f /opt/orca/VERSION
fi
# The crash-looping build you are rolling back from tripped StartLimitBurst
sudo systemctl reset-failed orca-serve.service
sudo systemctl start orca-serve.service
ORCA_SERVICE_STOPPED=0
sudo rm -rf -- "$ORCA_RESTORE"
@@ -805,9 +832,24 @@ refuse to run there and print the command to run on the machine you want.
`orca` user and that `/opt/orca` is readable by that user.
- Clients cannot connect: make sure `--pairing-address` is an address reachable
from the client, and make sure firewalls allow the selected `--port`.
- Journal shows `Another Orca instance is already running for this userData
profile` and the unit exits `3`: another process already owns the profile, so
`RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the
owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or
keep it and leave the unit down), then run
`sudo systemctl reset-failed orca-serve && sudo systemctl start orca-serve` —
`reset-failed` clears the failed state and any start-limit counter. If no owner
exists, the lock is stale (Chromium recorded a pid that
has since been reused): remove `SingletonLock` and `SingletonSocket` from the
userData directory and start again. If an earlier crash-loop already leaked
AppImage mounts, list them with `findmnt -rn -t fuse.orca-linux.AppImage` and
release only the ones with no live owner using `fusermount -uz <target>` (or
`umount -l <target>`), leaving the running instance's mount alone.
- Service crash-loops right after an upgrade: use [Roll back](#roll-back) with
the pre-upgrade `.ready` bundle. Do not rerun the upgrade first; doing so would
make the crashing version the next rollback binary.
make the crashing version the next rollback binary. The loop trips
`StartLimitBurst`, so any manual `systemctl start` outside that script needs
`sudo systemctl reset-failed orca-serve.service` first.
- Diagnosing other missing libraries: extract the AppImage without launching it
with `./orca-linux.AppImage --appimage-extract`, then run
`ldd squashfs-root/orca` to list any shared libraries the host is missing.
+101
View File
@@ -0,0 +1,101 @@
# Remote wire compatibility
Orca's remote-server feature pairs a desktop client to a remote Orca runtime, and
users update the two independently. **Mixed versions are the normal state**, not an
edge case. This page is the contract for changing anything a paired client and host
exchange: the runtime RPC envelope, the terminal binary stream, and the content
either side publishes over them.
`src/shared/protocol-version.ts` says when to bump `RUNTIME_PROTOCOL_VERSION`. This
page covers the changes that do _not_ bump it and are therefore easy to get wrong.
## Rule 1 — a new optional JSON field on an existing frame is safe
Every JSON payload is parsed with a decoder that ignores unknown keys (zod `.strip()`
on RPC params, `JSON.parse` on stream frames). An older peer that has never heard of
the field simply does not read it.
Safe:
```ts
// host adds a field; older clients ignore it
encodeTerminalStreamJson({ kind, cols, rows, hiddenOutputReason })
```
**The field is safe only for as long as every reader treats it as optional.** The
moment a newer client _requires_ it, that client is broken against every host that
predates the field — which is the same defect as removing a field, just discovered
later. If new behavior depends on the field being present, that is Rule 2: negotiate
it, or make the reader fall back.
## Rule 2 — a new stream opcode is NOT safe; negotiate it
`decodeTerminalStreamFrame` returns `null` for an opcode it does not know, and
`runtime-rpc.ts` drops that frame without an error:
```ts
const frame = decodeTerminalStreamFrame(bytes)
if (!frame) {
return // silently dropped — the sender never learns
}
```
So a new opcode sent to an older peer does not fail loudly. It vanishes, and the
feature behind it appears to hang. Input sent under a new opcode is swallowed.
A new opcode must be announced in the subscribe handshake and sent only after the
peer confirms it. The existing pattern is `SetOutputPaused` (opcode 16):
- the client advertises support in the `Subscribe` frame's `capabilities`;
- the host echoes `capabilities: { outputPause: 1 }` on the `subscribed` event;
- the client sends opcode 16 only after that echo (`stream.supportsOutputPause`);
- the host only acts on opcode 16 when it negotiated it (`stream.supportsOutputPause`).
Reuse an existing opcode with a new optional payload field (Rule 1) whenever that
expresses the change; reach for a new opcode only when framing genuinely differs.
Opcode numbers are permanent. See the `Ack = 13` and `ClaimViewport = 14` comments
in `src/shared/terminal-stream-protocol.ts` for why a shipped number cannot be
reused even if the feature behind it is removed.
## Rule 3 — changing what the host publishes breaks old clients with no wire change
The frame shape can be untouched and the skew still real, because clients react to
frame _content_. PR #12641 is the worked example: the host stopped synthesizing a
finished agent status, and clients running older code saw different content in an
identical frame.
Treat these as wire changes even though nothing in the codec moves:
- a field the host stops populating (an old client reading it now sees `undefined`);
- a value whose meaning, units, or nullability changes;
- content the host stops synthesizing, trims, or starts deriving from a new source;
- a frame the host stops sending, or starts sending, on an existing path.
If old clients cannot interpret the new projection correctly, gate it behind a
runtime capability the same way Rule 2 gates an opcode.
## Enforcement
`tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts` runs the real
host RPC methods and the real renderer multiplexer from two builds against each
other — current working tree against the newest release tag, in both skew
directions — over one scripted terminal journey (subscribe, input, hide/reveal
snapshot, drop, reconnect).
Run it with:
```bash
pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
```
It fails when a frame is refused by the receiving build's decoder (Rule 2), when the
observed frame sequence changes (Rule 3), or when published snapshot content or
negotiated capabilities differ from the contract. Adding an optional field keeps it
green (Rule 1); making a client depend on that field turns the new-client/old-host
pairing red.
The harness covers the terminal stream only. It does **not** cover the session-tab
sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, or
the relay transport. A change on those paths still needs its own reasoning against
the three rules above.
+86
View File
@@ -0,0 +1,86 @@
# Windows setup-runner shell
On native Windows, Orca writes the `orca.yaml` setup script (and the issue command) to a generated
runner file and types a launch command into a terminal. The runner is a **`.cmd` batch file by
default**, exactly as it has been since setup hooks shipped.
A script opts into bash by starting with a `#!` interpreter line:
```yaml
scripts:
setup: |
#!/usr/bin/env bash
[ -f .env ] || cp .env.example .env
pnpm install
```
Without that line the script keeps running under `cmd.exe`:
```yaml
scripts:
setup: |
copy .env.example .env
xcopy /E assets dist
```
## Why the script declares it, not the terminal preference
`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the
language a project's setup script is written in. Deriving the runner from it had two consequences:
- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`,
`xcopy`, `set VAR=value`, and `if errorlevel 1` stopped working.
- Two people on the same repo got different interpreters for the same `orca.yaml`, so no project
could write a setup script that worked for all of its Windows contributors.
A `#!` line is per-project, explicit, and identical for everyone who checks the repo out.
The same rule applies to the per-user setup command in **Settings → repository hooks**
(`repo.hookSettings.scripts.setup`): it is merged into the same script that reaches the runner, so a
POSIX one-liner stored there needs its own `#!` line to run under bash on Windows.
## What the `#!` line does and does not select
The generated runner is always executed by bash (`bash <runner>`; Git Bash on native Windows), on
every platform. The `#!` line therefore does two things:
- It declares the script is written for a POSIX shell, which is what selects the bash runner.
- Its option flags are replayed with `set`, so `#!/usr/bin/env -S bash -euo pipefail` really does
get `pipefail`. Without that replay the flags would be silently dropped, because `bash <runner>`
never parses the interpreter line. Only the flags `set` itself accepts
(`[--abefhkmnptuvxBCHP] [-o option]`) are replayed; invocation-only ones such as `-l` are
dropped, because `set -l` exits 2 and would abort the runner before its first line.
The interpreter name itself is not honored beyond "is this a POSIX shell": `#!/bin/sh` and
`#!/bin/zsh` scripts run under bash, exactly as they already did on macOS and Linux.
## Requirements for the bash runner
A `#!` line only takes effect when Orca can actually launch bash from the configured terminal — the
terminal shell must resolve to Git Bash (`resolveWindowsGitBashShellPath`). The generated runner
uses MSYS `/c/...` paths, which Cygwin and the WSL shim do not accept, and the launch command is
typed into whatever shell the terminal opened with.
When bash is not available (a PowerShell/cmd terminal, or an SSH-to-Windows host, which always uses
the remote's `.cmd` runner) the `#!` script is **not** executed under cmd. The generated `.cmd`
runner prints why and exits 1, because running the interpreter-agnostic prefix of a bash script
(`pnpm install`, `git submodule update`) and only failing at the first bash-only line leaves a
half-set-up worktree that looks finished.
## Launching a `.cmd` runner from a Git Bash terminal
The runner format and the shell that types the launch command are independent: a Git Bash terminal
with a batch-syntax setup script gets a `.cmd` runner launched from a bash pane. `cmd.exe /c
"C:\..."` cannot be used there — MSYS rewrites the bare `/c` switch into a drive path, so cmd opens
interactively and the runner never executes (issue #6896). Those launches reuse the PowerShell
`ProcessStartInfo` launcher (`buildWindowsCmdRunnerDelayedLaunchCommand`), which carries the switch
and the runner path outside the command line. `WorktreeSetupLaunch.shell` therefore describes the
launching pane; the runner file's `.cmd`/`.sh` extension describes the format.
The `wait-for-setup` gate follows the same split. The pane types the gate and already quoted the
agent startup command for itself, so a `.cmd` runner launched from a Git Bash pane still gets the
bash gate — PowerShell's `Invoke-Expression` cannot parse POSIX `'\''` escaping. The gate wraps the
same `ProcessStartInfo` launcher, so the batch runner is never handed to bash.
WSL worktrees and non-Windows platforms are unaffected: they always use the bash runner. SSH hosts
choose their runner from the remote path format, never from local Windows preferences.
+8 -2
View File
@@ -3,13 +3,14 @@ import { resolve } from 'node:path'
import { defineConfig, type UserConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { createBootstrapFatalExitBanner } from './build-plugins/bootstrap-fatal-exit-banner'
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner'
import { createPlainNodeEntryGuardPlugin } from './config/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',
'psl',
// Why: Windows NSIS deploys app.asar before external resources; bootstrap must
// not race the later resources/node_modules copy.
'zod'
@@ -216,6 +217,11 @@ export const electronViteConfig: UserConfig = {
'session-scanner-opencode-sqlite-worker-entry': resolve(
'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts'
),
// Why: libuv spawns processes inline on the calling loop, so the port
// scan's probe commands run on a worker thread instead of the UI one.
'port-scan-command-worker-entry': resolve(
'src/main/ports/port-scan-command-worker-entry.ts'
),
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
+1
View File
@@ -1,5 +1,6 @@
node_modules/
src/terminal/terminal-webview-engine.generated.ts
src/components/pr-sidebar/mermaid-webview-engine.generated.ts
.expo/
dist/
/android/
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.36",
"version": "0.0.41",
"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": 9
"versionCode": 11
},
"plugins": [
"expo-router",
+17 -10
View File
@@ -8,8 +8,9 @@ import * as Linking from 'expo-linking'
import { colors } from '../src/theme/mobile-theme'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationPath } from '../src/notifications/notification-routing'
import { loadHosts } from '../src/transport/host-store'
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
import { loadHostCatalog } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
@@ -34,6 +35,7 @@ Notifications.setNotificationHandler({
export default function RootLayout() {
const router = useRouter()
const openNotificationRoute = useOpenNotificationRoute()
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
@@ -68,6 +70,7 @@ export default function RootLayout() {
return () => sub.remove()
}, [router])
// ─── Notification tap routing ───
// Why: iOS delivers local notification taps through expo-notifications,
// not Linking. Route both cold-start and warm-start responses to the host
// and worktree that scheduled the notification.
@@ -91,10 +94,13 @@ export default function RootLayout() {
}
}
async function getNavigationPath(data: unknown): Promise<string | null> {
const hosts = await loadHosts().catch(() => null)
return getNotificationNavigationPath(data, {
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined
async function getNavigationTarget(data: unknown) {
const hosts = await loadHostCatalog().catch(() => null)
return getNotificationNavigationTarget(data, {
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined,
credentialStatusByHostId: hosts
? new Map(hosts.map((host) => [host.id, host.credentialStatus]))
: undefined
})
}
@@ -118,13 +124,13 @@ export default function RootLayout() {
}
}
const path = await getNavigationPath(response.notification.request.content.data)
const target = await getNavigationTarget(response.notification.request.content.data)
clearLastNotificationResponse()
if (disposed) {
return
}
if (path) {
router.push(path)
if (target) {
openNotificationRoute(target)
}
}
@@ -140,7 +146,8 @@ export default function RootLayout() {
disposed = true
sub.remove()
}
}, [router])
}, [openNotificationRoute])
// ─── End notification tap routing ───
// Why: hide the native splash only once the navigation Stack has been laid
// out — this is the earliest moment the user will see actual app content.
+61 -46
View File
@@ -1,14 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import {
View,
Text,
StyleSheet,
SectionList,
Pressable,
ActivityIndicator,
Alert,
RefreshControl
} from 'react-native'
import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
import {
@@ -64,6 +55,8 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
@@ -74,6 +67,7 @@ import { leaveHostRoute } from '../../../src/host-route-exit'
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
import {
createInitialHostRouteActionState,
hostNewWorktreeSessionRoute,
resolveHostRouteActionState,
setHostRouteNewWorktreeVisible
} from '../../../src/host-route-action-state'
@@ -95,6 +89,7 @@ import { useWorkspaceSections } from '../../../src/worktree/use-workspace-sectio
import { getMobileWorkspaceLineageGroupKey } from '../../../src/worktree/mobile-workspace-lineage'
import { areWorktreeListsEqual } from '../../../src/worktree/worktree-list-snapshot'
import { WorktreeCatalogSnapshotClient } from '../../../src/worktree/worktree-catalog-snapshot-client'
import { HostWorkspaceListStates } from '../../../src/worktree/host-workspace-list-states'
import { repoColor } from '../../../src/worktree/repo-color'
import {
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
@@ -125,9 +120,12 @@ export function HostScreen({
action: actionProp,
onHideSidebar
}: HostScreenProps = {}) {
const params = useLocalSearchParams<{ hostId: string; action?: string }>()
const params = useLocalSearchParams<{ hostId: string; action?: string; notice?: string }>()
const hostId = hostIdProp ?? params.hostId
const action = actionProp ?? params.action
const [dismissedNotice, setDismissedNotice] = useState<string | null>(null)
const noticeParam = params.notice?.trim()
const routeNotice = visibleHostRouteNotice(embedded, noticeParam, dismissedNotice)
const router = useRouter()
const pathname = usePathname()
const insets = useSafeAreaInsets()
@@ -154,6 +152,9 @@ export function HostScreen({
const forceReconnectHost = useForceReconnect()
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
// Why (STA-3123): error code of the last failed worktree.ps, so a broken catalog
// path renders as a failure instead of an empty host. Cleared on the next success.
const [catalogError, setCatalogError] = useState<string | null>(null)
// Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll.
const [optimisticActiveWorktreeId, setOptimisticActiveWorktreeId] = useState<string | null>(null)
// One tick drives every visible agent row's relative timestamp.
@@ -169,7 +170,8 @@ export function HostScreen({
const [filters, setFilters] = useState<FilterState>({
filterRepoIds: new Set(),
hideSleeping: false,
hideDefaultBranch: false
hideDefaultBranch: false,
alwaysShowDefaultBranch: true
})
const [groupMode, setGroupMode] = useState<MobileGroupMode>('repo')
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
@@ -200,6 +202,7 @@ export function HostScreen({
sortMode: 'recent',
hideSleeping: false,
hideDefaultBranch: false,
alwaysShowDefaultBranch: true,
filterRepoIds: [],
collapsedGroups: [],
workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES
@@ -211,6 +214,7 @@ export function HostScreen({
sortMode,
hideSleeping: filters.hideSleeping,
hideDefaultBranch: filters.hideDefaultBranch,
alwaysShowDefaultBranch: filters.alwaysShowDefaultBranch !== false,
filterRepoIds: [...filters.filterRepoIds],
collapsedGroups: [...collapsedGroups],
workspaceStatuses
@@ -227,7 +231,8 @@ export function HostScreen({
setFilters({
filterRepoIds: new Set(next.filterRepoIds),
hideSleeping: next.hideSleeping,
hideDefaultBranch: next.hideDefaultBranch
hideDefaultBranch: next.hideDefaultBranch,
alwaysShowDefaultBranch: next.alwaysShowDefaultBranch
})
}, [])
@@ -239,6 +244,9 @@ export function HostScreen({
if (!client) {
return
}
// alwaysShowDefaultBranchWorkspace is deliberately absent: mobile reads it
// but has no toggle, so echoing its local default would silently revert a
// desktop opt-out on the first filter tap before ui.get lands (#8873).
const payload: WorkspaceViewSettings = {
groupBy: groupModeToDesktop(next.groupMode),
sortBy: next.sortMode,
@@ -326,6 +334,7 @@ export function HostScreen({
repoMetadataFetchedAtRef.current = 0
// Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId.
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
setCatalogError(null)
if (freshCache) {
setWorktrees(freshCache)
setLastKnownWorktrees(freshCache)
@@ -427,17 +436,27 @@ export function HostScreen({
const requestHostId = hostId
try {
const pendingCatalog = await worktreeCatalogRef.current.fetch(requestClient, requestHostId)
const fetched = await worktreeCatalogRef.current.fetch(requestClient, requestHostId)
if (clientRef.current !== requestClient || hostId !== requestHostId) {
return
}
if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) {
return
}
// Why (STA-3123): a failed catalog request must not pass for "0 worktrees";
// surface it so a broken remote host is diagnosable instead of looking empty.
if (fetched.kind === 'request_failed') {
setCatalogError(fetched.code)
return
}
if (fetched.pending.admission.kind === 'invalid') {
setCatalogError('invalid_response')
}
// Why: unchanged responses still yield the confirmed rows, so every poll reasserts
// host truth over optimistic local edits regardless of payload size.
const confirmed = worktreeCatalogRef.current.admit(pendingCatalog)
const confirmed = worktreeCatalogRef.current.admit(fetched.pending)
if (confirmed) {
setCatalogError(null)
// Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path.
setWorktrees((current) =>
areWorktreeListsEqual(current, confirmed) ? current : confirmed
@@ -448,7 +467,7 @@ export function HostScreen({
setWorktreesLoaded(true)
// Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list.
if (hostId) {
setCachedWorktrees(hostId, confirmed)
setCachedWorktrees(hostId, confirmed, { proven: true })
}
// Drop the optimistic active override once the host reports it active, so later desktop changes win.
setOptimisticActiveWorktreeId((pending) =>
@@ -486,6 +505,9 @@ export function HostScreen({
}
} catch {
// Will retry on reconnect
if (clientRef.current === requestClient && hostId === requestHostId) {
setCatalogError('network_error')
}
} finally {
fetchWorktreesInFlightRef.current = false
}
@@ -496,7 +518,8 @@ export function HostScreen({
useFocusEffect(
useCallback(() => {
// Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff).
clientRef.current?.notifyForeground()
// 'focus' keeps a healthy relay green — probe, never suspend (S2 grey blink).
clientRef.current?.notifyForeground('focus')
}, [])
)
@@ -718,10 +741,9 @@ export function HostScreen({
)
const displayWorktrees = useMemo(() => {
const base =
connState === 'disconnected' || connState === 'reconnecting' || connState === 'auth-failed'
? lastKnownWorktrees
: worktrees
// Why: live `worktrees` is authoritative only while connected; under the amber
// mount default, connecting/handshaking must keep the pre-reconnect list too.
const base = connState === 'connected' ? worktrees : lastKnownWorktrees
if (sleptIds.size === 0 && optimisticActiveWorktreeId === null) {
return base
}
@@ -1084,6 +1106,14 @@ export function HostScreen({
/>
)}
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
{routeNotice && (
<HostRouteNoticeBanner
message={routeNotice}
onDismiss={() => setDismissedNotice(noticeParam ?? null)}
/>
)}
{/* Search bar */}
{showSearch && (
<View style={styles.searchBar}>
@@ -1099,27 +1129,15 @@ export function HostScreen({
</View>
)}
{/* Loading state */}
{((connState === 'connecting' || connState === 'reconnecting') &&
displayWorktrees.length === 0) ||
(connState === 'connected' && !worktreesLoaded && displayWorktrees.length === 0) ? (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : null}
{/* Empty state */}
{connState === 'connected' && worktreesLoaded && sections.length === 0 && (
<View style={styles.centered}>
<Text style={styles.emptyText}>
{search
? 'No matching worktrees'
: activeFilterCount > 0
? 'No worktrees match filters'
: 'No worktrees'}
</Text>
</View>
)}
<HostWorkspaceListStates
connState={connState}
worktreesLoaded={worktreesLoaded}
displayCount={displayWorktrees.length}
sectionCount={sections.length}
catalogError={catalogError}
search={search}
activeFilterCount={activeFilterCount}
/>
{sections.length > 0 && (
<SectionList
@@ -1386,10 +1404,7 @@ export function HostScreen({
}}
onCreated={(worktreeId, worktreeName) => {
void fetchWorktrees({ allowDuringModal: true })
const params = new URLSearchParams({ name: worktreeName, created: '1' })
navigateFromHostList(
`/h/${hostId}/session/${encodeURIComponent(worktreeId)}?${params.toString()}`
)
navigateFromHostList(hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName))
}}
onRouteVisibleChange={setShowNewWorktreeVisible}
/>
+103 -107
View File
@@ -107,6 +107,7 @@ import type {
TerminalWebViewHandle
} from '../../../../src/terminal/terminal-webview-contract'
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import {
getDefaultTerminalAccessoryBuiltInIds,
@@ -195,8 +196,11 @@ import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTer
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc'
import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership'
import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
import { useMobileFileTapHandlers } from '../../../../src/session/use-mobile-file-tap-handlers'
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
import { useMissingWorktreeBounce } from '../../../../src/session/use-missing-worktree-bounce'
import { hostRouteWithNotice } from '../../../../src/host-route-notice'
import { LAST_VISITED_WORKTREE_STORAGE_KEY } from '../../../../src/worktree/last-visited-worktree-repo'
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
@@ -235,6 +239,11 @@ import {
} 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 {
TerminalViewportResubscribeBudget,
readTerminalViewportDims,
runTerminalViewportFitPass
} from '../../../../src/session/mobile-terminal-viewport-resubscribe'
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects'
@@ -853,12 +862,19 @@ export default function SessionScreen() {
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const forceReconnectHost = useForceReconnect()
const worktreeName = useLiveWorktreeName({
const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({
client,
connState,
routeName: routeWorktreeName,
worktreeId
})
// Why: a workspace deleted on the desktop leaves every RPC on this route failing forever.
useMissingWorktreeBounce({
hostId,
worktreeId,
resolution: worktreeResolution,
bounce: (id) => router.replace(hostRouteWithNotice(id, 'worktree-missing'))
})
// Master-detail state: wide layouts dock a tapped panel beside the session; narrow keeps it null and pushes full-screen routes.
const { isWideLayout } = useResponsiveLayout()
const [activePanel, setActivePanel] = useState<ActivePanel>(null)
@@ -1030,6 +1046,8 @@ export default function SessionScreen() {
const subscribingHandlesRef = useRef<Set<string>>(new Set())
const initializedHandlesRef = useRef<Set<string>>(new Set())
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
// Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337).
const viewportResubscribeBudgetRef = useRef(new TerminalViewportResubscribeBudget())
// Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered.
const webReadyHandlesRef = useRef<Set<string>>(new Set())
const activeHandleRef = useRef<string | null>(null)
@@ -1376,6 +1394,7 @@ export default function SessionScreen() {
subscribingHandlesRef.current.clear()
initializedHandlesRef.current.clear()
terminalDiagnosticsRef.current.clearTerminalCache()
viewportResubscribeBudgetRef.current.clear()
webReadyHandlesRef.current.clear()
subscribeSeqRef.current.clear()
layoutSeqRef.current.clear()
@@ -1505,10 +1524,11 @@ export default function SessionScreen() {
return
}
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const scrollbackCols = cols
const scrollbackRows = rows
const { hostCols, hostRows } = readTerminalViewportDims(data)
// Why: absent host dims must not be coerced into a comparable size — 80x24
// never equals a phone viewport and armed a zero-delay resubscribe loop (STA-3337).
const cols = hostCols ?? viewportRef.current?.cols ?? 80
const rows = hostRows ?? viewportRef.current?.rows ?? 24
const initialData =
typeof data.serialized === 'string' && data.serialized.length > 0
? data.serialized
@@ -1527,46 +1547,36 @@ export default function SessionScreen() {
ref.init(cols, rows, initialData, false, oscLinks)
initializedHandlesRef.current.add(handle)
if (data.displayMode) {
const displayMode = data.displayMode as MobileDisplayMode
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
setTerminalModes((prev) =>
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
)
}
// Why: cold-start refit — init()'s fit can run against a transient scrollWidth, so re-fire against a settled DOM.
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init and resubscribe so the server can phone-fit.
const needsResubscribe =
!viewportMeasuredRef.current ||
(viewportRef.current != null &&
(scrollbackCols !== viewportRef.current.cols ||
scrollbackRows !== viewportRef.current.rows))
if (needsResubscribe) {
void (async () => {
// Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06).
await getTerminalRef(handle)?.awaitReady()
if (subscribeSeqRef.current.get(handle) !== seq) {
return
}
const dims = await getTerminalRef(handle)?.measureFitDimensions(
terminalFrameHeightRef.current || undefined
)
// Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation.
if (subscribeSeqRef.current.get(handle) !== seq) {
return
}
if (!getTerminalRef(handle)) {
return
}
// Why: scrollback came back at cols=80 (server's null-viewport fallback), so this subscriber record has no viewport — resubscribe so the server stores it.
if (dims) {
diagnostics.streamResubscribing(handle, seq, dims)
viewportRef.current = dims
viewportMeasuredRef.current = true
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
}
})()
}
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init
// and resubscribe so the server can phone-fit — bounded per handle so a
// non-converging host degrades visibly instead of hot-looping (STA-3337).
runTerminalViewportFitPass({
handle,
seq,
hostCols,
hostRows,
budget: viewportResubscribeBudgetRef.current,
diagnostics,
viewportRef,
viewportMeasuredRef,
subscribeSeqRef,
initializedHandlesRef,
terminalUnsubsRef,
terminalFrameHeightRef,
getTerminalRef,
unsubscribeTerminal,
subscribeToTerminal,
scheduleDelayedAction,
showToast
})
} else if (data.type === 'metadata') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
} else if (data.type === 'data') {
@@ -1591,8 +1601,12 @@ export default function SessionScreen() {
} else if (data.type === 'resized') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
// Server resize: reinit xterm on a full-buffer snapshot (width reflow rewraps scrollback), else just resize geometry.
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const viewport = viewportMeasuredRef.current ? viewportRef.current : null
const [cols, rows] = viewportResubscribeBudgetRef.current.observeResize(
handle,
data,
viewport
)
const serialized = typeof data.serialized === 'string' ? data.serialized : null
diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null)
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
@@ -1602,8 +1616,10 @@ export default function SessionScreen() {
getTerminalRef(handle)?.resize(cols, rows)
}
if (data.displayMode) {
const displayMode = data.displayMode as MobileDisplayMode
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
setTerminalModes((prev) =>
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
)
}
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
@@ -1619,7 +1635,7 @@ export default function SessionScreen() {
}
subscribingHandlesRef.current.delete(handle)
},
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction]
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, showToast]
)
const nativeChatStream = useMobileNativeChatTerminalStream({
@@ -1687,7 +1703,8 @@ export default function SessionScreen() {
try {
const response = await client.sendRequest('terminal.list', {
worktree: `id:${worktreeId}`
worktree: `id:${worktreeId}`,
includeVisualLayouts: false
})
if (response.ok) {
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
@@ -1720,12 +1737,16 @@ export default function SessionScreen() {
unsubscribeTerminal(handle)
terminalRefs.current.delete(handle)
initializedHandlesRef.current.delete(handle)
viewportResubscribeBudgetRef.current.forget(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)
// Why: same absence-gated refill for the viewport-fit budget — a handle that
// left the list and returned may converge now, so it earns fresh attempts.
viewportResubscribeBudgetRef.current.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>()
@@ -2629,7 +2650,7 @@ export default function SessionScreen() {
useEffect(() => {
if (hostId && worktreeId) {
void AsyncStorage.setItem(
'orca:last-visited-worktree',
LAST_VISITED_WORKTREE_STORAGE_KEY,
JSON.stringify({ hostId, worktreeId })
)
}
@@ -2861,7 +2882,8 @@ export default function SessionScreen() {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
notifyClients: false,
navigation: 'caller'
navigation: 'caller',
intent: 'user'
}).catch(() => {})
}
}
@@ -2901,7 +2923,8 @@ export default function SessionScreen() {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false,
navigation: 'caller'
navigation: 'caller',
intent: 'user'
}).catch(() => {})
}
return
@@ -2925,7 +2948,8 @@ export default function SessionScreen() {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false,
navigation: 'caller'
navigation: 'caller',
intent: 'user'
}).catch(() => {})
}
if (tab.type === 'browser') {
@@ -3182,44 +3206,23 @@ export default function SessionScreen() {
})
}, [])
// Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss.
const handleFileTapActivationSeqRef = useRef(0)
const handleFileTap = useCallback(
(handle: string, pathText: string, line: number | null, column: number | null) => {
if (handle !== activeHandleRef.current || !client) {
return
}
const activationSeq = ++handleFileTapActivationSeqRef.current
openMobileTerminalFileTap<MobileSessionTab>({
client,
hostId,
worktreeId,
worktreeName: routeWorktreeName,
terminalHandle: handle,
pathText,
cwd: terminalCwdRef.current.get(handle) ?? null,
line,
column,
pushPreviewRoute: (href) => router.push(href),
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
triggerOpenFeedback: triggerSelection,
fetchSessionTabs,
getSessionTabs: () => sessionTabsRef.current,
getActiveSessionTabId: () => activeSessionTabIdRef.current,
getActivationState: (activated) => ({
activated,
activationSeq,
latestActivationSeq: handleFileTapActivationSeqRef.current,
sourceTerminalHandle: handle,
activeTerminalHandle: activeHandleRef.current,
activeTabType: activeSessionTabTypeRef.current
}),
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
scheduleDelayedAction
})
},
[client, fetchSessionTabs, hostId, routeWorktreeName, router, scheduleDelayedAction, worktreeId]
)
// Tap a terminal or chat file path → resolve on host, open as file tab/preview.
const { handleFileTap, handleNativeChatFileTap } = useMobileFileTapHandlers<MobileSessionTab>({
client,
hostId,
worktreeId,
worktreeName: routeWorktreeName,
activeHandleRef,
terminalCwdRef,
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
fetchSessionTabs,
getSessionTabs: () => sessionTabsRef.current,
getActiveSessionTabId: () => activeSessionTabIdRef.current,
getActiveSessionTabType: () => activeSessionTabTypeRef.current,
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
scheduleDelayedAction,
reportChatTapFailure: nativeChatSendError.show
})
const handleOpenedFileDiffActivationSeqRef = useRef(0)
// Capture active tab at tap time; reading it after openDiff would misread a mid-RPC switch and let the retry steal focus.
@@ -3590,6 +3593,7 @@ export default function SessionScreen() {
if (
current &&
current.cursorY === metrics.cursorY &&
current.contentBottomRow === metrics.contentBottomRow &&
current.rows === metrics.rows &&
current.altScreen === metrics.altScreen
) {
@@ -4196,7 +4200,10 @@ export default function SessionScreen() {
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId,
notifyClients: false,
navigation: 'caller'
navigation: 'caller',
// Why: this only ever runs for the tab the user is looking at, so it is the
// tail of their tap — the gesture that materializes a parked pane.
intent: 'user'
})
.then((response) => {
if (!response.ok) {
@@ -4271,24 +4278,11 @@ export default function SessionScreen() {
? Math.max(0, keyboardHeight - insets.bottom)
: keyboardHeight
: 0
const activeTerminalKeyboardLift = (() => {
if (keyboardLift <= 0 || !activeHandle) {
return 0
}
const metrics = terminalKeyboardMetrics.get(activeHandle)
if (!metrics || metrics.rows <= 0 || terminalFrameHeightRef.current <= 0) {
return keyboardLift
}
if (metrics.altScreen) {
return keyboardLift
}
const rowHeight = terminalFrameHeightRef.current / metrics.rows
const cursorBottom = (metrics.cursorY + 1) * rowHeight
const dockTop = terminalFrameHeightRef.current - keyboardLift
const margin = rowHeight
// Why: only move the terminal when the cursor would sit under the raised input dock; short top output stays put.
return Math.min(keyboardLift, Math.max(0, cursorBottom + margin - dockTop))
})()
const activeTerminalKeyboardLift = computeActiveTerminalKeyboardLift({
keyboardLift,
metrics: activeHandle ? terminalKeyboardMetrics.get(activeHandle) : undefined,
terminalFrameHeight: terminalFrameHeightRef.current
})
const toastAnimatedStyle = {
opacity: toastOpacityRef.current,
transform: [{ translateY: -keyboardLift }]
@@ -4752,6 +4746,7 @@ export default function SessionScreen() {
))}
<MobileNativeChatOverlay
controller={nativeChatController}
onOpenFile={handleNativeChatFileTap}
images={nativeChatImages}
onMicPress={handleDictationToggle}
micActive={dictation.isRecording}
@@ -4991,6 +4986,7 @@ export default function SessionScreen() {
<MobileTerminalLiveInputStatus
dictation={dictation}
isAttaching={isAttaching}
liveInputText={liveInputCapture}
/>
</Pressable>
<MobileTerminalInputActions
+2
View File
@@ -165,6 +165,8 @@ type RepoSummary = {
kind?: 'git' | 'folder'
connectionId?: string | null
issueSourcePreference?: IssueSourcePreference
/** Fork parent resolved by the host; drives upstream Project row matching. */
upstream?: { owner: string; repo: string; host?: string } | null
}
type IssueSourcePreference = 'upstream' | 'origin' | 'auto'
+245 -304
View File
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'
import { QrCode, Settings, ChevronRight, Terminal, Plus, ListTodo } from 'lucide-react-native'
import { QrCode, Settings, ChevronRight, Terminal, ListTodo } from 'lucide-react-native'
import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons'
import {
type AccountsSnapshot,
@@ -15,34 +15,46 @@ import {
UsageBar
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts } from '../src/transport/host-store'
import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation'
import { loadHostCatalog } from '../src/transport/host-store'
import { selectConnectableHostProfiles } from '../src/transport/host-catalog-selection'
import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
import { WORKTREE_PS_FULL_LIMIT } from '../src/worktree/worktree-catalog-snapshot-client'
import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch'
import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total'
import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info'
import type { RpcClient } from '../src/transport/rpc-client'
import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate'
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
import {
useAllHostClients,
useCloseHost,
useForceReconnect,
usePrimeHosts
} from '../src/transport/client-context'
resolveHomeHostConnectionState,
selectHomeAutoConnectHostIds
} from '../src/transport/home-host-auto-connect'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import {
loadMobileOnboardingSteps,
mobileOnboardingDestination
} from '../src/onboarding/mobile-onboarding-plan'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import type { ConnectionState, HostCatalogEntry, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { MobileHostCard } from '../src/components/MobileHostCard'
import { MobileHomeQuickActions } from '../src/components/MobileHomeQuickActions'
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
import { ActionSheetModal } from '../src/components/ActionSheetModal'
import { getHostListActionSheetActions } from '../src/host-list-action-sheet-actions'
import { ConfirmModal } from '../src/components/ConfirmModal'
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
import {
setCachedWorktrees,
getCachedWorktrees,
getProvenCachedWorktrees
} from '../src/cache/worktree-cache'
import {
LAST_VISITED_WORKTREE_STORAGE_KEY,
readLastVisitedWorktreeRecord
} from '../src/worktree/last-visited-worktree-repo'
import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache'
import { colors, spacing, radii } from '../src/theme/mobile-theme'
import {
@@ -52,43 +64,16 @@ import {
} from '../src/tasks/mobile-task-providers'
import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks'
import { useResponsiveLayout } from '../src/layout/responsive-layout'
import { createMobileSessionHref } from '../src/session/mobile-session-route'
function endpointLabel(endpoint: string): string {
try {
const url = new URL(endpoint)
return `${url.hostname}${url.port ? `:${url.port}` : ''}`
} catch {
return endpoint
}
}
type StatsSummary = {
totalAgentsSpawned: number
totalPRsCreated: number
totalAgentTimeMs: number
firstEventAt: number | null
}
type WorktreeSummary = {
worktreeId: string
repo: string
branch: string
displayName: string
liveTerminalCount: number
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
// The worktree the desktop currently has focused (exactly one is true).
isActive?: boolean
// Last terminal-output time (ms); breaks ties when nothing is focused.
lastOutputAt?: number
}
type HostWorktreeInfo = {
hostId: string
totalWorktrees: number
activeCount: number
lastActiveWorktree: WorktreeSummary | null
}
import { useOpenMobileSession } from '../src/session/use-open-mobile-session'
import { useOpenMobileAccounts } from '../src/accounts/use-open-mobile-accounts'
import {
isResumeTargetConfirmedMissing,
selectHomeResumeCard,
type HomeResumeCard
} from '../src/worktree/home-resume-card'
import { hostRouteWithNotice } from '../src/host-route-notice'
import { hostNewWorktreeRoute } from '../src/host-route-action-state'
import { hostEndpointLabel } from '../src/transport/host-endpoint-label'
type HomeTaskSettings = {
visibleTaskProviders?: unknown
@@ -138,7 +123,9 @@ function clientKey(client: RpcClient): number {
function fetchStats(
client: RpcClient,
hostId: string,
setStats: (s: StatsSummary) => void,
setStats: (
updater: (prev: Record<string, HomeStatsSummary>) => Record<string, HomeStatsSummary>
) => void,
disposed: () => boolean
) {
sendSingleFlightRequest(client, hostId, 'stats.summary')
@@ -147,71 +134,13 @@ function fetchStats(
return
}
if (response.ok) {
setStats(response.result as StatsSummary)
// Keyed by host: the header totals every desktop instead of showing whoever replied last.
setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary }))
}
})
.catch(() => {})
}
function fetchWorktreeInfo(
client: RpcClient,
hostId: string,
setInfo: (
updater: (prev: Record<string, HostWorktreeInfo>) => Record<string, HostWorktreeInfo>
) => void,
disposed: () => boolean
) {
// Why: only seed a zeroed entry when the host has no prior info; keep cached data on transient failure so counts don't flip to 0 during reconnects.
const markLoadedIfMissing = () => {
setInfo((prev) => {
if (prev[hostId]) {
return prev
}
return {
...prev,
[hostId]: {
hostId,
totalWorktrees: 0,
activeCount: 0,
lastActiveWorktree: null
}
}
})
}
sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT })
.then((response) => {
if (disposed()) {
return
}
if (response.ok) {
const result = response.result as { worktrees: WorktreeSummary[] }
const worktrees = result.worktrees ?? []
setCachedWorktrees(hostId, worktrees)
const activeStatuses = new Set(['working', 'active', 'permission'])
const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status))
// Mirror the desktop's focused workspace (see pickResumeWorktree).
const lastActive = pickResumeWorktree(worktrees)
setInfo((prev) => ({
...prev,
[hostId]: {
hostId,
totalWorktrees: worktrees.length,
activeCount: active.length,
lastActiveWorktree: lastActive
}
}))
} else {
markLoadedIfMissing()
}
})
.catch(() => {
if (!disposed()) {
markLoadedIfMissing()
}
})
}
function fetchAccountsSnapshot(
client: RpcClient,
hostId: string,
@@ -287,17 +216,20 @@ function repoColor(name: string): string {
export default function HomeScreen() {
const router = useRouter()
const openMobileHostEdit = useOpenMobileHostEdit()
const openMobileTasks = useOpenMobileTasks()
const openMobileSession = useOpenMobileSession()
const openMobileAccounts = useOpenMobileAccounts()
const insets = useSafeAreaInsets()
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
const [hosts, setHosts] = useState<HostProfile[]>([])
const [hostCatalog, setHostCatalog] = useState<HostCatalogEntry[]>([])
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null)
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
const [stats, setStats] = useState<StatsSummary | null>(null)
const [statsByHost, setStatsByHost] = useState<Record<string, HomeStatsSummary>>({})
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
const [taskProvidersByHost, setTaskProvidersByHost] = useState<Record<string, TaskProvider[]>>({})
@@ -309,8 +241,15 @@ export default function HomeScreen() {
const onboardingOptInCheckedRef = useRef(false)
// Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md.
const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog])
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const allClients = useAllHostClients(hostIds)
// Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total.
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts])
const allClients = useAllHostClients(hostIds, {
autoConnectHostIds,
closeUnusedOnRelease: true
})
const hostPaths = useMemo(
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
[allClients]
@@ -373,12 +312,12 @@ export default function HomeScreen() {
useFocusEffect(
useCallback(() => {
let stale = false
void loadHosts().then(async (h) => {
void loadHostCatalog().then(async (catalog) => {
if (stale) {
return
}
setHosts(h)
if (h.length === 0 || onboardingOptInCheckedRef.current) {
setHostCatalog(catalog)
if (catalog.length === 0 || onboardingOptInCheckedRef.current) {
return
}
onboardingOptInCheckedRef.current = true
@@ -390,18 +329,18 @@ export default function HomeScreen() {
router.replace(mobileOnboardingDestination(onboardingSteps))
}
})
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
if (stale || !raw) {
void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => {
if (stale) {
return
}
try {
setLastVisited(JSON.parse(raw))
} catch {}
// Why the validating reader: this record becomes the Resume card's navigation target,
// so a malformed or older-shaped payload must read as no history, not a broken route.
setLastVisited(readLastVisitedWorktreeRecord(raw))
})
for (const entry of allClientsRef.current) {
if (entry.client.getState() === 'connected') {
fetchStats(entry.client, entry.hostId, setStats, () => stale)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale)
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale)
}
@@ -416,6 +355,10 @@ export default function HomeScreen() {
() => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected),
[hosts]
)
const sortedHostCatalog = useMemo(
() => [...hostCatalog].sort((a, b) => b.lastConnected - a.lastConnected),
[hostCatalog]
)
// Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working.
useEffect(() => {
@@ -454,17 +397,24 @@ export default function HomeScreen() {
}
}
// Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'.
for (const host of hosts) {
for (const host of hostCatalog) {
if (liveIds.has(host.id)) {
continue
}
if (!host.publicKeyB64 || !host.deviceToken) {
if (host.credentialStatus === 'missing') {
if (next[host.id] !== 'auth-failed') {
next[host.id] = 'auth-failed'
changed = true
}
continue
}
if (host.credentialStatus === 'temporarily-unavailable') {
if (next[host.id] !== 'disconnected') {
next[host.id] = 'disconnected'
changed = true
}
continue
}
const prevState = next[host.id]
if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') {
next[host.id] = 'disconnected'
@@ -473,69 +423,79 @@ export default function HomeScreen() {
}
// Drop entries for hosts we no longer track at all.
for (const id of Object.keys(next)) {
if (!liveIds.has(id) && hosts.some((h) => h.id === id) === false) {
if (!liveIds.has(id) && hostCatalog.some((h) => h.id === id) === false) {
delete next[id]
changed = true
}
}
return changed ? next : prev
})
}, [allClients, hosts])
}, [allClients, hostCatalog])
// Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap.
useEffect(() => {
const cleanups: Array<() => void> = []
for (const entry of allClients) {
let unsubNotif: (() => void) | null = null
let unsubAccounts: (() => void) | null = null
let statsFetched = false
const wireUp = (state: ConnectionState) => {
if (state === 'connected') {
if (!unsubNotif) {
unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId)
}
if (!unsubAccounts) {
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
if (!payload || typeof payload !== 'object') {
return
// Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body
// because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the
// returned disposer owns every handle allocated here.
const wireHostSubscriptions = (entry: {
hostId: string
client: RpcClient
state: ConnectionState
}) => {
let unsubNotif: (() => void) | null = null
let unsubAccounts: (() => void) | null = null
const refetchGate = createHostConnectRefetchGate()
const wireUp = (state: ConnectionState) => {
const reconnected = refetchGate.observe(state)
if (state === 'connected') {
if (!unsubNotif) {
unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId)
}
if (!unsubAccounts) {
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
if (!payload || typeof payload !== 'object') {
return
}
const evt = payload as { type?: string; snapshot?: unknown }
if (evt.type === 'ready' || evt.type === 'snapshot') {
try {
const snapshot = decodeAccountsSnapshot(evt.snapshot)
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot }))
} catch {
// Keep the last proven snapshot; malformed remote data must
// not enter render state or crash the home host cards.
}
const evt = payload as { type?: string; snapshot?: unknown }
if (evt.type === 'ready' || evt.type === 'snapshot') {
try {
const snapshot = decodeAccountsSnapshot(evt.snapshot)
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot }))
} catch {
// Keep the last proven snapshot; malformed remote data must
// not enter render state or crash the home host cards.
}
}
})
}
if (!statsFetched) {
statsFetched = true
fetchStats(entry.client, entry.hostId, setStats, () => false)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
}
} else {
if (unsubNotif) {
unsubNotif()
unsubNotif = null
}
if (unsubAccounts) {
unsubAccounts()
unsubAccounts = null
}
}
})
}
// Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host
// snapshot on every reconnect — a one-shot latch left the card on stale data forever.
if (reconnected) {
fetchStats(entry.client, entry.hostId, setStatsByHost, () => false)
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
}
} else {
if (unsubNotif) {
unsubNotif()
unsubNotif = null
}
if (unsubAccounts) {
unsubAccounts()
unsubAccounts = null
}
}
wireUp(entry.state)
const unsubState = entry.client.onStateChange(wireUp)
cleanups.push(() => {
unsubState()
unsubNotif?.()
unsubAccounts?.()
})
}
wireUp(entry.state)
const unsubState = entry.client.onStateChange(wireUp)
return () => {
unsubState()
unsubNotif?.()
unsubAccounts?.()
}
}
// Re-runs per (hostId, client) pair; the socket stays open so it's cheap.
useEffect(() => {
const cleanups = allClients.map((entry) => wireHostSubscriptions(entry))
return () => {
for (const c of cleanups) {
c()
@@ -549,28 +509,43 @@ export default function HomeScreen() {
.join(',')
])
// Why: prefer the worktree last opened on this device so Resume reflects mobile session history.
// Why: don't gate on 'connected' so the card doesn't flash empty for ~1s on cold-start; cached data holds until fresh RPC lands.
const resumeWorktree = useMemo(() => {
// Why: only surface Resume for connected hosts; a stale worktree taps into a route that can't load.
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
if (match) {
return { hostId: lastVisited.hostId, worktree: match }
// Why: the card renders from cached/snapshot data the moment a candidate exists — see
// selectHomeResumeCard for why its slot must not wait for the host to connect.
const resumeCard = useMemo(
() =>
selectHomeResumeCard({
hosts: sortedHosts,
hostStates,
worktreeInfo,
lastVisited,
cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null
}),
[sortedHosts, hostStates, worktreeInfo, lastVisited]
)
// Why: the card is drawn from a snapshot that can name a workspace the desktop has since
// deleted. When the host has proven otherwise, open its workspace list rather than a session
// screen whose every RPC would fail. An unproven catalog is not evidence — that tap goes
// through and the session screen bounces once the host answers (F7).
const openResume = useCallback(
(card: HomeResumeCard) => {
if (
isResumeTargetConfirmedMissing(
card,
getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null
)
) {
router.push(hostRouteWithNotice(card.hostId, 'worktree-missing'))
return
}
}
for (const host of sortedHosts) {
if (hostStates[host.id] !== 'connected') {
continue
}
const info = worktreeInfo[host.id]
if (info?.lastActiveWorktree) {
return { hostId: host.id, worktree: info.lastActiveWorktree }
}
}
return null
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
openMobileSession({
hostId: card.hostId,
worktreeId: card.worktree.worktreeId,
name: card.worktree.displayName || card.worktree.repo
})
},
[openMobileSession, router]
)
// Why: only show Account usage for connected hosts; stale cached usage would imply live data.
const accountsHosts = useMemo(() => {
@@ -591,10 +566,11 @@ export default function HomeScreen() {
return items
}, [sortedHosts, hostStates, accountsByHost])
const primaryConnectedHost = useMemo(
() => sortedHosts.find((host) => hostStates[host.id] === 'connected') ?? null,
const connectedHosts = useMemo(
() => sortedHosts.filter((host) => hostStates[host.id] === 'connected'),
[sortedHosts, hostStates]
)
const primaryConnectedHost = connectedHosts[0] ?? null
const primaryTaskProviders = primaryConnectedHost
? (taskProvidersByHost[primaryConnectedHost.id] ?? ['github'])
: []
@@ -612,7 +588,7 @@ export default function HomeScreen() {
disabled={!primaryConnectedHost}
style={({ pressed }) => [
styles.taskHomeCard,
!primaryConnectedHost && styles.quickActionDisabled,
!primaryConnectedHost && styles.cardDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => {
@@ -669,7 +645,7 @@ export default function HomeScreen() {
try {
await removeHostAndCloseClient(hostToRemove.id, closeHostClient)
setConfirmRemove(null)
setHosts(await loadHosts())
setHostCatalog(await loadHostCatalog())
} catch {
// Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent.
setConfirmRemove(hostToRemove)
@@ -695,7 +671,7 @@ export default function HomeScreen() {
</Pressable>
</View>
{hosts.length === 0 ? (
{hostCatalog.length === 0 ? (
/* ─── Empty state: onboarding ─── */
<View
style={[
@@ -734,7 +710,7 @@ export default function HomeScreen() {
) : (
/* ─── Populated state ─── */
<FlatList
data={sortedHosts}
data={sortedHostCatalog}
keyExtractor={(h) => h.id}
// Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator.
contentContainerStyle={[
@@ -772,10 +748,13 @@ export default function HomeScreen() {
}
ItemSeparatorComponent={CardGap}
renderItem={({ item }) => {
const state = hostStates[item.id] ?? 'connecting'
const state = resolveHomeHostConnectionState(
item.id,
hostStates[item.id],
autoConnectHostIds
)
const attempts = hostAttempts[item.id] ?? 0
const lastConnectedAt = hostLastConnected[item.id] ?? null
const info = worktreeInfo[item.id]
const verdict = classifyConnection({
state,
reconnectAttempts: attempts,
@@ -785,16 +764,36 @@ export default function HomeScreen() {
return (
<MobileHostCard
host={item}
credentialStatus={item.credentialStatus}
state={state}
verdict={verdict}
path={hostPaths[item.id] ?? 'lan'}
worktreeCounts={
info ? { total: info.totalWorktrees, active: info.activeCount } : undefined
}
onPress={() => router.push(`/h/${item.id}`)}
worktreeInfo={worktreeInfo[item.id]}
onPress={() => {
if (item.credentialStatus === 'missing') {
router.push('/pair-scan')
} else if (item.credentialStatus === 'temporarily-unavailable') {
void loadHostCatalog()
.then(setHostCatalog)
.catch(() => Alert.alert('Could not check pairing', 'Please try again.'))
} else {
router.push(`/h/${item.id}`)
}
}}
onLongPress={() => {
triggerMediumImpact()
setActionTarget(item)
if (item.profile) {
setActionTarget(item.profile)
} else {
setConfirmRemove(item)
}
}}
onOpenActions={() => {
if (item.profile) {
setActionTarget(item.profile)
} else {
setConfirmRemove(item)
}
}}
/>
)
@@ -802,85 +801,52 @@ export default function HomeScreen() {
ListFooterComponent={
<View>
{/* ─── Resume card ─── */}
{resumeWorktree ? (
{resumeCard ? (
<>
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Resume</Text>
<Pressable
style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]}
onPress={() =>
router.push(
createMobileSessionHref({
hostId: resumeWorktree.hostId,
worktreeId: resumeWorktree.worktree.worktreeId,
name: resumeWorktree.worktree.displayName || resumeWorktree.worktree.repo
})
)
}
disabled={!resumeCard.actionable}
style={({ pressed }) => [
styles.resumeCard,
!resumeCard.actionable && styles.cardDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => openResume(resumeCard)}
>
<View style={styles.resumeIcon}>
<Terminal size={18} color={colors.textSecondary} />
</View>
<View style={styles.resumeMain}>
<Text style={styles.resumeTitle} numberOfLines={1}>
{resumeWorktree.worktree.displayName}
{resumeCard.worktree.displayName}
</Text>
<View style={styles.resumeSub}>
<View
style={[
styles.repoDot,
{ backgroundColor: repoColor(resumeWorktree.worktree.repo) }
{ backgroundColor: repoColor(resumeCard.worktree.repo) }
]}
/>
<Text style={styles.resumeSubText} numberOfLines={1}>
{resumeWorktree.worktree.repo}
{resumeCard.worktree.repo}
{' · '}
{resumeWorktree.worktree.branch}
{resumeCard.worktree.branch}
</Text>
</View>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
{renderTaskHomeCard()}
</>
) : (
<>
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
{renderTaskHomeCard()}
</>
)}
) : null}
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
{renderTaskHomeCard()}
{/* ─── Quick actions ─── */}
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
<View style={styles.quickActions}>
<Pressable
style={({ pressed }) => [styles.quickAction, pressed && styles.hostCardPressed]}
onPress={() => router.push('/pair-scan')}
>
<View style={styles.quickActionIcon}>
<QrCode size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
</Pressable>
<Pressable
disabled={!primaryConnectedHost}
style={({ pressed }) => [
styles.quickAction,
!primaryConnectedHost && styles.quickActionDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => {
if (primaryConnectedHost) {
router.push(`/h/${primaryConnectedHost.id}?action=newWorktree`)
}
}}
>
<View style={styles.quickActionIcon}>
<Plus size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>New Workspace</Text>
</Pressable>
</View>
<MobileHomeQuickActions
connectedHosts={connectedHosts}
onPairDesktop={() => router.push('/pair-scan')}
onCreateWorkspace={(hostId) => router.push(hostNewWorktreeRoute(hostId))}
/>
{/* ─── Account usage ─── */}
{accountsHosts.length > 0 ? (
@@ -903,7 +869,7 @@ export default function HomeScreen() {
styles.accountsCard,
pressed && styles.hostCardPressed
]}
onPress={() => router.push(`/h/${host.id}/accounts`)}
onPress={() => openMobileAccounts(host.id)}
>
{showHostName ? (
<Text style={styles.accountsHostLabel} numberOfLines={1}>
@@ -968,18 +934,24 @@ export default function HomeScreen() {
<ActionSheetModal
visible={actionTarget != null}
title={actionTarget?.name}
message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined}
message={actionTarget ? hostEndpointLabel(actionTarget.endpoint) : undefined}
actions={getHostListActionSheetActions({
host: actionTarget,
state: actionTarget ? (hostStates[actionTarget.id] ?? 'connecting') : 'disconnected',
state: actionTarget
? resolveHomeHostConnectionState(
actionTarget.id,
hostStates[actionTarget.id],
autoConnectHostIds
)
: 'disconnected',
hasEverConnected: actionTarget
? (hostLastConnected[actionTarget.id] ?? null) != null
: false,
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: closeHostClient,
onEdit: (hostId) => navigateToMobileHostEdit(router, hostId),
onRemove: setConfirmRemove
onEdit: openMobileHostEdit,
onRemove: (host) => setConfirmRemove(host)
})}
onClose={() => setActionTarget(null)}
/>
@@ -1183,6 +1155,9 @@ const styles = StyleSheet.create({
paddingRight: spacing.md,
paddingVertical: 12
},
cardDisabled: {
opacity: 0.45
},
taskHomeIcon: {
width: 46,
height: 46,
@@ -1276,40 +1251,6 @@ const styles = StyleSheet.create({
marginTop: 4
},
/* ─── Quick actions ─── */
quickActions: {
flexDirection: 'row',
gap: spacing.sm
},
quickAction: {
flex: 1,
flexDirection: 'row',
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
paddingVertical: 10,
paddingHorizontal: 12,
alignItems: 'center',
gap: 10
},
quickActionDisabled: {
opacity: 0.45
},
quickActionIcon: {
width: 28,
height: 28,
borderRadius: 9,
backgroundColor: 'rgba(255,255,255,0.04)',
alignItems: 'center',
justifyContent: 'center'
},
quickActionLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.textSecondary
},
/* ─── Empty state ─── */
emptyContainer: {
flex: 1
+2 -2
View File
@@ -12,7 +12,7 @@ import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useAllHostClients } from '../src/transport/client-context'
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
@@ -127,7 +127,7 @@ export default function TerminalSettingsScreen() {
void loadHosts().then(setHosts)
}, [])
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const hostClients = useAllHostClients(hostIds)
const { clients: hostClients } = useFocusedSettingsHostClients(hostIds)
const hostClientsById = useMemo(
() => new Map(hostClients.map((entry) => [entry.hostId, entry.client])),
[hostClients]
+3 -12
View File
@@ -9,12 +9,12 @@ import {
View
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useRouter } from 'expo-router'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useAllHostClients } from '../src/transport/client-context'
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
import type { RpcClient } from '../src/transport/rpc-client'
import { BottomDrawer } from '../src/components/BottomDrawer'
import { VoiceModelList } from '../src/components/VoiceModelList'
@@ -47,7 +47,7 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
void loadHosts().then(setHosts)
}, [])
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const hostClients = useAllHostClients(hostIds)
const { clients: hostClients, focused: routeFocused } = useFocusedSettingsHostClients(hostIds)
// Voice dictation runs on the paired desktop, so pick the first connected host.
const client: RpcClient | null = useMemo(
() => hostClients.find((entry) => entry.state === 'connected')?.client ?? null,
@@ -59,15 +59,6 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
const [error, setError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
const [routeFocused, setRouteFocused] = useState(false)
useFocusEffect(
useCallback(() => {
setRouteFocused(true)
return () => setRouteFocused(false)
}, [])
)
const refresh = useCallback(async (): Promise<boolean | undefined> => {
if (!client) {
return false
+2 -1
View File
@@ -7,7 +7,7 @@
"start": "node scripts/start-expo.mjs",
"android": "expo run:android",
"ios": "expo run:ios",
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "oxlint",
@@ -48,6 +48,7 @@
"expo-status-bar": "^55.0.6",
"lowlight": "^3.3.0",
"lucide-react-native": "^1.14.0",
"mermaid": "11.16.0",
"react": "^19.2.6",
"react-dom": "19.2.6",
"react-native": "^0.83.9",
+792
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const scriptDir = import.meta.dirname
const mobileRoot = path.resolve(scriptDir, '..')
const outputPath = path.join(
mobileRoot,
'src',
'components',
'pr-sidebar',
'mermaid-webview-engine.generated.ts'
)
// Why: unlike the terminal engine (esbuilt from source for old-WebView shims),
// mermaid's prebuilt UMD bundle is embedded verbatim — it is the exact artifact
// the diagram WebView previously fetched from a CDN, now pinned and
// integrity-checked through the lockfile and available offline.
async function main() {
const packageJsonPath = require.resolve('mermaid/package.json')
const [packageJson, bundleJs] = await Promise.all([
readFile(packageJsonPath, 'utf8').then(JSON.parse),
readFile(path.join(path.dirname(packageJsonPath), 'dist', 'mermaid.min.js'), 'utf8')
])
// A literal </script> inside the bundle would close the inline script tag it
// is embedded in; \/ is identical to / in JS strings, regexes, and comments.
const inlineSafeJs = bundleJs.replace(/<\/script/gi, '<\\/script')
const source = [
'// Generated by scripts/build-mermaid-webview-engine.mjs.',
`// Package: mermaid@${packageJson.version} (dist/mermaid.min.js, embedded verbatim).`,
'// Do not edit by hand; regenerate via pnpm postinstall.',
`export const MERMAID_ENGINE_JS = ${JSON.stringify(inlineSafeJs)}`,
''
].join('\n')
await writeFile(outputPath, source)
}
await main()

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