mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/telemetry-gaps
This commit is contained in:
+100
-7
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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/*
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
---
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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,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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
---
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-3
@@ -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,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
@@ -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",
|
||||
|
||||
Generated
+39
-36
@@ -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': {}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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}`
|
||||
|
||||
@@ -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}]`)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -41,7 +41,6 @@ function createService(now: () => number, blocker = createBlocker()): AgentAwake
|
||||
blocker,
|
||||
now,
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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('"', '\\"')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: []
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})()`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user