Merge remote-tracking branch 'origin/main' into brennanb2025/telemetry-gaps

This commit is contained in:
brennanb2025
2026-05-17 15:54:07 -07:00
754 changed files with 69201 additions and 9822 deletions
+100 -7
View File
@@ -7,10 +7,22 @@ on:
description: Ref to check out (defaults to the calling workflow's ref)
required: false
type: string
workflow_dispatch:
inputs:
ref:
description: Ref to check out (defaults to the workflow ref)
required: false
type: string
schedule:
# Why: GitHub cron uses UTC; these slots map to 10am and 3pm
# America/Phoenix for the default-branch E2E run.
- cron: '0 17,22 * * *'
jobs:
e2e:
build:
name: build e2e app
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
@@ -18,9 +30,77 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
# Why: electron-vite's globalSetup invokes a full app build, which
# compiles native modules via node-gyp. Mirrors the install step in
# pr.yml's verify job so E2E doesn't hit missing-toolchain errors.
# Why: the E2E build compiles native modules via node-gyp. Mirrors the
# install step in pr.yml's verify job so E2E doesn't hit missing-toolchain
# errors.
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: building once avoids five parallel electron-vite builds inside
# Playwright globalSetup, which otherwise contends for CPU/RAM on OSS
# runners before the sharded tests even start.
- name: Build Electron app for E2E
run: npx electron-vite build --mode e2e
- name: Upload E2E build output
uses: actions/upload-artifact@v4
with:
name: e2e-build-out
path: out/
retention-days: 1
if-no-files-found: error
e2e:
name: e2e ${{ matrix.shard_name }}
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- shard: '1/5'
shard_name: 1-of-5
- shard: '2/5'
shard_name: 2-of-5
- shard: '3/5'
shard_name: 3-of-5
- shard: '4/5'
shard_name: 4-of-5
- shard: '5/5'
shard_name: 5-of-5
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
# Why: pnpm install rebuilds native modules, and those postinstall
# scripts still need the Linux toolchain even though this shard reuses
# the prebuilt Electron output.
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
@@ -55,8 +135,21 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run E2E tests
run: xvfb-run --auto-servernum pnpm run test:e2e
- name: Download E2E build output
uses: actions/download-artifact@v4
with:
name: e2e-build-out
path: out/
# Why: the Electron suite is wall-clock constrained on OSS runners, but
# raising workers on one runner OOMs. Sharding keeps each VM at 4 workers
# while splitting the 91-test headless suite across separate runners.
# SKIP_BUILD makes Playwright globalSetup reuse the single build job's
# artifact instead of starting five concurrent electron-vite builds.
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
# launches but never creates a BrowserWindow.
- name: Run E2E tests (${{ matrix.shard_name }})
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e --shard=${{ matrix.shard }}
# Why: Playwright retains traces/screenshots only on failure. Uploading
# them as an artifact makes post-mortem debugging on CI possible without
@@ -65,7 +158,7 @@ jobs:
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
name: playwright-traces-${{ matrix.shard_name }}
path: test-results/
retention-days: 7
if-no-files-found: ignore
-9
View File
@@ -88,12 +88,3 @@ jobs:
- name: Build
run: pnpm build
e2e:
uses: ./.github/workflows/e2e.yml
# Why: check out the PR head directly instead of refs/pull/N/merge.
# The merge ref doesn't exist when the PR has conflicts or GitHub
# hasn't computed the merge commit yet, causing checkout to fail
# for reasons unrelated to the code under test.
with:
ref: ${{ github.event.pull_request.head.sha }}
-106
View File
@@ -441,112 +441,6 @@ jobs:
--generate-notes \
--prerelease="$is_rc"
# Why: the rc.7 incident did not match the original publisher hypothesis.
# Poll both authenticated release state and anonymous atom visibility during
# real cuts so the next diagnosis has proof instead of timing guesses.
monitor-release:
needs:
- cut
- create-release
if: needs.cut.outputs.should_release == 'true'
runs-on: ubuntu-latest
continue-on-error: true
permissions:
contents: read
steps:
- name: Poll release visibility during cut
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
run: |
set -euo pipefail
log_path="$RUNNER_TEMP/release-cut-instrumentation-${TAG}.jsonl"
release_error="$RUNNER_TEMP/release-api-error.txt"
: >"$log_path"
for attempt in $(seq 1 60); do
timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
release_json=""
release_error_body=""
if releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" 2>"$release_error")"; then
# Why: releases-by-tag cannot inspect the draft we are monitoring.
if ! release_json="$(jq -e -c --arg tag "$TAG" '
map(select(.tag_name == $tag))
| if length == 1 then .[0] else empty end
' <<<"$releases_json")"; then
release_json=""
release_error_body="Release $TAG was not found in the draft-aware releases list."
fi
else
release_error_body="$(cat "$release_error")"
fi
atom_body="$(curl -fsSL --retry 2 --max-time 10 "https://github.com/$GITHUB_REPOSITORY/releases.atom" 2>/dev/null || true)"
tag_in_anon_atom=false
if printf '%s' "$atom_body" | grep -Fq "/releases/tag/$TAG"; then
tag_in_anon_atom=true
fi
if [[ -n "$release_json" ]]; then
jq -c \
--arg timestamp "$timestamp" \
--argjson tag_in_anon_atom "$tag_in_anon_atom" \
--arg run_id "$GITHUB_RUN_ID" \
--arg run_attempt "$GITHUB_RUN_ATTEMPT" \
'{
timestamp: $timestamp,
tag: .tag_name,
draft: .draft,
prerelease: .prerelease,
published_at: .published_at,
author: .author.login,
asset_count: (.assets | length),
assets: (.assets | map({
name,
state,
size,
created_at,
updated_at,
uploader: .uploader.login
})),
tag_in_anon_atom: $tag_in_anon_atom,
workflow_run: {
id: $run_id,
attempt: $run_attempt
}
}' <<<"$release_json" >>"$log_path"
draft="$(jq -r '.draft' <<<"$release_json")"
if [[ "$draft" == "false" && "$tag_in_anon_atom" == "true" ]]; then
break
fi
else
jq -nc \
--arg timestamp "$timestamp" \
--arg tag "$TAG" \
--arg error "$release_error_body" \
--argjson tag_in_anon_atom "$tag_in_anon_atom" \
'{
timestamp: $timestamp,
tag: $tag,
release_api_error: $error,
tag_in_anon_atom: $tag_in_anon_atom
}' >>"$log_path"
fi
sleep 30
done
echo "Wrote $log_path"
- name: Upload release visibility log
if: always()
uses: actions/upload-artifact@v4
with:
name: release-cut-instrumentation-${{ needs.cut.outputs.tag }}
path: ${{ runner.temp }}/release-cut-instrumentation-*.jsonl
if-no-files-found: warn
# Why: E2E runs alongside the release for visibility (failures surface as a
# red check on the tag), but is NOT in `publish-release`'s needs list.
# Releases already take a while and the suite is already a required check
+12 -5
View File
@@ -70,11 +70,18 @@ design-docs/
# Machine-local agent hook endpoint files may contain auth tokens.
/agent-hooks/
# Local-only design/planning docs (not checked in)
docs/*.md
docs/**/*.md
!docs/README*.md
!docs/**/README*.md
# Local-only design/planning docs (not checked in).
# Durable docs must live in one of the allow-listed locations below.
docs/**
!docs/
!docs/assets/
!docs/assets/**
!docs/readme/
!docs/readme/**
!docs/reference/
!docs/reference/**
!docs/STYLEGUIDE.md
!docs/configurable-open-in-menu.md
# Stably CLI (only docs/ are tracked)
.stably/*
+1
View File
@@ -33,6 +33,7 @@ All changes must consider the SSH use case. Don't assume local-only execution.
## GitHub CLI Usage
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
Never commit PR evidence images; attach them to the PR conversation instead.
## Type Declarations: Prefer `.ts` Over `.d.ts`
+4 -3
View File
@@ -9,12 +9,12 @@
</p>
<p align="center">
<a href="README.md">English</a> · <a href="docs/README.zh-CN.md">中文</a> · <a href="docs/README.ja.md">日本語</a> · <a href="docs/README.ko.md">한국어</a> · <a href="docs/README.es.md">Español</a>
<a href="README.md">English</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a> · <a href="docs/readme/README.es.md">Español</a>
</p>
<p align="center">
<strong>The AI Orchestrator for 100x builders.</strong><br/>
Run Claude Code, Codex, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
Run Claude Code, Codex, Grok, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
Available for <strong>macOS, Windows, and Linux</strong>.
</p>
@@ -33,6 +33,7 @@ Orca supports any CLI agent (*not just this list*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
@@ -60,7 +61,7 @@ Orca supports any CLI agent (*not just this list*).
## Features
- **No login required** — Bring your own Claude Code or Codex subscription.
- **No login required** — Bring your own Claude Code, Codex, or Grok subscription.
- **Worktree-native** — Every feature gets its own worktree. No stashing, no branch juggling. Spin up and switch instantly.
- **Multi-agent terminals** — Run multiple AI agents side-by-side in tabs and panes. See which ones are active at a glance.
- **Built-in source control** — Review AI-generated diffs, make quick edits, and commit without leaving Orca.
+1 -1
View File
@@ -36,7 +36,7 @@ for (const platform of PLATFORMS) {
outfile: join(outDir, 'relay.js'),
// Native addons cannot be bundled — they must exist on the remote host.
// The relay gracefully degrades when they are absent.
external: ['node-pty', '@parcel/watcher'],
external: ['node-pty', '@parcel/watcher', 'better-sqlite3'],
sourcemap: false,
minify: true,
define: {
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { release } from 'node:os'
import { basename, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const require = createRequire(import.meta.url)
const scriptPath = fileURLToPath(import.meta.url)
const projectDir = resolve(dirname(scriptPath), '../..')
const runtime = readRuntimeArg()
const NATIVE_MODULES = ['better-sqlite3', 'node-pty']
const CHILD_CHECK_FLAG = '--check-only'
if (process.argv.includes(CHILD_CHECK_FLAG)) {
const failures = collectNativeModuleFailures()
if (failures.length > 0) {
for (const failure of failures) {
console.error(`${failure.moduleName}: ${failure.message}`)
}
process.exit(1)
}
process.exit(0)
}
if (runtime === 'node') {
ensureNodeRuntime()
} else if (runtime === 'electron') {
ensureElectronRuntime()
} else {
console.error('Usage: node config/scripts/ensure-native-runtime.mjs --runtime=node|electron')
process.exit(2)
}
function readRuntimeArg() {
const inline = process.argv.find((arg) => arg.startsWith('--runtime='))
if (inline) {
return inline.slice('--runtime='.length)
}
const runtimeIndex = process.argv.indexOf('--runtime')
if (runtimeIndex >= 0) {
return process.argv[runtimeIndex + 1]
}
return null
}
function ensureNodeRuntime() {
const initial = runCurrentProcessCheck()
if (initial.ok) {
return
}
const failedModules = initial.failures.map((failure) => failure.moduleName)
console.warn(
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
)
printCheckError(initial)
runPnpm(['rebuild', ...failedModules])
const final = runCurrentProcessCheck()
if (!final.ok) {
console.error(
`[native-runtime] Native modules still do not load for ${formatRuntimeLabel('node')}.`
)
printCheckError(final)
process.exit(1)
}
}
function ensureElectronRuntime() {
const initial = runElectronCheck()
if (initial.ok) {
return
}
console.warn(
`[native-runtime] ${formatRuntimeLabel('electron')} cannot load native modules; rebuilding native deps for Electron.`
)
printCheckError(initial)
runNodeScript(['config/scripts/rebuild-native-deps.mjs'])
const final = runElectronCheck()
if (!final.ok) {
console.error(
`[native-runtime] Native modules still do not load for ${formatRuntimeLabel('electron')}.`
)
printCheckError(final)
process.exit(1)
}
}
function runCurrentProcessCheck() {
const failures = collectNativeModuleFailures()
if (failures.length === 0) {
return { ok: true, failures }
}
return { ok: false, failures }
}
function runElectronCheck() {
let electronExecutable
try {
electronExecutable = require('electron')
} catch (error) {
return { ok: false, error }
}
const result = spawnSync(electronExecutable, [scriptPath, CHILD_CHECK_FLAG], {
cwd: projectDir,
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1'
},
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
return {
ok: result.status === 0,
status: result.status,
stdout: result.stdout,
stderr: result.stderr,
error: result.error
}
}
function collectNativeModuleFailures() {
const failures = []
for (const moduleName of NATIVE_MODULES) {
try {
loadNativeModule(moduleName)
} catch (cause) {
failures.push({ moduleName, message: formatError(cause), cause })
}
}
return failures
}
function loadNativeModule(moduleName) {
if (moduleName === 'better-sqlite3') {
const Database = require(moduleName)
// Why: better-sqlite3 defers loading its .node binding until Database is
// constructed, so a plain require() misses Node ABI mismatches.
const db = new Database(':memory:')
db.close()
return
}
if (moduleName === 'node-pty') {
loadNodePtyNativeModule()
return
}
require(moduleName)
}
function loadNodePtyNativeModule() {
require('node-pty')
const { loadNativeModule } = require('node-pty/lib/utils')
const nativeName = getNodePtyNativeModuleName()
// Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
loadNativeModule(nativeName)
}
function getNodePtyNativeModuleName() {
if (process.platform !== 'win32') {
return 'pty'
}
return getWindowsBuildNumber() >= 18309 ? 'conpty' : 'pty'
}
function getWindowsBuildNumber() {
const match = /(\d+)\.(\d+)\.(\d+)/g.exec(release())
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
}
function runPnpm(args) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(command, args, {
cwd: projectDir,
stdio: 'inherit',
shell: false
})
if (result.error || result.status !== 0) {
console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
if (result.error) {
console.error(formatError(result.error))
}
process.exit(result.status ?? 1)
}
}
function runNodeScript(args) {
const result = spawnSync(process.execPath, args, {
cwd: projectDir,
stdio: 'inherit'
})
if (result.error || result.status !== 0) {
console.error(`[native-runtime] ${basename(process.execPath)} ${args.join(' ')} failed.`)
if (result.error) {
console.error(formatError(result.error))
}
process.exit(result.status ?? 1)
}
}
function printCheckError(result) {
for (const failure of result.failures ?? []) {
console.warn(`[native-runtime] ${failure.moduleName}: ${failure.message}`)
}
if (result.error) {
console.warn(`[native-runtime] ${formatError(result.error)}`)
}
if (result.stderr?.trim()) {
console.warn(result.stderr.trim())
}
if (result.stdout?.trim()) {
console.warn(result.stdout.trim())
}
if (
result.status != null &&
!result.error &&
!result.stderr?.trim() &&
!result.stdout?.trim() &&
result.status !== 0
) {
console.warn(`[native-runtime] Native check exited with status ${result.status}.`)
}
}
function formatError(error) {
return error instanceof Error ? error.message : String(error)
}
function formatRuntimeLabel(value) {
if (value === 'electron') {
return `Electron ${process.env.npm_package_devDependencies_electron ?? ''}`.trim()
}
return `Node ${process.versions.node}`
}
+174 -3
View File
@@ -1,6 +1,14 @@
import { spawn } from 'node:child_process'
import { execFileSync, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
import {
chmodSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import net from 'node:net'
import { createRequire } from 'node:module'
import path from 'node:path'
@@ -14,6 +22,162 @@ delete process.env.ELECTRON_RUN_AS_NODE
const require = createRequire(import.meta.url)
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const STABLE_NAME_FLAG = '--stable-name'
const rawForwardedArgs = process.argv.slice(2)
// Why: keep an escape hatch for tools that key off Electron's stock app name.
// The flag is runner-only and must not leak into Chromium/electron-vite.
const useStableElectronName =
process.env.ORCA_DEV_STABLE_NAME === '1' || rawForwardedArgs.includes(STABLE_NAME_FLAG)
const forwardedRaw = rawForwardedArgs.filter((arg) => arg !== STABLE_NAME_FLAG)
if (useStableElectronName) {
process.env.ORCA_DEV_STABLE_NAME = '1'
}
function readGitValue(args) {
try {
const value = execFileSync('git', ['-C', repoRoot, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
return value || null
} catch {
return null
}
}
function lastBranchSegment(value) {
return value.replace(/\\/g, '/').split('/').filter(Boolean).at(-1) ?? value
}
function formatDevInstanceLabel(branch, worktreeName) {
if (branch && worktreeName) {
if (branch === worktreeName || lastBranchSegment(branch) === worktreeName) {
return worktreeName
}
return `${worktreeName} @ ${branch}`
}
return branch || worktreeName || null
}
function createDockTitle(branch, label) {
return `Orca: ${branch || label || 'dev'}`
}
function seedDevInstanceIdentityEnv() {
const branch =
process.env.ORCA_DEV_BRANCH ||
readGitValue(['symbolic-ref', '--quiet', '--short', 'HEAD']) ||
readGitValue(['rev-parse', '--short', 'HEAD'])
const worktreeName = process.env.ORCA_DEV_WORKTREE_NAME || path.basename(repoRoot)
const label = process.env.ORCA_DEV_INSTANCE_LABEL || formatDevInstanceLabel(branch, worktreeName)
const identitySeed = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
const dockTitle = process.env.ORCA_DEV_DOCK_TITLE || createDockTitle(branch, label)
process.env.ORCA_DEV_REPO_ROOT ||= repoRoot
process.env.ORCA_DEV_INSTANCE_KEY ||= identitySeed
if (branch) {
process.env.ORCA_DEV_BRANCH ||= branch
}
if (worktreeName) {
process.env.ORCA_DEV_WORKTREE_NAME ||= worktreeName
}
if (label) {
// Why: parallel `pn dev` runs need a stable origin label for window titles,
// Dock names, and automation sessions without re-running git in Electron.
process.env.ORCA_DEV_INSTANCE_LABEL ||= label
}
process.env.ORCA_DEV_DOCK_TITLE ||= dockTitle
}
function setPlistValue(plistPath, key, value) {
execFileSync('/usr/bin/plutil', ['-replace', key, '-string', value, plistPath])
}
function sanitizeBundleIdPart(value) {
return (
value
.toLowerCase()
.replace(/[^a-z0-9.-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'dev'
)
}
function sanitizeMacAppBundleName(value) {
return (
Array.from(value, (char) => {
const code = char.charCodeAt(0)
return code < 32 || code === 127 || char === '/' || char === '\\' ? '-' : char
})
.join('')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120) || 'Orca'
)
}
function prepareMacDevElectronApp() {
if (process.platform !== 'darwin') {
return
}
const sourceAppPath = path.join(repoRoot, 'node_modules', 'electron', 'dist', 'Electron.app')
const electronPackagePath = path.join(repoRoot, 'node_modules', 'electron', 'package.json')
if (!existsSync(sourceAppPath)) {
return
}
let electronVersion = null
try {
electronVersion = JSON.parse(readFileSync(electronPackagePath, 'utf8')).version ?? null
} catch {}
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
const bundleLayoutVersion = 'dock-title-app-filename-v2'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
)
.digest('hex')
.slice(0, 12)
const distDir = path.join(repoRoot, 'out', 'electron-dev', hash)
// Why: macOS Dock hover uses the bundle's filesystem display name for
// electron-vite's direct binary launch path, even when Info.plist is patched.
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
const appPath = path.join(distDir, appBundleName)
const markerPath = path.join(distDir, 'orca-dev-electron-app.json')
const bundleId = `com.stablyai.orca.dev.${sanitizeBundleIdPart(hash)}`
const expectedMarker = JSON.stringify(
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
null,
2
)
if (existsSync(markerPath) && existsSync(appPath)) {
try {
if (readFileSync(markerPath, 'utf8') === expectedMarker) {
process.env.ELECTRON_EXEC_PATH = path.join(appPath, 'Contents', 'MacOS', 'Electron')
return
}
} catch {}
}
rmSync(distDir, { recursive: true, force: true })
mkdirSync(distDir, { recursive: true })
cpSync(sourceAppPath, appPath, { recursive: true })
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
setPlistValue(plistPath, 'CFBundleName', title)
setPlistValue(plistPath, 'CFBundleDisplayName', title)
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
// Why no re-sign: dev launches execute the copied Electron binary directly,
// and Electron's framework bundle is ambiguous to codesign when deep-signing
// an already-built distribution. Avoid blocking `pn dev` on local signing.
writeFileSync(markerPath, expectedMarker, 'utf8')
process.env.ELECTRON_EXEC_PATH = path.join(appPath, 'Contents', 'MacOS', 'Electron')
}
function getDevUserDataPath() {
if (process.env.ORCA_DEV_USER_DATA_PATH) {
@@ -72,6 +236,11 @@ if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') {
prepareDevCliWrapper()
}
seedDevInstanceIdentityEnv()
if (!useStableElectronName && process.env.ORCA_SKIP_DEV_ELECTRON_APP_PREPARE !== '1') {
prepareMacDevElectronApp()
}
// Why: tests inject a tiny fake CLI here so they can verify Ctrl+C tears down
// the full child tree without depending on a real electron-vite install.
const electronViteCli =
@@ -82,7 +251,6 @@ const electronViteCli =
// without manual port juggling. Pick a best-effort deterministic port per
// worktree; falls back to a probe sweep if the deterministic pick or its
// neighbors are busy (multiple worktrees may share a machine).
const forwardedRaw = process.argv.slice(2)
function isPortFree(port) {
return new Promise((resolve) => {
const srv = net.createServer()
@@ -131,6 +299,9 @@ const userPassedPort = forwardedRaw.some(
// Why: --help/--version exit immediately; binding a probe socket and printing
// a debug-port line would be noise.
const isHelpOrVersion = forwardedRaw.some((a) => a === '--help' || a === '-h' || a === '--version')
if (!isHelpOrVersion && process.env.ORCA_DEV_INSTANCE_LABEL) {
console.error(`[orca-dev] Instance: ${process.env.ORCA_DEV_INSTANCE_LABEL}`)
}
let forwardedExtras = []
if (!userPassedPort && !isHelpOrVersion) {
const envPortRaw = process.env.REMOTE_DEBUGGING_PORT
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
import { accessSync, constants, existsSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
function isExecutable(filePath, platform, access = accessSync) {
if (platform === 'win32') {
return true
}
try {
access(filePath, constants.X_OK)
return true
} catch {
return false
}
}
function quoteWindowsArg(value) {
return `"${value.replace(/"/g, '""')}"`
}
function spawnOptionalSetup(spawn, setupPath, worktreePath, platform, env) {
if (platform === 'win32') {
spawn(
env.ComSpec || 'cmd.exe',
['/d', '/s', '/c', `call ${quoteWindowsArg(setupPath)} ${quoteWindowsArg(worktreePath)}`],
{
stdio: 'inherit',
windowsVerbatimArguments: true
}
)
return
}
spawn(setupPath, [worktreePath], {
stdio: 'inherit'
})
}
export function runInternalDevSetup({
env = process.env,
cwd = process.cwd(),
platform = process.platform,
exists = existsSync,
access = accessSync,
spawn = spawnSync
} = {}) {
const setupPath = env.ORCA_INTERNAL_DEV_SETUP?.trim()
if (!setupPath || !exists(setupPath) || !isExecutable(setupPath, platform, access)) {
return 0
}
// Why: this hook is an optional local accelerator; failures should not block
// creating a worktree or running the normal dependency install.
spawnOptionalSetup(spawn, setupPath, env.ORCA_WORKTREE_PATH || cwd, platform, env)
return 0
}
if (process.argv[1] && resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1])) {
process.exit(runInternalDevSetup())
}
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import { runInternalDevSetup } from './run-internal-dev-setup.mjs'
describe('runInternalDevSetup', () => {
it('does nothing when ORCA_INTERNAL_DEV_SETUP is unset', () => {
const spawn = vi.fn()
expect(runInternalDevSetup({ env: {}, exists: vi.fn(), spawn })).toBe(0)
expect(spawn).not.toHaveBeenCalled()
})
it('does nothing when the configured setup path does not exist', () => {
const spawn = vi.fn()
expect(
runInternalDevSetup({
env: { ORCA_INTERNAL_DEV_SETUP: '/tmp/missing' },
exists: () => false,
spawn
})
).toBe(0)
expect(spawn).not.toHaveBeenCalled()
})
it('skips non-executable setup paths on POSIX', () => {
const spawn = vi.fn()
expect(
runInternalDevSetup({
env: { ORCA_INTERNAL_DEV_SETUP: '/tmp/setup' },
platform: 'linux',
exists: () => true,
access: () => {
throw new Error('EACCES')
},
spawn
})
).toBe(0)
expect(spawn).not.toHaveBeenCalled()
})
it('runs the optional setup with the worktree path and ignores its exit status', () => {
const spawn = vi.fn(() => ({ status: 1 }))
expect(
runInternalDevSetup({
env: {
ORCA_INTERNAL_DEV_SETUP: '/tmp/setup',
ORCA_WORKTREE_PATH: '/tmp/worktree'
},
platform: 'linux',
exists: () => true,
access: () => undefined,
spawn
})
).toBe(0)
expect(spawn).toHaveBeenCalledWith('/tmp/setup', ['/tmp/worktree'], {
stdio: 'inherit'
})
})
it('uses cmd.exe with explicit quoting so .cmd setup shims receive paths with spaces', () => {
const spawn = vi.fn(() => ({ status: 0 }))
expect(
runInternalDevSetup({
env: {
ORCA_INTERNAL_DEV_SETUP: 'C:\\tools\\setup.cmd',
ORCA_WORKTREE_PATH: 'C:\\repo\\worktree'
},
platform: 'win32',
exists: () => true,
spawn
})
).toBe(0)
expect(spawn).toHaveBeenCalledWith(
'cmd.exe',
['/d', '/s', '/c', 'call "C:\\tools\\setup.cmd" "C:\\repo\\worktree"'],
{
stdio: 'inherit',
windowsVerbatimArguments: true
}
)
})
})
-21
View File
@@ -1,21 +0,0 @@
# Orca CLI Docs
Keep this folder focused on the durable references for Orca's public CLI and runtime model.
## Keep These Docs
- `orca-cli-focused-v1-status.md`
- What is actually implemented and intentionally in scope now.
- `orca-cli-v1-spec.md`
- The public CLI contract, selector grammar, JSON envelope, and command semantics.
- `orca-runtime-layer-design.md`
- Why the runtime layer exists and which boundaries it owns.
- `orca-cli-bundled-distribution.md`
- How the bundled desktop-app distribution and PATH registration model works.
## Why The Folder Is Small
Earlier design and implementation work produced several planning and evaluation docs.
Those were useful while the feature was taking shape, but they were intentionally removed
once the implementation converged so future readers are not forced to choose between
multiple overlapping sources of truth.
+1
View File
@@ -57,6 +57,7 @@ For diff status, file-tree decorations, and the changes view, use the git decora
| `--git-decoration-renamed` | Renamed |
| `--git-decoration-untracked` | Untracked |
| `--git-decoration-copied` | Copied |
| `--git-decoration-ignored` | Ignored by git |
Use these *only* for git status. Don't reuse them for unrelated state colors — that breaks the convention.
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

-36
View File
@@ -1,36 +0,0 @@
# Claude Runtime Auth Switching
Orca switches Claude Code accounts by materializing a selected managed account into Claude's shared runtime auth surfaces. The runtime surfaces are:
- `.claude/.credentials.json`
- macOS scoped Keychain credentials for the active `CLAUDE_CONFIG_DIR`
- macOS legacy `Claude Code-credentials`
- `.claude.json` `oauthAccount`
## Core Invariants
1. Never write a user/runtime surface unless Orca can prove it owns the current value on that surface.
2. Treat each credential surface independently. File, scoped Keychain, and legacy Keychain can each be owned, external, missing, or unknown.
3. `oauthAccount` metadata is restored or cleared only when metadata itself matches Orca's last managed value, or when a credential surface has already proved the current runtime state belongs to the managed account being cleaned up.
4. Invalid or unparsable runtime config is unknown, not null. Unknown config must be preserved.
5. Missing managed account records are unknown. Orca clears the active selection but does not mutate runtime auth without account identity proof.
6. Missing managed credentials for an existing account can be cleaned up using the account record identity. Only surfaces whose current credentials match that account are restored or cleared.
7. Read-back of refreshed tokens must evaluate all runtime credential candidates and persist only a single unambiguous managed-account match.
## Snapshot Policy
Before entering managed mode from system mode, Orca captures the system-default runtime state. On restore, the snapshot is only applied to surfaces whose current value still equals Orca's managed value, except for missing-managed-credential recovery where account identity is the proof.
Snapshots are schema-validated before use. Invalid snapshots are deleted and treated as absent.
When recapturing while the credentials file still equals the managed account, Orca preserves any previous snapshot value for Keychain surfaces that still equal the managed credentials. This prevents a failed restore followed by restart from recapturing managed Keychain values as system defaults.
## Read-Back Policy
Claude can refresh OAuth tokens in any runtime credential surface. Orca reads all available candidates, filters out stale or ambiguous matches, then chooses the freshest accepted candidate. Cold-start read-back is conservative: credentials must be newer than the matched managed account. Warm read-back rejects only metadata-proven older credentials, allowing equal-expiry token rotation.
## Failure Policy
Keychain reads during snapshot capture must succeed for both active services on macOS; otherwise Orca aborts managed entry. Best-effort Keychain reads are acceptable for token read-back because another surface may still contain a fresh candidate.
Add-account cleanup must restore/delete the legacy active Keychain item before reporting success. If cleanup fails, the login is treated as failed so Orca does not silently leave the user's legacy Claude state pointing at the captured account.
+126
View File
@@ -0,0 +1,126 @@
# Configurable Open In Menu
## Current behavior (code-checked)
- `WorktreeOpenInMenu` renders exactly two entries: `VS Code` and platform file manager (`Finder`/`File Explorer`/`File Manager`).
- `openWorktreePath()` blocks local launches for remote/server context via `isLocalPathOpenBlocked(...)` before IPC.
- Renderer calls `window.api.shell.openInExternalEditor(path)` with one argument only.
- Preload/API types expose `openInExternalEditor(path: string)` only.
- Main IPC `shell:openInExternalEditor` always launches hardcoded `code` (via `resolveCliCommand('code')`), validates absolute+exists path, and returns `{ ok: false, reason: 'not-absolute' | 'not-found' | 'launch-failed' }` on failure.
- Settings persistence currently shallow-merges most fields. Only `notifications` and `telemetry` are deep-merged in main; renderer deep-merges `notifications`, `telemetry`, and `voice` locally.
- `settings:set` returns the merged settings object, but the renderer currently ignores that return value and applies its own optimistic merge.
## Goal
Add configurable extra editor launchers (Cursor, Zed, custom) to the worktree sidebar `Open in` submenu while preserving:
- VS Code as fixed default entry.
- existing local-path blocking for remote/SSH/server contexts.
- existing path validation and failure-to-toast behavior.
## Non-goals
- No remote editor launching.
- No command existence checks during settings editing.
- No shell template expansion, env interpolation, per-launcher cwd overrides, or argv parsing.
- No removal/configuration of the built-in VS Code row.
## Data model
Add to `GlobalSettings`:
- `openInApplications?: OpenInApplication[]`
- `type OpenInApplication = { id: string; label: string; command: string }`
Default in `getDefaultSettings()`:
- `openInApplications: []`
Why optional in type but present in defaults: keeps backward compatibility with older persisted files while giving runtime code a stable default after merge.
## Normalization contract
Implement one shared normalizer in `src/shared/` and use it in both renderer update path and main persistence update path. It must run in main before persistence.
Rules:
- trim `label`/`command`.
- drop rows with empty `label` or empty `command`.
- drop rows with duplicate `id` (keep first).
- if `id` is missing/blank, generate one (e.g. `crypto.randomUUID()` in renderer before save).
- cap length (e.g. 8).
Do not dedupe by `command`: users may intentionally keep separate labels for the same command with different wrappers/scripts later.
Main remains source of truth. Renderer normalization is UX only; main normalization must always run.
Also normalize on load (not only on `settings:set`) so externally edited/stale persisted rows are repaired on startup.
## IPC and launch behavior
Change signature across shared preload surface and main handler:
- `openInExternalEditor(path: string, command?: string)`
Main launch behavior:
- if `command` is missing/blank after trim, fall back to `code`.
- otherwise resolve and launch that command token (same resolution path currently used for `code` via `resolveCliCommand`).
- keep existing `validateLocalPathTarget(...)` path checks.
- keep `getSpawnArgsForWindows(command, [path])` path for Windows.
- keep detached spawn semantics and `launch-failed` mapping.
Constraint to document in UI copy: command is not shell-parsed. `cursor --new-window` is treated as a binary name and will fail. Users must provide an executable command (or wrapper script) only.
Important: this is not “free”. Every call site and type layer (`preload/index.ts`, `preload/api-types.ts`, renderer callers, `shell.test.ts`) must be updated together.
## Menu behavior
`WorktreeOpenInMenu` order:
1. `VS Code` (fixed)
2. configured `openInApplications`
3. file manager
Each configured row invokes `openInExternalEditor(worktreePath, app.command)`.
Remote/local guard stays exactly where it is now (`openWorktreePath`) so all rows (including file manager) remain blocked consistently in remote contexts.
## Settings UI
Add General section: `Open In Menu`.
- static note: VS Code is always included.
- presets: add Cursor (`cursor`) and Zed (`zed`).
- editable rows for label/command.
- remove row.
- add custom row.
Search indexing: add entries in `general-search.ts` so settings search can discover this section.
## Consistency and concurrency
- Multi-window: renderer has a `settings:changed` listener, but main currently emits that event only for View > Appearance toggles. `settings:set` does not broadcast generic updates. So edits to `openInApplications` in one window are not guaranteed to appear live in another until reload/fetch.
- Renderer optimistic merge is temporary UI state only. Main write result is authoritative and may normalize away invalid rows. Use the `settings:set` return value to rebase local state immediately after each write.
- Concurrent edits are last-write-wins at the field level (`openInApplications` array replaced wholesale). There is no compare-and-swap/version guard.
- External file mutation of `orca-data.json` is only observed on app restart; no live file watch exists.
## Edge cases
- Empty/whitespace command or label -> dropped by normalizer.
- Missing `openInApplications` in persisted settings -> treated as `[]` via defaults merge.
- Duplicate IDs (including hand-edited config) -> first survives, later rows dropped.
- Command strings with spaces/flags (e.g. `cursor --foo`) -> fail at spawn unless provided via wrapper executable.
- Launcher not in PATH or non-executable -> `launch-failed` and existing toast.
- Path exists but is a file (not directory) still passes current validation and will be passed to launcher/file manager; this is existing behavior.
- Relative/non-existent worktree path -> existing `not-absolute`/`not-found` error flow.
- Remote/server worktree -> blocked before IPC regardless of configured launchers.
## Tests required
- `shell.test.ts`: optional command path, blank command fallback to `code`, Windows spawn arg path, failure mapping.
- `WorktreeOpenInMenu.test.tsx`: configured rows render in order, command forwarded, remote guard still blocks all targets.
- settings normalization tests (shared normalizer + persistence update path): trim, drop invalid rows, cap, duplicate-id behavior.
- settings normalization tests: missing/blank id handling and generated-id stability on edit.
- `settings:set`/renderer integration test: renderer applies authoritative returned settings, not only optimistic local merge.
- General settings search entries include new section keywords.
@@ -1,198 +0,0 @@
# SSH Remote Workspace Sync Plan
Planning note for refreshing PR #1690 against current `main`. This is not a merge plan for #1690; current `main` already owns same-client SSH PTY persistence through durable remote PTY leases, relay grace reconnect, explicit expired-session behavior, and provider cleanup semantics.
## Product Boundary
Orca should support an opt-in mode for an SSH target where terminal workspace chrome for that target is stored on the remote host and can be restored by another Orca client connecting to the same target.
In scope:
- Per-SSH-target remote workspace snapshots: terminal tabs, split layouts, active worktree/tab state, and remote PTY IDs needed to reattach live relay PTYs.
- Device presence for clients currently connected to the same synced target.
- Cross-client PTY reattach when the remote relay still owns the PTY.
- Conflict-safe snapshot writes with revisions and stale-write handling.
- Packaged app relay asset lookup and deployment reliability.
Out of scope:
- A general cloud sync service.
- Syncing local repos, local PTYs, local browser runtime state, credentials, secrets, shell history, or local filesystem paths.
- Guaranteed survival after the remote relay/host process exits.
- Automatic agent recovery as core PTY persistence.
Keep explicit/user-driven:
- Enabling sync per SSH target.
- Terminating remote sessions through an explicit destructive action. Normal disconnect detaches only.
- Resuming expired Claude/Codex agent sessions. Show an affordance using stored metadata; do not auto-inject resume commands into a shell.
- Resolving true conflicts that cannot be merged by target/worktree scope.
## Gap Analysis
Already covered on current `main`:
- Same-client SSH PTY survival across close/quit/reconnect through `activeConnectionIdsAtShutdown`, `remoteSessionIdsByTabId`, durable `SshRemotePtyLease` records, relay grace reconnect, and renderer deferred reattach.
- Explicit expired-session behavior: failed `pty.attach` marks leases expired and the renderer starts from the expired state rather than silently pretending the old process survived.
- Provider absence and relay teardown semantics: disconnect detaches, terminate/remove is destructive, and provider registration/ownership are centralized in `SshRelaySession`/`pty.ts`.
- Relay-side PTY buffering, attach, shutdown, and grace timers.
#1690 adds beyond that:
- Relay-side `workspace.*` RPCs and JSON snapshot storage on the remote host.
- A target-scoped session filter and scoped merge so one target's snapshot does not overwrite unrelated local/remote workspaces.
- Remote workspace IPC (`remoteWorkspace:*`) and preload API.
- Renderer hydration of remote snapshots and event-driven application of `workspace.changed`.
- Device presence polling and status UI.
- Multi-client relay dispatcher/socket support so more than one Orca client can talk to the same relay daemon.
- Resize ownership logic so background clients do not resize a shared remote PTY.
- Packaged relay asset lookup through `process.resourcesPath`/`extraResources`.
- Agent resume metadata tied to pane keys. This remains deferred for this implementation.
Now redundant or risky from #1690:
- Do not copy the renderer terminal restore changes wholesale. They overlap directly with recently stabilized `reconnectPersistedTerminals`, deferred SSH session IDs, split-leaf PTY mappings, and expired-session handling.
- Do not replace same-client persistence with relay snapshots. Local workspace session remains the source of truth for classic SSH targets and for non-synced targets.
- Do not default sync to on for existing or new targets until product decides retention/privacy behavior.
- Do not use unlimited relay lifetime as an implicit consequence of sync without a visible setting and cleanup story.
- Do not auto-run agent resume commands after an expired PTY.
## Fresh Architecture
Use two separate layers:
1. **Local session persistence remains authoritative for this Orca install.** It continues to write normal workspace sessions and durable SSH PTY leases exactly as current `main` does.
2. **Remote workspace sync is an opt-in target-level overlay.** When enabled and connected, Orca pushes only that target's scoped workspace slice to the relay and hydrates only that target's slice from the relay.
Data ownership:
- Main process owns target configuration, active SSH sessions, remote workspace IPC, snapshot revision checks, ID translation, and old-relay compatibility handling.
- Relay owns synced snapshot storage under a per-user remote directory, keyed by a sanitized namespace derived from stable target identity.
- Renderer owns local Zustand workspace state and applies remote snapshots only through a guarded hydration path.
- PTY lifetime remains owned by the relay and current `SshRemotePtyLease`/PTY ownership maps. Remote snapshots store PTY IDs as references, not as process ownership.
Remote snapshot shape:
- `namespace`, `revision`, `updatedAt`, `schemaVersion`, `clientId`, and `session`.
- `session` is an explicit v1 projection, not raw `WorkspaceSessionState`.
- v1 includes only terminal/worktree state, keyed by stable remote worktree path. Import/export translates between remote worktree paths and local `repoId::path` IDs at the main/renderer boundary.
- Editor/browser chrome, browser URL history, browser profile IDs, and global active connection IDs are excluded from v1 to avoid syncing local-install identifiers or privacy-sensitive local state.
- Additive fields are okay; wrong-typed core fields must fall back safely.
Sync flow:
- On connect for sync-enabled target: register providers, fetch worktrees, fetch `workspace.get`, translate remote path IDs to local worktree IDs, scope-merge into local state, then let existing terminal reattach paths consume the resulting PTY IDs.
- Local state remains authoritative immediately after startup. A remote snapshot is applied only if this target has no local dirty state newer than the last synced revision, or the user explicitly refreshes from remote.
- On local session write: if workspace session is ready and the target was hydrated, export only that target's terminal/worktree slice using remote worktree paths and `workspace.patch` with the last known revision.
- On stale revision: if both local and newer remote touched the same worktree since the base revision, set conflict state. Otherwise preserve out-of-scope/untouched remote state, merge local touched worktrees, and retry once.
- On relay `workspace.changed`: ignore self-originated events, queue per target, apply only newer revisions, and avoid triggering a resize unless there is trusted local foreground intent.
Failure semantics:
- Old relay without `workspace.*`: keep classic SSH behavior, mark sync "unavailable until reconnect/update", and do not fail SSH connection.
- Remote snapshot parse failure: ignore the remote snapshot, leave local session intact, and show sync error for that target.
- Patch failure: keep local session as-is and retry on next session write or reconnect.
- Expired PTY referenced by remote snapshot: use the current expired-session path; do not silently replace with a live shell unless the existing UI path already does that intentionally.
- Explicit disconnect keeps remote PTYs according to current detach semantics. `Terminate remote sessions` kills PTYs. Target removal is destructive after confirmation and must clear or tombstone that target's synced PTY references.
## Phased Implementation Plan
1. **Relay packaging and compatibility PR**
- Add relay `extraResources` packaging and `getLocalRelayCandidates(platform)` lookup using `process.resourcesPath`.
- Keep current dev path and `ORCA_RELAY_PATH`.
- Add tests for packaged candidate order and missing package diagnostics.
2. **Target setting and main IPC skeleton PR**
- Add `remoteWorkspaceSyncEnabled` and bounded `remoteWorkspaceSyncGracePeriodSeconds` to `SshTarget`.
- Default to false unless product explicitly chooses otherwise.
- Add preload/main `remoteWorkspace:*` IPC behind feature availability.
- No renderer hydration yet; verify old targets normalize cleanly.
3. **Relay workspace snapshot RPC PR**
- Add relay `workspace.get`, `workspace.patch`, and `workspace.presence`.
- Store snapshots atomically under a user-private remote directory.
- Include namespace sanitization, revisions, schema version, and stale-revision responses.
- Wire old-relay method-not-found handling in main as "sync unavailable", not connection failure.
4. **Projected session filtering/merge PR**
- Implement target-scoped session extraction in main using repo `connectionId`.
- Export/import terminal workspace state through a path-keyed projected schema.
- Implement merge that replaces only known in-scope worktrees and preserves out-of-scope remote state.
- Cover terminal tabs, split layouts, active IDs, `remoteSessionIdsByTabId`, and last-visited timestamps. Defer editor/browser chrome.
5. **Renderer guarded hydrate/push PR**
- Fetch synced snapshots only after target worktrees are loaded.
- Apply snapshots through a remote-hydration guard and per-target queue.
- Push snapshots from the existing debounced session writer only for hydrated, connected, sync-enabled targets.
- Reuse existing `reconnectPersistedTerminals`; only feed it scoped remote session IDs.
- Do not enable live cross-client PTY attach unless the multi-client dispatcher and resize ownership from phase 6 are also present.
6. **Multi-client relay + resize ownership PR**
- Extend relay dispatcher/socket bridge to support independent client frame state.
- Broadcast notifications to all clients, but keep request/response routing per client.
- Add foreground/local-intent resize ownership so background hydration or non-focused clients do not resize shared PTYs.
- Treat mobile fit locks and current PTY locks as higher priority than desktop resize.
7. **Presence and status UI PR**
- Add lightweight presence polling and status display for sync-enabled connected targets.
- Show sync phase: idle, pulling, pushing, synced, conflict, error, unavailable/offline.
- Follow `docs/STYLEGUIDE.md` tokens and existing status bar patterns.
8. **Agent resume metadata polish PR**
- Persist provider/session/cwd metadata as recovery hints only.
- Add an explicit "Resume Claude/Codex session" action when a synced PTY expired.
- Avoid auto-sending commands on mount or after snapshot hydration.
## Test Plan
Unit:
- Target normalization preserves existing targets and defaults sync off.
- Namespace derivation is stable and sanitization rejects path traversal/control chars.
- Relay snapshot read/write handles missing, corrupt, stale, and atomic-write cases.
- Target-scoped filter excludes local repos and other SSH targets.
- Projected snapshot import/export translates stable remote worktree paths to local `repoId::path` IDs.
- Scoped merge preserves out-of-scope remote worktrees and deletes in-scope records when the remote snapshot says they are gone.
- Stale revision retry preserves edits from the newer remote snapshot.
- Old relay `-32601` maps to sync unavailable.
- Packaged relay candidate lookup searches `process.resourcesPath` and dev paths.
- Resize ownership gates remote resize on focused/local user intent.
Integration:
- Current same-client persistence still passes: close/quit/reconnect restores SSH PTYs without remote workspace sync enabled.
- Sync-enabled target push writes only that target's session slice.
- Cross-device simulation with two mux clients: client A pushes, client B receives `workspace.changed`, hydrates, and attaches the same relay PTY.
- Two clients patch concurrently: one stale response, one retry, no out-of-scope data loss.
- Explicit disconnect detaches but preserves leases; terminate sessions kills remote PTYs and marks leases terminated.
- Relay restart or expired grace marks PTY references expired and does not auto-resume agents.
- Provider-registration wait, if still needed, waits only for restored SSH reattach and does not mask normal spawn failures.
E2E/manual:
- Packaged macOS/Linux/Windows app can deploy the relay from packaged resources.
- Old running relay continues classic SSH behavior and shows sync unavailable until reconnected with a new relay.
- Two Orca clients on different machines connect to the same target, open a shared remote workspace, and verify tab/layout sync.
- Resize a focused terminal on one client while another client is backgrounded; full-screen TUI dimensions follow only the focused client.
- Mobile/desktop mixed client does not fight PTY dimensions when mobile fit lock is active.
- Passphrase-protected targets defer reconnect without losing remote session IDs.
## Risks And Decisions
Decisions before implementation:
- Default: should remote workspace sync be opt-in off for all targets? Recommendation: yes.
- Retention: is `0` unlimited relay lifetime acceptable, or should sync use a large bounded default with visible cleanup?
- Namespace identity: include `configHost`, host, port, and username. Label changes do not affect namespace.
- Snapshot scope: v1 is terminal + layout + active worktree/tab only. Editor/browser comes later with explicit allowlists and translation rules.
- Conflict UX: retry once automatically, then surface "Refresh from remote" / "Overwrite remote" choices.
- Agent resume UX: explicit action location in expired terminal state or status toast.
Main risks:
- Cross-device resize fights can corrupt TUI layout; ship ownership gates with the first cross-client PTY attach.
- Snapshot merge bugs can delete unrelated local or remote workspace state; keep target filtering/merge isolated and heavily tested.
- Unlimited remote relay lifetime can leave processes running unexpectedly; make retention visible and terminate explicit.
- Old relays may live across app updates; every new RPC must fail open.
- Renderer hydration can trigger existing session writers and feedback loops; use hydration guards and per-target revision tracking.
- Remote snapshots contain path/workspace metadata on the SSH host; treat as local-to-remote-user data and document the storage path.
-132
View File
@@ -1,132 +0,0 @@
# GitHub Work Item Drawer: Fix Reopen Flash
## Symptom
After #1655, reopening a GH issue/PR drawer can still flash a loading/skeleton frame before cached content appears.
## Verified root cause
`GitHubItemDialog` currently paints from component state (`details`, `loading`, `error`) that is updated in an effect (`src/renderer/src/components/GitHubItemDialog.tsx:2480+`).
Current sequence on close/reopen of the same item:
1. Close path runs effect branch with no `workItem` and calls `setDetails(null)`.
2. Reopen renders once with `details === null`.
3. Effect reads `workItemDetailsCache` and then calls `setDetails(painted)`.
4. Second render shows cached content.
With sheet animation, that first empty paint is visible as a flash.
## Refactor direction
Move drawer paint state to the module cache and subscribe with `useSyncExternalStore`. The drawer should read the cache entry synchronously during render.
This is not optional if we want reopen to paint cached content on first render.
## Required cache-store changes
Add a store subscription layer around `workItemDetailsCache`:
- `subscribeWorkItemDetailsCache(listener)`
- `notifyWorkItemDetailsCache()`
Call `notifyWorkItemDetailsCache()` after every actual cache mutation:
- `touchWorkItemDetailsCache`
- `invalidateWorkItemDetailsCacheForKey`
- `invalidateWorkItemDetailsCacheByMatch`
Also increment `workItemDetailsCacheGeneration` on **all** invalidations, not only `invalidateWorkItemDetailsCacheByMatch`. Otherwise an in-flight fetch launched before exact-key invalidation can repopulate stale data.
## `useSyncExternalStore` contract details
Use:
```ts
const cachedEntry = useSyncExternalStore(
subscribeWorkItemDetailsCache,
() => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined)
)
```
Important constraints:
- `getSnapshot` must return a referentially stable value when store data has not changed.
- Returning the map entry object is valid **only if** writes replace entry identity (`delete+set` with a new entry object).
- Do not build fresh wrapper objects in `getSnapshot` (that would violate stability and can cause render loops).
## Component state changes
Remove component-local `details/loading/error` state for work-item details.
Derive view state from `cachedEntry`:
- `details = cachedEntry?.details ?? null`
- blocking `loading = !!cachedEntry?.pending && !cachedEntry?.details`
- blocking `error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null`
Keep stale-on-error behavior by preserving `details` in cache while setting `error`.
## Effect responsibilities after refactor
The main effect should:
- handle item-change bookkeeping (`prevItemIdRef`, `optimisticCommentsRef`, `setTab`)
- decide freshness and in-flight dedupe
- launch fetch and write results/errors/pending back into cache
The effect should not call `setDetails`, `setLoading`, or `setError` for work-item details.
## Optimistic comment race and merge rules
`appendOptimisticComment` must write through cache only (no local `setDetails`).
Rules:
- push comment into `optimisticCommentsRef`
- patch cache entry comments (if present), mark stale (`fetchedAt: 0`), notify
- derive rendered comments from cached details plus optimistic refs
Dependency pitfall:
- do not depend on a freshly-created array from `optimisticCommentsRef.current` in `useMemo`
- memo should key off stable signals (cache entry identity); cache notifications will re-render after optimistic writes anyway
This avoids unnecessary recomputation and avoids accidental infinite rerender patterns from unstable deps.
## Invalidation while drawer is open
When `invalidateWorkItemDetailsCacheByMatch` deletes the current key while drawer is mounted:
- subscription notifies immediately
- render sees `cachedEntry === undefined`
- UI falls back to loading/empty state and effect triggers refetch
This is expected and preferred over showing stale state.
## Fetch race constraints (must keep)
Keep current protections:
- per-key in-flight dedupe via `entry.pending`
- generation guard to block stale writes after invalidation
- stale-on-null handling (`result === null` must not wipe existing valid cached details)
Add one missing consistency rule:
- if fetch resolves after invalidation and key was dropped, do not recreate entry unless generation still matches launch generation.
## Scope of deletion
Delete only the work-item-detail local state and call sites:
- `useState<GitHubWorkItemDetails | null>` (`details`)
- `useState<boolean>` (`loading`) for this data path
- `useState<string | null>` (`error`) for this data path
- corresponding `setDetails`/`setLoading`/`setError` calls in the details effect and optimistic comment append path
Do not touch unrelated loading/error state in other subviews.
## Expected result
Reopen of a cached item paints content on first render (no flash). Background refetch still runs when stale, and cross-window/local invalidations remain authoritative.
-118
View File
@@ -1,118 +0,0 @@
# GitHub Work Item Drawer: Cache & Latency
## Problem
`GitHubItemDialog` clears `details` to `null` on every open and waits for `gh:workItemDetails` before rendering. Reopening the same item therefore pays full IPC + `gh` process startup latency again.
The slow path is mostly process overhead and fan-out in `src/main/github/work-item-details.ts`, not GitHub API quota.
## What is true today
- `gh api --cache` reduces network/API pressure, but it does not remove local process startup, JSON parsing, or IPC serialization.
- A 304 response does not consume primary REST rate-limit quota, but it is still a network round-trip. With `--cache`, many calls are served from local cache anyway.
- The issue details path is currently multi-call:
1. `getWorkItem` (`/issues/:n` or PR fallback).
2. `getIssueBodyAndComments` (`/issues/:n` and `/issues/:n/comments`).
3. `getWorkItemParticipants` (GraphQL participants).
4. `getMentionParticipants` (GraphQL user hydration for visible authors).
So “3 spawns” understates the real hot path.
## Goals
- Reopen latency should be near-instant for recently viewed items.
- Cold issue-open latency should drop by reducing `gh` command count.
- Correctness must survive local mutations, repo/source switches, and multi-window usage.
## Non-goals
- No disk-persistent cache in this iteration.
- No attempt to replace PR files/diff path with GraphQL.
## Design
### 1. Renderer SWR cache with paint-first reads
Add a module-level LRU cache in `GitHubItemDialog.tsx` keyed by:
`repoPath + issueSourcePreference + type + number`
Cache value:
- `details`
- `fetchedAt`
- `pending?: Promise`
- `error?: string`
Behavior:
1. On open, render cached `details` immediately when present (do not clear to `null`).
2. If entry age <= `FRESH_MS` (30s), skip fetch.
3. If stale or missing, fetch in background and replace cache + UI when resolved.
4. On fetch failure with cached data, keep stale data visible and show non-blocking error state.
5. On fetch failure without cached data, show blocking error state.
### 2. In-flight dedupe in renderer
Store a single pending promise per key in the same cache entry. Concurrent opens/re-renders for the same key must await the same promise.
This dedupe must be keyed the same as the data cache key to avoid cross-repo or cross-source collisions.
### 3. Explicit invalidation rules
“Background refetch is authoritative” is necessary but not sufficient.
Invalidate (or patch + mark stale) on successful local mutations:
- issue state/labels/assignees/body edits
- new comments/reactions
- PR review comment create/resolve
Scope:
- per-item key in current window
- broadcast to other windows via main-process event (`gh:workItemMutated`) so their caches invalidate too
Also invalidate on context switches:
- `repoPath` change
- issue source preference change (`origin`/`upstream`/`auto`)
- sign-out/account change
For out-of-band mutations (web UI/other tools), rely on TTL + manual refresh action in drawer header.
### 4. Main-process issue fetch collapse (GraphQL-first)
Do not claim “one call returns everything” unless we actually ship and verify it.
Feasible single GraphQL issue query fields:
- issue body
- labels
- assignees
- participants
- comments(first: N) with author login + avatarUrl + body + createdAt + url
Limits and required fallbacks:
- GraphQL pagination still applies (`first: 100`). More comments require paging.
- Some comment authors can be null/ghost; renderer must keep existing fallback behavior.
- If GraphQL fails (permissions, partial errors), fall back to current REST+GraphQL path.
- Keep `getMentionParticipants` only if query omits non-participant visible authors; otherwise remove it.
PR path remains unchanged in this doc. PR file/diff/check behavior is intentionally out of scope.
## Edge cases this design must handle
- Reopen same item after optimistic comment: optimistic comment must survive stale cache reads until authoritative fetch includes it.
- Switching between upstream/origin issue source with same issue number must never reuse the wrong cache entry.
- Item-type collision (`issue #123` vs `pr #123`) must never reuse cache entry.
- Drawer close/open races: stale request responses must still be dropped (`requestIdRef` guard stays).
- Unauthorized/404 should not overwrite valid cached data with empty shells.
## Rollout
1. Implement renderer SWR + in-flight dedupe + stale-on-error behavior.
2. Add mutation-driven invalidation and cross-window invalidation event.
3. Implement GraphQL-first issue details with strict fallback.
4. Keep telemetry: measure open-to-first-paint and open-to-fresh-data before/after.
-651
View File
@@ -1,651 +0,0 @@
# Mobile scrollback parity: hydrate headless emulator from renderer on first PTY data
## Problem
When a mobile companion client subscribes to a terminal, it sees substantially less scrollback than the desktop renderer for the same PTY. In the worst case observed (a `claude`-then-shell session in the `edit-issues` worktree), the desktop shows ~30 lines of post-exit agent summary while mobile shows a single bare prompt.
The mismatch is reproducible and not a transient race: it persists across resubscribes, app return-from-background, and PTY-clean shell prompts.
## Where data lives today
Two separate xterm.js instances exist for every connected PTY:
1. **Renderer xterm** — owned by `TerminalPane` in the renderer process. Created when the user mounts the pane. Listens to `pty:data` IPC for the lifetime of the pane. 50,000-row scrollback. Source of truth for the visible desktop terminal.
2. **Runtime headless emulator** — `HeadlessEmulator` in `src/main/runtime/orca-runtime.ts`. Created lazily on first `runtime.onPtyData(ptyId, …)` call. 5,000-row scrollback. Used to serve `terminal.read`, `terminal.subscribe`, `lastAgentStatus`, and TUI-idle detection. This is what mobile, the CLI, and any non-renderer consumer get.
| Consumer | Reads from | Lifetime |
|---|---|---|
| Desktop pane | Renderer xterm | Pane mount → unmount |
| Mobile (`terminal.subscribe`) | Headless emulator (primary), renderer (fallback) | Runtime startup → PTY exit |
| `terminal.read` (CLI / agent) | Headless emulator | Runtime startup → PTY exit |
| `lastAgentStatus` | Headless emulator (via OSC detection on `onPtyData`) | Runtime startup → PTY exit |
The two xterms are fed by the same provider stream but are "born" at different moments:
- The renderer xterm gets hydrated on attach via cold-restore data (`result.coldRestore.scrollback`) or warm reattach (`result.snapshot`) in `pty-connection.ts`, plus all live bytes since.
- The headless emulator only sees bytes that flow through `runtime.onPtyData` since *runtime* startup. After an Orca relaunch, the headless emulator starts empty and only catches up on new live data; it never replays the cold-restore payload.
- Commit `8a5ea4b7` added `seedHeadlessTerminal(ptyId, data, size?)` which runs from `ipc/pty.ts` on `provider.spawn` and seeds the emulator from `result.snapshot` / `result.coldRestore.scrollback`. That helps daemon-restored sessions but **does not help when the daemon's persisted state is itself near-empty** — the `edit-issues` reproduction has `checkpoint.json` with `scrollbackAnsi: ""` because the daemon checkpoint only stores the visible screen, not real scrollback.
So the dominant residual case is: daemon checkpoint is near-empty, the user opens a desktop pane (which hydrates from the live PTY post-spawn and accumulates a rich 50k-row buffer), then the user opens mobile — and mobile reads from the headless emulator, which has only seen bytes since runtime startup with nothing to replay.
## Goal
Mobile, CLI, and `lastAgentStatus` should display the same scrollback as the desktop renderer for any PTY whenever the user has a desktop pane mounted for it. The fix must:
- Be a small, targeted change to the runtime (no daemon, IPC schema, or persistence-format changes).
- Not regress the "no desktop pane mounted" cases (background terminals, headless agent runs, CLI on a freshly-restored daemon).
- Not introduce reflow / SGR drift between desktop and mobile.
- Not introduce a per-keystroke or per-subscribe renderer round-trip on the hot path.
- Reinforce — not violate — the existing invariant that the main process is authoritative for terminal state served to mobile/CLI/`terminal.read`.
## Non-goals
- **Mobile-only-after-relaunch (Scenario A).** A user who relaunches Orca and opens mobile *without* ever opening the desktop pane will still see only what the daemon checkpoint stored (often the visible screen and nothing more). Solving this requires daemon-side persisted scrollback, which is a separate, larger effort with its own privacy, format-stability, retention, and IO-cost considerations. This design explicitly does not solve Scenario A.
- Persisting full scrollback to disk (the "option 2" architecture).
- Unifying the renderer and headless emulators into a single main-process service.
- Changing mobile's xterm-side replay logic. Today it correctly handles either source.
- Changing the read-priority order in `serializeTerminalBufferFromAvailableState`. Headless stays primary; renderer stays the no-headless fallback.
## Proposed change
When the runtime first sees `pty:data` for a `ptyId` that already has a renderer pane registered (detected via a new `ptyController.hasRendererSerializer(ptyId)` predicate, backed by the `serializersByPtyId` registry in `pty-buffer-serializer.ts`), perform a one-time IPC round-trip to the renderer, get its serialized buffer, and seed the headless emulator with it **before** writing the live byte that just arrived. Subsequent `pty:data` calls for the same `ptyId` skip hydration via a per-PTY guard. If no renderer serializer is registered yet on the first byte, the guard is left absent so a later byte can retry once the pane mounts.
This is structurally the same operation `seedHeadlessTerminal` already does on `provider.spawn` from daemon-restored state. The new path covers the gap the existing seeding doesn't: the renderer has a richer buffer than the daemon does, and we want that richer buffer mirrored into the headless emulator on first runtime touch.
After hydration, all consumers (`terminal.read`, `terminal.subscribe`, `lastAgentStatus`, mobile, CLI) get the same fuller scrollback through the existing read paths. No priority flip is required. No per-consumer fallback. No per-subscribe IPC.
### System context — before
```
┌─────────────────────────────────────────────┐
│ Renderer process │
│ │
│ TerminalPane → xterm.js (50k rows) │
│ ▲ │
│ │ replayIntoTerminal(coldRestore. │
│ │ scrollback) on pane mount │
│ │ + live pty:data writes │
└─────┼───────────────────────────────────────┘
│
pty:data│IPC
│
┌─────┴───────────────────────────────────────┐
│ Main process │
│ │
│ ipc/pty.ts (provider.spawn) │
│ ├─→ seedHeadlessTerminal(snapshot) │
│ │ ↓ │
│ │ HeadlessEmulator (5k rows) ───┐ │
│ │ ▲ │ │
│ │ │ trackHeadlessTerminalData│ │
│ │ │ on every onPtyData │ │
│ └─→ runtime.onPtyData(...)────────┘ │
│ ▼ │
│ serializeHeadless │
│ (primary read source) │
│ │ │
│ ▼ │
│ mobile / CLI / lastStatus │
└─────────────────────────────────────────────┘
▲
pty:data│from daemon
│
┌─────┴───────────────────────────────────────┐
│ Daemon │
│ checkpoint.json (visible screen only) │
└─────────────────────────────────────────────┘
Bug: when daemon checkpoint is near-empty but the renderer xterm has
accumulated a rich live buffer, mobile reads from the headless emulator
which only saw bytes since runtime startup — and so sees less than desktop.
```
### System context — after
```
┌─────────────────────────────────────────────┐
│ Renderer process │
│ │
│ TerminalPane → xterm.js (50k rows) │
│ │ ▲ │
│ │ │ unchanged hydration path │
│ │ └─── live pty:data writes │
│ │ │
│ │ ptyController.serializeBuffer(ptyId) │
│ │ called ONCE per (ptyId, runtime) │
│ │ on first onPtyData │
│ ▼ │
└─────┼───────────────────────────────────────┘
│
pty:data│IPC + one-time serialize round-trip
│
┌─────┴───────────────────────────────────────┐
│ Main process │
│ │
│ onPtyData(ptyId, data) (first time) │
│ ├─→ if ptyController.serializeBuffer │
│ │ returns non-empty: │
│ │ hydrateHeadlessFromRenderer → │
│ │ seedHeadlessTerminal(rendered) │
│ │ mark hydrated │
│ ├─→ trackHeadlessTerminalData(data) │
│ │ (chained via writeChain after seed) │
│ │ │
│ HeadlessEmulator (5k rows, now seeded │
│ with renderer's view) │
│ ↓ │
│ serializeHeadless (UNCHANGED PRIMARY) │
│ ↓ │
│ mobile / CLI / lastAgentStatus / read │
└─────────────────────────────────────────────┘
Read priorities are unchanged. Headless emulator is still the primary
source of truth; renderer is consulted exactly once, at hydration time,
not per consumer or per subscribe.
```
### Ordering invariant (DO NOT REORDER)
In `onPtyData(ptyId, data, at)`, the call sequence is:
```ts
this.agentDetector?.onData(ptyId, data, at)
this.maybeHydrateHeadlessFromRenderer(ptyId) // ← MUST come BEFORE
this.trackHeadlessTerminalData(ptyId, data) // ← this line
```
**`maybeHydrateHeadlessFromRenderer` MUST be invoked before `trackHeadlessTerminalData`.** If reordered, `trackHeadlessTerminalData`'s lazy-create branch will populate `headlessTerminals` with a fresh empty emulator first, and `maybeHydrateHeadlessFromRenderer`'s `!headlessTerminals.has(ptyId)` precondition (the "live bytes already arrived" guard) will short-circuit — silently disabling hydration for the lifetime of the PTY. There is no test that catches this regression cheaply (the failure mode is "less scrollback than expected", which is the original bug); the guarantee lives in the call-site comment in `orca-runtime.ts`.
A `// DO NOT REORDER — see "Ordering invariant" in mobile-prefer-renderer-scrollback.md` comment is mandatory at this call site.
### Pseudocode
```ts
// Per-PTY hydration tracker. Key absent = idle (next byte may attempt).
// 'pending' = round-trip in flight; 'done' = succeeded or unrecoverably
// finished; 'skipped' is no longer used — instead we leave the key absent
// when the precondition fails so a later byte can retry.
private headlessHydrationState = new Map<string, 'pending' | 'done'>()
onPtyData(ptyId: string, data: string, at: number): void {
this.agentDetector?.onData(ptyId, data, at)
this.maybeHydrateHeadlessFromRenderer(ptyId)
this.trackHeadlessTerminalData(ptyId, data)
// …existing OSC / tail / leaf bookkeeping unchanged
}
private async maybeHydrateHeadlessFromRenderer(ptyId: string): Promise<void> {
if (this.headlessHydrationState.has(ptyId)) return
if (this.headlessTerminals.has(ptyId)) {
// Live bytes already arrived for this ptyId — re-seeding now would
// duplicate them. Mark done; never retry. (Daemon-snapshot seeding
// is handled separately: see "Cooperation with seedHeadlessTerminal"
// — that path is gated on `!hasRendererSerializer(ptyId)` so it does
// not pre-empt this hydration when a renderer pane is mounted.)
this.headlessHydrationState.set(ptyId, 'done')
return
}
// Why: leave the state absent (not 'done') when there's no renderer
// serializer or controller. The pane may mount one byte later; the
// next onPtyData call gets another chance. This is the contract change
// motivated by Open Question P2-2.
if (!this.ptyController?.serializeBuffer) return
if (!this.ptyController.hasRendererSerializer?.(ptyId)) return // not yet registered
this.headlessHydrationState.set(ptyId, 'pending')
// Why: eagerly create the headless state at PTY dims so concurrent live
// writes from trackHeadlessTerminalData chain onto the same writeChain.
// This matches seedHeadlessTerminal's pattern (orca-runtime.ts:780-803);
// without it, a live byte arriving during the serializeBuffer await would
// lazy-create a separate state that we'd later overwrite, dropping the
// live byte.
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
const state: RuntimeHeadlessTerminal = {
emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }),
writeChain: Promise.resolve()
}
this.headlessTerminals.set(ptyId, state)
// Why: append the renderer-fetch + seed-write to the chain. Live writes
// that arrive before the chain head resolves are queued behind it via
// trackHeadlessTerminalData's existing `state.writeChain.then(...)` pattern.
state.writeChain = state.writeChain.then(async () => {
try {
const rendered = await this.ptyController!.serializeBuffer!(ptyId, {
scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS,
altScreenForcesZeroRows: true
})
if (!rendered || rendered.data.length === 0) {
this.headlessHydrationState.set(ptyId, 'done')
return
}
// Why: resize to the renderer's reported dims before writing the seed
// so the serialized layout reflows correctly. Then resize back to the
// PTY's current dims after the seed lands so subsequent live writes
// use the right cell grid.
state.emulator.resize(rendered.cols, rendered.rows)
await state.emulator.write(rendered.data)
const ptyDims = this.getTerminalSize(ptyId)
if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) {
state.emulator.resize(ptyDims.cols, ptyDims.rows)
}
// Why: status parity. SerializeAddon does NOT round-trip OSC 0/1/2
// title bytes, so extracting from rendered.data would always be null.
// applySeededAgentStatus runs detectAgentStatusFromTitle and writes
// leaf.lastAgentStatus only when the result is non-null; it MUST NOT
// call resolveTuiIdleWaiters or deliverPendingMessages because seed-
// derived status reflects historical state.
if (rendered.lastTitle) {
this.applySeededAgentStatus(ptyId, rendered.lastTitle)
}
} catch {
// Hydration is best-effort. Live writes continue via the same
// writeChain that this catch-arm leaves intact.
} finally {
this.headlessHydrationState.set(ptyId, 'done')
}
})
}
```
**Eager-state ordering invariant.** Even though the chain head blocks live writes until the seed completes, the LIVE BYTE ITSELF (the one that triggered `onPtyData`) is enqueued onto `state.writeChain` BY `trackHeadlessTerminalData` AFTER `maybeHydrateHeadlessFromRenderer` returns synchronously and registers its seed-write link. The live byte's chain link executes after the seed-write resolves. That is the ordering invariant the eager-state pattern preserves.
**Resize-during-hydration note.** `resizeHeadlessTerminal` (orca-runtime.ts:828) deliberately bypasses `writeChain` and calls `state.emulator.resize(...)` directly — that's existing behavior we preserve. If a `pty:resize` IPC arrives during the hydration window (between `headlessTerminals.set` and `seedPromise` resolution), the resize lands on the emulator while the seed-write is mid-flight; this can produce a one-frame visual artifact in the seed but cannot corrupt subsequent live writes (which the seed-then-resize-back invariant restores). The narrow window — typically <200ms — is bounded by the renderer-IPC round-trip duration.
The cap and alt-screen rule on the renderer side mirror `serializeHeadlessTerminalBuffer`: 1000-row limit on rehydration payload, force `scrollbackRows = 0` when the renderer xterm reports `buffer.active.type === 'alternate'`. This unifies semantics between hydration source and live-serialize source — the user sees the same depth regardless of which path served them, and an alt-screen TUI never bleeds normal-buffer scrollback into the seed.
### IPC contract changes
Hydration relies on the renderer-side serializer accepting an option bag. Today the chain is single-arg end to end, so changes must land in lockstep:
- `src/preload/index.ts` — the `pty:serializeBuffer:request` payload extends to `{ requestId, ptyId, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } }`. Renderer-side type for the listener callback widens accordingly.
- `src/main/ipc/pty.ts` — `requestSerializedBuffer(ptyId, opts?)` accepts the opts object and forwards it on the IPC `webContents.send` payload. Snapshot-shape validation widens to accept an optional `lastTitle: string`. Existing 750 ms timeout unchanged.
- `src/main/runtime/orca-runtime.ts` — the `RuntimePtyController.serializeBuffer?` signature widens to `(ptyId, opts?) => Promise<{ data: string, cols: number, rows: number, lastTitle?: string } | null>`. A new optional `hasRendererSerializer?(ptyId): boolean` lets the runtime check pane mount status before paying the IPC cost (implementation: a getter against `serializersByPtyId` in `pty-buffer-serializer.ts`).
- `src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts` — the registered `SerializeFn` widens to `(opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …`. The pane's serializer reads the opts: if `altScreenForcesZeroRows && pane.terminal.buffer.active.type === 'alternate'`, call `pane.serializeAddon.serialize({ scrollback: 0 })`; otherwise `pane.serializeAddon.serialize({ scrollback: opts?.scrollbackRows })`. The response payload includes `lastTitle: lastTitleByPtyId.get(ptyId)?.title` (omitted when no title has been observed for this PTY). Default behavior (no opts) preserves today's full-buffer serialize for any caller that hasn't migrated.
- `serializeTerminalBufferFromAvailableState` (the renderer-fallback read path in `orca-runtime.ts:843`) forwards `scrollbackRows` so the fallback's depth matches the primary path. **It does NOT pass `altScreenForcesZeroRows: true`** — see "alt-screen flag scoping" below.
**alt-screen flag scoping (only on hydration path).** `altScreenForcesZeroRows: true` is passed ONLY by `maybeHydrateHeadlessFromRenderer` (the hydration path). The read-fallback path (`serializeTerminalBufferFromAvailableState`) passes `altScreenForcesZeroRows: false` (or omits the flag, default false). Reasoning:
- **Hydration:** wants the *normal* buffer's scrollback. xterm discards alt-screen content when alt-screen exits anyway, so suppressing scrollback during alt-screen prevents transient TUI bytes from bleeding into the seed that the headless emulator will hold for the rest of the session.
- **Read fallback:** is invoked by `terminal.read` / `terminal.subscribe` for an actively-running TUI when the headless emulator returned null. The caller wants whatever is currently visible — including alt-screen content — so suppressing it would produce a worse result than today.
Both call sites pass explicit opts in the pseudocode (no implicit defaults relied on). The read-fallback shape:
```ts
// In serializeTerminalBufferFromAvailableState (read-fallback):
rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, {
scrollbackRows: opts.scrollbackRows,
altScreenForcesZeroRows: false // read-fallback wants visible alt-screen content
}) ?? Promise.resolve(null))
```
### Renderer-side prerequisite
Today, `registerPtySerializer(ptyId, …)` is only called inside `handleReattachResult` (`pty-connection.ts:571`). Fresh spawns — the dominant case — never register, so `hasRendererSerializer(ptyId)` would always return false and hydration would no-op. The renderer change required:
- Move (or duplicate) the `registerPtySerializer` call so it fires for **both** reattach and fresh spawn paths. Cleanest shape: extract the body (closure capturing `pane`, `deps.pendingWritesRef`, `deps.replayingPanesRef`) into a `registerPaneSerializerFor(ptyId)` helper and call it from `handleReattachResult` AND from `startFreshSpawn`'s `.then(spawnedPtyId => …)` once a `ptyId` is known.
- The helper accepts the new `opts` arg and threads it into `pane.serializeAddon.serialize({ scrollback: … })` per the IPC contract above.
**Cleanup contract & StrictMode safety.** Two requirements, both motivated by React 18 StrictMode double-mount and async pane disposal:
1. **`if (disposed) return` guard at the top of every post-spawn registration call** (both reattach and fresh-spawn paths). Rationale: StrictMode mounts the pane twice; the first mount is disposed before the second runs, but its `pty:spawn` IPC may have already resolved by the time `disposed` flips to true. Without the guard, the disposed first mount would call `registerPaneSerializerFor` after teardown — replacing the live registration from the second mount or registering a closure over a torn-down `pane.terminal`.
2. **Unregister via ownership token, not unconditional delete.** `serializersByPtyId` becomes `Map<ptyId, { fn: SerializeFn, owner: symbol }>`. Each call to `registerPtySerializer` mints a fresh `owner` symbol and returns an unregister closure that **only deletes the entry when the current entry's `owner === thisOwner`**. If a later registration has overwritten the entry (StrictMode second mount, fast pane swap), the unregister is a no-op so the live registration is preserved. The body of the unregister closure also clears any module-level state keyed off the same owner (lastTitle bookkeeping introduced below) AND disposes the xterm `onTitleChange` IDisposable that was installed at register time.
```ts
export function registerPtySerializer(ptyId, fn): () => void {
const owner = Symbol(ptyId)
serializersByPtyId.set(ptyId, { fn, owner })
ensureSerializerListener()
return () => {
const current = serializersByPtyId.get(ptyId)
if (current?.owner === owner) {
serializersByPtyId.delete(ptyId)
}
}
}
```
3. **Spawn-rejection cleanup is mandatory.** The renderer MUST call `pty:clearPendingPaneSerializer(paneKey, gen)` from the catch-arm of `pty:spawn` AND from pane unmount AFTER pre-signal but BEFORE settle. Without this, a failed-spawn paneKey leaks a pending intent that suppresses the next legitimate daemon-snapshot seed for that paneKey.
4. **Pre-signal applies to BOTH fresh-spawn AND reattach paths.** `declarePendingPaneSerializer(paneKey)` is mandatory on both — mirroring the `registerPaneSerializerFor(ptyId)` requirement. Reattach paths today already register the serializer in `handleReattachResult`, but the pre-signal must also fire on the reattach path so the cooperation gate in `pty:spawn` behaves consistently regardless of whether the renderer is taking over a pre-existing PTY or a fresh one.
The `unregisterSerializer()` call is wired into `onDataDisposable.dispose` so the registration is torn down on pane unmount, regardless of which path registered it.
### Renderer pre-signal handshake
The cooperation gate (`ipc/pty.ts` skipping `seedHeadlessTerminal` when a renderer serializer is registered) has a fundamental timing problem on **fresh spawns**: the renderer cannot have registered the serializer for the new ptyId yet, because it doesn't know the ptyId until `pty:spawn` resolves. So at the moment the cooperation gate runs (immediately after `provider.spawn` returns inside `pty:spawn`'s handler), `runtime.hasRendererSerializerFor(ptyId) === false` for every fresh spawn — and the daemon-snapshot seed always wins, defeating the gate for the dominant case.
The renderer does, however, know its **paneKey** before spawn: every pane has a stable `ORCA_PANE_KEY` env var that is part of `args.env` on `pty:spawn`. We use the paneKey as a pre-signal, on a channel that doesn't depend on knowing the ptyId yet:
1. **Pre-signal IPC with generation token.** Renderer calls `pty:declarePendingPaneSerializer(paneKey)` BEFORE invoking `pty:spawn`. Main process maintains `pendingByPaneKey: Map<paneKey, generation: number>` keyed by paneKey. On each pre-signal, main mints a fresh monotonic `gen` value (incrementing a module-level `genSeq` counter) and overwrites the entry, then returns `gen` to the renderer. The renderer awaits this IPC (capturing `gen`), then awaits the spawn IPC — guaranteeing the main process has the pending intent recorded by the time `pty:spawn` runs.
```ts
let genSeq = 0
const pendingByPaneKey = new Map<string, number>() // paneKey -> generation
function declarePending(paneKey: string): number {
const gen = ++genSeq
pendingByPaneKey.set(paneKey, gen)
return gen // returned to renderer, echoed back on settle/clear
}
function settlePending(paneKey: string, gen: number): void {
if (pendingByPaneKey.get(paneKey) === gen) pendingByPaneKey.delete(paneKey)
}
paneKeyTeardownListeners.add((paneKey, gen) => settlePending(paneKey, gen))
```
Why a generation counter: today's design hooks `pendingByPaneKey.delete(paneKey)` into `clearProviderPtyState` via `paneKeyTeardownListeners`. If the OLD PTY's `clearProviderPtyState` fires AFTER mount-2's pre-signal but BEFORE mount-2's spawn, an unconditional teardown deletes mount-2's pending entry — defeating the gate. The generation counter solves this: the `paneKeyTeardownListeners` wiring captures the generation at the time the old PTY was REGISTERED (via the existing `registerPty` call site) and the teardown only fires `settlePending` with that generation. If mount-2 has already replaced the entry with a new gen, `pendingByPaneKey.get(K) !== oldGen` and the teardown is a no-op.
2. **Cooperation gate consults the pre-signal.** Inside `pty:spawn`, after `provider.spawn` returns, the gate becomes:
```ts
const paneKey = args.env?.ORCA_PANE_KEY
const rendererPreSignaled =
typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256
&& pendingByPaneKey.has(paneKey)
const rendererAlreadyRegistered =
runtime?.hasRendererSerializerFor?.(result.id) ?? false
if (!rendererPreSignaled && !rendererAlreadyRegistered) {
// No renderer is or will be authoritative for this PTY — daemon-snapshot
// seed runs as today.
runtime?.seedHeadlessTerminal(result.id, /* …snapshot/coldRestore… */)
}
```
The `rendererAlreadyRegistered` branch covers reattach, where the pane mounted before spawn returned and registration completed in the meantime. The `rendererPreSignaled` branch covers fresh spawn, where registration has not happened yet but is imminent. Note: the gate matches when `pendingByPaneKey.has(paneKey)` is true *regardless of generation*; the generation is only used by cleanup paths (teardown listeners, settle, clear) to prevent cross-generation deletion. **Invariant:** fresh-spawn always relies on the pre-signal branch (`hasRendererSerializerFor(ptyId)` is necessarily false at gate-time since the renderer hasn't received `ptyId` yet); the registered branch only fires for reattach where the pane mounted before spawn returned.
3. **Settle / clear (echo gen back).** After the spawn IPC resolves and the renderer calls `registerPtySerializer(ptyId, …)`, it calls `pty:settlePaneSerializer(paneKey, gen)` to remove the pending intent (echoing the `gen` value it captured from declarePending). The handler runs `settlePending(paneKey, gen)` which only deletes if the stored gen still matches. (The simpler "clear on first onPtyData" or "clear on receipt of `pty:registerPaneSerializer`" alternatives work too — the design picks the explicit `settle` IPC because it makes the contract auditable from the renderer side and avoids coupling main-process state to onPtyData flow.) On pane unmount or registration failure, the renderer also calls `pty:clearPendingPaneSerializer(paneKey, gen)` (sibling handler with the same `settlePending` semantics) to drop the pending intent.
**Sequence diagram (fresh spawn, happy path):**
```
Renderer Main / ipc-pty.ts Runtime
│ │ │
│ pty:declarePendingPaneSerializer │ │
│ (paneKey) ───────────────────────────▶│ pendingByPaneKey.set(K, gen) │
│ ◀──────────────────────── ack(gen) ──│ │
│ │ │
│ pty:spawn(args incl. ORCA_PANE_KEY) ─▶│ │
│ │ provider.spawn(...) → result │
│ │ pendingByPaneKey.has(paneKey)│
│ │ === true → SKIP │
│ │ seedHeadlessTerminal │
│ │ │
│ ◀──────────────────────────── ptyId ──│ │
│ │ │
│ registerPtySerializer(ptyId, fn) │ │
│ pty:settlePaneSerializer(paneKey, │ │
│ gen) ──────────────────────────────▶│ settlePending(paneKey, gen) │
│ │ if gen matches → delete │
│ │ │
│ │ first onPtyData(ptyId,data)──▶│
│ │ │ maybeHydrateFromRenderer
│ │ │ (headlessTerminals empty,
│ │ │ hasRendererSerializer true
│ │ │ → IPC round-trip → seed)
```
**Renderer-side call shape.** The renderer threads `gen` through every call site so cleanup (settle/clear) can be matched against the generation it captured at declare time:
```ts
// In pty-connection.ts startFreshSpawn / handleReattachResult:
const gen = await window.api.pty.declarePendingPaneSerializer(paneKey)
try {
const ptyId = await window.api.pty.spawn(args)
registerPaneSerializerFor(ptyId)
await window.api.pty.settlePaneSerializer(paneKey, gen)
} catch (err) {
await window.api.pty.clearPendingPaneSerializer(paneKey, gen)
throw err
}
```
**Correctness properties:**
1. **Pre-signal arrives before `pty:spawn`.** The renderer awaits the pre-signal IPC, then awaits the spawn IPC. Electron `ipcRenderer.invoke`/`send` ordering on a single renderer is preserved on the main side, so the main process processes the pre-signal before `pty:spawn`'s handler runs.
2. **Missing paneKey degrades to today's behavior.** If `args.env?.ORCA_PANE_KEY` is absent (e.g., a pane that isn't yet wired to set it, an old renderer build, or a non-pane spawn path), the pre-signal lookup returns false, `rendererAlreadyRegistered` is also false on a fresh spawn, and the daemon-snapshot seed runs as today. No regression for callers that don't participate in the handshake.
3. **Pre-signal without settle (renderer crashes between spawn and registerPtySerializer).** The pending intent stays in the map, but it has no observable effect: the cooperation gate already ran, so the daemon snapshot was suppressed; the headless emulator stays empty; the first `onPtyData` falls through `hasRendererSerializer(ptyId) === false` and hydration's precondition leaves the state map absent. `trackHeadlessTerminalData` then lazy-creates the emulator and live writes populate it. We lose the daemon snapshot for this PTY in that narrow window — accepted limitation, since the renderer was clearly going to be authoritative but didn't make it. (To bound staleness, the entry in `pendingByPaneKey` is cleared whenever a paneKey teardown event fires via the existing `paneKeyTeardownListeners` registry, gated by the generation token.)
**Files-touched implications:** see "IPC contract changes" and "Files touched" — both `pty:declarePendingPaneSerializer` and `pty:settlePaneSerializer` need preload bindings AND main handlers.
### Status parity
Seeding the emulator bypasses `onPtyData`'s OSC-extraction path, so without intervention `leaf.lastAgentStatus` stays whatever it was (typically `null` for daemon-restored leaves) until the next live byte happens to carry an OSC title. Mobile would then see a stale or missing agent badge despite a rich seed.
A previous draft of this design called `extractLastOscTitle(rendered.data)` on the seed payload to recover the title. **That doesn't work**: xterm's `SerializeAddon.serialize()` writes out the visible buffer as a stream of cursor moves, SGR resets, and printable characters — it does NOT round-trip OSC 0/1/2 (window/icon title) escape sequences. The renderer xterm consumes the OSC and sets `terminal.options.title`, but the bytes never appear in `serialize()`'s output. So `extractLastOscTitle` on a seed payload is dead code — it always returns null even when the renderer's pane currently shows a perfectly good title. We need the title via a different channel.
**Approach: ship now via IPC widening, document as best-effort.** The renderer pane already wires `pane.terminal.onTitleChange` (in `pty-connection.ts`) to keep the tab title in sync. We capture the latest observed title in module state alongside the serializer registration, and widen the serialize-buffer IPC payload to carry it back to main:
1. **Renderer side** (`pty-buffer-serializer.ts`):
- Module-level `lastTitleByPtyId: Map<ptyId, { title: string, owner: symbol, disposable: IDisposable }>` keyed by the same ownership-token symbol as `serializersByPtyId` (see "Renderer-side prerequisite"). Populated by a wrapper around `pane.terminal.onTitleChange` installed at the same time as `registerPtySerializer`. The entry stores BOTH the IDisposable returned from `pane.terminal.onTitleChange(...)` AND the owner symbol alongside the latest title.
- When the pane unregisters, the `unregisterSerializer` closure disposes the IDisposable AND deletes the map entry, both gated by the owner-token match — same ownership semantics as the serializer map, so a StrictMode disposed first-mount cannot wipe the live second-mount's title or leak a dangling listener.
- The IPC response payload widens from `{ data, cols, rows }` to `{ data, cols, rows, lastTitle?: string }`. Default omitted when no title was observed for this PTY (e.g., fresh shell pre-prompt).
2. **Main side** (`ipc/pty.ts`, `orca-runtime.ts`):
- `requestSerializedBuffer` snapshot-shape validation accepts `lastTitle?: string`.
- `RuntimePtyController.serializeBuffer(ptyId, opts?)` returns `Promise<{ data, cols, rows, lastTitle?: string } | null>`.
- `maybeHydrateHeadlessFromRenderer` calls `applySeededAgentStatus(ptyId, rendered.lastTitle)` only when `rendered.lastTitle` is a non-empty string (the helper itself also short-circuits on null/empty for safety). The helper internally runs `detectAgentStatusFromTitle` and only writes `leaf.lastAgentStatus` when the result is one of `'idle' | 'working' | 'permission'`.
3. **`applySeededAgentStatus(ptyId, title)`** (new helper in `orca-runtime.ts`). Behavior:
- If `title == null` or empty, no-op.
- Look up the leaf for `ptyId` via the existing pty→leaf mapping. Run `detectAgentStatusFromTitle(rendered.lastTitle)` (the helper from `src/shared/agent-detection.ts`). If the result is non-null, set `leaf.lastAgentStatus = result`. If null (title is not a recognizable agent title), leave `lastAgentStatus` untouched — the next live OSC byte will populate it through the normal onPtyData path.
- **MUST NOT** call `resolveTuiIdleWaiters(leaf)` or `deliverPendingMessages(leaf)`. The seeded status reflects the renderer's last-observed historical title, not a live transition; resolving idle waiters or delivering pending messages on stale data could mistakenly unblock orchestration callers that should only react to a fresh prompt-return.
- **Why** (mandatory comment in the helper definition): `// seed-derived status reflects historical state; orchestration waiters must only react to live transitions to avoid resolving on stale data.`
- **Rationale for routing through `detectAgentStatusFromTitle`:** we mirror the live path (`onPtyData` already uses `detectAgentStatusFromTitle`) so seeded and live paths produce the same union value, and downstream `leaf.lastAgentStatus === 'idle'` consumers (orchestration `tui-idle` waiters, pending-message gating) keep working. `leaf.lastAgentStatus` is typed `AgentStatus | null` where `AgentStatus = 'working' | 'idle' | 'permission'`; assigning the raw title string would be type-incorrect.
This delivers status parity for the dominant case via the `lastTitle` field (any pane that has rendered a prompt or had a TUI emit OSC titles will have populated `lastTitleByPtyId` long before mobile subscribes). Edge cases where the renderer never observed a title — fresh shell pre-prompt, pane that mounted but never received bytes — leave `lastAgentStatus` as `null`; that's acceptable since `lastAgentStatus` is documented best-effort and the next live OSC title will populate it through the normal `onPtyData` path.
**Status parity / accepted limitations.** Side effect of seeding `lastAgentStatus`: if the seed sets status to `'idle'`, the next live OSC byte that re-emits the same `'idle'` title will see `prevStatus === 'idle'` and the existing transition gate (`agentStatus === 'idle' && prevStatus !== 'idle'`) skips. Orchestration `tui-idle` waiters registered between seeding and the next idle transition will hang until either (a) a non-idle status arrives followed by another idle transition, or (b) the waiter times out. This is an accepted limitation. If it becomes a real problem, future work could split into `lastSeededAgentStatus` (informational, not gating) vs `lastAgentStatus` (gates transitions).
### Files touched
- `src/main/runtime/orca-runtime.ts` — add `headlessHydrationState`, `maybeHydrateHeadlessFromRenderer`, wire into `onPtyData` (with mandatory `// DO NOT REORDER` comment per "Ordering invariant"). Widen `RuntimePtyController.serializeBuffer?` to `(ptyId, opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null>` and add `hasRendererSerializer?(ptyId)`. Add `applySeededAgentStatus(ptyId, title)` helper that writes `leaf.lastAgentStatus` only — must NOT call `resolveTuiIdleWaiters` or `deliverPendingMessages` (mandatory "Why" comment in the helper definition). Add `hasRendererSerializerFor(ptyId)` runtime-service-level method that delegates to `ptyController.hasRendererSerializer` (consumed by `ipc/pty.ts`'s cooperation gate). Forward `scrollbackRows` from `serializeTerminalBufferFromAvailableState` into the renderer-fallback call (do NOT pass `altScreenForcesZeroRows: true` on this path — see "alt-screen flag scoping").
- `src/main/ipc/pty.ts` — `requestSerializedBuffer(ptyId, opts?)` accepts opts and forwards them on the `pty:serializeBuffer:request` IPC payload. Wire `hasRendererSerializer` through a renderer-registry getter. **Cooperate with hydration:** when a renderer serializer is registered for the spawning PTY *or* the renderer pre-signaled intent for `args.env.ORCA_PANE_KEY`, skip seeding from `result.snapshot` / `result.coldRestore.scrollback` so the daemon's bare-prompt state cannot pre-empt the richer renderer hydration that follows on first byte. **Add three new IPC handlers** with consistent paneKey validation (`typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256` — invalid input is rejected before mutating state): `pty:declarePendingPaneSerializer(paneKey): Promise<number>` (renderer → main, before `pty:spawn`) mints a fresh generation `gen`, stores `pendingByPaneKey.set(paneKey, gen)` in a module-level `pendingByPaneKey: Map<string, number>`, and returns `gen` to the renderer; `pty:settlePaneSerializer(paneKey, gen)` (renderer → main, after `registerPtySerializer`) runs `settlePending(paneKey, gen)` which deletes only if the stored gen still matches; `pty:clearPendingPaneSerializer(paneKey, gen)` (renderer → main, on spawn-rejection / unmount-before-settle) has the same `settlePending` semantics. Hook `paneKeyTeardownListeners.add((paneKey, gen) => settlePending(paneKey, gen))` so a torn-down PTY's listener captures the generation it was registered under and only clears the map when that generation still matches — ensuring a teardown for the OLD PTY cannot delete a NEW PTY's pending intent if mount-2's pre-signal landed in between. (See "Renderer pre-signal handshake" and "Cooperation with seedHeadlessTerminal" in Open questions.)
- `src/preload/index.ts` — extend the `pty:serializeBuffer:request` listener payload type to include `opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }`. Add three new bindings under `pty`: `declarePendingPaneSerializer(paneKey: string): Promise<number>` (returns the `gen` token), `settlePaneSerializer(paneKey: string, gen: number): Promise<void>`, and `clearPendingPaneSerializer(paneKey: string, gen: number): Promise<void>`, all forwarded as `ipcRenderer.invoke('pty:declarePendingPaneSerializer', …)` / `'pty:settlePaneSerializer'` / `'pty:clearPendingPaneSerializer'` so the renderer can `await` them and rely on main-side ordering before issuing `pty:spawn` / after `registerPtySerializer`. The renderer captures `gen` from declare and threads it through every settle/clear call.
- `src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts` — widen `SerializeFn` to `(opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …`; default to today's full-buffer behavior when opts is absent. Convert `serializersByPtyId: Map<ptyId, SerializeFn>` to `Map<ptyId, { fn: SerializeFn, owner: symbol }>` and have `registerPtySerializer` mint a fresh owner symbol per call, returning an unregister closure that deletes only when `current?.owner === thisOwner` (StrictMode safety — see "Renderer-side prerequisite"). Add a parallel `lastTitleByPtyId: Map<ptyId, { title: string, owner: symbol, disposable: IDisposable }>` populated by a wrapper around `pane.terminal.onTitleChange`, with the unregister closure both disposing the IDisposable AND deleting the entry under the same owner-token check. The serialize listener reads from `lastTitleByPtyId` to populate `lastTitle` in the IPC response.
- `src/renderer/src/components/terminal-pane/pty-connection.ts` — register the serializer for **fresh spawns** as well as reattach. Extract the registration body into a shared `registerPaneSerializerFor(ptyId)` helper called from both `handleReattachResult` AND the post-spawn callback in `startFreshSpawn`. Thread opts (scrollback cap, alt-screen flag) into `pane.serializeAddon.serialize`. Add `if (disposed) return` guard at the top of every post-spawn registration call (StrictMode safety — see "Renderer-side prerequisite"). Track the latest `pane.terminal.onTitleChange` value in module state alongside the serializer registration so the serializer's IPC response can include `lastTitle` (see "Status parity"). Call `const gen = await window.api.pty.declarePendingPaneSerializer(paneKey)` BEFORE `pty:spawn` on every fresh-spawn path (capturing the returned generation token), and `settlePaneSerializer(paneKey, gen)` AFTER `registerPaneSerializerFor(ptyId)` succeeds. On spawn-rejection (catch-arm) AND on pane unmount AFTER pre-signal but BEFORE settle, call `clearPendingPaneSerializer(paneKey, gen)` — this is mandatory (see "Renderer-side prerequisite" item 3). Unregister on pane unmount via the existing `onDataDisposable.dispose` hook (now using the ownership-token contract from "Renderer-side prerequisite").
- `src/main/runtime/scrollback-limits.ts` (new) — main-process module exporting `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000`. Imported by both `src/main/runtime/rpc/methods/terminal.ts` and `src/main/runtime/orca-runtime.ts`. (The constant is main-process-only; promoting it to `src/shared/` would expose a runtime cap to the renderer for no benefit.)
- `src/main/runtime/orca-runtime.test.ts` — add hydration unit tests (see "Test plan").
- `src/main/runtime/mobile-subscribe-integration.test.ts` — extend with cross-process hydration scenario.
No changes to:
- `pty:data` batching, daemon adapter.
- `serializeTerminalBufferFromAvailableState` priority order (headless still primary; renderer still no-headless fallback).
- Mobile RPC method signatures (`terminal.subscribe`, `terminal.resizeForClient`).
- Mobile WebView / `TerminalWebView.tsx`.
- Persistence (`HistoryManager`, `checkpoint.json` shape).
## Why this is structurally sound
The renderer xterm is, in practice, the fuller and more authoritative state for any mounted pane:
1. **It hydrates on attach.** `pty-connection.ts` calls `replayIntoTerminal(pane, …, coldRestore.scrollback)` and writes `snapshot` immediately after pane mount. The headless emulator never replays either. So even on a session that started before the current Orca run, the renderer xterm contains the full history; the headless emulator does not.
2. **It has a bigger buffer.** 50,000 rows vs 5,000.
3. **It is updated on the same IPC tick as the visible UI.** No staleness vs what the user sees.
Hydrating the headless emulator from the renderer on first runtime touch is the same shape of operation as the existing `seedHeadlessTerminal(provider.spawn snapshot)` path. We are not introducing a new architectural pattern; we are extending an existing one to the case where the daemon snapshot is empty but the renderer is rich. The runtime remains authoritative for everything mobile, CLI, `terminal.read`, and `lastAgentStatus` see — the renderer is consulted exactly once per `(ptyId, runtime)` pair.
This is a structural-adjacent improvement that fixes the dominant case (user has both desktop and mobile open) while consciously deferring Scenario A (mobile-only after relaunch) to a separate daemon-persistence design. It is honest about what it does and does not solve.
## Data flow paths
### Happy path — renderer mounted, hydration succeeds
```
T0 user opens desktop pane.
- Fresh spawn: pty-connection.ts calls
const gen = await pty:declarePendingPaneSerializer(paneKey)
BEFORE pty:spawn, then awaits both. Main mints a fresh gen and
records the pending intent under it. After spawn resolves with
ptyId, registerPaneSerializerFor(ptyId) +
pty:settlePaneSerializer(paneKey, gen) clear the intent (only if
the stored gen still matches). On spawn-rejection or unmount-
before-settle, pty:clearPendingPaneSerializer(paneKey, gen) runs
instead (mandatory — see "Renderer-side prerequisite" item 3).
- Reattach: registerPaneSerializerFor(ptyId) runs in
handleReattachResult once the ptyId is known.
Either way, ipc/pty.ts's cooperation gate sees that the renderer
is or will be authoritative for this PTY (via either
hasRendererSerializerFor(ptyId) === true OR a pre-signaled paneKey)
and SKIPS the daemon-snapshot seedHeadlessTerminal call.
T1 PTY emits live byte → ipc/pty.ts forwards to renderer (xterm grows)
→ ipc/pty.ts forwards to runtime.onPtyData
T2 onPtyData: maybeHydrateHeadlessFromRenderer
- hasRendererSerializer(ptyId) === true → state: absent → pending
- serialize round-trip with opts {scrollbackRows, altScreenForcesZeroRows}
- on resolve: HeadlessEmulator built at rendered.cols/rows,
applySeededAgentStatus(ptyId, rendered.lastTitle), write(rendered.data)
- resize emulator to PTY dims if they diverge from rendered dims
T3 trackHeadlessTerminalData(data) chains AFTER the seed via writeChain,
so the live byte is appended on top of the seeded buffer — order
preserved.
T4 hydration state → done. Subsequent onPtyData calls skip the guard.
T5 user opens mobile → terminal.subscribe → serializeHeadless returns
seeded buffer + everything since. Mobile == desktop, and
leaf.lastAgentStatus is populated from rendered.lastTitle
(sourced from pane.terminal.onTitleChange, NOT from the seed payload —
SerializeAddon does not round-trip OSC titles).
```
### Nil path — Scenario A: no renderer ever mounts
```
T0 user relaunches Orca, opens mobile only.
T1 ipc/pty.ts: provider.spawn returns coldRestore.scrollback = ""
(daemon checkpoint had scrollbackAnsi: "" — visible screen only)
or a thin visible-screen snapshot. hasRendererSerializerFor(ptyId)
is false (no pane mounted), so the cooperation gate does NOT skip:
seedHeadlessTerminal runs as today.
T2 PTY emits live byte → onPtyData → maybeHydrateHeadlessFromRenderer
- hasRendererSerializer(ptyId) is false → state map left absent.
(Pane could still mount later in this runtime; we'd retry then.)
T3 trackHeadlessTerminalData populates from live bytes; emulator
already contains the daemon-snapshot seed.
T4 Mobile sees daemon's visible-screen snapshot + live bytes since.
KNOWN LIMITATION; out of scope for this design.
```
### Error path — renderer throws or unmounts mid-hydration
```
T0–T1 same as happy path through `pending` state.
T2 ptyController.serializeBuffer rejects (renderer disposed,
channel closed, IPC timeout, etc.).
- catch swallows the error.
- hydration state → done (we do not loop on every byte).
T3 Live bytes continue to populate the emulator via
trackHeadlessTerminalData (which lazily creates the headless
state at PTY dims). We lose the seed for this runtime but do
not block live data flow.
```
The "advance to done on serialize failure" rule is intentional. Re-trying on every subsequent byte would create a renderer round-trip on every byte until success — too costly for a best-effort path. The "leave state absent when the renderer isn't ready yet" rule (the precondition checks before `pending`) is the inverse trade-off: those checks are cheap (Map lookups), so retrying them is fine.
### SSH note
SSH-backed PTYs flow through the same `runtime.onPtyData` entry point as local and daemon-backed PTYs (see `ipc/pty.ts` provider listener — all providers call `runtime.onPtyData(id, data, at)` uniformly). Hydration applies to SSH PTYs without special-casing: when a desktop pane is mounted on an SSH-backed terminal, its `registerPtySerializer` registration is identical to the local case, and the runtime sees `hasRendererSerializer(ptyId) === true` regardless of provider. The mobile + SSH + relaunch case (rare in practice) inherits Scenario A's known-limitation behavior.
### Concurrent path — live data arrives during hydration
The renderer round-trip is asynchronous; live `pty:data` will continue to arrive before `serializeBuffer` resolves. The pattern from `seedHeadlessTerminal` already handles this: every emulator write is appended to `state.writeChain`, so:
```
writeChain: Promise.resolve()
→ seed write ← scheduled by maybeHydrateHeadlessFromRenderer
→ live byte 1 ← scheduled by trackHeadlessTerminalData
→ live byte 2
→ live byte N
```
`emulator.write` is invoked in chain order, preserving the byte sequence exactly as the wire delivered it. The seed always lands before the live bytes that triggered hydration, because the trigger logic schedules the seed write *before* it returns control to `onPtyData`, which then schedules the live write.
## Performance
`ptyController.serializeBuffer(ptyId)` does an IPC round-trip to the renderer where xterm's SerializeAddon runs synchronously over the buffer. Cost is roughly proportional to scrollback size: empirically ~50–200 ms for a full 50k-row buffer.
The cost profile is materially different from the rejected priority-flip approach:
| Approach | Renderer round-trip frequency |
|---|---|
| Priority flip (rejected) | Once per `terminal.subscribe` AND `terminal.resizeForClient` |
| Hydrate-on-first-touch (this) | Once per `(ptyId, runtime)` pair, ever |
For a typical session — Orca starts, user opens desktop pane, agent runs for hours, user opens mobile periodically — this approach pays the IPC cost exactly once *for hydration* per `(ptyId, runtime)` pair. The existing renderer-fallback inside `serializeTerminalBufferFromAvailableState` is preserved — it can still hit the renderer on `terminal.read` / `terminal.subscribe` when the headless emulator returns null — but that path is the read-time fallback, not the hydration path. The priority-flip approach paid the IPC cost on every mobile re-subscribe (foreground/background, navigation between worktrees, phone-fit toggle). The "first byte after pane mount" trigger ties the hydration cost to a moment that is already doing IO work, so the latency is hidden inside the existing flow.
There is no per-keystroke cost; `trackHeadlessTerminalData` is unchanged.
## Reflow asymmetry
Hydration happens at whatever cols the renderer is currently at — typically the desktop's natural size (e.g., 105 cols). When mobile later subscribes and triggers a 49-col phone-fit resize, the headless emulator (now seeded with 105-col data) reflows to 49 cols just like the renderer would. Both buffers reflow from the same starting content, so they stay in sync.
This is a real improvement over the priority-flip proposal, which was sensitive to the order in which renderer and headless reflow completed across the IPC tick boundary. With hydrate-on-mount the seed has already landed before any phone-fit resize is even requested, so the reflow asymmetry concerns largely go away: at subscribe time both buffers contain the same content and respond to the same resize event in the same `handleMobileSubscribe` synchronous block.
## Risks
1. **Live data arrives during pending hydration.** Mitigated by chaining through `state.writeChain`, exactly as `seedHeadlessTerminal` already does. Order is preserved.
2. **Renderer pane unmount mid-round-trip.** Caught by `try/catch`; the guard advances to `done` so we don't loop on every subsequent byte, and live writes continue via lazy emulator creation in `trackHeadlessTerminalData`. We lose the seed for that PTY, which is acceptable given how rare unmount-during-IPC is.
3. **Wire-payload size on hydration.** Capped at 1000 rows from day one via the shared `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS` constant. Alt-screen forces 0 rows. This matches the existing serialize path's behavior, so users see identical depth from both sources. The headless emulator's own 5,000-row `DEFAULT_SCROLLBACK` (in `HeadlessEmulator`) is comfortably above the 1k hydration cap; if a future change raises the hydration cap, `HeadlessEmulator`'s default must be bumped in lockstep or the seed will be silently truncated as it lands in the emulator.
4. **Renderer xterm is *behind* the runtime.** Theoretically possible if `pty:data` is delivered to the runtime before the renderer (depends on dispatch order in `ipc/pty.ts`). The renderer's xterm uses an asynchronous `write(callback)`, so even when bytes are delivered the buffer may not yet contain them. In practice this is sub-millisecond and the seed will be at most one frame behind the live byte that triggered it — better than today's headless-only path which has none of those bytes.
5. **Hydration races registerPty.** If `onPtyData` fires before `runtime.registerPty` has been called for this PTY, `getTerminalSize` may return null and we default to 80×24 for the post-seed PTY dims. The seed itself is constructed at the renderer's reported dims (see Pseudocode), so xterm reflow only happens on the post-seed `resize` call — at most one reflow, and only when PTY and renderer dims actually diverge.
6. **PTY-size vs renderer-dim divergence (resize-after-seed reflow).** Under mobile-fit overrides, or in the brief window before a `pty:resize` lands on the main process, the renderer's xterm dims and the runtime's `ptySizes` entry can disagree. Constructing the headless emulator at PTY-size and then writing a seed captured at renderer-dim would force xterm to wrap/reflow the seed in the wrong column count — visible as broken line breaks or doubled prompts. Mitigation: build the emulator at `rendered.cols / rendered.rows`, then resize to PTY-size after the seed lands. See Pseudocode. **At extreme size deltas (e.g., 105→32), the seed-then-reflow path can produce minor visible divergence vs the renderer's own reflow** (xterm reflow is not strictly invertible across very different widths, and the headless emulator runs the reflow on already-laid-out cells rather than the original byte stream). This is an accepted limitation, not a correctness issue: the reflow result is still valid xterm output, just potentially line-broken slightly differently than what the desktop pane shows for the same scrollback. Mobile users have always seen a phone-fit reflow distinct from desktop's; this design does not change that.
7. **Note: writes are flushed before serialize.** The renderer-side serializer (`pty-buffer-serializer.ts:registerPtySerializer`) flushes `pendingWritesRef` via `replayIntoTerminalAsync` before calling `pane.serializeAddon.serialize()`, so the seed faithfully includes hidden-pane buffered writes. No additional sequencing logic is needed in the runtime.
8. **`pendingWritesRef` double-write window (severity-adjusted P2).** When a pane is hidden at the moment bytes arrive, the renderer-side `pendingWritesRef` buffers them while `runtime.onPtyData` simultaneously routes the same bytes into the headless emulator via `trackHeadlessTerminalData`. The renderer's serializer flushes `pendingWritesRef` before serializing, so the seed payload includes those bytes. If hydration runs after `trackHeadlessTerminalData` has already written the same bytes, the headless emulator ends up with a duplicated stretch.
**Mitigation: the P0-A2 pre-signal handshake makes this rare in practice.** With pre-signal, hydration is the FIRST writer for a fresh-spawn-with-pre-signal — `trackHeadlessTerminalData`'s lazy-create check sees the emulator already exists (created at hydration time) and just appends, so live bytes between seed-fetch and seed-resolve are queued behind the seed via `state.writeChain`. The double-write only manifests in the narrow combo of: (a) reattach path, (b) hidden pane buffering live bytes into `pendingWritesRef`, AND (c) hydration runs after `trackHeadlessTerminalData` has already populated the headless emulator. The impact is bounded: at most one ~8 ms PTY-batch's worth of bytes can be duplicated, the duplicate is visible only in mobile/CLI scrollback (desktop renders from its own xterm which never duplicates), and a future shell prompt overwrites the area anyway. Document as a known limitation; not a correctness-blocking issue. If it becomes user-visible, the fix is to track a per-pty "live bytes seen since last serialize request" cursor on the renderer and trim the seed payload to bytes the renderer observed before hydration started — out of scope for this design.
## Test plan
### Unit (`orca-runtime.test.ts`)
- Hydration runs on the *first* `onPtyData` for a `ptyId` when `hasRendererSerializer` is true and `serializeBuffer` returns non-empty data. Assert the emulator's serialized output contains both the seeded prefix and the live byte.
- Subsequent `onPtyData` calls do **not** re-trigger hydration after a successful run. Mock `serializeBuffer` and assert it was called exactly once across N `onPtyData` invocations for the same ptyId.
- "Exactly once" claim is qualified: the renderer round-trip happens once per `(ptyId, runtime)` for hydration. The existing `serializeTerminalBufferFromAvailableState` renderer-fallback (consulted only when the headless emulator returns null) is preserved and may still hit the renderer on `terminal.read` / `terminal.subscribe` paths.
- When `ptyController` is null (CLI / headless test runs), hydration leaves the state map untouched, headless emulator is created lazily as today, no IPC attempt.
- When `hasRendererSerializer(ptyId)` returns false on the first byte but true on a later byte, hydration **does** run on the later byte (state is left absent on idle, not consumed). Confirms the contract change for Open Question P2-2.
- When `ptyController.serializeBuffer` rejects, the guard advances to `done` (no infinite retry), the emulator is created lazily by `trackHeadlessTerminalData`, live bytes still write through.
- Live data arriving during pending hydration is queued behind the seed write. Schedule the seed-write as a long pending promise, fire several `onPtyData` calls, await `writeChain`, assert the final emulator content has the seed prefix followed by the live bytes in correct order.
- The 1000-row cap is applied to the renderer-sourced hydration: mock `serializeBuffer` to return a payload representing >1000 rows, assert the requested option bag includes `scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS`.
- Alt-screen flag is forwarded: assert the request includes `altScreenForcesZeroRows: true`. (The alt-screen branch is exercised in renderer-side serializer tests where it has access to a real xterm `buffer.active.type`.)
- **Status parity (lastTitle field):** mock `serializeBuffer` to return `{ data, cols, rows, lastTitle }` where `lastTitle` is a recognizable agent title. Fire `onPtyData`. Assert `leaf.lastAgentStatus` is one of `'idle' | 'working' | 'permission'` (whatever `detectAgentStatusFromTitle` returns for that title) after hydration and before any live OSC title bytes are observed. Conversely, when `lastTitle` is absent / undefined OR `detectAgentStatusFromTitle(lastTitle)` returns `null` (unrecognizable title), assert `leaf.lastAgentStatus` is left unchanged from its prior value (no overwrite).
- **Multi-PTY concurrent hydration:** fire `onPtyData` for ptyA and ptyB before either `serializeBuffer` resolves. Assert each PTY's hydration completes with its own payload (no cross-talk through the IPC response listener pool — each request has a unique `requestId`).
- **Re-entrant `onPtyData`:** when `agentDetector.onData` synchronously triggers another `onPtyData` for the same ptyId (or a different ptyId), confirm the hydration state machine still holds — no double-pending, no skipped seed, no out-of-order emulator writes.
- **Cooperation with `seedHeadlessTerminal`:** when a renderer serializer is registered for `ptyId`, the spawn-time call from `ipc/pty.ts` skips seeding (no daemon-snapshot bytes written to the emulator). When no serializer is registered, the daemon-snapshot path runs as today.
- **Fresh-spawn timing race (P0-A2 pre-signal handshake):** simulate the renderer pre-signaling intent for paneKey via `const gen = await pty:declarePendingPaneSerializer(paneKey)`, then call the `pty:spawn` handler with `args.env.ORCA_PANE_KEY === paneKey`. Assert `seedHeadlessTerminal` is **NOT** invoked even though `hasRendererSerializerFor(result.id)` is false at that moment (the renderer hasn't received the ptyId yet). The renderer mock then `registerPtySerializer(ptyId, …)` and `pty:settlePaneSerializer(paneKey, gen)`. Fire `onPtyData(ptyId, liveByte)`. Assert `maybeHydrateHeadlessFromRenderer` runs and the headless emulator is populated from the renderer's serialize payload, with the live byte appended after the seed via `writeChain`.
- **Pre-signal without settle (renderer crash):** simulate `pty:declarePendingPaneSerializer(paneKey)` followed by `pty:spawn` (gate skips daemon seed) but the renderer never calls `registerPtySerializer` or `settlePaneSerializer`. Fire `onPtyData`. Assert hydration's preconditions leave the state map absent, `trackHeadlessTerminalData` lazy-creates the emulator, and live bytes populate it. The pending-paneKey entry is dropped on the next paneKey teardown event.
- **Pre-signal preserved across paneKey reuse during teardown:** simulate (a) old PTY for paneKey K is exiting, its `paneKeyTeardownListener` is queued (captured at registration time with `gen1`). (b) Mount-2 calls `pty:declarePendingPaneSerializer(K)` and stores generation `gen2` (`gen2 > gen1`). (c) The queued teardown for the old PTY runs; the listener invokes `settlePending(K, gen1)` and SKIPS the delete because `pendingByPaneKey.get(K) === gen2`. (d) Mount-2 calls `pty:spawn`; the cooperation gate matches (`pendingByPaneKey.has(K) === true`) and seed is suppressed. Assert daemon-snapshot seed did NOT run.
- **Test imports for `scrollback-limits.ts`:** tests in `orca-runtime.test.ts` and `mobile-subscribe-integration.test.ts` import `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS` from `../scrollback-limits` so cap-assertion lines match the production constant (no hardcoded `1000`).
- **Status parity (real `SerializeAddon` round-trip):** in a renderer-side unit test (using a real `xterm` `Terminal` + `SerializeAddon`), write OSC 0/1/2 title bytes (e.g., `\x1b]0;some-title\x07`) into the terminal, await the `onTitleChange` listener, then call `serializeAddon.serialize()`. **Assert the serialize output does NOT contain the OSC title bytes** — this codifies the limitation that motivated the `lastTitle` field. Then, in a runtime-side unit test, mock the renderer-IPC response to include `{ data, cols, rows, lastTitle }` where `lastTitle` is a known agent title, fire `onPtyData`, and assert `applySeededAgentStatus` was called with that title and `leaf.lastAgentStatus` is the corresponding `'idle' | 'working' | 'permission'` value returned by `detectAgentStatusFromTitle` afterward.
- **`applySeededAgentStatus` does not resolve waiters:** with a leaf that has pending TUI-idle waiters and pending orchestration messages registered, call `applySeededAgentStatus(ptyId, title)` where `title` is a recognizable agent title. Assert `leaf.lastAgentStatus` matches `detectAgentStatusFromTitle(title)` (one of `'idle' | 'working' | 'permission'`), AND assert `resolveTuiIdleWaiters` and `deliverPendingMessages` were **NOT** invoked. (Spy on the methods or assert the waiter / pending-message queues are unchanged.) This guards against future drift where a refactor lifts shared logic between the live and seeded paths.
### Integration (`mobile-subscribe-integration.test.ts`)
- Cross-process scenario with a mocked renderer that registers a richer scrollback than the headless adapter would produce. After the runtime sees its first live byte, mobile subscribes; assert the `scrollback` frame's `serialized` field includes the seeded prefix.
- Scenario A regression guard: no renderer registered, mobile subscribes, assert mobile receives only what the daemon snapshot + live bytes provide, hydration was never invoked. (Documents the known limitation.)
### Manual
On a real device, reproduce the original `edit-issues` post-claude-exit scenario, confirm the mobile WebView now shows the same agent summary the desktop shows.
## Open questions
- **Hydration trigger point.** Three plausible options: (a) first `onPtyData`, (b) `runtime.registerPty`, (c) an explicit renderer-pane-mount notification from the renderer. (a) is the chosen approach: cheap, ties cost to a moment already paying IO, and **retryable within a runtime**. The state map is left absent (not `done` or `skipped`) when the renderer serializer isn't yet registered, so a later byte can attempt hydration once the pane mounts. The only consume-on-idle case is `headlessTerminals.has(ptyId)` (live bytes already arrived), where re-seeding would duplicate. (b) fires before the renderer has any data to serialize. (c) needs a new wire from `pty-connection.ts → ipc/pty.ts → runtime`.
- **Cooperation with `seedHeadlessTerminal`.** Today `seedHeadlessTerminal` writes to the headless emulator on `provider.spawn` from `result.snapshot` / `result.coldRestore.scrollback`. The daemon's `result.snapshot` is the visible-screen-only ANSI from `checkpoint.json` — almost always non-empty (it includes the bare prompt) but typically much thinner than what the renderer pane has. If both fire for the same PTY, the daemon path lands first and `headlessTerminals.has(ptyId)` becomes true; the hydration guard would then mark itself `done` and the richer renderer payload is lost. **Decision: gate `seedHeadlessTerminal` on (a) an already-registered renderer serializer OR (b) a renderer pre-signal for the spawning PTY's paneKey.** In `ipc/pty.ts`, before calling `runtime.seedHeadlessTerminal(...)`, check both `runtime.hasRendererSerializerFor(result.id)` AND `pendingByPaneKey.has(args.env?.ORCA_PANE_KEY)` (after validating the paneKey shape — see P1-B2 in the cooperation gate snippet). If either is true, skip the daemon-snapshot seed entirely and let renderer hydration be canonical. (a) covers reattach, where the pane mounted before spawn returned. (b) covers fresh spawn, where the renderer pre-signaled intent before `pty:spawn` but cannot have registered yet because it doesn't know the ptyId. If neither is true (Scenario A — mobile-only, no desktop pane), the daemon-snapshot path runs unchanged. See "Renderer pre-signal handshake" for the full sequence and correctness properties. This is cleaner than a length-comparison "richness heuristic" and lets the renderer be authoritative whenever it's mounted *or* about to mount.
## Rollout
Single change touching the runtime and the renderer-side serialize controller. No feature flag — the hydration is best-effort and degrades to today's behavior when the renderer cannot answer or has nothing useful to share.
Ready for implementation review. Scenario A remains a known limitation tracked for a future daemon-persistence design.
-698
View File
@@ -1,698 +0,0 @@
# Mobile Presence Lock for Desktop Terminal
Design doc for repurposing the existing "phone-fit" banner into a general
presence-based interaction lock between the desktop renderer and mobile
clients sharing a single PTY.
## Problem
The desktop and mobile clients share full read/write access to the same
underlying PTY. There is no synchronization between desktop's xterm `onData`
(which calls `provider.write(ptyId, bytes)` via the `pty:write` IPC) and
mobile's `terminal.send` RPC (which also calls `provider.write` through the
runtime). When both parties type at the same time, bytes interleave at the
TTY level — `ls<enter>` and `pwd<enter>` typed concurrently can produce
`lpws<enter>d<enter>` and execute unintended commands.
The same is true for resize: desktop `pty:resize` and mobile
`terminal.resizeForClient` both reach `provider.resize(ptyId, ...)` without
coordination. The renderer-side `safeFit` has a partial guard, but the
`pty:resize` IPC handler does not check the override server-side.
A teammate using the original mobile build reported "odd behavior using
laptop + mobile at the same time." The most visible symptom is the desktop
terminal jumping to phone dimensions; the more dangerous symptom is silent
input interleaving.
## Today's banner
The existing banner in `TerminalPane.tsx` is keyed on
`getFitOverrideForPty(ptyId)`:
- It only appears when mobile is in `auto`/`phone` display mode AND a phone
resize actually happened.
- It says "Terminal resized for phone (W×H)" with a "Restore" button that
calls `runtime:restoreTerminalFit(ptyId)`.
- That IPC handler sets the display mode to `desktop` and applies the
display mode, which resizes the PTY back to desktop dims and clears the
override. The banner unmounts.
In `desktop` display mode (mobile is subscribed but viewing at desktop dims)
there is no override and no banner — the desktop user has no idea mobile is
watching.
## Goal
Repurpose the banner into a general presence-based lock with this single
rule:
> At any moment, each PTY has exactly one _driver_ — desktop, a specific
> mobile client, or nobody. While mobile drives, desktop input and resize
> are dropped; the desktop banner explains why and offers **Take back** to
> hand the floor over. While desktop drives, mobile keystrokes silently
> reclaim the floor (no mobile-side banner — see "Asymmetric UX" below).
The lock exists at two layers:
- **Renderer guard** (primary UX): `pty-connection.ts` drops xterm `onData`
and `onResize` while a mobile client is the driver. Banner explains why.
- **Server-side defense** (defense-in-depth): the `pty:write` and
`pty:resize` IPC handlers consult the runtime's driver state and drop
desktop-side calls while a mobile client is the driver.
## Behavioral model
### Driver state machine
Instead of a binary lock, each PTY carries a tagged driver state. There is
exactly one driver per PTY at any moment, and transitions are atomic
(updated and emitted together from the runtime).
```ts
type DriverState = { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string }
```
- `idle` — no mobile subscribers; desktop input and resize flow through
normally. (We don't bother distinguishing "no one is here" from "only
desktop is here" since they behave identically; `idle` is the catch-all
for "nothing is currently locking the desktop out.")
- `desktop` — at least one mobile client is subscribed but the desktop has
reclaimed the floor; desktop input and resize flow through.
- `mobile{clientId}` — `clientId` is the mobile actor that most recently
drove this PTY. The banner is mounted on desktop; desktop input and
resize are dropped.
Invariants:
- `currentDriver(ptyId)` is always exactly one of the three kinds.
- On the main process, a transition mutates `currentDriver(ptyId)` and
emits `terminalDriverChanged(ptyId, driver)` in the same critical
section. The renderer's mirror in `mobile-driver-state.ts` is updated
only when the IPC arrives — there is a brief IPC-hop window during
which the renderer's `getDriverForPty(ptyId)` returns a stale value.
This is why the server-side `pty:write` / `pty:resize` defenses are
**load-bearing, not redundant**: a desktop keystroke fired during
that window passes the renderer guard and is dropped by the server
guard.
- Only one driver per PTY at any moment. Multiple mobile clients can
_subscribe_ simultaneously (see "Multi-mobile subscriber model"), but
only the most recent mobile actor is the driver.
### Transitions
| Current driver | Trigger | Next driver | Side effect |
| -------------- | ---------------------------------------------------------------------------- | --------------------------- | ----------------------------------------------------------------------------- |
| `idle` | mobile subscribes with `displayMode='auto'` (first client for this ptyId) | `mobile{clientId}` | banner mounts on desktop; PTY resizes to phone dims |
| `idle` | mobile subscribes with `displayMode='desktop'` (first client for this ptyId) | `desktop` | inner subscriber map populated; **no** banner; PTY stays at desktop dims |
| `idle` | desktop input or first PTY data after no subscribers | `idle` (no transition) | — |
| `mobile{A}` | desktop clicks **Take back** | `desktop` | banner unmounts; PTY snaps to desktop dims if at phone dims |
| `mobile{A}` | mobile A sends input/resize/setDisplayMode | `mobile{A}` (no transition) | — |
| `mobile{A}` | mobile B sends input | `mobile{B}` | (no banner change; both are "mobile") |
| `mobile{A}` | last mobile client unsubscribes | `idle` | banner unmounts |
| `desktop` | any mobile client sends input/resize | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims if that client's mode is auto |
| `desktop` | mobile sets `displayMode` to `auto` or `phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims (deliberate "I want to drive" gesture) |
| `desktop` | mobile sets `displayMode` to `desktop` | `desktop` (no transition) | — (already desktop-mode watching) |
| `desktop` | mobile subscribes-fresh with `auto`/`phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims |
| `desktop` | mobile subscribes-fresh with `desktop` | `desktop` (no transition) | inner map updated; no banner |
| `desktop` | last mobile client unsubscribes | `idle` | (banner already unmounted) |
**Subscribe-in-desktop-mode rule.** A mobile client subscribing in
`displayMode='desktop'` is treated as a passive watch, not a take-floor
gesture. The driver stays at `idle`/`desktop`, so the desktop user is not
interrupted. The instant that client (or any peer) sends input, sets the
display mode to `auto`/`phone`, or sends a resize, the runtime transitions
to `mobile{thatClient}` and the banner appears. This matches the rest of
the design: the lock engages on _interaction_, not on _presence_.
The protocol is "first-mover wins until the other party acts." Desktop
clicks Take back, mobile types, desktop types again — banner ping-pongs as
each side acts. The `clientId` carried in `mobile{clientId}` is updated
each time a mobile actor takes the floor; this is the wire channel by
which the runtime knows _which_ phone last drove (useful for the
forward-path coordinator described at the bottom of this doc, and for the
multi-mobile semantics described below).
### Take back and PTY dimensions
Take back has two sub-cases:
1. **Mobile was in `phone`/`auto` mode (PTY at phone dims)**. Take back
resizes PTY back to desktop dims (existing behavior of
`applyMobileDisplayMode('desktop')` when `wasResizedToPhone` is true).
2. **Mobile was in `desktop` mode (PTY already at desktop dims)**. Take
back is a pure lock-flag flip; no resize. Today's
`applyMobileDisplayMode('desktop')` already short-circuits the resize in
this case.
In the symmetric direction, mobile reclaim:
1. **Mobile is in `phone`/`auto` mode but PTY is at desktop dims** (because
desktop just clicked Take back): mobile reclaim re-applies the phone
resize via `applyMobileDisplayMode(currentMode)`.
2. **Mobile is in `desktop` mode**: pure lock-flag flip; no resize.
## End-user UX
| Driver | Overlay (desktop) | Desktop input | Desktop resize |
| ----------- | ------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------- |
| `idle` | hidden | allowed | allowed |
| `mobile{*}` | loud lock overlay with **Take back** and **Collapse**; collapsed chip keeps **Take back** visible | blocked at xterm.onData (silent drop) | blocked in renderer + dropped in `pty:resize` IPC server-side |
| `desktop` | hidden | allowed | allowed |
Walkthroughs:
**Phone connects while you're typing.** Overlay appears. PTY may resize to
phone dims (existing). Your next keystroke is dropped. Click Take back to
unlock and (if needed) restore desktop dims.
**You click Take back.** Overlay gone. You type freely. Mobile stays
connected; mobile sees the desktop-sized terminal because PTY snapped back
(or stayed at desktop dims if mobile was in desktop mode).
**Mobile types something while you're reclaimed.** Overlay reappears. Your
next keystroke is blocked. PTY may snap back to phone dims (if mobile is in
auto mode).
**Mobile disconnects.** The active-driver overlay is gone. If the PTY is
still held at phone dimensions, the held-fit Restore overlay remains until
desktop restores or the finite auto-restore timer fires.
**Multiple panes.** Driver state is per-pane. Phone on pane A doesn't
affect pane B.
**Text selection / scrollback / copy.** Always allowed. The lock is
keystroke and resize only.
**Output continues to render.** While the overlay is mounted, terminal
output streams to xterm normally — it is the mobile actor's bytes you
are seeing. Only desktop-side keystrokes and resize are dropped. This
matters because "your input is paused" can read ambiguously; output
flow is unaffected.
## Asymmetric UX (accepted tradeoff)
Mobile sees no banner in this PR. The mobile UI is visually unchanged: no
"Desktop is driving" indicator, no analog to the Take back affordance.
This is asymmetric and we are accepting it deliberately:
- **Smaller mobile UI surface.** Mobile already has limited screen real
estate and a constrained component set; introducing a presence banner
there is a non-trivial design + i18n + dismissibility task that we want
to defer until the lock model has settled in production.
- **Faster ship.** Driver state machine + multi-mobile subscriber fix +
desktop banner is the smallest change that fixes the dangerous collision
(silent input interleaving). Adding a mobile-side banner doubles the UI
scope.
- **Mobile reclaim is naturally signaled.** When a mobile user types
while desktop drives, the runtime flips the driver to
`mobile{thatClient}`, the desktop banner remounts, and (if mobile is in
auto mode) the PTY snaps back to phone dims. The mobile user sees
the pane reflow and their keystrokes appear in the output stream. There
is no silent black-hole condition on mobile that a banner would
resolve.
- **Mobile keystrokes always reclaim implicitly.** The protocol on mobile
is "just type to take the floor." There is no mobile-side button to
surface; a banner with no actionable control is closer to noise than
signal.
The flip side: a mobile user typing into a stale view _won't_ be told
"hold on, desktop is driving" before their keystroke lands. They just take
the floor. We consider that acceptable because (a) they're explicitly
acting, and (b) the desktop user gets the warning side of the tradeoff
where the risk of unintended action is much higher.
If usage data later shows mobile users frequently being surprised by
desktop activity, a mobile banner can be added without changing the wire
format — `terminalDriverChanged(ptyId, driver)` already carries everything
needed.
## Architecture
### Where the truth lives
The `mobileSubscribers` map (re-keyed; see "Multi-mobile subscriber model"
below) plus a new `currentDriver: Map<ptyId, DriverState>` on the runtime
are the source of truth. The renderer learns the driver state via a new
IPC event `terminal-driver-changed(ptyId, driver: DriverState)` emitted
from the runtime through the existing notifier path. The structured
payload is intentional: a binary `locked: boolean` would lose the
`clientId` we need for multi-mobile semantics today and for the unified
write coordinator on the forward path.
The runtime exposes `getDriver(ptyId): DriverState`, replacing the old
`isLocked(ptyId): boolean`. Server-side `pty:write` / `pty:resize`
defenses check `runtime.getDriver(ptyId).kind === 'mobile'` to drop
desktop-side calls.
A new renderer module `mobile-driver-state.ts` mirrors
`mobile-fit-overrides.ts`: keyed by ptyId, supports
`getDriverForPty(ptyId): DriverState` and a subscribe-style change
listener. The `TerminalPane` banner mounts when
`getDriverForPty(ptyId).kind === 'mobile'`, and `pty-connection`'s
`onData`/`onResize` guards drop input/resize under the same predicate.
### Why not reuse `getFitOverrideForPty`?
The fit override only fires when the PTY was actually resized
(`mode='auto'` and `wasResizedToPhone=true`). It misses the
desktop-mode case where mobile is subscribed but no resize happened. The
driver state is broader than the fit override.
### Why a new IPC event vs extending `terminal-fit-override-changed`?
Cleaner separation. The fit override is about dimensions; the driver
state is about ownership. They happen to overlap in the auto/phone
subcase, but conflating them long-term ties future work on either to the
other.
### Why a structured payload vs `locked: boolean`?
The runtime needs to know _which_ mobile client most recently drove (for
multi-mobile semantics today and for the future write coordinator). A
structured `DriverState` payload makes that information first-class on
the wire instead of smuggling it through a side channel. The cost is ~70
LoC over a binary lock; the payoff is a symmetric UX foundation and a
future-proof wire format.
## Multi-mobile subscriber model
The `mobileSubscribers` map is today shaped `Map<ptyId, Subscriber>` and
each `terminal.subscribe` call overwrites the previous subscriber for that
ptyId. That overwrite is fine for a binary `has(ptyId)` presence check but
breaks the moment we treat presence as a real set: phone B subscribing
silently evicts phone A, so phone A unsubscribing or its WS dropping looks
like "no mobile clients" even while phone B is still on the line. The
driver state machine sits directly on top of presence, so soundness here
is a prerequisite, not a polish.
We rekey to `Map<ptyId, Map<clientId, Subscriber>>` and update each
callsite:
- **`handleMobileSubscribe(ptyId, clientId, subscriber)`** — get-or-create
the inner map, then `inner.set(clientId, subscriber)`. Do not overwrite
peer clients. If the inner map was empty before insert, this is the
first subscriber for the PTY; emit the `idle → mobile{clientId}` driver
transition.
- **`handleMobileUnsubscribe(ptyId, clientId)`** — `inner.delete(clientId)`,
then if the inner map is now empty, delete the outer entry, run the
existing restore-resize logic, and emit the `* → idle` driver
transition. If the inner map is non-empty, do **not** fire restore /
driver-change; peers still have the floor.
- **`applyMobileDisplayMode(ptyId)`** — iterate the inner map. The
restore-resize semantics need a single representative subscriber (the
desktop dims to restore _to_ are stored on the subscriber record). Pick
the earliest by subscribe time so the restore target is stable as
later phones come and go. The display mode itself is per-PTY runtime
state, not per-client.
**Active phone-fit dim selection (multi-mobile).** When two or more
mobile clients subscribe with different viewports (e.g., iPhone +
iPad), the PTY can only be at one phone-fit size at a time. Rule:
**the most recent mobile actor's viewport wins**. This matches the
driver state machine — whoever last took the floor (`mobile{X}`)
also dictates the active phone-fit dims. When that client
unsubscribes, the next-most-recent surviving subscriber's viewport
wins; on the last client leaving, the inner map empties and we run
the existing restore back to the earliest-recorded desktop dims. We
do not use `min(cols, rows)` across subscribers because that
produces no clear principal — multiple TUIs are forced to render
for an aggregate viewport that nobody actually has, which is worse
than picking a single owner. Most-recent-actor is consistent with
the rest of the protocol (whoever acted last has the floor) and is
cheap to reason about.
**Subscriber record extension.** "Most-recent actor" and
"earliest-by-subscribe-time" both require timestamps that today's
Subscriber record does not carry. Extend it with two fields:
```ts
interface Subscriber {
clientId: string
viewport: { cols: number; rows: number }
wasResizedToPhone: boolean
previousCols: number | null
previousRows: number | null
subscribedAt: number // ms since epoch, set on insert
lastActedAt: number // ms since epoch, init = subscribedAt;
// updated on every mobileTookFloor for this client
}
```
- `applyMobileDisplayMode(ptyId)` for active phone-fit dims:
iterate the inner map, pick `argmax(lastActedAt)`, use that
subscriber's `viewport`.
- For restore-to-desktop semantics on last-client-leaves: pick
`argmin(subscribedAt)` **among subscribers with non-null
`previousCols`/`previousRows`** and use that subscriber's
`previousCols/Rows` as the restore target. Desktop-mode
subscribers carry `previousCols/Rows = null` by design (the
existing `handleMobileSubscribe` short-circuits dim capture for
desktop-mode joins to avoid capturing a stale full-width size),
so they are not viable restore targets. If no surviving
subscriber has captured dims, fall back to
`lastRendererSizes.get(ptyId)` (the desktop renderer's most
recent reported size) — same fallback the existing first-insert
capture path uses.
Both operations are O(n) over the inner map, which is bounded by
the number of concurrently subscribed mobile clients (typically 1,
rarely 2-3). No indexing needed.
- **`isMobileSubscriberActive(ptyId)`** — returns `true` iff the inner map
is non-empty.
- **Driver state for the mobile side** stores the `clientId` of the most
recent mobile actor. Whenever any mobile client sends
input/resize/setDisplayMode/subscribes-fresh, that client becomes the
`currentDriver` (`mobile{thatClient}`). Phone B taking over from phone A
is an internal mobile→mobile transition: no banner change, but the
recorded `clientId` updates so the runtime always knows who is the
authoritative mobile actor.
The semantic upshot is that the desktop banner is governed by "is the
inner map non-empty AND has the desktop not reclaimed?" rather than the
old single-slot heuristic, and Take back / mobile-reclaim correctly
ping-pong even when two phones are on the same PTY.
## Implementation
Files touched:
1. `src/main/runtime/orca-runtime.ts` — rekey `mobileSubscribers` to
`Map<ptyId, Map<clientId, Subscriber>>`, add `currentDriver` map,
`getDriver(ptyId)` getter, transition methods, notifier hook. Update
`handleMobileSubscribe` / `handleMobileUnsubscribe` /
`applyMobileDisplayMode` / `isMobileSubscriberActive` per the
multi-mobile model above. **Extend `onPtyExit` cleanup** to
`currentDriver.delete(ptyId)` and emit `terminalDriverChanged(ptyId,
{ kind: 'idle' })` so any banner mounted on the dead pane unmounts.
Without this, a dead PTY's last driver state lingers and the
renderer banner could persist on a dead pane until tab teardown.
2. `src/main/runtime/orca-runtime.ts` interface `RuntimeNotifier` — add
`terminalDriverChanged(ptyId, driver: DriverState)`.
3. `src/main/window-manager.ts` (or wherever `RuntimeNotifier` is wired) —
forward to renderer via `webContents.send('terminal-driver-changed', …)`.
4. `src/preload/index.ts` + `src/preload/api-types.ts` — expose the new
event with the structured `DriverState` payload.
5. `src/main/ipc/runtime.ts` — add `runtime:reclaimTerminal` IPC (or
extend `runtime:restoreTerminalFit`) which drives the
`mobile{*} → desktop` transition.
6. `src/main/runtime/rpc/methods/terminal.ts` — drive the
`* → mobile{clientId}` transition in `terminal.send`,
`terminal.resizeForClient`, `terminal.setDisplayMode`, and the
subscribe handler (with the subscribe-in-desktop-mode exception
above).
**Wire-format changes required.** The driver state machine tags
the active mobile actor with `clientId`, so every mobile RPC method
that can take the floor must carry the caller's identity. Today
only `terminal.subscribe` and `terminal.resizeForClient` do; we
extend the others.
**Canonical identity shape.** Use the existing
`terminal.subscribe` shape: `client: { id: string; type: 'mobile'
\| 'desktop' }` (with `type` optional). `TerminalResizeForClient`
keeps its grandfathered top-level `clientId: string` field for
backward compatibility — implementers may add an aliased nested
`client.id` setter for consistency at the call site, but the wire
shape is preserved.
New fields on existing schemas:
- `TerminalSend` gains `client: { id: string; type?: 'mobile' \|
'desktop' }` (optional for backward compatibility — falls back
to "the most recent mobile actor" when absent).
- `TerminalSetDisplayMode` gains the same `client` field.
- `TerminalUnsubscribe` gains `client: { id: string }` so the
server can derive the composite cleanup key (see below); the
existing `subscriptionId` field is kept and remains the primary
lookup, with `client.id` used only when the caller passed a
bare-handle `subscriptionId`.
**Subscribe-side composite key.** Change `subscriptionId` from
`params.terminal` to `${params.terminal}:${params.client.id}` so
two phones subscribing to the same terminal handle do not evict
each other via `registerSubscriptionCleanup`. Without this fix,
the multi-mobile rekey to `Map<ptyId, Map<clientId, Subscriber>>`
would be silently defeated at the RPC subscription layer (phone
B's subscribe runs phone A's cleanup → A's data listener tears
down → A's `handleMobileUnsubscribe` fires).
**Unsubscribe-side composite key.** The mobile RPC client today
emits `terminal.unsubscribe` with `params: { subscriptionId:
stream.params.terminal }` — the bare handle. With the subscribe
side now keying by composite, a bare-handle unsubscribe will miss
in `subscriptionCleanups.get(bareHandle)` and silently no-op,
leaking the data listener and leaving `mobileSubscribers`
populated forever (banner stuck, driver never returns to `idle`).
Fix: the mobile RPC client emits `{ subscriptionId:
${terminal}:${clientId}, client: { id: clientId } }`, and the
server's `cleanupSubscription` uses whichever the caller sent —
if both `subscriptionId` and `client.id` are present and the
caller passed only `terminal` in `subscriptionId`, the server
reconstructs the composite key from `client.id`. Belt-and-braces
so a stale older mobile build still cleans up correctly.
This is a coordinated client/server wire-format change. The
mobile app must ship in lockstep; if a stale mobile client emits
bare-handle unsubscribe to a new server, the server's
reconstruction path catches it. If a new mobile client emits
composite-handle unsubscribe to a stale server, the stale server
ignores `client.id` and the composite key fails to match — but
that direction is moot because the stale server has not adopted
composite keys on subscribe either. No client newer than server,
so this is safe.
7. `src/main/ipc/pty.ts` — defense in depth: drop `pty:write` and
`pty:resize` calls when
`runtime.getDriver(id).kind === 'mobile'`. **Preserve the existing
`runtime.isResizeSuppressed()` short-circuit at the top of the
`pty:resize` handler** — the new driver-state guard is _in
addition to_, not in place of, the suppression window. The two
guards have different purposes: `isResizeSuppressed()` blocks the
safeFit cascade after a take-back transition (preventing
collateral resize corruption of background panes), while the
driver-state check blocks desktop-side resizes whenever mobile is
driving. Both must apply.
8. `src/renderer/src/lib/pane-manager/mobile-driver-state.ts` — new
renderer store mirroring `mobile-fit-overrides.ts`. Exposes
`getDriverForPty(ptyId): DriverState` and a change listener.
9. `src/renderer/src/components/terminal-pane/pty-connection.ts` — guard
`onData` and `onResize` on
`getDriverForPty(ptyId).kind === 'mobile'`.
10. `src/renderer/src/components/terminal-pane/TerminalPane.tsx` — switch
banner to consume driver state, update copy, rename Restore → Take
back.
### Server-side detail
```ts
// orca-runtime.ts
private currentDriver = new Map<string, DriverState>()
getDriver(ptyId: string): DriverState {
return this.currentDriver.get(ptyId) ?? { kind: 'idle' }
}
private setDriver(ptyId: string, next: DriverState): void {
this.currentDriver.set(ptyId, next)
this.notifier?.terminalDriverChanged(ptyId, next)
}
reclaimTerminalForDesktop(ptyId: string): void {
if (!this.isMobileSubscriberActive(ptyId)) return
// Snap PTY back to desktop dims if currently at phone dims.
// applyMobileDisplayMode is a no-op resize when already at desktop dims.
this.setMobileDisplayMode(ptyId, 'desktop')
this.applyMobileDisplayMode(ptyId)
this.setDriver(ptyId, { kind: 'desktop' })
}
private mobileTookFloor(ptyId: string, clientId: string): void {
// If mobile is in phone/auto mode, re-apply phone resize on the first
// hand-off back from desktop. Mobile-to-mobile hand-offs are no-ops
// for resize.
const prev = this.getDriver(ptyId)
if (prev.kind === 'desktop') this.applyMobileDisplayMode(ptyId)
this.setDriver(ptyId, { kind: 'mobile', clientId })
}
handleMobileUnsubscribe(ptyId: string, clientId: string) {
const inner = this.mobileSubscribers.get(ptyId)
if (!inner) return
inner.delete(clientId)
if (inner.size > 0) return // peer mobile clients still drive
this.mobileSubscribers.delete(ptyId)
// Drive transition fires synchronously so the desktop banner
// unmounts immediately. The PTY restore runs inside the existing
// 300ms pendingRestoreTimers debounce — see "Restore-debounce
// window" under Edge cases.
this.setDriver(ptyId, { kind: 'idle' })
// Existing restore-resize logic (300ms debounced timer) runs here.
}
```
The mobile-took-floor path is invoked from the RPC method handlers (not
from `sendTerminal` directly) so that the driver flip is bound to
deliberate mobile actions, not to internal runtime calls.
### Renderer guards
```ts
// pty-connection.ts onData
if (currentPtyId && getDriverForPty(currentPtyId).kind === 'mobile') {
return // Mobile is driving this PTY; banner explains.
}
// existing transport.sendInput(data)
// pty-connection.ts onResize
// Why: keep both predicates. getFitOverrideForPty fires synchronously
// in the renderer the moment safeFit runs; getDriverForPty arrives via
// IPC and lags by one round-trip. Removing the fit-override predicate
// would re-introduce the resize-over-mobile-fit bug mobile-fit-overrides
// was added to prevent.
if (
currentPtyId &&
(getFitOverrideForPty(currentPtyId) || getDriverForPty(currentPtyId).kind === 'mobile')
) {
return
}
```
### Overlay copy
The lock UI is a full-pane overlay (not a thin banner) so users cannot
miss it. The driving state can be collapsed to a corner chip while the
session is being watched; the held-fit state stays loud because there is
no live output to monitor (see `MobileDriverOverlay.tsx`).
Driving state, expanded:
- Eyebrow: `Mobile is driving this terminal`
- Title: `Your keyboard is paused`
- Body: `Output below is being typed from your phone. Take back to resume typing on the desktop, or collapse to keep watching.`
- Buttons: `Collapse`, `Take back`
Driving state, collapsed (corner chip): `● Mobile driving [ Take back ]`.
Clicking the label re-expands.
Held-at-phone-fit (no active mobile subscriber, PTY still at phone dims):
- Eyebrow: `Held at phone size`
- Title: `This terminal is sized for your mobile app`
- Body: `The session is still being held at the dimensions your phone last reported. Restore to use it on your desktop.`
- Button: `Restore desktop size`
There is no auto-unlock-on-WS-silence and no liveness probing; the overlay
stays up until the desktop user takes back / restores, until mobile actor
sends input (which keeps mobile as driver), or until the last mobile
subscriber disconnects.
## Edge cases
- **Mobile drops network**. WS doesn't immediately know. The inner
subscriber map retains the entry for ~30s until ping timeout. Banner
stays. Take back still works during this window — it transitions to
`desktop` immediately. When mobile eventually reconnects with
`terminal.subscribe`, that client takes the floor again
(`desktop → mobile{thatClient}`).
- **Two phones on same PTY**. The inner map carries both subscribers.
Driver state is `mobile{whicheverActedLast}`. Phone A unsubscribing
while phone B is still on does _not_ drop the banner; only the last
client leaving the inner map transitions to `idle`.
- **Pre-locked input in flight**. `transport.sendInput` is
fire-and-forget; there is no in-flight queue to drain. The first
dropped keystroke is on the renderer side, which is good enough.
- **Take back while phone is offline**. Works (transitions to `desktop`).
When phone reconnects with a fresh subscribe, driver flips to
`mobile{thatClient}`.
- **Display mode toggle from mobile**. `terminal.setDisplayMode` is
treated as mobile interaction → that client takes the floor. Aligns
with "any deliberate mobile action takes the floor."
- **Subscribe from a fresh client (reconnect with new clientId)**. The
subscribe path runs the `desktop → mobile{thatClient}` (or
`idle → mobile{thatClient}`) transition. A mobile reconnect takes back
the floor from desktop — correct semantic since the user actively
reopened the mobile view.
- **Held-fit window on last-subscriber-leaves**. When the last mobile
subscriber unsubscribes, the driver transitions to `idle`
synchronously. The active-driver overlay swaps to the held-fit Restore
overlay while the phone-fit override remains. With the default
indefinite hold, that Restore overlay stays up until desktop restores;
with finite auto-restore, it stays up until the timer restores the PTY
back to desktop dims. This keeps the "why is my terminal phone-sized?"
explanation visible even after the mobile driver is gone.
## Tests
- `orca-runtime.test.ts` (driver state machine):
- Every transition row in the table above has a unit test asserting
`(prev, trigger) → (next, side effect, emitted event payload)`.
- `getDriver` defaults to `{ kind: 'idle' }` for unknown ptyIds.
- `terminalDriverChanged` is emitted exactly once per state change and
carries the structured `DriverState` payload (not a boolean).
- `orca-runtime.test.ts` (multi-mobile subscriber sequencing):
- Phone A subscribes (`auto`) → driver is `mobile{A}`, banner emit
fires.
- Phone A subscribes (`desktop`) from `idle` → driver stays `desktop`
(subscribe-in-desktop-mode does not take the floor); inner map
has A.
- Phone B subscribes (`auto`) while A is still on (`auto`) → driver
flips to `mobile{B}` (subscribe-fresh-with-auto/phone counts as
take-floor); inner map has both subscribers; active phone-fit
dims switch to B's viewport.
- Phone B unsubscribes while A is still on → driver remains mobile;
active phone-fit dims revert to A's viewport (next-most-recent
actor); banner stays up; no `idle` transition.
- Phone A unsubscribes (last client leaves) → driver transitions to
`idle`, banner unmounts, PTY restores to earliest-recorded
desktop dims.
- `applyMobileDisplayMode` picks the **most-recent-actor's**
viewport for active phone-fit dims and the
**earliest-by-subscribe-time** desktop dims for restore.
- Mobile sets `displayMode` to `desktop` from `mobile{*}` does _not_
transition to `desktop` automatically (existing setDisplayMode
semantics stand) — but the runtime's _driver_ transition rule for
`desktop → desktop` (mode change to desktop while already in
desktop driver) is a no-op.
- `terminal.test.ts` (subscriptionId per-client keying):
- Phone A subscribes to terminal handle `T`, phone B subscribes to
the same handle `T` → A's data listener still receives bytes
after B's subscribe (subscriptionId is `${T}:${clientId}` so
`registerSubscriptionCleanup` does not collide).
- Regression: prior to the fix, the second subscribe ran the first
subscribe's cleanup and tore down phone A's stream.
- `pty-connection.test.ts`:
- `onData` is dropped while `getDriverForPty(id).kind === 'mobile'`.
- `onData` is delivered when driver flips to `desktop` or `idle`.
- `onResize` is dropped while driver is `mobile`.
- `pty.test.ts` (or new `pty-driver-state.test.ts`):
- `pty:write` IPC is dropped when `runtime.getDriver(id).kind ===
'mobile'`.
- `pty:resize` IPC is dropped under the same predicate.
## Out of scope
- Mobile yanking desktop tab focus via `terminal.focus`.
- Cold-restore ack from mobile-only attach.
- Mobile-side "Desktop is driving" banner (see "Asymmetric UX" above).
These are independent collisions tracked separately.
## Forward path
The natural next step (not in this PR) is unifying both writers — the
desktop renderer's `pty:write` IPC and the mobile RPC's `terminal.send`
— into a single coordinator that queues bytes through a runtime-owned
write path. The driver state machine cleanly supports this: the
coordinator can use `currentDriver(ptyId)` as the admission predicate
(only the current driver's bytes are dequeued), and the
`mobile{clientId}` payload tells it which queue head to drain when a
specific phone is the active actor. Today's PR keeps two independent
write paths and uses driver state purely as a drop-filter; the same
state machine becomes the scheduling input on the forward path without
a wire-format change.
## Rollout
Single PR. No feature flag needed; behavior is strictly additive (locks
an existing collision surface) and the existing banner UX continues to
work under the new driver-state model with sharper copy.
-406
View File
@@ -1,406 +0,0 @@
# Single Shared RPC Client per Host (Mobile)
Design doc for collapsing the per-screen WebSocket connection model into a
single shared `RpcClient` per paired host, owned by a React context that
sits above the route tree.
## Problem
The mobile app today opens **one WebSocket per screen per host**:
| Screen | Connections per host |
|---|---|
| Home (`app/index.tsx`) | 1 (with persistent `accounts.subscribe` stream) |
| Host detail (`app/h/[hostId]/index.tsx`) | +1 |
| Worktree session (`app/h/[hostId]/session/[worktreeId].tsx`) | +1 |
| Accounts (`app/h/[hostId]/accounts.tsx`) | +1 |
| Pair confirm (briefly) | +1 |
A user actively browsing one host typically holds **3–4 simultaneous
sockets** to the desktop runtime. Each call to `connect()` runs its own
E2EE handshake, allocates an ephemeral keypair, and runs an independent
reconnect loop with exponential backoff.
This causes three observable problems:
1. **Stuck-connecting / reconnecting for minutes.** The desktop's
`MAX_WS_CONNECTIONS = 32` is shared across all clients. A user who
navigates rapidly accumulates stale sockets faster than they can be
reaped by TCP keepalive (which can take 60–300s on default systems
for half-open connections from a phone leaving Wi-Fi range or
backgrounding). Once the cap is hit, new sockets are rejected with WS
close code `1013 Maximum connections reached`. The mobile reconnect
loop does not recognize 1013 as terminal — it retries with backoff,
each retry also dropped, until enough stale sockets are reaped.
Result: a screen that should connect in <1s is stuck for 1–5 minutes.
2. **Tab create/delete hangs.** `client.sendRequest('terminal.create')`
awaits `waitForConnected()`. If the session-screen client is in
`connecting`/`reconnecting` state because its socket lost the cap
race, the await blocks until the 30s `REQUEST_TIMEOUT_MS` fires.
The user sees nothing happen.
3. **Triple cost on every cold-start.** Three E2EE handshakes,
three Curve25519 keypair generations, three subscription
re-registrations on every app launch. On low-end Android, this
visibly delays first paint by hundreds of milliseconds.
The architecture also wastes server resources: each socket carries its
own E2EE channel, its own subscription set, its own driver-state-machine
client identity (cf. `docs/mobile-presence-lock.md`). The server already
has logic to reconcile multi-socket-per-token tear-down
(`hasOtherConnections` in `ws-transport.ts`) — that logic exists *because*
this design forced the question; with a single client per host, it becomes
unnecessary.
## Today's transport ownership
Five files independently call `connect(endpoint, deviceToken, publicKeyB64)`:
```
mobile/app/pair-confirm.tsx # one-shot during pairing
mobile/app/pair-scan.tsx # one-shot during pairing
mobile/app/index.tsx # N (one per paired host)
mobile/app/h/[hostId]/index.tsx # 1 (host detail)
mobile/app/h/[hostId]/session/[worktreeId].tsx # 1 (session)
mobile/app/h/[hostId]/accounts.tsx # 1 (accounts)
```
Each owns a `useRef<RpcClient | null>` and calls `client.close()` from a
cleanup function. The home screen additionally maintains a
`clientsRef: Array<{ hostId, client }>` so its own usage of the per-host
client survives across navigation events.
This pattern works for *correctness* — every cleanup eventually closes
its socket — but it breaks under three real-world conditions:
1. **Rapid navigation.** Mounts spawn before unmounts complete; cleanup
`client.close()` runs after a new screen has already opened a fresh
socket to the same host. Two sockets briefly coexist for the same
token, multiplied across screens.
2. **Network drops.** A backgrounded/locked phone on a flaky network
leaves sockets half-open. The server doesn't get a FIN; cleanup
relies on TCP keepalive timing. Meanwhile the foreground app, on
resume, opens fresh sockets. The half-open ones eat the cap until
reaped.
3. **Hot reload during dev.** Each Metro hot reload fires a new render
tree without unmounting the old, so connections leak.
## Goal
> A paired host has at most **one active WebSocket** at any time, owned
> by a context provider above the route tree. All screens for that host
> share that client. The pair flows are the only places that create
> short-lived clients (and they explicitly close those after pairing
> completes).
This is the architectural fix to the symptoms above. Combined with the
two recently-shipped hotfixes (token cache + stable `useEffect`
dependency on home screen), this completes the connection-lifecycle
work.
## Design
### Layered ownership
```
RootLayout (<RpcClientProvider>)
└── routes
└── <HostScopedClientGate hostId={...}> // mounts when route has hostId
├── h/[hostId]/ // host detail
├── h/[hostId]/session/[worktreeId]/ // session
└── h/[hostId]/accounts/ // accounts
```
Two providers, layered:
1. **`RpcClientProvider` (root)** — owns one `RpcClient` per host,
keyed by `hostId`. Lifecycle: opens on first request for that
host's client, holds open until app shutdown OR until the host is
removed (`removeHost(hostId)` triggers explicit close). Reuses
existing `loadHosts()` cache from the recently-merged
`host-store.ts` work.
2. **`HostScopedClientGate` (per host)** — a thin route-layout
component placed at `app/h/_layout.tsx`. Reads `hostId` from
route params, requests the client for that host from the root
provider, exposes it via context to descendants, and renders a
loading state until the client reaches `connected`. Guarantees
every descendant screen sees the same client instance for that
host — no per-screen `connect()` calls.
The home screen (`app/index.tsx`) lives outside `HostScopedClientGate`
since it spans all hosts; it consumes the root provider directly via
a multi-host hook (see API below).
### API
```ts
// New file: mobile/src/transport/client-context.tsx
type RpcClientContext = {
// Get-or-open. Returns the singleton client for hostId; opens it
// lazily on first call, reuses it for all subsequent callers. Never
// returns null (returns a placeholder client in 'connecting' state
// if open hasn't completed).
getClient: (hostId: string) => RpcClient
// Connection state for a given host (driven by client.onStateChange).
useHostState: (hostId: string) => ConnectionState
// Useful for the home screen which renders all hosts at once.
useAllClients: () => Array<{ hostId: string; client: RpcClient }>
}
export const RpcClientProvider: React.FC<{ children: React.ReactNode }>
export const useHostClient: (hostId: string) => {
client: RpcClient
state: ConnectionState
}
```
Internal store (single `useRef` in the provider):
```ts
type StoreEntry = {
client: RpcClient
state: ConnectionState
refCount: number // number of active screens holding this client
closeTimer: NodeJS.Timeout | null
}
const store = useRef(new Map<string, StoreEntry>())
```
### Lifecycle rules
1. **Open on first read.** First `getClient(hostId)` call for a host
reads the host record (uses cached `loadHosts()`), then calls
`connect()` and stores the client. Subsequent calls return the
cached entry.
2. **Idle close timer.** When `refCount` drops to 0 (all screens for
that host unmounted), schedule a 30-second close timer. If a screen
for the same host mounts within 30s, cancel the timer. Otherwise,
close the client and remove from the store.
- Why 30s: covers fast tab-switching and back-navigation without
keeping idle sockets forever. Tunable based on observed behavior.
3. **Forced close on host removal.** `removeHost(hostId)` from
`host-store.ts` calls into the provider to close the client
immediately and delete the store entry.
4. **App backgrounded.** No special action — let TCP keepalive and
server-side reaping handle it. Reconnect happens on foreground.
5. **App foregrounded.** Trigger a `getState()` poll on every
non-closed entry; if any are in `disconnected` (TCP died while
backgrounded), the existing reconnect loop handles it. No new
client allocations.
### Public surface for screens
Each screen replaces:
```ts
// Before
const [client, setClient] = useState<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
useEffect(() => {
let rpcClient: RpcClient | null = null
void (async () => {
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (!host) return
rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState)
setClient(rpcClient)
})()
return () => {
rpcClient?.close()
}
}, [hostId])
```
with:
```ts
// After
const { client, state } = useHostClient(hostId)
```
Total LoC reduction across screens: ~150 lines.
### Pair flow exception
`app/pair-confirm.tsx` and `app/pair-scan.tsx` continue to call `connect()`
directly with **explicit `client.close()`** after the test request returns.
Reason: the host record doesn't yet exist in `loadHosts()` during pairing,
so the provider has nothing to look up. The pair flow's client is a
short-lived transient that delivers `getStatus()` once and then dies.
After `saveHost()` succeeds, the user is navigated away; the next time
they enter `/h/[hostId]/...`, the provider opens a fresh client through
the normal path.
### Streaming subscription handling
The home screen's `accounts.subscribe` stream and the session screen's
terminal subscriptions remain owned by their respective screens — the
provider doesn't manage subscriptions, only the underlying transport.
Each screen's effect calls `client.subscribe(...)` and stores the
returned unsubscribe function. On unmount, the screen unsubscribes
(returns to the existing per-screen pattern, just over a shared
transport). The transport's `subscribe()` already correctly multiplexes
multiple listeners on one WebSocket via the `id` field.
### State propagation
`useHostState(hostId)` returns the live `ConnectionState`. The provider
maintains a per-host `useState` keyed by hostId; the `client.onStateChange`
listener is wired once at client creation and updates the corresponding
state slot. `useHostState` reads from this state via `useSyncExternalStore`
or a context selector — the choice is mostly preference; in this
codebase, given the small state shape, a simple `useContext + useMemo`
of the matching slot is fine.
## Migration
Step-by-step, each step independently shippable:
1. **Add `RpcClientProvider` and `useHostClient`.** No callers yet.
Wire into `app/_layout.tsx`. Existing screens unchanged.
2. **Migrate session screen** (highest-risk, most-used). Replace
per-screen `connect()` with `useHostClient`. Test connection
behavior, terminal create/delete, scrollback hydration.
3. **Migrate host detail and accounts screens.** Same pattern.
4. **Migrate home screen.** Replace `clientsRef` with
`useAllClients()`. The home screen's per-host streaming
subscriptions move into a hook that runs per-host.
5. **Add `HostScopedClientGate`** at `app/h/_layout.tsx` to centralize
the gate and remove duplicated loading-state logic.
6. **Delete legacy code.** Remove the dead `connect()` import paths
from each screen. Codepoint reduction.
7. **Remove server-side `hasOtherConnections` complexity** in a
follow-up: with one socket per token, the multi-socket reconciliation
in `runtime-rpc.ts` `wsTransport.onConnectionClose` simplifies.
This is a desktop-side cleanup PR done after mobile rolls out.
Each step is tested in isolation; rollback per step is trivial.
## Risks
### R1: Connection loss while screens are mounted
**Risk.** Today, when a screen unmounts due to network loss, its
client closes and reopens on remount. Under the new design, a
network-loss-during-screen-mounted means the client lives but is in
`reconnecting` state.
**Mitigation.** The existing `RpcClient` already handles this — its
internal reconnect loop runs invisibly. Screens already render based
on `connState === 'connected'`, so they stay in their `connecting`
UI until the loop succeeds. No regression.
### R2: One bad host poisons the singleton
**Risk.** If the client for one host is wedged in `reconnecting` due
to a desktop-side issue, all screens for that host inherit the wedged
state. Under the per-screen design, navigating to a different screen
gave a fresh client with a chance to connect cleanly.
**Mitigation.** "Force reconnect" affordance: a button on the host
detail "Connection issues" UI calls
`provider.forceReconnect(hostId)` — close + reopen the client. Users
who hit a stuck state get a one-tap recovery without uninstalling.
Implemented as part of step 2.
### R3: Idle close timer races
**Risk.** A user navigates from session → home → back to session
within 35 seconds. The 30s idle timer fires between hops and closes
the client; the back-navigation has to wait for a fresh handshake.
**Mitigation.** Cancel the timer at `getClient(hostId)` time, not at
mount time. As long as the consumer holds a reference to the client,
the timer is paused. Standard refcount pattern.
### R4: Memory / state leak on rapid host removal
**Risk.** User removes a host while a screen for it is mounted.
The screen's reference is now dangling.
**Mitigation.** `removeHost(hostId)` triggers an explicit close +
delete from the store. Screens already handle `auth-failed` /
`disconnected` states (the client transitions to one of them on
forced close). Add a navigation-bounce in those states so the screen
returns to the host list.
### R5: Pair-flow socket leaks through provider
**Risk.** If pair-confirm crashes mid-handshake before its explicit
`close()`, the socket leaks.
**Mitigation.** Independent of the provider — same risk exists today.
Add a try/finally + cleanup useEffect in pair-confirm.
### R6: Provider re-initialization on hot reload (dev only)
**Risk.** Metro hot reload re-runs `RpcClientProvider`, possibly
spawning new clients while old ones are still in the store.
**Mitigation.** On provider mount, scan the store for entries whose
clients report `closed` state and prune. Acceptable dev-only friction.
## Test Plan
### Unit / hook tests
- `useHostClient` returns the same client instance across multiple
consumers for the same hostId.
- `removeHost(hostId)` immediately closes the client (assert via
`client.getState()`).
- Idle close timer: zero refcount → 30s wait → client closed.
- Idle close timer cancellation: zero refcount → 15s wait →
consumer subscribes → no close.
### Integration / manual
- Create one host; navigate home → host detail → session → back ×10
rapidly. Single socket on desktop (verify via desktop debug log).
- Background app for 5 min; foreground; verify reconnect uses same
client instance, no leak.
- Remove host while session screen mounted; screen bounces back to
home; client closed.
- Force-reconnect button on host detail; client closes and reopens
cleanly.
- Hot reload during development; no socket leak (verify desktop
active-connection count).
### Regression
- Terminal create/delete works during normal browse (Bug A from
initial reports — should never recur once cap pressure is removed).
- Scrollback hydration unchanged.
- Phone-fit / driver-lock state machine unchanged
(`docs/mobile-presence-lock.md` invariants hold).
## Out of scope
- **Desktop-side connection LRU eviction.** Useful as a defense-in-depth
but not needed once mobile self-limits to one socket per host.
- **Application-level ping/pong.** Worth adding but separate concern;
helps server reap dead sockets faster regardless of how many a single
client opens.
- **iOS share extension or system-wide deep-link integration.** Future
product work.
## Effort estimate
3–4 hours including tests and incremental migration. Each step
independently mergeable.
## References
- `docs/mobile-presence-lock.md` — driver-state-machine that depends
on per-client identity. Single-client-per-host simplifies but
doesn't break this contract.
- `docs/mobile-prefer-renderer-scrollback.md` — scrollback hydration
flow. Subscriptions remain per-screen; transport changes are
transparent.
- `mobile/src/transport/rpc-client.ts` — existing `connect()`
implementation; reconnect loop, E2EE handshake, subscription
multiplexing all preserved as-is.
- `src/main/runtime/rpc/ws-transport.ts` — server-side
`MAX_WS_CONNECTIONS = 32`, `hasOtherConnections` reconciliation
that becomes simpler post-migration.
-76
View File
@@ -1,76 +0,0 @@
# Quick Open Gitignored Files
## Problem
Cmd/Ctrl+P Quick Open does not list arbitrary gitignored files in the active workspace.
- `src/renderer/src/components/QuickOpen.tsx:257` calls `window.api.fs.listFiles(...)`; filtering in the renderer only fuzzy-matches the returned list, so missing files are already absent from the backend result.
- `src/main/ipc/filesystem-list-files.ts:46` uses `buildRgArgsForQuickOpen(...)` for local worktrees, then merges the primary rg pass with `envPass`.
- `src/relay/fs-handler-list-files.ts:37` uses the same shared rg args for SSH worktrees.
- `src/shared/quick-open-filter.ts:252` builds the primary rg pass without `--no-ignore-vcs`, so gitignored files are hidden.
- `src/shared/quick-open-filter.ts:265` builds a second `--no-ignore-vcs` pass, but it is restricted to `.env*` and `**/.env*`.
- `src/shared/quick-open-filter.ts:357` builds the git fallback primary pass with `--exclude-standard`; `src/shared/quick-open-filter.ts:365` only adds `.env*` pathspecs.
## Root Cause
Quick Open intentionally mirrors `rg --files --hidden` with gitignore respect, then adds a special-case pass for gitignored `.env*` files. That policy lives in shared code used by local main-process listing, SSH relay listing, and git fallback listing, so non-env ignored files are excluded consistently in every workspace type.
## Non-goals
- Do not change right-sidebar text search behavior.
- Do not add a new UI preference or mode switch.
- Do not follow symlinks or broaden traversal outside the authorized workspace root.
- Do not surface heavy generated directories already excluded by Quick Open policy, including `node_modules`, `.git`, `.cache`, `.next`, and other `HIDDEN_DIR_BLOCKLIST` entries.
- Do not change fuzzy ranking, selection, file-opening behavior, or shortcut handling.
## Design
1. Replace the `.env*` rg second pass with a general ignored-files pass.
- Keep `--files --hidden --no-ignore-vcs`, hidden-dir blocklist globs, nested-worktree exclude globs, `searchRoot='.'`, and path-separator handling.
- Remove positive `.env*` globs so the second pass is no longer a whitelist.
- Keep the primary pass unchanged.
2. Rename pass naming from `envPass` to `ignoredPass` in shared/main/relay code.
- This is a behavior change, not cosmetic: the second pass now returns all gitignored candidates.
3. Replace git fallback `.env*` pass with ignored-files pass.
- Keep primary as `['--cached', '--others', '--exclude-standard', ...]`.
- Use ignored pass as `['--others', '--ignored', '--exclude-standard', ...]`.
- Keep nested-worktree pathspec exclusion semantics (`--`, `.`, excludes when exclude prefixes exist).
- Do not assume both runtimes have identical error semantics today:
- local main fallback currently resolves on most git failures;
- relay fallback rejects on spawn/signal failures.
Preserve each runtime’s current behavior unless explicitly changing it in a separate doc.
4. Keep post-filters unchanged.
- `shouldIncludeQuickOpenPath` remains final blocklist enforcement.
- `shouldExcludeQuickOpenRelPath` remains nested-worktree correctness backstop.
- Set-based merge still dedupes cross-pass overlap.
5. Do not change renderer request-lifecycle logic in this doc.
- Current `QuickOpen.tsx` effect cleanup cancels the prior request before the next effect body runs.
- This change is backend listing policy only.
6. Update tests.
- `quick-open-filter.test.ts`: assert rg ignored pass has `--no-ignore-vcs` and no `.env*` globs; assert git ignored pass has `--others --ignored --exclude-standard` and no `.env*` pathspec whitelist.
- `filesystem-list-files.test.ts`: update pass-detection helpers that currently key off `'**/.env*'`; cover ignored non-env files; keep local fallback’s resolve-on-failure behavior unchanged.
- Add relay coverage in `src/relay/fs-handler.test.ts` (or new focused relay tests) for ignored-pass args and current reject-on-signal behavior.
## Edge Cases
- Gitignored files inside `node_modules`, `.git`, `.cache`, `.next`, `.npm`, `.npm-global`, `.gvfs`, and other blocklisted dirs must remain hidden.
- Nested linked worktree paths passed as `excludePaths` must remain excluded from both rg and git passes.
- Local/SSH candidate sets should match when both run the same backend path (rg or git) and complete successfully; timeout/error behavior remains intentionally different today.
- Windows and WSL path normalization must remain unchanged; output still passes through `normalizeQuickOpenRgLine`.
- If rg is unavailable, git fallback should include ignored files only when Git can enumerate them; non-git roots keep existing fallback limits.
- Timeout/signal behavior must not regress into partial false-empty results.
- Keep existing timeout asymmetry unless intentionally changed: local rg/git fallback uses 10s timeouts, relay rg uses 25s.
- `--no-ignore-vcs` also includes files ignored by parent/global excludes; blocklists are the guardrail against accidental heavy trees.
## Rollout
1. Update `src/shared/quick-open-filter.ts` types, rg args, git args, and comments.
2. Update local main-process and SSH relay callers/tests for `ignoredPass`.
3. Run focused tests for quick-open filters and list-files (main + relay).
4. Run `pnpm typecheck` and `pnpm lint`.
5. Validate in Electron on local + SSH worktrees with gitignored non-env files and nested linked worktrees.
+21 -20
View File
@@ -1,5 +1,5 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
@@ -9,12 +9,12 @@
</p>
<p align="center">
<a href="../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
</p>
<p align="center">
<strong>El orquestador de IA para desarrolladores 100x.</strong><br/>
Ejecuta Claude Code, Codex u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.<br/>
Ejecuta Claude Code, Codex, Grok u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.<br/>
Disponible para <strong>macOS, Windows y Linux</strong>.
</p>
@@ -23,7 +23,7 @@
</p>
<p align="center">
<img src="assets/file-drag.gif" alt="Captura de Orca" width="800" />
<img src="../assets/file-drag.gif" alt="Captura de Orca" width="800" />
</p>
## Agentes compatibles
@@ -31,8 +31,9 @@
Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
@@ -46,7 +47,7 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
@@ -102,7 +103,7 @@ yay -S stably-orca-git
Controla tus agentes desde el teléfono.
<p align="center">
<picture><source srcset="assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca de escritorio con la app companion móvil" width="720" /></picture>
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca de escritorio con la app companion móvil" width="720" /></picture>
</p>
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
@@ -115,19 +116,19 @@ Controla tus agentes desde el teléfono.
Haz clic en cualquier tarjeta para explorar el flujo de trabajo.
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>Worktrees en paralelo</strong><br/><br/><picture><source srcset="assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="assets/feature-wall/parallel-worktrees.jpg" alt="Orquestación de worktrees en paralelo" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>Terminales divididas</strong><br/><br/><picture><source srcset="assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="assets/feature-wall/terminal-splits.jpg" alt="Terminales divididas de nivel Ghostty" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>Modo diseño</strong><br/><br/><picture><source srcset="assets/feature-wall/design-mode.gif" type="image/gif"><img src="assets/feature-wall/design-mode.jpg" alt="Navegador integrado y modo diseño" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub y Linear nativos</strong><br/><br/><picture><source srcset="assets/feature-wall/github-linear.gif" type="image/gif"><img src="assets/feature-wall/github-linear.jpg" alt="Flujos de GitHub y Linear en Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>Cualquier agente CLI</strong><br/><br/><picture><source srcset="assets/feature-wall/cli-agents.gif" type="image/gif"><img src="assets/feature-wall/cli-agents.jpg" alt="Compatible con cualquier agente CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>Worktrees por SSH</strong><br/><br/><picture><source srcset="assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>Archivos a agentes</strong><br/><br/><picture><source srcset="assets/feature-wall/file-drag.gif" type="image/gif"><img src="assets/feature-wall/file-drag.jpg" alt="Arrastra archivos e imágenes al prompt de un agente" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>Anotar diffs de IA</strong><br/><br/><picture><source srcset="assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs generados por IA" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="assets/feature-wall/orca-cli.gif" type="image/gif"><img src="assets/feature-wall/orca-cli.jpg" alt="Automatiza Orca desde la CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>Búsqueda nativa</strong><br/><br/><picture><source srcset="assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="assets/feature-wall/keyboard-native.jpg" alt="Búsqueda nativa en los flujos de Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>Cambio de cuenta y seguimiento de uso</strong><br/><br/><picture><source srcset="assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="assets/feature-wall/codex-accounts.jpg" alt="Cambio de cuenta y seguimiento de uso" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>Previews ricos del repo</strong><br/><br/><picture><source srcset="assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="assets/feature-wall/markdown-editor.jpg" alt="Previsualización de Markdown, imágenes, PDFs y documentos del repo" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>Divide cualquier cosa</strong><br/><br/><picture><source srcset="assets/feature-wall/split-screen.gif" type="image/gif"><img src="assets/feature-wall/split-screen.jpg" alt="Paneles divididos para agentes, terminales, navegadores y archivos" width="390" /></picture><br/></kbd></a>
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>Worktrees en paralelo</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orquestación de worktrees en paralelo" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>Terminales divididas</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Terminales divididas de nivel Ghostty" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>Modo diseño</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navegador integrado y modo diseño" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub y Linear nativos</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Flujos de GitHub y Linear en Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>Cualquier agente CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="Compatible con cualquier agente CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>Worktrees por SSH</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>Archivos a agentes</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Arrastra archivos e imágenes al prompt de un agente" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>Anotar diffs de IA</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs generados por IA" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Automatiza Orca desde la CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>Búsqueda nativa</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Búsqueda nativa en los flujos de Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>Cambio de cuenta y seguimiento de uso</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="Cambio de cuenta y seguimiento de uso" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>Previews ricos del repo</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Previsualización de Markdown, imágenes, PDFs y documentos del repo" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>Divide cualquier cosa</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="Paneles divididos para agentes, terminales, navegadores y archivos" width="390" /></picture><br/></kbd></a>
</p>
---
+20 -19
View File
@@ -1,5 +1,5 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
@@ -9,7 +9,7 @@
</p>
<p align="center">
<a href="../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
</p>
<p align="center">
@@ -23,7 +23,7 @@
</p>
<p align="center">
<img src="assets/file-drag.gif" alt="Orca Screenshot" width="800" />
<img src="../assets/file-drag.gif" alt="Orca Screenshot" width="800" />
</p>
## 対応するエージェント
@@ -31,8 +31,9 @@
Orca は任意の CLI エージェントに対応しています(*このリストに限定されません*)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
@@ -46,7 +47,7 @@ Orca は任意の CLI エージェントに対応しています(*このリス
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
@@ -102,7 +103,7 @@ yay -S stably-orca-git
スマートフォンからエージェントを操作できます。
<p align="center">
<picture><source srcset="assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca デスクトップとモバイル companion アプリ" width="720" /></picture>
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca デスクトップとモバイル companion アプリ" width="720" /></picture>
</p>
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
@@ -115,19 +116,19 @@ yay -S stably-orca-git
各タイルをクリックすると、そのワークフローを確認できます。
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>並列ワークツリー</strong><br/><br/><picture><source srcset="assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="assets/feature-wall/parallel-worktrees.jpg" alt="並列ワークツリーのオーケストレーション" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>ターミナル分割</strong><br/><br/><picture><source srcset="assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="assets/feature-wall/terminal-splits.jpg" alt="Ghostty クラスのターミナル分割" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>デザインモード</strong><br/><br/><picture><source srcset="assets/feature-wall/design-mode.gif" type="image/gif"><img src="assets/feature-wall/design-mode.jpg" alt="組み込みブラウザとデザインモード" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub と Linear をネイティブに</strong><br/><br/><picture><source srcset="assets/feature-wall/github-linear.gif" type="image/gif"><img src="assets/feature-wall/github-linear.jpg" alt="Orca の GitHub と Linear ワークフロー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意の CLI エージェント</strong><br/><br/><picture><source srcset="assets/feature-wall/cli-agents.gif" type="image/gif"><img src="assets/feature-wall/cli-agents.jpg" alt="任意の CLI エージェントに対応" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH ワークツリー</strong><br/><br/><picture><source srcset="assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="assets/feature-wall/ssh-worktrees.jpg" alt="SSH 経由のリモートワークツリー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>ファイルをエージェントへ</strong><br/><br/><picture><source srcset="assets/feature-wall/file-drag.gif" type="image/gif"><img src="assets/feature-wall/file-drag.jpg" alt="ファイルや画像をエージェントのプロンプトへドラッグ" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 注釈</strong><br/><br/><picture><source srcset="assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="assets/feature-wall/annotate-diff.jpg" alt="AI が生成した Diff への注釈" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="assets/feature-wall/orca-cli.gif" type="image/gif"><img src="assets/feature-wall/orca-cli.jpg" alt="CLI から Orca をスクリプト操作" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>ネイティブ検索</strong><br/><br/><picture><source srcset="assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="assets/feature-wall/keyboard-native.jpg" alt="Orca ワークフロー全体のネイティブ検索" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>アカウント切り替えと使用量トラッキング</strong><br/><br/><picture><source srcset="assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="assets/feature-wall/codex-accounts.jpg" alt="アカウント切り替えと使用量トラッキング" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>リッチなリポジトリプレビュー</strong><br/><br/><picture><source srcset="assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="assets/feature-wall/markdown-editor.jpg" alt="Markdown、画像、PDF、リポジトリ文書のプレビュー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>何でも分割表示</strong><br/><br/><picture><source srcset="assets/feature-wall/split-screen.gif" type="image/gif"><img src="assets/feature-wall/split-screen.jpg" alt="エージェント、ターミナル、ブラウザ、ファイルの分割表示" width="390" /></picture><br/></kbd></a>
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>並列ワークツリー</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="並列ワークツリーのオーケストレーション" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>ターミナル分割</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty クラスのターミナル分割" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>デザインモード</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="組み込みブラウザとデザインモード" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub と Linear をネイティブに</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca の GitHub と Linear ワークフロー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意の CLI エージェント</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="任意の CLI エージェントに対応" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH ワークツリー</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH 経由のリモートワークツリー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>ファイルをエージェントへ</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="ファイルや画像をエージェントのプロンプトへドラッグ" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 注釈</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI が生成した Diff への注釈" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI から Orca をスクリプト操作" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>ネイティブ検索</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Orca ワークフロー全体のネイティブ検索" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>アカウント切り替えと使用量トラッキング</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="アカウント切り替えと使用量トラッキング" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>リッチなリポジトリプレビュー</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown、画像、PDF、リポジトリ文書のプレビュー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>何でも分割表示</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="エージェント、ターミナル、ブラウザ、ファイルの分割表示" width="390" /></picture><br/></kbd></a>
</p>
---
+21 -20
View File
@@ -1,5 +1,5 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
@@ -9,12 +9,12 @@
</p>
<p align="center">
<a href="../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
</p>
<p align="center">
<strong>100x 빌더를 위한 AI 오케스트레이터.</strong><br/>
Claude Code, Codex, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
Claude Code, Codex, Grok, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
<strong>macOS, Windows, Linux</strong>에서 사용할 수 있습니다.
</p>
@@ -23,7 +23,7 @@
</p>
<p align="center">
<img src="assets/file-drag.gif" alt="Orca 스크린샷" width="800" />
<img src="../assets/file-drag.gif" alt="Orca 스크린샷" width="800" />
</p>
## 지원 에이전트
@@ -31,8 +31,9 @@
Orca는 모든 CLI 에이전트를 지원합니다(*아래 목록에만 한정되지 않습니다*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
@@ -46,7 +47,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(*아래 목록에만 한정
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
@@ -102,7 +103,7 @@ yay -S stably-orca-git
휴대폰에서 에이전트를 제어하세요.
<p align="center">
<picture><source srcset="assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 데스크톱과 모바일 companion 앱" width="720" /></picture>
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 데스크톱과 모바일 companion 앱" width="720" /></picture>
</p>
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
@@ -115,19 +116,19 @@ yay -S stably-orca-git
타일을 클릭해 각 워크플로를 살펴보세요.
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>병렬 Worktree</strong><br/><br/><picture><source srcset="assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="assets/feature-wall/parallel-worktrees.jpg" alt="병렬 worktree 오케스트레이션" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>터미널 분할</strong><br/><br/><picture><source srcset="assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="assets/feature-wall/terminal-splits.jpg" alt="Ghostty급 터미널 분할" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>디자인 모드</strong><br/><br/><picture><source srcset="assets/feature-wall/design-mode.gif" type="image/gif"><img src="assets/feature-wall/design-mode.jpg" alt="내장 브라우저와 디자인 모드" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 및 Linear 네이티브</strong><br/><br/><picture><source srcset="assets/feature-wall/github-linear.gif" type="image/gif"><img src="assets/feature-wall/github-linear.jpg" alt="Orca의 GitHub 및 Linear 워크플로" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>모든 CLI 에이전트</strong><br/><br/><picture><source srcset="assets/feature-wall/cli-agents.gif" type="image/gif"><img src="assets/feature-wall/cli-agents.jpg" alt="모든 CLI 에이전트 지원" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="assets/feature-wall/ssh-worktrees.jpg" alt="SSH를 통한 원격 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>에이전트로 파일 드래그</strong><br/><br/><picture><source srcset="assets/feature-wall/file-drag.gif" type="image/gif"><img src="assets/feature-wall/file-drag.jpg" alt="파일과 이미지를 에이전트 프롬프트로 드래그" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 주석</strong><br/><br/><picture><source srcset="assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="assets/feature-wall/annotate-diff.jpg" alt="AI가 생성한 diff에 주석 달기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="assets/feature-wall/orca-cli.gif" type="image/gif"><img src="assets/feature-wall/orca-cli.jpg" alt="CLI에서 Orca 스크립팅" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>네이티브 검색</strong><br/><br/><picture><source srcset="assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="assets/feature-wall/keyboard-native.jpg" alt="Orca 워크플로 전반의 네이티브 검색" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>계정 전환 및 사용량 추적</strong><br/><br/><picture><source srcset="assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="assets/feature-wall/codex-accounts.jpg" alt="계정 전환 및 사용량 추적" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>풍부한 리포지토리 미리보기</strong><br/><br/><picture><source srcset="assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="assets/feature-wall/markdown-editor.jpg" alt="Markdown, 이미지, PDF, 리포지토리 문서 미리보기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>무엇이든 분할</strong><br/><br/><picture><source srcset="assets/feature-wall/split-screen.gif" type="image/gif"><img src="assets/feature-wall/split-screen.jpg" alt="에이전트, 터미널, 브라우저, 파일을 위한 분할 패널" width="390" /></picture><br/></kbd></a>
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>병렬 Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="병렬 worktree 오케스트레이션" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>터미널 분할</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty급 터미널 분할" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>디자인 모드</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="내장 브라우저와 디자인 모드" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 및 Linear 네이티브</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca의 GitHub 및 Linear 워크플로" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>모든 CLI 에이전트</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="모든 CLI 에이전트 지원" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH를 통한 원격 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>에이전트로 파일 드래그</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="파일과 이미지를 에이전트 프롬프트로 드래그" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 주석</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI가 생성한 diff에 주석 달기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI에서 Orca 스크립팅" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>네이티브 검색</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Orca 워크플로 전반의 네이티브 검색" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>계정 전환 및 사용량 추적</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="계정 전환 및 사용량 추적" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>풍부한 리포지토리 미리보기</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown, 이미지, PDF, 리포지토리 문서 미리보기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>무엇이든 분할</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="에이전트, 터미널, 브라우저, 파일을 위한 분할 패널" width="390" /></picture><br/></kbd></a>
</p>
---
@@ -1,5 +1,5 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
@@ -9,7 +9,7 @@
</p>
<p align="center">
<a href="../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
</p>
<p align="center">
@@ -23,7 +23,7 @@
</p>
<p align="center">
<img src="assets/file-drag.gif" alt="Orca Screenshot" width="800" />
<img src="../assets/file-drag.gif" alt="Orca Screenshot" width="800" />
</p>
## 支持的智能体
@@ -31,8 +31,9 @@
Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
@@ -46,7 +47,7 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
@@ -102,7 +103,7 @@ yay -S stably-orca-git
用手机控制你的智能体。
<p align="center">
<picture><source srcset="assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 桌面端与移动 companion 应用" width="720" /></picture>
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 桌面端与移动 companion 应用" width="720" /></picture>
</p>
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
@@ -115,19 +116,19 @@ yay -S stably-orca-git
点击任意卡片了解对应工作流。
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>并行 Worktree</strong><br/><br/><picture><source srcset="assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="assets/feature-wall/parallel-worktrees.jpg" alt="并行 worktree 编排" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>终端分屏</strong><br/><br/><picture><source srcset="assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="assets/feature-wall/terminal-splits.jpg" alt="Ghostty 级终端分屏" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>设计模式</strong><br/><br/><picture><source srcset="assets/feature-wall/design-mode.gif" type="image/gif"><img src="assets/feature-wall/design-mode.jpg" alt="内置浏览器与设计模式" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 与 Linear 原生集成</strong><br/><br/><picture><source srcset="assets/feature-wall/github-linear.gif" type="image/gif"><img src="assets/feature-wall/github-linear.jpg" alt="Orca 中的 GitHub 与 Linear 工作流" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意 CLI 智能体</strong><br/><br/><picture><source srcset="assets/feature-wall/cli-agents.gif" type="image/gif"><img src="assets/feature-wall/cli-agents.jpg" alt="支持任意 CLI 智能体" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="assets/feature-wall/ssh-worktrees.jpg" alt="通过 SSH 使用远程 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>拖文件给智能体</strong><br/><br/><picture><source srcset="assets/feature-wall/file-drag.gif" type="image/gif"><img src="assets/feature-wall/file-drag.jpg" alt="将文件和图片拖入智能体提示" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>标注 AI Diff</strong><br/><br/><picture><source srcset="assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="assets/feature-wall/annotate-diff.jpg" alt="标注 AI 生成的 diff" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="assets/feature-wall/orca-cli.gif" type="image/gif"><img src="assets/feature-wall/orca-cli.jpg" alt="从 CLI 脚本化 Orca" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>原生搜索</strong><br/><br/><picture><source srcset="assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="assets/feature-wall/keyboard-native.jpg" alt="贯穿 Orca 工作流的原生搜索" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>账号切换与用量追踪</strong><br/><br/><picture><source srcset="assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="assets/feature-wall/codex-accounts.jpg" alt="账号切换与用量追踪" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>丰富仓库预览</strong><br/><br/><picture><source srcset="assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="assets/feature-wall/markdown-editor.jpg" alt="Markdown、图片、PDF 和仓库文档预览" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>任意分屏</strong><br/><br/><picture><source srcset="assets/feature-wall/split-screen.gif" type="image/gif"><img src="assets/feature-wall/split-screen.jpg" alt="为智能体、终端、浏览器和文件分屏" width="390" /></picture><br/></kbd></a>
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>并行 Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="并行 worktree 编排" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>终端分屏</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty 级终端分屏" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>设计模式</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="内置浏览器与设计模式" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 与 Linear 原生集成</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca 中的 GitHub 与 Linear 工作流" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意 CLI 智能体</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="支持任意 CLI 智能体" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="通过 SSH 使用远程 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>拖文件给智能体</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="将文件和图片拖入智能体提示" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>标注 AI Diff</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="标注 AI 生成的 diff" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="从 CLI 脚本化 Orca" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>原生搜索</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="贯穿 Orca 工作流的原生搜索" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>账号切换与用量追踪</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="账号切换与用量追踪" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>丰富仓库预览</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown、图片、PDF 和仓库文档预览" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>任意分屏</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="为智能体、终端、浏览器和文件分屏" width="390" /></picture><br/></kbd></a>
</p>
---
+16
View File
@@ -0,0 +1,16 @@
# Durable Docs
Keep this folder for versioned reference docs that are meant to survive past a
single design or implementation pass.
## What Goes Here
- Stable reference material.
- Public-facing docs that are not part of the root README.
- Docs that other checked-in files link to.
## What Stays Out
Ephemeral design notes, implementation sketches, and planning docs should stay as
local Markdown files under `docs/`. They are ignored by default so they do not get
checked in accidentally.
-165
View File
@@ -1,165 +0,0 @@
# Auto-refresh on entering the Checks tab
## Problem
The Checks panel fetches on visibility, but those fetches are cache-respecting:
- `CACHE_TTL = 300_000` for PRs and comments.
- `CHECKS_CACHE_TTL = 60_000` for checks.
- `ChecksPanel.tsx` fetches PRs on repo/branch changes without `force`.
- Checks polling and the comments effect also call the store without `force`.
That means entering the Checks tab can render stale PR metadata, a cached `null`
"no PR" result, stale comments, or stale checks. The checks path is partially
protected by a shorter renderer TTL, but the main process also uses `gh api
--cache 60s` for the REST checks endpoint unless `noCache` is set. Manual refresh
already bypasses these renderer and `gh api` caches for the requests it starts.
## Goal
When the user enters the Checks tab, run one freshness check for the active
worktree and force-refresh only when the relevant cache timestamps are older
than a small grace window. "Entering" means:
- opening the right sidebar while the Checks tab is selected;
- switching from another right-sidebar tab to Checks;
- switching active worktree/repo/branch while Checks is already visible.
The refresh must cover PR discovery too, including cached `null`, so a PR opened
outside Orca can appear without waiting up to 5 minutes.
## Non-goals
- Changing the existing polling cadence while the panel stays visible.
- Refreshing on window focus, network reconnect, or arbitrary background events.
- Changing the manual refresh button.
- Prefetching checks for non-active worktrees.
- Adding cross-renderer or cross-window request coordination. Orca currently has
one live main window; renderer in-flight maps are not a multi-window primitive.
## Design
1. **Entry trigger.** Add an effect in `ChecksPanel.tsx` keyed by:
```ts
const entryKey = isPanelVisible && repo && !isFolder && branch
? `${activeWorktreeId ?? ''}::${repo.path}::${branch}`
: ''
```
Track the last processed visible `entryKey` in a ref. When `entryKey` is empty,
reset the ref to `''`. When it becomes non-empty and differs from the ref, this
is an entry. This is required; a `prevKey !== currentKey` check that does not
reset on hide would miss closing and reopening the same PR.
2. **Grace window.** Define `ENTRY_REFRESH_GRACE_MS = 15_000` in
`ChecksPanel.tsx`. Select only timestamps, not whole cache records:
```ts
const prFetchedAt = useAppStore(
(s) => (prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined)
)
const checksFetchedAt = useAppStore(
(s) => prNumber
? s.checksCache[`${repo?.path ?? ''}::pr-checks::${prNumber}`]?.fetchedAt
: undefined
)
const commentsFetchedAt = useAppStore(
(s) => prNumber
? s.commentsCache[`${repo?.path ?? ''}::pr-comments::${prNumber}`]?.fetchedAt
: undefined
)
```
Missing PR cache is stale. A cached PR value of `null` is still a PR cache
entry and should be refreshed on entry once outside the grace window. When a
PR number is known, missing checks/comments timestamps are stale. When no PR is
known, checks/comments are not relevant yet.
Run `handleRefresh()` if the oldest relevant timestamp is missing or older
than `Date.now() - ENTRY_REFRESH_GRACE_MS`; otherwise skip.
3. **Reuse `handleRefresh`, but pass the refreshed head SHA through.** The
manual refresh flow is the right shape: force `fetchPRForBranch`, then if a
PR is returned, force checks and comments for the returned PR. Extend
`fetchChecks()` to accept a `headShaOverride` (or call `fetchPRChecks`
directly from `handleRefresh`) so the checks request uses
`refreshedPR.headSha`, not the stale `pr?.headSha` captured before the PR
refresh completed. This handles PR number changes, cached `null`, and
external force-pushes correctly.
4. **Reset polling attention state before refresh.** When the entry refresh runs,
set `pollIntervalRef.current = 30_000` and `prevChecksRef.current = ''` before
calling `handleRefresh()`. `fetchChecks()` will then write the new signature
from the forced result.
5. **Do not overstate in-flight behavior.** Current store behavior is:
- `fetchPRForBranch({ force: true })` bypasses a non-forced in-flight PR
request and uses a generation guard so the older result cannot overwrite the
newer cache entry.
- `fetchPRChecks({ force: true })` and `fetchPRComments({ force: true })` do
**not** bypass any in-flight request for the same key. If a non-forced poll
is already in flight, entry refresh will join it and may not pass
`noCache: true` to the main process.
Accept that tradeoff for this feature. It avoids duplicate `gh` calls during a
visible polling race. If strict "entry always bypasses gh cache" semantics are
required later, change checks/comments in-flight maps to track `{ promise,
force, generation }` like PRs.
## API cost and feasibility
This is not free. A cold entry refresh can start:
- one PR lookup (`gh:prForBranch`);
- one checks request (`gh:prChecks`);
- one comments request (`gh:prComments`).
`gh:prChecks` usually calls `gh api repos/{owner}/{repo}/commits/{sha}/check-runs`
and uses `--cache 60s` unless forced; if that fails it falls back to `gh pr
checks`, which does not use the `--cache` flag.
`gh:prComments` is heavier than "one GitHub API call": it runs issue comments
REST, review threads GraphQL, and reviews REST in parallel. `noCache` only
removes `--cache 60s` from the REST `gh api` calls; the GraphQL call is always
made.
The 15 s grace window is therefore required, not cosmetic.
## Edge cases
- **Cached no-PR result.** Do not skip just because `prNumber` is null. Refresh
the PR cache entry on tab entry when its timestamp is outside the grace window.
- **First Checks entry after app start.** Only `prCache` and `issueCache` are
persisted. `checksCache` and `commentsCache` start empty, so a known PR should
force checks/comments on entry.
- **Worktree switch while Checks is visible.** Include `activeWorktreeId` in the
entry key so same repo/branch switches still count as a new entry. The existing
render-time local reset handles stale title/loading state.
- **Rapid tab toggles.** Hiding the panel resets the processed entry key; showing
it again re-evaluates timestamps. The grace window suppresses duplicate calls.
- **Concurrent polling tick.** Checks/comments may reuse the in-flight poll
request instead of forcing a new `noCache` request. This is intentional for now.
- **Conflicting PR refresh.** The existing `mergeable === 'CONFLICTING'` effect
can still force a PR refresh. Entry refresh may also run, but PR generation
guards prevent older PR responses from overwriting newer cache entries.
- **PR head changed externally.** `handleRefresh()` must use the refreshed PR
returned by `fetchPRForBranch` before fetching checks, so checks are requested
with the current `headSha`.
- **Component unmount mid-fetch.** The comments auto-fetch guards stale
responses; the checks fetch path and `handleRefresh()` do not cancel local
setters. This feature should keep that behavior unless tests expose a warning
or stale-state regression.
- **SSH / remote repo.** No path manipulation or platform-specific shortcut code
is needed. The same IPC-backed `gh` path is used.
## Rollout
1. Add timestamp selectors, `ENTRY_REFRESH_GRACE_MS`, and the entry effect in
`ChecksPanel.tsx`.
2. Keep `handleRefresh()` as the single refresh implementation.
3. Add `ChecksPanel.test.tsx` if absent. Cover stale PR/null cache refreshes,
fresh-within-grace skips, known PR with missing checks/comments refreshes,
hidden panel no-op, and hide/show same PR re-evaluation.
4. Run `pnpm typecheck && pnpm lint`.
-387
View File
@@ -1,387 +0,0 @@
# Resource Usage popover: merge Resources + Sessions into one view
## Problem
The status-bar Resource Usage popover currently has two tabs:
- **Resources** — per-worktree CPU/memory grouped by repo, sourced from
`MemorySnapshot.worktrees`. Local PTYs only (SSH excluded by design at
`src/main/ipc/pty.ts:832` because remote process trees are not visible
to the local `ps`/`wmic` sweep).
- **Sessions** — flat list of every PTY the daemon tracks (local **and**
SSH), sourced from `pty.listSessions()`. Adds tab-binding info, click-
to-navigate, kill-X per row, and "Kill N orphans".
The data sources only partially overlap: Sessions can show worktrees
(e.g. `orca/Porpoise`, `orca/Stingray`) that never appear in Resources
because they're SSH-backed. Users see this as a confusing inconsistency
between two adjacent tabs in the same popover.
The user wants one unified view that:
1. Lists every PTY the daemon knows about (the union, not the
intersection).
2. Shows CPU/memory **when available** (i.e. for local PTYs) and `—` for
remote ones, rather than silently dropping remote sessions.
3. Keeps tab-binding affordances (click row → navigate, kill X with
confirm dialog) on every row.
4. Surfaces orphans (PTYs not bound to any tab) with the "Kill N
orphans" bulk action.
## Direction (already decided by user)
> Merge into one tab, drop the switcher entirely.
Out of scope:
- Adding new IPC. The merge uses **only** the data already available:
`MemorySnapshot` and `pty.listSessions()`.
- Changing what the memory collector tracks. SSH PTYs remain unsampled.
The unified view simply shows them with empty metric cells instead of
hiding them.
- Adding new actions (e.g. "stop all in repo", per-repo restart, etc.).
The exact action set today (kill one with confirm, kill orphans, sleep
worktree, restart daemon, kill-all-sessions) is preserved.
## Data model
### Inputs
- `snapshot: MemorySnapshot | null` — local-only CPU/Mem data, grouped
by `worktreeId`. May be `null` while the daemon is unreachable.
- `sessions: DaemonSession[]` — every PTY id the daemon knows, with
`cwd`, `title`, and a sticky `isAlive`-equivalent.
- `tabsByWorktree`, `ptyIdsByTabId`, `runtimePaneTitlesByTabId`,
`workspaceSessionReady` — store-side context used to (a) compute
bound/orphan and (b) resolve human labels.
### Output: a renderer-local view model
The merge produces a renderer-local view-model — **not** a widening of
the shared `WorktreeMemory` shape. `src/shared/types.ts` and the
collector are untouched. New types live next to the merge helper:
```ts
// src/renderer/src/components/status-bar/unifiedRow.ts
type Metric = number | null // null === "no local sample" (e.g. SSH)
type UnifiedSessionRow = {
sessionId: string
label: string
bound: boolean
tabId: string | null
cpu: Metric
memory: Metric
hasLocalSamples: boolean
}
type UnifiedWorktreeRow = {
worktreeId: string
worktreeName: string
repoId: string
repoName: string
cpu: Metric
memory: Metric
history: number[] // empty when hasLocalSamples is false
hasLocalSamples: boolean
sessions: UnifiedSessionRow[]
}
type UnifiedRepoGroup = {
repoId: string
repoName: string
cpu: Metric // null if every child has hasLocalSamples false
memory: Metric
hasRemoteChildren: boolean // drives the "· remote" badge on the row
worktrees: UnifiedWorktreeRow[]
}
```
A small adapter inside `mergeSnapshotAndSessions` converts each
`WorktreeMemory` from the shared type into a `UnifiedWorktreeRow` with
numeric metrics and `hasLocalSamples: true`. Synthetic remote rows are
constructed directly with `null` metrics. The existing `MetricPair` and
`Sparkline` callsites in the popover get a thin wrapper that renders
`—` when its input is `null`; nothing in `src/shared/` or `src/main/`
changes.
### Merge algorithm
The merge is renderer-only and pure. Inputs in priority order:
- **Snapshot first.** `snapshot.worktrees` is the authoritative source
for any local PTY's CPU/Mem and for the worktree's identity (it
already carries `worktreeId`, `worktreeName`, `repoId`, `repoName`,
and a `sessions[]` array with per-session metrics).
- **Sessions augments.** `sessions: DaemonSession[]` is the union over
the snapshot — it adds (a) SSH PTYs that the local memory collector
cannot see and (b) any local PTY that registered too late to make
this snapshot's sweep.
#### Join key (snapshot session ↔ daemon session)
Both layers identify a session by the **same string**: the PTY id.
- `SessionMemory.sessionId` (collector) is `pty.sessionId ?? pty.ptyId`
from `pty-registry.ts`. In-process local PTYs spawn with
`args.sessionId === undefined` (`pty.ts:684`), so the registry stores
`sessionId: null` and the fallback `pty.ptyId` is used — which equals
`result.id` from the local provider's spawn, which is also what
`LocalPtyProvider.listProcesses()` returns as `id`
(`local-pty-provider.ts:518-522`).
- For daemon-hosted spawns, `mintPtySessionId(worktreeId)` produces the
`${worktreeId}@@${shortUuid}` form
(`src/main/daemon/pty-session-id.ts`); both the registry and
`pty.listSessions()` carry that exact string as the id.
Therefore the dedup key is simply `session.id` on both sides. The
merge MUST build `Set<string>` of all `SessionMemory.sessionId`s from
the snapshot and skip any `DaemonSession` whose `id` is already in the
set (so a local session never appears twice — once with metrics, once
as a `—` placeholder).
#### Worktree resolution for `DaemonSession`s
When a `DaemonSession` is **not** already accounted for by the
snapshot, we need to bucket it under a worktree group. Try in order:
1. **Tab-store walk** (existing logic in `SessionsTabPanel`): look up
`ptyIdsByTabId` → `tabId` → `tabsByWorktree` → `worktreeId`. This
resolves any session bound to a live tab in *this* renderer.
2. **Session-id parse**: if the id contains `@@`, take
`id.slice(0, id.lastIndexOf('@@'))` as a candidate `worktreeId`.
This is the convention enforced by `mintPtySessionId` (see
`pty-session-id.ts:5-7` and the symmetric parser in
`daemon-pty-adapter.ts:328-331`). It correctly recovers the
worktreeId for SSH sessions that haven't been bound to a tab in
this Orca instance — which is the user's primary scenario
(`orca/Stingray`, `orca/Sawfish`, etc.).
3. **Unattributed**: if neither resolves, bucket under a synthetic
`unattributed` repo group at the bottom of the list.
The `repoId` for each new worktree group is recovered the same way the
collector does it (`collector.ts:resolveWorktreeNames`): split the
worktreeId on the first `::`. Resolved repo display name comes from
the renderer-side store (`store.repos[repoId]?.displayName`) when
available; otherwise the bare `repoId` is used.
#### Step-by-step
1. Initialize `repos: Map<repoId, UnifiedRepoGroup>` empty.
2. Insert all `snapshot.worktrees` (if any) into the map, grouped by
their `repoId`. Each worktree carries `hasLocalSamples: true` and
numeric metrics; its sessions inherit numeric metrics.
3. Build `seenSessionIds = new Set<string>(...all session ids in
snapshot.worktrees[].sessions)`.
4. For each `DaemonSession` in `sessions: DaemonSession[]`:
- If `seenSessionIds.has(session.id)`, skip (already merged from
snapshot).
- Resolve `worktreeId` per the three-step rule above.
- If the resolved `worktreeId` is already a worktree group in the
map, append the session to its `sessions[]` with `cpu: null`,
`memory: null`, `hasLocalSamples: false`. (Reachable for SSH
sessions whose worktree happens to match a locally-active
worktree id — rare but possible.)
- Otherwise, create a new worktree group with `cpu: null`,
`memory: null`, `history: []`, `hasLocalSamples: false`, and
append the session.
5. Compute per-repo aggregates: sum `cpu`/`memory` from worktrees with
`hasLocalSamples === true`. If the repo has *any* worktree with
`hasLocalSamples === false`, set `repoHasRemoteChildren: true` (used
by the UI to render a `· remote` badge on the repo header so the
user knows the displayed totals exclude remote worktrees).
6. The Orca app section (Main / Renderer / Other) renders unchanged
below the list.
The whole pass is O(W + S) where W = snapshot worktrees and S =
sessions. No nested scans.
### Why this is safe with current data
- Every existing local session continues to render with the same
numbers. The merge **adds** rows; it does not change values.
- The snapshot rebuilds every poll (10s), so SSH sessions transitioning
to local (or vice versa) flip representation on the next tick. No
reconciliation logic needed.
- Orphan detection still works: a session is "bound" iff its
`sessionId` appears in `boundPtyIds`. Sessions in repos we couldn't
resolve still get the X-kill affordance.
## UI
### Trigger (status-bar badge) — unchanged
Mac/Linux/Windows safe (no platform-specific glyphs introduced).
### Popover — single panel, fixed height
```
┌─────────────────────────────────────────────────────────────┐
│ Resource Usage ⟳ 🗑 ✕ │ ← header
├─────────────────────────────────────────────────────────────┤
│ 1.9% · 955.2 MB · 3% of system RAM │ ← summary
├─────────────────────────────────────────────────────────────┤
│ Name CPU Memory · │ ← sort row
├─────────────────────────────────────────────────────────────┤
│ ▾ Triton 0.0% 125.5 MB │
│ Terminal 1 0.0% 63.2 MB ✕│
│ Terminal 1 0.0% 1.3 MB ✕│
│ ▾ Stingray ⓘ remote — — │
│ orca/Stingray — — ✕│
│ ▾ Sawfish ⓘ remote — — │
│ orca/Sawfish — — ✕│
│ ───────────────────────── │
│ ▸ Orca app 0.5% 829.7 MB │
└─────────────────────────────────────────────────────────────┘
↑ fixed 420px body, owns its own scroll
↓ orphan bulk-kill pill renders here when N>0
```
The header keeps the icon-only **Restart daemon** (RotateCw) and
**Kill all sessions** (Trash2) buttons in the top-right.
### Interaction states (4 paths)
| Scenario | Resource cells | Kill X | Click row |
|-----------------------------|----------------|-------------|--------------|
| Local PTY, bound | numeric | hover only | navigates |
| Local PTY, orphan | numeric | always | no-op |
| Remote PTY, bound | `—` | hover only | navigates |
| Remote PTY, orphan | `—` | always | no-op |
`—` is rendered as `text-muted-foreground/50`. The worktree row carries
a small `· remote` badge when `hasLocalSamples === false`; the repo row
carries the same badge when `hasRemoteChildren === true` (so users
know the displayed repo totals exclude remote worktrees).
**`bound` semantics are unchanged from today:** a session is bound iff
`boundPtyIds.has(session.id)` evaluated against this renderer's
`ptyIdsByTabId`. SSH sessions bound to tabs in *another* Orca process
look identical to local orphans here — same as before the merge.
### Empty / loading / error states
- Daemon unreachable → existing banner remains, body shows nothing.
- Sessions error AND no snapshot AND zero sessions → "Resource data
unavailable. Restart daemon."
- Snapshot present but zero worktrees AND zero sessions → "Nothing
running right now."
- Loading (no snapshot, no error) → "Loading…".
### Sort
Three buttons in the sort row: **Name**, **CPU**, **Memory**. CPU/Mem
sort puts `null` metrics last (stable). Name uses `localeCompare`.
Selection is repo-group aware: sorting by CPU sorts repos by aggregate
CPU and worktrees within a repo by their CPU.
### Confirm dialogs (unchanged)
- Per-row kill X: confirm Dialog with copy "Kill this session? Force-
quits `<id>`. Any unsaved work in that pane is lost. This can't be
undone."
- Header Trash2: existing "Kill all sessions" Dialog from
`useDaemonActions`.
- Header RotateCw: existing "Restart daemon" Dialog from
`useDaemonActions`.
- Worktree-level Sleep / Delete: unchanged.
## Implementation plan
1. **New helper** `mergeSnapshotAndSessions.ts` (renderer-side, pure
function) that takes `(snapshot, sessions, storeContext)` and
returns the unified `RepoGroup[]` shape above. Unit-testable.
2. **Refactor `ResourceUsageStatusSegment.tsx`**:
- Remove the tab switcher (drop `activeTab` state, both
`'resources'` / `'sessions'` branches, the inline pill component,
and the `Trash2` import for the old switcher).
- Replace the body with a single render path that consumes the
unified `RepoGroup[]` from the helper.
- Keep the existing `WorktreeSection`/`AppSection` patterns — extend
them to render `null` metrics as `—` and to render the kill-X on
each session row (with bound-vs-orphan visibility rule).
- Promote `killConfirm` state and the confirm Dialog from
`SessionsTabPanel` to the segment level since there's only one
panel now.
- Keep `onSessionsChanged` plumbing — call it from kill / kill-
orphans handlers to trigger an immediate `refreshSessions()`.
- Delete the now-unused `SessionsTabPanel` component.
3. **Wire metric formatting** to render `—` when metric is `null`. Use
the `null`-vs-`0` distinction already present in the data model;
don't conflate them.
4. **Migration**: status-bar item id stays `'resource-usage'`. The
`migrateStatusBarItems` helper is unchanged. No persistence
migration needed for this UI-only change.
## Testing
- Unit: `mergeSnapshotAndSessions` with synthetic inputs covering:
- All 4 interaction-state paths (local-bound, local-orphan,
remote-bound, remote-orphan).
- **Dedup**: a local session that appears in both the snapshot and
the daemon list is rendered exactly once with numeric metrics.
- **`@@` parse**: an SSH session id `repoX::/path/wtA@@abcd` with no
matching tab resolves to repoId `repoX`, worktreeId
`repoX::/path/wtA`.
- **Tab walk wins over `@@` parse**: a session bound to a tab whose
worktreeId differs from the `@@` prefix uses the tab's worktreeId
(defensive against id-format drift).
- **Repo aggregate excludes remote children**: a repo with one
local (125 MB) and one remote (`null`) worktree reports
`cpu/memory = 125 MB` and `hasRemoteChildren = true`.
- Edge cases: snapshot null, sessions empty, both empty, sessionId
without `@@` and no tab match → falls into Unattributed.
- Manual:
- Open with mixed local + SSH worktrees → both render, SSH rows show
`—` and a `· remote` badge on their worktree and repo headers.
- Kill a remote session via the X → confirm dialog → optimistic
removal so row disappears immediately (does not wait 10s).
- Kill all (header trash) → orphans gone, bound sessions remain.
- Resize window / scroll the body → fixed 420px holds, no reflow.
- Accessibility: tab through rows → kill X focusable; after kill
confirm, focus lands back on the popover content root, not on
`<body>`. Dialog is keyboard-dismissable except while in-flight.
## Risks & mitigations
- **Risk**: SSH worktrees the user never opened locally still appear in
Resources (potentially noisy if the daemon retains many idle SSH
sessions). **Mitigation**: bucketing under a `· remote` badge keeps
them visually distinct from local-active groups; sort-by-memory pushes
them to the bottom. If noise becomes a real complaint, a future PR
can add a "hide remote" toggle.
- **Risk**: Some sessions can't be resolved to any worktreeId (e.g.
daemon-internal sessions whose ids don't follow the `@@` convention,
or sessions whose worktreeId resolves to a repo store doesn't know
about). **Mitigation**: synthetic `Unattributed` repo group at the
bottom, which already exists in spirit as `ORPHAN_WORKTREE_ID` in the
memory collector.
- **Risk**: Kill-X on a remote session fires `pty.kill(id)`, which the
IPC layer routes through `ptyOwnership` to the right SSH provider.
Behavior is identical to the old Sessions tab — no new failure mode.
- **Risk: poll-rate skew between snapshot (2s) and sessions (10s).**
After killing an SSH session, the daemon-side `sessions` list can
retain the dead row for up to 10s while the snapshot has already
moved on; conversely a freshly-spawned local PTY shows up in
`sessions` first and is rendered as a remote placeholder for up to
2s before the next snapshot promotes it.
**Mitigation**: per-row kill optimistically removes the session id
from the renderer's local `sessions` state immediately after the IPC
resolves (in addition to calling `onSessionsChanged()`), so the
killed row never lingers on screen. The new-session-flash is
bounded by `POLL_MS` and acceptable.
- **Risk: focus dropped to `<body>` after kill confirm.** When the
killed session disappears on the next refresh, the per-row X that
had focus is unmounted. With the confirm Dialog now mounted at the
segment level, this regression is more visible.
**Mitigation**: after `runKillConfirmed` resolves, focus the
popover's content root via a stable ref so keyboard users land back
in the list, not on `<body>`.
## Non-goals
- No new IPC.
- No collector changes.
- No new persisted UI prefs (the per-tab visibility split is gone, not
replaced with a per-section toggle).
- No remote memory sampling — that's a separate, much bigger project.
-54
View File
@@ -1,54 +0,0 @@
# Right Sidebar Header Drag Region
## Problem
Right sidebar header blank space does not drag the window.
- In top mode, the header row (`right-sidebar/index.tsx`, current line ~313) has no `-webkit-app-region: drag`.
- In side mode, the title row (`right-sidebar/index.tsx`, current line ~332) has no `-webkit-app-region: drag`.
- The app already uses draggable titlebar surfaces elsewhere (`.titlebar`, `.titlebar-left` in `main.css`), so this is an inconsistency.
## Current Behavior (Verified)
- `ActivityBarButton` renders plain `<button>` elements with no `no-drag` class.
- Close button is already safe because `.sidebar-toggle` sets `-webkit-app-region: no-drag`.
- Top-mode activity bar context menu is currently bound to the full header row via `ContextMenuTrigger`.
- Side-mode activity bar context menu is bound to the side icon strip, not the side-mode title row.
## Constraints That Matter
- `-webkit-app-region: drag` is required for Electron window drag hit-testing.
- Interactive descendants inside a drag region must be `-webkit-app-region: no-drag`, or click behavior becomes unreliable.
- Do not depend on blank-space right-click inside a drag region for opening menus; attach context-menu trigger to a guaranteed `no-drag` target.
- No visual/layout changes; keep existing Windows inset behavior (`right-sidebar-header-inset`, `right-sidebar-header-side-inset`, `side-activity-bar-windows-inset`).
## Design
1. Add utility classes in `src/renderer/src/assets/main.css`:
- `.right-sidebar-header-drag { -webkit-app-region: drag; user-select: none; }`
- `.right-sidebar-header-no-drag { -webkit-app-region: no-drag; }`
2. Apply `.right-sidebar-header-drag` to both header wrappers:
- top activity-bar row
- side-mode title row
3. Apply `.right-sidebar-header-no-drag` to all interactive header descendants:
- `ActivityBarButton` root button
- top-mode icon-row wrapper that owns context-menu trigger
- close button is already covered by `.sidebar-toggle` (keep as-is)
4. Move top-mode `ContextMenuTrigger` from the whole header row to a `no-drag` target (icon-row wrapper). Do not bind it to the drag surface.
5. Keep side-mode context-menu wiring on the side icon strip unchanged.
## Validation
- Top mode: blank area drags window.
- Top mode: tab icons click reliably; close button click remains reliable.
- Top mode: context menu opens from icon row (not blank drag area).
- Side mode: title/blank area drags; close button remains clickable.
- Left-edge resize handle remains usable (`z-10`, absolute overlay).
- Windows: overlay controls do not cover close button/icons after insets.
- Sidebar closed (`width: 0`, `overflow-hidden`): no leaked hit targets.
## Concurrency / Consistency
- Multi-window safe: renderer-local CSS/DOM only, no shared mutable state.
- Store updates (`activityBarPosition`, `rightSidebarTab`, `rightSidebarOpen`) are safe: drag/no-drag is static class wiring on re-rendered elements.
- No IPC or async cross-process dependency, so no invalidation races introduced.
-87
View File
@@ -1,87 +0,0 @@
# Sidebar Repo Filter Redesign
## Problem
The current sidebar filter UI does not scale beyond a small repo count. Rendering every repo as a checkbox row in a menu creates poor scanability, high click cost, and clipping/scroll friction.
Constraints from current code:
- Filter semantics are store-driven (`showActiveOnly`, `hideDefaultBranchWorkspace`, `filterRepoIds`) and consumed by `computeVisibleWorktreeIds`.
- `searchRepos()` already provides ranking (display name first, path fallback).
- The trigger badge is the primary “filters active” affordance and must stay.
## Goals
1. Make repo filtering usable with large repo sets.
2. Keep toggle filters one click.
3. Add fast bulk actions (`All`, `None`, `Clear all`).
4. Preserve existing filter semantics and badge behavior.
## Non-goals
- No semantic changes to filtering logic in `visible-worktrees.ts`.
- No sticky-all persistence model like `RepoMultiCombobox`.
- No store schema migration.
## Correctness Notes (from code audit)
- `searchRepos()` is not debounced; it is synchronous `useMemo` filtering.
- Popover wheel rescue in `popover.tsx` only runs when the wheel handler is attached to a `PopoverContent` element that has class `popover-scroll-content`. Putting that class on an inner div does not activate the rescue path.
- `command.tsx` already includes a wheel fallback on `CommandList` for scroll-locked Radix Dialog parents.
- Sidebar filters are persisted via `window.api.ui.set` (debounced in `App.tsx`) and restored from persisted UI on launch.
## Proposed UI
Use `Popover` and keep trigger button/badge unchanged.
Popover sections:
1. Header: `Filters` + `Clear all` (shown only when any filter is active).
2. Toggle rows: `Active only`, `Hide default branch`.
3. Repo section (only when `repos.length > 1`):
- Label + selected count.
- `All` and `None` actions.
- Search box (`Search repos...`).
- Scrollable repo list with checkmark, repo dot/name, and SSH badge when `connectionId` exists.
- Secondary path line for disambiguation when names collide.
4. Bottom action: `Add project` pinned below repo list.
## Implementation Decision: cmdk vs plain input
Use `Command` primitives (as in `repo-multi-combobox.tsx`) with `shouldFilter={false}` and feed pre-ranked `searchRepos()` results.
Why:
- Better keyboard behavior out of the box (arrow navigation, enter selection).
- Existing `CommandList` wheel fallback handles scroll-lock contexts that currently break plain inner-scroll regions.
- Consistent behavior with an existing repo selector pattern in the app.
## Edge Cases / Invalidation
- Repo removed while popover is open: selection count and rows derive from live `repos`; stale ids must not count toward badge or selected count.
- Repo added while open: `All` should include the new repo immediately (derive from current `repos` each render).
- External repo mutations (sync/import) during search: filtered list updates from live `repos`; empty-state must not hide `Add project`.
- SSH repos: show SSH indicator to avoid ambiguity with local repos of same display name.
- 0 or 1 repo: hide repo filter section. Keep `Add project` visible outside the repo-count gate so users can still recover from low-repo states.
- Multi-window: filter state is window-local; this redesign does not introduce cross-window synchronization.
## Accessibility / Focus
- Do not use `role="menuitemcheckbox"` unless the container is a true menu. Use semantic buttons or cmdk items with explicit `aria-selected`/checked indicators.
- Autofocus search on open.
- Esc closes via Radix Popover default.
- Ensure focus returns to trigger on close.
## Rollout
1. Replace sidebar filter content with Popover + Command-based repo list.
2. Keep `searchRepos()` as the only ranking source.
3. Keep current filter setters and badge derivation semantics.
4. Ensure scroll behavior works inside dialog parents by using `CommandList` (or by moving wheel handling to the actual scroll container).
5. Validate with `pnpm typecheck` and `pnpm lint`.
## Test Scope
- Unit coverage remains in `visible-worktrees` for filter semantics.
- Add/adjust component tests for:
- keyboard navigation and selection,
- stale repo id handling,
- `All`/`None` behavior with live repo mutations,
- `Add project` visibility at repo count 0/1.
-18
View File
@@ -1,18 +0,0 @@
# Sidekick animation state mapping
Orca's normalized agent status model has four hook-reported states: `working`, `blocked`, `waiting`, and `done`. `interrupted` is an optional flag on `done`, not a separate failure state.
Codex pet spritesheets can expose more visual rows than Orca has agent states. Sidekick should treat those row names as an asset contract, not as proof that Codex or Orca reports matching runtime states.
The current mapping is:
| Orca condition | Sidekick animation |
| --- | --- |
| Sidekick is being dragged | `jumping` |
| Any fresh agent is `blocked` or `waiting` | `waiting` |
| Any fresh agent is `working` | `running` |
| Any fresh agent is `done` | `review` |
| Any retained completed agent exists | `review` |
| No fresh or retained agent state exists | `idle` |
`SidekickAnimationName` deliberately omits `failed`. Orca distinguishes interrupted completions from normal completions, but interruption can mean user cancellation rather than agent failure, so mapping it to `failed` would overstate the status until Orca has a real failure/error signal. Codex spritesheets may still expose a `failed` row — that row stays as part of the asset contract but is never selected at runtime.
-176
View File
@@ -1,176 +0,0 @@
# Tasks Page Resume State
## Goal
When a user leaves the Tasks page and comes back later, Orca should reopen the page in the same working context instead of falling back to a generic GitHub Issues/PRs list.
The resume behavior should cover:
- GitHub vs Linear.
- GitHub Issues/PRs vs GitHub Project mode.
- The selected GitHub Issues/PRs preset or custom search.
- The selected GitHub Project and Project view.
- The selected repos for GitHub Issues/PRs.
- The selected Linear teams.
This should be lightweight. It should not introduce a second project-selection model or duplicate state that is already persisted elsewhere.
## Existing Persisted State
The current settings model already remembers most durable task choices:
- `settings.defaultTaskSource` remembers GitHub vs Linear.
- `settings.defaultRepoSelection` remembers GitHub repo selection for cross-repo Issues/PRs.
- `settings.defaultLinearTeamSelection` remembers Linear team selection.
- `settings.githubProjects.activeProject` remembers the active GitHub Project.
- `settings.githubProjects.lastViewByProject` remembers the last selected Project view per Project.
- `settings.defaultTaskViewPreset` remembers the user's default GitHub Issues/PRs preset.
These should stay in place. They are already wired into the page and, for repo/team/project selection, they represent actual user preferences rather than purely transient page state.
## Missing State
The missing piece is the user's current page position inside Tasks:
- Whether GitHub is showing `Issues/PRs` or `Project`.
- Which GitHub Issues/PRs preset is currently active.
- The currently applied GitHub Issues/PRs query when the user has typed a custom search.
- Which Linear preset is currently active.
- The currently applied Linear query when the user has typed a search.
Linear presets are visible task tabs and drive the fetch path, so they must be restored too.
## Proposed Shape
Add a small optional field to `PersistedUIState`:
```ts
export type TaskResumeState = {
githubMode?: 'items' | 'project'
githubItemsPreset?: TaskViewPresetId | null
githubItemsQuery?: string
linearPreset?: 'assigned' | 'created' | 'all' | 'completed'
linearQuery?: string
}
```
Then add:
```ts
taskResumeState?: TaskResumeState
```
to `PersistedUIState`.
Why `PersistedUIState`: this is page-position UI state, similar to sidebar filters and widths. It is not an app-wide setting and should not be presented as a configurable default.
Do not add `source` to `TaskResumeState`. `settings.defaultTaskSource` already represents the user's last-used task source today, and duplicating it would create two persisted sources of truth. Keep source changes on the existing settings path.
Do not reset `githubMode` just because the current source is Linear. Source selection and GitHub sub-mode are independent pieces of context: if the user was in GitHub Project mode, switched to Linear, then later switches back to GitHub, GitHub should still reopen in Project mode.
## Restore Rules
On Tasks page mount:
1. If `taskPageData.taskSource` was passed by a caller, use it. Explicit navigation intent should win.
2. Otherwise fall back to `settings.defaultTaskSource`.
3. Apply these restore rules only after both settings and persisted UI state have hydrated. The current app loads those asynchronously before setting `persistedUIReady`; a `useState` initializer that reads `settings === null` or an unhydrated `taskResumeState` will capture defaults and miss the restored context.
The restore should run once per Tasks page mount, after hydration is ready. It should not keep reapplying persisted state over local user interactions while the page remains open.
For GitHub:
1. Restore `githubMode` from `taskResumeState.githubMode`.
2. If mode is `items`, restore `githubItemsPreset` and `githubItemsQuery`.
3. Repo selection continues to come from `settings.defaultRepoSelection`.
4. If mode is `project`, use `settings.githubProjects.activeProject` and `settings.githubProjects.lastViewByProject`.
5. If Project mode has no active project or view, show the Project picker empty state instead of silently switching to Issues/PRs.
For Linear:
1. Restore `linearPreset`.
2. Restore `linearQuery`.
3. Team selection continues to come from `settings.defaultLinearTeamSelection`.
Fallback behavior:
- If resume state is absent, use today's defaults.
- If `githubItemsPreset` is non-null, derive the query from the preset unless `githubItemsQuery` disagrees because of a future migration. The preset should be treated as authoritative.
- If `githubItemsPreset` is null, use `githubItemsQuery` as a custom search.
- If `linearQuery` is non-empty, treat it as custom search and keep the restored `linearPreset` as the preset to return to after the search is cleared. The visible active preset should be suppressed while the search input is non-empty, matching today's UI.
## Write Rules
Update `taskResumeState` only when the user changes the active page context:
- Switching GitHub mode between Issues/PRs and Project.
- Clicking a GitHub Issues/PRs preset.
- Applying the debounced GitHub Issues/PRs search.
- Clearing the GitHub Issues/PRs search.
- Clicking a Linear preset.
- Applying the debounced Linear search.
- Clearing the Linear search.
Do not persist every raw search keystroke. The current UI applies search after a 300 ms debounce, so the persisted value should follow the debounced applied query, not the raw input. A user who types, waits for results, leaves Tasks, and comes back should see the same applied query.
Task source, repo selection, Linear team selection, active Project, and active Project view should continue writing through their existing settings paths.
The resume setter should merge partial updates with the existing resume state so changing one dimension does not erase the others. It should persist via `window.api.ui.set({ taskResumeState })` after updating local Zustand state.
When writing preset selections, clear stale custom-query ambiguity explicitly:
- GitHub preset click: write `{ githubItemsPreset: presetId, githubItemsQuery: undefined }` or the canonical preset query. Restore must still use the preset as authoritative.
- GitHub custom search apply/debounce: write `{ githubItemsPreset: null, githubItemsQuery: trimmedQuery }`.
- GitHub search clear: write `{ githubItemsPreset: null, githubItemsQuery: '' }`.
- Linear preset click: write `{ linearPreset: presetId, linearQuery: '' }`.
- Linear custom search apply/debounce: write `{ linearQuery: trimmedQuery }` without changing `linearPreset`.
- Linear search clear: write `{ linearQuery: '' }` without changing `linearPreset`.
Do not persist mode changes caused only by hiding Project mode while another source is active. In practice, avoid an effect that changes persisted `githubMode` from `project` to `items` when `taskSource !== 'github'`.
## Implementation Notes
Primary files:
- `src/shared/types.ts`: add `TaskResumeState` and `taskResumeState?: TaskResumeState`.
- `src/shared/constants.ts`: no required default beyond leaving the field absent. If a concrete default is preferred, use GitHub Issues/PRs with the existing default preset.
- `src/renderer/src/store/slices/ui.ts`: hydrate `taskResumeState` defensively, expose a setter such as `setTaskResumeState`, and use it in `openTaskPage` prefetch selection.
- `src/renderer/src/components/TaskPage.tsx`: initialize source from existing settings/page data, and initialize GitHub mode, GitHub item preset/query, Linear preset, and Linear query from the resume state.
- `src/renderer/src/App.tsx`: no new work should be needed if the field rides the existing `ui:get` hydration.
The setter should call `window.api.ui.set({ taskResumeState })` through the same persistence path used by other persisted UI fields, or follow the existing store pattern if there is already a central UI save effect for the relevant slice.
Hydration must sanitize the nested object because `PersistedUIState` comes from disk. Invalid `githubMode`, invalid GitHub preset ids, invalid Linear preset ids, or non-string queries should fall back field-by-field rather than entering Zustand as untrusted values.
`TaskPage` should subscribe to `persistedUIReady`, `settings`, and `taskResumeState`, then perform a one-shot local initialization once both settings and UI hydration are available. This avoids the current class of bugs where `useState(settings?.defaultTaskViewPreset ?? 'all')` captures `'all'` before settings arrive.
`openTaskPage` currently prefetches the settings default GitHub preset. After this change, it should prefetch the query the Tasks page will actually mount with:
1. If explicit `taskPageData.taskSource` is `linear`, skip GitHub prefetch.
2. If explicit `taskPageData.taskSource` is `github`, or the resolved source is GitHub, prefetch only when the resolved GitHub mode is `items`.
3. If `githubItemsPreset` is non-null, prefetch that preset query.
4. If `githubItemsPreset` is null and `githubItemsQuery` is non-empty, prefetch the custom query.
5. Otherwise prefetch `settings.defaultTaskViewPreset`.
The prefetch should also use the same repo selection the page will use: `taskPageData.preselectedRepoId` when present, otherwise `settings.defaultRepoSelection`, otherwise all eligible repos. Warming only `activeRepoId` is acceptable as an optimization fallback, but it is not equivalent to the mounted cross-repo query.
## Non-Goals
Do not remember scroll position, selected table row, open dialogs, pagination page, Project search overrides, or cached API results in this pass.
Those states are more fragile because they depend on live remote data. The first version should only restore the user's broad working context.
## Acceptance Criteria
- Open Tasks, switch to Linear, leave Tasks, return to Tasks: Linear is selected.
- Open Tasks, switch to GitHub Project mode, switch to Linear, leave Tasks, return to Tasks, then switch back to GitHub: GitHub is still in Project mode.
- Open Tasks, switch to GitHub Project mode, select a Project view, leave Tasks, return to Tasks: Project mode opens on the same Project view.
- Open Tasks, choose GitHub Issues/PRs `My PRs`, leave Tasks, return to Tasks: GitHub Issues/PRs opens with `My PRs` active.
- Open Tasks, type and apply a custom GitHub search, leave Tasks, return to Tasks: GitHub Issues/PRs opens with that custom query and no preset selected.
- Open Tasks, type a GitHub search, wait for the debounced results, leave Tasks, return to Tasks: GitHub Issues/PRs opens with that applied query and no preset selected.
- Open Tasks, choose Linear `Completed`, leave Tasks, return to Tasks: Linear opens with `Completed` active.
- Open Tasks, type a Linear search, wait for the debounced results, leave Tasks, return to Tasks: Linear opens with that applied query.
- Open Tasks before hydration has completed, then wait for hydration: Tasks applies the persisted source/mode/preset/query exactly once and does not overwrite subsequent user changes.
- Corrupt `taskResumeState` on disk with invalid modes/preset ids/non-string queries, restart, and open Tasks: invalid fields are ignored field-by-field without breaking the page.
- Existing repo selection, Linear team selection, and GitHub Project view persistence keep working unchanged.
- Restart the app after each source/mode/preset/custom-query scenario above: the same Tasks context is restored from persisted UI/settings state.
+78
View File
@@ -0,0 +1,78 @@
# Worktree Delete Preflight
## Problem
- Local delete paths kill PTYs before git deletion:
- `worktrees:remove` IPC (`src/main/ipc/worktrees.ts`)
- `removeManagedWorktree` runtime/RPC (`src/main/runtime/orca-runtime.ts`)
- On non-force failures (dirty/untracked is common), the worktree stays on disk but terminals are already gone.
- PTY teardown is intentionally destructive and best-effort (`src/main/runtime/worktree-teardown.ts`), so non-force deletability must be checked first.
## Ground Truth From Code
- There is no dry-run remove path currently used in git helpers (`src/main/git/worktree.ts`).
- Errors shown to users are normalized through `formatWorktreeRemovalError` (`src/main/ipc/worktree-logic.ts`).
- SSH-backed repos already delegate deletion to provider APIs and should remain provider-owned.
- Current orphan cleanup (`is not a working tree` handling + prune + metadata cleanup) lives in IPC/runtime remove catch blocks and must not regress.
## Non-goals
- Do not change force-delete semantics; force may still kill PTYs before git remove.
- Do not add new renderer confirmation states or copy.
- Do not attempt to predict every possible git remove failure.
- Do not change SSH provider teardown ownership.
## Design
1. Add local preflight helper in `src/main/git/worktree.ts`.
- Export `assertWorktreeCleanForRemoval(worktreePath: string, force = false): Promise<void>`.
- If `force`, return immediately.
- Run `git status --porcelain --untracked-files=all` in `cwd = worktreePath`.
- If output is non-empty, throw a dedicated error (dirty/untracked).
- If command fails, rethrow original error.
2. IPC local delete ordering (`worktrees:remove`).
- Keep canonicalization and protected-path validation first (`getRegisteredDeletableWorktree`).
- Keep SSH provider branch unchanged.
- Keep archive hook and symlink cleanup before preflight, so preflight checks the exact post-hook/post-symlink state that `git worktree remove` will see.
- Run preflight.
- If preflight throws an orphan/missing-worktree style error, continue to existing remove path so current orphan cleanup behavior still executes.
Treat at least these as orphan-compatible for preflight: `is not a working tree`, `not a git repository`, and missing-path (`ENOENT`) failures from running status in a removed directory.
- For other preflight failures, throw via `formatWorktreeRemovalError(...)`.
- Only after successful preflight: run `killAllProcessesForWorktree(...)`, then `removeWorktree(...)`.
3. Runtime/RPC local delete ordering (`removeManagedWorktree`).
- Keep SSH branch unchanged.
- Keep current hook behavior (archive optional via `--run-hooks`, warning when configured but skipped).
- Run preflight after hook handling.
- Preserve orphan compatibility exactly as in IPC: preflight must not short-circuit existing orphan cleanup semantics (including `not a git repository`/`ENOENT` preflight failures that should fall through to the existing remove/catch path).
- On successful preflight: run PTY teardown, then `removeWorktree`.
- Route failures through existing formatted error surface.
4. Failure-class behavior contract.
- Dirty/untracked (non-force): fail before PTY teardown.
- Preflight subprocess/tooling failures: fail before PTY teardown, formatted.
- Orphan/missing-worktree conditions (`is not a working tree`, `not a git repository`, `ENOENT`): retain current cleanup-and-metadata-removal behavior by running the existing remove/catch flow without PTY teardown.
- Force deletes: no preflight; keep current teardown-before-remove order.
5. Tests.
- Update ordering assertions for non-force local deletes to `preflight -> kill -> git`.
- Add IPC/runtime tests proving dirty non-force failures happen before any PTY kill.
- Add IPC/runtime tests proving preflight error formatting uses `formatWorktreeRemovalError` path.
- Add IPC/runtime regression tests proving orphan cleanup still runs when preflight encounters orphan-like failures.
- Keep force ordering tests (`kill -> git`).
- Keep SSH tests proving local PTY teardown is not used for SSH-backed repos.
## Concurrency, Consistency, Limits
- Preflight narrows, but does not close, the race window: external edits can occur after preflight and before `git worktree remove`.
- Multi-window and out-of-band mutation races remain possible between canonicalization, hooks/symlink cleanup, preflight, kill, and remove.
- IPC has symlink cleanup before preflight; runtime does not. That asymmetry remains unless runtime gains equivalent cleanup.
- Cost is one additional git subprocess per non-force local delete; this is acceptable for a user-initiated destructive action.
## Rollout
1. Implement `assertWorktreeCleanForRemoval` + unit tests in `src/main/git/remove-worktree.test.ts`.
2. Wire IPC ordering and failure mapping in `src/main/ipc/worktrees.ts`; update `src/main/ipc/worktrees.test.ts`.
3. Wire runtime ordering and failure mapping in `src/main/runtime/orca-runtime.ts`; update `src/main/runtime/orca-runtime.test.ts`.
4. Run focused tests, then `pnpm typecheck` and `pnpm lint`.
+14 -1
View File
@@ -1896,7 +1896,20 @@ export default function SessionScreen() {
if (!terminalGestureInputInFlightRef.current.has(handle)) {
void flushTerminalGestureInput(handle)
} else {
terminalGestureInputQueuesRef.current.delete(handle)
// Why: an RPC is in-flight and the new batch would overflow the
// pending-sequences cap. Appending preserves the already-queued
// bytes (which would otherwise be dropped) — the in-flight flush's
// finally block will pick up the merged queue. The cap is a soft
// guideline; brief overflow during in-flight is preferable to
// silently dropping user input.
current.bytes += bytes
current.sequenceCount += sequenceCount
current.lastUpdatedMs = now
current.timer = setTimeout(() => {
current.timer = null
void flushTerminalGestureInput(handle)
}, TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS)
return
}
}
@@ -91,10 +91,11 @@ class AudioEngine (context: Context) {
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
requestAudioFocus()
// Route audio to external device if connected, otherwise route to speaker
updateAudioRouting()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// Route audio to external device if connected, otherwise route to speaker.
// AudioDeviceInfo/getDevices are API 23+, while the module supports API 21.
updateAudioRouting()
// Listen for changes in audio routing
val callback = object:android.media.AudioDeviceCallback(){
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
@@ -110,6 +111,8 @@ class AudioEngine (context: Context) {
}
audioDeviceCallback = callback
audioManager.registerAudioDeviceCallback(callback, null)
} else {
updateLegacyAudioRouting()
}
val bufferSize = AudioTrack.getMinBufferSize(
@@ -136,6 +139,7 @@ class AudioEngine (context: Context) {
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun updateAudioRouting() {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
var isExternalDeviceConnected = false
@@ -177,6 +181,15 @@ class AudioEngine (context: Context) {
}
}
@Suppress("DEPRECATION")
private fun updateLegacyAudioRouting() {
val isExternalDeviceConnected =
audioManager.isWiredHeadsetOn ||
audioManager.isBluetoothScoOn ||
audioManager.isBluetoothA2dpOn
audioManager.isSpeakerphoneOn = !isExternalDeviceConnected
}
@SuppressLint("NewApi")
private fun requestAudioFocus() {
val listener = AudioManager.OnAudioFocusChangeListener { focusChange ->
@@ -36,6 +36,7 @@ type AgentOption = {
const AGENT_OPTIONS: AgentOption[] = [
{ id: 'claude', label: 'Claude' },
{ id: 'codex', label: 'Codex' },
{ id: 'grok', label: 'Grok', faviconDomain: 'x.ai' },
{ id: 'copilot', label: 'GitHub Copilot', faviconDomain: 'github.com' },
{ id: 'opencode', label: 'OpenCode', faviconDomain: 'opencode.ai' },
{ id: 'pi', label: 'Pi' },
@@ -67,6 +68,7 @@ const ALL_AGENTS = [...AGENT_OPTIONS, BLANK_TERMINAL]
const AGENT_COMMANDS: Record<string, string> = {
claude: 'claude',
codex: 'codex',
grok: 'grok',
copilot: 'copilot',
opencode: 'opencode',
pi: 'pi',
+1 -2
View File
@@ -1,5 +1,4 @@
scripts:
setup: |
[ -n "$ORCA_INTERNAL_DEV_SETUP" ] && [ -x "$ORCA_INTERNAL_DEV_SETUP" ] && \
"$ORCA_INTERNAL_DEV_SETUP" "$ORCA_WORKTREE_PATH" || true
node config/scripts/run-internal-dev-setup.mjs
pnpm install
+20 -13
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.2-rc.8",
"version": "1.4.5-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -13,20 +13,25 @@
"format": "oxfmt --write .",
"lint": "oxlint",
"prepare": "husky",
"test": "vitest run --config config/vitest.config.ts",
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
"verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs",
"vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs",
"tc:node": "tsgo --noEmit -p config/tsconfig.node.json",
"tc:cli": "tsgo --noEmit -p config/tsconfig.tc.cli.json",
"tc:web": "tsgo --noEmit -p config/tsconfig.tc.web.json",
"tc": "tsgo --noEmit -p config/tsconfig.node.json && tsgo --noEmit -p config/tsconfig.tc.cli.json && tsgo --noEmit -p config/tsconfig.tc.web.json",
"typecheck:node": "tsc --noEmit -p config/tsconfig.node.json --composite false",
"typecheck:cli": "tsc --noEmit -p config/tsconfig.cli.json --composite false",
"typecheck:web": "tsc --noEmit -p config/tsconfig.web.json --composite false",
"typecheck": "tsc --noEmit -p config/tsconfig.node.json --composite false && tsc --noEmit -p config/tsconfig.cli.json --composite false && tsc --noEmit -p config/tsconfig.web.json --composite false",
"start": "electron-vite preview",
"dev": "node config/scripts/run-electron-vite-dev.mjs",
"tc:node": "pnpm run typecheck:node",
"tc:cli": "pnpm run typecheck:cli",
"tc:web": "pnpm run typecheck:web",
"tc": "pnpm run typecheck",
"typecheck:node": "tsgo --noEmit -p config/tsconfig.node.json",
"typecheck:cli": "tsgo --noEmit -p config/tsconfig.tc.cli.json",
"typecheck:web": "tsgo --noEmit -p config/tsconfig.tc.web.json",
"typecheck": "tsgo --noEmit -p config/tsconfig.node.json && tsgo --noEmit -p config/tsconfig.tc.cli.json && tsgo --noEmit -p config/tsconfig.tc.web.json",
"typecheck:tsc:node": "tsc --noEmit -p config/tsconfig.node.json --composite false",
"typecheck:tsc:cli": "tsc --noEmit -p config/tsconfig.cli.json --composite false",
"typecheck:tsc:web": "tsc --noEmit -p config/tsconfig.web.json --composite false",
"typecheck:tsc": "tsc --noEmit -p config/tsconfig.node.json --composite false && tsc --noEmit -p config/tsconfig.cli.json --composite false && tsc --noEmit -p config/tsconfig.web.json --composite false",
"start": "node config/scripts/ensure-native-runtime.mjs --runtime=electron && electron-vite preview",
"dev": "node config/scripts/ensure-native-runtime.mjs --runtime=electron && node config/scripts/run-electron-vite-dev.mjs",
"dev-stable-name": "node config/scripts/ensure-native-runtime.mjs --runtime=electron && node config/scripts/run-electron-vite-dev.mjs --stable-name",
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
"build:relay": "node config/scripts/build-relay.mjs",
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
@@ -39,6 +44,7 @@
"build:release": "pnpm run build:relay && pnpm run build:computer-macos && pnpm run verify:computer-native && pnpm run build:electron-vite && pnpm run build:web && pnpm run build:cli",
"postinstall": "pnpm rebuild electron && node config/scripts/rebuild-native-deps.mjs",
"rebuild:electron": "node config/scripts/rebuild-native-deps.mjs",
"rebuild:node": "pnpm rebuild better-sqlite3 node-pty",
"build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir",
"build:win": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --win",
"build:icons": "bash resources/icon-source/generate.sh",
@@ -127,6 +133,7 @@
"tweetnacl": "^1.0.3",
"unified": "^11.0.5",
"ws": "^8.20.0",
"yaml": "^2.8.4",
"zod": "~4.4.3",
"zustand": "^5.0.13"
},
@@ -143,7 +150,7 @@
"@types/react-dom": "^19.2.3",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260406.1",
"@typescript/native-preview": "7.0.0-dev.20260421.2",
"@vitejs/plugin-react": "^5.2.0",
"electron": "^41.5.0",
"electron-builder": "^26.8.1",
+39 -36
View File
@@ -139,12 +139,12 @@ importers:
electron-updater:
specifier: ^6.8.3
version: 6.8.3
entities:
specifier: ^6.0.1
version: 6.0.1
emoji-picker-react:
specifier: ^4.19.1
version: 4.19.1(react@19.2.5)
entities:
specifier: ^6.0.1
version: 6.0.1
github-slugger:
specifier: ^2.0.0
version: 2.0.0
@@ -250,6 +250,9 @@ importers:
ws:
specifier: ^8.20.0
version: 8.20.0
yaml:
specifier: ^2.8.4
version: 2.8.4
zod:
specifier: ~4.4.3
version: 4.4.3
@@ -294,8 +297,8 @@ importers:
specifier: ^8.18.1
version: 8.18.1
'@typescript/native-preview':
specifier: 7.0.0-dev.20260406.1
version: 7.0.0-dev.20260406.1
specifier: 7.0.0-dev.20260421.2
version: 7.0.0-dev.20260421.2
'@vitejs/plugin-react':
specifier: ^5.2.0
version: 5.2.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
@@ -2944,43 +2947,43 @@ packages:
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260406.1':
resolution: {integrity: sha512-q6AMrDHlD6dQHpRuGOewhvKTCBDWRgJ42678+muvHbdHkBafRSUSBRiYasUp8cInK22jlkVwNLqQn6j7Sl9zVg==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260421.2':
resolution: {integrity: sha512-fHv1r3ZmVo6zxuAIFmuX3w9QxbcauoG0SsWhmDwm6VmRubLlOJIcmTtlmV3JAb9oOnq8LuzZljzT7Q39fSMQDw==}
cpu: [arm64]
os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260406.1':
resolution: {integrity: sha512-3v8LplbhJwY4GlBawgJt4ydn0sYeowGMniGiPMBl38ioYAIJOriuBvAk+S/gbOoIfLjMLq0L65fnQN+w9+i5ag==}
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260421.2':
resolution: {integrity: sha512-KWTR6xbW9t+JS7D5DQIzo75pqVXVWUxF9PMv/+S6xsnOjCVd6g0ixHcFpFMJMKSUQpGPr8Z5f7b8ks6LHW01jg==}
cpu: [x64]
os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260406.1':
resolution: {integrity: sha512-hRY2czBKisYtcbwgl8HZBA7u/KKUumNlL0X2OpCK1BtQzKbHXAXi1HxUCPbwgHu4v6uGibvniny0BPA6MVrHDA==}
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260421.2':
resolution: {integrity: sha512-VLMEuml3BhUb+jaL0TXQ4xvVODxJF+RhkI+tBWvlynsJI4khTXEiwWh+wPOJrsfBRYFRMXEu28Odl/HXkYze8w==}
cpu: [arm64]
os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20260406.1':
resolution: {integrity: sha512-KLa4bK2BxnQwc9uefI8rtaso3cNiRI5Y19z9Jx7UzFJS4YaxtFp5cVjfy4FlQ55ixtUExmCJH7GhSzuVeFl/Jw==}
'@typescript/native-preview-linux-arm@7.0.0-dev.20260421.2':
resolution: {integrity: sha512-BWLQO3nemLDSV5PoE5GPHe1dU9Dth77Kv8/cle9Ujcp4LhPo0KincdPqFH/qKeU/xvW25mgFueflZ1nc4rKuww==}
cpu: [arm]
os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20260406.1':
resolution: {integrity: sha512-Cbcy+lSctRHNmqvLl+l8RgomL0qX3wxEPKCOIdQ/ooicsIPFbkK57Cwdhw3RSpJ+Xb2LzLdneA2Q1Vg+f/Nwxg==}
'@typescript/native-preview-linux-x64@7.0.0-dev.20260421.2':
resolution: {integrity: sha512-qUrJWTB5/wv4wnRG0TRXElAxc2kykNiRNyEIEqBbLmzDlrcvAW7RRy8MXoY1ZyTiKGMu14itZ3x9oW6+blFpRw==}
cpu: [x64]
os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260406.1':
resolution: {integrity: sha512-IFIkbYABge7f83A16awJJWUCrDG1Lw4//NCdU927i/CjgxG09q4ZJeVXcIbZQ5lJWwraiSTa6AMqvhF/tnR2KQ==}
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260421.2':
resolution: {integrity: sha512-Rc6NsWlZmCs5YUKVzKgwoBOoRUGsPzct4BDMRX0csD1devLBBc4AbUXWKsJRbpwIAnqMO1ld4sNHEb+wXgfNHQ==}
cpu: [arm64]
os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20260406.1':
resolution: {integrity: sha512-Jk7wP6SPaELTGY+ijvpude+dmXX9WeuLQLk9qjevQXTusFVcT8vjvepxJVshSJ1+Thmxy1t9v4l1pHnM3XUMjw==}
'@typescript/native-preview-win32-x64@7.0.0-dev.20260421.2':
resolution: {integrity: sha512-GQv1+dya1t6EqF2Cpsb+xoozovdX10JUSf6Kl/8xNkTapzmlHd+uMr+8ku3jIASTxoRGn0Mklgjj3MDKrOTuLg==}
cpu: [x64]
os: [win32]
'@typescript/native-preview@7.0.0-dev.20260406.1':
resolution: {integrity: sha512-hlVajr01Y/ZmI9iZ7A6BgPxqXccGqxuc/PmVNdanr/LZdtsH9q11y2H3NFMaOrbPlDoeWxHvGUT3wsZllCphyg==}
'@typescript/native-preview@7.0.0-dev.20260421.2':
resolution: {integrity: sha512-CmajHI25HpVWE9R1XFoxr+cphJPxoYD3eFioQtAvXYkMFKnLdICMS9pXre9Pybizb75ejRxjKD5/CVG055rEIg==}
hasBin: true
'@ungap/structured-clone@1.3.1':
@@ -8848,36 +8851,36 @@ snapshots:
'@types/node': 25.6.0
optional: true
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260406.1':
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260421.2':
optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260406.1':
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260421.2':
optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260406.1':
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260421.2':
optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20260406.1':
'@typescript/native-preview-linux-arm@7.0.0-dev.20260421.2':
optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20260406.1':
'@typescript/native-preview-linux-x64@7.0.0-dev.20260421.2':
optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260406.1':
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260421.2':
optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20260406.1':
'@typescript/native-preview-win32-x64@7.0.0-dev.20260421.2':
optional: true
'@typescript/native-preview@7.0.0-dev.20260406.1':
'@typescript/native-preview@7.0.0-dev.20260421.2':
optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260406.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260406.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260406.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260406.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260406.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260406.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260406.1
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260421.2
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260421.2
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260421.2
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260421.2
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260421.2
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260421.2
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260421.2
'@ungap/structured-clone@1.3.1': {}
+1 -1
View File
@@ -190,7 +190,7 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea
- Use `terminal read` before `terminal send` unless the next input is obvious.
- Use `terminal wait --terminal <handle> --for exit` only when the task actually depends on process completion.
- Use `terminal wait --terminal <handle> --for tui-idle` to wait for an agent CLI (Claude Code, Gemini, Codex, etc.) to finish its current task. This detects the working→idle OSC title transition. Always pass `--timeout-ms` as a safety net — unsupported CLIs will hang until timeout.
- Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup (e.g. `--command "claude"` to launch Claude Code) and `--title` for labeling. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching.
- Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup (e.g. `--command "claude"` to launch Claude Code) and `--title` for labeling. In local Orca sessions, `--command "codex"` is routed through Orca's visible terminal path automatically so Codex does not start as a headless/background PTY. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching.
- Use `terminal split` to create split panes within an existing terminal tab. Pass `--command` to run a command in the new pane.
- Prefer Orca worktree selectors over hardcoded paths when Orca identity already exists.
- If the user asks for CLI UX feedback, test the public `orca` command first. Only inspect `src/cli` or use `node out/cli/index.js` if the public command is missing or the task is explicitly about implementation internals.
+1 -1
View File
@@ -157,7 +157,7 @@ orca terminal close [--terminal <handle>] [--json]
Why: `--terminal` is optional for most commands. When omitted, Orca auto-resolves to the active terminal in the current worktree.
Why: `--command "claude"` launches Claude Code in the new terminal. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching.
Why: `--command "claude"` launches Claude Code in the new terminal. In local Orca sessions, `--command "codex"` launches Codex through Orca's visible terminal path automatically so Codex does not start as a headless/background PTY. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching.
Why: `--for tui-idle` detects the working→idle OSC title transition for recognized agent CLIs (Claude Code, Gemini, Codex, etc.). Always pass `--timeout-ms` — real coding tasks routinely take 15-60 minutes.
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { shouldForceVisibleCodexTerminal } from './codex-command-classification'
describe('shouldForceVisibleCodexTerminal', () => {
it('forces visible terminal creation for interactive Codex sessions', () => {
expect(shouldForceVisibleCodexTerminal('codex')).toBe(true)
expect(shouldForceVisibleCodexTerminal('codex -m gpt-5 "fix the flaky test"')).toBe(true)
expect(shouldForceVisibleCodexTerminal('codex resume --last')).toBe(true)
expect(shouldForceVisibleCodexTerminal('codex fork')).toBe(true)
expect(shouldForceVisibleCodexTerminal('codex login')).toBe(true)
expect(shouldForceVisibleCodexTerminal('codex cloud')).toBe(true)
expect(shouldForceVisibleCodexTerminal('codex -c active=cloud cloud')).toBe(true)
expect(shouldForceVisibleCodexTerminal('codex.cmd resume --last')).toBe(true)
expect(shouldForceVisibleCodexTerminal('env OPENAI_API_KEY=stub codex')).toBe(true)
})
it('keeps one-shot Codex commands on the background path', () => {
expect(shouldForceVisibleCodexTerminal('codex exec summarize')).toBe(false)
expect(shouldForceVisibleCodexTerminal('codex -m gpt-5 review')).toBe(false)
expect(shouldForceVisibleCodexTerminal('codex login status')).toBe(false)
expect(shouldForceVisibleCodexTerminal('codex login --with-api-key')).toBe(false)
expect(shouldForceVisibleCodexTerminal('codex cloud list --json')).toBe(false)
expect(shouldForceVisibleCodexTerminal('codex -c active=cloud cloud list --json')).toBe(false)
expect(shouldForceVisibleCodexTerminal('codex cloud --enable foo list --json')).toBe(false)
expect(
shouldForceVisibleCodexTerminal('env -u DEBUG CODEX_HOME=/tmp/codex codex exec summarize')
).toBe(false)
expect(shouldForceVisibleCodexTerminal('codex cloud exec "fix it"')).toBe(false)
expect(shouldForceVisibleCodexTerminal('codex cloud --version')).toBe(false)
expect(shouldForceVisibleCodexTerminal('codex --help')).toBe(false)
})
it('ignores non-Codex commands', () => {
expect(shouldForceVisibleCodexTerminal(undefined)).toBe(false)
expect(shouldForceVisibleCodexTerminal('claude')).toBe(false)
expect(shouldForceVisibleCodexTerminal('npm exec codex')).toBe(false)
})
})
+277
View File
@@ -0,0 +1,277 @@
const CODEX_NON_INTERACTIVE_SUBCOMMANDS = new Set([
'exec',
'e',
'review',
'logout',
'mcp',
'plugin',
'mcp-server',
'app-server',
'remote-control',
'app',
'completion',
'update',
'doctor',
'sandbox',
'debug',
'execpolicy',
'apply',
'a',
'cloud',
'cloud-tasks',
'responses-api-proxy',
'stdio-to-uds',
'exec-server',
'features',
'help',
'version'
])
const CODEX_NON_INTERACTIVE_CLOUD_SUBCOMMANDS = new Set([
'exec',
'status',
'list',
'apply',
'diff',
'help'
])
const CODEX_NON_INTERACTIVE_LOGIN_SUBCOMMANDS = new Set(['status', 'help'])
const CODEX_GLOBAL_FLAGS_WITH_VALUES = new Set([
'--config',
'-c',
'--enable',
'--disable',
'--remote',
'--remote-auth-token-env',
'--image',
'-i',
'--model',
'-m',
'--local-provider',
'--profile',
'-p',
'--sandbox',
'-s',
'--cd',
'-C',
'--add-dir',
'--ask-for-approval',
'-a'
])
const CODEX_GLOBAL_BOOLEAN_FLAGS = new Set([
'--oss',
'--dangerously-bypass-approvals-and-sandbox',
'--search',
'--no-alt-screen',
'--help',
'-h',
'--version',
'-V'
])
const CODEX_LOGIN_FLAGS_WITH_VALUES = new Set(['-c', '--config', '--enable', '--disable'])
const CODEX_LOGIN_BOOLEAN_FLAGS = new Set([
'--with-api-key',
'--with-access-token',
'--device-auth',
'--help',
'-h'
])
const CODEX_CLOUD_FLAGS_WITH_VALUES = new Set(['-c', '--config', '--enable', '--disable'])
const CODEX_CLOUD_BOOLEAN_FLAGS = new Set(['--help', '-h', '--version', '-V'])
type CodexCommandToken = {
value: string
index: number
}
function tokenizeLeadingShellWords(command: string, limit: number): string[] {
const tokens: string[] = []
let current = ''
let quote: '"' | "'" | null = null
for (let i = 0; i < command.length; i += 1) {
const ch = command[i]
if (quote) {
if (ch === quote) {
quote = null
} else {
current += ch
}
continue
}
if (ch === '"' || ch === "'") {
quote = ch
continue
}
if (/\s/.test(ch)) {
if (current) {
tokens.push(current)
if (tokens.length >= limit) {
return tokens
}
current = ''
}
continue
}
current += ch
}
if (current && tokens.length < limit) {
tokens.push(current)
}
return tokens
}
function commandBasename(command: string): string {
const normalized = command.replace(/\\/g, '/')
return normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase()
}
function isCodexExecutable(command: string): boolean {
return command === 'codex' || command === 'codex.exe' || command === 'codex.cmd'
}
function isShellAssignment(token: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)
}
function stripShellLaunchPrefix(tokens: string[]): string[] {
const remaining = [...tokens]
while (remaining[0] && isShellAssignment(remaining[0])) {
remaining.shift()
}
if (remaining[0] && commandBasename(remaining[0]) === 'env') {
remaining.shift()
while (remaining[0]) {
const token = remaining[0]
if (isShellAssignment(token)) {
remaining.shift()
continue
}
if (token === '-u' || token === '--unset') {
remaining.splice(0, 2)
continue
}
if (token.startsWith('--unset=')) {
remaining.shift()
continue
}
if (token.startsWith('-')) {
remaining.shift()
continue
}
break
}
}
return remaining
}
function codexGlobalOptionName(token: string): string {
const separatorIndex = token.indexOf('=')
return separatorIndex === -1 ? token : token.slice(0, separatorIndex)
}
function isHelpFlag(token: string): boolean {
return token === '--help' || token === '-h'
}
function isVersionFlag(token: string): boolean {
return token === '--version' || token === '-V'
}
function findCodexSubcommand(
tokens: string[],
startIndex: number,
flagsWithValues: Set<string>,
booleanFlags: Set<string>
): CodexCommandToken | null {
for (let i = startIndex; i < tokens.length; i += 1) {
const token = tokens[i]
if (token === '--') {
return tokens[i + 1] ? { value: '<prompt>', index: i + 1 } : null
}
const optionName = codexGlobalOptionName(token)
if (isHelpFlag(optionName) || isVersionFlag(optionName)) {
return { value: isVersionFlag(optionName) ? 'version' : 'help', index: i }
}
if (flagsWithValues.has(optionName)) {
if (optionName === token) {
i += 1
}
continue
}
if (booleanFlags.has(optionName)) {
continue
}
return { value: token, index: i }
}
return null
}
function isNonInteractiveCodexSubcommand(tokens: string[]): boolean {
const subcommand = findCodexSubcommand(
tokens,
1,
CODEX_GLOBAL_FLAGS_WITH_VALUES,
CODEX_GLOBAL_BOOLEAN_FLAGS
)
if (!subcommand) {
return false
}
const normalizedSubcommand = subcommand.value.toLowerCase()
if (normalizedSubcommand === 'login') {
// Why: bare `codex login` displays an auth flow; only explicit status/help
// or stdin-fed token modes are safe to leave in a background PTY.
const loginStartIndex = subcommand.index + 1
const loginSubcommand = findCodexSubcommand(
tokens,
loginStartIndex,
CODEX_LOGIN_FLAGS_WITH_VALUES,
CODEX_LOGIN_BOOLEAN_FLAGS
)
return (
tokens
.slice(loginStartIndex)
.some((token) => token === '--with-api-key' || token === '--with-access-token') ||
tokens.slice(loginStartIndex).some(isHelpFlag) ||
(loginSubcommand !== null &&
CODEX_NON_INTERACTIVE_LOGIN_SUBCOMMANDS.has(loginSubcommand.value.toLowerCase()))
)
}
if (normalizedSubcommand === 'cloud') {
// Why: bare `codex cloud` opens the interactive cloud browser, while its
// named child commands are plain one-shot commands.
const cloudStartIndex = subcommand.index + 1
const cloudSubcommand = findCodexSubcommand(
tokens,
cloudStartIndex,
CODEX_CLOUD_FLAGS_WITH_VALUES,
CODEX_CLOUD_BOOLEAN_FLAGS
)
return (
tokens.slice(cloudStartIndex).some((token) => isHelpFlag(token) || isVersionFlag(token)) ||
(cloudSubcommand !== null &&
CODEX_NON_INTERACTIVE_CLOUD_SUBCOMMANDS.has(cloudSubcommand.value.toLowerCase()))
)
}
return CODEX_NON_INTERACTIVE_SUBCOMMANDS.has(normalizedSubcommand)
}
export function shouldForceVisibleCodexTerminal(command: string | undefined): boolean {
if (!command) {
return false
}
const tokens = stripShellLaunchPrefix(
tokenizeLeadingShellWords(command.trim(), 32).filter((token) => token.length > 0)
)
const executable = tokens[0] ? commandBasename(tokens[0]) : ''
if (!isCodexExecutable(executable)) {
return false
}
return !isNonInteractiveCodexSubcommand(tokens)
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { RuntimeRpcFailureError } from './runtime-client'
import { formatCliError, formatWorktreeList } from './format'
import type { RuntimeWorktreeRecord } from '../shared/runtime-types'
function worktree(overrides: Partial<RuntimeWorktreeRecord> = {}): RuntimeWorktreeRecord {
const base: RuntimeWorktreeRecord = {
id: 'repo::/tmp/repo/child',
repoId: 'repo',
path: '/tmp/repo/child',
head: 'abc123',
branch: 'feature/child',
isBare: false,
isMainWorktree: false,
parentWorktreeId: null,
childWorktreeIds: [],
lineage: null,
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
git: {
path: '/tmp/repo/child',
head: 'abc123',
branch: 'feature/child',
isBare: false,
isMainWorktree: false
},
displayName: '',
comment: ''
}
return { ...base, ...overrides }
}
describe('formatCliError', () => {
it('prints runtime next steps for structured lineage errors', () => {
const error = new RuntimeRpcFailureError({
id: 'req_1',
ok: false,
error: {
code: 'LINEAGE_PARENT_NOT_FOUND',
message: 'Parent workspace was not found.',
data: {
nextSteps: [
'Run `orca worktree list` and pass a valid --parent-worktree selector.',
'Retry with --no-parent to create without lineage.',
123
]
}
},
_meta: { runtimeId: 'runtime-1' }
})
expect(formatCliError(error)).toBe(
[
'Parent workspace was not found.',
'Next step: Run `orca worktree list` and pass a valid --parent-worktree selector.',
'Next step: Retry with --no-parent to create without lineage.'
].join('\n')
)
})
})
describe('formatWorktreeList', () => {
it('includes parent and child workspace relationships in text output', () => {
const output = formatWorktreeList({
worktrees: [
worktree({
id: 'repo::/tmp/repo/parent',
path: '/tmp/repo/parent',
branch: 'feature/parent',
childWorktreeIds: ['repo::/tmp/repo/child']
}),
worktree({
parentWorktreeId: 'repo::/tmp/repo/parent'
})
],
totalCount: 2,
truncated: false
})
expect(output).toContain('parentWorktreeId: null')
expect(output).toContain('childWorktreeIds: repo::/tmp/repo/child')
expect(output).toContain('parentWorktreeId: repo::/tmp/repo/parent')
expect(output).toContain('childWorktreeIds: []')
})
})
+16 -4
View File
@@ -60,6 +60,18 @@ export function formatCliError(error: unknown): string {
) {
return `${message}\nOrca is not running. Run 'orca open' first.`
}
if (error instanceof RuntimeRpcFailureError) {
const data = error.response.error.data
const nextSteps =
data && typeof data === 'object' && Array.isArray((data as { nextSteps?: unknown }).nextSteps)
? (data as { nextSteps: unknown[] }).nextSteps.filter(
(step): step is string => typeof step === 'string'
)
: []
if (nextSteps.length > 0) {
return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
}
}
return message
}
@@ -250,10 +262,10 @@ export function formatWorktreeList(result: RuntimeWorktreeListResult): string {
return 'No worktrees found.'
}
const body = result.worktrees
.map(
(worktree) =>
`${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}`
)
.map((worktree) => {
const childCount = worktree.childWorktreeIds?.length ?? 0
return `${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nparentWorktreeId: ${String(worktree.parentWorktreeId ?? 'null')}\nchildWorktreeIds: ${childCount > 0 ? worktree.childWorktreeIds.join(',') : '[]'}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}`
})
.join('\n\n')
return result.truncated
? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
+7 -1
View File
@@ -207,12 +207,18 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
},
'orchestration task-create': async ({ flags, client, json }) => {
const callerTerminalHandle =
typeof process.env.ORCA_TERMINAL_HANDLE === 'string' &&
process.env.ORCA_TERMINAL_HANDLE.length > 0
? process.env.ORCA_TERMINAL_HANDLE
: undefined
const result = await client.call<{ task: { id: string; status: string } }>(
'orchestration.taskCreate',
{
spec: getRequiredStringFlag(flags, 'spec'),
deps: getOptionalStringFlag(flags, 'deps'),
parent: getOptionalStringFlag(flags, 'parent')
parent: getOptionalStringFlag(flags, 'parent'),
callerTerminalHandle
}
)
printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`)
+10 -2
View File
@@ -11,6 +11,7 @@ import type {
RuntimeTerminalWait
} from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { shouldForceVisibleCodexTerminal } from '../codex-command-classification'
import {
formatTerminalClose,
formatTerminalCreate,
@@ -122,11 +123,18 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
'Remote terminal create requires --worktree because the client cwd cannot identify a server worktree.'
)
}
const command = getOptionalStringFlag(flags, 'command')
const focus = flags.get('focus') === true
const rendererBacked = !client.isRemote && shouldForceVisibleCodexTerminal(command)
const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
command: getOptionalStringFlag(flags, 'command'),
command,
title: getOptionalStringFlag(flags, 'title'),
focus: flags.get('focus') === true
// Why: Codex's interactive TUI must be born in a renderer-backed
// terminal. The runtime's default create path spawns first in a
// headless/background PTY and only adopts into the UI afterward.
focus,
...(rendererBacked ? { rendererBacked: true, activate: focus } : {})
})
printResult(result, json, formatTerminalCreate)
},
+91 -15
View File
@@ -1,10 +1,12 @@
import type {
RuntimeWorktreeListResult,
RuntimeWorktreePsResult,
RuntimeWorktreeRecord
RuntimeWorktreeRecord,
RuntimeWorktreeCreateResult
} from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format'
import { RuntimeClientError } from '../runtime-client'
import {
getOptionalNullableNumberFlag,
getOptionalNumberFlag,
@@ -12,7 +14,11 @@ import {
getOptionalStringFlag,
getRequiredStringFlag
} from '../flags'
import { getRequiredWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors'
import {
getOptionalWorktreeSelector,
getRequiredWorktreeSelector,
resolveCurrentWorktreeSelector
} from '../selectors'
type HookWarningResult = {
warning?: string
@@ -24,6 +30,48 @@ function printHookWarning(result: HookWarningResult, json: boolean): void {
}
}
function printLineageSummary(result: RuntimeWorktreeCreateResult, json: boolean): void {
if (json) {
return
}
for (const warning of result.warnings ?? []) {
console.error(`warning: ${warning.message}`)
}
if (result.lineage) {
const source =
result.lineage.capture.source === 'terminal-context'
? 'terminal'
: result.lineage.capture.source === 'cwd-context'
? 'cwd'
: result.lineage.capture.source === 'orchestration-context'
? 'orchestration'
: result.lineage.capture.source === 'explicit-cli-flag'
? 'explicit flag'
: 'manual action'
console.error(
`parent: ${result.lineage.parentWorktreeId} (${result.lineage.capture.confidence} from ${source})`
)
} else {
console.error('parent: none')
}
}
function assertParentFlagsCompatible(flags: Map<string, string | boolean>): void {
if (flags.has('parent-worktree') && flags.get('no-parent') === true) {
throw new RuntimeClientError(
'invalid_argument',
'Choose either --parent-worktree or --no-parent, not both.'
)
}
const parentWorktree = flags.get('parent-worktree')
if (
flags.has('parent-worktree') &&
(typeof parentWorktree !== 'string' || parentWorktree === '')
) {
throw new RuntimeClientError('invalid_argument', 'Missing required --parent-worktree')
}
}
export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
'worktree ps': async ({ flags, client, json }) => {
const result = await client.call<RuntimeWorktreePsResult>('worktree.ps', {
@@ -50,28 +98,56 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
})
printResult(result, json, formatWorktreeShow)
},
'worktree create': async ({ flags, client, json }) => {
const result = await client.call<{ worktree: RuntimeWorktreeRecord; warning?: string }>(
'worktree.create',
{
repo: getRequiredStringFlag(flags, 'repo'),
name: getRequiredStringFlag(flags, 'name'),
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
comment: getOptionalStringFlag(flags, 'comment'),
runHooks: flags.get('run-hooks') === true,
activate: flags.get('activate') === true || flags.get('run-hooks') === true
}
'worktree create': async ({ flags, client, cwd, json }) => {
assertParentFlagsCompatible(flags)
const callerTerminalHandle =
typeof process.env.ORCA_TERMINAL_HANDLE === 'string' &&
process.env.ORCA_TERMINAL_HANDLE.length > 0
? process.env.ORCA_TERMINAL_HANDLE
: undefined
const explicitParentWorktree = await getOptionalWorktreeSelector(
flags,
'parent-worktree',
cwd,
client
)
const noParent = flags.get('no-parent') === true
let cwdParentWorktree: string | undefined
if (!explicitParentWorktree && !noParent) {
try {
// Why: agent shells can lose ORCA_TERMINAL_HANDLE while still running
// inside an Orca worktree. Cwd keeps CLI-created children nestable.
cwdParentWorktree = await resolveCurrentWorktreeSelector(cwd, client)
} catch {
cwdParentWorktree = undefined
}
}
const result = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
repo: getRequiredStringFlag(flags, 'repo'),
name: getRequiredStringFlag(flags, 'name'),
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
comment: getOptionalStringFlag(flags, 'comment'),
runHooks: flags.get('run-hooks') === true,
activate: flags.get('activate') === true || flags.get('run-hooks') === true,
parentWorktree: explicitParentWorktree,
...(cwdParentWorktree ? { cwdParentWorktree } : {}),
noParent,
callerTerminalHandle
})
printHookWarning(result.result, json)
printLineageSummary(result.result, json)
printResult(result, json, formatWorktreeShow)
},
'worktree set': async ({ flags, client, cwd, json }) => {
assertParentFlagsCompatible(flags)
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', {
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client),
displayName: getOptionalStringFlag(flags, 'display-name'),
linkedIssue: getOptionalNullableNumberFlag(flags, 'issue'),
comment: getOptionalStringFlag(flags, 'comment')
comment: getOptionalStringFlag(flags, 'comment'),
parentWorktree: await getOptionalWorktreeSelector(flags, 'parent-worktree', cwd, client),
noParent: flags.get('no-parent') === true
})
printResult(result, json, formatWorktreeShow)
},
+7 -2
View File
@@ -152,10 +152,10 @@ Common Commands:
orca environment show --environment <selector> [--json]
orca environment rm --environment <selector> [--json]
orca worktree list [--repo <selector>] [--limit <n>] [--json]
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--activate] [--json]
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
orca worktree show --worktree <selector> [--json]
orca worktree current [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--parent-worktree <selector>|--no-parent] [--json]
orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]
orca worktree ps [--limit <n>] [--json]
orca terminal list [--worktree <selector>] [--limit <n>] [--json]
@@ -178,6 +178,8 @@ Selectors:
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
--parent-worktree <selector> Parent worktree selector; create infers one from the Orca terminal or current directory by default
--no-parent Force worktree creation/update to have no parent lineage
Terminal Send Options:
--text <text> Text to send to the terminal
@@ -344,8 +346,11 @@ export function formatFlagHelp(flag: string): string {
limit: '--limit <n> Maximum number of rows to return',
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
name: '--name <name> Name for the new worktree',
'no-parent': '--no-parent Force no parent lineage',
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
pages: '--pages <n> Number of scroll pages',
'parent-worktree':
'--parent-worktree <selector> Parent worktree selector; create infers one by default',
path: '--path <path> Filesystem path to the repo',
query: '--query <text> Search text for matching refs',
ref: '--ref <ref> Base ref to persist for the repo',
+727 -5
View File
@@ -107,6 +107,7 @@ describe('orca cli worktree awareness', () => {
beforeEach(() => {
callMock.mockReset()
delete process.env.ORCA_TERMINAL_HANDLE
serveOrcaAppMock.mockReset()
getDefaultUserDataPathMock.mockClear()
addEnvironmentFromPairingCodeMock.mockReset()
@@ -246,13 +247,175 @@ describe('orca cli worktree awareness', () => {
worktree: `path:${path.resolve('/tmp/repo/feature')}`,
displayName: undefined,
linkedIssue: undefined,
comment: 'hello'
comment: 'hello',
parentWorktree: undefined,
noParent: false
})
})
it('passes parent lineage through worktree.set', async () => {
queueFixtures(
callMock,
okFixture('req_set_parent', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
parentWorktreeId: 'repo::/tmp/repo/parent',
childWorktreeIds: [],
lineage: {
worktreeId: 'repo::/tmp/repo/child',
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'repo::/tmp/repo/parent',
parentWorktreeInstanceId: 'parent-instance',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 1
}
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--parent-worktree',
'id:repo::/tmp/repo/parent',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
comment: undefined,
parentWorktree: 'id:repo::/tmp/repo/parent',
noParent: false
})
})
it('resolves current for explicit parent-worktree on set', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/parent', 'feature/parent')]),
okFixture('req_set_parent', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
parentWorktreeId: 'repo::/tmp/repo/parent',
childWorktreeIds: [],
lineage: null
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--parent-worktree',
'current',
'--json'
],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
comment: undefined,
parentWorktree: `path:${path.resolve('/tmp/repo/parent')}`,
noParent: false
})
})
it('rejects contradictory parent flags on worktree.set before resolving selectors', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'set',
'--worktree',
'id:repo::/tmp/repo/child',
'--parent-worktree',
'current',
'--no-parent',
'--json'
],
'/tmp/not-managed'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Choose either --parent-worktree or --no-parent, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects bare parent-worktree on worktree.set', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['worktree', 'set', '--worktree', 'id:repo::/tmp/repo/child', '--parent-worktree', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing required --parent-worktree'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('passes parent removal through worktree.set', async () => {
queueFixtures(
callMock,
okFixture('req_clear_parent', {
worktree: {
...buildWorktree('/tmp/repo/child', 'feature/child'),
parentWorktreeId: null,
childWorktreeIds: [],
lineage: null
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['worktree', 'set', '--worktree', 'id:repo::/tmp/repo/child', '--no-parent', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.set', {
worktree: 'id:repo::/tmp/repo/child',
displayName: undefined,
linkedIssue: undefined,
comment: undefined,
parentWorktree: undefined,
noParent: true
})
})
it('passes explicit activation through worktree.create', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1')
})
@@ -264,14 +427,247 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.create', {
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'feature',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: true
activate: true,
parentWorktree: undefined,
cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`,
noParent: false,
callerTerminalHandle: undefined
})
})
it('passes an explicit parent through worktree.create without cwd inference', async () => {
queueFixtures(
callMock,
okFixture('req_create', {
worktree: {
...buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
parentWorktreeId: 'repo-1::/tmp/repo/parent',
lineage: {
worktreeId: 'repo-1::/tmp/repo/child',
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'repo-1::/tmp/repo/parent',
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
},
lineage: {
worktreeId: 'repo-1::/tmp/repo/child',
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'repo-1::/tmp/repo/parent',
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
},
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'id:repo-1::/tmp/repo/parent',
'--json'
],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: 'id:repo-1::/tmp/repo/parent',
noParent: false,
callerTerminalHandle: undefined
})
})
it('resolves current for explicit parent-worktree on create', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/parent', 'feature/parent', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'current',
'--json'
],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: `path:${path.resolve('/tmp/repo/parent')}`,
noParent: false,
callerTerminalHandle: undefined
})
})
it('rejects contradictory parent flags on worktree.create before resolving selectors', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'current',
'--no-parent',
'--json'
],
'/tmp/not-managed'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Choose either --parent-worktree or --no-parent, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects bare parent-worktree on worktree.create', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing required --parent-worktree'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('passes no-parent through worktree.create and skips cwd inference', async () => {
queueFixtures(
callMock,
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--no-parent', '--json'],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
noParent: true,
callerTerminalHandle: undefined
})
})
it('passes caller terminal handle through worktree.create with cwd fallback', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_parent'
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledTimes(2)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`,
noParent: false,
callerTerminalHandle: 'term_parent'
})
})
@@ -464,6 +860,7 @@ describe('orca cli worktree awareness', () => {
it('opts into setup and activation when worktree.create runs hooks', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1')
})
@@ -475,14 +872,18 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('worktree.create', {
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'feature',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: true,
activate: true
activate: true,
parentWorktree: undefined,
cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`,
noParent: false,
callerTerminalHandle: undefined
})
})
@@ -521,6 +922,265 @@ describe('orca cli worktree awareness', () => {
})
})
it('forces the visible terminal path for interactive Codex startup commands', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex',
'--command',
'codex',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: false,
rendererBacked: true,
activate: false
})
})
it('keeps explicit focus semantics when forcing Codex through the renderer path', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex',
'--command',
'codex',
'--focus',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: true,
rendererBacked: true,
activate: true
})
})
it('does not force the visible terminal path for explicit Codex exec commands', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex exec'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex exec',
'--command',
'codex exec summarize',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex exec summarize',
title: 'Codex exec',
focus: false
})
})
it('does not force the visible terminal path for Codex exec commands after global options', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex exec'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex exec',
'--command',
'codex -m gpt-5 --sandbox workspace-write exec summarize',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
title: 'Codex exec',
focus: false
})
})
it('does not force the visible terminal path for Codex review commands after long options', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex review'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex review',
'--command',
'codex --model=gpt-5 --sandbox=workspace-write review',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
title: 'Codex review',
focus: false
})
})
it('does not force the visible terminal path for Codex help commands', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex help'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex help',
'--command',
'codex --help',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --help',
title: 'Codex help',
focus: false
})
})
it('forces the visible terminal path for Codex prompts after global options', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/tmp/repo/feature',
title: 'Codex prompt'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'path:/tmp/repo/feature',
'--title',
'Codex prompt',
'--command',
'codex -m gpt-5 "fix the flaky test"',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 "fix the flaky test"',
title: 'Codex prompt',
focus: false,
rendererBacked: true,
activate: false
})
})
it('uses the resolved enclosing worktree for other worktree consumers', async () => {
queueFixtures(
callMock,
@@ -598,6 +1258,30 @@ describe('orca cli worktree awareness', () => {
errSpy.mockRestore()
})
it('passes the caller terminal handle through orchestration task-create', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_creator'
callMock.mockResolvedValueOnce({
id: 'req_task_create',
ok: true,
result: {
task: { id: 'task_1', status: 'ready' }
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['orchestration', 'task-create', '--spec', 'spawn child workspace'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('orchestration.taskCreate', {
spec: 'spawn child workspace',
deps: undefined,
parent: undefined,
callerTerminalHandle: 'term_creator'
})
})
it('passes dev mode to injected orchestration dispatches', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_sender'
process.env.ORCA_USER_DATA_PATH = '/tmp/orca-dev'
@@ -696,6 +1380,44 @@ describe('orca cli worktree awareness', () => {
})
})
it('does not force remote Codex terminal creates through a local renderer path', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/srv/orca/feature',
title: 'Codex'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'id:repo-1::/srv/orca/feature',
'--command',
'codex',
'--title',
'Codex',
'--pairing-code',
'remote-runtime',
'--json'
],
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: 'codex',
title: 'Codex',
focus: false
})
})
it('does not resolve implicit remote browser targets from client cwd', async () => {
queueFixtures(
callMock,
+10 -2
View File
@@ -47,11 +47,19 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P
const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath)
const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code')
const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment')
// Why: pass `null` (not `undefined`) when remote selection is suppressed
// so the RuntimeClient default parameter does not re-activate the
// ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands
// that must run locally (environment / serve).
const client = new RuntimeClient(
undefined,
undefined,
typeof pairingCode === 'string' ? pairingCode : undefined,
typeof environmentSelector === 'string' ? environmentSelector : undefined
typeof pairingCode === 'string' ? pairingCode : ignoreRemoteSelection ? null : undefined,
typeof environmentSelector === 'string'
? environmentSelector
: ignoreRemoteSelection
? null
: undefined
)
await dispatch(parsed.commandPath, {
flags: parsed.flags,
@@ -0,0 +1,65 @@
import { mkdtempSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { createServer, type Server, type Socket } from 'net'
import { afterEach, describe, expect, it } from 'vitest'
import { RuntimeClient } from './client'
const servers = new Set<Server>()
const sockets = new Set<Socket>()
afterEach(async () => {
for (const socket of sockets) {
socket.destroy()
}
sockets.clear()
await Promise.all(
[...servers].map(
(server) =>
new Promise<void>((resolve) => {
server.close(() => resolve())
})
)
)
servers.clear()
})
describe.skipIf(process.platform === 'win32')('RuntimeClient timeout policy', () => {
it('does not crash while resolving terminal.wait defaults without params', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
const request = JSON.parse(String(data).trim()) as { id: string }
socket.write(
`${JSON.stringify({
id: request.id,
ok: true,
result: { satisfied: true },
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeFileSync(
join(userDataPath, 'orca-runtime.json'),
JSON.stringify({
runtimeId: 'runtime-1',
pid: process.pid,
transports: [{ kind: 'unix', endpoint }],
authToken: 'token',
startedAt: Date.now()
}),
'utf8'
)
const client = new RuntimeClient(userDataPath, 100)
const response = await client.call<{ satisfied: boolean }>('terminal.wait')
expect(response.result).toEqual({ satisfied: true })
})
})
+17 -8
View File
@@ -13,7 +13,7 @@ import {
RUNTIME_PROTOCOL_VERSION
} from '../../shared/protocol-version'
// Why: for `orchestration.check --wait` the caller's method-level
// Why: for long-poll methods the caller's method-level
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
// socket timeout to `timeoutMs + GRACE_MS` so the client's own idle timer
// never fires before the server-side waiter has had a chance to resolve and
@@ -83,15 +83,17 @@ export class RuntimeClient {
return response
}
// Why: centralises the per-method timeout policy. `orchestration.check` with
// `wait: true` is the only long-poll today, and its inner waiter budget
// lives in `params.timeoutMs`. We widen the client-side socket timeout to
// `timeoutMs + grace` so it doesn't fire before the server has a chance to
// resolve. Without this, a 5 min wait would still die at the 60 s default.
// Why: centralises the per-method timeout policy. Long-poll inner waiter
// budgets live in `params.timeoutMs`; widen the client-side socket timeout
// to `timeoutMs + grace` so it doesn't fire before the server has a chance
// to resolve. Without this, a 5 min wait would still die at the 60 s default.
// See design doc §3.1.
private resolveMethodTimeoutMs(method: string, params?: unknown): number {
if (method === 'orchestration.check' && isWaitingCheck(params)) {
const inner = Number((params as { timeoutMs?: unknown }).timeoutMs)
if (
(method === 'orchestration.check' && isWaitingCheck(params)) ||
method === 'terminal.wait'
) {
const inner = Number(getTimeoutMsParam(params))
if (Number.isFinite(inner) && inner > 0) {
return Math.max(inner + LONG_POLL_CLIENT_GRACE_MS, this.requestTimeoutMs)
}
@@ -225,3 +227,10 @@ function isWaitingCheck(params: unknown): boolean {
(params as { wait: unknown }).wait === true
)
}
function getTimeoutMsParam(params: unknown): unknown {
if (typeof params !== 'object' || params === null || !('timeoutMs' in params)) {
return undefined
}
return (params as { timeoutMs?: unknown }).timeoutMs
}
+86
View File
@@ -0,0 +1,86 @@
import { mkdtempSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { createServer, type Socket } from 'net'
import { afterEach, describe, expect, it } from 'vitest'
import type { RuntimeMetadata } from '../../shared/runtime-bootstrap'
import { sendRequest } from './transport'
const servers = new Set<ReturnType<typeof createServer>>()
const sockets = new Set<Socket>()
afterEach(async () => {
for (const socket of sockets) {
socket.destroy()
}
sockets.clear()
await Promise.all(
[...servers].map(
(server) =>
new Promise<void>((resolve) => {
server.close(() => resolve())
})
)
)
servers.clear()
})
// Why: these tests create Unix domain socket servers in temp directories.
// Windows does not support Unix domain sockets in the same way.
describe.skipIf(process.platform === 'win32')('runtime transport', () => {
it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
let keepalive: ReturnType<typeof setInterval> | null = null
socket.once('close', () => {
sockets.delete(socket)
if (keepalive) {
clearInterval(keepalive)
}
})
socket.once('data', (data) => {
const request = JSON.parse(String(data).trim()) as { id: string }
keepalive = setInterval(() => {
socket.write('{"_keepalive":true}\n')
}, 15)
setTimeout(() => {
if (keepalive) {
clearInterval(keepalive)
keepalive = null
}
socket.write(
`${JSON.stringify({
id: request.id,
ok: true,
result: { satisfied: true },
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
}, 90)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
const metadata: RuntimeMetadata = {
runtimeId: 'runtime-1',
pid: 123,
transports: [{ kind: 'unix', endpoint }],
authToken: 'token',
startedAt: 1
}
const response = await sendRequest<{ satisfied: boolean }>(
metadata,
'terminal.wait',
undefined,
40
)
expect(response).toMatchObject({
ok: true,
result: { satisfied: true }
})
})
})
+15 -3
View File
@@ -91,7 +91,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
path: ['worktree', 'create'],
summary: 'Create a new Orca-managed worktree',
usage:
'orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--activate] [--json]',
'orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'repo',
@@ -99,10 +99,14 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
'base-branch',
'issue',
'comment',
'parent-worktree',
'no-parent',
'run-hooks',
'activate'
],
notes: [
'By default, Orca records the new worktree as a child of the caller workspace when it can infer one from the Orca terminal or current directory.',
'Pass --parent-worktree to choose a parent explicitly, or --no-parent to force no lineage.',
'By default this creates the worktree and its first terminal without switching the active Orca workspace.',
'Repo-defined setup hooks follow the repository setup policy; pass --run-hooks to force them.',
'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.',
@@ -113,8 +117,16 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
path: ['worktree', 'set'],
summary: 'Update Orca metadata for a worktree',
usage:
'orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'display-name', 'issue', 'comment']
'orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--parent-worktree <selector>|--no-parent] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'worktree',
'display-name',
'issue',
'comment',
'parent-worktree',
'no-parent'
]
},
{
path: ['worktree', 'rm'],
+6
View File
@@ -12,6 +12,9 @@ type WorktreeFixture = {
repoId: string
path: string
branch: string
parentWorktreeId: string | null
childWorktreeIds: string[]
lineage: null
linkedIssue: null
git: {
path: string
@@ -35,6 +38,9 @@ export function buildWorktree(
repoId,
path,
branch,
parentWorktreeId: null,
childWorktreeIds: [],
lineage: null,
linkedIssue: null,
git: { path, head, branch, isBare: false, isMainWorktree: false },
displayName: '',
-1
View File
@@ -41,7 +41,6 @@ function createService(now: () => number, blocker = createBlocker()): AgentAwake
blocker,
now,
logger: {
debug: vi.fn(),
warn: vi.fn()
}
})
+2 -27
View File
@@ -15,7 +15,7 @@ type PowerSaveBlocker = {
isStarted: (id: number) => boolean
}
type Logger = Pick<Console, 'debug' | 'warn'>
type Logger = Pick<Console, 'warn'>
type AgentAwakeServiceOptions = {
blocker?: PowerSaveBlocker
@@ -60,13 +60,6 @@ export class AgentAwakeService {
this.scheduleStaleTimer()
const runningStatusCount = this.getEligibleRunningStatusCount()
const shouldBlock = this.enabled && runningStatusCount > 0
this.logger.debug('[agent-awake] refresh', {
reason,
enabled: this.enabled,
runningStatusCount,
shouldBlock,
blockerId: this.blockerId
})
if (shouldBlock) {
this.startBlocker(reason, runningStatusCount)
} else {
@@ -135,12 +128,6 @@ export class AgentAwakeService {
try {
const id = this.blocker.start('prevent-app-suspension')
this.blockerId = id
this.logger.debug('[agent-awake] started blocker', {
reason,
enabled: this.enabled,
runningStatusCount,
blockerId: id
})
this.reconcileBlocker('post-start')
} catch (err) {
this.logger.warn('[agent-awake] failed to start blocker', {
@@ -168,14 +155,7 @@ export class AgentAwakeService {
error: err
})
}
if (!this.reconcileBlocker('post-stop')) {
this.logger.debug('[agent-awake] stopped blocker', {
reason,
enabled: this.enabled,
runningStatusCount,
blockerId: id
})
}
this.reconcileBlocker('post-stop')
}
private reconcileBlocker(reason: string): boolean {
@@ -185,11 +165,6 @@ export class AgentAwakeService {
const id = this.blockerId
try {
const isStarted = this.blocker.isStarted(id)
this.logger.debug('[agent-awake] reconciled blocker', {
reason,
blockerId: id,
isStarted
})
if (!isStarted) {
this.blockerId = null
}
@@ -14,8 +14,11 @@ import { homedir, tmpdir } from 'os'
import { join } from 'path'
import { spawnSync } from 'child_process'
import {
buildWindowsAgentHookPostCommand,
createManagedCommandMatcher,
getSharedManagedScriptPath,
hookDefinitionHasManagedCommand,
removeManagedCommands,
wrapPosixHookCommand,
writeManagedScript,
writeHooksJson,
@@ -174,6 +177,71 @@ describe('createManagedCommandMatcher', () => {
})
})
describe('removeManagedCommands', () => {
const match = createManagedCommandMatcher('copilot-hook.sh')
it('removes managed direct bash/powershell/command fields', () => {
const cleaned = removeManagedCommands(
[
{
type: 'command',
bash: '/bin/sh "/Users/alice/Orca/agent-hooks/copilot-hook.sh"',
timeoutSec: 5
},
{
type: 'command',
powershell: "& 'C:\\Users\\alice\\Orca\\agent-hooks\\copilot-hook.sh'",
timeoutSec: 5
},
{
type: 'command',
command: 'echo user hook',
timeoutSec: 5
}
],
match
)
expect(cleaned).toEqual([{ type: 'command', command: 'echo user hook', timeoutSec: 5 }])
})
it('preserves unrelated nested hooks while removing managed entries', () => {
const cleaned = removeManagedCommands(
[
{
hooks: [
{ type: 'command', command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' },
{ type: 'command', command: 'echo keep me' }
]
}
],
match
)
expect(cleaned).toEqual([{ hooks: [{ type: 'command', command: 'echo keep me' }] }])
})
})
describe('hookDefinitionHasManagedCommand', () => {
it('detects managed commands in direct and nested fields', () => {
const match = createManagedCommandMatcher('copilot-hook.sh')
expect(
hookDefinitionHasManagedCommand(
{ bash: '/bin/sh "/Users/alice/Orca/agent-hooks/copilot-hook.sh"' },
match
)
).toBe(true)
expect(
hookDefinitionHasManagedCommand(
{ hooks: [{ type: 'command', command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' }] },
match
)
).toBe(true)
expect(hookDefinitionHasManagedCommand({ bash: 'echo no' }, match)).toBe(false)
})
})
describe('getSharedManagedScriptPath', () => {
it("returns ~/.orca/agent-hooks/<scriptFileName> rooted at the user's home", () => {
expect(getSharedManagedScriptPath('claude-hook.sh')).toBe(
@@ -229,6 +297,15 @@ describe('wrapPosixHookCommand', () => {
)
})
it('can scope environment variables to the guarded script invocation', () => {
const cmd = wrapPosixHookCommand('/does/not/exist.sh', {
ORCA_COPILOT_HOOK_EVENT: 'UserPromptSubmit'
})
expect(cmd).toBe(
"if [ -x '/does/not/exist.sh' ]; then ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit' /bin/sh '/does/not/exist.sh'; fi"
)
})
it.skipIf(process.platform === 'win32')(
'returns exit code 0 when the script does not exist (no-op)',
() => {
@@ -254,3 +331,16 @@ describe('wrapPosixHookCommand', () => {
}
)
})
describe('buildWindowsAgentHookPostCommand', () => {
it('forces UTF-8 for redirected hook stdin and POST bodies', () => {
const command = buildWindowsAgentHookPostCommand('codex')
expect(command).toContain('[Console]::InputEncoding=$utf8')
expect(command).toContain('[Console]::OutputEncoding=$utf8')
expect(command).toContain('$bodyBytes=$utf8.GetBytes($body)')
expect(command).toContain("-ContentType 'application/json; charset=utf-8'")
expect(command).toContain('/hook/codex')
expect(command).not.toContain("'Content-Type'='application/json'")
})
})
+56 -6
View File
@@ -11,6 +11,7 @@ import {
import { homedir } from 'os'
import { dirname, join } from 'path'
import { randomUUID } from 'crypto'
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import { grantDirAcl, isPermissionError } from '../win32-utils'
export type HookCommandConfig = {
@@ -22,6 +23,9 @@ export type HookCommandConfig = {
export type HookDefinition = {
matcher?: string
command?: string
bash?: string
powershell?: string
hooks?: HookCommandConfig[]
[key: string]: unknown
}
@@ -80,12 +84,23 @@ export function getSharedManagedScriptPath(scriptFileName: string): string {
// missing/non-executable script a silent no-op so a broken install never
// poisons the user's session. Failures inside the script itself are
// unaffected — only the missing-script case short-circuits.
export function wrapPosixHookCommand(scriptPath: string): string {
export function wrapPosixHookCommand(scriptPath: string, env: Record<string, string> = {}): string {
// Why: POSIX single-quote escape so $, `, ", and \ in scriptPath are taken
// literally — avoids a shell-injection footgun if a future caller passes an
// arbitrary path.
const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'`
return `if [ -x ${quoted} ]; then /bin/sh ${quoted}; fi`
const envPrefix = Object.entries(env)
.map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`)
.join(' ')
const invocation = envPrefix ? `${envPrefix} /bin/sh ${quoted}` : `/bin/sh ${quoted}`
return `if [ -x ${quoted} ]; then ${invocation}; fi`
}
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
// Why: Windows PowerShell 5.1 defaults redirected stdin/request bodies to the
// active code page. Hook payloads are UTF-8 JSON, so force UTF-8 on both read
// and POST or CJK prompts arrive in Orca as literal question marks.
return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/${source}') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes | Out-Null } catch {}"`
}
export function removeManagedCommands(
@@ -93,19 +108,54 @@ export function removeManagedCommands(
isManagedCommand: (command: string | undefined) => boolean
): HookDefinition[] {
return definitions.flatMap((definition) => {
if (!Array.isArray(definition.hooks)) {
const directCommandKeys = ['command', 'bash', 'powershell'] as const
const directManagedKeys = directCommandKeys.filter((key) => isManagedCommand(definition[key]))
const hasNestedHooks = Array.isArray(definition.hooks)
const hasManagedNestedHook =
hasNestedHooks && definition.hooks!.some((hook) => isManagedCommand(hook.command))
if (directManagedKeys.length === 0 && !hasManagedNestedHook) {
return [definition]
}
const filteredHooks = definition.hooks.filter((hook) => !isManagedCommand(hook.command))
if (filteredHooks.length === 0) {
const nextDefinition: HookDefinition = { ...definition }
for (const key of directManagedKeys) {
delete nextDefinition[key]
}
if (hasManagedNestedHook) {
const filteredHooks = definition.hooks!.filter((hook) => !isManagedCommand(hook.command))
if (filteredHooks.length > 0) {
nextDefinition.hooks = filteredHooks
} else {
delete nextDefinition.hooks
}
}
const hasCommandAfterCleanup =
directCommandKeys.some((key) => typeof nextDefinition[key] === 'string') ||
(Array.isArray(nextDefinition.hooks) && nextDefinition.hooks.length > 0)
if (!hasCommandAfterCleanup) {
return []
}
return [{ ...definition, hooks: filteredHooks }]
return [nextDefinition]
})
}
export function hookDefinitionHasManagedCommand(
definition: HookDefinition,
isManagedCommand: (command: string | undefined) => boolean
): boolean {
return (
isManagedCommand(definition.command) ||
isManagedCommand(definition.bash) ||
isManagedCommand(definition.powershell) ||
(Array.isArray(definition.hooks) &&
definition.hooks.some((hook) => isManagedCommand(hook.command)))
)
}
// Why: temp+rename so concurrent Orca instances writing this shared path can't
// produce a torn script that an in-flight `/bin/sh <scriptPath>` would source.
export function writeManagedScript(scriptPath: string, content: string): void {
@@ -11,6 +11,9 @@ import { CodexHookService } from '../codex/hook-service'
import { CursorHookService } from '../cursor/hook-service'
import { GeminiHookService } from '../gemini/hook-service'
import { ClaudeHookService } from '../claude/hook-service'
import { GrokHookService } from '../grok/hook-service'
import { CopilotHookService } from '../copilot/hook-service'
import { HermesHookService } from '../hermes/hook-service'
type FakeFs = {
files: Map<string, string>
@@ -124,6 +127,14 @@ describe('remote hook service installers', () => {
{
path: '/home/dev/.orca/agent-hooks/cursor-hook.sh',
install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.orca/agent-hooks/grok-hook.sh',
install: (sftp: SFTPWrapper) => new GrokHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.orca/agent-hooks/copilot-hook.sh',
install: (sftp: SFTPWrapper) => new CopilotHookService().installRemote(sftp, '/home/dev')
}
]
@@ -185,12 +196,14 @@ describe('remote hook service installers', () => {
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
})
it('installs remote Gemini and Cursor configs using their CLI-specific schemas', async () => {
it('installs remote Gemini, Cursor, and Grok configs using their CLI-specific schemas', async () => {
const gemini = createFakeSftp()
const cursor = createFakeSftp()
const grok = createFakeSftp()
await new GeminiHookService().installRemote(gemini.sftp, '/home/dev')
await new CursorHookService().installRemote(cursor.sftp, '/home/dev')
await new GrokHookService().installRemote(grok.sftp, '/home/dev')
const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
@@ -220,5 +233,89 @@ describe('remote hook service installers', () => {
expect(definition?.command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh')
expect(definition?.hooks).toBeUndefined()
}
const grokConfig = JSON.parse(grok.fs.files.get('/home/dev/.grok/hooks/orca-status.json')!) as {
hooks: Record<string, { matcher?: string; hooks?: { command: string }[] }[]>
}
for (const eventName of [
'SessionStart',
'UserPromptSubmit',
'Stop',
'SessionEnd',
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'Notification'
]) {
const definition = grokConfig.hooks[eventName]?.[0]
const command = definition?.hooks?.[0]?.command
expect(command).toContain('/home/dev/.orca/agent-hooks/grok-hook.sh')
expect(command).toMatch(/^if \[ -x /)
}
expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('*')
})
it('installs remote Copilot hooks under the user-level hooks directory', async () => {
const { sftp, fs } = createFakeSftp()
fs.dirs.add('/home/dev/.copilot')
fs.dirs.add('/home/dev/.copilot/hooks')
fs.files.set(
'/home/dev/.copilot/hooks/orca.json',
JSON.stringify({
version: 99,
disableAllHooks: true,
hooks: {}
})
)
const status = await new CopilotHookService().installRemote(sftp, '/home/dev/')
expect(status.state).toBe('installed')
expect(status.configPath).toBe('/home/dev/.copilot/hooks/orca.json')
const config = JSON.parse(fs.files.get('/home/dev/.copilot/hooks/orca.json')!) as {
version: number
disableAllHooks?: boolean
hooks: Record<string, { bash?: string; timeoutSec?: number }[]>
}
expect(config.version).toBe(1)
for (const eventName of [
'SessionStart',
'SessionEnd',
'UserPromptSubmit',
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'subagentStart',
'SubagentStop',
'PreCompact',
'Stop',
'ErrorOccurred',
'PermissionRequest',
'Notification'
]) {
const definition = config.hooks[eventName]?.[0]
expect(definition?.bash).toContain('/home/dev/.orca/agent-hooks/copilot-hook.sh')
expect(definition?.bash).toContain(`ORCA_COPILOT_HOOK_EVENT='${eventName}'`)
expect(definition?.timeoutSec).toBe(5)
}
expect(config.disableAllHooks).toBeUndefined()
expect(fs.files.get('/home/dev/.orca/agent-hooks/copilot-hook.sh')).toContain('#!/bin/sh')
expect(fs.modes.get('/home/dev/.orca/agent-hooks/copilot-hook.sh')).toBe(0o755)
})
it('installs remote Hermes plugin files and enables the plugin', async () => {
const { sftp, fs } = createFakeSftp()
const status = await new HermesHookService().installRemote(sftp, '/home/dev')
expect(status.state).toBe('installed')
expect(status.configPath).toBe('/home/dev/.hermes/config.yaml')
expect(fs.files.get('/home/dev/.hermes/plugins/orca-status/plugin.yaml')).toContain(
'pre_llm_call'
)
expect(fs.files.get('/home/dev/.hermes/plugins/orca-status/__init__.py')).toContain(
'/hook/hermes'
)
expect(fs.files.get('/home/dev/.hermes/config.yaml')).toContain('orca-status')
})
})
@@ -0,0 +1,56 @@
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
type RemoteManagedHookInstaller = readonly [
AgentHookInstallStatus['agent'],
(sftp: SFTPWrapper, remoteHome: string) => Promise<AgentHookInstallStatus>
]
const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)],
['codex', (sftp, remoteHome) => codexHookService.installRemote(sftp, remoteHome)],
['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)],
['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)],
['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)],
['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)]
]
export async function installRemoteManagedAgentHooks(
sftp: SFTPWrapper,
remoteHome: string
): Promise<AgentHookInstallStatus[]> {
const results: AgentHookInstallStatus[] = []
for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) {
try {
const result = await install(sftp, remoteHome)
results.push(result)
if (result.state === 'error') {
console.warn(
`[agent-hooks] Remote ${agent} managed hook install failed for ${result.configPath}: ${
result.detail ?? 'unknown error'
}`
)
}
} catch (error) {
// Why: remote hook installation must not block SSH workspace startup.
// A broken agent config or transient SFTP failure should degrade status
// reporting only, while terminals/filesystem/git still come online.
const detail = error instanceof Error ? error.message : String(error)
console.warn(`[agent-hooks] Remote ${agent} managed hook install threw: ${detail}`)
results.push({
agent,
state: 'error',
configPath: remoteHome,
managedHooksPresent: false,
detail
})
}
}
return results
}
+621
View File
@@ -290,6 +290,50 @@ describe('AgentHookServer listener replay', () => {
}
})
it('maps registered legacy numeric HTTP pane keys to stable pane keys', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
server.registerPaneKeyAlias('tab-1:0', PANE)
const env = server.buildPtyEnv()
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody(
{
hook_event_name: 'UserPromptSubmit',
prompt: 'legacy pane'
},
{ paneKey: 'tab-1:0' }
)
)
})
expect(response.status).toBe(204)
const listener = vi.fn()
server.setListener(listener)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: expect.objectContaining({
state: 'working',
prompt: 'legacy pane',
agentType: 'claude'
})
})
)
} finally {
server.stop()
}
})
it('tracks hook posts with an empty paneKey before dropping them', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
@@ -488,6 +532,47 @@ describe('AgentHookServer listener replay', () => {
server.stop()
}
})
it('accepts Hermes plugin hook posts on /hook/hermes', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/hermes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody({
hook_event_name: 'pre_llm_call',
user_message: 'verify Hermes route'
})
)
})
expect(response.status).toBe(204)
const listener = vi.fn()
server.setListener(listener)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
payload: expect.objectContaining({
state: 'working',
prompt: 'verify Hermes route',
agentType: 'hermes'
})
})
)
} finally {
server.stop()
}
})
})
describe('Claude hook normalization', () => {
@@ -1587,6 +1672,402 @@ describe('Pi hook normalization', () => {
})
})
describe('Copilot hook normalization', () => {
it('UserPromptSubmit maps to working and captures the prompt', () => {
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'add a migration' }),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.agentType).toBe('copilot')
expect(result?.payload.prompt).toBe('add a migration')
})
it('accepts camelCase Copilot event names from older hook configs', () => {
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({ hook_event_name: 'userPromptSubmitted', prompt: 'camel event' }),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.prompt).toBe('camel event')
})
it('infers Copilot user prompt payloads that omit hook_event_name', () => {
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({ prompt: 'raw prompt payload' }),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.prompt).toBe('raw prompt payload')
})
it('captures initialPrompt from Copilot sessionStart payloads', () => {
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({ initialPrompt: 'first prompt' }),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.prompt).toBe('first prompt')
})
it('PreToolUse stays working and surfaces tool context', () => {
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({
hook_event_name: 'PreToolUse',
toolName: 'bash',
toolInput: { command: 'pnpm test' }
}),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.toolName).toBe('bash')
expect(result?.payload.toolInput).toBe('pnpm test')
})
it('PreToolUse ask_user maps to blocked and surfaces the question', () => {
_internals.normalizeHookPayload(
'copilot',
buildBody({ prompt: 'ask me a question' }),
'production'
)
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({
toolCalls: [
{
name: 'ask_user',
args: JSON.stringify({ question: 'Which deployment target should I use?' })
}
]
}),
'production'
)
expect(result?.payload.state).toBe('blocked')
expect(result?.payload.prompt).toBe('ask me a question')
expect(result?.payload.toolName).toBe('ask_user')
expect(result?.payload.toolInput).toBe('Which deployment target should I use?')
expect(result?.payload.lastAssistantMessage).toBe('Which deployment target should I use?')
})
it('PermissionRequest stays working and preserves tool context', () => {
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({
hook_event_name: 'PermissionRequest',
tool_name: 'bash',
tool_input: { command: 'rm -rf /tmp/orca-test' }
}),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.toolName).toBe('bash')
expect(result?.payload.toolInput).toBe('rm -rf /tmp/orca-test')
})
it('surfaces lowercase Copilot file tool input previews', () => {
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({
hook_event_name: 'PreToolUse',
tool_name: 'edit',
tool_input: { path: '/repo/src/app.ts' }
}),
'production'
)
expect(result?.payload.toolName).toBe('edit')
expect(result?.payload.toolInput).toBe('/repo/src/app.ts')
})
it('Notification(permission_prompt) maps to blocked and surfaces message text', () => {
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({
hook_event_name: 'Notification',
notification_type: 'permission_prompt',
title: 'Approval needed',
message: 'Allow Bash to run?'
}),
'production'
)
expect(result?.payload.state).toBe('blocked')
expect(result?.payload.lastAssistantMessage).toBe('Allow Bash to run?')
})
it('Notification(elicitation_dialog) preserves the cached prompt', () => {
_internals.normalizeHookPayload(
'copilot',
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'deploy the app' }),
'production'
)
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({
hook_event_name: 'Notification',
notification_type: 'elicitation_dialog',
message: 'Which environment?'
}),
'production'
)
expect(result?.payload.state).toBe('blocked')
expect(result?.payload.prompt).toBe('deploy the app')
expect(result?.payload.lastAssistantMessage).toBe('Which environment?')
})
it('Notification(elicitation_dialog) accepts camelCase type and surfaces the question', () => {
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({
hook_event_name: 'Notification',
notificationType: 'elicitation_dialog',
message: 'Which deployment target should I use?'
}),
'production'
)
expect(result?.payload.state).toBe('blocked')
expect(result?.payload.lastAssistantMessage).toBe('Which deployment target should I use?')
})
it('later progress clears a prior blocked state for the same pane', () => {
_internals.normalizeHookPayload(
'copilot',
buildBody({
hook_event_name: 'PermissionRequest',
tool_name: 'bash',
tool_input: { command: 'pnpm build' }
}),
'production'
)
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({
hook_event_name: 'PostToolUse',
tool_name: 'bash',
tool_input: { command: 'pnpm build' },
tool_result: { text_result_for_llm: 'build passed' }
}),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.lastAssistantMessage).toBe('build passed')
})
it('Stop reads the final assistant message from Copilot transcript events', () => {
const tmpDir = mkdtempSync(join(tmpdir(), 'orca-copilot-transcript-'))
const transcriptPath = join(tmpDir, 'events.jsonl')
try {
const lines = [
{
type: 'assistant.message',
data: {
content: '',
toolRequests: [{ name: 'bash', arguments: { command: 'pnpm test' } }]
}
},
{
type: 'assistant.message',
data: { content: 'Done - tests pass now.', toolRequests: [] }
}
]
writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`)
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }),
'production'
)
expect(result?.payload.state).toBe('done')
expect(result?.payload.lastAssistantMessage).toBe('Done - tests pass now.')
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
})
it('unknown event name returns null', () => {
const result = _internals.normalizeHookPayload(
'copilot',
buildBody({ hook_event_name: 'somethingElse' }),
'production'
)
expect(result).toBeNull()
})
it('accepts authenticated HTTP posts on /hook/copilot', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const listener = vi.fn()
server.setListener(listener)
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody({ hook_event_name: 'Notification', notificationType: 'permission_prompt' })
)
})
expect(response.status).toBe(204)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
paneKey: PANE,
payload: expect.objectContaining({ state: 'blocked', agentType: 'copilot' })
})
)
} finally {
server.stop()
}
})
it('updates Copilot Stop with final transcript text after a non-blocking retry', async () => {
const server = new AgentHookServer()
const tmpDir = mkdtempSync(join(tmpdir(), 'orca-copilot-transcript-retry-'))
const transcriptPath = join(tmpDir, 'events.jsonl')
writeFileSync(transcriptPath, '')
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const listener = vi.fn()
server.setListener(listener)
await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody({
hook_event_name: 'PostToolUse',
tool_result: { text_result_for_llm: 'stale tool output' }
})
)
})
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath })
)
})
expect(response.status).toBe(204)
await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody({ hook_event_name: 'SessionEnd', reason: 'complete' }))
})
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
state: 'done',
lastAssistantMessage: undefined
})
})
)
writeFileSync(
transcriptPath,
`${JSON.stringify({
type: 'assistant.message',
data: { content: 'Done after transcript flush.' }
})}\n`
)
await new Promise((resolve) => setTimeout(resolve, 120))
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
state: 'done',
lastAssistantMessage: 'Done after transcript flush.'
})
})
)
} finally {
server.stop()
rmSync(tmpDir, { recursive: true, force: true })
}
})
it('updates Grok Stop with final chat-history text after a non-blocking retry', async () => {
const server = new AgentHookServer()
const tmpDir = mkdtempSync(join(tmpdir(), 'orca-grok-chat-history-retry-'))
const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf528'
const cwd = join(tmpDir, 'workspace')
const sessionDir = join(tmpDir, '.grok', 'sessions', encodeURIComponent(cwd), sessionId)
mkdirSync(sessionDir, { recursive: true })
writeFileSync(join(sessionDir, 'chat_history.jsonl'), '')
vi.stubEnv('HOME', tmpDir)
vi.stubEnv('USERPROFILE', tmpDir)
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const listener = vi.fn()
server.setListener(listener)
await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/grok`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody({ hookEventName: 'user_prompt_submit', prompt: 'hihi' }))
})
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/grok`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody({ hookEventName: 'Stop', sessionId, cwd }))
})
expect(response.status).toBe(204)
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
state: 'done',
lastAssistantMessage: undefined
})
})
)
writeFileSync(
join(sessionDir, 'chat_history.jsonl'),
`${JSON.stringify({ type: 'assistant', content: 'Hi! How can I help you today?' })}\n`
)
await new Promise((resolve) => setTimeout(resolve, 120))
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
state: 'done',
lastAssistantMessage: 'Hi! How can I help you today?'
})
})
)
} finally {
server.stop()
vi.unstubAllEnvs()
rmSync(tmpDir, { recursive: true, force: true })
}
})
})
describe('Endpoint file lifecycle', () => {
let userDataPath: string
@@ -2127,6 +2608,112 @@ describe('Last-status persistence', () => {
}
})
it('hydrates registered legacy numeric pane keys as stable pane status entries', async () => {
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
writeFileSync(
lastStatusPath(),
JSON.stringify({
version: 2,
entries: {
'tab-1:0': {
paneKey: 'tab-1:0',
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
receivedAt: recentTs(),
stateStartedAt: recentTs(-1000),
payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' }
}
}
}),
'utf8'
)
const server = new AgentHookServer()
server.registerPaneKeyAlias('tab-1:0', PANE)
await server.start({
env: 'production',
userDataPath
})
try {
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
state: 'working',
prompt: 'legacy cached',
agentType: 'claude'
})
])
} finally {
server.stop()
}
})
it('clears hydrated stable statuses when their persisted legacy alias PTY is cleared', async () => {
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
writeFileSync(
lastStatusPath(),
JSON.stringify({
version: 2,
entries: {
'tab-1:0': {
paneKey: 'tab-1:0',
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
receivedAt: recentTs(),
stateStartedAt: recentTs(-1000),
payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' }
}
}
}),
'utf8'
)
const server = new AgentHookServer()
const statusListener = vi.fn()
server.registerPaneKeyAlias('tab-1:0', PANE, 'pty-1')
server.subscribeStatusChanges(statusListener)
await server.start({
env: 'production',
userDataPath
})
try {
expect(server.getStatusSnapshot()).toHaveLength(1)
server.clearPaneKeyAliasesForPty('pty-1')
expect(server.getStatusSnapshot()).toEqual([])
expect(statusListener).toHaveBeenCalledWith([])
} finally {
server.stop()
}
})
it('does not clear a stable status when alias cleanup no longer owns that pane', () => {
const server = new AgentHookServer()
server.registerPaneKeyAlias('tab-1:0', PANE, 'old-pty')
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
server.clearPaneKeyAliasesForPty('old-pty', { shouldClearStablePaneKey: () => false })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
agentType: 'claude'
})
])
})
it('drops a hydrate entry whose tabId disagrees with the paneKey prefix', async () => {
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
writeFileSync(
@@ -2318,6 +2905,40 @@ describe('AgentHookServer ingestRemote', () => {
expect(server.getStatusSnapshot()).toEqual([])
})
it('maps registered legacy numeric relay pane keys to stable pane keys', () => {
const server = new AgentHookServer()
const payload = parseAgentStatusPayload(
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
)
if (!payload) {
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
}
const listener = vi.fn()
server.registerPaneKeyAlias('tab-1:0', PANE)
server.setListener(listener)
server.ingestRemote(
{ paneKey: 'tab-1:0', tabId: 'tab-1', worktreeId: 'wt-1', payload },
'conn-1'
)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: 'conn-1',
payload
})
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
tabId: 'tab-1',
state: 'working',
prompt: 'p'
})
])
})
it('drops remote relay envelopes whose tabId disagrees with the paneKey tab', () => {
const server = new AgentHookServer()
const payload = parseAgentStatusPayload(
+236 -23
View File
@@ -22,6 +22,7 @@ import {
clearPaneCacheState,
createHookListenerState,
getEndpointFileName,
hasPendingAgentResultText,
HOOK_REQUEST_SLOWLORIS_MS,
MAX_PANE_KEY_LEN,
normalizeHookPayload,
@@ -39,7 +40,8 @@ import {
type AgentStatusState,
normalizeAgentStatusPayload
} from '../../shared/agent-status-types'
import { parsePaneKey } from '../../shared/stable-pane-id'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
import type { LegacyPaneKeyAliasEntry } from '../../shared/types'
export type { AgentHookSource }
@@ -63,11 +65,19 @@ export type AgentHookStatusChangeEntry = {
}
type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void
type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void
type PaneKeyAliasEntry = {
stablePaneKey: string
ptyId: string | null
updatedAt: number
}
// Why: name of the on-disk cache that survives Orca restart. Lives next to
// the endpoint file in userData/agent-hooks/ so all hook-server-owned cross-
// restart artifacts stay co-located.
const LAST_STATUS_FILE_NAME = 'last-status.json'
const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5
const ASSISTANT_MESSAGE_RETRY_MS = 50
// Why: starts at 2 (not 1) because pre-merge dev iterations of this branch
// wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a
@@ -215,6 +225,8 @@ export class AgentHookServer {
// Why: hydrated last-status rows are useful UI continuity, but they are not
// evidence of live agent work in this main-process runtime.
private runtimeObservedStatusPaneKeys = new Set<string>()
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
// Why: full path to the on-disk last-status cache. Set in start() from
// userDataPath. Null when the server runs without a userDataPath (e.g.
// tests that skip the userDataPath option) — in that case, persistence is
@@ -223,6 +235,7 @@ export class AgentHookServer {
// Why: trailing-edge debounce timer. Captured per-instance so multiple
// server instances in the same process (tests) don't share state.
private statusPersistTimer: ReturnType<typeof setTimeout> | null = null
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
// Why: identity check — skip writes when the JSON-stringified contents
// exactly match the last successful disk write. Cheap protection against
// re-firing trailing timers when nothing changed.
@@ -305,6 +318,189 @@ export class AgentHookServer {
}
}
private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload {
if (payload.payload.state !== 'done' || payload.payload.lastAssistantMessage) {
this.clearAssistantMessageRetry(payload.paneKey)
}
const enriched = this.attachStatusTiming(payload)
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.onAgentStatus?.(enriched)
return enriched
}
private clearAssistantMessageRetry(paneKey: string): void {
const timer = this.assistantMessageRetryTimers.get(paneKey)
if (!timer) {
return
}
clearTimeout(timer)
this.assistantMessageRetryTimers.delete(paneKey)
}
private scheduleAssistantMessageRetry(
source: AgentHookSource,
body: unknown,
original: EnrichedAgentHookEventPayload,
attempt = 1
): void {
if (
original.payload.lastAssistantMessage ||
!hasPendingAgentResultText(source, body) ||
attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS
) {
return
}
this.clearAssistantMessageRetry(original.paneKey)
const timer = setTimeout(() => {
try {
this.assistantMessageRetryTimers.delete(original.paneKey)
const current = this.state.lastStatusByPaneKey.get(original.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
if (
!current ||
current.payload.agentType !== original.payload.agentType ||
current.payload.prompt !== original.payload.prompt ||
current.payload.lastAssistantMessage
) {
return
}
const normalized = normalizeHookPayload(this.state, source, body, this.env)
if (!normalized?.payload.lastAssistantMessage) {
this.scheduleAssistantMessageRetry(source, body, original, attempt + 1)
return
}
// Why: some agents POST Stop before their transcript/chat-history line
// is flushed. Retry from a timer so the hook request returns immediately.
this.applyNormalizedStatus(normalized)
} catch (err) {
console.error('[agent-hooks] assistant message retry failed:', err)
}
}, ASSISTANT_MESSAGE_RETRY_MS)
this.assistantMessageRetryTimers.set(original.paneKey, timer)
if (typeof timer.unref === 'function') {
timer.unref()
}
}
setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void {
this.paneKeyAliasPersistenceListener = listener
}
private getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] {
return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) =>
entry.ptyId
? [
{
ptyId: entry.ptyId,
legacyPaneKey,
stablePaneKey: entry.stablePaneKey,
updatedAt: entry.updatedAt
}
]
: []
)
}
private notifyPaneKeyAliasPersistenceListener(): void {
this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases())
}
registerPaneKeyAlias(
legacyPaneKey: string,
stablePaneKey: string,
ptyId?: string,
updatedAt = Date.now(),
options?: { overwriteExisting?: boolean }
): void {
const legacy = parseLegacyNumericPaneKey(legacyPaneKey)
const stable = parsePaneKey(stablePaneKey)
if (!legacy || !stable || legacy.tabId !== stable.tabId) {
return
}
const existing = this.legacyPaneKeyAliases.get(legacy.paneKey)
if (existing && options?.overwriteExisting === false) {
return
}
const normalizedPtyId =
typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId
const normalizedUpdatedAt =
Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now())
if (
existing &&
existing.stablePaneKey === stablePaneKey &&
existing.ptyId === (normalizedPtyId ?? null) &&
existing.updatedAt === normalizedUpdatedAt
) {
return
}
this.legacyPaneKeyAliases.set(legacy.paneKey, {
stablePaneKey,
ptyId: normalizedPtyId ?? null,
updatedAt: normalizedUpdatedAt
})
if (normalizedPtyId) {
this.notifyPaneKeyAliasPersistenceListener()
}
}
clearPaneKeyAliasesForPty(
ptyId: string,
options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean }
): void {
let aliasChanged = false
let statusChanged = false
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
if (entry.ptyId === ptyId) {
this.legacyPaneKeyAliases.delete(legacyPaneKey)
clearPaneCacheState(this.state, legacyPaneKey)
const shouldClearStablePaneKey =
options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true
if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) {
statusChanged = true
}
if (shouldClearStablePaneKey) {
// Why: after hydrate, legacy rows are stored under the stable key. If
// this PTY is later proven dead before ptyPaneKey is rebuilt, alias
// cleanup is the only path that can evict that retained status.
clearPaneCacheState(this.state, entry.stablePaneKey)
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
}
aliasChanged = true
}
}
if (aliasChanged) {
this.notifyPaneKeyAliasPersistenceListener()
}
if (statusChanged) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
}
private resolvePaneKeyAlias(paneKey: string): string {
return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey
}
private normalizeHookBodyPaneKeyAlias(body: unknown): unknown {
if (typeof body !== 'object' || body === null) {
return body
}
const record = body as Record<string, unknown>
const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : ''
const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey
if (!stablePaneKey) {
return body
}
// Why: pre-migration live shells keep posting their immutable numeric
// ORCA_PANE_KEY. The reattach path proves the UUID leaf once, then this
// bridge lets hook caches and renderer state use only the stable key.
return { ...record, paneKey: stablePaneKey }
}
/** Ingest a payload that arrived over the relay JSON-RPC channel rather
* than the local HTTP server. `connectionId` is the SshChannelMultiplexer
* identity Orca holds (the wire envelope carries connectionId: null and
@@ -341,7 +537,7 @@ export class AgentHookServer {
// Why: match the listener's HTTP path — `normalizeHookPayload` trims and
// length-caps paneKey before caching, so the cache key here must follow
// the same rule or remote-vs-local events for the same pane would diverge.
const paneKey = envelope.paneKey.trim()
const paneKey = this.resolvePaneKeyAlias(envelope.paneKey.trim())
const parsedPaneKey = parsePaneKey(paneKey)
if (paneKey.length === 0) {
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
@@ -398,12 +594,7 @@ export class AgentHookServer {
connectionId: trimmedConnectionId,
payload: normalizedPayload
}
const enriched = this.attachStatusTiming(event)
this.runtimeObservedStatusPaneKeys.add(paneKey)
this.state.lastStatusByPaneKey.set(paneKey, enriched)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.onAgentStatus?.(enriched)
this.applyNormalizedStatus(event)
}
async start(options?: { env?: string; userDataPath?: string }): Promise<void> {
@@ -460,14 +651,11 @@ export class AgentHookServer {
}
trackEmptyPaneKeyHook(body)
const normalized = normalizeHookPayload(this.state, source, body, this.env)
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env)
if (normalized) {
const enriched = this.attachStatusTiming(normalized)
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.onAgentStatus?.(enriched)
const enriched = this.applyNormalizedStatus(normalized)
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
}
res.writeHead(204)
@@ -518,6 +706,10 @@ export class AgentHookServer {
this.token = ''
this.env = 'production'
this.onAgentStatus = null
for (const timer of this.assistantMessageRetryTimers.values()) {
clearTimeout(timer)
}
this.assistantMessageRetryTimers.clear()
// Why: intentionally do NOT delete the endpoint file on stop(). A stale
// file points at a dead port, which matches the fail-open policy. Unlink
// would introduce a TOCTOU race vs. a concurrent Orca instance.
@@ -527,6 +719,7 @@ export class AgentHookServer {
this.lastStatusFilePath = null
this.lastWrittenJson = null
this.runtimeObservedStatusPaneKeys.clear()
this.legacyPaneKeyAliases.clear()
clearAllListenerCaches(this.state)
this.notifyStatusChangeListeners()
}
@@ -539,24 +732,39 @@ export class AgentHookServer {
* lands. clearPaneState (which wipes all three caches) is the right shape
* only for PTY-teardown. */
dropStatusEntry(paneKey: string): void {
if (!this.state.lastStatusByPaneKey.has(paneKey)) {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
if (!this.state.lastStatusByPaneKey.has(resolvedPaneKey)) {
return
}
this.state.lastStatusByPaneKey.delete(paneKey)
this.runtimeObservedStatusPaneKeys.delete(paneKey)
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
this.clearAssistantMessageRetry(resolvedPaneKey)
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
clearPaneState(paneKey: string): void {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
// Why: only schedule a write when we actually evicted a status entry —
// dropping prompt/tool caches for a pane that never produced a hook
// event does not change the on-disk file, and skipping the write avoids
// re-stat'ing on every dead-pane teardown.
const hadStatus = this.state.lastStatusByPaneKey.has(paneKey)
clearPaneCacheState(this.state, paneKey)
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
this.clearAssistantMessageRetry(resolvedPaneKey)
clearPaneCacheState(this.state, resolvedPaneKey)
let clearedAlias = false
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
if (stablePaneKey.stablePaneKey === resolvedPaneKey) {
this.legacyPaneKeyAliases.delete(legacyPaneKey)
clearPaneCacheState(this.state, legacyPaneKey)
clearedAlias = true
}
}
if (clearedAlias) {
this.notifyPaneKeyAliasPersistenceListener()
}
if (hadStatus) {
this.runtimeObservedStatusPaneKeys.delete(paneKey)
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
@@ -655,9 +863,14 @@ export class AgentHookServer {
// tick.
const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS
for (const [paneKey, rawEntry] of Object.entries(entries)) {
const entry = sanitizeHydratedEntry(paneKey, rawEntry)
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
const rawResolvedEntry =
resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null
? rawEntry
: { ...(rawEntry as Record<string, unknown>), paneKey: resolvedPaneKey }
const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry)
if (entry && entry.receivedAt >= ttlCutoff) {
this.state.lastStatusByPaneKey.set(paneKey, entry)
this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry)
hydrated += 1
} else {
dropped += 1
+53 -1
View File
@@ -25,7 +25,7 @@ vi.mock('node:os', async () => {
}
})
const { markCopilotFolderTrusted, markCursorWorkspaceTrusted } =
const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted } =
await import('./agent-trust-presets')
beforeEach(() => {
@@ -111,3 +111,55 @@ describe('markCopilotFolderTrusted', () => {
}
})
})
describe('markCodexProjectTrusted', () => {
it('writes ~/.codex/config.toml with the project marked trusted', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-'))
try {
const realpath = realpathSync(workspace)
markCodexProjectTrusted(workspace)
const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
expect(existsSync(configPath)).toBe(true)
const written = readFileSync(configPath, 'utf-8')
expect(written).toContain(`[projects."${escapeTomlBasicString(realpath)}"]`)
expect(written).toContain('trust_level = "trusted"')
} finally {
rmSync(workspace, { recursive: true, force: true })
}
})
it('preserves existing config keys and updates an existing project block', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-'))
const realpath = realpathSync(workspace)
try {
const codexDir = join(testState.fakeHomeDir, '.codex')
mkdirSync(codexDir, { recursive: true })
writeFileSync(
join(codexDir, 'config.toml'),
[
'model = "gpt-5.5"',
'',
`[projects."${escapeTomlBasicString(realpath)}"]`,
'notes = "keep"',
'trust_level = "untrusted"',
''
].join('\n'),
'utf-8'
)
markCodexProjectTrusted(workspace)
const written = readFileSync(join(codexDir, 'config.toml'), 'utf-8')
expect(written).toContain('model = "gpt-5.5"')
expect(written).toContain('notes = "keep"')
expect(written).toContain('trust_level = "trusted"')
expect(written).not.toContain('trust_level = "untrusted"')
} finally {
rmSync(workspace, { recursive: true, force: true })
}
})
})
function escapeTomlBasicString(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
}
+20 -2
View File
@@ -2,10 +2,12 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { writeFileAtomically } from './codex-accounts/fs-utils'
import { upsertProjectTrustLevel } from './codex/config-toml-trust'
/**
* Pre-mark a workspace as trusted for cursor-agent / GitHub Copilot CLI so
* the agent's "Do you trust this folder?" menu does not fire on first launch.
* Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or
* Codex so the agent's "Do you trust this folder?" menu does not fire on
* first launch.
*
* Why: Orca's "drop URL into agent input as a draft" flow injects the URL
* via bracketed-paste once the TUI is up. If the trust menu intercepts the
@@ -18,6 +20,8 @@ import { writeFileAtomically } from './codex-accounts/fs-utils'
* Side note: a `--trust`-style CLI flag exists in cursor-agent but only
* applies in `--print/headless` mode (per its --help). Copilot has no
* documented flag at all (verified against @github/copilot 1.0.32 bundle).
* Codex's `--dangerously-bypass-approvals-and-sandbox` would also change
* approval/sandbox policy, so it is not equivalent to "trust this project".
*/
/**
@@ -93,6 +97,20 @@ export function markCopilotFolderTrusted(workspacePath: string): void {
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`)
}
/**
* Codex stores project trust in ~/.codex/config.toml under:
* [projects."<realpath>"]
* trust_level = "trusted"
*
* Verified against codex-rs/tui/src/onboarding/trust_directory.rs and
* codex-rs/core/src/config/config_tests.rs in the Codex CLI source.
*/
export function markCodexProjectTrusted(workspacePath: string): void {
const absPath = canonicalize(workspacePath)
const configPath = join(homedir(), '.codex', 'config.toml')
upsertProjectTrustLevel(configPath, absPath, 'trusted')
}
function canonicalize(p: string): string {
// Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but
// both Cursor and Copilot's trust comparators run realpath() before the
+93 -3
View File
@@ -1,4 +1,9 @@
import type { ExternalAutomationJob } from '../../shared/automations-types'
import type {
ExternalAutomationJob,
ExternalAutomationProvider,
ExternalAutomationRun,
ExternalAutomationRunStatus
} from '../../shared/automations-types'
type ExternalJobRecord = Record<string, unknown>
@@ -43,6 +48,52 @@ function isoFromMs(value: unknown): string | null {
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
function asExternalRunStatus(value: unknown): ExternalAutomationRunStatus {
return value === 'completed' || value === 'failed' || value === 'unknown' ? value : 'unknown'
}
function mapExternalRuns({
managerId,
provider,
jobId,
rawRuns
}: {
managerId: string
provider: ExternalAutomationProvider
jobId: string
rawRuns: unknown
}): ExternalAutomationRun[] {
if (!Array.isArray(rawRuns)) {
return []
}
return rawRuns
.filter(isRecord)
.map((run, index) => {
const runAt = asString(run.run_at) ?? asString(run.runAt)
const id = asString(run.id) ?? `${jobId}:${runAt ?? index}`
return {
id,
managerId,
provider,
jobId: asString(run.job_id) ?? asString(run.jobId) ?? jobId,
runAt,
status: asExternalRunStatus(run.status),
outputPreview: asString(run.output_preview) ?? asString(run.outputPreview),
outputContent: asString(run.output_content) ?? asString(run.outputContent),
error: asString(run.error),
outputPath: asString(run.output_path) ?? asString(run.outputPath)
}
})
.sort((a, b) => {
const aTime = a.runAt ? Date.parse(a.runAt) : Number.NaN
const bTime = b.runAt ? Date.parse(b.runAt) : Number.NaN
if (Number.isFinite(aTime) && Number.isFinite(bTime)) {
return bTime - aTime
}
return b.id.localeCompare(a.id)
})
}
function hermesScheduleDisplay(job: ExternalJobRecord): string {
const direct = asString(job.schedule_display)
if (direct) {
@@ -61,6 +112,19 @@ function hermesScheduleDisplay(job: ExternalJobRecord): string {
return asString(schedule) ?? '?'
}
function hermesRawSchedule(job: ExternalJobRecord): string | null {
const schedule = job.schedule
if (isRecord(schedule)) {
return (
asString(schedule.expr) ??
asString(schedule.value) ??
asString(schedule.display) ??
asString(schedule.run_at)
)
}
return asString(schedule) ?? asString(job.schedule_display)
}
function hermesPromptPreview(job: ExternalJobRecord): string {
const prompt = asString(job.prompt) ?? ''
if (prompt) {
@@ -94,6 +158,14 @@ function openClawScheduleDisplay(job: ExternalJobRecord): string {
return kind ?? '?'
}
function openClawRawSchedule(job: ExternalJobRecord): string | null {
const schedule = job.schedule
if (!isRecord(schedule)) {
return asString(schedule)
}
return asString(schedule.expr) ?? asString(schedule.cron) ?? null
}
function openClawPromptPreview(job: ExternalJobRecord): string {
const payload = job.payload
if (!isRecord(payload)) {
@@ -117,14 +189,23 @@ export function mapHermesJobs(managerId: string, rawJobs: unknown): ExternalAuto
provider: 'hermes',
name: (asString(job.name) ?? preview) || id,
schedule: hermesScheduleDisplay(job),
rawSchedule: hermesRawSchedule(job),
enabled,
state: asString(job.state) ?? (enabled ? 'scheduled' : 'paused'),
prompt: asString(job.prompt),
promptPreview: preview,
nextRunAt: asString(job.next_run_at),
lastRunAt: asString(job.last_run_at),
lastStatus: asString(job.last_status),
lastError: asString(job.last_error) ?? asString(job.last_delivery_error),
workdir: asString(job.workdir)
workdir: asString(job.workdir),
runCount: asNumber(job.run_count) ?? (Array.isArray(job.runs) ? job.runs.length : 0),
runs: mapExternalRuns({
managerId,
provider: 'hermes',
jobId: id,
rawRuns: job.runs
})
}
})
}
@@ -147,18 +228,27 @@ export function mapOpenClawJobs(managerId: string, rawJobs: unknown): ExternalAu
provider: 'openclaw',
name: (asString(job.name) ?? preview) || id,
schedule: openClawScheduleDisplay(job),
rawSchedule: openClawRawSchedule(job),
enabled,
state: !enabled
? 'disabled'
: asNumber(state.runningAtMs)
? 'running'
: (lastStatus ?? 'idle'),
prompt: openClawPromptPreview(job) || null,
promptPreview: preview,
nextRunAt: isoFromMs(state.nextRunAtMs),
lastRunAt: isoFromMs(state.lastRunAtMs),
lastStatus,
lastError: asString(state.lastError) ?? asString(state.lastDeliveryError),
workdir: null
workdir: null,
runCount: asNumber(job.run_count) ?? (Array.isArray(job.runs) ? job.runs.length : 0),
runs: mapExternalRuns({
managerId,
provider: 'openclaw',
jobId: id,
rawRuns: job.runs
})
}
})
}
+193 -2
View File
@@ -1,6 +1,13 @@
/* eslint-disable max-lines -- Why: external automation mapping and lifecycle IPC share fixtures. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { runExternalAutomationAction } from './external-manager'
import {
createExternalAutomation,
listExternalAutomationRuns,
runExternalAutomationAction,
updateExternalAutomation
} from './external-manager'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
import { getActiveMultiplexer } from '../ipc/ssh'
const execFileMock = vi.hoisted(() =>
vi.fn((...args: unknown[]) => {
@@ -20,6 +27,7 @@ vi.mock('../ipc/ssh', () => ({
beforeEach(() => {
execFileMock.mockClear()
vi.mocked(getActiveMultiplexer).mockReset()
})
describe('mapHermesJobs', () => {
@@ -46,14 +54,72 @@ describe('mapHermesJobs', () => {
provider: 'hermes',
name: 'Nightly audit',
schedule: '0 9 * * 1-5',
rawSchedule: '0 9 * * 1-5',
enabled: true,
state: 'scheduled',
prompt: 'Audit the repo for risky dependency changes',
promptPreview: 'Audit the repo for risky dependency changes',
nextRunAt: '2026-05-16T09:00:00Z',
lastRunAt: '2026-05-15T09:00:00Z',
lastStatus: 'ok',
lastError: null,
workdir: '/repo'
workdir: '/repo',
runCount: 0,
runs: []
}
])
})
it('normalizes Hermes output files into run history', () => {
const jobs = mapHermesJobs('hermes:local', [
{
id: 'job-1',
name: 'Nightly audit',
schedule_display: '0 9 * * 1-5',
runs: [
{
id: 'job-1:2026-05-15_09-00-00.md',
job_id: 'job-1',
run_at: '2026-05-15T09:00:00',
status: 'completed',
output_preview: 'No risky dependency changes.',
output_path: '/home/me/.hermes/cron/output/job-1/2026-05-15_09-00-00.md'
},
{
id: 'job-1:2026-05-14_09-00-00.md',
job_id: 'job-1',
run_at: '2026-05-14T09:00:00',
status: 'failed',
error: 'RuntimeError: missing key'
}
]
}
])
expect(jobs[0].runs).toEqual([
{
id: 'job-1:2026-05-15_09-00-00.md',
managerId: 'hermes:local',
provider: 'hermes',
jobId: 'job-1',
runAt: '2026-05-15T09:00:00',
status: 'completed',
outputPreview: 'No risky dependency changes.',
outputContent: null,
error: null,
outputPath: '/home/me/.hermes/cron/output/job-1/2026-05-15_09-00-00.md'
},
{
id: 'job-1:2026-05-14_09-00-00.md',
managerId: 'hermes:local',
provider: 'hermes',
jobId: 'job-1',
runAt: '2026-05-14T09:00:00',
status: 'failed',
outputPreview: null,
outputContent: null,
error: 'RuntimeError: missing key',
outputPath: null
}
])
})
@@ -78,11 +144,77 @@ describe('mapHermesJobs', () => {
enabled: false,
state: 'paused',
promptPreview: 'Script: disk-check.sh',
prompt: null,
rawSchedule: 'every 30m',
lastError: 'home channel missing'
})
})
})
describe('createExternalAutomation', () => {
it('creates local Hermes cron jobs through the CLI', async () => {
await createExternalAutomation({
managerId: 'hermes:local',
provider: 'hermes',
target: { type: 'local' },
name: 'Nightly audit',
prompt: 'Audit the repo',
schedule: '0 9 * * 1-5',
workdir: '/repo'
})
expect(execFileMock).toHaveBeenCalledWith(
'hermes',
[
'cron',
'create',
'0 9 * * 1-5',
'Audit the repo',
'--name',
'Nightly audit',
'--deliver',
'local',
'--workdir',
'/repo'
],
{ encoding: 'utf-8' },
expect.any(Function)
)
})
it('updates local Hermes cron jobs through the CLI', async () => {
await updateExternalAutomation({
managerId: 'hermes:local',
provider: 'hermes',
target: { type: 'local' },
jobId: 'job-1',
name: 'Nightly audit',
prompt: 'Audit the repo',
schedule: '0 10 * * 1-5',
workdir: '/repo'
})
expect(execFileMock).toHaveBeenCalledWith(
'hermes',
[
'cron',
'edit',
'job-1',
'--schedule',
'0 10 * * 1-5',
'--prompt',
'Audit the repo',
'--name',
'Nightly audit',
'--workdir',
'/repo'
],
{ encoding: 'utf-8' },
expect.any(Function)
)
})
})
describe('runExternalAutomationAction', () => {
it('runs local Hermes lifecycle actions through the CLI', async () => {
await runExternalAutomationAction({
@@ -133,6 +265,63 @@ describe('runExternalAutomationAction', () => {
})
})
describe('listExternalAutomationRuns', () => {
it('requests paginated Hermes runs from the remote relay', async () => {
const request = vi.fn().mockResolvedValue({
total: 42,
runs: [
{
id: 'job-1:2026-05-15_09-00-00.md',
job_id: 'job-1',
run_at: '2026-05-15T09:00:00',
status: 'completed',
output_preview: 'No risky dependency changes.'
}
]
})
vi.mocked(getActiveMultiplexer).mockReturnValue({
isDisposed: () => false,
request
} as unknown as ReturnType<typeof getActiveMultiplexer>)
await expect(
listExternalAutomationRuns({
managerId: 'hermes:ssh:ssh-1',
provider: 'hermes',
target: { type: 'ssh', connectionId: 'ssh-1' },
jobId: 'job-1',
page: 2,
pageSize: 10
})
).resolves.toMatchObject({
managerId: 'hermes:ssh:ssh-1',
provider: 'hermes',
jobId: 'job-1',
page: 2,
pageSize: 10,
total: 42,
runs: [
{
id: 'job-1:2026-05-15_09-00-00.md',
managerId: 'hermes:ssh:ssh-1',
provider: 'hermes',
jobId: 'job-1',
runAt: '2026-05-15T09:00:00',
status: 'completed',
outputPreview: 'No risky dependency changes.'
}
]
})
expect(request).toHaveBeenCalledWith('externalAutomations.runs', {
provider: 'hermes',
jobId: 'job-1',
page: 2,
pageSize: 10
})
})
})
describe('mapOpenClawJobs', () => {
it('normalizes OpenClaw cron jobs into external automation rows', () => {
const jobs = mapOpenClawJobs('openclaw:local', {
@@ -159,8 +348,10 @@ describe('mapOpenClawJobs', () => {
provider: 'openclaw',
name: 'Morning report',
schedule: 'cron 0 9 * * * @ America/Phoenix',
rawSchedule: '0 9 * * *',
enabled: true,
state: 'ok',
prompt: 'Summarize overnight alerts',
promptPreview: 'Summarize overnight alerts',
nextRunAt: '2026-05-16T16:00:00.000Z',
lastRunAt: '2026-05-15T16:00:00.000Z',
+221 -3
View File
@@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: external automation discovery, pagination,
* and lifecycle routing share provider/target validation and remote relay fallbacks. */
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
@@ -7,16 +9,26 @@ import { promisify } from 'util'
import type {
ExternalAutomationAction,
ExternalAutomationActionInput,
ExternalAutomationCreateInput,
ExternalAutomationManager,
ExternalAutomationProvider
ExternalAutomationProvider,
ExternalAutomationRunsInput,
ExternalAutomationRunsPage,
ExternalAutomationUpdateInput
} from '../../shared/automations-types'
import type { SshTarget } from '../../shared/ssh-types'
import type { Store } from '../persistence'
import { getActiveMultiplexer } from '../ipc/ssh'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
import {
clearHermesCronOutputRunCountCache,
readHermesCronOutputRunsPage
} from './hermes-cron-output'
const execFileAsync = promisify(execFile)
const HERMES_JOBS_FILE = join(homedir(), '.hermes', 'cron', 'jobs.json')
const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
const HERMES_CRON_DIR = join(HERMES_HOME, 'cron')
const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json')
const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json')
const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
@@ -40,7 +52,28 @@ async function readLocalHermesJobs(): Promise<unknown[]> {
}
const content = await readFile(HERMES_JOBS_FILE, 'utf-8')
const parsed = JSON.parse(content) as unknown
return Array.isArray(parsed) ? parsed : []
const jobs = Array.isArray(parsed)
? parsed
: isRecord(parsed) && Array.isArray(parsed.jobs)
? parsed.jobs
: []
return Promise.all(
jobs.map(async (job) => {
if (!isRecord(job)) {
return job
}
const jobId = typeof job.id === 'string' ? job.id : null
if (!jobId) {
return job
}
const runsPage = await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: 0 })
return {
...job,
run_count: runsPage.total,
runs: []
}
})
)
}
async function readLocalOpenClawJobs(): Promise<unknown[]> {
@@ -69,6 +102,7 @@ async function listLocalHermesManager(): Promise<ExternalAutomationManager | nul
id: managerId,
provider: 'hermes',
label: 'Hermes on this computer',
targetLabel: 'this computer',
target: { type: 'local' },
status: readError ? 'unavailable' : 'available',
error:
@@ -96,6 +130,7 @@ async function listLocalOpenClawManager(): Promise<ExternalAutomationManager | n
id: managerId,
provider: 'openclaw',
label: 'OpenClaw on this computer',
targetLabel: 'this computer',
target: { type: 'local' },
status: readError ? 'unavailable' : 'available',
error:
@@ -134,6 +169,7 @@ async function listRemoteManager(
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
targetLabel: target.label,
target: { type: 'ssh', connectionId: target.id },
status: 'unavailable',
error: 'SSH target is not connected.',
@@ -155,6 +191,7 @@ async function listRemoteManager(
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
targetLabel: target.label,
target: { type: 'ssh', connectionId: target.id },
status: readError ? 'unavailable' : 'available',
error:
@@ -170,6 +207,7 @@ async function listRemoteManager(
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
targetLabel: target.label,
target: { type: 'ssh', connectionId: target.id },
status: 'unavailable',
error: remoteRelayErrorMessage(error),
@@ -198,6 +236,64 @@ export async function listExternalAutomationManagers(
]
}
export async function listExternalAutomationRuns(
input: ExternalAutomationRunsInput
): Promise<ExternalAutomationRunsPage> {
if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) {
throw new Error('Invalid external automation job ID.')
}
const page = Number.isFinite(input.page) ? Math.max(1, Math.floor(input.page)) : 1
const pageSize = Number.isFinite(input.pageSize)
? Math.min(100, Math.max(1, Math.floor(input.pageSize)))
: 25
if (input.provider !== 'hermes') {
return {
managerId: input.managerId,
provider: input.provider,
target: input.target,
jobId: input.jobId,
page,
pageSize,
total: 0,
runs: []
}
}
if (input.target.type === 'local') {
const result = await readHermesCronOutputRunsPage(input.jobId, { page, pageSize })
return {
managerId: input.managerId,
provider: input.provider,
target: input.target,
jobId: input.jobId,
page,
pageSize,
total: result.total,
runs: mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs }])[0]?.runs ?? []
}
}
const mux = getActiveMultiplexer(input.target.connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`SSH target "${input.target.connectionId}" is not connected.`)
}
const result = (await mux.request('externalAutomations.runs', {
provider: input.provider,
jobId: input.jobId,
page,
pageSize
})) as { total?: number; runs?: unknown[] }
return {
managerId: input.managerId,
provider: input.provider,
target: input.target,
jobId: input.jobId,
page,
pageSize,
total: typeof result.total === 'number' && Number.isFinite(result.total) ? result.total : 0,
runs:
mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs ?? [] }])[0]?.runs ?? []
}
}
function hermesCommandForAction(action: ExternalAutomationAction): string {
switch (action) {
case 'pause':
@@ -224,6 +320,125 @@ function openClawCommandForAction(action: ExternalAutomationAction): string {
}
}
function normalizeHermesCronMutationInput(input: ExternalAutomationCreateInput): {
name: string
prompt: string
schedule: string
workdir: string | null
} {
if (input.provider !== 'hermes') {
throw new Error('Only Hermes cron creation and editing are supported.')
}
const name = input.name.trim()
const prompt = input.prompt.trim()
const schedule = input.schedule.trim()
const workdir = input.workdir?.trim() || null
if (!prompt) {
throw new Error('Hermes cron requires a prompt.')
}
if (!schedule) {
throw new Error('Hermes cron requires a schedule.')
}
return {
name: name || prompt.slice(0, 50).trim() || 'Hermes cron',
prompt,
schedule,
workdir
}
}
function hermesCronCreateArgs(input: {
name: string
prompt: string
schedule: string
workdir: string | null
}): string[] {
const args = [
'cron',
'create',
input.schedule,
input.prompt,
'--name',
input.name,
'--deliver',
'local'
]
if (input.workdir) {
args.push('--workdir', input.workdir)
}
return args
}
function hermesCronEditArgs(
jobId: string,
input: {
name: string
prompt: string
schedule: string
workdir: string | null
}
): string[] {
const args = [
'cron',
'edit',
jobId,
'--schedule',
input.schedule,
'--prompt',
input.prompt,
'--name',
input.name
]
if (input.workdir) {
args.push('--workdir', input.workdir)
}
return args
}
export async function createExternalAutomation(
input: ExternalAutomationCreateInput
): Promise<void> {
const normalized = normalizeHermesCronMutationInput(input)
if (input.target.type === 'local') {
await execFileAsync('hermes', hermesCronCreateArgs(normalized), { encoding: 'utf-8' })
clearHermesCronOutputRunCountCache()
return
}
const mux = getActiveMultiplexer(input.target.connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`SSH target "${input.target.connectionId}" is not connected.`)
}
await mux.request('externalAutomations.create', {
provider: input.provider,
...normalized
})
}
export async function updateExternalAutomation(
input: ExternalAutomationUpdateInput
): Promise<void> {
if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) {
throw new Error('Invalid external automation job ID.')
}
const normalized = normalizeHermesCronMutationInput(input)
if (input.target.type === 'local') {
await execFileAsync('hermes', hermesCronEditArgs(input.jobId, normalized), {
encoding: 'utf-8'
})
clearHermesCronOutputRunCountCache(input.jobId)
return
}
const mux = getActiveMultiplexer(input.target.connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`SSH target "${input.target.connectionId}" is not connected.`)
}
await mux.request('externalAutomations.update', {
provider: input.provider,
jobId: input.jobId,
...normalized
})
}
export async function runExternalAutomationAction(
input: ExternalAutomationActionInput
): Promise<void> {
@@ -236,6 +451,9 @@ export async function runExternalAutomationAction(
: openClawCommandForAction(input.action)
if (input.target.type === 'local') {
await execFileAsync(input.provider, ['cron', command, input.jobId], { encoding: 'utf-8' })
if (input.provider === 'hermes') {
clearHermesCronOutputRunCountCache(input.jobId)
}
return
}
const mux = getActiveMultiplexer(input.target.connectionId)
@@ -0,0 +1,232 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
let hermesHome: string | null = null
let extraTempDirs: string[] = []
const previousHermesHome = process.env.HERMES_HOME
const fakeDbRows = vi.hoisted(() => ({
sessions: [] as Record<string, unknown>[],
messages: [] as Record<string, unknown>[]
}))
const fakePrepareSqls = vi.hoisted(() => [] as string[])
const fakeDatabase = vi.hoisted(() =>
vi.fn(function FakeDatabase() {
return {
prepare: vi.fn((sql: string) => {
fakePrepareSqls.push(sql)
return {
get: vi.fn((param: string) =>
sql.includes('FROM sessions')
? fakeDbRows.sessions.find((session) => session.id === param)
: undefined
),
all: vi.fn((param: string) =>
sql.includes('FROM sessions')
? fakeDbRows.sessions
: fakeDbRows.messages.filter((message) => message.session_id === param)
)
}
}),
close: vi.fn()
}
})
)
vi.mock('better-sqlite3', () => ({
default: fakeDatabase
}))
async function loadReader() {
vi.resetModules()
return import('./hermes-cron-output')
}
async function createHermesHome(): Promise<string> {
hermesHome = await mkdtemp(join(tmpdir(), 'orca-hermes-output-'))
process.env.HERMES_HOME = hermesHome
return hermesHome
}
beforeEach(() => {
fakeDbRows.sessions = []
fakeDbRows.messages = []
fakePrepareSqls.length = 0
fakeDatabase.mockClear()
})
afterEach(async () => {
if (previousHermesHome === undefined) {
delete process.env.HERMES_HOME
} else {
process.env.HERMES_HOME = previousHermesHome
}
if (hermesHome) {
await rm(hermesHome, { recursive: true, force: true })
hermesHome = null
}
await Promise.all(extraTempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
extraTempDirs = []
vi.resetModules()
})
describe('readHermesCronOutputRunsPage', () => {
it('attaches the state.db transcript to the matching Hermes output file run', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
const scriptLogPath = join(home, 'Automations', 'x-automation', 'logs', 'x-monitor.log')
await mkdir(outputDir, { recursive: true })
await mkdir(dirname(scriptLogPath), { recursive: true })
await writeFile(scriptLogPath, 'raw script log line\nsecond raw script log line\n', 'utf-8')
await writeFile(
join(outputDir, '2026-05-15_09-02-00.md'),
`# Cron Job: Monitor automation
**Job ID:** job-1
## Response
Success — ./run-x-monitor.sh completed with exit code 0.
Latest log path: ${scriptLogPath}
Run summary: monitor automation completed successfully.
`,
'utf-8'
)
await writeFile(join(home, 'state.db'), '', 'utf-8')
fakeDbRows.sessions = [
{
id: 'cron_job-1_20260515_090000',
title: 'Monitor automation',
started_at: Date.UTC(2026, 4, 15, 9, 0, 0) / 1000,
ended_at: Date.UTC(2026, 4, 15, 9, 1, 58) / 1000,
model: 'gpt-5',
message_count: 2,
input_tokens: 100,
output_tokens: 50
}
]
fakeDbRows.messages = [
{
session_id: 'cron_job-1_20260515_090000',
role: 'tool',
content: 'full command output line',
tool_name: 'terminal',
timestamp: Date.UTC(2026, 4, 15, 9, 1, 0) / 1000
}
]
const { readHermesCronOutputRunsPage } = await loadReader()
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 })
expect(page.total).toBe(1)
expect(page.runs[0]).toMatchObject({
id: 'job-1:2026-05-15_09-02-00.md',
status: 'completed',
output_path: join(outputDir, '2026-05-15_09-02-00.md')
})
expect((page.runs[0] as { output_content?: string }).output_content).toContain(
'monitor automation completed successfully.'
)
expect((page.runs[0] as { output_content?: string }).output_content).toContain(
'## Latest log file'
)
expect((page.runs[0] as { output_content?: string }).output_content).toContain(
'raw script log line'
)
expect((page.runs[0] as { output_content?: string }).output_content).toContain(
'## Full session log'
)
expect((page.runs[0] as { output_content?: string }).output_content).toContain(
'full command output line'
)
})
it('does not hydrate referenced logs outside Hermes home', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
const outsideDir = await mkdtemp(join(tmpdir(), 'orca-hermes-outside-'))
extraTempDirs.push(outsideDir)
const outsideLogPath = join(outsideDir, 'secret.log')
await mkdir(outputDir, { recursive: true })
await writeFile(outsideLogPath, 'do not expose this\n', 'utf-8')
await writeFile(
join(outputDir, '2026-05-15_09-02-00.md'),
`# Cron Job: Monitor automation
## Response
Latest log path: ${outsideLogPath}
Run summary: monitor automation completed successfully.
`,
'utf-8'
)
const { readHermesCronOutputRunsPage } = await loadReader()
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 })
expect((page.runs[0] as { output_content?: string }).output_content).not.toContain(
'## Latest log file'
)
expect((page.runs[0] as { output_content?: string }).output_content).not.toContain(
'do not expose this'
)
})
it('uses a count-only path when page size is zero', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
await mkdir(outputDir, { recursive: true })
await writeFile(
join(outputDir, '2026-05-15_09-02-00.md'),
'this content should not be read for count-only listing',
'utf-8'
)
await writeFile(join(home, 'state.db'), '', 'utf-8')
fakeDbRows.sessions = [
{
id: 'cron_job-1_20260515_090000',
started_at: Date.UTC(2026, 4, 15, 9, 0, 0) / 1000
}
]
fakeDbRows.messages = [
{
session_id: 'cron_job-1_20260515_090000',
role: 'tool',
content: 'full command output line'
}
]
const { readHermesCronOutputRunsPage } = await loadReader()
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })
expect(page).toEqual({ total: 1, runs: [] })
expect(fakePrepareSqls.some((sql) => sql.includes('FROM messages'))).toBe(false)
})
it('caches count-only reads until the cache is cleared', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
await mkdir(outputDir, { recursive: true })
await writeFile(join(outputDir, '2026-05-15_09-02-00.md'), 'first run', 'utf-8')
const { clearHermesCronOutputRunCountCache, readHermesCronOutputRunsPage } = await loadReader()
await expect(readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })).resolves.toEqual({
total: 1,
runs: []
})
await writeFile(join(outputDir, '2026-05-15_09-03-00.md'), 'second run', 'utf-8')
await expect(readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })).resolves.toEqual({
total: 1,
runs: []
})
clearHermesCronOutputRunCountCache('job-1')
await expect(readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })).resolves.toEqual({
total: 2,
runs: []
})
})
})
+642
View File
@@ -0,0 +1,642 @@
/* eslint-disable max-lines -- Why: Hermes run history has to reconcile
* markdown output files with SQLite session transcripts from separate stores. */
import { existsSync } from 'fs'
import { open, readdir, readFile, realpath, stat } from 'fs/promises'
import { homedir } from 'os'
import { isAbsolute, join, relative, resolve } from 'path'
import Database from 'better-sqlite3'
const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
const HERMES_OUTPUT_DIR = join(HERMES_HOME, 'cron', 'output')
const HERMES_STATE_DB = join(HERMES_HOME, 'state.db')
const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.md$/
const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/
const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000
const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024
const FULL_SESSION_LOG_HEADING = '## Full session log'
const REFERENCED_LOG_HEADING = '## Latest log file'
const LATEST_LOG_PATH_PATTERN =
/\bLatest log path:\s*(?<path>(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i
export type HermesCronOutputRunsPage = {
total: number
runs: unknown[]
}
type HermesOutputRunRef = {
kind: 'output'
id: string
job_id: string
run_at: string | null
run_key: string | null
output_path: string
}
type HermesSessionRunRef = {
kind: 'session'
id: string
job_id: string
run_at: string | null
run_key: string | null
}
type HermesMergedRunRef = {
id: string
job_id: string
run_at: string | null
run_key: string | null
output: HermesOutputRunRef | null
session: HermesSessionRunRef | null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value : null
}
function runAtFromHermesOutputFile(filename: string): string | null {
const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename)
if (!match) {
return null
}
const [, year, month, day, hour, minute, second] = match
return `${year}-${month}-${day}T${hour}:${minute}:${second}`
}
function runKeyFromHermesOutputFile(filename: string): string | null {
const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename)
if (!match) {
return null
}
const [, year, month, day, hour, minute, second] = match
return `${year}${month}${day}_${hour}${minute}${second}`
}
function runAtFromUnixSeconds(value: unknown): string | null {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null
}
const date = new Date(value * 1000)
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
function sortableTimeFromRunKey(runKey: string | null): number {
if (!runKey) {
return Number.NaN
}
const match = HERMES_RUN_KEY_PATTERN.exec(runKey)
if (!match) {
return Number.NaN
}
const [, year, month, day, hour, minute, second] = match
return Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second)
)
}
function escapeSqlLike(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
}
function cleanRunPreview(value: string): string | null {
const normalized = value
.replace(/```[\s\S]*?```/g, ' ')
.replace(/[#*_`>()]/g, ' ')
.replaceAll('[', ' ')
.replaceAll(']', ' ')
.replace(/\s+/g, ' ')
.trim()
if (!normalized) {
return null
}
return normalized.length > 180 ? `${normalized.slice(0, 177)}...` : normalized
}
function parseHermesOutput(content: string): {
status: 'completed' | 'failed' | 'unknown'
outputPreview: string | null
outputContent: string
error: string | null
} {
const failed = /^#\s+Cron Job:.*\(FAILED\)/m.test(content) || /^##\s+Error\b/m.test(content)
const errorMatch = /##\s+Error\s+```([\s\S]*?)```/m.exec(content)
const responseMatch = /##\s+Response\s+([\s\S]*)$/m.exec(content)
const error = errorMatch ? cleanRunPreview(errorMatch[1]) : null
return {
status: failed ? 'failed' : responseMatch ? 'completed' : 'unknown',
outputPreview: cleanRunPreview(responseMatch?.[1] ?? errorMatch?.[1] ?? content),
outputContent: content,
error
}
}
function extractLatestLogPath(content: string): string | null {
const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim()
if (!rawPath) {
return null
}
return rawPath.replace(/^`|`$/g, '').trim()
}
async function readReferencedLogFile(content: string): Promise<{
path: string
content: string
truncated: boolean
} | null> {
const logPath = extractLatestLogPath(content)
if (!logPath || !isAbsolute(logPath)) {
return null
}
try {
const homeRealPath = await realpath(HERMES_HOME)
const logRealPath = await realpath(logPath)
const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath))
// Why: the output body can contain agent-authored text, so only hydrate
// referenced files that resolve inside Hermes' own data directory.
if (relativeToHermesHome.startsWith('..') || isAbsolute(relativeToHermesHome)) {
return null
}
const logStat = await stat(logPath)
if (!logStat.isFile()) {
return null
}
if (logStat.size <= MAX_REFERENCED_LOG_BYTES) {
return {
path: logPath,
content: await readFile(logPath, 'utf-8'),
truncated: false
}
}
const file = await open(logPath, 'r')
try {
const buffer = Buffer.alloc(MAX_REFERENCED_LOG_BYTES)
await file.read(buffer, 0, MAX_REFERENCED_LOG_BYTES, logStat.size - MAX_REFERENCED_LOG_BYTES)
return {
path: logPath,
content: buffer.toString('utf-8'),
truncated: true
}
} finally {
await file.close()
}
} catch {
return null
}
}
async function appendReferencedLogFile(content: string): Promise<string> {
if (content.includes(REFERENCED_LOG_HEADING)) {
return content
}
const logFile = await readReferencedLogFile(content)
if (!logFile) {
return content
}
const note = logFile.truncated
? `Showing the last ${MAX_REFERENCED_LOG_BYTES} bytes because the log file is larger.`
: null
return [
content,
'---',
REFERENCED_LOG_HEADING,
'',
`Path: ${logFile.path}`,
note,
'```text',
logFile.content.trimEnd(),
'```'
]
.filter((part) => part !== null)
.join('\n\n')
}
function formatSessionMessages(messages: Record<string, unknown>[]): string | null {
if (messages.length === 0) {
return null
}
return messages
.map((message) => {
const role = typeof message.role === 'string' ? message.role : 'message'
const content = typeof message.content === 'string' ? message.content.trim() : ''
const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : ''
const reasoning =
typeof message.reasoning_content === 'string'
? message.reasoning_content.trim()
: typeof message.reasoning === 'string'
? message.reasoning.trim()
: ''
const parts = [
`## ${role}${toolName ? ` / ${toolName}` : ''}`,
reasoning ? `### Reasoning\n\n${reasoning}` : null,
content || '(empty)'
].filter(Boolean)
return parts.join('\n\n')
})
.join('\n\n---\n\n')
}
function getRunKey(run: unknown): string | null {
return isRecord(run) ? asString(run.run_key) : null
}
function getRunOutputContent(run: unknown): string | null {
return isRecord(run) ? asString(run.output_content) : null
}
function mergeOutputAndSessionContent(
outputContent: string | null,
sessionContent: string | null
): string | null {
if (!sessionContent) {
return outputContent
}
if (!outputContent) {
return `${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}`
}
if (outputContent.includes(FULL_SESSION_LOG_HEADING)) {
return outputContent
}
return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}`
}
function findMatchingSessionRunIndex(
outputRun: unknown,
sessionRuns: unknown[],
usedSessionRunIndexes: Set<number>
): number | null {
const outputRunKey = getRunKey(outputRun)
const exactMatchIndex = sessionRuns.findIndex(
(sessionRun, index) =>
!usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey
)
if (exactMatchIndex >= 0) {
return exactMatchIndex
}
const outputTime = sortableTimeFromRunKey(outputRunKey)
if (!Number.isFinite(outputTime)) {
return null
}
let bestIndex: number | null = null
let bestGap = Number.POSITIVE_INFINITY
for (let index = 0; index < sessionRuns.length; index += 1) {
if (usedSessionRunIndexes.has(index)) {
continue
}
const sessionTime = sortableTimeFromRunKey(getRunKey(sessionRuns[index]))
if (!Number.isFinite(sessionTime)) {
continue
}
const gap = outputTime - sessionTime
if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) {
continue
}
bestIndex = index
bestGap = gap
}
return bestIndex
}
function mergeHermesOutputAndSessionRuns(outputRuns: unknown[], sessionRuns: unknown[]): unknown[] {
const usedSessionRunIndexes = new Set<number>()
const mergedOutputRuns = outputRuns.map((outputRun) => {
if (!isRecord(outputRun)) {
return outputRun
}
const sessionRunIndex = findMatchingSessionRunIndex(
outputRun,
sessionRuns,
usedSessionRunIndexes
)
if (sessionRunIndex === null) {
return outputRun
}
const sessionRun = sessionRuns[sessionRunIndex]
if (!isRecord(sessionRun)) {
return outputRun
}
usedSessionRunIndexes.add(sessionRunIndex)
// Hermes writes the markdown output at completion, while state.db keeps
// the actual turn-by-turn transcript under the cron session start time.
return {
...outputRun,
output_preview: asString(outputRun.output_preview) ?? asString(sessionRun.output_preview),
output_content: mergeOutputAndSessionContent(
getRunOutputContent(outputRun),
getRunOutputContent(sessionRun)
)
}
})
return [
...mergedOutputRuns,
...sessionRuns.filter((_, index) => !usedSessionRunIndexes.has(index))
]
}
function mergeHermesOutputAndSessionRunRefs(
outputRefs: HermesOutputRunRef[],
sessionRefs: HermesSessionRunRef[]
): HermesMergedRunRef[] {
const usedSessionRunIndexes = new Set<number>()
const mergedOutputRefs = outputRefs.map((outputRef) => {
const sessionRunIndex = findMatchingSessionRunIndex(
outputRef,
sessionRefs,
usedSessionRunIndexes
)
const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex]
if (sessionRunIndex !== null) {
usedSessionRunIndexes.add(sessionRunIndex)
}
return {
id: outputRef.id,
job_id: outputRef.job_id,
run_at: outputRef.run_at,
run_key: outputRef.run_key,
output: outputRef,
session: sessionRef
}
})
return [
...mergedOutputRefs,
...sessionRefs
.filter((_, index) => !usedSessionRunIndexes.has(index))
.map((sessionRef) => ({
id: sessionRef.id,
job_id: sessionRef.job_id,
run_at: sessionRef.run_at,
run_key: sessionRef.run_key,
output: null,
session: sessionRef
}))
]
}
export async function readHermesCronOutputRuns(jobId: string): Promise<unknown[]> {
return (await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: Number.MAX_SAFE_INTEGER }))
.runs
}
async function readHermesCronOutputRunRefs(jobId: string): Promise<HermesMergedRunRef[]> {
const outputRuns = await readHermesOutputFileRunRefs(jobId)
return mergeHermesOutputAndSessionRunRefs(outputRuns, readHermesSessionDbRunRefs(jobId)).sort(
(a, b) => {
const aTime = getRawRunTime(a)
const bTime = getRawRunTime(b)
if (Number.isFinite(aTime) && Number.isFinite(bTime)) {
return bTime - aTime
}
return getRawRunId(b).localeCompare(getRawRunId(a))
}
)
}
// Why: opening the Automations page calls readHermesCronOutputRunsPage with
// pageSize:0 once per local Hermes job to populate badge counts. Without a
// cache this performs N readdir + N sqlite open/query on every list call,
// which scales linearly with job and run-history size on the main process.
const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000
type HermesRunCountCacheEntry = {
promise: Promise<number>
expiresAt: number
}
const hermesRunCountCache = new Map<string, HermesRunCountCacheEntry>()
export function clearHermesCronOutputRunCountCache(jobId?: string): void {
if (jobId) {
hermesRunCountCache.delete(jobId)
return
}
hermesRunCountCache.clear()
}
async function readHermesCronOutputRunCount(jobId: string): Promise<number> {
const now = Date.now()
const cached = hermesRunCountCache.get(jobId)
if (cached && cached.expiresAt > now) {
return cached.promise
}
const entry: HermesRunCountCacheEntry = {
promise: readHermesCronOutputRunRefs(jobId).then((refs) => refs.length),
expiresAt: Number.POSITIVE_INFINITY
}
hermesRunCountCache.set(jobId, entry)
try {
const count = await entry.promise
entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS
return count
} catch (error) {
if (hermesRunCountCache.get(jobId) === entry) {
hermesRunCountCache.delete(jobId)
}
throw error
}
}
async function hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise<unknown> {
const outputRun = ref.output ? await readHermesOutputFileRun(ref.output) : null
const sessionRun = ref.session ? readHermesSessionDbRunById(jobId, ref.session.id) : null
return (
mergeHermesOutputAndSessionRuns(
outputRun ? [outputRun] : [],
sessionRun ? [sessionRun] : []
)[0] ??
outputRun ??
sessionRun ??
ref
)
}
export async function readHermesCronOutputRunsPage(
jobId: string,
{
page,
pageSize
}: {
page: number
pageSize: number
}
): Promise<HermesCronOutputRunsPage> {
if (!EXTERNAL_JOB_ID_PATTERN.test(jobId)) {
return { total: 0, runs: [] }
}
const safePage = Math.max(1, Math.floor(page))
const safePageSize = Math.max(0, Math.floor(pageSize))
if (safePageSize === 0) {
// Why: manager listing only needs a badge count; hydrating markdown logs
// and full session transcripts can make opening Automations very slow.
return { total: await readHermesCronOutputRunCount(jobId), runs: [] }
}
const runRefs = await readHermesCronOutputRunRefs(jobId)
const start = (safePage - 1) * safePageSize
const pageRefs = runRefs.slice(start, start + safePageSize)
return {
total: runRefs.length,
runs: await Promise.all(pageRefs.map((ref) => hydrateHermesRunRef(jobId, ref)))
}
}
function getRawRunId(run: unknown): string {
if (typeof run === 'object' && run !== null && 'id' in run) {
return String((run as { id: unknown }).id)
}
return ''
}
function getRawRunTime(run: unknown): number {
if (typeof run !== 'object' || run === null || !('run_at' in run)) {
return Number.NaN
}
const runAt = (run as { run_at: unknown }).run_at
return typeof runAt === 'string' ? Date.parse(runAt) : Number.NaN
}
async function readHermesOutputFileRunRefs(jobId: string): Promise<HermesOutputRunRef[]> {
const outputDir = join(HERMES_OUTPUT_DIR, jobId)
if (!existsSync(outputDir)) {
return []
}
const entries = await readdir(outputDir, { withFileTypes: true })
return entries
.filter((entry) => entry.isFile() && HERMES_OUTPUT_FILE_PATTERN.test(entry.name))
.map((entry) => ({
kind: 'output' as const,
id: `${jobId}:${entry.name}`,
job_id: jobId,
run_at: runAtFromHermesOutputFile(entry.name),
run_key: runKeyFromHermesOutputFile(entry.name),
output_path: join(outputDir, entry.name)
}))
}
async function readHermesOutputFileRun(ref: HermesOutputRunRef): Promise<unknown> {
try {
const content = await readFile(ref.output_path, 'utf-8')
const parsed = parseHermesOutput(content)
const outputContent = await appendReferencedLogFile(parsed.outputContent)
return {
id: ref.id,
job_id: ref.job_id,
run_at: ref.run_at,
run_key: ref.run_key,
status: parsed.status,
output_preview: parsed.outputPreview,
output_content: outputContent,
error: parsed.error,
output_path: ref.output_path
}
} catch (error) {
return {
id: ref.id,
job_id: ref.job_id,
run_at: ref.run_at,
run_key: ref.run_key,
status: 'unknown',
output_preview: null,
output_content: null,
error: error instanceof Error ? error.message : String(error),
output_path: ref.output_path
}
}
}
function readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] {
if (!existsSync(HERMES_STATE_DB)) {
return []
}
try {
const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true })
try {
const pattern = `cron\\_${escapeSqlLike(jobId)}\\_%`
const rows = db
.prepare(
`SELECT id, started_at
FROM sessions
WHERE id LIKE ? ESCAPE '\\'
ORDER BY started_at DESC`
)
.all(pattern) as Record<string, unknown>[]
return rows.map((row) => {
const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}`
return {
kind: 'session',
id: runId,
job_id: jobId,
run_at: runAtFromUnixSeconds(row.started_at),
run_key: runId.split(`${jobId}_`).at(-1) ?? null
}
})
} finally {
db.close()
}
} catch {
return []
}
}
function readHermesSessionDbRunById(jobId: string, runId: string): unknown | null {
if (!existsSync(HERMES_STATE_DB)) {
return null
}
try {
const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true })
try {
const row = db
.prepare(
`SELECT id, title, started_at, ended_at, end_reason, model, message_count,
input_tokens, output_tokens, estimated_cost_usd
FROM sessions
WHERE id = ?`
)
.get(runId) as Record<string, unknown> | undefined
if (!row) {
return null
}
const messages = db
.prepare(
`SELECT role, content, tool_name, reasoning, reasoning_content
FROM messages
WHERE session_id = ?
ORDER BY timestamp, id`
)
.all(runId) as Record<string, unknown>[]
const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null
const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null
const messageCount = typeof row.message_count === 'number' ? row.message_count : null
const tokenCount =
(typeof row.input_tokens === 'number' ? row.input_tokens : 0) +
(typeof row.output_tokens === 'number' ? row.output_tokens : 0)
const summaryParts = [
title,
model ? `Model: ${model}` : null,
messageCount !== null ? `${messageCount} messages` : null,
tokenCount > 0 ? `${tokenCount} tokens` : null
].filter(Boolean)
return {
id: runId,
job_id: jobId,
run_at: runAtFromUnixSeconds(row.started_at),
run_key: runId.split(`${jobId}_`).at(-1) ?? null,
status: typeof row.ended_at === 'number' ? 'completed' : 'unknown',
output_preview: summaryParts.join(' · ') || null,
output_content: formatSessionMessages(messages),
error: null,
output_path: HERMES_STATE_DB
}
} finally {
db.close()
}
} catch {
return null
}
}
+163
View File
@@ -88,4 +88,167 @@ describe('AutomationService', () => {
new Date('2026-05-14T09:00:00').getTime()
)
})
it('attaches provider usage when a completed run can be attributed', async () => {
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Costed check',
prompt: 'Check spend',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, Date.now(), 'manual')
store.updateAutomationRun({
runId: run.id,
status: 'dispatched',
workspaceId: 'wt1',
terminalSessionId: 'tab-1',
error: null
})
const usage = {
status: 'known' as const,
provider: 'claude' as const,
model: 'claude-sonnet-4-6',
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 25,
cacheWriteTokens: 10,
reasoningOutputTokens: null,
totalTokens: 185,
estimatedCostUsd: 0.001,
estimatedCostSource: 'api_equivalent' as const,
providerSessionId: 'provider-session-1',
attribution: 'provider_session_time_window' as const,
collectedAt: Date.now(),
unavailableReason: null,
unavailableMessage: null
}
const getAutomationRunUsage = vi.fn().mockResolvedValue(usage)
const service = new AutomationService(store, {
tickMs: 60_000,
claudeUsage: { getAutomationRunUsage } as never
})
const updated = await service.markDispatchResult({
runId: run.id,
status: 'completed',
workspaceId: 'wt1',
terminalSessionId: 'tab-1',
error: null
})
expect(updated.usage).toEqual(usage)
expect(getAutomationRunUsage).toHaveBeenCalledWith({
worktreeId: 'wt1',
terminalSessionId: 'tab-1',
startedAt: expect.any(Number),
completedAt: expect.any(Number)
})
})
it('does not recollect usage for an already-finalized run', async () => {
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Costed check',
prompt: 'Check spend',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, Date.now(), 'manual')
store.updateAutomationRun({
runId: run.id,
status: 'dispatched',
workspaceId: 'wt1',
terminalSessionId: 'tab-1',
error: null
})
const usage = {
status: 'known' as const,
provider: 'claude' as const,
model: 'claude-sonnet-4-6',
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 25,
cacheWriteTokens: 10,
reasoningOutputTokens: null,
totalTokens: 185,
estimatedCostUsd: 0.001,
estimatedCostSource: 'api_equivalent' as const,
providerSessionId: 'provider-session-1',
attribution: 'provider_session_time_window' as const,
collectedAt: Date.now(),
unavailableReason: null,
unavailableMessage: null
}
const getAutomationRunUsage = vi.fn().mockResolvedValue(usage)
const service = new AutomationService(store, {
tickMs: 60_000,
claudeUsage: { getAutomationRunUsage } as never
})
const result = {
runId: run.id,
status: 'completed' as const,
workspaceId: 'wt1',
terminalSessionId: 'tab-1',
error: null
}
const first = await service.markDispatchResult(result)
const second = await service.markDispatchResult(result)
expect(first.usage).toEqual(usage)
expect(second.usage).toEqual(usage)
expect(getAutomationRunUsage).toHaveBeenCalledTimes(1)
})
it('records unsupported usage cleanly for completed agents without local usage stores', async () => {
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Gemini check',
prompt: 'Check spend',
agentId: 'gemini',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, Date.now(), 'manual')
store.updateAutomationRun({
runId: run.id,
status: 'dispatched',
workspaceId: 'wt1',
terminalSessionId: 'tab-1',
error: null
})
const service = new AutomationService(store, { tickMs: 60_000 })
const updated = await service.markDispatchResult({
runId: run.id,
status: 'completed',
workspaceId: 'wt1',
terminalSessionId: 'tab-1',
error: null
})
expect(updated.usage?.status).toBe('unavailable')
expect(updated.usage?.unavailableReason).toBe('provider_unsupported')
})
})
+121 -4
View File
@@ -4,8 +4,12 @@ import type {
Automation,
AutomationDispatchRequest,
AutomationDispatchResult,
AutomationRun
AutomationRun,
AutomationRunStatus,
AutomationRunUsage
} from '../../shared/automations-types'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
const DEFAULT_TICK_MS = 60 * 1000
@@ -16,10 +20,17 @@ export class AutomationService {
private webContents: WebContents | null = null
private rendererReady = false
private evaluating = false
private readonly claudeUsage: ClaudeUsageStore | null
private readonly codexUsage: CodexUsageStore | null
constructor(store: Store, opts: { tickMs?: number } = {}) {
constructor(
store: Store,
opts: { tickMs?: number; claudeUsage?: ClaudeUsageStore; codexUsage?: CodexUsageStore } = {}
) {
this.store = store
this.tickMs = opts.tickMs ?? DEFAULT_TICK_MS
this.claudeUsage = opts.claudeUsage ?? null
this.codexUsage = opts.codexUsage ?? null
}
setWebContents(webContents: WebContents | null): void {
@@ -62,8 +73,104 @@ export class AutomationService {
return run
}
markDispatchResult(result: AutomationDispatchResult): AutomationRun {
return this.store.updateAutomationRun(result)
async markDispatchResult(result: AutomationDispatchResult): Promise<AutomationRun> {
const run = this.store.updateAutomationRun(result)
if (!isFinalRunStatus(run.status)) {
return run
}
// Why: the renderer's mark-completed effect can re-fire for the same run
// before refresh() flips its status snapshot off 'dispatched'. Re-running
// collectRunUsage advances the attribution window and can rewrite an
// already-collected 'known' usage to 'unavailable'/'ambiguous_session'.
if (run.usage) {
return run
}
const usage = await this.collectRunUsage(run)
return this.store.updateAutomationRun({
runId: run.id,
status: run.status,
workspaceId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
usage,
error: run.error
})
}
private async collectRunUsage(run: AutomationRun): Promise<AutomationRunUsage> {
const automation = this.store.listAutomations().find((entry) => entry.id === run.automationId)
const collectedAt = Date.now()
const unavailable = (
provider: AutomationRunUsage['provider'],
unavailableReason: AutomationRunUsage['unavailableReason'],
unavailableMessage: string
): AutomationRunUsage => ({
status: 'unavailable',
provider,
model: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningOutputTokens: null,
totalTokens: null,
estimatedCostUsd: null,
estimatedCostSource: null,
providerSessionId: null,
attribution: null,
collectedAt,
unavailableReason,
unavailableMessage
})
if (!automation || run.status !== 'completed') {
return unavailable(
automation?.agentId === 'codex'
? 'codex'
: automation?.agentId === 'claude'
? 'claude'
: null,
'run_not_finished',
'Usage is only collected for completed automation runs.'
)
}
if (automation.executionTargetType === 'ssh') {
return unavailable(
automation.agentId === 'codex'
? 'codex'
: automation.agentId === 'claude'
? 'claude'
: null,
'remote_usage_unavailable',
'Remote automation usage is not available from local usage logs.'
)
}
if (automation.agentId === 'claude') {
if (!this.claudeUsage) {
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
}
return this.claudeUsage.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
}
if (automation.agentId === 'codex') {
if (!this.codexUsage) {
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
}
return this.codexUsage.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
}
return unavailable(
null,
'provider_unsupported',
'This agent does not report usage to Orca yet.'
)
}
private async evaluateDueRuns(): Promise<void> {
@@ -128,3 +235,13 @@ export class AutomationService {
webContents.send('automations:dispatchRequested', payload)
}
}
function isFinalRunStatus(status: AutomationRunStatus): boolean {
return (
status === 'completed' ||
status === 'dispatch_failed' ||
status === 'skipped_missed' ||
status === 'skipped_unavailable' ||
status === 'skipped_needs_interactive_auth'
)
}
+179
View File
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getAzureDevOpsAuthStatus,
getAzureDevOpsPullRequestForBranch,
normalizeAzureDevOpsApiBaseUrl
} from './client'
import { _resetAzureDevOpsRepoRefCache } from './repository-ref'
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
const OLD_ENV = process.env
const OLD_FETCH = globalThis.fetch
describe('Azure DevOps client', () => {
beforeEach(() => {
process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' }
gitExecFileAsyncMock.mockReset()
_resetAzureDevOpsRepoRefCache()
})
afterEach(() => {
process.env = OLD_ENV
globalThis.fetch = OLD_FETCH
_resetAzureDevOpsRepoRefCache()
})
it('normalizes configured API base URLs', () => {
expect(normalizeAzureDevOpsApiBaseUrl('https://dev.azure.com/acme/Project/_apis/')).toBe(
'https://dev.azure.com/acme/Project'
)
})
it('marks token-only auth as configured but unverified because repository remotes supply the API base URL', async () => {
delete process.env.ORCA_AZURE_DEVOPS_API_BASE_URL
await expect(getAzureDevOpsAuthStatus()).resolves.toEqual({
configured: true,
authenticated: false,
account: null,
baseUrl: null,
tokenConfigured: true
})
})
it('resolves a PR for a branch through repository, PR, and status REST calls', async () => {
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://dev.azure.com/acme/Project/_git/repo\n'
})
const requests: { pathname: string; search: string; authorization: string | null }[] = []
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = new URL(String(input))
requests.push({
pathname: url.pathname,
search: url.search,
authorization: null
})
const response = (body: unknown): Response =>
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
if (url.pathname === '/acme/Project/_apis/git/repositories/repo') {
return response({
id: 'repo-guid',
webUrl: 'https://dev.azure.com/acme/Project/_git/repo'
})
}
if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests') {
expect(url.searchParams.get('searchCriteria.sourceRefName')).toBe(
'refs/heads/feature/azure'
)
expect(url.searchParams.get('searchCriteria.status')).toBe('all')
return response({
value: [
{
pullRequestId: 17,
title: 'Old Azure PR',
status: 'completed',
creationDate: '2026-05-10T00:00:00Z',
lastMergeSourceCommit: { commitId: 'oldsha' }
},
{
pullRequestId: 18,
title: 'Azure branch',
status: 'active',
creationDate: '2026-05-11T00:00:00Z',
mergeStatus: 'succeeded',
lastMergeSourceCommit: { commitId: 'abc123' }
}
]
})
}
if (
url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/18/statuses'
) {
return response({ value: [{ state: 'succeeded' }] })
}
return new Response(JSON.stringify({ message: 'not found' }), { status: 404 })
}) as never
await expect(
getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/feature/azure')
).resolves.toEqual({
number: 18,
title: 'Azure branch',
state: 'open',
url: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/18',
status: 'success',
updatedAt: '2026-05-11T00:00:00Z',
mergeable: 'MERGEABLE',
headSha: 'abc123'
})
expect(requests.map((request) => request.pathname)).toEqual([
'/acme/Project/_apis/git/repositories/repo',
'/acme/Project/_apis/git/repositories/repo-guid/pullRequests',
'/acme/Project/_apis/git/repositories/repo-guid/pullRequests/18/statuses'
])
})
it('uses the most recent branch PR instead of preferring stale active PRs', async () => {
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://dev.azure.com/acme/Project/_git/repo\n'
})
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = new URL(String(input))
const response = (body: unknown): Response =>
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
if (url.pathname === '/acme/Project/_apis/git/repositories/repo') {
return response({ id: 'repo-guid' })
}
if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests') {
return response({
value: [
{
pullRequestId: 20,
title: 'Stale active PR',
status: 'active',
creationDate: '2026-05-10T00:00:00Z',
mergeStatus: 'succeeded',
lastMergeSourceCommit: { commitId: 'oldsha' }
},
{
pullRequestId: 21,
title: 'Latest completed PR',
status: 'completed',
creationDate: '2026-05-15T00:00:00Z',
closedDate: '2026-05-16T00:00:00Z',
mergeStatus: 'succeeded',
lastMergeSourceCommit: { commitId: 'newsha' }
}
]
})
}
if (
url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/21/statuses'
) {
return response({ value: [{ state: 'succeeded' }] })
}
return new Response(JSON.stringify({ message: 'not found' }), { status: 404 })
}) as never
await expect(
getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/feature/azure')
).resolves.toMatchObject({
number: 21,
title: 'Latest completed PR',
state: 'merged',
headSha: 'newsha'
})
})
})
+327
View File
@@ -0,0 +1,327 @@
import { Buffer } from 'buffer'
import {
deriveAzureDevOpsStatus,
mapAzureDevOpsPullRequest,
type AzureDevOpsPullRequestInfo,
type RawAzureDevOpsPullRequest,
type RawAzureDevOpsStatus
} from './pull-request-mappers'
import { getAzureDevOpsRepoRef, type AzureDevOpsRepoRef } from './repository-ref'
const REQUEST_TIMEOUT_MS = 5000
type AzureDevOpsAuthConfig = {
apiBaseUrl: string | null
pat: string | null
accessToken: string | null
username: string | null
}
export type AzureDevOpsAuthStatus = {
configured: boolean
authenticated: boolean
account: string | null
baseUrl: string | null
tokenConfigured: boolean
}
type RequestOptions = {
searchParams?: Record<string, string | number>
timeoutMs?: number
}
type RawAzureDevOpsRepository = {
id?: string | null
name?: string | null
webUrl?: string | null
_links?: {
web?: {
href?: string | null
} | null
} | null
}
function envValue(name: string): string | null {
const value = process.env[name]?.trim() ?? ''
return value.length > 0 ? value : null
}
export function normalizeAzureDevOpsApiBaseUrl(value: string): string {
return value
.trim()
.replace(/\/+$/, '')
.replace(/\/_apis$/i, '')
}
function getAuthConfig(): AzureDevOpsAuthConfig {
return {
apiBaseUrl: envValue('ORCA_AZURE_DEVOPS_API_BASE_URL'),
pat: envValue('ORCA_AZURE_DEVOPS_TOKEN') ?? envValue('ORCA_AZURE_DEVOPS_PAT'),
accessToken: envValue('ORCA_AZURE_DEVOPS_ACCESS_TOKEN'),
username: envValue('ORCA_AZURE_DEVOPS_USERNAME')
}
}
function tokenConfigured(config: AzureDevOpsAuthConfig): boolean {
return Boolean(config.pat || config.accessToken)
}
function authHeaders(config: AzureDevOpsAuthConfig): Record<string, string> {
if (config.accessToken) {
return { Authorization: `Bearer ${config.accessToken}` }
}
if (config.pat) {
const encoded = Buffer.from(`${config.username ?? ''}:${config.pat}`).toString('base64')
return { Authorization: `Basic ${encoded}` }
}
return {}
}
function configuredApiBaseUrl(repo: AzureDevOpsRepoRef): string {
const configured = getAuthConfig().apiBaseUrl
return configured ? normalizeAzureDevOpsApiBaseUrl(configured) : repo.apiBaseUrl
}
function apiUrl(baseUrl: string, path: string, searchParams?: RequestOptions['searchParams']): URL {
const url = new URL(`${baseUrl.replace(/\/+$/, '')}${path}`)
const params = { ...searchParams, 'api-version': searchParams?.['api-version'] ?? '7.1' }
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value))
}
return url
}
async function requestJsonAtBase<T>(
baseUrl: string,
path: string,
options: RequestOptions = {}
): Promise<T | null> {
const config = getAuthConfig()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? REQUEST_TIMEOUT_MS)
try {
const response = await fetch(apiUrl(baseUrl, path, options.searchParams), {
headers: {
Accept: 'application/json',
...authHeaders(config)
},
signal: controller.signal
})
if (!response.ok) {
return null
}
return (await response.json()) as T
} catch {
return null
} finally {
clearTimeout(timeout)
}
}
function requestJson<T>(
repo: AzureDevOpsRepoRef,
path: string,
options: RequestOptions = {}
): Promise<T | null> {
return requestJsonAtBase(configuredApiBaseUrl(repo), path, options)
}
function encodePathSegment(value: string): string {
return encodeURIComponent(value)
}
async function getRepository(
repo: AzureDevOpsRepoRef
): Promise<{ idOrName: string; webBaseUrl: string } | null> {
const raw = await requestJson<RawAzureDevOpsRepository>(
repo,
`/_apis/git/repositories/${encodePathSegment(repo.repository)}`
)
if (!raw) {
return { idOrName: repo.repository, webBaseUrl: repo.webBaseUrl }
}
return {
idOrName: raw.id?.trim() || repo.repository,
webBaseUrl: raw.webUrl ?? raw._links?.web?.href ?? repo.webBaseUrl
}
}
function readStatusList(
raw: RawAzureDevOpsStatus[] | { value?: RawAzureDevOpsStatus[] } | null
): RawAzureDevOpsStatus[] {
if (Array.isArray(raw)) {
return raw
}
return raw?.value ?? []
}
async function getPullRequestStatuses(
repo: AzureDevOpsRepoRef,
repoIdOrName: string,
pr: RawAzureDevOpsPullRequest
): Promise<RawAzureDevOpsStatus[]> {
const raw = await requestJson<RawAzureDevOpsStatus[] | { value?: RawAzureDevOpsStatus[] }>(
repo,
`/_apis/git/repositories/${encodePathSegment(repoIdOrName)}/pullRequests/${encodePathSegment(
String(pr.pullRequestId)
)}/statuses`
)
const prStatuses = readStatusList(raw)
if (prStatuses.length > 0) {
return prStatuses
}
const commitId = pr.lastMergeSourceCommit?.commitId?.trim()
if (!commitId) {
return pr.statuses ?? []
}
const commitStatuses = await requestJson<
RawAzureDevOpsStatus[] | { value?: RawAzureDevOpsStatus[] }
>(
repo,
`/_apis/git/repositories/${encodePathSegment(repoIdOrName)}/commits/${encodePathSegment(
commitId
)}/statuses`
)
return readStatusList(commitStatuses)
}
async function normalizePullRequest(
repo: AzureDevOpsRepoRef,
repoIdOrName: string,
webBaseUrl: string,
raw: RawAzureDevOpsPullRequest
): Promise<AzureDevOpsPullRequestInfo | null> {
const statuses = await getPullRequestStatuses(repo, repoIdOrName, raw)
return mapAzureDevOpsPullRequest(raw, deriveAzureDevOpsStatus(statuses), webBaseUrl)
}
function sortPullRequestsForBranch(
left: RawAzureDevOpsPullRequest,
right: RawAzureDevOpsPullRequest
): number {
const leftTime = Date.parse(left.closedDate ?? left.creationDate ?? '') || 0
const rightTime = Date.parse(right.closedDate ?? right.creationDate ?? '') || 0
if (leftTime !== rightTime) {
return rightTime - leftTime
}
const leftActive = left.status?.trim().toLowerCase() === 'active'
const rightActive = right.status?.trim().toLowerCase() === 'active'
if (leftActive !== rightActive) {
return leftActive ? -1 : 1
}
return 0
}
export async function getAzureDevOpsAuthStatus(): Promise<AzureDevOpsAuthStatus> {
const config = getAuthConfig()
const baseUrl = config.apiBaseUrl ? normalizeAzureDevOpsApiBaseUrl(config.apiBaseUrl) : null
const hasToken = tokenConfigured(config)
if (!baseUrl && !hasToken) {
return {
configured: false,
authenticated: false,
account: null,
baseUrl: null,
tokenConfigured: false
}
}
if (!baseUrl) {
return {
configured: true,
authenticated: false,
account: null,
baseUrl: null,
tokenConfigured: hasToken
}
}
const connection = await requestJsonAtBase<{
authenticatedUser?: {
providerDisplayName?: string | null
customDisplayName?: string | null
uniqueName?: string | null
} | null
}>(baseUrl, '/_apis/connectionData', { timeoutMs: 4000 })
const user = connection?.authenticatedUser
return {
configured: hasToken || connection !== null,
authenticated: connection !== null && (hasToken || user !== null),
account: user?.providerDisplayName ?? user?.customDisplayName ?? user?.uniqueName ?? null,
baseUrl,
tokenConfigured: hasToken
}
}
export async function getAzureDevOpsPullRequest(
repoPath: string,
prNumber: number
): Promise<AzureDevOpsPullRequestInfo | null> {
const repo = await getAzureDevOpsRepoRef(repoPath)
if (!repo) {
return null
}
const repository = await getRepository(repo)
if (!repository) {
return null
}
const raw = await requestJson<RawAzureDevOpsPullRequest>(
repo,
`/_apis/git/repositories/${encodePathSegment(repository.idOrName)}/pullRequests/${encodePathSegment(
String(prNumber)
)}`
)
return raw ? normalizePullRequest(repo, repository.idOrName, repository.webBaseUrl, raw) : null
}
export async function getAzureDevOpsPullRequestForBranch(
repoPath: string,
branch: string,
linkedPRNumber?: number | null
): Promise<AzureDevOpsPullRequestInfo | null> {
const branchName = branch.replace(/^refs\/heads\//, '')
if (!branchName && linkedPRNumber == null) {
return null
}
const repo = await getAzureDevOpsRepoRef(repoPath)
if (!repo) {
return null
}
const repository = await getRepository(repo)
if (!repository) {
return null
}
if (branchName) {
const list = await requestJson<{ value?: RawAzureDevOpsPullRequest[] }>(
repo,
`/_apis/git/repositories/${encodePathSegment(repository.idOrName)}/pullRequests`,
{
searchParams: {
'searchCriteria.sourceRefName': `refs/heads/${branchName}`,
'searchCriteria.status': 'all',
$top: 10
}
}
)
const raw = (list?.value ?? []).sort(sortPullRequestsForBranch)[0]
if (raw) {
return normalizePullRequest(repo, repository.idOrName, repository.webBaseUrl, raw)
}
}
if (typeof linkedPRNumber !== 'number') {
return null
}
const raw = await requestJson<RawAzureDevOpsPullRequest>(
repo,
`/_apis/git/repositories/${encodePathSegment(repository.idOrName)}/pullRequests/${encodePathSegment(
String(linkedPRNumber)
)}`
)
return raw ? normalizePullRequest(repo, repository.idOrName, repository.webBaseUrl, raw) : null
}
export async function getAzureDevOpsRepoSlug(repoPath: string): Promise<AzureDevOpsRepoRef | null> {
return getAzureDevOpsRepoRef(repoPath)
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
deriveAzureDevOpsStatus,
mapAzureDevOpsPullRequest,
mapAzureDevOpsPullRequestState
} from './pull-request-mappers'
describe('Azure DevOps pull request mappers', () => {
it('maps Azure DevOps PR states into the hosted review state set', () => {
expect(mapAzureDevOpsPullRequestState({ status: 'active' })).toBe('open')
expect(mapAzureDevOpsPullRequestState({ status: 'active', isDraft: true })).toBe('draft')
expect(mapAzureDevOpsPullRequestState({ status: 'completed' })).toBe('merged')
expect(mapAzureDevOpsPullRequestState({ status: 'abandoned' })).toBe('closed')
})
it('derives check status from Azure DevOps PR and commit status states', () => {
expect(deriveAzureDevOpsStatus([{ state: 'succeeded' }])).toBe('success')
expect(deriveAzureDevOpsStatus([{ state: 'succeeded' }, { state: 'pending' }])).toBe('pending')
expect(deriveAzureDevOpsStatus([{ state: 'succeeded' }, { state: 'failed' }])).toBe('failure')
expect(deriveAzureDevOpsStatus([])).toBe('neutral')
})
it('maps a raw PR into hosted review info', () => {
expect(
mapAzureDevOpsPullRequest(
{
pullRequestId: 42,
title: 'Add Azure support',
status: 'active',
creationDate: '2026-05-15T12:00:00Z',
mergeStatus: 'succeeded',
lastMergeSourceCommit: { commitId: 'abc123' }
},
'success',
'https://dev.azure.com/acme/Project/_git/repo'
)
).toEqual({
number: 42,
title: 'Add Azure support',
state: 'open',
url: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/42',
status: 'success',
updatedAt: '2026-05-15T12:00:00Z',
mergeable: 'MERGEABLE',
headSha: 'abc123'
})
})
})
@@ -0,0 +1,125 @@
import type { CheckStatus, PRMergeableState } from '../../shared/types'
export type RawAzureDevOpsStatus = {
state?: string | null
}
export type RawAzureDevOpsPullRequest = {
pullRequestId?: number
codeReviewId?: number
title?: string | null
status?: string | null
isDraft?: boolean | null
creationDate?: string | null
closedDate?: string | null
mergeStatus?: string | null
sourceRefName?: string | null
lastMergeSourceCommit?: {
commitId?: string | null
} | null
statuses?: RawAzureDevOpsStatus[] | null
_links?: {
web?: {
href?: string | null
} | null
} | null
}
export type AzureDevOpsPullRequestInfo = {
number: number
title: string
state: 'open' | 'closed' | 'merged' | 'draft'
url: string
status: CheckStatus
updatedAt: string
mergeable: PRMergeableState
headSha?: string
}
export function mapAzureDevOpsPullRequestState(
raw: Pick<RawAzureDevOpsPullRequest, 'isDraft' | 'status'>
): AzureDevOpsPullRequestInfo['state'] {
const status = raw.status?.trim().toLowerCase()
if (status === 'completed') {
return 'merged'
}
if (status === 'abandoned') {
return 'closed'
}
if (raw.isDraft) {
return 'draft'
}
return 'open'
}
export function mapAzureDevOpsMergeable(mergeStatus: string | null | undefined): PRMergeableState {
switch (mergeStatus?.trim().toLowerCase()) {
case 'succeeded':
return 'MERGEABLE'
case 'conflicts':
return 'CONFLICTING'
default:
return 'UNKNOWN'
}
}
function classifyAzureDevOpsStatus(state: string | null | undefined): CheckStatus {
switch (state?.trim().toLowerCase()) {
case 'succeeded':
case 'success':
return 'success'
case 'failed':
case 'error':
case 'rejected':
case 'canceled':
case 'cancelled':
return 'failure'
case 'pending':
case 'inprogress':
case 'in_progress':
case 'queued':
case 'running':
return 'pending'
default:
return 'neutral'
}
}
export function deriveAzureDevOpsStatus(statuses: readonly RawAzureDevOpsStatus[]): CheckStatus {
if (statuses.length === 0) {
return 'neutral'
}
const classified = statuses.map((status) => classifyAzureDevOpsStatus(status.state))
if (classified.includes('failure')) {
return 'failure'
}
if (classified.includes('pending')) {
return 'pending'
}
if (classified.every((status) => status === 'success')) {
return 'success'
}
return 'neutral'
}
export function mapAzureDevOpsPullRequest(
raw: RawAzureDevOpsPullRequest,
status: CheckStatus,
webBaseUrl: string
): AzureDevOpsPullRequestInfo | null {
if (typeof raw.pullRequestId !== 'number' || !raw.title) {
return null
}
const headSha = raw.lastMergeSourceCommit?.commitId?.trim()
return {
number: raw.pullRequestId,
title: raw.title,
state: mapAzureDevOpsPullRequestState(raw),
url:
raw._links?.web?.href ?? `${webBaseUrl.replace(/\/+$/, '')}/pullrequest/${raw.pullRequestId}`,
status,
updatedAt: raw.closedDate ?? raw.creationDate ?? '',
mergeable: mapAzureDevOpsMergeable(raw.mergeStatus),
...(headSha ? { headSha } : {})
}
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { parseAzureDevOpsRepoRef } from './repository-ref'
describe('parseAzureDevOpsRepoRef', () => {
it('parses dev.azure.com HTTPS remotes', () => {
expect(
parseAzureDevOpsRepoRef('https://dev.azure.com/acme/Project%20One/_git/repo-name')
).toEqual({
host: 'dev.azure.com',
organization: 'acme',
project: 'Project One',
repository: 'repo-name',
apiBaseUrl: 'https://dev.azure.com/acme/Project%20One',
webBaseUrl: 'https://dev.azure.com/acme/Project%20One/_git/repo-name'
})
})
it('parses legacy visualstudio.com HTTPS remotes', () => {
expect(parseAzureDevOpsRepoRef('https://acme.visualstudio.com/Project/_git/repo.git')).toEqual({
host: 'acme.visualstudio.com',
organization: 'acme',
project: 'Project',
repository: 'repo',
apiBaseUrl: 'https://acme.visualstudio.com/Project',
webBaseUrl: 'https://acme.visualstudio.com/Project/_git/repo'
})
})
it('parses Azure DevOps Services SSH remotes', () => {
expect(parseAzureDevOpsRepoRef('git@ssh.dev.azure.com:v3/acme/Project/repo')).toEqual({
host: 'dev.azure.com',
organization: 'acme',
project: 'Project',
repository: 'repo',
apiBaseUrl: 'https://dev.azure.com/acme/Project',
webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo'
})
})
it('parses Azure DevOps Server HTTPS remotes from the _git path convention', () => {
expect(
parseAzureDevOpsRepoRef('https://ado.example.com/tfs/DefaultCollection/Project/_git/repo.git')
).toEqual({
host: 'ado.example.com',
organization: null,
project: 'Project',
repository: 'repo',
apiBaseUrl: 'https://ado.example.com/tfs/DefaultCollection/Project',
webBaseUrl: 'https://ado.example.com/tfs/DefaultCollection/Project/_git/repo'
})
})
it('ignores non-Azure remotes', () => {
expect(parseAzureDevOpsRepoRef('git@github.com:stablyai/orca.git')).toBeNull()
})
})
+208
View File
@@ -0,0 +1,208 @@
import { gitExecFileAsync } from '../git/runner'
export type AzureDevOpsRepoRef = {
host: string
project: string
repository: string
apiBaseUrl: string
webBaseUrl: string
organization?: string | null
}
const repoRefCache = new Map<string, AzureDevOpsRepoRef | null>()
/** @internal - exposed for tests only */
export function _resetAzureDevOpsRepoRefCache(): void {
repoRefCache.clear()
}
function decodeSegment(value: string): string {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function encodeSegment(value: string): string {
return encodeURIComponent(value)
}
function splitPath(path: string): string[] {
return path
.replace(/\.git$/i, '')
.split('/')
.map((part) => part.trim())
.filter(Boolean)
.map(decodeSegment)
}
function joinUrl(origin: string, segments: readonly string[]): string {
const path = segments.map(encodeSegment).join('/')
return path ? `${origin.replace(/\/+$/, '')}/${path}` : origin.replace(/\/+$/, '')
}
function parseGitHttpPath(
pathname: string
): { prefix: string[]; project: string; repository: string } | null {
const parts = splitPath(pathname)
const gitIndex = parts.findIndex((part) => part.toLowerCase() === '_git')
if (gitIndex < 1 || gitIndex + 1 >= parts.length) {
return null
}
const project = parts[gitIndex - 1]
const repository = parts[gitIndex + 1]
if (!project || !repository) {
return null
}
return {
prefix: parts.slice(0, gitIndex - 1),
project,
repository
}
}
function makeCloudRef(
host: string,
organization: string,
project: string,
repository: string
): AzureDevOpsRepoRef {
return {
host: host.toLowerCase(),
organization,
project,
repository,
apiBaseUrl: joinUrl('https://dev.azure.com', [organization, project]),
webBaseUrl: joinUrl('https://dev.azure.com', [organization, project, '_git', repository])
}
}
function makeServerRef(
host: string,
origin: string,
prefix: readonly string[],
project: string,
repository: string
): AzureDevOpsRepoRef {
return {
host: host.toLowerCase(),
organization: null,
project,
repository,
apiBaseUrl: joinUrl(origin, [...prefix, project]),
webBaseUrl: joinUrl(origin, [...prefix, project, '_git', repository])
}
}
function parseDevAzureUrl(url: URL): AzureDevOpsRepoRef | null {
const parsed = parseGitHttpPath(url.pathname)
const organization = parsed?.prefix[0]
if (!parsed || !organization) {
return null
}
return makeCloudRef(url.hostname, organization, parsed.project, parsed.repository)
}
function parseVisualStudioUrl(url: URL): AzureDevOpsRepoRef | null {
const parsed = parseGitHttpPath(url.pathname)
const suffix = '.visualstudio.com'
const host = url.hostname.toLowerCase()
if (!parsed || !host.endsWith(suffix)) {
return null
}
const organization = host.slice(0, -suffix.length)
if (!organization) {
return null
}
return {
host,
organization,
project: parsed.project,
repository: parsed.repository,
apiBaseUrl: joinUrl(url.origin, [...parsed.prefix, parsed.project]),
webBaseUrl: joinUrl(url.origin, [...parsed.prefix, parsed.project, '_git', parsed.repository])
}
}
function parseCloudSshPath(host: string, rawPath: string): AzureDevOpsRepoRef | null {
if (host.toLowerCase() !== 'ssh.dev.azure.com') {
return null
}
const parts = splitPath(rawPath)
if (parts.length < 4 || parts[0].toLowerCase() !== 'v3') {
return null
}
const [, organization, project, repository] = parts
if (!organization || !project || !repository) {
return null
}
return makeCloudRef('dev.azure.com', organization, project, repository)
}
function parseScpLike(remoteUrl: string): AzureDevOpsRepoRef | null {
const match = remoteUrl.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
if (!match) {
return null
}
return parseCloudSshPath(match[1], match[2])
}
export function parseAzureDevOpsRepoRef(remoteUrl: string): AzureDevOpsRepoRef | null {
const trimmed = remoteUrl.trim()
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
return parseScpLike(trimmed)
}
try {
const url = new URL(trimmed)
const host = url.hostname.toLowerCase()
if (host === 'ssh.dev.azure.com') {
return parseCloudSshPath(host, url.pathname)
}
if (!['http:', 'https:', 'ssh:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
if (host === 'dev.azure.com') {
return parseDevAzureUrl(url)
}
if (host.endsWith('.visualstudio.com')) {
return parseVisualStudioUrl(url)
}
const parsed = parseGitHttpPath(url.pathname)
if (!parsed || !['http:', 'https:'].includes(url.protocol.toLowerCase())) {
return null
}
// Why: Azure DevOps Server remotes are self-hosted and only reliably
// identifiable by the `_git` path convention.
return makeServerRef(host, url.origin, parsed.prefix, parsed.project, parsed.repository)
} catch {
return null
}
}
export async function getAzureDevOpsRepoRefForRemote(
repoPath: string,
remoteName: string
): Promise<AzureDevOpsRepoRef | null> {
const cacheKey = `${repoPath}\0${remoteName}`
if (repoRefCache.has(cacheKey)) {
return repoRefCache.get(cacheKey)!
}
try {
const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath
})
const result = parseAzureDevOpsRepoRef(stdout)
repoRefCache.set(cacheKey, result)
return result
} catch {
repoRefCache.set(cacheKey, null)
return null
}
}
export async function getAzureDevOpsRepoRef(repoPath: string): Promise<AzureDevOpsRepoRef | null> {
return getAzureDevOpsRepoRefForRemote(repoPath, 'origin')
}
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest'
import { clampGrabPayload } from './browser-grab-payload'
function makeRawPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
page: {
sanitizedUrl: 'https://example.com/page?access_token=secret#hash',
title: 'Example',
viewportWidth: 1280,
viewportHeight: 720,
scrollX: 0,
scrollY: 0,
devicePixelRatio: 1,
capturedAt: '2026-05-15T00:00:00.000Z'
},
target: {
tagName: 'button',
selector: 'button.primary',
elementPath: 'main > button.primary',
fullPath: 'html > body > main > button.primary',
cssClasses: 'primary',
nearbyElements: ['span "Label"'],
selectedText: '',
isFixed: false,
reactComponents: '<App> <Button>',
sourceFile: 'src/Button.tsx:12:4',
textSnippet: 'Submit',
htmlSnippet: '<button class="primary">Submit</button>',
attributes: { class: 'primary', type: 'button' },
accessibility: {
role: 'button',
accessibleName: 'Submit',
ariaLabel: null,
ariaLabelledBy: null
},
rectViewport: { x: 10, y: 20, width: 100, height: 40 },
rectPage: { x: 10, y: 20, width: 100, height: 40 },
computedStyles: {
display: 'inline-flex',
position: 'static',
width: '100px',
height: '40px',
margin: '0px',
padding: '8px',
color: 'rgb(0, 0, 0)',
backgroundColor: 'rgba(0, 0, 0, 0)',
border: '0px none',
borderRadius: '0px',
fontFamily: 'Geist',
fontSize: '14px',
fontWeight: '400',
lineHeight: '20px',
textAlign: 'center',
zIndex: 'auto'
}
},
nearbyText: ['Submit'],
ancestorPath: ['button', 'main'],
screenshot: null,
...overrides
}
}
describe('clampGrabPayload', () => {
it('redacts secret-bearing cssClasses outside the attribute path', () => {
const payload = clampGrabPayload(
makeRawPayload({
target: {
...(makeRawPayload().target as Record<string, unknown>),
cssClasses: 'primary access_token=secret'
}
})
)
expect(payload?.target.cssClasses).toBe('[redacted]')
})
it('redacts secret-bearing browser annotation metadata fields', () => {
const payload = clampGrabPayload(
makeRawPayload({
target: {
...(makeRawPayload().target as Record<string, unknown>),
elementPath: 'main > button[aria-label="access_token=secret"]',
fullPath: 'body > main > button#client_secret',
reactComponents: '<App> <PasswordSecretPanel>',
sourceFile: 'src/client_secret/Button.tsx:12:4',
nearbyElements: ['span "api_key=secret"'],
selectedText: 'password=secret',
accessibility: {
role: 'button',
accessibleName: 'access_token=secret',
ariaLabel: 'access_token=secret',
ariaLabelledBy: null
}
}
})
)
expect(payload?.target.elementPath).toBe('[redacted]')
expect(payload?.target.fullPath).toBe('[redacted]')
expect(payload?.target.reactComponents).toBe('[redacted]')
expect(payload?.target.sourceFile).toBe('[redacted]')
expect(payload?.target.nearbyElements).toEqual(['[redacted]'])
expect(payload?.target.selectedText).toBe('[redacted]')
expect(payload?.target.accessibility.accessibleName).toBe('[redacted]')
expect(payload?.target.accessibility.ariaLabel).toBe('[redacted]')
})
it('keeps and clamps browser annotation metadata fields', () => {
const payload = clampGrabPayload(
makeRawPayload({
target: {
...(makeRawPayload().target as Record<string, unknown>),
nearbyElements: Array.from({ length: 10 }, (_, index) => `item-${index}`),
reactComponents: '<App>'.repeat(200),
sourceFile: 'src/Button.tsx:12:4'
}
})
)
expect(payload?.target.nearbyElements).toHaveLength(6)
expect(payload?.target.reactComponents?.length).toBeLessThanOrEqual(512)
expect(payload?.target.sourceFile).toBe('src/Button.tsx:12:4')
})
})
+40 -5
View File
@@ -105,6 +105,26 @@ export function clampGrabPayload(raw: unknown): BrowserGrabPayload | null {
return filtered
}
const safeMetadataStr = (value: unknown, max: number): string => {
const strValue = safeStr(value, max)
return strValue && containsSecret(strValue) ? '[redacted]' : strValue
}
const safeNullableMetadataStr = (value: unknown, max: number): string | null =>
safeMetadataStr(value, max) || null
const safeMetadataArray = (
arr: unknown,
maxEntries: number,
maxEntryLength: number
): string[] => {
const items = Array.isArray(arr) ? arr : []
return items
.slice(0, maxEntries)
.map((item) => safeMetadataStr(item, maxEntryLength))
.filter(Boolean)
}
const safeRect = (r: unknown): BrowserGrabRect => {
if (!r || typeof r !== 'object') {
return { x: 0, y: 0, width: 0, height: 0 }
@@ -136,15 +156,30 @@ export function clampGrabPayload(raw: unknown): BrowserGrabPayload | null {
},
target: {
tagName: safeStr(target.tagName, 50),
selector: safeStr(target.selector, 500),
selector: safeStr(target.selector, GRAB_BUDGET.selectorMaxLength),
elementPath: safeMetadataStr(target.elementPath, GRAB_BUDGET.pathMaxLength),
fullPath: safeMetadataStr(target.fullPath, GRAB_BUDGET.pathMaxLength),
cssClasses: safeMetadataStr(target.cssClasses, GRAB_BUDGET.cssClassesMaxLength),
nearbyElements: safeMetadataArray(
target.nearbyElements,
GRAB_BUDGET.nearbyElementsMaxEntries,
GRAB_BUDGET.nearbyElementMaxLength
),
selectedText: safeMetadataStr(target.selectedText, GRAB_BUDGET.selectedTextMaxLength) || null,
isFixed: target.isFixed === true,
reactComponents: safeNullableMetadataStr(
target.reactComponents,
GRAB_BUDGET.reactComponentsMaxLength
),
sourceFile: safeNullableMetadataStr(target.sourceFile, GRAB_BUDGET.sourceFileMaxLength),
textSnippet: clampStr(target.textSnippet, GRAB_BUDGET.textSnippetMaxLength),
htmlSnippet: clampStr(target.htmlSnippet, GRAB_BUDGET.htmlSnippetMaxLength),
attributes: safeAttributes(target.attributes),
accessibility: {
role: safeStr(accessibility?.role) || null,
accessibleName: safeStr(accessibility?.accessibleName) || null,
ariaLabel: safeStr(accessibility?.ariaLabel) || null,
ariaLabelledBy: safeStr(accessibility?.ariaLabelledBy) || null
role: safeNullableMetadataStr(accessibility?.role, 500),
accessibleName: safeNullableMetadataStr(accessibility?.accessibleName, 500),
ariaLabel: safeNullableMetadataStr(accessibility?.ariaLabel, 500),
ariaLabelledBy: safeNullableMetadataStr(accessibility?.ariaLabelledBy, 500)
},
rectViewport: safeRect(target.rectViewport),
rectPage: safeRect(target.rectPage),
+20 -10
View File
@@ -1,6 +1,24 @@
import type { BrowserGrabRect, BrowserGrabScreenshot } from '../../shared/browser-grab-types'
import { GRAB_BUDGET } from '../../shared/browser-grab-types'
const HIDE_BROWSER_GRAB_OVERLAY_SCRIPT = `(function(){
var g = window.__orcaGrab;
if (g && g.host) g.host.style.display = 'none';
document.querySelectorAll('[data-orca-browser-annotation-overlay]').forEach(function(el) {
el.setAttribute('data-orca-previous-display', el.style.display || '');
el.style.display = 'none';
});
})()`
const RESTORE_BROWSER_GRAB_OVERLAY_SCRIPT = `(function(){
var g = window.__orcaGrab;
if (g && g.host) g.host.style.display = '';
document.querySelectorAll('[data-orca-browser-annotation-overlay]').forEach(function(el) {
el.style.display = el.getAttribute('data-orca-previous-display') || '';
el.removeAttribute('data-orca-previous-display');
});
})()`
/**
* Capture a screenshot of the guest surface and optionally crop it to
* the given CSS-pixel rect.
@@ -26,20 +44,12 @@ export async function captureSelectionScreenshot(
// label don't appear in the screenshot. The overlay is restored after.
// Wrapped in try/finally so the overlay is always restored even if
// capturePage() throws (e.g., guest destroyed mid-capture).
await guest
.executeJavaScript(
`(function(){ var g = window.__orcaGrab; if (g && g.host) g.host.style.display = 'none'; })()`
)
.catch(() => {})
await guest.executeJavaScript(HIDE_BROWSER_GRAB_OVERLAY_SCRIPT).catch(() => {})
let image: Electron.NativeImage
try {
image = await guest.capturePage()
} finally {
await guest
.executeJavaScript(
`(function(){ var g = window.__orcaGrab; if (g && g.host) g.host.style.display = ''; })()`
)
.catch(() => {})
await guest.executeJavaScript(RESTORE_BROWSER_GRAB_OVERLAY_SCRIPT).catch(() => {})
}
if (image.isEmpty()) {
return null
+27
View File
@@ -22,6 +22,14 @@ function isTerminalTabSwitchChord(input: Electron.Input): boolean {
)
}
function isCtrlTabSwitchKey(input: Electron.Input): boolean {
return input.code === 'Tab' && input.control && !input.meta && !input.alt
}
function isControlKeyRelease(input: Electron.Input): boolean {
return input.type === 'keyUp' && (input.code === 'ControlLeft' || input.code === 'ControlRight')
}
export function setupGuestContextMenu(args: {
browserTabId: string
guest: Electron.WebContents
@@ -221,7 +229,26 @@ export function setupGuestShortcutForwarding(args: {
shouldForwardDictationShortcut?: ShouldForwardDictationShortcut
}): () => void {
const { browserTabId, guest, resolveRenderer, shouldForwardDictationShortcut } = args
let ctrlTabSwitching = false
const handler = (event: Electron.Event, input: Electron.Input): void => {
if (isCtrlTabSwitchKey(input)) {
event.preventDefault()
if (input.type === 'keyDown') {
ctrlTabSwitching = true
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true })
}
return
}
if (ctrlTabSwitching && isControlKeyRelease(input)) {
event.preventDefault()
ctrlTabSwitching = false
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:ctrlTabKeyUp')
return
}
if (input.type !== 'keyDown') {
return
}
@@ -12,6 +12,7 @@ const {
guestSetBackgroundThrottlingMock,
guestSetWindowOpenHandlerMock,
guestExecuteJavaScriptMock,
guestExecuteJavaScriptInIsolatedWorldMock,
guestIsDestroyedMock,
guestGetZoomFactorMock,
guestCapturePageMock,
@@ -25,6 +26,7 @@ const {
guestSetBackgroundThrottlingMock: vi.fn(),
guestSetWindowOpenHandlerMock: vi.fn(),
guestExecuteJavaScriptMock: vi.fn(),
guestExecuteJavaScriptInIsolatedWorldMock: vi.fn(),
guestIsDestroyedMock: vi.fn(() => false),
guestGetZoomFactorMock: vi.fn(() => 1),
guestCapturePageMock: vi.fn(),
@@ -53,6 +55,7 @@ function makeGuest(id: number) {
off: guestOffMock,
openDevTools: vi.fn(),
executeJavaScript: guestExecuteJavaScriptMock,
executeJavaScriptInIsolatedWorld: guestExecuteJavaScriptInIsolatedWorldMock,
getZoomFactor: guestGetZoomFactorMock,
capturePage: guestCapturePageMock,
getURL: vi.fn(() => 'https://example.com/')
@@ -67,6 +70,7 @@ describe('browserManager grab operations', () => {
vi.clearAllMocks()
guestIsDestroyedMock.mockReturnValue(false)
guestExecuteJavaScriptMock.mockResolvedValue(true)
guestExecuteJavaScriptInIsolatedWorldMock.mockResolvedValue(true)
browserManager.unregisterAll()
guest = makeGuest(101)
@@ -753,11 +757,11 @@ describe('browserManager grab operations', () => {
})
expect(guestExecuteJavaScriptMock).toHaveBeenNthCalledWith(
1,
`(function(){ var g = window.__orcaGrab; if (g && g.host) g.host.style.display = 'none'; })()`
expect.stringContaining('__orcaGrab')
)
expect(guestExecuteJavaScriptMock).toHaveBeenNthCalledWith(
2,
`(function(){ var g = window.__orcaGrab; if (g && g.host) g.host.style.display = ''; })()`
expect.stringContaining('__orcaGrab')
)
expect(guestExecuteJavaScriptMock).toHaveBeenNthCalledWith(3, 'window.innerWidth')
})
+99
View File
@@ -1108,6 +1108,75 @@ describe('browserManager', () => {
expect(rendererSendMock).toHaveBeenNthCalledWith(9, 'ui:hardReloadBrowserPage')
})
it('forwards browser guest Ctrl+Tab keydown and Ctrl release', () => {
const rendererSendMock = vi.fn()
const guest = {
id: 407,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockImplementation((id: number) => {
if (id === guest.id) {
return guest
}
if (id === rendererWebContentsId) {
return { isDestroyed: vi.fn(() => false), send: rendererSendMock }
}
return null
})
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'browser-1',
webContentsId: guest.id,
rendererWebContentsId
})
const beforeInputHandler = guestOnMock.mock.calls
.filter(([event]) => event === 'before-input-event')
.at(-1)?.[1] as
| ((event: { preventDefault: () => void }, input: Record<string, unknown>) => void)
| undefined
const keyDownPreventDefault = vi.fn()
beforeInputHandler?.(
{ preventDefault: keyDownPreventDefault },
{
type: 'keyDown',
code: 'Tab',
key: 'Tab',
meta: false,
control: true,
alt: false,
shift: false
}
)
const keyUpPreventDefault = vi.fn()
beforeInputHandler?.(
{ preventDefault: keyUpPreventDefault },
{
type: 'keyUp',
code: 'ControlRight',
key: 'Control',
meta: false,
control: false,
alt: false,
shift: false
}
)
expect(keyDownPreventDefault).toHaveBeenCalledTimes(1)
expect(keyUpPreventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:ctrlTabKeyDown', { shiftKey: false })
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:ctrlTabKeyUp')
})
it('cleans up prior guest listeners before re-registering the same tab', () => {
const guest = {
id: 808,
@@ -1220,6 +1289,7 @@ describe('browserManager', () => {
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock,
executeJavaScriptInIsolatedWorld: vi.fn().mockResolvedValue(true),
debugger: {
isAttached: debuggerIsAttached,
attach: debuggerAttach,
@@ -1358,5 +1428,34 @@ describe('browserManager', () => {
expect(debuggerAttach).toHaveBeenCalledWith('1.3')
expect(debuggerSendCommand).not.toHaveBeenCalled()
})
it('installs annotation viewport bridge in an isolated world', async () => {
const { guest } = makeGuest(4646)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-annotations',
webContentsId: guest.id as number,
rendererWebContentsId
})
const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
})
expect(ok).toBe(true)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
expect.any(Number),
[
expect.objectContaining({
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
})
],
false
)
})
})
})
+55
View File
@@ -37,6 +37,11 @@ import {
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { cleanElectronUserAgent } from './browser-session-ua'
import type { BrowserViewportOverride } from '../../shared/types'
import {
type BrowserAnnotationViewportBridgeOptions,
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
buildBrowserAnnotationViewportBridgeScript
} from '../../shared/browser-annotation-viewport-bridge'
// Why: mobile presets need a touch-capable UA or responsive sites serve the
// desktop variant based on UA sniffing. This is the Chrome DevTools default
@@ -109,6 +114,7 @@ export class BrowserManager {
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
private readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
private readonly annotationViewportBridgeOpsByTabId = new Map<string, Promise<unknown>>()
private readonly worktreeIdByTabId = new Map<string, string>()
private readonly policyAttachedGuestIds = new Set<number>()
private readonly policyCleanupByGuestId = new Map<number, () => void>()
@@ -681,6 +687,7 @@ export class BrowserManager {
// Why: drop any pending viewport-op chain for this tab so the Map doesn't
// retain a resolved promise keyed to a destroyed guest.
this.viewportOpsByTabId.delete(browserTabId)
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
}
unregisterAll(): void {
@@ -708,6 +715,7 @@ export class BrowserManager {
this.pendingPermissionEventsByGuestId.clear()
this.pendingPopupEventsByGuestId.clear()
this.pendingDownloadIdsByGuestId.clear()
this.annotationViewportBridgeOpsByTabId.clear()
}
getGuestWebContentsId(browserTabId: string): number | null {
@@ -956,6 +964,53 @@ export class BrowserManager {
}
}
async setAnnotationViewportBridge(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
): Promise<boolean> {
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
.then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
try {
return await next
} finally {
if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) === next) {
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
}
}
}
private async doSetAnnotationViewportBridgeImpl(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
): Promise<boolean> {
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
if (!webContentsId) {
return false
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
this.webContentsIdByTabId.delete(browserTabId)
this.tabIdByWebContentsId.delete(webContentsId)
return false
}
try {
// Why: the scroll bridge runs outside the page world so page monkey
// patches cannot read the per-tab token or tamper with bridge state.
await guest.executeJavaScriptInIsolatedWorld(
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
[{ code: buildBrowserAnnotationViewportBridgeScript(options) }],
false
)
return true
} catch {
return false
}
}
private async doSetViewportOverrideImpl(
browserTabId: string,
override: BrowserViewportOverride | null
@@ -119,4 +119,22 @@ describe('buildGuestOverlayScript', () => {
// The catch block should return '' not the raw URL
expect(script).toContain("return '';")
})
it('arm script slices text nodes before normalizing bounded text', () => {
const script = buildGuestOverlayScript('arm')
expect(script).toContain("normalizeText((node.nodeValue || '').slice(0, remaining))")
expect(script).toContain('value = value.slice(start, end)')
expect(script).not.toContain('normalizeText(node.nodeValue ||')
expect(script).not.toContain("(el.textContent || '').trim()")
expect(script).not.toContain('ref.textContent')
})
it('arm script walks nearby siblings without materializing sibling arrays', () => {
const script = buildGuestOverlayScript('arm')
expect(script).toContain('previousElementSibling')
expect(script).toContain('nextElementSibling')
expect(script).not.toContain('Array.from(parent.children)')
})
})
+418 -34
View File
@@ -68,8 +68,18 @@ const ARM_SCRIPT = `(function() {
nearbyTextEntryMaxLength: 200,
nearbyTextMaxEntries: 10,
htmlSnippetMaxLength: 4096,
ancestorPathMaxEntries: 10
ancestorPathMaxEntries: 10,
nearbyElementsMaxEntries: 6,
nearbyElementMaxLength: 160,
selectorMaxLength: 700,
pathMaxLength: 900,
cssClassesMaxLength: 500,
selectedTextMaxLength: 500,
sourceFileMaxLength: 500,
reactComponentsMaxLength: 500
};
var TEXT_NODE_SCAN_LIMIT = 80;
var NEARBY_ELEMENT_SCAN_LIMIT = 80;
// --- Safe attribute names ---
var SAFE_ATTRS = new Set([
@@ -118,9 +128,96 @@ const ARM_SCRIPT = `(function() {
}
}
function normalizeText(text) {
return String(text || '').trim().replace(/\\s+/g, ' ');
}
function getBoundedText(el, max) {
try {
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
var chunks = [];
var length = 0;
var inspected = 0;
var node = walker.nextNode();
while (node && length < max + 20 && inspected < TEXT_NODE_SCAN_LIMIT) {
inspected++;
var separatorLength = chunks.length > 0 ? 1 : 0;
var remaining = max + 20 - length - separatorLength;
if (remaining <= 0) break;
var value = normalizeText((node.nodeValue || '').slice(0, remaining));
if (value) {
chunks.push(value.slice(0, remaining));
length += Math.min(value.length, remaining) + separatorLength;
}
node = walker.nextNode();
}
return clampStr(normalizeText(chunks.join(' ')), max);
} catch (e) {
return '';
}
}
function getTextSnippet(el) {
var text = (el.textContent || '').trim().replace(/\\s+/g, ' ');
return clampStr(text, BUDGET.textSnippetMaxLength);
return getBoundedText(el, BUDGET.textSnippetMaxLength);
}
function getSelectedText() {
try {
var selection = window.getSelection ? window.getSelection() : null;
if (!selection || selection.rangeCount === 0) return '';
var chunks = [];
var length = 0;
var inspected = 0;
for (var i = 0; i < selection.rangeCount && length < BUDGET.selectedTextMaxLength + 20; i++) {
var range = selection.getRangeAt(i);
var walkerRoot = range.commonAncestorContainer;
var walker = document.createTreeWalker(
walkerRoot,
NodeFilter.SHOW_TEXT,
{
acceptNode: function(node) {
if (range.intersectsNode && !range.intersectsNode(node)) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
var node = walkerRoot.nodeType === Node.TEXT_NODE ? walkerRoot : walker.nextNode();
while (
node &&
length < BUDGET.selectedTextMaxLength + 20 &&
inspected < TEXT_NODE_SCAN_LIMIT
) {
inspected++;
var textNode = node;
var value = textNode.nodeValue || '';
var separatorLength = chunks.length > 0 ? 1 : 0;
var remaining = BUDGET.selectedTextMaxLength + 20 - length - separatorLength;
if (remaining <= 0) break;
if (value) {
var start = textNode === range.startContainer ? range.startOffset : 0;
var end = textNode === range.endContainer ? range.endOffset : value.length;
if (end > start + remaining) {
end = start + remaining;
}
if (textNode === range.startContainer) {
start = Math.min(start, value.length);
}
value = value.slice(start, end);
value = normalizeText(value);
}
if (value) {
chunks.push(value.slice(0, remaining));
length += Math.min(value.length, remaining) + separatorLength;
}
node = walker.nextNode();
}
}
return clampStr(chunks.join(' '), BUDGET.selectedTextMaxLength);
} catch (e) {
return '';
}
}
function getHtmlSnippet(el) {
@@ -171,14 +268,14 @@ const ARM_SCRIPT = `(function() {
var names = [];
for (var i = 0; i < parts.length; i++) {
var ref = document.getElementById(parts[i]);
if (ref) names.push((ref.textContent || '').trim());
if (ref) names.push(getBoundedText(ref, 100));
}
if (names.length) accessibleName = names.join(' ');
} else {
// Fall back to text content for buttons/links
var tag = el.tagName.toLowerCase();
if (tag === 'button' || tag === 'a' || tag === 'label') {
accessibleName = clampStr((el.textContent || '').trim(), 100);
accessibleName = getBoundedText(el, 100);
} else if (el.getAttribute('title')) {
accessibleName = el.getAttribute('title');
} else if (el.getAttribute('alt')) {
@@ -204,47 +301,162 @@ const ARM_SCRIPT = `(function() {
return result;
}
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(value);
}
return String(value).replace(/[^a-zA-Z0-9_-]/g, function(ch) {
return '\\\\' + ch;
});
}
function looksHashy(value) {
return /^[A-Za-z0-9_-]{12,}$/.test(value) && /\\d/.test(value) && /[A-Z]/.test(value);
}
function getStableClasses(el, maxCount) {
if (!el.classList) return [];
var result = [];
for (var i = 0; i < el.classList.length && result.length < maxCount; i++) {
var cls = el.classList[i];
if (!cls || cls.length > 60 || containsSecret(cls)) continue;
if (/^css-[a-z0-9]+$/i.test(cls) || looksHashy(cls)) continue;
result.push(cls);
}
return result;
}
function buildSelectorPart(el) {
var tag = el.tagName.toLowerCase();
var id = el.id;
if (id && !containsSecret(id)) {
return tag + '#' + cssEscape(id);
}
var classes = getStableClasses(el, 2);
if (classes.length > 0) {
return tag + classes.map(function(cls) { return '.' + cssEscape(cls); }).join('');
}
return tag;
}
function isUniqueSelector(selector) {
try {
return document.querySelectorAll(selector).length === 1;
} catch(e) {
return false;
}
}
function getNthOfTypeSuffix(current) {
var tag = current.tagName;
var index = 1;
var sibling = current.previousElementSibling;
while (sibling) {
if (sibling.tagName === tag) index++;
sibling = sibling.previousElementSibling;
}
if (index > 1) return ':nth-of-type(' + index + ')';
sibling = current.nextElementSibling;
while (sibling) {
if (sibling.tagName === tag) return ':nth-of-type(1)';
sibling = sibling.nextElementSibling;
}
return '';
}
function buildSelector(el) {
var parts = [];
var current = el;
while (current && current !== document.body && parts.length < 10) {
var tag = current.tagName.toLowerCase();
var id = current.id;
if (id) {
parts.unshift(tag + '#' + CSS.escape(id));
break;
}
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body && parts.length < 10) {
var part = buildSelectorPart(current);
var parent = current.parentElement;
if (parent) {
var siblings = Array.from(parent.children).filter(
function(c) { return c.tagName === current.tagName; }
);
if (siblings.length > 1) {
var index = siblings.indexOf(current) + 1;
parts.unshift(tag + ':nth-of-type(' + index + ')');
} else {
parts.unshift(tag);
}
} else {
parts.unshift(tag);
if (parent && !isUniqueSelector(parts.concat([part]).reverse().join(' > '))) {
part += getNthOfTypeSuffix(current);
}
parts.unshift(part);
var selector = parts.join(' > ');
if (isUniqueSelector(selector)) {
return clampStr(selector, BUDGET.selectorMaxLength);
}
current = parent;
}
return parts.join(' > ') || el.tagName.toLowerCase();
return clampStr(parts.join(' > ') || el.tagName.toLowerCase(), BUDGET.selectorMaxLength);
}
function buildReadablePath(el) {
var parts = [];
var current = el;
while (current && current !== document.documentElement && parts.length < 6) {
var tag = current.tagName.toLowerCase();
if (tag === 'html' || tag === 'body') break;
var label = tag;
var aria = current.getAttribute('aria-label');
var role = current.getAttribute('role');
var stableClasses = getStableClasses(current, 1);
if (current.id && !containsSecret(current.id)) {
label = '#' + cssEscape(current.id);
} else if (aria && !containsSecret(aria)) {
label = tag + '[aria-label="' + clampStr(aria, 40).replace(/"/g, '\\\\"') + '"]';
} else if (role && !containsSecret(role)) {
label = tag + '[role="' + clampStr(role, 30).replace(/"/g, '\\\\"') + '"]';
} else if (stableClasses.length > 0) {
label = '.' + cssEscape(stableClasses[0]);
}
parts.unshift(label);
current = current.parentElement;
}
return clampStr(parts.join(' > '), BUDGET.pathMaxLength);
}
function buildFullPath(el) {
var parts = [];
var current = el;
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement && parts.length < 20) {
parts.unshift(buildSelectorPart(current));
current = current.parentElement;
}
return clampStr(parts.join(' > '), BUDGET.pathMaxLength);
}
function getNearbyText(el) {
var results = [];
var parent = el.parentElement;
if (!parent) return results;
var siblings = parent.children;
for (var i = 0; i < siblings.length && results.length < BUDGET.nearbyTextMaxEntries; i++) {
if (siblings[i] === el) continue;
var text = (siblings[i].textContent || '').trim().replace(/\\s+/g, ' ');
function addSiblingText(sibling) {
if (!sibling) return;
var text = getBoundedText(sibling, BUDGET.nearbyTextEntryMaxLength);
if (text) {
results.push(clampStr(text, BUDGET.nearbyTextEntryMaxLength));
}
}
var inspected = 0;
var previous = el.previousElementSibling;
var next = el.nextElementSibling;
while (
results.length < BUDGET.nearbyTextMaxEntries &&
inspected < NEARBY_ELEMENT_SCAN_LIMIT &&
(previous || next)
) {
if (previous) {
var previousSibling = previous;
previous = previous.previousElementSibling;
inspected++;
addSiblingText(previousSibling);
}
if (
next &&
results.length < BUDGET.nearbyTextMaxEntries &&
inspected < NEARBY_ELEMENT_SCAN_LIMIT
) {
var nextSibling = next;
next = next.nextElementSibling;
inspected++;
addSiblingText(nextSibling);
}
}
return results;
}
@@ -260,9 +472,149 @@ const ARM_SCRIPT = `(function() {
return path;
}
function getNearbyElements(el) {
var parent = el.parentElement;
if (!parent) return [];
var result = [];
function addSibling(sibling) {
if (!sibling) return;
if (sibling === el) return;
var rect = sibling.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
var label = sibling.tagName.toLowerCase();
var stableClasses = getStableClasses(sibling, 1);
if (stableClasses.length > 0) label += '.' + stableClasses[0];
var text = getBoundedText(sibling, 50);
if (text) label += ' "' + clampStr(text, 50) + '"';
result.push(clampStr(label, BUDGET.nearbyElementMaxLength));
}
var inspected = 0;
var previous = el.previousElementSibling;
var next = el.nextElementSibling;
while (
result.length < BUDGET.nearbyElementsMaxEntries &&
inspected < NEARBY_ELEMENT_SCAN_LIMIT &&
(previous || next)
) {
if (previous) {
var previousSibling = previous;
previous = previous.previousElementSibling;
inspected++;
addSibling(previousSibling);
}
if (
next &&
result.length < BUDGET.nearbyElementsMaxEntries &&
inspected < NEARBY_ELEMENT_SCAN_LIMIT
) {
var nextSibling = next;
next = next.nextElementSibling;
inspected++;
addSibling(nextSibling);
}
}
return result;
}
function isElementFixed(el) {
var current = el;
while (current && current !== document.body) {
var position = window.getComputedStyle(current).position;
if (position === 'fixed' || position === 'sticky') return true;
current = current.parentElement;
}
return false;
}
function getFiberFromElement(el) {
var keys = Object.keys(el);
for (var i = 0; i < keys.length; i++) {
if (keys[i].indexOf('__reactFiber$') === 0 || keys[i].indexOf('__reactInternalInstance$') === 0) {
try {
return el[keys[i]] || null;
} catch (e) {
return null;
}
}
}
return null;
}
function getComponentNameFromFiber(fiber) {
if (!fiber) return null;
var type = fiber.type || fiber.elementType;
if (!type || typeof type === 'string') return null;
if (type.displayName || type.name) return type.displayName || type.name;
if (type.render && (type.render.displayName || type.render.name)) {
return type.render.displayName || type.render.name;
}
if (type.type && (type.type.displayName || type.type.name)) {
return type.type.displayName || type.type.name;
}
return null;
}
function shouldSkipReactName(name) {
if (!name || name.length <= 2) return true;
return /^(Fragment|Root|Routes|Route|Outlet|Provider|Consumer|Profiler|Suspense)$/.test(name) ||
/(?:Boundary|BoundaryHandler|Router|Provider|Consumer|Context|Wrapper)$/.test(name) ||
/^(Inner|Outer|Client|Server|RSC|Dev|React|Hot)/.test(name);
}
function cleanSourcePath(path) {
if (!path) return '';
return String(path)
.replace(/[?#].*$/, '')
.replace(/^turbopack:\\/\\/\\/\\[project\\]\\//, '')
.replace(/^webpack-internal:\\/\\/\\/\\.\\//, '')
.replace(/^webpack-internal:\\/\\/\\//, '')
.replace(/^webpack:\\/\\/\\/\\.\\//, '')
.replace(/^webpack:\\/\\/\\//, '')
.replace(/^turbopack:\\/\\/\\//, '')
.replace(/^https?:\\/\\/[^/]+\\//, '')
.replace(/^file:\\/\\/\\//, '/')
.replace(/^\\([^)]+\\)\\/\\.\\//, '')
.replace(/^\\.\\//, '');
}
function getReactMetadata(el) {
try {
var fiber = getFiberFromElement(el);
var components = [];
var sourceFile = null;
var depth = 0;
while (fiber && depth < 35) {
var name = getComponentNameFromFiber(fiber);
if (name && !shouldSkipReactName(name) && components.indexOf(name) === -1 && components.length < 6) {
components.push(name);
}
var source = fiber._debugSource || (fiber._debugOwner && fiber._debugOwner._debugSource);
if (!sourceFile && source && source.fileName && source.lineNumber) {
sourceFile = cleanSourcePath(source.fileName) + ':' + source.lineNumber +
(source.columnNumber !== undefined ? ':' + source.columnNumber : '');
if (containsSecret(sourceFile)) {
sourceFile = null;
}
}
fiber = fiber.return;
depth++;
}
return {
reactComponents: components.length > 0
? clampStr(components.slice().reverse().map(function(c) { return '<' + c + '>'; }).join(' '), BUDGET.reactComponentsMaxLength)
: null,
sourceFile: sourceFile ? clampStr(sourceFile, BUDGET.sourceFileMaxLength) : null
};
} catch (e) {
return { reactComponents: null, sourceFile: null };
}
}
// --- Build full payload for an element ---
function extractPayload(el) {
var rect = el.getBoundingClientRect();
var react = getReactMetadata(el);
return {
page: {
sanitizedUrl: sanitizeUrl(window.location.href),
@@ -277,6 +629,16 @@ const ARM_SCRIPT = `(function() {
target: {
tagName: el.tagName.toLowerCase(),
selector: buildSelector(el),
elementPath: buildReadablePath(el),
fullPath: buildFullPath(el),
cssClasses: containsSecret(el.getAttribute('class') || '')
? '[redacted]'
: clampStr(el.getAttribute('class') || '', BUDGET.cssClassesMaxLength),
nearbyElements: getNearbyElements(el),
selectedText: getSelectedText() || null,
isFixed: isElementFixed(el),
reactComponents: react.reactComponents,
sourceFile: react.sourceFile,
textSnippet: getTextSnippet(el),
htmlSnippet: getHtmlSnippet(el),
attributes: getSafeAttributes(el),
@@ -349,7 +711,7 @@ const ARM_SCRIPT = `(function() {
// Build label text
var tag = el.tagName.toLowerCase();
var role = el.getAttribute('role');
var text = (el.textContent || '').trim().replace(/\\s+/g, ' ');
var text = getBoundedText(el, 40);
if (text.length > 40) text = text.slice(0, 37) + '...';
var w = Math.round(rect.width);
var h = Math.round(rect.height);
@@ -420,6 +782,16 @@ const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) {
return;
}
function extractSelectedPayload(el) {
try {
return grab.extractPayload(el);
} catch (error) {
grab.cleanup();
reject(error instanceof Error ? error : new Error('Failed to extract element context'));
return null;
}
}
function onClick(e) {
e.preventDefault();
e.stopPropagation();
@@ -432,7 +804,8 @@ const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) {
reject(new Error('cancelled'));
return;
}
var payload = grab.extractPayload(el);
var payload = extractSelectedPayload(el);
if (!payload) return;
// Why: freeze the highlight instead of removing it so the user sees
// which element was selected while the copy menu is shown. Teardown
// happens later when the renderer calls setGrabMode(false) or re-arms.
@@ -456,7 +829,8 @@ const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) {
reject(new Error('cancelled'));
return;
}
var payload = grab.extractPayload(el);
var payload = extractSelectedPayload(el);
if (!payload) return;
grab.freezeHighlight();
resolve({ __orcaContextMenu: true, payload: payload });
}
@@ -482,7 +856,13 @@ const FINALIZE_SCRIPT = `(function() {
if (!grab) return null;
var el = grab.getCurrentElement();
if (!el) return null;
var payload = grab.extractPayload(el);
var payload = null;
try {
payload = grab.extractPayload(el);
} catch (e) {
grab.cleanup();
return null;
}
grab.cleanup();
return payload;
})()`
@@ -499,7 +879,11 @@ const EXTRACT_HOVER_SCRIPT = `(function() {
if (!grab) return null;
var el = grab.getCurrentElement();
if (!el) return null;
return grab.extractPayload(el);
try {
return grab.extractPayload(el);
} catch (e) {
return null;
}
})()`
// ---------------------------------------------------------------------------
+62
View File
@@ -257,4 +257,66 @@ describe('ClaudeUsageStore', () => {
expect(summary.estimatedCostUsd).toBeCloseTo(8.07)
})
it('returns automation usage for a single matching worktree session', async () => {
const worktreeId = 'repo-1::/workspace/repo-a'
const store = createStoreWithState({
scanState: {
enabled: true,
lastScanStartedAt: 1,
lastScanCompletedAt: 2,
lastScanError: null
},
sessions: [
{
sessionId: 'session-1',
firstTimestamp: '2026-04-09T15:00:00.000Z',
lastTimestamp: '2026-04-09T15:05:00.000Z',
model: 'claude-sonnet-4-6',
lastCwd: '/workspace/repo-a',
lastGitBranch: 'feature/a',
primaryWorktreeId: worktreeId,
primaryRepoId: 'repo-1',
turnCount: 1,
totalInputTokens: 1000,
totalOutputTokens: 500,
totalCacheReadTokens: 200,
totalCacheWriteTokens: 100,
locationBreakdown: [
{
locationKey: `worktree:${worktreeId}`,
projectLabel: 'Repo A',
repoId: 'repo-1',
worktreeId,
turnCount: 1,
inputTokens: 1000,
outputTokens: 500,
cacheReadTokens: 200,
cacheWriteTokens: 100
}
]
}
]
})
;(store as unknown as { refresh: typeof store.refresh }).refresh = vi.fn().mockResolvedValue({
enabled: true,
isScanning: false,
lastScanStartedAt: 1,
lastScanCompletedAt: 2,
lastScanError: null,
hasAnyClaudeData: true
})
const usage = await store.getAutomationRunUsage({
worktreeId,
terminalSessionId: 'tab-1',
startedAt: new Date('2026-04-09T14:59:00.000Z').getTime(),
completedAt: new Date('2026-04-09T15:06:00.000Z').getTime()
})
expect(usage.status).toBe('known')
expect(usage.providerSessionId).toBe('session-1')
expect(usage.totalTokens).toBe(1800)
expect(usage.estimatedCostUsd).toBeCloseTo(0.010935)
})
})
+136 -1
View File
@@ -12,6 +12,7 @@ import type {
ClaudeUsageSessionRow,
ClaudeUsageSummary
} from '../../shared/claude-usage-types'
import type { AutomationRunUsage } from '../../shared/automations-types'
import type { Store } from '../persistence'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { ClaudeUsagePersistedState } from './types'
@@ -19,6 +20,7 @@ import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from
const SCHEMA_VERSION = 3
const STALE_MS = 5 * 60_000
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
// Why: capture the path after configureDevUserDataPath() but before app.setName()
// mutates Electron's derived userData location, matching the persistence/store pattern.
@@ -36,6 +38,13 @@ type ClaudeModelPricing = {
cacheWriteAboveThreshold?: number
}
type AutomationUsageLookupInput = {
worktreeId: string | null
terminalSessionId: string | null
startedAt: number | null
completedAt: number | null
}
const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000
const SONNET_LONG_CONTEXT_PRICING = {
thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS,
@@ -298,7 +307,16 @@ export class ClaudeUsageStore {
if (parsed.schemaVersion !== SCHEMA_VERSION) {
// Why: scanner semantics affect persisted totals, so old Claude caches
// must be rebuilt after parser/source changes instead of reused briefly.
return getDefaultState()
// Preserve scanState.enabled so existing users keep tracking on across
// schema bumps; the next refresh will repopulate the analytics.
const defaults = getDefaultState()
return {
...defaults,
scanState: {
...defaults.scanState,
enabled: parsed.scanState?.enabled ?? defaults.scanState.enabled
}
}
}
return {
...getDefaultState(),
@@ -622,6 +640,123 @@ export class ClaudeUsageStore {
})
}
async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise<AutomationRunUsage> {
const collectedAt = Date.now()
const unavailable = (
unavailableReason: AutomationRunUsage['unavailableReason'],
unavailableMessage: string
): AutomationRunUsage => ({
status: 'unavailable',
provider: 'claude',
model: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningOutputTokens: null,
totalTokens: null,
estimatedCostUsd: null,
estimatedCostSource: null,
providerSessionId: null,
attribution: null,
collectedAt,
unavailableReason,
unavailableMessage
})
if (!this.state.scanState.enabled) {
return unavailable('usage_not_enabled', 'Claude usage tracking is not enabled.')
}
if (!input.worktreeId || !input.startedAt || !input.completedAt) {
return unavailable('no_matching_session', 'Run session metadata is incomplete.')
}
const scanState = await this.refresh(true)
if (scanState.lastScanError) {
return unavailable('scan_failed', scanState.lastScanError)
}
const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS
const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS
const candidates = this.state.sessions.filter((session) => {
const first = new Date(session.firstTimestamp).getTime()
const last = new Date(session.lastTimestamp).getTime()
if (!Number.isFinite(first) || !Number.isFinite(last)) {
return false
}
if (session.sessionId === input.terminalSessionId) {
return true
}
if (first < windowStart || first > windowEnd || last > windowEnd) {
return false
}
return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId)
})
if (candidates.length === 0) {
return unavailable('no_matching_session', 'No Claude usage session matched this run.')
}
if (candidates.length > 1) {
return unavailable(
'ambiguous_session',
'Multiple Claude usage sessions matched this run window.'
)
}
const session = candidates[0]
const scopedLocations = session.locationBreakdown.filter(
(entry) => entry.worktreeId === input.worktreeId
)
const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown
const totals = locations.reduce(
(acc, entry) => {
acc.turns += entry.turnCount
acc.inputTokens += entry.inputTokens
acc.outputTokens += entry.outputTokens
acc.cacheReadTokens += entry.cacheReadTokens
acc.cacheWriteTokens += entry.cacheWriteTokens
return acc
},
{
turns: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0
}
)
const estimatedCostUsd = estimateCostUsd(
session.model,
totals.inputTokens,
totals.outputTokens,
totals.cacheReadTokens,
totals.cacheWriteTokens
)
return {
status: 'known',
provider: 'claude',
model: session.model,
inputTokens: totals.inputTokens,
outputTokens: totals.outputTokens,
cacheReadTokens: totals.cacheReadTokens,
cacheWriteTokens: totals.cacheWriteTokens,
reasoningOutputTokens: null,
totalTokens:
totals.inputTokens + totals.outputTokens + totals.cacheReadTokens + totals.cacheWriteTokens,
estimatedCostUsd,
estimatedCostSource: estimatedCostUsd === null ? null : 'api_equivalent',
providerSessionId: session.sessionId,
// Why: Orca terminal tab ids and Claude usage session ids are different
// systems today, so attribution is intentionally limited to one local
// provider session in the run's worktree/time window.
attribution: 'provider_session_time_window',
collectedAt,
unavailableReason: null,
unavailableMessage: null
}
}
private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) {
const cutoff = getRangeCutoff(range)
return this.state.dailyAggregates.filter((entry) => {
+2 -1
View File
@@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
readHooksJson,
removeManagedCommands,
@@ -82,7 +83,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/claude') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`,
buildWindowsAgentHookPostCommand('claude'),
'exit /b 0',
''
].join('\r\n')
@@ -74,6 +74,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
floatingTerminalCwd: '~',
floatingTerminalTriggerLocation: 'floating-button',
diffDefaultView: 'inline',
combinedDiffFileTreeVisibleByDefault: false,
notifications: {
enabled: true,
agentTaskComplete: true,
@@ -93,6 +94,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
visibleTaskProviders: ['github', 'gitlab', 'linear'],
defaultRepoSelection: null,
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
@@ -720,17 +720,35 @@ export class CodexRuntimeHomeService {
}
private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null {
let rawContents: string
try {
const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown
rawContents = readFileSync(snapshotPath, 'utf-8')
} catch {
return null
}
try {
const parsed = JSON.parse(rawContents) as unknown
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
'authJson' in parsed &&
(typeof parsed.authJson === 'string' || parsed.authJson === null)
(typeof (parsed as { authJson: unknown }).authJson === 'string' ||
(parsed as { authJson: unknown }).authJson === null)
) {
return parsed as CodexSystemDefaultSnapshot
}
// Why: pre-PR snapshots wrote raw auth.json contents verbatim. Treat any
// valid JSON object without an authJson wrapper as the legacy format so
// upgraders do not lose their system-default auth on first deselect.
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
!('authJson' in parsed)
) {
return { authJson: rawContents }
}
} catch {
return null
}
+2
View File
@@ -67,6 +67,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
floatingTerminalCwd: '~',
floatingTerminalTriggerLocation: 'floating-button',
diffDefaultView: 'inline',
combinedDiffFileTreeVisibleByDefault: false,
notifications: {
enabled: true,
agentTaskComplete: true,
@@ -86,6 +87,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
visibleTaskProviders: ['github', 'gitlab', 'linear'],
defaultRepoSelection: null,
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
+18
View File
@@ -158,6 +158,16 @@ describe('resolveClaudeCommand', () => {
expect(resolveClaudeCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(bunPath)
})
it('finds native Windows claude.exe in user-local bin', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-'))
const nativePath = join(root, '.local', 'bin', 'claude.exe')
makeExecutable(nativePath)
expect(resolveClaudeCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe(
nativePath
)
})
it('returns the bare command when no filesystem candidate exists', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-'))
@@ -195,4 +205,12 @@ describe('getVersionManagerBinPaths', () => {
expect(paths).toContain(join(root, '.local', 'share', 'pnpm'))
expect(paths).not.toContain(join(root, 'Library', 'pnpm'))
})
it('includes Windows user-local bin for native CLI installers', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-'))
const paths = getVersionManagerBinPaths({ platform: 'win32', pathEnv: '', homePath: root })
expect(paths).toContain(join(root, '.local', 'bin'))
expect(paths).toContain(join(root, 'AppData', 'Roaming', 'npm'))
})
})
+3
View File
@@ -98,6 +98,9 @@ function getVersionManagerDirectories(
}
if (platform === 'win32') {
// Why: Anthropic's native Windows installer places claude.exe here, and
// GUI-launched Orca may not inherit the user's PATH entry for it.
directories.push(join(homePath, '.local', 'bin'))
directories.push(join(homePath, 'AppData', 'Roaming', 'npm'))
directories.push(join(homePath, 'AppData', 'Local', 'pnpm'))
directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin'))
+98 -1
View File
@@ -654,11 +654,108 @@ describe('CodexUsageStore', () => {
sessions: [],
dailyAggregates: [],
scanState: {
enabled: false,
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
})
it('returns automation usage for a single matching worktree session', async () => {
const worktreeId = 'repo-1::/workspace/repo'
const store = createStoreWithState({
scanState: {
enabled: true,
lastScanStartedAt: 1,
lastScanCompletedAt: 2,
lastScanError: null
},
sessions: [
{
sessionId: 'session-1',
firstTimestamp: '2026-04-10T15:00:00.000Z',
lastTimestamp: '2026-04-10T15:05:00.000Z',
primaryModel: 'gpt-5',
hasMixedModels: false,
primaryProjectLabel: 'Repo',
hasMixedLocations: false,
primaryWorktreeId: worktreeId,
primaryRepoId: 'repo-1',
eventCount: 1,
totalInputTokens: 1000,
totalCachedInputTokens: 400,
totalOutputTokens: 250,
totalReasoningOutputTokens: 100,
totalTokens: 1250,
hasInferredPricing: false,
locationBreakdown: [
{
locationKey: `worktree:${worktreeId}`,
projectLabel: 'Repo',
repoId: 'repo-1',
worktreeId,
eventCount: 1,
inputTokens: 1000,
cachedInputTokens: 400,
outputTokens: 250,
reasoningOutputTokens: 100,
totalTokens: 1250,
hasInferredPricing: false
}
],
modelBreakdown: [
{
modelKey: 'gpt-5',
modelLabel: 'gpt-5',
eventCount: 1,
inputTokens: 1000,
cachedInputTokens: 400,
outputTokens: 250,
reasoningOutputTokens: 100,
totalTokens: 1250,
hasInferredPricing: false
}
],
locationModelBreakdown: [
{
locationKey: `worktree:${worktreeId}`,
modelKey: 'gpt-5',
modelLabel: 'gpt-5',
repoId: 'repo-1',
worktreeId,
eventCount: 1,
inputTokens: 1000,
cachedInputTokens: 400,
outputTokens: 250,
reasoningOutputTokens: 100,
totalTokens: 1250,
hasInferredPricing: false
}
]
}
]
})
;(store as unknown as { refresh: typeof store.refresh }).refresh = vi.fn().mockResolvedValue({
enabled: true,
isScanning: false,
lastScanStartedAt: 1,
lastScanCompletedAt: 2,
lastScanError: null,
hasAnyCodexData: true
})
const usage = await store.getAutomationRunUsage({
worktreeId,
terminalSessionId: 'tab-1',
startedAt: new Date('2026-04-10T14:59:00.000Z').getTime(),
completedAt: new Date('2026-04-10T15:06:00.000Z').getTime()
})
expect(usage.status).toBe('known')
expect(usage.providerSessionId).toBe('session-1')
expect(usage.cacheReadTokens).toBe(400)
expect(usage.reasoningOutputTokens).toBe(100)
expect(usage.estimatedCostUsd).toBeCloseTo(0.0033)
})
})

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