Merge remote-tracking branch 'origin/main' into brennanb2025/orchestration-relationship-ux

# Conflicts:
#	src/cli/index.test.ts
#	src/main/ipc/worktrees.ts
#	src/main/persistence.test.ts
#	src/main/runtime/orca-runtime.test.ts
#	src/main/runtime/orca-runtime.ts
#	src/main/runtime/rpc/methods/worktree.test.ts
#	src/main/runtime/rpc/methods/worktree.ts
#	src/renderer/src/components/sidebar/SidebarHeader.tsx
#	src/renderer/src/components/sidebar/WorktreeCard.tsx
#	src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
#	src/renderer/src/components/sidebar/WorktreeList.tsx
#	src/renderer/src/components/sidebar/worktree-list-groups.test.ts
#	src/renderer/src/components/sidebar/worktree-list-groups.ts
#	src/renderer/src/store/slices/worktrees.test.ts
#	src/shared/runtime-types.ts
This commit is contained in:
brennanb2025
2026-05-15 14:53:24 -07:00
831 changed files with 89124 additions and 14651 deletions
+2
View File
@@ -95,6 +95,8 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest *stable
**Safety guarantees:**
- Stable releases are refused if the new version isn't strictly greater than the latest published stable. This is the only rule `electron-updater` actually needs — it compares semver within the `latest` channel, so a regressing stable is the one thing that breaks auto-update for fresh installs.
- Complete RC draft releases created by the release workflow are published before cutting a new tag, so a GitHub queue failure in the final publish job can be resumed safely.
- If the latest RC tag exists but is still draft-only or missing its GitHub Release, the workflow resumes that tag before cutting the next RC. This keeps retries from leaving multiple unpublished draft releases behind.
- Off-main releases (when `ref` is not the tip of `main`) only push the tag. `main` is never mutated from a non-main ref, so you can safely release an older commit without polluting history.
- When `ref` is the tip of `main`, the version-bump commit is fast-forwarded onto `main` so local `package.json` stays in sync with what's shipped.
+14
View File
@@ -40,6 +40,14 @@ jobs:
run_install: false
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool
# Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
# permission on Linux runners; node-pty's install fallback then fails
# before this smoke job can exercise the native package.
- 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"
- run: pnpm install --frozen-lockfile
- run: pnpm verify:computer-native
- run: pnpm build:cli
@@ -80,6 +88,12 @@ jobs:
with:
run_install: false
- run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool
# Why: keep scheduled Linux e2e on the same native install path as PR
# smoke and pr.yml's verify job.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
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"
- run: pnpm install --frozen-lockfile
- run: pnpm verify:computer-native
- run: pnpm build:cli
+3
View File
@@ -71,6 +71,9 @@ jobs:
- name: Check feature wall asset budget
run: pnpm check:feature-wall-assets
- name: Verify macOS entitlements
run: pnpm verify:macos-entitlements
- name: Typecheck
run: pnpm typecheck
+79 -41
View File
@@ -16,7 +16,7 @@ name: Cut Release
# 5. Write package.json, commit (detached), tag, push tag.
# 6. If ref was the tip of origin/main, fast-forward main to include the
# version-bump commit so developers see the right version locally.
# 7. Invoke release.yml to build and publish artifacts.
# 7. Build and publish artifacts from the tag.
on:
workflow_dispatch:
@@ -61,7 +61,7 @@ jobs:
timeout-minutes: 15
outputs:
tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }}
should_release: ${{ steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
steps:
- name: Checkout ref
uses: actions/checkout@v4
@@ -174,9 +174,21 @@ jobs:
echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}"
echo "Reason: ${{ steps.existing.outputs.reason }}"
- name: Publish complete release-cut RC drafts from prior runs
id: publish_drafts
# Why: this must run even when the current slot already cut a tag; a
# later cron retry may be the first chance to unstick a complete RC draft.
if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node config/scripts/publish-complete-draft-releases.mjs
- name: Compute next version
id: version
if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
# Why: if an RC run only had complete drafts to publish, stop there
# instead of immediately cutting another RC after the recovered one.
# Stable dispatches should still cut the requested stable release.
if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) && !((github.event_name == 'schedule' || inputs.kind == 'rc') && steps.publish_drafts.outputs.published_count != '0' && steps.publish_drafts.outputs.skipped_count == '0')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
@@ -238,22 +250,50 @@ jobs:
' "$(strip_pre "$1")" "$2"
}
next_rc_in_series() {
# Given a base version, find the highest existing -rc.N for it
# and return base-rc.(N+1). If none exists, base-rc.0.
highest_rc_for_base() {
local base="$1"
local highest
highest="$(git tag --list "v${base}-rc.*" \
git tag --list "v${base}-rc.*" \
| sed -E "s/^v${base//./\\.}-rc\.//" \
| sort -n \
| tail -n 1 || true)"
if [[ -z "$highest" ]]; then
echo "${base}-rc.0"
| tail -n 1 || true
}
release_draft_state() {
# Prints: true, false, or missing.
local tag="$1"
local state_file="$RUNNER_TEMP/release-state-${tag//[^A-Za-z0-9_.-]/_}"
if gh release view "$tag" \
--repo "$GITHUB_REPOSITORY" \
--json isDraft \
--jq '.isDraft' >"$state_file" 2>/dev/null; then
cat "$state_file"
else
echo "${base}-rc.$((highest + 1))"
echo "missing"
fi
}
recover_unpublished_tag() {
local tag="$1"
local reason="$2"
local release_state
release_state="$(release_draft_state "$tag")"
case "$release_state" in
missing|true)
echo "::warning::Tag $tag already exists but has no published release ($reason) - recovering by re-dispatching the release build against the existing tag."
echo "recovered_tag=$tag" >>"$GITHUB_OUTPUT"
echo "recovered=true" >>"$GITHUB_OUTPUT"
exit 0
;;
false)
return 1
;;
*)
echo "::error::Unexpected release state for $tag: $release_state" >&2
exit 1
;;
esac
}
# Fresh repo fallback so the math below never divides by zero.
if [[ -z "$latest_stable" ]]; then
latest_stable="0.0.0"
@@ -270,7 +310,18 @@ jobs:
# that class of bug; minor/major RCs are cut by running
# that stable kind first.
base="$(bump "$latest_stable" patch)"
new="$(next_rc_in_series "$base")"
highest_rc="$(highest_rc_for_base "$base")"
if [[ -z "$highest_rc" ]]; then
new="${base}-rc.0"
else
existing_rc_tag="v${base}-rc.${highest_rc}"
# Why: a failed or GitHub-stuck run can leave the highest RC
# tag attached to a draft/missing release. Resume that tag so
# retries finish the intended release instead of piling up
# vX.Y.Z-rc.N+1 drafts.
recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true
new="${base}-rc.$((highest_rc + 1))"
fi
;;
patch|minor|major)
new="$(bump "$latest_stable" "$KIND")"
@@ -289,7 +340,7 @@ jobs:
# Orphan-tag recovery.
#
# Why: if a previous cut pushed the tag but was cancelled (or the
# dependent release.yml job otherwise failed to start) before the
# dependent release build jobs otherwise failed to start) before the
# GitHub Release was published, the tag now exists on the remote
# but "latest stable" still points at the prior version. Every
# subsequent patch cut then recomputes the same version and dies
@@ -301,7 +352,7 @@ jobs:
# Recovery policy: if the tag exists AND no GitHub release has
# been published for it (draft-or-absent both count as "not
# shipped"), treat this as a resumable state: emit the existing
# tag as the job output so the downstream release.yml job runs
# tag as the job output so the downstream release build jobs run
# against it and finishes what the earlier attempt started. The
# bump/commit/push steps are skipped in that case — there is
# nothing to bump; the tag is already on the remote.
@@ -310,32 +361,10 @@ jobs:
# already exist — that's a real conflict (someone tagged manually
# over a shipped version) and needs human attention.
if git rev-parse "v$new" >/dev/null 2>&1; then
# Why: use the REST API, not `gh release view`. `gh api` returns
# `true`/`false` for a real release, while a 404 means the tag is
# orphaned and can be safely re-dispatched. Capture failures
# explicitly so the 404 payload never leaks into the state check.
release_state="missing"
if gh api \
"repos/$GITHUB_REPOSITORY/releases/tags/v$new" \
--jq '.draft' >"$RUNNER_TEMP/release-state" 2>/dev/null; then
release_state="$(cat "$RUNNER_TEMP/release-state")"
fi
case "$release_state" in
missing|true)
echo "::warning::Tag v$new already exists but no published release is attached — recovering by re-dispatching release.yml against the existing tag."
echo "recovered_tag=v$new" >>"$GITHUB_OUTPUT"
echo "recovered=true" >>"$GITHUB_OUTPUT"
exit 0
;;
false)
echo "::error::Tag v$new already exists and has a published release. Refusing to re-cut over a shipped version." >&2
exit 1
;;
*)
echo "::error::Unexpected release state for v$new: $release_state" >&2
exit 1
;;
esac
recover_unpublished_tag "v$new" "tag collision" || {
echo "::error::Tag v$new already exists and has a published release. Refusing to re-cut over a shipped version." >&2
exit 1
}
fi
echo "version=$new" >>"$GITHUB_OUTPUT"
@@ -634,6 +663,12 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why: `plutil -lint` accepts duplicate plist keys, but `codesign`
# rejects duplicate entitlements after the expensive app build.
- name: Verify macOS entitlements
if: matrix.platform == 'mac'
run: pnpm verify:macos-entitlements
# Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`)
# requires the build identity to be the literal string `stable` or `rc`,
# substituted by electron-vite's `define` block at build time. Derive
@@ -674,6 +709,9 @@ jobs:
- name: Build app
run: pnpm build:release
env:
# Why: Vite's web build crossed Node's default old-space ceiling on
# the macOS release runner, leaving v1.4.2-rc.8 as an incomplete draft.
NODE_OPTIONS: --max-old-space-size=4096
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
+8
View File
@@ -67,9 +67,14 @@ design-docs/
.context/
.atl/
# 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
# Stably CLI (only docs/ are tracked)
.stably/*
@@ -87,3 +92,6 @@ playwright-report/
/.claude/skills/
/.agents/skills/
/skills-lock.json
# Agent hook runtime endpoints (machine-local secrets)
/agent-hooks/
+4 -3
View File
@@ -9,12 +9,12 @@
</p>
<p align="center">
<a href="README.md">English</a> · <a href="docs/README.zh-CN.md">中文</a> · <a href="docs/README.ja.md">日本語</a> · <a href="docs/README.es.md">Español</a>
<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>
</p>
<p align="center">
<strong>The AI Orchestrator for 100x builders.</strong><br/>
Run Claude Code, Codex, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
Run Claude Code, Codex, Grok, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
Available for <strong>macOS, Windows, and Linux</strong>.
</p>
@@ -33,6 +33,7 @@ Orca supports any CLI agent (*not just this list*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
@@ -60,7 +61,7 @@ Orca supports any CLI agent (*not just this list*).
## Features
- **No login required** — Bring your own Claude Code or Codex subscription.
- **No login required** — Bring your own Claude Code, Codex, or Grok subscription.
- **Worktree-native** — Every feature gets its own worktree. No stashing, no branch juggling. Spin up and switch instantly.
- **Multi-agent terminals** — Run multiple AI agents side-by-side in tabs and panes. See which ones are active at a glance.
- **Built-in source control** — Review AI-generated diffs, make quick edits, and commit without leaving Orca.
+14
View File
@@ -7,6 +7,13 @@ const featureWallResources = {
from: 'resources/onboarding/feature-wall',
to: 'onboarding/feature-wall'
}
// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged
// apps. Keeping relay assets as extraResources makes them real directories
// instead of paths hidden inside app.asar.
const relayExtraResource = {
from: 'out/relay',
to: 'relay'
}
/** @type {import('electron-builder').Configuration} */
module.exports = {
@@ -39,6 +46,8 @@ module.exports = {
// integration — dependencies inside the asar archive are invisible to
// require(). Unpack CLI runtime deps so they resolve from
// app.asar.unpacked/node_modules/.
// Why: remote runtime connections use WebSocket + E2EE from the packaged CLI
// before the GUI process starts, so those deps need the same treatment.
// Why: sherpa-onnx native bindings (platform-specific subpackages) must be
// unpacked because they ship .node addons + .dylib/.so files that cannot be
// dlopen()'d from inside the asar archive.
@@ -49,6 +58,8 @@ module.exports = {
'out/main/computer-sidecar.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
'node_modules/tweetnacl/**',
'node_modules/zod/**',
'node_modules/sherpa-onnx*/**'
],
@@ -81,6 +92,7 @@ module.exports = {
win: {
executableName: 'Orca',
extraResources: [
relayExtraResource,
{
from: 'resources/win32/bin/orca.cmd',
to: 'bin/orca.cmd'
@@ -134,6 +146,7 @@ module.exports = {
hardenedRuntime: isMacRelease,
notarize: isMacRelease,
extraResources: [
relayExtraResource,
{
from: 'resources/darwin/bin/orca',
to: 'bin/orca'
@@ -170,6 +183,7 @@ module.exports = {
// The Linux installer should not claim those system package/file names.
executableName: 'orca-ide',
extraResources: [
relayExtraResource,
{
from: 'resources/linux/bin/orca',
to: 'bin/orca'
+8
View File
@@ -25,4 +25,12 @@ case "$(uname -s)" in
esac
export ORCA_USER_DATA_PATH="${ORCA_DEV_USER_DATA_PATH:-$default_data}"
electron_executable="$REPO_ROOT/node_modules/.bin/electron"
case "$(uname -s)" in
MINGW*|MSYS*) electron_executable="$REPO_ROOT/node_modules/electron/dist/electron.exe" ;;
esac
if [ -z "${ORCA_APP_EXECUTABLE:-}" ] && [ -x "$electron_executable" ]; then
export ORCA_APP_EXECUTABLE="$electron_executable"
export ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1
fi
exec node "$CLI_ENTRY" "$@"
@@ -0,0 +1,133 @@
#!/usr/bin/env node
import { appendFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { verifyRequiredReleaseAssets } from './verify-release-required-assets.mjs'
const API_VERSION = '2022-11-28'
const RELEASE_CUT_AUTHOR = 'github-actions[bot]'
const DESKTOP_RC_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$/
export function isReleaseCutDraft(release) {
return (
release?.draft === true &&
release?.author?.login === RELEASE_CUT_AUTHOR &&
typeof release?.tag_name === 'string' &&
DESKTOP_RC_TAG_PATTERN.test(release.tag_name)
)
}
function isRcTag(tag) {
return tag.includes('-rc.')
}
async function githubJson(fetchImpl, url, token, options = {}) {
const res = await fetchImpl(url, {
...options,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': API_VERSION,
...options.headers
}
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${body.slice(0, 300)}`)
}
return res.json()
}
async function fetchReleases(repo, token, fetchImpl) {
const releases = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases?per_page=100`,
token
)
if (!Array.isArray(releases)) {
throw new Error(`GitHub releases response for ${repo} was not an array`)
}
return releases
}
export async function publishCompleteDraftReleases({
repo,
token,
fetchImpl = fetch,
verifyReleaseAssets = verifyRequiredReleaseAssets,
log = console.log
}) {
if (!repo) {
throw new Error('repo is required')
}
if (!token) {
throw new Error('token is required')
}
const releases = await fetchReleases(repo, token, fetchImpl)
const candidates = releases
.filter(isReleaseCutDraft)
.sort((a, b) => new Date(a.created_at ?? 0).getTime() - new Date(b.created_at ?? 0).getTime())
const published = []
const skipped = []
for (const release of candidates) {
const tag = release.tag_name
try {
await verifyReleaseAssets({ repo, tag, token })
} catch (error) {
const reason = error instanceof Error ? error.message.split('\n')[0] : String(error)
skipped.push({ tag, reason })
log(`Skipping incomplete RC draft release ${tag}: ${reason}`)
continue
}
// Why: only release-cut-authored RC drafts with a complete asset set are
// resumed here; incomplete drafts stay private for the normal rebuild path.
await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases/${release.id}`, token, {
method: 'PATCH',
body: JSON.stringify({
draft: false,
prerelease: isRcTag(tag)
})
})
published.push(tag)
log(`Published complete RC draft release ${tag}`)
}
if (published.length === 0 && skipped.length === 0) {
log('No complete release-cut RC drafts to publish.')
}
return { published, skipped }
}
export function writeGithubOutputs({ published, skipped }, outputPath = process.env.GITHUB_OUTPUT) {
if (!outputPath) {
return
}
appendFileSync(
outputPath,
`${[
`published_count=${published.length}`,
`skipped_count=${skipped.length}`,
`published_tags=${published.join(',')}`,
`skipped_tags=${skipped.map((item) => item.tag).join(',')}`
].join('\n')}\n`
)
}
async function main() {
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca'
const result = await publishCompleteDraftReleases({ repo, token })
writeGithubOutputs(result)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error.message)
process.exit(1)
})
}
@@ -0,0 +1,136 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
isReleaseCutDraft,
publishCompleteDraftReleases,
writeGithubOutputs
} from './publish-complete-draft-releases.mjs'
function jsonResponse(body, init = {}) {
return {
ok: init.ok ?? true,
status: init.status ?? 200,
statusText: init.statusText ?? 'OK',
json: vi.fn(async () => body),
text: vi.fn(async () => (typeof body === 'string' ? body : JSON.stringify(body)))
}
}
describe('isReleaseCutDraft', () => {
it('only accepts bot-authored desktop draft releases', () => {
expect(
isReleaseCutDraft({
draft: true,
tag_name: 'v1.4.2-rc.7',
author: { login: 'github-actions[bot]' }
})
).toBe(true)
expect(
isReleaseCutDraft({
draft: true,
tag_name: 'mobile-v0.0.7',
author: { login: 'github-actions[bot]' }
})
).toBe(false)
expect(
isReleaseCutDraft({
draft: true,
tag_name: 'v1.4.2',
author: { login: 'github-actions[bot]' }
})
).toBe(false)
expect(
isReleaseCutDraft({
draft: false,
tag_name: 'v1.4.2',
author: { login: 'github-actions[bot]' }
})
).toBe(false)
})
})
describe('publishCompleteDraftReleases', () => {
it('publishes complete release-cut drafts and skips incomplete ones', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(
jsonResponse([
{
id: 7,
draft: true,
tag_name: 'v1.4.2-rc.7',
created_at: '2026-05-15T07:31:19Z',
author: { login: 'github-actions[bot]' }
},
{
id: 8,
draft: true,
tag_name: 'v1.4.2-rc.8',
created_at: '2026-05-15T10:51:57Z',
author: { login: 'github-actions[bot]' }
}
])
)
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.2-rc.7', draft: false }))
const verifyReleaseAssets = vi.fn(async ({ tag }) => {
if (tag === 'v1.4.2-rc.8') {
throw new Error('Release v1.4.2-rc.8 is missing required assets.')
}
})
const log = vi.fn()
const result = await publishCompleteDraftReleases({
repo: 'stablyai/orca',
token: 'token',
fetchImpl,
verifyReleaseAssets,
log
})
expect(result).toEqual({
published: ['v1.4.2-rc.7'],
skipped: [
{
tag: 'v1.4.2-rc.8',
reason: 'Release v1.4.2-rc.8 is missing required assets.'
}
]
})
expect(fetchImpl).toHaveBeenLastCalledWith(
'https://api.github.com/repos/stablyai/orca/releases/7',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ draft: false, prerelease: true })
})
)
})
})
describe('writeGithubOutputs', () => {
it('writes count outputs for workflow conditions', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-release-outputs-'))
const outputPath = join(dir, 'output')
try {
writeGithubOutputs(
{
published: ['v1.4.2-rc.7'],
skipped: [{ tag: 'v1.4.2-rc.8', reason: 'missing assets' }]
},
outputPath
)
expect(readFileSync(outputPath, 'utf8')).toBe(
`${[
'published_count=1',
'skipped_count=1',
'published_tags=v1.4.2-rc.7',
'skipped_tags=v1.4.2-rc.8'
].join('\n')}\n`
)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
+25 -6
View File
@@ -23,9 +23,15 @@ function getDevUserDataPath() {
return path.join(process.env.HOME ?? '', 'Library', 'Application Support', 'orca-dev')
}
if (process.platform === 'win32') {
return path.join(process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'), 'orca-dev')
return path.join(
process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'),
'orca-dev'
)
}
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'), 'orca-dev')
return path.join(
process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'),
'orca-dev'
)
}
function prepareDevCliWrapper() {
@@ -33,18 +39,19 @@ function prepareDevCliWrapper() {
mkdirSync(binDir, { recursive: true })
const userDataPath = getDevUserDataPath()
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
const electronBin = getElectronExecutable()
if (process.platform === 'win32') {
writeFileSync(
path.join(binDir, 'orca-dev.cmd'),
`@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nnode "${cliPath}" %*\r\n`,
`@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nset "ORCA_APP_EXECUTABLE=${electronBin}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${cliPath}" %*\r\n`,
'utf8'
)
} else {
const wrapperPath = path.join(binDir, 'orca-dev')
writeFileSync(
wrapperPath,
`#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexec node ${JSON.stringify(cliPath)} "$@"\n`,
`#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`,
'utf8'
)
chmodSync(wrapperPath, 0o755)
@@ -54,6 +61,13 @@ function prepareDevCliWrapper() {
console.log(`[orca-dev] Prepared wrapper in ${binDir}`)
}
function getElectronExecutable() {
if (process.platform === 'win32') {
return path.join(repoRoot, 'node_modules', 'electron', 'dist', 'electron.exe')
}
return path.join(repoRoot, 'node_modules', '.bin', 'electron')
}
if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') {
prepareDevCliWrapper()
}
@@ -109,7 +123,10 @@ function parseDebugPortEnv(raw) {
// space-separated form. `--remote-debugging-pipe` opts into pipe-based
// debugging — don't fight the user's choice by injecting a port.
const userPassedPort = forwardedRaw.some(
(a) => a === '--remote-debugging-port' || a.startsWith('--remote-debugging-port=') || a === '--remote-debugging-pipe'
(a) =>
a === '--remote-debugging-port' ||
a.startsWith('--remote-debugging-port=') ||
a === '--remote-debugging-pipe'
)
// Why: --help/--version exit immediately; binding a probe socket and printing
// a debug-port line would be noise.
@@ -136,7 +153,9 @@ if (!userPassedPort && !isHelpOrVersion) {
// resolve to ::1 on IPv6-first hosts).
console.error(`[orca-dev] Remote debugging on http://127.0.0.1:${port}`)
} else {
console.error('[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.')
console.error(
'[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.'
)
}
}
const forwardedArgs = ['dev', ...forwardedRaw, ...forwardedExtras]
@@ -0,0 +1,136 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const repoRoot = resolve(__dirname, '../..')
const defaultPlists = [
'resources/build/entitlements.mac.plist',
'resources/build/entitlements.computer-use.mac.plist'
]
const plistPaths = process.argv.slice(2)
const pathsToCheck = plistPaths.length > 0 ? plistPaths : defaultPlists
let failed = false
for (const plistPath of pathsToCheck) {
const absolutePath = resolve(repoRoot, plistPath)
const xml = readFileSync(absolutePath, 'utf8')
const problems = findDuplicateDictKeys(xml)
if (problems.length === 0) {
console.log(`${plistPath}: OK`)
continue
}
failed = true
console.error(`${plistPath}: duplicate plist dict keys found`)
for (const problem of problems) {
console.error(
`- ${problem.key} first appears on line ${problem.firstLine}, duplicated on line ${problem.duplicateLine}`
)
}
}
if (failed) {
process.exit(1)
}
function findDuplicateDictKeys(xml) {
const problems = []
const lineStarts = buildLineStarts(xml)
const dictStack = []
// Why: `plutil -lint` accepts duplicate keys, but `codesign` rejects
// duplicate entitlements during release signing.
const tokenPattern =
/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<key\b[^>]*>([\s\S]*?)<\/key>|<(\/?)dict\b[^>]*>/g
for (const match of xml.matchAll(tokenPattern)) {
if (match[0].startsWith('<!--') || match[0].startsWith('<![CDATA[')) {
continue
}
if (match[0].startsWith('<key')) {
const currentDict = dictStack.at(-1)
if (!currentDict) {
continue
}
const key = decodeXmlEntities(match[1].trim())
const duplicateLine = lineNumberForIndex(lineStarts, match.index)
const firstLine = currentDict.keys.get(key)
if (firstLine) {
problems.push({ key, firstLine, duplicateLine })
} else {
currentDict.keys.set(key, duplicateLine)
}
continue
}
if (match[2] === '/') {
dictStack.pop()
} else {
dictStack.push({ keys: new Map() })
}
}
return problems
}
function buildLineStarts(text) {
const starts = [0]
for (let index = 0; index < text.length; index += 1) {
if (text[index] === '\n') {
starts.push(index + 1)
}
}
return starts
}
function lineNumberForIndex(lineStarts, targetIndex) {
let low = 0
let high = lineStarts.length - 1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
if (lineStarts[mid] <= targetIndex) {
low = mid + 1
} else {
high = mid - 1
}
}
return high + 1
}
function decodeXmlEntities(value) {
return value.replace(/&(#x?[0-9a-fA-F]+|amp|lt|gt|apos|quot);/g, (entity, body) => {
if (body === 'amp') {
return '&'
}
if (body === 'lt') {
return '<'
}
if (body === 'gt') {
return '>'
}
if (body === 'apos') {
return "'"
}
if (body === 'quot') {
return '"'
}
if (body.startsWith('#x')) {
return String.fromCodePoint(Number.parseInt(body.slice(2), 16))
}
if (body.startsWith('#')) {
return String.fromCodePoint(Number.parseInt(body.slice(1), 10))
}
return entity
})
}
@@ -11,7 +11,7 @@ export function getRequiredReleaseAssetNames(tag) {
'latest-mac.yml',
'latest.yml',
'orca-linux.AppImage',
`orca_${version}_amd64.deb`,
`orca-ide_${version}_amd64.deb`,
'orca-windows-setup.exe',
'orca-windows-setup.exe.blockmap',
`Orca-${version}-mac.zip`,
+14
View File
@@ -0,0 +1,14 @@
import { readFile } from 'fs/promises'
import { resolve } from 'path'
const indexPath = resolve('out/web/web-index.html')
const html = await readFile(indexPath, 'utf8')
const absoluteAssetReference = /\b(?:src|href)=["']\/assets\//.exec(html)
if (absoluteAssetReference) {
console.error(
`Web build must use relative asset URLs for reverse-proxy pairing URLs; found ${absoluteAssetReference[0]} in ${indexPath}`
)
process.exit(1)
}
+5 -1
View File
@@ -1,6 +1,10 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["../src/relay/**/*"],
"include": [
"../src/relay/**/*",
"../src/main/pty/overlay-mirror.ts",
"../src/main/pty/shell-startup-env.ts"
],
"exclude": ["../src/relay/integration.test.ts"],
"compilerOptions": {
"composite": true,
+1 -1
View File
@@ -10,6 +10,6 @@ export default defineConfig({
},
test: {
environment: 'node',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx']
include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'config/scripts/**/*.test.mjs']
}
})
+3 -2
View File
@@ -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.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>
@@ -33,6 +33,7 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
+2 -1
View File
@@ -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.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">
@@ -33,6 +33,7 @@ Orca は任意の CLI エージェントに対応しています(*このリス
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
+147
View File
@@ -0,0 +1,147 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="지원 플랫폼" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="X에서 팔로우" /></a>
</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>
</p>
<p align="center">
<strong>100x 빌더를 위한 AI 오케스트레이터.</strong><br/>
Claude Code, Codex, Grok, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
<strong>macOS, Windows, Linux</strong>에서 사용할 수 있습니다.
</p>
<p align="center">
<a href="#설치"><strong>다운로드 🐋</strong></a>
</p>
<p align="center">
<img src="assets/file-drag.gif" alt="Orca 스크린샷" width="800" />
</p>
## 지원 에이전트
Orca는 모든 CLI 에이전트를 지원합니다(*아래 목록에만 한정되지 않습니다*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
</p>
---
## 기능
- **로그인 불필요** — 보유한 Claude Code 또는 Codex 구독을 그대로 사용하세요.
- **Worktree 네이티브** — 모든 기능은 자체 worktree를 가집니다. stash나 브랜치 전환에 얽매이지 않고 즉시 만들고 전환할 수 있습니다.
- **멀티 에이전트 터미널** — 여러 AI 에이전트를 탭과 패널에서 나란히 실행하세요. 어떤 에이전트가 활성 상태인지 한눈에 볼 수 있습니다.
- **내장 소스 관리** — AI가 생성한 diff를 검토하고, 빠르게 수정하고, Orca를 떠나지 않고 커밋할 수 있습니다.
- **GitHub 통합** — PR, issue, Actions 체크가 각 worktree에 자동으로 연결됩니다.
- **SSH 지원** — 원격 머신에 연결하고 Orca에서 직접 에이전트를 실행할 수 있습니다.
- **알림** — 에이전트가 완료되거나 주의가 필요할 때 알려줍니다. 스레드를 읽지 않음으로 표시해 나중에 다시 볼 수 있습니다.
---
## 설치
### Mac, Linux, Windows
- **[onOrca.dev에서 다운로드](https://onOrca.dev)**
- 또는 **[GitHub Releases 페이지](https://github.com/stablyai/orca/releases/latest)** 에서 받기
*패키지 매니저로도 설치할 수 있습니다:*
### macOS (Homebrew)
```bash
brew install --cask stablyai/orca/orca
```
### Arch Linux (AUR)
```bash
# 사전 컴파일된 바이너리
yay -S stably-orca-bin
# GitHub 소스에서 빌드
yay -S stably-orca-git
```
---
## 모바일 Companion 앱
휴대폰에서 에이전트를 제어하세요.
<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>
</p>
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [GH release에서 다운로드(최신 mobile 릴리스를 찾으세요)](https://github.com/stablyai/orca/releases)
---
## 기능 쇼케이스
타일을 클릭해 각 워크플로를 살펴보세요.
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>병렬 Worktree</strong><br/><br/><picture><source srcset="assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="assets/feature-wall/parallel-worktrees.jpg" alt="병렬 worktree 오케스트레이션" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>터미널 분할</strong><br/><br/><picture><source srcset="assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="assets/feature-wall/terminal-splits.jpg" alt="Ghostty급 터미널 분할" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>디자인 모드</strong><br/><br/><picture><source srcset="assets/feature-wall/design-mode.gif" type="image/gif"><img src="assets/feature-wall/design-mode.jpg" alt="내장 브라우저와 디자인 모드" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 및 Linear 네이티브</strong><br/><br/><picture><source srcset="assets/feature-wall/github-linear.gif" type="image/gif"><img src="assets/feature-wall/github-linear.jpg" alt="Orca의 GitHub 및 Linear 워크플로" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>모든 CLI 에이전트</strong><br/><br/><picture><source srcset="assets/feature-wall/cli-agents.gif" type="image/gif"><img src="assets/feature-wall/cli-agents.jpg" alt="모든 CLI 에이전트 지원" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="assets/feature-wall/ssh-worktrees.jpg" alt="SSH를 통한 원격 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>에이전트로 파일 드래그</strong><br/><br/><picture><source srcset="assets/feature-wall/file-drag.gif" type="image/gif"><img src="assets/feature-wall/file-drag.jpg" alt="파일과 이미지를 에이전트 프롬프트로 드래그" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 주석</strong><br/><br/><picture><source srcset="assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="assets/feature-wall/annotate-diff.jpg" alt="AI가 생성한 diff에 주석 달기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="assets/feature-wall/orca-cli.gif" type="image/gif"><img src="assets/feature-wall/orca-cli.jpg" alt="CLI에서 Orca 스크립팅" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>네이티브 검색</strong><br/><br/><picture><source srcset="assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="assets/feature-wall/keyboard-native.jpg" alt="Orca 워크플로 전반의 네이티브 검색" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>계정 전환 및 사용량 추적</strong><br/><br/><picture><source srcset="assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="assets/feature-wall/codex-accounts.jpg" alt="계정 전환 및 사용량 추적" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>풍부한 리포지토리 미리보기</strong><br/><br/><picture><source srcset="assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="assets/feature-wall/markdown-editor.jpg" alt="Markdown, 이미지, PDF, 리포지토리 문서 미리보기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>무엇이든 분할</strong><br/><br/><picture><source srcset="assets/feature-wall/split-screen.gif" type="image/gif"><img src="assets/feature-wall/split-screen.jpg" alt="에이전트, 터미널, 브라우저, 파일을 위한 분할 패널" width="390" /></picture><br/></kbd></a>
</p>
---
## 커뮤니티와 지원
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요.
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
- **응원하기:** 이 리포지토리에 star를 눌러 일일 릴리스를 따라와 주세요.
---
## 개발
기여하거나 로컬에서 실행하고 싶으신가요? [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 가이드를 확인하세요.
+2 -1
View File
@@ -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.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">
@@ -33,6 +33,7 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
@@ -0,0 +1,198 @@
# 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.
+86 -78
View File
@@ -46,7 +46,7 @@ watching.
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
> 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
@@ -69,10 +69,7 @@ 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 }
type DriverState = { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string }
```
- `idle` — no mobile subscribers; desktop input and resize flow through
@@ -98,26 +95,26 @@ Invariants:
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
_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) |
| 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
@@ -125,13 +122,13 @@ 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 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
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).
@@ -156,27 +153,29 @@ In the symmetric direction, mobile reclaim:
## End-user UX
| Driver | Banner (desktop) | Desktop input | Desktop resize |
|---|---|---|---|
| `idle` | hidden | allowed | allowed |
| `mobile{*}` | "🔒 Mobile is driving this terminal — your input is paused. Click **Take back** to resume." | blocked at xterm.onData (silent drop) | blocked in renderer + dropped in `pty:resize` IPC server-side |
| `desktop` | hidden | allowed | allowed |
| 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.** Banner pops in. PTY may resize to
**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.** Banner gone. You type freely. Mobile stays
**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.** Banner reappears. Your
**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.** Banner gone permanently. Driver returns to `idle`.
**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.
@@ -184,7 +183,7 @@ affect pane B.
**Text selection / scrollback / copy.** Always allowed. The lock is
keystroke and resize only.
**Output continues to render.** While the banner is mounted, terminal
**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
@@ -216,7 +215,7 @@ This is asymmetric and we are accepting it deliberately:
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
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
@@ -268,7 +267,7 @@ other.
### Why a structured payload vs `locked: boolean`?
The runtime needs to know *which* mobile client most recently drove (for
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
@@ -301,7 +300,7 @@ callsite:
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
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.
@@ -333,9 +332,9 @@ callsite:
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
subscribedAt: number // ms since epoch, set on insert
lastActedAt: number // ms since epoch, init = subscribedAt;
// updated on every mobileTookFloor for this client
}
```
@@ -358,6 +357,7 @@ callsite:
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
@@ -384,7 +384,7 @@ Files touched:
`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.
{ 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
@@ -410,16 +410,15 @@ Files touched:
**Canonical identity shape.** Use the existing
`terminal.subscribe` shape: `client: { id: string; type: 'mobile'
\| 'desktop' }` (with `type` optional). `TerminalResizeForClient`
\| '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
'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
@@ -439,13 +438,13 @@ Files touched:
**Unsubscribe-side composite key.** The mobile RPC client today
emits `terminal.unsubscribe` with `params: { subscriptionId:
stream.params.terminal }` — the bare handle. With the subscribe
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
${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
@@ -461,12 +460,13 @@ Files touched:
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
`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
@@ -539,7 +539,7 @@ deliberate mobile actions, not to internal runtime calls.
```ts
// pty-connection.ts onData
if (currentPtyId && getDriverForPty(currentPtyId).kind === 'mobile') {
return // Mobile is driving this PTY; banner explains.
return // Mobile is driving this PTY; banner explains.
}
// existing transport.sendInput(data)
@@ -551,28 +551,40 @@ if (currentPtyId && getDriverForPty(currentPtyId).kind === 'mobile') {
// was added to prevent.
if (
currentPtyId &&
(getFitOverrideForPty(currentPtyId) ||
getDriverForPty(currentPtyId).kind === 'mobile')
(getFitOverrideForPty(currentPtyId) || getDriverForPty(currentPtyId).kind === 'mobile')
) {
return
}
```
### Banner copy
### Overlay copy
```
🔒 Mobile is driving this terminal — your input is paused. [ Take back ]
```
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`).
When PTY is at phone dims, append the dim suffix:
```
🔒 Mobile is driving this terminal (80×24) — your input is paused. [ Take back ]
```
Driving state, expanded:
The phrasing makes Take back the discoverable resolution. There is no
auto-unlock-on-WS-silence and no liveness probing; the banner stays up
until the desktop user takes back, until mobile actor sends input (which
keeps mobile as driver), or until the last mobile subscriber disconnects.
- 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
@@ -584,7 +596,7 @@ keeps mobile as driver), or until the last mobile subscriber disconnects.
(`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
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
@@ -600,18 +612,14 @@ keeps mobile as driver), or until the last mobile subscriber disconnects.
`idle → mobile{thatClient}`) transition. A mobile reconnect takes back
the floor from desktop — correct semantic since the user actively
reopened the mobile view.
- **Restore-debounce window on last-subscriber-leaves**. When the
last mobile subscriber unsubscribes, the driver transitions to
`idle` synchronously (banner unmounts immediately on desktop), but
the PTY-dim restore runs inside the existing 300ms
`pendingRestoreTimers` debounce. For up to 300ms after the banner
unmounts, the PTY remains at phone dims while desktop input flows
through. Desktop typing into a still-squished terminal is a brief
visual mismatch, not a correctness bug — the existing debounce is
there to absorb rapid tab switches without thrashing PTY size, and
emitting `idle` synchronously preserves that behavior. Do not
reorder these (driver-emit-first, then debounced restore) without
re-introducing thrash.
- **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
@@ -640,9 +648,9 @@ keeps mobile as driver), or until the last mobile subscriber disconnects.
- `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*
- 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
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):
@@ -658,7 +666,7 @@ keeps mobile as driver), or until the last mobile subscriber disconnects.
- `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'`.
'mobile'`.
- `pty:resize` IPC is dropped under the same predicate.
## Out of scope
+54
View File
@@ -0,0 +1,54 @@
# 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.
+2
View File
@@ -7,6 +7,8 @@ Local development uses two processes:
- Orca desktop/Electron from the repo root. This hosts the mobile WebSocket RPC server on port `6768`.
- Expo Metro from `mobile/`. This serves the React Native app on port `8081`.
Unless a command says otherwise, run mobile app commands from the `mobile/` directory.
## Prerequisites
- Node.js 24+
+184 -2
View File
@@ -48,6 +48,7 @@ import {
type TerminalModes,
type TerminalWebViewHandle
} from '../../../../src/terminal/TerminalWebView'
import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input'
import { StatusDot } from '../../../../src/components/StatusDot'
import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
import { TextInputModal } from '../../../../src/components/TextInputModal'
@@ -205,6 +206,28 @@ const STATUS_LABELS: Record<ConnectionState, string> = {
'auth-failed': 'Auth failed'
}
const TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY = 64
const TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND = 120
const TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS = 16
const TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES = 32
const TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS = 250
type TerminalGestureInputBucket = {
tokens: number
lastRefillMs: number
}
type TerminalGestureInputQueue = {
bytes: string
sequenceCount: number
timer: ReturnType<typeof setTimeout> | null
lastUpdatedMs: number
}
function isWheelMouseTrackingMode(mode: TerminalModes['mouseTrackingMode'] | undefined): boolean {
return mode === 'vt200' || mode === 'drag' || mode === 'any'
}
function TerminalPaneView({
handle,
active,
@@ -216,7 +239,8 @@ function TerminalPaneView({
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic
onHaptic,
onTerminalInput
}: {
handle: string
active: boolean
@@ -229,6 +253,7 @@ function TerminalPaneView({
onModesChanged: (handle: string, modes: TerminalModes) => void
onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput: (handle: string, bytes: string) => void
}) {
const setRef = useCallback(
(ref: TerminalWebViewHandle | null) => {
@@ -256,6 +281,7 @@ function TerminalPaneView({
onModesChanged={(m) => onModesChanged(handle, m)}
onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
onHaptic={onHaptic}
onTerminalInput={(bytes) => onTerminalInput(handle, bytes)}
/>
</View>
)
@@ -466,9 +492,13 @@ export default function SessionScreen() {
// Why: WebView pushes terminal modes (bracketed-paste, alt-screen) on every
// change so paste reads a synchronous snapshot — no round-trip required.
const ptyModesRef = useRef<Map<string, TerminalModes>>(new Map())
const terminalGestureInputBucketsRef = useRef<Map<string, TerminalGestureInputBucket>>(new Map())
const terminalGestureInputQueuesRef = useRef<Map<string, TerminalGestureInputQueue>>(new Map())
const terminalGestureInputInFlightRef = useRef<Set<string>>(new Set())
const initialModesSeenRef = useRef<Set<string>>(new Set())
const deviceTokenRef = useRef<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
const connStateRef = useRef<ConnectionState>(connState)
// Why: measured once from TerminalWebView on mount, then passed with every
// subscribe call so the server can auto-fit the PTY to phone dimensions.
const viewportRef = useRef<{ cols: number; rows: number } | null>(null)
@@ -1295,6 +1325,16 @@ export default function SessionScreen() {
clientRef.current = client
}, [client])
useEffect(() => {
connStateRef.current = connState
if (connState === 'connected') return
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) clearTimeout(queued.timer)
}
terminalGestureInputQueuesRef.current.clear()
terminalGestureInputInFlightRef.current.clear()
}, [connState])
// Why: only clear terminal cache on actual unmount. Running it whenever
// `client` changes — including the initial null → real-client transition
// from useHostClient's async open path — would unsubscribe terminals and
@@ -1432,6 +1472,11 @@ export default function SessionScreen() {
activeSessionTabTypeRef.current = null
pendingActiveSessionTabIdRef.current = null
pendingActiveTerminalHandleRef.current = null
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) clearTimeout(queued.timer)
}
terminalGestureInputQueuesRef.current.clear()
terminalGestureInputInFlightRef.current.clear()
setActiveHandle(null)
setTerminals([])
terminalsRef.current = []
@@ -1654,6 +1699,13 @@ export default function SessionScreen() {
terminalRefs.current.set(handle, ref)
} else {
terminalRefs.current.delete(handle)
terminalGestureInputBucketsRef.current.delete(handle)
const queued = terminalGestureInputQueuesRef.current.get(handle)
if (queued?.timer) {
clearTimeout(queued.timer)
}
terminalGestureInputQueuesRef.current.delete(handle)
terminalGestureInputInFlightRef.current.delete(handle)
}
}, [])
@@ -1754,6 +1806,132 @@ export default function SessionScreen() {
}
}
const allowTerminalGestureInput = useCallback(
(handle: string, sequenceCount: number): boolean => {
const now = Date.now()
const current = terminalGestureInputBucketsRef.current.get(handle) ?? {
tokens: TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY,
lastRefillMs: now
}
const elapsedSeconds = Math.max(0, now - current.lastRefillMs) / 1000
const tokens = Math.min(
TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY,
current.tokens + elapsedSeconds * TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND
)
// Why: tokens represent terminal control sequences, not WebView messages;
// one legitimate gesture message may batch up to 32 wheel/key reports.
if (tokens < sequenceCount) {
terminalGestureInputBucketsRef.current.set(handle, { tokens, lastRefillMs: now })
return false
}
terminalGestureInputBucketsRef.current.set(handle, {
tokens: tokens - sequenceCount,
lastRefillMs: now
})
return true
},
[]
)
const flushTerminalGestureInput = useCallback(async (handle: string) => {
const queued = terminalGestureInputQueuesRef.current.get(handle)
if (!queued) return
if (queued.timer) {
clearTimeout(queued.timer)
queued.timer = null
}
if (terminalGestureInputInFlightRef.current.has(handle)) return
terminalGestureInputQueuesRef.current.delete(handle)
const isActive =
handle === activeHandleRef.current && activeSessionTabTypeRef.current === 'terminal'
const isFresh = Date.now() - queued.lastUpdatedMs <= TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS
const rpc = clientRef.current
if (!rpc || connStateRef.current !== 'connected' || !isActive || !isFresh) return
terminalGestureInputInFlightRef.current.add(handle)
try {
await rpc.sendRequest('terminal.send', {
terminal: handle,
text: queued.bytes,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
} catch {
// Transient failure
} finally {
terminalGestureInputInFlightRef.current.delete(handle)
const next = terminalGestureInputQueuesRef.current.get(handle)
if (next) {
if (Date.now() - next.lastUpdatedMs > TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS) {
if (next.timer) clearTimeout(next.timer)
terminalGestureInputQueuesRef.current.delete(handle)
} else {
void flushTerminalGestureInput(handle)
}
}
}
}, [])
const enqueueTerminalGestureInput = useCallback(
(handle: string, bytes: string, sequenceCount: number) => {
const now = Date.now()
const current = terminalGestureInputQueuesRef.current.get(handle)
if (
current &&
current.sequenceCount + sequenceCount <= TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES
) {
current.bytes += bytes
current.sequenceCount += sequenceCount
current.lastUpdatedMs = now
return
}
if (current) {
if (current.timer) clearTimeout(current.timer)
if (!terminalGestureInputInFlightRef.current.has(handle)) {
void flushTerminalGestureInput(handle)
} else {
terminalGestureInputQueuesRef.current.delete(handle)
}
}
const queued: TerminalGestureInputQueue = {
bytes,
sequenceCount,
timer: null,
lastUpdatedMs: now
}
queued.timer = setTimeout(() => {
queued.timer = null
void flushTerminalGestureInput(handle)
}, TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS)
terminalGestureInputQueuesRef.current.set(handle, queued)
},
[flushTerminalGestureInput]
)
const handleTerminalInput = useCallback(
async (handle: string, bytes: string) => {
if (!client || connState !== 'connected' || bytes.length === 0) return
if (handle !== activeHandleRef.current || activeSessionTabTypeRef.current !== 'terminal')
return
const modes = ptyModesRef.current.get(handle)
// Why: WebView messages can become PTY input here. Only TUI scroll paths
// generate gesture input, and the bridge is rate-limited for SSH safety.
if (!modes?.altScreen && !isWheelMouseTrackingMode(modes?.mouseTrackingMode)) return
const sequenceCount = countTerminalGestureInputSequences(bytes)
if (sequenceCount == null) return
if (!allowTerminalGestureInput(handle, sequenceCount)) return
enqueueTerminalGestureInput(handle, bytes, sequenceCount)
},
[allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput]
)
async function handleClearTerminal(target: Terminal) {
if (!client) return
getTerminalRef(target.handle)?.clear()
@@ -1890,7 +2068,10 @@ export default function SessionScreen() {
if (text.length === 0) return
const modes = ptyModesRef.current.get(activeHandle) || {
bracketedPasteMode: false,
altScreen: false
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
}
const wrap = modes.bracketedPasteMode && !modes.altScreen
// Why: strip embedded bracketed-paste markers from clipboard text so a
@@ -2335,6 +2516,7 @@ export default function SessionScreen() {
onModesChanged={handleModesChanged}
onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics}
onHaptic={handleHaptic}
onTerminalInput={handleTerminalInput}
/>
))}
{toastMessage && (
@@ -7,41 +7,41 @@ class AudioEngine {
private var engineConfigChangeObserver: Any?
private var sessionInterruptionObserver: Any?
private var mediaServicesResetObserver: Any?
public private(set) var voiceIOFormat: AVAudioFormat
public private(set) var isRecording = false
private var wasRecordingBeforeInterruption = false
public var onMicDataCallback: ((Data) -> Void)?
public var onInputVolumeCallback: ((Float) -> Void)?
public var onOutputVolumeCallback: ((Float) -> Void)?
public var onAudioInterruptionCallback: ((String) -> Void)?
private var inputLevelTimer: Timer?
private var outputLevelTimer: Timer?
private var inputBuffer = [Float](repeating: 0, count: 2048)
private var outputBuffer = [Float](repeating: 0, count: 2048)
private var inputBufferIndex = 0
private var outputBufferIndex = 0
private var hasFirstInputBeenDiscarded = false
private var discardRecording = false
private var discardFirstInputMillis = 2000
enum AudioEngineError: Error {
case audioFormatError
}
init() throws {
avAudioEngine.attach(speechPlayer)
guard let format = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1) else {
throw AudioEngineError.audioFormatError
}
voiceIOFormat = format
print("Voice IO format: \(String(describing: voiceIOFormat))")
engineConfigChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: avAudioEngine,
@@ -60,12 +60,12 @@ class AudioEngine {
queue: .main) { [weak self] _ in
self?.handleMediaServicesWereReset()
}
self.setupAudioSession()
self.setup()
self.start()
}
deinit {
if let observer = engineConfigChangeObserver {
NotificationCenter.default.removeObserver(observer)
@@ -77,29 +77,29 @@ class AudioEngine {
NotificationCenter.default.removeObserver(observer)
}
}
func setupAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
} catch {
print("Could not set the audio category: \(error.localizedDescription)")
}
do {
try session.setPreferredSampleRate(voiceIOFormat.sampleRate)
} catch {
print("Could not set the preferred sample rate: \(error.localizedDescription)")
}
do {
try session.setActive(true)
} catch {
print("Could not set the audio session as active")
}
}
func setup() {
let input = avAudioEngine.inputNode
do {
@@ -108,15 +108,15 @@ class AudioEngine {
print("Could not enable voice processing \(error)")
return
}
avAudioEngine.inputNode.isVoiceProcessingInputMuted = !isRecording
let output = avAudioEngine.outputNode
let mainMixer = avAudioEngine.mainMixerNode
avAudioEngine.connect(speechPlayer, to: mainMixer, format: voiceIOFormat)
avAudioEngine.connect(mainMixer, to: output, format: voiceIOFormat)
input.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in
// We don't do any input processing (no volume calculation or passing mic data to the callback) if discardRecording == true
// See comment in the playPCMData function
@@ -125,48 +125,48 @@ class AudioEngine {
self?.updateInputVolume()
}
}
mainMixer.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in
self?.processOutputBuffer(buffer)
self?.updateOutputVolume()
}
avAudioEngine.prepare()
}
func processMicrophoneBuffer(_ buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData?[0] else {
print("Error: Could not access channel data")
return
}
let frameCount = Int(buffer.frameLength)
var int16Samples = [Int16](repeating: 0, count: frameCount)
// Convert float samples to Int16 and update input buffer for volume calculation
for i in 0..<frameCount {
let floatSample = max(-1.0, min(1.0, channelData[i]))
int16Samples[i] = Int16(floatSample * Float(Int16.max))
inputBuffer[inputBufferIndex] = floatSample
inputBufferIndex = (inputBufferIndex + 1) % inputBuffer.count
}
// Create Data object from Int16 samples
let data = Data(bytes: int16Samples, count: frameCount * MemoryLayout<Int16>.size)
// Send the data to the callback
onMicDataCallback?(data)
}
func processOutputBuffer(_ buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData?[0] else {
print("Error: Could not access channel data")
return
}
let frameCount = Int(buffer.frameLength)
// Update output buffer for volume calculation
for i in 0..<frameCount {
let floatSample = max(-1.0, min(1.0, channelData[i]))
@@ -174,7 +174,7 @@ class AudioEngine {
outputBufferIndex = (outputBufferIndex + 1) % outputBuffer.count
}
}
func start() {
do {
try avAudioEngine.start()
@@ -182,7 +182,7 @@ class AudioEngine {
print("Could not start audio engine: \(error)")
}
}
func playPCMData(_ pcmData: Data) {
// Looks like we don't get a proper AEC for the very first chunks of audio that we play.
// To work around this, we will discard microphone input for the first few milliseconds.
@@ -196,33 +196,33 @@ class AudioEngine {
self.discardRecording = false
}
}
guard let buffer = createBuffer(from: pcmData) else {
print("Failed to create audio buffer")
return
}
speechPlayer.scheduleBuffer(buffer)
if !speechPlayer.isPlaying {
speechPlayer.play()
}
}
private func createBuffer(from data: Data) -> AVAudioPCMBuffer? {
let frameCount = UInt32(data.count) / 2 // 16-bit input = 2 bytes per frame
let format = AVAudioFormat(commonFormat: .pcmFormatFloat32,
sampleRate: 16000,
channels: 1,
interleaved: false)!
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
return nil
}
buffer.frameLength = frameCount
data.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) in
if let sourcePtr = rawBufferPointer.baseAddress?.assumingMemoryBound(to: Int16.self),
let destPtr = buffer.floatChannelData?[0] {
@@ -231,15 +231,15 @@ class AudioEngine {
}
}
}
return buffer
}
func bypassVoiceProcessing(_ bypass: Bool) {
let input = avAudioEngine.inputNode
input.isVoiceProcessingBypassed = bypass
}
func toggleRecording(_ val: Bool) -> Bool {
isRecording = val
if !isRecording {
@@ -251,10 +251,10 @@ class AudioEngine {
avAudioEngine.inputNode.isVoiceProcessingInputMuted = false
}
print("Recording \(isRecording ? "started" : "stopped")")
return isRecording
}
func stopRecordingAndPlayer(clearInterruptionResume: Bool = true){
if clearInterruptionResume {
wasRecordingBeforeInterruption = false
@@ -268,7 +268,7 @@ class AudioEngine {
speechPlayer.stop()
updateOutputVolume()
}
func resumeRecordingAndPlayer(){
do {
try AVAudioSession.sharedInstance().setActive(true)
@@ -279,12 +279,12 @@ class AudioEngine {
isRecording = toggleRecording(true)
speechPlayer.play()
}
func tearDown() {
stopRecordingAndPlayer()
avAudioEngine.stop()
}
var isPlaying: Bool {
return speechPlayer.isPlaying
}
@@ -308,13 +308,13 @@ class AudioEngine {
print("Playback resumed")
}
}
private func checkEngineIsRunning() {
if !avAudioEngine.isRunning {
start()
}
}
private func handleAudioSessionInterruption(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
@@ -343,39 +343,39 @@ class AudioEngine {
fatalError("Unknown type: \(type)")
}
}
private func handleMediaServicesWereReset() {
self.avAudioEngine.stop()
self.setup()
self.start()
}
private func updateInputVolume() {
let volume = calculateRMSLevel(from: inputBuffer)
onInputVolumeCallback?(volume)
}
private func updateOutputVolume() {
let volume = calculateRMSLevel(from: outputBuffer)
onOutputVolumeCallback?(volume)
}
private func calculateRMSLevel(from buffer: [Float]) -> Float {
let epsilon: Float = 1e-5 // To avoid log(0)
let rmsValue = sqrt(buffer.reduce(0) { $0 + $1 * $1 } / Float(buffer.count))
// Convert to decibels
let dbValue = 20 * log10(max(rmsValue, epsilon))
// Normalize decibel value to 0-1 range
// Assuming minimum audible is -60dB and maximum is 0dB
let minDb: Float = -80.0
let normalizedValue = max(0.0, min(1.0, (dbValue - minDb) / abs(minDb)))
// Optional: Apply exponential factor to push smaller values down
let expFactor: Float = 2.0 // Adjust this value to change the curve
let adjustedValue = pow(normalizedValue, expFactor)
return adjustedValue
}
}
+8 -1
View File
@@ -267,7 +267,14 @@ ws.on('open', () => {
})
ws.on('message', (data) => {
const plaintext = decrypt(data.toString())
let plaintext: string | null = null
try {
plaintext = decrypt(data.toString())
} catch {
// Plaintext handshake/control frames such as e2ee_ready are handled by the
// connect flow above. The global listener only cares about encrypted RPC.
return
}
if (!plaintext) return
const response = JSON.parse(plaintext) as RpcResponse
if (response._meta?.runtimeId) {
@@ -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',
+388 -116
View File
@@ -4,9 +4,14 @@ import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import { colors } from '../theme/mobile-theme'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
export type TerminalModes = {
bracketedPasteMode: boolean
altScreen: boolean
mouseTrackingMode: TerminalMouseTrackingMode
sgrMouseMode: boolean
sgrMousePixelsMode: boolean
}
export type TerminalKeyboardAvoidanceMetrics = {
@@ -22,6 +27,7 @@ export type TerminalSelectionEvents = {
onModesChanged?: (modes: TerminalModes) => void
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
}
export type TerminalWebViewHandle = {
@@ -178,6 +184,8 @@ const XTERM_HTML = `<!DOCTYPE html>
(function() {
var surface = document.getElementById('terminal-surface');
var ESC = String.fromCharCode(27);
var C1_CSI = String.fromCharCode(155);
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null;
var writeQueue = [];
var writesDraining = false;
@@ -190,6 +198,10 @@ const XTERM_HTML = `<!DOCTYPE html>
var initRows = 24;
var terminalGeneration = 0;
var activeAltScreenSnapshot = false;
var trackedMouseTrackingMode = 'none';
var sgrMouseMode = false;
var sgrMousePixelsMode = false;
var mouseModeScanTail = '';
var handledMessageIds = [];
// Why: after init() the initial scrollback applyFitScale may have run
// against an empty buffer (or one without the widest line yet). Re-fit
@@ -406,6 +418,58 @@ const XTERM_HTML = `<!DOCTYPE html>
return on > 0 ? data.slice(on) : data;
}
function updateMouseModeFromData(data) {
if (typeof data !== 'string' || data.length === 0) return;
var input = mouseModeScanTail + data;
mouseModeScanTail = extractMouseModeScanTail(input);
var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g');
var match;
while ((match = re.exec(input)) !== null) {
if (match[0] === ESC + 'c') {
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
continue;
}
var enabled = (match[2] || match[4]) === 'h';
var params = (match[1] || match[3]).split(';');
for (var i = 0; i < params.length; i++) {
if (params[i] === '') continue;
var param = Number(params[i]);
if (!Number.isInteger(param)) continue;
if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none';
if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none';
if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none';
if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none';
if (param === 1006) {
sgrMouseMode = enabled;
sgrMousePixelsMode = false;
}
if (param === 1016) {
sgrMouseMode = false;
sgrMousePixelsMode = enabled;
}
}
}
}
function extractMouseModeScanTail(input) {
var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI));
if (start === -1) return '';
var tail = input.slice(start);
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return '';
if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail;
if (tail.indexOf(ESC + '[?') === 0) {
return /^[0-9;]*$/.test(tail.slice(3)) ? tail : '';
}
if (tail.indexOf(C1_CSI + '?') === 0) {
return /^[0-9;]*$/.test(tail.slice(2)) ? tail : '';
}
return '';
}
function pumpWrites(gen) {
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = writeQueue.shift();
@@ -439,7 +503,21 @@ const XTERM_HTML = `<!DOCTYPE html>
afterDrainCallbacks = [];
initRows = rows || 24;
firstDataPending = true;
mouseModeScanTail = '';
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
};
var replayData = normalizeInitialData(initialData);
// Why: normalizeInitialData can discard pre-alt-screen bytes. Keep the
// mirrored modes aligned with exactly what this mobile xterm replays.
updateMouseModeFromData(replayData);
activeAltScreenSnapshot = isAltScreenActive(replayData);
var oldTerm = term;
var oldSurface = surface;
@@ -453,6 +531,7 @@ const XTERM_HTML = `<!DOCTYPE html>
nextSurface.style.top = '0';
document.getElementById('terminal-container').appendChild(nextSurface);
surface = nextSurface;
attachSurfaceEventHandlers(surface);
oldSurface.removeAttribute('id');
}
@@ -522,6 +601,7 @@ const XTERM_HTML = `<!DOCTYPE html>
}
function write(data) {
updateMouseModeFromData(data);
writeQueue.push(data);
pumpWrites(terminalGeneration);
// Why: first live data chunk after init may widen the buffer past
@@ -623,7 +703,12 @@ const XTERM_HTML = `<!DOCTYPE html>
writeQueue = [];
afterDrainCallbacks = [];
writesDraining = false;
mouseModeScanTail = '';
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
if (term) { term.clear(); term.reset(); }
emitModesIfChanged();
resetEvictionCounter();
if (selMode === 'select') {
notify({ type: 'selection-evicted' });
@@ -715,13 +800,39 @@ const XTERM_HTML = `<!DOCTYPE html>
if (!term) return;
var bp = !!(term.modes && term.modes.bracketedPasteMode);
var alt = false;
var mouseTrackingMode = getMouseTrackingMode();
try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
if (bp !== lastEmittedModes.bracketedPasteMode || alt !== lastEmittedModes.altScreen) {
lastEmittedModes = { bracketedPasteMode: bp, altScreen: alt };
notify({ type: 'modes', bracketedPasteMode: bp, altScreen: alt });
if (
bp !== lastEmittedModes.bracketedPasteMode ||
alt !== lastEmittedModes.altScreen ||
mouseTrackingMode !== lastEmittedModes.mouseTrackingMode ||
sgrMouseMode !== lastEmittedModes.sgrMouseMode ||
sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode
) {
lastEmittedModes = {
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: sgrMouseMode,
sgrMousePixelsMode: sgrMousePixelsMode
};
notify({
type: 'modes',
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: sgrMouseMode,
sgrMousePixelsMode: sgrMousePixelsMode
});
}
}
var lastEmittedModes = { bracketedPasteMode: false, altScreen: false };
var lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
};
function emitKeyboardAvoidanceMetrics() {
if (!term) return;
@@ -772,6 +883,147 @@ const XTERM_HTML = `<!DOCTYPE html>
return { col: col, row: viewportRow + viewportY };
}
function viewportToMouseReportCell(clientX, clientY) {
if (!term) return null;
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW <= 0 || cellH <= 0) return null;
if (typeof clientX !== 'number') clientX = window.innerWidth / 2;
if (typeof clientY !== 'number') clientY = window.innerHeight / 2;
var total = getTotalScale();
if (total <= 0) total = 1;
var sx = (clientX - panX) / total;
var sy = (clientY - panY) / total;
var maxX = Math.max(0, term.cols * cellW - 1);
var maxY = Math.max(0, term.rows * cellH - 1);
if (sx < 0) sx = 0;
if (sx > maxX) sx = maxX;
if (sy < 0) sy = 0;
if (sy > maxY) sy = maxY;
var col = Math.floor(sx / cellW);
var row = Math.floor(sy / cellH);
if (col < 0) col = 0;
if (col > term.cols - 1) col = term.cols - 1;
if (row < 0) row = 0;
if (row > term.rows - 1) row = term.rows - 1;
return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) };
}
function isAlternateBufferActive() {
try {
return !!(term && term.buffer && term.buffer.active && term.buffer.active.type === 'alternate');
} catch (e) {
return false;
}
}
function getMouseTrackingMode() {
try {
if (term && term.modes && typeof term.modes.mouseTrackingMode === 'string') {
var mode = term.modes.mouseTrackingMode;
if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') return mode;
return 'none';
}
} catch (e) {}
if (
trackedMouseTrackingMode === 'x10' ||
trackedMouseTrackingMode === 'vt200' ||
trackedMouseTrackingMode === 'drag' ||
trackedMouseTrackingMode === 'any'
) {
return trackedMouseTrackingMode;
}
return 'none';
}
function repeatSequence(sequence, count) {
var out = '';
for (var i = 0; i < count; i++) out += sequence;
return out;
}
function buildArrowScrollSequence(lines) {
var prefix = '[';
try {
if (term && term.modes && term.modes.applicationCursorKeysMode) prefix = 'O';
} catch (e) {}
return ESC + prefix + (lines < 0 ? 'A' : 'B');
}
function buildMouseWheelSequence(lines, clientX, clientY) {
var cell = viewportToMouseReportCell(clientX, clientY);
if (!cell) return '';
var eventCode = lines < 0 ? 64 : 65;
if (sgrMousePixelsMode) {
return ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M';
}
if (sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
return ESC + '[<' + eventCode + ';' + (cell.col + 1) + ';' + (cell.row + 1) + 'M';
}
// Why: xterm increments zero-based mouse cells before encoding reports.
var button = eventCode + 32;
var col = cell.col + 1 + 32;
var row = cell.row + 1 + 32;
// Why: non-SGR mouse bytes above ASCII are not preserved reliably through
// the mobile JSON/RPC string path. Fall back to keys for wide terminals.
if (button > 126 || col > 126 || row > 126) return '';
return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row);
}
function isWheelMouseTrackingMode(mode) {
return mode !== 'none' && mode !== 'x10';
}
function buildMouseWheelScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
var sequence = buildMouseWheelSequence(lines, clientX, clientY);
if (!sequence) return '';
return repeatSequence(sequence, count);
}
function buildTuiScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
var mouseTrackingMode = getMouseTrackingMode();
var sequence = '';
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
sequence = buildMouseWheelSequence(lines, clientX, clientY);
}
if (!sequence) sequence = buildArrowScrollSequence(lines);
return repeatSequence(sequence, count);
}
function routeScrollLines(lines, clientX, clientY) {
if (!term || lines === 0) return;
var mouseTrackingMode = getMouseTrackingMode();
var alternateBufferActive = isAlternateBufferActive();
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
// Why: xterm sends wheel events to mouse-aware TUIs before considering
// scrollback, even if the app stays on the normal buffer.
var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY);
if (mouseInput) {
notify({ type: 'terminal-input', bytes: mouseInput });
return;
}
// Why: default mouse encoding can be unrepresentable in our ASCII-safe
// RPC path on wide terminals. Send bounded arrows instead of local
// scrollback/no-op while a mouse-aware app owns scroll gestures.
var fallbackInput = buildTuiScrollInput(lines, clientX, clientY);
if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput });
return;
}
if (alternateBufferActive) {
// Why: alternate-screen TUIs own their scroll state and xterm has no
// scrollback there, so mobile scroll gestures must become terminal input.
var input = buildTuiScrollInput(lines, clientX, clientY);
if (input) notify({ type: 'terminal-input', bytes: input });
return;
}
term.scrollLines(lines);
}
function cellToViewportPx(col, absRow) {
if (!term) return { x: 0, y: 0 };
var cellW = getCellWidth();
@@ -1134,12 +1386,6 @@ const XTERM_HTML = `<!DOCTYPE html>
} catch (err) {}
});
// Why: event listeners are registered once here (not inside init()) so
// they don't accumulate on re-init. They close over the mutable 'term'
// variable, so they always reference the current terminal instance.
surface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
surface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
var ts = {
lastX: 0, lastY: 0, lastTime: 0, velY: 0,
accumDelta: 0, momentumId: null, isPinching: false,
@@ -1151,121 +1397,132 @@ const XTERM_HTML = `<!DOCTYPE html>
return Math.sqrt(dx * dx + dy * dy);
}
surface.addEventListener('touchstart', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (ts.momentumId) {
cancelAnimationFrame(ts.momentumId);
ts.momentumId = null;
}
if (e.touches.length === 2) {
ts.isPinching = true;
ts.pinchDist = getDistance(e.touches[0], e.touches[1]);
ts.pinchScale = userScale;
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var total = getTotalScale();
ts.pinchSurfX = (mx - panX) / total;
ts.pinchSurfY = (my - panY) / total;
} else if (e.touches.length === 1) {
ts.isPinching = false;
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
}
}, { capture: true, passive: true });
function attachSurfaceEventHandlers(targetSurface) {
if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) return;
targetSurface.__orcaSurfaceHandlersAttached = true;
// Why: init() swaps in a new hidden surface to avoid flicker; each
// replacement needs gesture handlers or tab-switch replays stop scrolling.
targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
surface.addEventListener('touchmove', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
e.preventDefault();
e.stopPropagation();
if (e.touches.length === 2) {
ts.isPinching = true;
var dist = getDistance(e.touches[0], e.touches[1]);
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var ratio = dist / ts.pinchDist;
userScale = Math.max(1, Math.min(5, ts.pinchScale * ratio));
var total = getTotalScale();
panX = mx - ts.pinchSurfX * total;
panY = my - ts.pinchSurfY * total;
clampPan();
updateTransform();
} else if (e.touches.length === 1 && !ts.isPinching) {
var x = e.touches[0].clientX;
var y = e.touches[0].clientY;
var now = Date.now();
var dt = now - ts.lastTime;
if (userScale > 1.05) {
panX += x - ts.lastX;
panY += y - ts.lastY;
clampPan();
updateTransform();
} else {
var deltaY = ts.lastY - y;
if (dt > 0) ts.velY = deltaY / dt;
ts.lastTime = now;
var effectiveCellH = getCellHeight() * currentScale;
ts.accumDelta += deltaY;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
term.scrollLines(lines);
}
targetSurface.addEventListener('touchstart', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (ts.momentumId) {
cancelAnimationFrame(ts.momentumId);
ts.momentumId = null;
}
ts.lastX = x;
ts.lastY = y;
}
}, { capture: true, passive: false });
surface.addEventListener('touchend', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
if (ts.isPinching && e.touches.length < 2) {
ts.isPinching = false;
if (userScale < 1.15) {
userScale = 1; panX = 0; panY = 0;
updateTransform();
}
if (e.touches.length === 1) {
if (e.touches.length === 2) {
ts.isPinching = true;
ts.pinchDist = getDistance(e.touches[0], e.touches[1]);
ts.pinchScale = userScale;
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var total = getTotalScale();
ts.pinchSurfX = (mx - panX) / total;
ts.pinchSurfY = (my - panY) / total;
} else if (e.touches.length === 1) {
ts.isPinching = false;
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
}
return;
}
}, { capture: true, passive: true });
if (e.touches.length === 0 && userScale <= 1.05) {
var vel = ts.velY;
var FRICTION = 0.95;
var MIN_VEL = 0.02;
function momentumStep() {
vel *= FRICTION;
if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; }
var effectiveCellH = getCellHeight() * currentScale;
ts.accumDelta += vel * 16;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
term.scrollLines(lines);
targetSurface.addEventListener('touchmove', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
e.preventDefault();
e.stopPropagation();
if (e.touches.length === 2) {
ts.isPinching = true;
var dist = getDistance(e.touches[0], e.touches[1]);
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var ratio = dist / ts.pinchDist;
userScale = Math.max(1, Math.min(5, ts.pinchScale * ratio));
var total = getTotalScale();
panX = mx - ts.pinchSurfX * total;
panY = my - ts.pinchSurfY * total;
clampPan();
updateTransform();
} else if (e.touches.length === 1 && !ts.isPinching) {
var x = e.touches[0].clientX;
var y = e.touches[0].clientY;
var now = Date.now();
var dt = now - ts.lastTime;
if (userScale > 1.05) {
panX += x - ts.lastX;
panY += y - ts.lastY;
clampPan();
updateTransform();
} else {
var deltaY = ts.lastY - y;
if (dt > 0) ts.velY = deltaY / dt;
ts.lastTime = now;
var effectiveCellH = getCellHeight() * currentScale;
ts.accumDelta += deltaY;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, x, y);
}
}
ts.momentumId = requestAnimationFrame(momentumStep);
ts.lastX = x;
ts.lastY = y;
}
if (Math.abs(vel) > MIN_VEL) {
ts.momentumId = requestAnimationFrame(momentumStep);
}, { capture: true, passive: false });
targetSurface.addEventListener('touchend', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
if (ts.isPinching && e.touches.length < 2) {
ts.isPinching = false;
if (userScale < 1.15) {
userScale = 1; panX = 0; panY = 0;
updateTransform();
}
if (e.touches.length === 1) {
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
}
return;
}
}
}, { capture: true, passive: true });
if (e.touches.length === 0 && userScale <= 1.05) {
var vel = ts.velY;
var FRICTION = 0.95;
var MIN_VEL = 0.02;
function momentumStep() {
vel *= FRICTION;
if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; }
var effectiveCellH = getCellHeight() * currentScale;
ts.accumDelta += vel * 16;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, ts.lastX, ts.lastY);
}
ts.momentumId = requestAnimationFrame(momentumStep);
}
if (Math.abs(vel) > MIN_VEL) {
ts.momentumId = requestAnimationFrame(momentumStep);
}
}
}, { capture: true, passive: true });
}
attachSurfaceEventHandlers(surface);
window.addEventListener('message', function(e) {
try {
@@ -1310,7 +1567,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic
onHaptic,
onTerminalInput
},
ref
) {
@@ -1395,10 +1653,23 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
} else if (msg.type === 'selection-evicted') {
onSelectionEvicted?.()
} else if (msg.type === 'modes') {
const mouseTrackingMode =
msg.mouseTrackingMode === 'x10' ||
msg.mouseTrackingMode === 'vt200' ||
msg.mouseTrackingMode === 'drag' ||
msg.mouseTrackingMode === 'any'
? msg.mouseTrackingMode
: 'none'
onModesChanged?.({
bracketedPasteMode: !!msg.bracketedPasteMode,
altScreen: !!msg.altScreen
altScreen: !!msg.altScreen,
mouseTrackingMode,
sgrMouseMode: !!msg.sgrMouseMode,
sgrMousePixelsMode: !!msg.sgrMousePixelsMode
})
} else if (msg.type === 'terminal-input') {
const bytes = typeof msg.bytes === 'string' ? msg.bytes : ''
if (bytes.length > 0) onTerminalInput?.(bytes)
} else if (msg.type === 'keyboard-avoidance-metrics') {
const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0
const rows = typeof msg.rows === 'number' ? msg.rows : 0
@@ -1430,7 +1701,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic
onHaptic,
onTerminalInput
]
)
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import {
countTerminalGestureInputSequences,
isTerminalGestureInput
} from './terminal-gesture-input'
const ESC = '\x1b'
describe('isTerminalGestureInput', () => {
it('accepts repeated arrow scroll sequences', () => {
expect(isTerminalGestureInput(`${ESC}[A${ESC}[B${ESC}OA${ESC}OB`)).toBe(true)
expect(isTerminalGestureInput(`${ESC}[A`.repeat(32))).toBe(true)
expect(countTerminalGestureInputSequences(`${ESC}[A${ESC}[B${ESC}OA${ESC}OB`)).toBe(4)
expect(countTerminalGestureInputSequences(`${ESC}[A`.repeat(32))).toBe(32)
})
it('accepts repeated SGR wheel sequences', () => {
expect(isTerminalGestureInput(`${ESC}[<64;1;1M${ESC}[<65;120;40M`)).toBe(true)
expect(isTerminalGestureInput(`${ESC}[<64;0;0M`)).toBe(true)
})
it('accepts repeated default mouse wheel sequences', () => {
expect(
isTerminalGestureInput(
`${ESC}[M${String.fromCharCode(96, 97, 33)}${ESC}[M${String.fromCharCode(97, 126, 126)}`
)
).toBe(true)
})
it('rejects shell text and other terminal input', () => {
expect(isTerminalGestureInput('rm -rf .\r')).toBe(false)
expect(isTerminalGestureInput(`${ESC}[200~paste${ESC}[201~`)).toBe(false)
})
it('rejects malformed or oversized sequences', () => {
expect(isTerminalGestureInput(`${ESC}[<63;0;1M`)).toBe(false)
expect(isTerminalGestureInput(`${ESC}[M` + String.fromCharCode(97, 32, 33))).toBe(false)
expect(isTerminalGestureInput(`${ESC}[A`.repeat(33))).toBe(false)
expect(countTerminalGestureInputSequences(`${ESC}[A`.repeat(33))).toBeNull()
expect(isTerminalGestureInput(`${ESC}[A`.repeat(700))).toBe(false)
})
})
@@ -0,0 +1,71 @@
const ESC = '\x1b'
const MAX_TERMINAL_GESTURE_INPUT_LENGTH = 2048
const MAX_TERMINAL_GESTURE_INPUT_SEQUENCES = 32
const SGR_MOUSE_WHEEL_SEQUENCE_RE = new RegExp(`^${ESC}\\[<(64|65);[0-9]{1,4};[0-9]{1,4}M$`)
function isDefaultMouseWheelSequence(bytes: string, offset: number): number | null {
if (!bytes.startsWith(`${ESC}[M`, offset) || offset + 6 > bytes.length) {
return null
}
const button = bytes.charCodeAt(offset + 3)
const col = bytes.charCodeAt(offset + 4)
const row = bytes.charCodeAt(offset + 5)
if ((button === 96 || button === 97) && col >= 33 && col <= 126 && row >= 33 && row <= 126) {
return offset + 6
}
return null
}
function isSgrMouseWheelSequence(bytes: string, offset: number): number | null {
if (!bytes.startsWith(`${ESC}[<`, offset)) {
return null
}
const end = bytes.indexOf('M', offset)
if (end === -1) {
return null
}
const sequence = bytes.slice(offset, end + 1)
return SGR_MOUSE_WHEEL_SEQUENCE_RE.test(sequence) ? end + 1 : null
}
function isArrowScrollSequence(bytes: string, offset: number): number | null {
const sequence = bytes.slice(offset, offset + 3)
if (
sequence === `${ESC}[A` ||
sequence === `${ESC}[B` ||
sequence === `${ESC}OA` ||
sequence === `${ESC}OB`
) {
return offset + 3
}
return null
}
export function countTerminalGestureInputSequences(bytes: string): number | null {
if (bytes.length === 0 || bytes.length > MAX_TERMINAL_GESTURE_INPUT_LENGTH) {
return null
}
let offset = 0
let sequenceCount = 0
while (offset < bytes.length) {
const next =
isArrowScrollSequence(bytes, offset) ??
isSgrMouseWheelSequence(bytes, offset) ??
isDefaultMouseWheelSequence(bytes, offset)
if (next == null) {
return null
}
sequenceCount += 1
if (sequenceCount > MAX_TERMINAL_GESTURE_INPUT_SEQUENCES) {
return null
}
offset = next
}
return sequenceCount
}
export function isTerminalGestureInput(bytes: string): boolean {
return countTerminalGestureInputSequences(bytes) != null
}
+5 -5
View File
@@ -1,20 +1,20 @@
// Why: declares the mobile's pairing protocol version and the minimum
// desktop version it can talk to. Duplicates the desktop's
// Why: declares the mobile client's runtime protocol version and the minimum
// server protocol it can talk to. Duplicates the desktop's
// `src/shared/protocol-version.ts` because Metro/Expo doesn't resolve
// outside `mobile/`. Manual sync is acceptable — these constants are
// expected to bump less than once a quarter.
//
// Bump MOBILE_PROTOCOL_VERSION when:
// - You change the meaning of an RPC mobile sends.
// - You stop relying on a desktop-side feature in a way old desktops
// - You stop relying on a server-side feature in a way old servers
// would notice.
// Do NOT bump for:
// - Adding new optional fields to outbound requests.
// - Reading new optional fields on incoming responses.
//
// Bump MIN_COMPATIBLE_DESKTOP_VERSION when mobile starts relying on a
// desktop feature added at a specific desktop protocol version. This
// triggers a hard-block screen for users paired to older desktops.
// server feature added at a specific runtime protocol version. This
// triggers a hard-block screen for users paired to older servers.
export const MOBILE_PROTOCOL_VERSION = 2
export const MIN_COMPATIBLE_DESKTOP_VERSION = 2
+12 -3
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.2-rc.1",
"version": "1.4.2-rc.8",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -15,6 +15,7 @@
"prepare": "husky",
"test": "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",
@@ -26,14 +27,16 @@
"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",
"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",
"smoke:computer": "node config/scripts/computer-use-smoke.mjs",
"verify:computer-native": "node config/scripts/verify-computer-native.mjs",
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/install-dev-cli.mjs",
"build:electron-vite": "node config/scripts/run-electron-vite-build.mjs",
"build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:computer-macos && pnpm run build:electron-vite && pnpm run build:cli",
"build:release": "pnpm run build:relay && pnpm run build:computer-macos && pnpm run verify:computer-native && pnpm run build:electron-vite && pnpm run build:cli",
"build:web": "vite build --config vite.web.config.ts && node config/scripts/verify-web-build.mjs",
"build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:computer-macos && pnpm run build:electron-vite && pnpm run build:web && pnpm run build:cli",
"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",
"build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir",
@@ -59,6 +62,7 @@
"@tiptap/extension-code-block-lowlight": "^3.22.5",
"@tiptap/extension-image": "^3.22.5",
"@tiptap/extension-link": "^3.22.5",
"@tiptap/extension-mathematics": "3.22.5",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-table": "3.22.4",
"@tiptap/extension-table-cell": "3.22.4",
@@ -86,12 +90,15 @@
"cmdk": "^1.1.1",
"dompurify": "^3.4.2",
"electron-updater": "^6.8.3",
"emoji-picker-react": "^4.19.1",
"entities": "^6.0.1",
"github-slugger": "^2.0.0",
"hosted-git-info": "^9.0.3",
"html-to-image": "^1.11.13",
"katex": "^0.16.45",
"lowlight": "^3.3.0",
"lucide-react": "^0.577.0",
"mdast-util-to-string": "^4.0.0",
"mermaid": "^11.15.0",
"monaco-editor": "^0.55.1",
"node-pty": "^1.1.0",
@@ -109,6 +116,7 @@
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"shadcn": "^4.7.0",
"sherpa-onnx": "1.12.37",
"simple-git": "^3.36.0",
@@ -117,6 +125,7 @@
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"tweetnacl": "^1.0.3",
"unified": "^11.0.5",
"ws": "^8.20.0",
"zod": "~4.4.3",
"zustand": "^5.0.13"
+47
View File
@@ -55,6 +55,9 @@ importers:
'@tiptap/extension-link':
specifier: ^3.22.5
version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)
'@tiptap/extension-mathematics':
specifier: 3.22.5
version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(katex@0.16.45)
'@tiptap/extension-placeholder':
specifier: ^3.22.5
version: 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))
@@ -136,6 +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)
github-slugger:
specifier: ^2.0.0
version: 2.0.0
@@ -154,6 +163,9 @@ importers:
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.5)
mdast-util-to-string:
specifier: ^4.0.0
version: 4.0.0
mermaid:
specifier: ^11.15.0
version: 11.15.0
@@ -205,6 +217,9 @@ importers:
remark-math:
specifier: ^6.0.0
version: 6.0.0
remark-parse:
specifier: ^11.0.0
version: 11.0.0
shadcn:
specifier: ^4.7.0
version: 4.7.0(@types/node@25.6.0)(typescript@5.9.3)
@@ -229,6 +244,9 @@ importers:
tweetnacl:
specifier: ^1.0.3
version: 1.0.3
unified:
specifier: ^11.0.5
version: 11.0.5
ws:
specifier: ^8.20.0
version: 8.20.0
@@ -2615,6 +2633,13 @@ packages:
'@tiptap/core': 3.22.5
'@tiptap/pm': 3.22.5
'@tiptap/extension-mathematics@3.22.5':
resolution: {integrity: sha512-ld2xoFHKyl4Qs+rgu3wn1UZBTsgDApEz2PD17E/XWlVXHO4KoiBOcLhrc5L9SL+aKOBXXmC/Ex1d+0hCptsBbg==}
peerDependencies:
'@tiptap/core': 3.22.5
'@tiptap/pm': 3.22.5
katex: ^0.16.4
'@tiptap/extension-ordered-list@3.22.5':
resolution: {integrity: sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==}
peerDependencies:
@@ -3835,6 +3860,12 @@ packages:
engines: {node: '>= 12.20.55'}
hasBin: true
emoji-picker-react@4.19.1:
resolution: {integrity: sha512-BmDdqInKFVYJpv7qS9WI6L9656cDAC+FkDvUjJds56nKHbaVTBNeDmLwKBytRnzu37zWHs9Isg7gt5PT43y6xA==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -4057,6 +4088,9 @@ packages:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
flairup@1.0.0:
resolution: {integrity: sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
@@ -8424,6 +8458,12 @@ snapshots:
'@tiptap/core': 3.22.5(@tiptap/pm@3.22.5)
'@tiptap/pm': 3.22.5
'@tiptap/extension-mathematics@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(katex@0.16.45)':
dependencies:
'@tiptap/core': 3.22.5(@tiptap/pm@3.22.5)
'@tiptap/pm': 3.22.5
katex: 0.16.45
'@tiptap/extension-ordered-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))':
dependencies:
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)
@@ -9802,6 +9842,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
emoji-picker-react@4.19.1(react@19.2.5):
dependencies:
flairup: 1.0.0
react: 19.2.5
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -10099,6 +10144,8 @@ snapshots:
locate-path: 5.0.0
path-exists: 4.0.0
flairup@1.0.0: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
-2
View File
@@ -20,7 +20,5 @@
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+2 -2
View File
@@ -14,7 +14,7 @@ export type CommandSpec = {
notes?: string[]
}
export const GLOBAL_FLAGS = ['help', 'json']
export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment']
export function parseArgs(argv: string[]): ParsedArgs {
const commandPath: string[] = []
@@ -93,7 +93,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
'storage',
'orchestration',
'computer',
'note'
'environment'
].includes(commandPath[0])) ||
(commandPath.length === 2 &&
commandPath[0] === 'storage' &&
+2 -2
View File
@@ -14,7 +14,7 @@ import { BROWSER_ENV_HANDLERS } from './handlers/browser-env'
import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage'
import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
import { COMPUTER_HANDLERS } from './handlers/computer'
import { NOTE_HANDLERS } from './handlers/note'
import { ENVIRONMENT_HANDLERS } from './handlers/environment'
export type HandlerContext = {
flags: Map<string, string | boolean>
@@ -42,7 +42,7 @@ function buildHandlers(): Map<string, CommandHandler> {
BROWSER_STORAGE_HANDLERS,
ORCHESTRATION_HANDLERS,
COMPUTER_HANDLERS,
NOTE_HANDLERS
ENVIRONMENT_HANDLERS
]
for (const group of groups) {
for (const [key, handler] of Object.entries(group)) {
+13 -3
View File
@@ -4,15 +4,25 @@ import { formatCliError, formatWorktreeList } from './format'
import type { RuntimeWorktreeRecord } from '../shared/runtime-types'
function worktree(overrides: Partial<RuntimeWorktreeRecord> = {}): RuntimeWorktreeRecord {
return {
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',
@@ -21,9 +31,9 @@ function worktree(overrides: Partial<RuntimeWorktreeRecord> = {}): RuntimeWorktr
isMainWorktree: false
},
displayName: '',
comment: '',
...overrides
comment: ''
}
return { ...base, ...overrides }
}
describe('formatCliError', () => {
+28 -34
View File
@@ -33,7 +33,7 @@ import type {
RuntimeWorktreePsResult,
RuntimeWorktreeRecord
} from '../shared/runtime-types'
import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../shared/notes-types'
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
@@ -113,6 +113,33 @@ export function formatStatus(status: CliStatusResult): string {
return formatCliStatus(status)
}
export function formatEnvironmentList(result: {
environments: PublicKnownRuntimeEnvironment[]
}): string {
if (result.environments.length === 0) {
return 'No saved environments.'
}
return result.environments
.map(
(environment) =>
`${environment.id} ${environment.name} ${environment.endpoints[0]?.endpoint ?? 'no-endpoint'}`
)
.join('\n')
}
export function formatEnvironment(environment: PublicKnownRuntimeEnvironment): string {
return [
`id: ${environment.id}`,
`name: ${environment.name}`,
`runtimeId: ${environment.runtimeId ?? 'unknown'}`,
`lastUsedAt: ${environment.lastUsedAt ?? 'never'}`,
`preferredEndpointId: ${environment.preferredEndpointId}`,
...environment.endpoints.map(
(endpoint) => `endpoint: ${endpoint.id} ${endpoint.kind} ${endpoint.endpoint}`
)
].join('\n')
}
export function formatTerminalList(result: RuntimeTerminalListResult): string {
if (result.terminals.length === 0) {
return 'No live terminals.'
@@ -192,39 +219,6 @@ export function formatTerminalWait(result: { wait: RuntimeTerminalWait }): strin
].join('\n')
}
export function formatNoteList(result: NoteListResult): string {
if (result.notes.length === 0) {
return 'No notes.'
}
const body = result.notes
.map((note) => {
const link = note.linkKind ? ` ${note.linkKind}` : ''
const preview = note.preview ? `\n${note.preview}` : ''
return `${note.id} ${note.title}${link}\npath: ${note.relativePath}\nupdated: ${note.updatedAt}${preview}`
})
.join('\n\n')
return result.truncated
? `${body}\n\ntruncated: showing ${result.notes.length} of ${result.totalCount}`
: body
}
export function formatNoteShow(result: NoteShowResult): string {
const link = result.linkKind ? `link: ${result.linkKind}` : 'link: none'
return [
`id: ${result.note.id}`,
`path: ${result.note.relativePath}`,
`title: ${result.note.title}`,
`revision: ${result.note.revision}`,
link,
'',
result.note.bodyMarkdown
].join('\n')
}
export function formatNoteMutation(result: NoteMutationResult): string {
return `Saved note ${result.note.id} (${result.note.title}) revision ${result.note.revision}.`
}
export function formatWorktreePs(result: RuntimeWorktreePsResult): string {
if (result.worktrees.length === 0) {
return 'No worktrees found.'
+27
View File
@@ -1,11 +1,38 @@
import type { CommandHandler } from '../dispatch'
import { formatCliStatus, formatStatus, printResult } from '../format'
import { RuntimeClientError, serveOrcaApp } from '../runtime-client'
export const CORE_HANDLERS: Record<string, CommandHandler> = {
open: async ({ client, json }) => {
const result = await client.openOrca()
printResult(result, json, formatCliStatus)
},
serve: async ({ flags, json }) => {
if (flags.get('no-pairing') === true && flags.get('mobile-pairing') === true) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --mobile-pairing or --no-pairing, not both.'
)
}
const rawPort = flags.get('port')
if (typeof rawPort === 'string') {
const port = Number(rawPort)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new RuntimeClientError('invalid_argument', `Invalid --port value: ${rawPort}`)
}
}
const exitCode = await serveOrcaApp({
json,
port: typeof rawPort === 'string' ? rawPort : null,
pairingAddress:
typeof flags.get('pairing-address') === 'string'
? (flags.get('pairing-address') as string)
: null,
noPairing: flags.get('no-pairing') === true,
mobilePairing: flags.get('mobile-pairing') === true
})
process.exitCode = exitCode
},
status: async ({ client, json }) => {
const result = await client.getCliStatus()
if (!json && !result.result.runtime.reachable) {
+75
View File
@@ -0,0 +1,75 @@
import type { CommandHandler } from '../dispatch'
import { formatEnvironment, formatEnvironmentList, printResult } from '../format'
import { getDefaultUserDataPath } from '../runtime-client'
import type { RuntimeRpcSuccess } from '../runtime-client'
import { RuntimeClientError } from '../runtime-client'
import { redactRuntimeEnvironment } from '../../shared/runtime-environments'
import {
addEnvironmentFromPairingCode,
listEnvironments,
removeEnvironment,
resolveEnvironment,
type EnvironmentAddResult,
type EnvironmentRemoveResult
} from '../runtime/environments'
export const ENVIRONMENT_HANDLERS: Record<string, CommandHandler> = {
'environment add': async ({ flags, json }) => {
const name = getRequiredStringFlag(flags, 'name')
const pairingCode = getRequiredStringFlag(flags, 'pairing-code')
const environment = redactRuntimeEnvironment(
addEnvironmentFromPairingCode(getDefaultUserDataPath(), {
name,
pairingCode
})
)
printResult(
localSuccess({ environment }),
json,
(result: EnvironmentAddResult) =>
`Saved environment ${result.environment.name} (${result.environment.id}).`
)
},
'environment list': async ({ json }) => {
const environments = listEnvironments(getDefaultUserDataPath()).map(redactRuntimeEnvironment)
printResult(localSuccess({ environments }), json, formatEnvironmentList)
},
'environment show': async ({ flags, json }) => {
const selector = getRequiredStringFlag(flags, 'environment')
const environment = redactRuntimeEnvironment(
resolveEnvironment(getDefaultUserDataPath(), selector)
)
printResult(localSuccess({ environment }), json, ({ environment: value }) =>
formatEnvironment(value)
)
},
'environment rm': async ({ flags, json }) => {
const selector = getRequiredStringFlag(flags, 'environment')
const removed = redactRuntimeEnvironment(removeEnvironment(getDefaultUserDataPath(), selector))
printResult(
localSuccess({ removed }),
json,
(result: EnvironmentRemoveResult) =>
`Removed environment ${result.removed.name} (${result.removed.id}).`
)
}
}
function getRequiredStringFlag(flags: Map<string, string | boolean>, name: string): string {
const value = flags.get(name)
if (typeof value !== 'string' || value.length === 0) {
throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
}
return value
}
function localSuccess<TResult>(result: TResult): RuntimeRpcSuccess<TResult> {
return {
id: 'local',
ok: true,
result,
_meta: {
runtimeId: 'local'
}
}
}
-95
View File
@@ -1,95 +0,0 @@
import { readFileSync } from 'fs'
import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../../shared/notes-types'
import type { CommandHandler } from '../dispatch'
import { formatNoteList, formatNoteMutation, formatNoteShow, printResult } from '../format'
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag
} from '../flags'
import { RuntimeClientError } from '../runtime-client'
import { getBrowserWorktreeSelector } from '../selectors'
async function getNoteWorktreeSelector(
flags: Map<string, string | boolean>,
cwd: string,
client: Parameters<CommandHandler>[0]['client']
): Promise<string> {
const worktree = await getBrowserWorktreeSelector(flags, cwd, client)
if (!worktree) {
throw new RuntimeClientError(
'selector_not_found',
'No Orca-managed worktree contains the current directory. Pass --worktree.'
)
}
return worktree
}
function readBody(flags: Map<string, string | boolean>, required: boolean): string | undefined {
const body = getOptionalStringFlag(flags, 'body')
const bodyFile = getOptionalStringFlag(flags, 'body-file')
const bodyStdin = flags.get('body-stdin') === true
const specified = [body !== undefined, bodyFile !== undefined, bodyStdin].filter(Boolean).length
if (specified > 1) {
throw new RuntimeClientError(
'invalid_argument',
'Pass only one of --body, --body-file, or --body-stdin'
)
}
if (body !== undefined) {
return body
}
if (bodyFile) {
return readFileSync(bodyFile, 'utf8')
}
if (bodyStdin) {
return readFileSync(0, 'utf8')
}
if (required) {
throw new RuntimeClientError('invalid_argument', 'Missing note body')
}
return undefined
}
export const NOTE_HANDLERS: Record<string, CommandHandler> = {
'note list': async ({ flags, client, cwd, json }) => {
const result = await client.call<NoteListResult>('note.list', {
worktree: await getNoteWorktreeSelector(flags, cwd, client),
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
})
printResult(result, json, formatNoteList)
},
'note show': async ({ flags, client, cwd, json }) => {
const result = await client.call<NoteShowResult>('note.show', {
worktree: await getNoteWorktreeSelector(flags, cwd, client),
note: getRequiredStringFlag(flags, 'note')
})
printResult(result, json, formatNoteShow)
},
'note create': async ({ flags, client, cwd, json }) => {
const result = await client.call<NoteMutationResult>('note.create', {
worktree: await getNoteWorktreeSelector(flags, cwd, client),
title: getRequiredStringFlag(flags, 'title'),
bodyMarkdown: readBody(flags, false),
makeActive: true
})
printResult(result, json, formatNoteMutation)
},
'note append': async ({ flags, client, cwd, json }) => {
const result = await client.call<NoteMutationResult>('note.append', {
worktree: await getNoteWorktreeSelector(flags, cwd, client),
note: getRequiredStringFlag(flags, 'note'),
bodyMarkdown: readBody(flags, true),
makeActive: true
})
printResult(result, json, formatNoteMutation)
},
'note search': async ({ flags, client, cwd, json }) => {
const result = await client.call<NoteListResult>('note.search', {
worktree: await getNoteWorktreeSelector(flags, cwd, client),
query: getRequiredStringFlag(flags, 'query'),
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
})
printResult(result, json, formatNoteList)
}
}
+29 -2
View File
@@ -1,16 +1,43 @@
import { resolve as resolvePath } from 'path'
import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format'
import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags'
import { RuntimeClientError } from '../runtime-client'
function isAbsoluteServerPath(value: string): boolean {
return (
value.startsWith('/') ||
/^[A-Za-z]:[\\/]/.test(value) ||
value.startsWith('\\\\') ||
value.startsWith('//')
)
}
function resolveRepoAddPath(inputPath: string, cwd: string, isRemote: boolean): string {
if (!isRemote) {
return resolvePath(cwd, inputPath)
}
// Why: the local CLI cwd is unrelated to a paired runtime's filesystem.
// Relative remote paths would silently target the wrong machine.
if (!isAbsoluteServerPath(inputPath)) {
throw new RuntimeClientError(
'invalid_argument',
'Remote repo add requires --path to be an absolute path on the remote server.'
)
}
return inputPath
}
export const REPO_HANDLERS: Record<string, CommandHandler> = {
'repo list': async ({ client, json }) => {
const result = await client.call<RuntimeRepoList>('repo.list')
printResult(result, json, formatRepoList)
},
'repo add': async ({ flags, client, json }) => {
'repo add': async ({ flags, client, cwd, json }) => {
const repoPath = getRequiredStringFlag(flags, 'path')
const result = await client.call<{ repo: Record<string, unknown> }>('repo.add', {
path: getRequiredStringFlag(flags, 'path')
path: resolveRepoAddPath(repoPath, cwd, client.isRemote)
})
printResult(result, json, formatRepoShow)
},
+6
View File
@@ -116,6 +116,12 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
printResult(result, json, formatTerminalRename)
},
'terminal create': async ({ flags, client, cwd, json }) => {
if (client.isRemote && !flags.has('worktree')) {
throw new RuntimeClientError(
'invalid_argument',
'Remote terminal create requires --worktree because the client cwd cannot identify a server worktree.'
)
}
const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
command: getOptionalStringFlag(flags, 'command'),
+15
View File
@@ -8,8 +8,15 @@ Usage: orca <command> [options]
Startup:
open Launch Orca and wait for the runtime to be reachable
serve Start a headless Orca runtime server
status Show app/runtime/graph readiness
Environments:
environment add Save a remote Orca runtime from a pairing code
environment list List saved remote Orca runtimes
environment show Show one saved remote Orca runtime
environment rm Remove a saved remote Orca runtime
Repos:
repo list List repos registered in Orca
repo add Add a project to Orca by filesystem path
@@ -138,7 +145,12 @@ Browser Automation:
Common Commands:
orca open [--json]
orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--json]
orca status [--json]
orca environment add --name <name> --pairing-code <code> [--json]
orca environment list [--json]
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>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
orca worktree show --worktree <selector> [--json]
@@ -178,10 +190,13 @@ Wait Options:
Output Options:
--json Emit machine-readable JSON instead of human text
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair#... code
--environment <selector> Connect using a saved environment id or name
--help Show this help message
Behavior:
Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first.
Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.
Use selectors for discovery and handles for repeated live terminal operations.
Browser Workflow:
+367 -2
View File
@@ -2,13 +2,47 @@
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
const {
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn()
}))
vi.mock('./runtime-client', () => {
class RuntimeClient {
readonly isRemote: boolean
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
constructor(
_userDataPath?: string,
_requestTimeoutMs?: number,
remotePairingCode?: string | null,
environmentSelector?: string | null
) {
const effectivePairingCode =
remotePairingCode === undefined
? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING)
: remotePairingCode
const effectiveEnvironment =
environmentSelector === undefined ? process.env.ORCA_ENVIRONMENT : environmentSelector
if (effectivePairingCode && effectiveEnvironment) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --pairing-code or --environment, not both.'
)
}
this.isRemote = Boolean(effectivePairingCode || effectiveEnvironment)
}
}
class RuntimeClientError extends Error {
@@ -32,10 +66,19 @@ vi.mock('./runtime-client', () => {
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError
RuntimeRpcFailureError,
serveOrcaApp: serveOrcaAppMock,
getDefaultUserDataPath: getDefaultUserDataPathMock
}
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
import {
buildCurrentWorktreeSelector,
COMMAND_SPECS,
@@ -58,10 +101,37 @@ describe('COMMAND_SPECS collision check', () => {
describe('orca cli worktree awareness', () => {
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
const originalUserDataPath = process.env.ORCA_USER_DATA_PATH
const originalPairingCode = process.env.ORCA_PAIRING_CODE
const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING
const originalEnvironment = process.env.ORCA_ENVIRONMENT
beforeEach(() => {
callMock.mockReset()
delete process.env.ORCA_TERMINAL_HANDLE
serveOrcaAppMock.mockReset()
getDefaultUserDataPathMock.mockClear()
addEnvironmentFromPairingCodeMock.mockReset()
listEnvironmentsMock.mockReset()
addEnvironmentFromPairingCodeMock.mockReturnValue({
id: 'env-1',
name: 'desk',
createdAt: 100,
updatedAt: 100,
lastUsedAt: null,
runtimeId: null,
endpoints: [
{
id: 'ws-env-1',
kind: 'websocket',
label: 'WebSocket',
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: 'pk'
}
],
preferredEndpointId: 'ws-env-1'
})
listEnvironmentsMock.mockReturnValue([])
})
afterEach(() => {
@@ -76,6 +146,21 @@ describe('orca cli worktree awareness', () => {
} else {
process.env.ORCA_USER_DATA_PATH = originalUserDataPath
}
if (originalPairingCode === undefined) {
delete process.env.ORCA_PAIRING_CODE
} else {
process.env.ORCA_PAIRING_CODE = originalPairingCode
}
if (originalRemotePairing === undefined) {
delete process.env.ORCA_REMOTE_PAIRING
} else {
process.env.ORCA_REMOTE_PAIRING = originalRemotePairing
}
if (originalEnvironment === undefined) {
delete process.env.ORCA_ENVIRONMENT
} else {
process.env.ORCA_ENVIRONMENT = originalEnvironment
}
})
it('builds the current worktree selector from cwd', () => {
@@ -116,6 +201,25 @@ describe('orca cli worktree awareness', () => {
expect(logSpy).toHaveBeenCalledTimes(1)
})
it('rejects remote `worktree current` without listing worktrees from client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['worktree', 'current', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo/src'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'current is a local cwd shortcut and cannot be resolved against a remote runtime.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('uses cwd when active is passed to worktree.set', async () => {
queueFixtures(
callMock,
@@ -379,6 +483,192 @@ describe('orca cli worktree awareness', () => {
})
})
it('starts a foreground headless server through `serve`', async () => {
serveOrcaAppMock.mockResolvedValue(0)
process.env.ORCA_ENVIRONMENT = 'stale-env'
await main(
['serve', '--json', '--port', '6768', '--pairing-address', '100.64.1.20', '--no-pairing'],
'/tmp/repo'
)
expect(serveOrcaAppMock).toHaveBeenCalledWith({
json: true,
port: '6768',
pairingAddress: '100.64.1.20',
noPairing: true,
mobilePairing: false
})
})
it('starts a foreground headless server with mobile pairing enabled', async () => {
serveOrcaAppMock.mockResolvedValue(0)
await main(
['serve', '--pairing-address', '100.64.1.20', '--mobile-pairing', '--json'],
'/tmp/repo'
)
expect(serveOrcaAppMock).toHaveBeenCalledWith({
json: true,
port: null,
pairingAddress: '100.64.1.20',
noPairing: false,
mobilePairing: true
})
})
it('rejects contradictory serve pairing flags', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['serve', '--mobile-pairing', '--no-pairing', '--json'], '/tmp/repo')
expect(serveOrcaAppMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --mobile-pairing or --no-pairing, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects invalid serve ports before launching the app', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['serve', '--port', 'not-a-port', '--json'], '/tmp/repo')
expect(serveOrcaAppMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Invalid --port value: not-a-port'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('lists saved environments even when ORCA_ENVIRONMENT is set', async () => {
process.env.ORCA_ENVIRONMENT = 'stale-env'
listEnvironmentsMock.mockReturnValue([addEnvironmentFromPairingCodeMock()])
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['environment', 'list', '--json'], '/tmp/repo')
expect(listEnvironmentsMock).toHaveBeenCalledWith('/tmp/orca-user-data')
expect(callMock).not.toHaveBeenCalled()
expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
})
it('adds saved environments even when ORCA_ENVIRONMENT is set', async () => {
process.env.ORCA_ENVIRONMENT = 'stale-env'
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['environment', 'add', '--name', 'desk', '--pairing-code', 'orca://pair#abc', '--json'],
'/tmp/repo'
)
expect(addEnvironmentFromPairingCodeMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
name: 'desk',
pairingCode: 'orca://pair#abc'
})
expect(callMock).not.toHaveBeenCalled()
expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
})
it('resolves repo.add paths against the invoking cli cwd', async () => {
queueFixtures(
callMock,
okFixture('req_repo_add', {
repo: {
id: 'repo-1',
path: path.resolve('/tmp/repo/apps/web'),
displayName: 'web'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['repo', 'add', '--path', './apps/web', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('repo.add', {
path: path.resolve('/tmp/repo/apps/web')
})
})
it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['repo', 'add', '--path', './apps/web', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Remote repo add requires --path to be an absolute path on the remote server.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('sends remote repo.add absolute paths unchanged', async () => {
queueFixtures(
callMock,
okFixture('req_repo_add', {
repo: {
id: 'repo-1',
path: '/srv/orca/web',
displayName: 'web'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['repo', 'add', '--path', '/srv/orca/web', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('repo.add', {
path: '/srv/orca/web'
})
})
it.each(['C:\\repo', 'C:/repo', '\\\\server\\share\\repo', '//server/share/repo'])(
'sends remote repo.add server absolute path %s unchanged',
async (serverPath) => {
queueFixtures(
callMock,
okFixture('req_repo_add', {
repo: {
id: 'repo-1',
path: serverPath,
displayName: 'web'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['repo', 'add', '--path', serverPath, '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('repo.add', {
path: serverPath
})
}
)
it('opts into setup and activation when worktree.create runs hooks', async () => {
queueFixtures(
callMock,
@@ -588,4 +878,79 @@ describe('orca cli worktree awareness', () => {
limit: undefined
})
})
it('rejects implicit remote terminal create instead of resolving from client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['terminal', 'create', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/client/repo/src'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Remote terminal create requires --worktree'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('sends explicit remote terminal create worktree selectors unchanged', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/srv/orca/feature',
title: 'Server terminal'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'id:repo-1::/srv/orca/feature',
'--pairing-code',
'remote-runtime',
'--json'
],
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: undefined,
title: undefined,
focus: false
})
})
it('does not resolve implicit remote browser targets from client cwd', async () => {
queueFixtures(
callMock,
okFixture('req_tab_current', {
tab: {
browserPageId: 'page-1',
index: 0,
url: 'https://example.com',
title: 'Example',
active: true,
worktreeId: 'repo-1::/srv/orca/feature'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['tab', 'current', '--pairing-code', 'remote-runtime', '--json'], '/tmp/client/src')
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined })
})
})
+13 -1
View File
@@ -15,6 +15,10 @@ import { COMMAND_SPECS } from './specs'
export { COMMAND_SPECS } from './specs'
export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors'
function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
return commandPath[0] === 'environment' || commandPath[0] === 'serve'
}
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
const parsed = parseArgs(argv)
const helpPath = resolveHelpPath(parsed)
@@ -40,7 +44,15 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P
// lookup so users do not get misleading "Orca is not running" failures for
// simple command typos or unsupported flags.
validateCommandAndFlags(COMMAND_SPECS, parsed)
const client = new RuntimeClient()
const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath)
const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code')
const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment')
const client = new RuntimeClient(
undefined,
undefined,
typeof pairingCode === 'string' ? pairingCode : undefined,
typeof environmentSelector === 'string' ? environmentSelector : undefined
)
await dispatch(parsed.commandPath, {
flags: parsed.flags,
client,
+1
View File
@@ -6,6 +6,7 @@ export {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError,
serveOrcaApp,
getDefaultUserDataPath,
type RuntimeRpcFailure,
type RuntimeRpcResponse,
+133 -4
View File
@@ -1,9 +1,17 @@
import type { CliStatusResult } from '../../shared/runtime-types'
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
import { launchOrcaApp } from './launch'
import { getDefaultUserDataPath, readMetadata } from './metadata'
import { getCliStatus } from './status'
import { sendRequest } from './transport'
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
import { sendWebSocketRequest } from './websocket-transport'
import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat'
import {
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../shared/protocol-version'
// Why: for `orchestration.check --wait` the caller's method-level
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
@@ -16,13 +24,27 @@ const LONG_POLL_CLIENT_GRACE_MS = 10_000
export class RuntimeClient {
private readonly userDataPath: string
private readonly requestTimeoutMs: number
private readonly remotePairing: PairingOffer | null
private readonly environmentSelector: string | null
private remoteCompatChecked = false
// Why: browser commands trigger first-time session init (agent-browser connect +
// CDP proxy setup) which can take 15-30s. 60s accommodates cold start without
// being so large that genuine hangs go unnoticed.
constructor(userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000) {
constructor(
userDataPath = getDefaultUserDataPath(),
requestTimeoutMs = 60_000,
remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null,
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null
) {
this.userDataPath = userDataPath
this.requestTimeoutMs = requestTimeoutMs
this.environmentSelector = environmentSelector
this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector)
}
get isRemote(): boolean {
return this.remotePairing !== null
}
async call<TResult>(
@@ -32,10 +54,30 @@ export class RuntimeClient {
timeoutMs?: number
}
): Promise<RuntimeRpcSuccess<TResult>> {
const metadata = readMetadata(this.userDataPath)
const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params)
if (this.remotePairing) {
if (method !== 'status.get') {
await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs)
}
const response = await sendWebSocketRequest<TResult>(
this.remotePairing,
method,
params,
effectiveTimeoutMs
)
if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
}
if (this.environmentSelector) {
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
runtimeId: response._meta.runtimeId
})
}
return response
}
const metadata = readMetadata(this.userDataPath)
const response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs)
if (!response.ok) {
if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
}
return response
@@ -58,9 +100,69 @@ export class RuntimeClient {
}
async getCliStatus(): Promise<RuntimeRpcSuccess<CliStatusResult>> {
if (this.remotePairing) {
const response = await this.call<RuntimeStatus>('status.get')
this.assertRemoteRuntimeStatusCompatible(response.result)
this.remoteCompatChecked = true
const graphState = response.result.graphStatus
return {
id: response.id,
ok: true,
result: {
app: {
running: true,
pid: null
},
runtime: {
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
reachable: true,
runtimeId: response.result.runtimeId
},
graph: {
state: graphState
}
},
_meta: response._meta
}
}
return getCliStatus(this.userDataPath)
}
private async ensureRemoteRuntimeCompatible(timeoutMs: number): Promise<void> {
if (!this.remotePairing || this.remoteCompatChecked) {
return
}
const response = await sendWebSocketRequest<RuntimeStatus>(
this.remotePairing,
'status.get',
undefined,
timeoutMs
)
if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
}
this.assertRemoteRuntimeStatusCompatible(response.result)
this.remoteCompatChecked = true
if (this.environmentSelector) {
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
runtimeId: response._meta.runtimeId
})
}
}
private assertRemoteRuntimeStatusCompatible(status: RuntimeStatus): void {
const verdict = evaluateRuntimeCompat({
clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion,
serverMinCompatibleClientProtocolVersion:
status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion
})
if (verdict.kind === 'blocked') {
throw new RuntimeClientError('incompatible_runtime', describeRuntimeCompatBlock(verdict))
}
}
async openOrca(timeoutMs = 15_000): Promise<RuntimeRpcSuccess<CliStatusResult>> {
const initial = await this.getCliStatus()
if (initial.result.runtime.reachable) {
@@ -84,6 +186,33 @@ export class RuntimeClient {
}
}
function resolveRemotePairing(
userDataPath: string,
pairingCode: string | null,
environmentSelector: string | null
): PairingOffer | null {
if (pairingCode && environmentSelector) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --pairing-code or --environment, not both.'
)
}
if (environmentSelector) {
return resolveEnvironmentPairingOffer(userDataPath, environmentSelector)
}
if (!pairingCode) {
return null
}
const pairing = parsePairingCode(pairingCode)
if (!pairing) {
throw new RuntimeClientError(
'invalid_argument',
'Invalid remote pairing code. Expected an orca://pair#... URL or bare pairing payload.'
)
}
return pairing
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+5 -62
View File
@@ -1,62 +1,5 @@
// Why: the Orca runtime is a separate process and may drift in version from
// the CLI (older CLI talking to newer app, or vice versa during dev HMR). A
// Zod schema at the decode boundary means a malformed frame surfaces as a
// single legible error instead of a silent mis-typed access downstream.
//
// The envelope shape mirrors src/main/runtime/rpc/core.ts. `result` is left
// unknown here — method-level types are checked by the caller via generics —
// so only the frame is validated, not the payload.
import { z } from 'zod'
const MetaSuccess = z.object({
runtimeId: z.string()
})
const MetaFailure = z
.object({
runtimeId: z.union([z.string(), z.null()])
})
.optional()
const Success = z.object({
id: z.string(),
ok: z.literal(true),
result: z.unknown(),
_meta: MetaSuccess
})
const Failure = z.object({
id: z.string(),
ok: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
data: z.unknown().optional()
}),
_meta: MetaFailure
})
// Why: transport-layer keepalive frame (server→client only). Not a terminal
// frame — the client reads past it and keeps waiting for the real
// success/failure. `id` and `_meta` are deliberately absent: keepalives carry
// no method-level semantics and aren't tied to a particular request (one
// connection handles one request today). See design doc §3.1.
const Keepalive = z.object({
_keepalive: z.literal(true)
})
// Why: switched from z.discriminatedUnion('ok', …) to z.union because
// keepalives have no `ok` field. Client code must branch on
// `'_keepalive' in frame` before treating the frame as Success/Failure.
export const RuntimeRpcEnvelopeSchema = z.union([Success, Failure, Keepalive])
export type RuntimeRpcKeepaliveFrame = z.infer<typeof Keepalive>
export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame {
return (
typeof frame === 'object' &&
frame !== null &&
'_keepalive' in frame &&
(frame as { _keepalive: unknown })._keepalive === true
)
}
export {
isKeepaliveFrame,
RuntimeRpcEnvelopeSchema,
type RuntimeRpcKeepaliveFrame
} from '../../shared/runtime-rpc-envelope'
+68
View File
@@ -0,0 +1,68 @@
import { mkdtempSync, statSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
import { encodePairingOffer } from '../../shared/pairing'
import {
addEnvironmentFromPairingCode,
getEnvironmentStorePath,
listEnvironments,
removeEnvironment,
resolveEnvironmentPairingOffer
} from './environments'
function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
return encodePairingOffer({
v: 2,
endpoint,
deviceToken: 'device-token',
publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64')
})
}
describe('CLI runtime environments', () => {
it('saves, resolves, and removes a paired environment', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-'))
const saved = addEnvironmentFromPairingCode(userDataPath, {
name: 'workstation',
pairingCode: pairingCode(),
now: 100
})
expect(listEnvironments(userDataPath)).toHaveLength(1)
expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation')).toMatchObject({
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'device-token'
})
expect(resolveEnvironmentPairingOffer(userDataPath, saved.id)).toMatchObject({
endpoint: 'ws://127.0.0.1:6768'
})
expect((statSync(getEnvironmentStorePath(userDataPath)).mode & 0o777).toString(8)).toBe('600')
const removed = removeEnvironment(userDataPath, 'workstation')
expect(removed.id).toBe(saved.id)
expect(listEnvironments(userDataPath)).toEqual([])
})
it('rejects an environment with the same name', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-'))
const first = addEnvironmentFromPairingCode(userDataPath, {
name: 'workstation',
pairingCode: pairingCode('ws://127.0.0.1:1111'),
now: 100
})
expect(() =>
addEnvironmentFromPairingCode(userDataPath, {
name: 'workstation',
pairingCode: pairingCode('ws://127.0.0.1:2222'),
now: 200
})
).toThrow('A server named "workstation" already exists.')
expect(listEnvironments(userDataPath)).toHaveLength(1)
expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation').endpoint).toBe(
'ws://127.0.0.1:1111'
)
expect(listEnvironments(userDataPath)[0]?.id).toBe(first.id)
})
})
+81
View File
@@ -0,0 +1,81 @@
import {
addEnvironmentFromPairingCode as addEnvironmentFromPairingCodeInStore,
getEnvironmentStorePath,
listEnvironments,
markEnvironmentUsed as markEnvironmentUsedInStore,
removeEnvironment as removeEnvironmentFromStore,
resolveEnvironment as resolveEnvironmentFromStore,
resolveEnvironmentPairingOffer as resolveEnvironmentPairingOfferFromStore,
RuntimeEnvironmentStoreError,
type RuntimeEnvironmentStoreErrorCode
} from '../../shared/runtime-environment-store'
import type {
KnownRuntimeEnvironment,
PublicKnownRuntimeEnvironment
} from '../../shared/runtime-environments'
import type { PairingOffer } from '../../shared/pairing'
import { RuntimeClientError } from './types'
export type EnvironmentAddResult = {
environment: PublicKnownRuntimeEnvironment
}
export type EnvironmentListResult = {
environments: PublicKnownRuntimeEnvironment[]
}
export type EnvironmentRemoveResult = {
removed: PublicKnownRuntimeEnvironment
}
export { getEnvironmentStorePath, listEnvironments }
export function addEnvironmentFromPairingCode(
userDataPath: string,
args: { name: string; pairingCode: string; now?: number }
): KnownRuntimeEnvironment {
return translateStoreError(() => addEnvironmentFromPairingCodeInStore(userDataPath, args))
}
export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment {
return translateStoreError(() => removeEnvironmentFromStore(userDataPath, selector))
}
export function resolveEnvironment(
userDataPath: string,
selector: string
): KnownRuntimeEnvironment {
return translateStoreError(() => resolveEnvironmentFromStore(userDataPath, selector))
}
export function resolveEnvironmentPairingOffer(
userDataPath: string,
selector: string
): PairingOffer {
return translateStoreError(() => resolveEnvironmentPairingOfferFromStore(userDataPath, selector))
}
export function markEnvironmentUsed(
userDataPath: string,
selector: string,
args: { runtimeId?: string | null; now?: number } = {}
): void {
translateStoreError(() => markEnvironmentUsedInStore(userDataPath, selector, args))
}
function translateStoreError<TResult>(fn: () => TResult): TResult {
try {
return fn()
} catch (error) {
if (error instanceof RuntimeEnvironmentStoreError) {
throw new RuntimeClientError(toRuntimeClientErrorCode(error.code), error.message)
}
throw error
}
}
function toRuntimeClientErrorCode(
code: RuntimeEnvironmentStoreErrorCode
): 'invalid_argument' | 'runtime_error' {
return code
}
+1
View File
@@ -1,4 +1,5 @@
export { RuntimeClient } from './client'
export { serveOrcaApp } from './launch'
export { getDefaultUserDataPath } from './metadata'
export {
RuntimeClientError,
+149
View File
@@ -0,0 +1,149 @@
import { resolve } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
}))
vi.mock('child_process', () => ({
spawn: spawnMock
}))
import { serveOrcaApp } from './launch'
describe('serveOrcaApp', () => {
beforeEach(() => {
spawnMock.mockReset()
process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca'
})
afterEach(() => {
delete process.env.ORCA_APP_EXECUTABLE
delete process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT
})
it('pins the Electron child cwd to the app root instead of the caller cwd', async () => {
const child = {
kill: vi.fn(),
once: vi.fn(
(event: string, handler: (code: number | null, signal: string | null) => void) => {
if (event === 'exit') {
queueMicrotask(() => handler(0, null))
}
return child
}
)
}
spawnMock.mockReturnValue(child)
await expect(serveOrcaApp({ json: true })).resolves.toBe(0)
expect(spawnMock).toHaveBeenCalledWith(
'/Applications/Orca.app/Contents/MacOS/Orca',
['--serve', '--serve-json'],
expect.objectContaining({
cwd: resolve(__dirname, '../../..')
})
)
})
it('passes mobile pairing through to the foreground server child', async () => {
const child = {
kill: vi.fn(),
once: vi.fn(
(event: string, handler: (code: number | null, signal: string | null) => void) => {
if (event === 'exit') {
queueMicrotask(() => handler(0, null))
}
return child
}
)
}
spawnMock.mockReturnValue(child)
await expect(
serveOrcaApp({
json: true,
port: '6768',
pairingAddress: '100.64.1.20',
mobilePairing: true
})
).resolves.toBe(0)
expect(spawnMock).toHaveBeenCalledWith(
'/Applications/Orca.app/Contents/MacOS/Orca',
[
'--serve',
'--serve-json',
'--serve-port',
'6768',
'--serve-pairing-address',
'100.64.1.20',
'--serve-mobile-pairing'
],
expect.objectContaining({
cwd: resolve(__dirname, '../../..')
})
)
})
it('passes the app root before serve flags for dev Electron executables', async () => {
process.env.ORCA_APP_EXECUTABLE = '/repo/node_modules/.bin/electron'
process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1'
const child = {
kill: vi.fn(),
once: vi.fn(
(event: string, handler: (code: number | null, signal: string | null) => void) => {
if (event === 'exit') {
queueMicrotask(() => handler(0, null))
}
return child
}
)
}
spawnMock.mockReturnValue(child)
await expect(serveOrcaApp({ json: true, port: '6768' })).resolves.toBe(0)
expect(spawnMock).toHaveBeenCalledWith(
'/repo/node_modules/.bin/electron',
[resolve(__dirname, '../../..'), '--serve', '--serve-json', '--serve-port', '6768'],
expect.objectContaining({
cwd: resolve(__dirname, '../../..')
})
)
})
it('uses a shell when a Windows npm command shim is the Electron executable', async () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
process.env.ORCA_APP_EXECUTABLE = 'C:\\repo\\node_modules\\.bin\\electron.cmd'
const child = {
kill: vi.fn(),
once: vi.fn(
(event: string, handler: (code: number | null, signal: string | null) => void) => {
if (event === 'exit') {
queueMicrotask(() => handler(0, null))
}
return child
}
)
}
spawnMock.mockReturnValue(child)
try {
await expect(serveOrcaApp({ json: true })).resolves.toBe(0)
expect(spawnMock).toHaveBeenCalledWith(
'C:\\repo\\node_modules\\.bin\\electron.cmd',
['--serve', '--serve-json'],
expect.objectContaining({
shell: true
})
)
} finally {
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor)
}
}
})
})
+100 -3
View File
@@ -1,5 +1,5 @@
import { spawn as spawnProcess } from 'child_process'
import { dirname } from 'path'
import { spawn as spawnProcess, type SpawnOptions } from 'child_process'
import { dirname, resolve } from 'path'
import { RuntimeClientError } from './types'
export function launchOrcaApp(): void {
@@ -15,9 +15,10 @@ export function launchOrcaApp(): void {
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
spawnProcess(overrideExecutable, [], {
spawnProcess(overrideExecutable, getExecutableAppArgs(), {
detached: true,
stdio: 'ignore',
...getExecutableSpawnOptions(overrideExecutable),
env: stripElectronRunAsNode(process.env)
}).unref()
return
@@ -53,6 +54,102 @@ export function launchOrcaApp(): void {
)
}
export function serveOrcaApp(
args: {
json?: boolean
port?: string | null
pairingAddress?: string | null
noPairing?: boolean
mobilePairing?: boolean
} = {}
): Promise<number> {
const executable = resolveForegroundOrcaExecutable()
const childArgs = [...getExecutableAppArgs(), '--serve']
if (args.json) {
childArgs.push('--serve-json')
}
if (args.port) {
childArgs.push('--serve-port', args.port)
}
if (args.pairingAddress) {
childArgs.push('--serve-pairing-address', args.pairingAddress)
}
if (args.noPairing) {
childArgs.push('--serve-no-pairing')
}
if (args.mobilePairing) {
childArgs.push('--serve-mobile-pairing')
}
const child = spawnProcess(executable, childArgs, {
cwd: resolveAppRoot(),
stdio: 'inherit',
...getExecutableSpawnOptions(executable),
env: stripElectronRunAsNode(process.env)
})
return new Promise((resolve, reject) => {
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
const forwardSignal = (signal: NodeJS.Signals): void => {
child.kill(signal)
forceKillTimer ??= setTimeout(() => {
child.kill('SIGKILL')
}, 5000)
}
const cleanup = (): void => {
process.off('SIGINT', forwardSignal)
process.off('SIGTERM', forwardSignal)
if (forceKillTimer) {
clearTimeout(forceKillTimer)
forceKillTimer = null
}
}
process.on('SIGINT', forwardSignal)
process.on('SIGTERM', forwardSignal)
child.once('error', (error) => {
cleanup()
reject(error)
})
child.once('exit', (code, signal) => {
cleanup()
if (typeof code === 'number') {
resolve(code)
return
}
reject(new RuntimeClientError('runtime_serve_failed', `Orca serve exited via ${signal}`))
})
})
}
function getExecutableAppArgs(): string[] {
return process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : []
}
function getExecutableSpawnOptions(executable: string): Pick<SpawnOptions, 'shell'> {
return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable) ? { shell: true } : {}
}
function resolveAppRoot(): string {
// Why: dev-mode resource resolution in the Electron child may consult
// process.cwd(). Pin it to the app root so `orca serve` behaves the same
// regardless of the shell directory it was launched from.
return resolve(__dirname, '../../..')
}
function resolveForegroundOrcaExecutable(): string {
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
return overrideExecutable
}
if (process.env.ELECTRON_RUN_AS_NODE === '1') {
return process.execPath
}
throw new RuntimeClientError(
'runtime_serve_failed',
'Could not determine how to start Orca server. Set ORCA_APP_EXECUTABLE to the Orca executable.'
)
}
function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next = { ...env }
delete next.ELECTRON_RUN_AS_NODE
+1 -1
View File
@@ -29,7 +29,7 @@ export async function getCliStatus(
try {
const response = await sendRequest<RuntimeStatus>(metadata, 'status.get', undefined, 1000)
if (!response.ok) {
if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
}
const graphState = response.result.graphStatus
+3 -3
View File
@@ -49,10 +49,10 @@ export async function sendRequest<TResult>(
settled = true
clearTimeout(timeout)
socket.end()
if (result.ok) {
resolve(result.response)
} else {
if (result.ok === false) {
reject(result.error)
} else {
resolve(result.response)
}
}
+6 -27
View File
@@ -1,31 +1,10 @@
// Why: the RPC envelope shape is the contract the CLI shares with the main
// runtime. Keeping the types and error classes in one leaf module lets every
// other runtime module depend on them without pulling in transport or launch
// code.
import type { RuntimeRpcFailure } from '../../shared/runtime-rpc-envelope'
export type RuntimeRpcSuccess<TResult> = {
id: string
ok: true
result: TResult
_meta: {
runtimeId: string
}
}
export type RuntimeRpcFailure = {
id: string
ok: false
error: {
code: string
message: string
data?: unknown
}
_meta?: {
runtimeId: string | null
}
}
export type RuntimeRpcResponse<TResult> = RuntimeRpcSuccess<TResult> | RuntimeRpcFailure
export type {
RuntimeRpcFailure,
RuntimeRpcResponse,
RuntimeRpcSuccess
} from '../../shared/runtime-rpc-envelope'
export class RuntimeClientError extends Error {
readonly code: string
+237
View File
@@ -0,0 +1,237 @@
import { createServer, type Server } from 'http'
import { mkdtempSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { WebSocketServer } from 'ws'
import { encodePairingOffer, type PairingOffer } from '../../shared/pairing'
import {
decrypt,
deriveSharedKey,
encrypt,
generateKeyPair,
publicKeyToBase64
} from '../../shared/e2ee-crypto'
import { RuntimeClient } from './client'
import { addEnvironmentFromPairingCode } from './environments'
import { RuntimeClientError } from './types'
import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../shared/protocol-version'
type TestRuntime = {
endpoint: string
publicKeyB64: string
deviceToken: string
close: () => Promise<void>
}
describe('CLI remote WebSocket transport', () => {
const servers: TestRuntime[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => server.close()))
})
it('calls a remote runtime through a mobile pairing offer', async () => {
const runtime = await startTestRuntime('runtime-ws-1')
servers.push(runtime)
const pairingUrl = encodePairingOffer({
v: 2,
endpoint: runtime.endpoint,
deviceToken: runtime.deviceToken,
publicKeyB64: runtime.publicKeyB64
})
const client = new RuntimeClient('/tmp/unused', 5_000, pairingUrl)
const response = await client.call<{ runtimeId: string }>('status.get')
expect(response.ok).toBe(true)
expect(response.result.runtimeId).toBe('runtime-ws-1')
})
it('rejects malformed remote pairing codes before local runtime lookup', () => {
expect(() => new RuntimeClient('/tmp/unused', 5_000, 'not-a-pairing-code')).toThrow(
RuntimeClientError
)
})
it('accepts a bare pairing payload as well as the orca URL wrapper', async () => {
const runtime = await startTestRuntime('runtime-ws-2')
servers.push(runtime)
const offer: PairingOffer = {
v: 2,
endpoint: runtime.endpoint,
deviceToken: runtime.deviceToken,
publicKeyB64: runtime.publicKeyB64
}
const barePayload = encodePairingOffer(offer).split('#')[1]!
const client = new RuntimeClient('/tmp/unused', 5_000, barePayload)
const status = await client.getCliStatus()
expect(status.result.runtime.reachable).toBe(true)
expect(status.result.runtime.runtimeId).toBe('runtime-ws-2')
})
it('connects through a saved environment selector', async () => {
const runtime = await startTestRuntime('runtime-env-1')
servers.push(runtime)
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-env-'))
addEnvironmentFromPairingCode(userDataPath, {
name: 'remote-dev',
pairingCode: encodePairingOffer({
v: 2,
endpoint: runtime.endpoint,
deviceToken: runtime.deviceToken,
publicKeyB64: runtime.publicKeyB64
})
})
const client = new RuntimeClient(userDataPath, 5_000, null, 'remote-dev')
const status = await client.getCliStatus()
expect(status.result.runtime.reachable).toBe(true)
expect(status.result.runtime.runtimeId).toBe('runtime-env-1')
})
it('blocks remote RPCs when the server protocol is too old', async () => {
const runtime = await startTestRuntime('runtime-old', { runtimeProtocolVersion: 1 })
servers.push(runtime)
const client = new RuntimeClient(
'/tmp/unused',
5_000,
encodePairingOffer({
v: 2,
endpoint: runtime.endpoint,
deviceToken: runtime.deviceToken,
publicKeyB64: runtime.publicKeyB64
})
)
await expect(client.call('repo.list')).rejects.toMatchObject({
code: 'incompatible_runtime',
message: expect.stringContaining('server is too old')
})
})
})
async function startTestRuntime(
runtimeId: string,
statusOverrides: {
runtimeProtocolVersion?: number
minCompatibleRuntimeClientVersion?: number
} = {}
): Promise<TestRuntime> {
const serverKeyPair = generateKeyPair()
const deviceToken = `token-${runtimeId}`
const httpServer = createServer()
const wss = new WebSocketServer({ server: httpServer })
wss.on('connection', (ws) => {
let sharedKey: Uint8Array | null = null
let authenticated = false
ws.on('message', (data) => {
const frame = data.toString()
if (!sharedKey) {
const hello = JSON.parse(frame) as { type?: string; publicKeyB64?: string }
const clientPublicKey = Buffer.from(hello.publicKeyB64 ?? '', 'base64')
sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey)
ws.send(JSON.stringify({ type: 'e2ee_ready' }))
return
}
const plaintext = decrypt(frame, sharedKey)
if (!plaintext) {
ws.close(4003, 'decrypt failed')
return
}
if (!authenticated) {
const auth = JSON.parse(plaintext) as { type?: string; deviceToken?: string }
if (auth.type !== 'e2ee_auth' || auth.deviceToken !== deviceToken) {
ws.send(encrypt(JSON.stringify({ type: 'e2ee_error' }), sharedKey))
ws.close(4001, 'auth failed')
return
}
authenticated = true
ws.send(encrypt(JSON.stringify({ type: 'e2ee_authenticated' }), sharedKey))
return
}
const request = JSON.parse(plaintext) as { id: string; method: string }
const response =
request.method === 'status.get'
? {
id: request.id,
ok: true,
result: {
runtimeId,
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion:
statusOverrides.runtimeProtocolVersion ?? RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion:
statusOverrides.minCompatibleRuntimeClientVersion ??
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
},
_meta: { runtimeId }
}
: {
id: request.id,
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId }
}
ws.send(encrypt(JSON.stringify(response), sharedKey))
})
})
await listen(httpServer)
const address = httpServer.address()
if (!address || typeof address === 'string') {
throw new Error('Expected TCP test server')
}
return {
endpoint: `ws://127.0.0.1:${address.port}`,
publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey),
deviceToken,
close: async () => {
await new Promise<void>((resolve) => {
wss.close(() => resolve())
for (const client of wss.clients) {
client.close()
}
})
await closeHttpServer(httpServer)
}
}
}
async function listen(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
}
async function closeHttpServer(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
+22
View File
@@ -0,0 +1,22 @@
import type { PairingOffer } from '../../shared/pairing'
import {
RemoteRuntimeClientError,
sendRemoteRuntimeRequest
} from '../../shared/remote-runtime-client'
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
export async function sendWebSocketRequest<TResult>(
pairing: PairingOffer,
method: string,
params: unknown,
timeoutMs: number
): Promise<RuntimeRpcResponse<TResult>> {
try {
return await sendRemoteRuntimeRequest<TResult>(pairing, method, params, timeoutMs)
} catch (error) {
if (error instanceof RemoteRuntimeClientError) {
throw new RuntimeClientError(error.code, error.message)
}
throw error
}
}
+27 -2
View File
@@ -1,5 +1,6 @@
import { isAbsolute, relative, resolve as resolvePath } from 'path'
import type { ComputerAppQuery, RuntimeWorktreeListResult } from '../shared/runtime-types'
import { isPathInsideOrEqual } from '../shared/cross-platform-path'
import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime-client'
import { getOptionalStringFlag, getRequiredStringFlag } from './flags'
@@ -26,7 +27,22 @@ export function normalizeWorktreeSelector(selector: string, cwd: string): string
return selector
}
function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient): void {
if (!client.isRemote) {
return
}
// Why: a paired CLI's cwd belongs to the client machine, not the runtime
// server, so cwd-derived worktree selectors are only valid locally.
throw new RuntimeClientError(
'invalid_argument',
`${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:<id>, branch:<branch>, issue:<number>, or path:<absolute-server-path>.`
)
}
function isWithinPath(parentPath: string, childPath: string): boolean {
if (isPathInsideOrEqual(parentPath, childPath)) {
return true
}
const relativePath = relative(parentPath, childPath)
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
}
@@ -35,6 +51,8 @@ export async function resolveCurrentWorktreeSelector(
cwd: string,
client: RuntimeClient
): Promise<string> {
assertLocalCwdWorktreeSelector('current', client)
const currentPath = resolvePath(cwd)
const worktrees = await client.call<RuntimeWorktreeListResult>('worktree.list', {
limit: 10_000
@@ -68,6 +86,7 @@ export async function getOptionalWorktreeSelector(
return undefined
}
if (value === 'active' || value === 'current') {
assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
@@ -81,13 +100,14 @@ export async function getRequiredWorktreeSelector(
): Promise<string> {
const value = getRequiredStringFlag(flags, name)
if (value === 'active' || value === 'current') {
assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
}
// Why: browser commands default to the current worktree (auto-resolve from cwd).
// --worktree all bypasses filtering. Omitting --worktree auto-resolves.
// Why: local browser commands default to the current worktree by auto-resolving
// from cwd. Remote commands omit worktree so the runtime uses server-side focus.
export async function getBrowserWorktreeSelector(
flags: Map<string, string | boolean>,
cwd: string,
@@ -99,10 +119,14 @@ export async function getBrowserWorktreeSelector(
}
if (value) {
if (value === 'active' || value === 'current') {
assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
}
if (client.isRemote) {
return undefined
}
// Default: auto-resolve from cwd
try {
return await resolveCurrentWorktreeSelector(cwd, client)
@@ -146,6 +170,7 @@ export async function getBrowserCommandTarget(
return { page }
}
if (explicitWorktree === 'active' || explicitWorktree === 'current') {
assertLocalCwdWorktreeSelector(explicitWorktree, client)
return {
page,
worktree: await resolveCurrentWorktreeSelector(cwd, client)
+19
View File
@@ -9,6 +9,25 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca open', 'orca open --json']
},
{
path: ['serve'],
summary: 'Start an Orca runtime server without opening a desktop window',
usage:
'orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'port', 'pairing-address', 'mobile-pairing', 'no-pairing'],
notes: [
'Runs in the foreground and prints the runtime endpoint. Stop it with Ctrl+C.',
'Use --pairing-address when clients should connect through a LAN, Tailscale, SSH-forward, or public tunnel address.',
'Use --mobile-pairing to print a mobile-scoped pairing QR/link instead of the default runtime-environment pairing link.',
'When the web client bundle is available, the server also prints a browser URL with the pairing data embedded.'
],
examples: [
'orca serve',
'orca serve --json',
'orca serve --port 6768 --pairing-address 100.64.1.20',
'orca serve --pairing-address 100.64.1.20 --mobile-pairing'
]
},
{
path: ['status'],
summary: 'Show app/runtime/graph readiness',
+30
View File
@@ -0,0 +1,30 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [
{
path: ['environment', 'add'],
summary: 'Save a remote Orca runtime environment from a pairing code',
usage: 'orca environment add --name <name> --pairing-code <code> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'name'],
examples: ['orca environment add --name work-laptop --pairing-code orca://pair#...']
},
{
path: ['environment', 'list'],
summary: 'List saved Orca runtime environments',
usage: 'orca environment list [--json]',
allowedFlags: [...GLOBAL_FLAGS]
},
{
path: ['environment', 'show'],
summary: 'Show one saved Orca runtime environment',
usage: 'orca environment show --environment <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS]
},
{
path: ['environment', 'rm'],
summary: 'Remove one saved Orca runtime environment',
usage: 'orca environment rm --environment <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS]
}
]
+2 -2
View File
@@ -4,7 +4,7 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
import { CORE_COMMAND_SPECS } from './core'
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
import { COMPUTER_COMMAND_SPECS } from './computer'
import { NOTE_COMMAND_SPECS } from './note'
import { ENVIRONMENT_COMMAND_SPECS } from './environment'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
@@ -12,5 +12,5 @@ export const COMMAND_SPECS: CommandSpec[] = [
...BROWSER_ADVANCED_COMMAND_SPECS,
...ORCHESTRATION_COMMAND_SPECS,
...COMPUTER_COMMAND_SPECS,
...NOTE_COMMAND_SPECS
...ENVIRONMENT_COMMAND_SPECS
]
-37
View File
@@ -1,37 +0,0 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const NOTE_COMMAND_SPECS: CommandSpec[] = [
{
path: ['note', 'list'],
summary: 'List project notes for the current Orca worktree',
usage: 'orca note list [--worktree <selector>] [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'limit']
},
{
path: ['note', 'show'],
summary: 'Show a project note',
usage: 'orca note show --note <selector> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'note', 'worktree']
},
{
path: ['note', 'create'],
summary: 'Create a project note',
usage:
'orca note create --title <title> [--body <text>|--body-file <path>|--body-stdin] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'title', 'body', 'body-file', 'body-stdin', 'worktree']
},
{
path: ['note', 'append'],
summary: 'Append Markdown to a project note',
usage:
'orca note append --note <selector> [--body <text>|--body-file <path>|--body-stdin] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'note', 'body', 'body-file', 'body-stdin', 'worktree']
},
{
path: ['note', 'search'],
summary: 'Search project notes',
usage: 'orca note search --query <text> [--worktree <selector>] [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'query', 'worktree', 'limit']
}
]
+208
View File
@@ -0,0 +1,208 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentAwakeService, AGENT_AWAKE_STATUS_STALE_AFTER_MS } from './agent-awake-service'
import type { AgentAwakeStatus } from './agent-awake-service'
vi.mock('electron', () => ({
powerSaveBlocker: {
start: vi.fn(),
stop: vi.fn(),
isStarted: vi.fn()
}
}))
function workingStatus(overrides: Partial<AgentAwakeStatus> = {}): AgentAwakeStatus {
return {
state: 'working',
receivedAt: 1_000,
observedInCurrentRuntime: true,
...overrides
}
}
function createBlocker() {
const startedIds = new Set<number>()
let nextId = 1
return {
start: vi.fn(() => {
const id = nextId++
startedIds.add(id)
return id
}),
stop: vi.fn((id: number) => {
startedIds.delete(id)
}),
isStarted: vi.fn((id: number) => startedIds.has(id)),
startedIds
}
}
function createService(now: () => number, blocker = createBlocker()): AgentAwakeService {
return new AgentAwakeService({
blocker,
now,
logger: {
debug: vi.fn(),
warn: vi.fn()
}
})
}
describe('AgentAwakeService', () => {
beforeEach(() => {
vi.useRealTimers()
})
it('does not start when disabled even with a running status', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setStatuses([workingStatus()])
expect(blocker.start).not.toHaveBeenCalled()
})
it('starts one prevent-app-suspension blocker when enabled with a fresh working status', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus()])
expect(blocker.start).toHaveBeenCalledTimes(1)
expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension')
})
it('starts and stops from settings flips around an already-running status', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setStatuses([workingStatus()])
service.setEnabled(true)
service.setEnabled(false)
expect(blocker.start).toHaveBeenCalledTimes(1)
expect(blocker.stop).toHaveBeenCalledWith(1)
})
it('ignores startup-hydrated working statuses that were not observed in this runtime', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus({ observedInCurrentRuntime: false })])
expect(blocker.start).not.toHaveBeenCalled()
})
it('does not start for blocked, waiting, or done statuses', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([
workingStatus({ state: 'blocked' }),
workingStatus({ state: 'waiting' }),
workingStatus({ state: 'done' })
])
expect(blocker.start).not.toHaveBeenCalled()
})
it('does not start a second blocker when one working status replaces another', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus({ receivedAt: 1_000 })])
service.setStatuses([workingStatus({ receivedAt: 1_100 })])
expect(blocker.start).toHaveBeenCalledTimes(1)
})
it('stops when the last running status is dropped', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus()])
service.setStatuses([])
expect(blocker.stop).toHaveBeenCalledWith(1)
})
it('does not start for a stale working status', () => {
const blocker = createBlocker()
const service = createService(() => AGENT_AWAKE_STATUS_STALE_AFTER_MS + 1_001, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus({ receivedAt: 1_000 })])
expect(blocker.start).not.toHaveBeenCalled()
})
it('stops when the only running status becomes stale without another event', () => {
vi.useFakeTimers()
let now = 1_000
const blocker = createBlocker()
const service = createService(() => now, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus({ receivedAt: 1_000 })])
now = 1_000 + AGENT_AWAKE_STATUS_STALE_AFTER_MS + 1
vi.advanceTimersByTime(AGENT_AWAKE_STATUS_STALE_AFTER_MS)
expect(blocker.stop).toHaveBeenCalledWith(1)
service.dispose()
})
it('reschedules stale expiry for a newer running status', () => {
vi.useFakeTimers()
let now = 1_000
const blocker = createBlocker()
const service = createService(() => now, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus({ receivedAt: 1_000 })])
now = 2_000
service.setStatuses([workingStatus({ receivedAt: 2_000 })])
now = 1_000 + AGENT_AWAKE_STATUS_STALE_AFTER_MS + 1
vi.advanceTimersByTime(AGENT_AWAKE_STATUS_STALE_AFTER_MS)
expect(blocker.stop).not.toHaveBeenCalled()
now = 2_000 + AGENT_AWAKE_STATUS_STALE_AFTER_MS + 1
vi.advanceTimersByTime(1_000)
expect(blocker.stop).toHaveBeenCalledWith(1)
service.dispose()
})
it('keeps the blocker id when stop fails and Electron reports it is still started', () => {
const blocker = createBlocker()
blocker.stop.mockImplementation(() => {
throw new Error('stop failed')
})
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus()])
service.setStatuses([])
service.setStatuses([])
expect(blocker.stop).toHaveBeenCalledTimes(2)
expect(blocker.stop).toHaveBeenCalledWith(1)
})
it('disposes by clearing timers and stopping an active blocker once', () => {
vi.useFakeTimers()
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus()])
service.dispose()
vi.advanceTimersByTime(AGENT_AWAKE_STATUS_STALE_AFTER_MS)
expect(blocker.stop).toHaveBeenCalledTimes(1)
expect(blocker.stop).toHaveBeenCalledWith(1)
})
})
+206
View File
@@ -0,0 +1,206 @@
import { powerSaveBlocker } from 'electron'
import type { AgentStatusState } from '../shared/agent-status-types'
export const AGENT_AWAKE_STATUS_STALE_AFTER_MS = 2 * 60 * 60 * 1000
export type AgentAwakeStatus = {
state: AgentStatusState
receivedAt: number
observedInCurrentRuntime: boolean
}
type PowerSaveBlocker = {
start: (type: 'prevent-app-suspension') => number
stop: (id: number) => void
isStarted: (id: number) => boolean
}
type Logger = Pick<Console, 'debug' | 'warn'>
type AgentAwakeServiceOptions = {
blocker?: PowerSaveBlocker
logger?: Logger
now?: () => number
}
export class AgentAwakeService {
private enabled = false
private statuses: AgentAwakeStatus[] = []
private blockerId: number | null = null
private staleTimer: ReturnType<typeof setTimeout> | null = null
private readonly blocker: PowerSaveBlocker
private readonly logger: Logger
private readonly now: () => number
constructor(options: AgentAwakeServiceOptions = {}) {
this.blocker = options.blocker ?? powerSaveBlocker
this.logger = options.logger ?? console
this.now = options.now ?? Date.now
}
setEnabled(enabled: boolean): void {
if (this.enabled === enabled) {
return
}
this.enabled = enabled
this.refresh('settings-change')
}
setStatuses(statuses: AgentAwakeStatus[]): void {
this.statuses = statuses.map((status) => ({ ...status }))
this.refresh('status-change')
}
dispose(): void {
this.clearStaleTimer()
this.stopBlocker('dispose')
}
private refresh(reason: string): void {
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 {
this.stopBlocker(reason, runningStatusCount)
}
}
private getEligibleRunningStatusCount(): number {
const now = this.now()
return this.statuses.filter((status) => this.isWakeEligible(status, now)).length
}
private isWakeEligible(status: AgentAwakeStatus, now: number): boolean {
return (
status.observedInCurrentRuntime &&
status.state === 'working' &&
Number.isFinite(status.receivedAt) &&
now - status.receivedAt <= AGENT_AWAKE_STATUS_STALE_AFTER_MS
)
}
private scheduleStaleTimer(): void {
this.clearStaleTimer()
const now = this.now()
let earliestExpiry: number | null = null
for (const status of this.statuses) {
if (
!status.observedInCurrentRuntime ||
status.state !== 'working' ||
!Number.isFinite(status.receivedAt)
) {
continue
}
const expiry = status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS
if (expiry <= now) {
continue
}
earliestExpiry = earliestExpiry === null ? expiry : Math.min(earliestExpiry, expiry)
}
if (earliestExpiry === null) {
return
}
this.staleTimer = setTimeout(() => {
this.staleTimer = null
this.refresh('stale-expiry')
}, earliestExpiry - now)
if (typeof this.staleTimer.unref === 'function') {
this.staleTimer.unref()
}
}
private clearStaleTimer(): void {
if (!this.staleTimer) {
return
}
clearTimeout(this.staleTimer)
this.staleTimer = null
}
private startBlocker(reason: string, runningStatusCount: number): void {
if (this.blockerId !== null) {
if (this.reconcileBlocker('start-reconcile')) {
return
}
}
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', {
reason,
enabled: this.enabled,
runningStatusCount,
error: err
})
}
}
private stopBlocker(reason: string, runningStatusCount = 0): void {
if (this.blockerId === null) {
return
}
const id = this.blockerId
try {
this.blocker.stop(id)
} catch (err) {
this.logger.warn('[agent-awake] failed to stop blocker', {
reason,
enabled: this.enabled,
runningStatusCount,
blockerId: id,
error: err
})
}
if (!this.reconcileBlocker('post-stop')) {
this.logger.debug('[agent-awake] stopped blocker', {
reason,
enabled: this.enabled,
runningStatusCount,
blockerId: id
})
}
}
private reconcileBlocker(reason: string): boolean {
if (this.blockerId === null) {
return false
}
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
}
return isStarted
} catch (err) {
this.logger.warn('[agent-awake] failed to reconcile blocker', {
reason,
blockerId: id,
error: err
})
return true
}
}
}
@@ -0,0 +1,269 @@
import { describe, expect, it } from 'vitest'
import type { SFTPWrapper } from 'ssh2'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
writeManagedScriptRemote,
writeTextFileRemoteAtomic
} from './installer-utils-remote'
type FakeFs = {
files: Map<string, string>
dirs: Set<string>
modes: Map<string, number>
openSshRenameCount: number
}
function createFakeSftp(
opts: {
plainRenameOverwrites?: boolean
openSshRename?: boolean
failDotFileWrites?: boolean
} = {}
): {
sftp: SFTPWrapper
fs: FakeFs
} {
const plainRenameOverwrites = opts.plainRenameOverwrites ?? true
const fs: FakeFs = {
files: new Map(),
dirs: new Set(['/']),
modes: new Map(),
openSshRenameCount: 0
}
const noEntryError = (path: string): { code: number; message: string } => ({
code: 2,
message: `ENOENT ${path}`
})
const fakeStats = (mode: number): { mode: number } => ({ mode })
const sftp = {
readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
const v = fs.files.get(path)
if (v === undefined) {
cb(noEntryError(path))
return
}
cb(null, v)
},
writeFile: (
path: string,
content: string,
options: string | { mode?: number },
cb: (err: unknown) => void
): void => {
if (opts.failDotFileWrites && path.includes('/.')) {
cb({ code: 4, message: `write failed ${path}` })
return
}
fs.files.set(path, content)
if (typeof options !== 'string' && options.mode !== undefined) {
fs.modes.set(path, options.mode)
}
cb(null)
},
rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
const v = fs.files.get(src)
if (v === undefined) {
cb(noEntryError(src))
return
}
if (!plainRenameOverwrites && fs.files.has(dst)) {
cb({ code: 4, message: `SSH_FX_FAILURE destination exists ${dst}` })
return
}
fs.files.set(dst, v)
fs.files.delete(src)
const mode = fs.modes.get(src)
if (mode !== undefined) {
fs.modes.set(dst, mode)
fs.modes.delete(src)
}
cb(null)
},
unlink: (path: string, cb: (err: unknown) => void): void => {
if (!fs.files.has(path)) {
cb(noEntryError(path))
return
}
fs.files.delete(path)
fs.modes.delete(path)
cb(null)
},
chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
fs.modes.set(path, mode)
cb(null)
},
stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
if (!fs.files.has(path)) {
cb(noEntryError(path))
return
}
cb(null, fakeStats(fs.modes.get(path) ?? 0o100644))
},
readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
if (fs.dirs.has(path)) {
cb(null, [])
return
}
cb(noEntryError(path))
},
mkdir: (path: string, cb: (err: unknown) => void): void => {
fs.dirs.add(path)
cb(null)
},
...(opts.openSshRename
? {
ext_openssh_rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
fs.openSshRenameCount += 1
const v = fs.files.get(src)
if (v === undefined) {
cb(noEntryError(src))
return
}
fs.files.set(dst, v)
fs.files.delete(src)
const mode = fs.modes.get(src)
if (mode !== undefined) {
fs.modes.set(dst, mode)
fs.modes.delete(src)
}
cb(null)
}
}
: {})
} as unknown as SFTPWrapper
return { sftp, fs }
}
describe('installer-utils-remote', () => {
it('returns {} when settings.json does not exist on the remote', async () => {
const { sftp } = createFakeSftp()
const result = await readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')
expect(result).toEqual({})
})
it('returns null when settings.json is malformed JSON', async () => {
const { sftp, fs } = createFakeSftp()
fs.files.set('/home/u/.claude/settings.json', 'not json {{')
const result = await readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')
expect(result).toBeNull()
})
it('rethrows non-ENOENT read errors so callers can distinguish I/O failures from parse failures', async () => {
const sftp = {
readFile: (_path: string, _enc: string, cb: (err: unknown) => void): void => {
// Why: SSH_FX_PERMISSION_DENIED (3) is a real I/O failure that should
// not collapse into the same null result the parse-error path uses.
cb({ code: 3, message: 'permission denied' })
}
} as unknown as SFTPWrapper
await expect(readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')).rejects.toMatchObject({
code: 3
})
})
it('atomically writes settings.json via tmp + rename', async () => {
const { sftp, fs } = createFakeSftp()
await writeHooksJsonRemote(sftp, '/home/u/.claude/settings.json', {
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'foo' }] }] }
})
expect(fs.files.has('/home/u/.claude/settings.json')).toBe(true)
expect(fs.dirs.has('/home/u/.claude')).toBe(true)
const contents = fs.files.get('/home/u/.claude/settings.json')!
const parsed = JSON.parse(contents)
expect(parsed.hooks.Stop[0].hooks[0].command).toBe('foo')
// Tmp must be cleaned up.
const tmp = Array.from(fs.files.keys()).find((k) => k.includes('.tmp'))
expect(tmp).toBeUndefined()
expect(fs.modes.get('/home/u/.claude/settings.json')).toBe(0o600)
})
it('preserves existing config file mode across atomic replacement', async () => {
const { sftp, fs } = createFakeSftp()
const path = '/home/u/.codex/config.toml'
fs.files.set(path, 'old')
fs.modes.set(path, 0o640)
await writeTextFileRemoteAtomic(sftp, path, 'new')
expect(fs.modes.get(path)).toBe(0o640)
})
it('uses OpenSSH overwrite rename when an atomic write updates an existing file', async () => {
const { sftp, fs } = createFakeSftp({
plainRenameOverwrites: false,
openSshRename: true
})
const path = '/home/u/.claude/settings.json'
fs.files.set(path, JSON.stringify({ hooks: {} }))
await writeHooksJsonRemote(sftp, path, {
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'new' }] }] }
})
expect(fs.openSshRenameCount).toBe(1)
expect(JSON.parse(fs.files.get(path)!).hooks.Stop[0].hooks[0].command).toBe('new')
})
it('leaves existing files intact when overwrite rename is unavailable', async () => {
const { sftp, fs } = createFakeSftp({ plainRenameOverwrites: false })
const path = '/home/u/.claude/settings.json'
fs.files.set(path, JSON.stringify({ hooks: { Stop: [] } }))
await expect(
writeHooksJsonRemote(sftp, path, {
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'fallback' }] }] }
})
).rejects.toMatchObject({ code: 4 })
expect(JSON.parse(fs.files.get(path)!).hooks.Stop).toEqual([])
expect(Array.from(fs.files.keys()).some((key) => key.includes('.tmp'))).toBe(false)
})
it('writes the managed script and chmods 0o755', async () => {
const { sftp, fs } = createFakeSftp()
await writeManagedScriptRemote(sftp, '/home/u/.orca/agent-hooks/claude-hook.sh', '#!/bin/sh\n')
expect(fs.files.get('/home/u/.orca/agent-hooks/claude-hook.sh')).toBe('#!/bin/sh\n')
expect(fs.modes.get('/home/u/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
})
it('replaces an existing managed script atomically via temp file rename', async () => {
const { sftp, fs } = createFakeSftp({
plainRenameOverwrites: false,
openSshRename: true
})
const path = '/home/u/.orca/agent-hooks/claude-hook.sh'
fs.files.set(path, 'old script')
await writeManagedScriptRemote(sftp, path, 'new script')
expect(fs.files.get(path)).toBe('new script')
expect(fs.modes.get(path)).toBe(0o755)
expect(Array.from(fs.files.keys()).some((key) => key.includes('.orca-backup-'))).toBe(false)
})
it('leaves the existing managed script intact when temp write fails', async () => {
const { sftp, fs } = createFakeSftp({ failDotFileWrites: true })
const path = '/home/u/.orca/agent-hooks/claude-hook.sh'
fs.files.set(path, 'old script')
await expect(writeManagedScriptRemote(sftp, path, 'new script')).rejects.toMatchObject({
code: 4
})
expect(fs.files.get(path)).toBe('old script')
})
it('skips a no-op write when contents already match', async () => {
const { sftp, fs } = createFakeSftp()
const path = '/home/u/.claude/settings.json'
await writeHooksJsonRemote(sftp, path, { hooks: {} })
const beforeKey = fs.files.get(path)
// Re-writing the same payload should produce the same content; there is
// no rename/tmp cycle visible to a downstream observer beyond the
// identical file body.
await writeHooksJsonRemote(sftp, path, { hooks: {} })
expect(fs.files.get(path)).toBe(beforeKey)
})
})
@@ -0,0 +1,385 @@
// Why: SFTP-backed equivalents of `installer-utils.ts` for the remote-install
// flow. Each function takes an `sftp` handle plus paths the agent CLI expects
// on the remote (e.g. `~/.claude/settings.json`). Lives in `agent-hooks/`
// because it shares the contract with the local installer (script body,
// hook-event shape, atomic-rename semantics) and any drift between them is
// exactly the bug we want to avoid.
//
// We deliberately keep the JSON merge logic in the existing
// `installer-utils.ts` and only swap fs primitives — the JSON shape and
// managed-command matching must stay identical to the local install.
//
// See docs/design/agent-status-over-ssh.md §8 (commit #8).
import { randomUUID } from 'crypto'
import type { SFTPWrapper, FileEntryWithStats } from 'ssh2'
import { isPlainObject, type HooksConfig } from './installer-utils'
const DEFAULT_REMOTE_CONFIG_MODE = 0o600
/** Read+JSON-parse a remote file. Returns `null` on parse failure (caller
* surfaces "could not parse" status to the UI), `{}` on missing file
* (matches local behavior — first-install case). Rethrows on other I/O
* failures (permission denied, EIO, channel closed) so the caller can
* distinguish transient SFTP errors from a malformed-JSON case rather
* than collapsing both into a misleading "could not parse" diagnostic. */
export async function readHooksJsonRemote(
sftp: SFTPWrapper,
remotePath: string
): Promise<HooksConfig | null> {
let body: string
try {
body = await readFile(sftp, remotePath)
} catch (err) {
if (isNoEntryError(err)) {
return {}
}
throw err
}
try {
const parsed = JSON.parse(body)
return isPlainObject(parsed) ? parsed : null
} catch {
return null
}
}
/** Atomically write a JSON config to the remote — write to a tmp path then
* rename, mirroring the local writeHooksJson contract. The .bak rotation is
* intentionally NOT carried over: the remote file is the user's, and a
* per-target backup convention belongs alongside the remote installer UI
* (out of scope for this commit). */
export async function writeHooksJsonRemote(
sftp: SFTPWrapper,
remotePath: string,
config: HooksConfig
): Promise<void> {
const dir = dirnamePosix(remotePath)
await mkdirpRemote(sftp, dir)
const serialized = `${JSON.stringify(config, null, 2)}\n`
// Why: skip the write when on-disk content is identical so repeated
// install() calls do not bump the file's mtime / inode unnecessarily.
try {
const existing = await readFile(sftp, remotePath)
if (existing === serialized) {
return
}
} catch {
// ENOENT or read error — fall through to the write below.
}
// Why: tmp + rename so a partial network drop mid-write does not leave a
// truncated settings.json that the agent CLI would refuse to load.
const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp`
try {
const mode = await getRemoteFileModeOrDefault(sftp, remotePath, DEFAULT_REMOTE_CONFIG_MODE)
await writeFile(sftp, tmp, serialized, mode)
await chmod(sftp, tmp, mode)
await rename(sftp, tmp, remotePath)
} finally {
// Best-effort cleanup if rename failed.
try {
await unlink(sftp, tmp)
} catch {
// already gone or never created
}
}
}
/** Write the managed hook script to the remote and chmod 0o755. POSIX-only —
* the relay deliberately does not support Windows-remote in v1 (see design
* doc §3 + §6). */
export async function writeManagedScriptRemote(
sftp: SFTPWrapper,
remotePath: string,
content: string
): Promise<void> {
const dir = dirnamePosix(remotePath)
await mkdirpRemote(sftp, dir)
try {
const existing = await readFile(sftp, remotePath)
if (existing === content) {
await chmod(sftp, remotePath, 0o755)
return
}
} catch {
// ENOENT or read error — fall through to the atomic write below.
}
// Why: existing configs may already invoke this script. Write/chmod a temp
// file first, then rename it into place so interrupted reinstalls do not
// leave the configured hook path truncated or non-executable.
const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp`
try {
await writeFile(sftp, tmp, content, 0o755)
await chmod(sftp, tmp, 0o755)
await rename(sftp, tmp, remotePath)
} finally {
try {
await unlink(sftp, tmp)
} catch {
// already gone or never created
}
}
}
export async function readTextFileRemote(
sftp: SFTPWrapper,
remotePath: string
): Promise<string | null> {
try {
return await readFile(sftp, remotePath)
} catch (err) {
if (isNoEntryError(err)) {
return null
}
throw err
}
}
export async function writeTextFileRemoteAtomic(
sftp: SFTPWrapper,
remotePath: string,
content: string
): Promise<void> {
const dir = dirnamePosix(remotePath)
await mkdirpRemote(sftp, dir)
try {
const existing = await readFile(sftp, remotePath)
if (existing === content) {
return
}
} catch {
// ENOENT or read error — fall through to the atomic write below.
}
const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp`
try {
const mode = await getRemoteFileModeOrDefault(sftp, remotePath, DEFAULT_REMOTE_CONFIG_MODE)
await writeFile(sftp, tmp, content, mode)
await chmod(sftp, tmp, mode)
await rename(sftp, tmp, remotePath)
} finally {
try {
await unlink(sftp, tmp)
} catch {
// already gone or never created
}
}
}
// ─── Private SFTP primitives ────────────────────────────────────────
async function readFile(sftp: SFTPWrapper, remotePath: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
sftp.readFile(remotePath, 'utf8', (err, data) => {
if (err) {
reject(err)
return
}
resolve(typeof data === 'string' ? data : data.toString('utf8'))
})
})
}
async function writeFile(
sftp: SFTPWrapper,
remotePath: string,
content: string,
mode?: number
): Promise<void> {
return new Promise((resolve, reject) => {
const options =
mode === undefined ? { encoding: 'utf8' as const } : { encoding: 'utf8' as const, mode }
sftp.writeFile(remotePath, content, options, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async function statMode(sftp: SFTPWrapper, remotePath: string): Promise<number> {
return new Promise((resolve, reject) => {
sftp.stat(remotePath, (err, stats) => {
if (err) {
reject(err)
return
}
resolve(stats.mode & 0o7777)
})
})
}
async function getRemoteFileModeOrDefault(
sftp: SFTPWrapper,
remotePath: string,
defaultMode: number
): Promise<number> {
try {
return await statMode(sftp, remotePath)
} catch (err) {
if (isNoEntryError(err)) {
return defaultMode
}
throw err
}
}
async function rename(sftp: SFTPWrapper, src: string, dst: string): Promise<void> {
if (typeof sftp.ext_openssh_rename === 'function') {
try {
await renameOpenSsh(sftp, src, dst)
return
} catch (err) {
if (!isUnsupportedExtensionError(err)) {
throw err
}
}
}
// Why: servers without OpenSSH overwrite-rename cannot safely replace an
// existing live config path. Renaming dst aside would leave settings.json
// missing if the SFTP channel dies before src is moved into place, so fail
// closed and keep the existing file intact.
await renamePlain(sftp, src, dst)
}
async function renamePlain(sftp: SFTPWrapper, src: string, dst: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.rename(src, dst, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async function renameOpenSsh(sftp: SFTPWrapper, src: string, dst: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.ext_openssh_rename(src, dst, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async function unlink(sftp: SFTPWrapper, remotePath: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.unlink(remotePath, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async function chmod(sftp: SFTPWrapper, remotePath: string, mode: number): Promise<void> {
return new Promise((resolve, reject) => {
sftp.chmod(remotePath, mode, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async function readdir(sftp: SFTPWrapper, remotePath: string): Promise<FileEntryWithStats[]> {
return new Promise((resolve, reject) => {
sftp.readdir(remotePath, (err, list) => {
if (err) {
reject(err)
return
}
resolve(list)
})
})
}
async function mkdir(sftp: SFTPWrapper, remotePath: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(remotePath, (err) => {
if (err) {
// SSH_FX_FAILURE (4) often means "already exists" on OpenSSH; we
// probe with stat afterwards rather than parse the error code.
reject(err)
return
}
resolve()
})
})
}
async function mkdirpRemote(sftp: SFTPWrapper, remotePath: string): Promise<void> {
if (remotePath === '/' || remotePath === '' || remotePath === '.') {
return
}
// Why: walk the path top-down rather than bottom-up so an existing parent
// chain doesn't cost a full readdir per segment. POSIX-only — Windows-
// remote is out of scope for v1.
const segments = remotePath.split('/').filter((s) => s.length > 0)
let current = remotePath.startsWith('/') ? '' : '.'
for (const seg of segments) {
current = current === '' ? `/${seg}` : current === '.' ? seg : `${current}/${seg}`
try {
await readdir(sftp, current)
} catch {
try {
await mkdir(sftp, current)
} catch (err) {
// Why: re-raise only when the dir really isn't there. SSH_FX_FAILURE
// on a concurrent mkdir from another client is harmless — readdir on
// the next iteration will succeed.
if (!isAlreadyExistsError(err)) {
throw err
}
}
}
}
}
function dirnamePosix(p: string): string {
const idx = p.lastIndexOf('/')
if (idx <= 0) {
return idx === 0 ? '/' : '.'
}
return p.slice(0, idx)
}
function isNoEntryError(err: unknown): boolean {
if (!err || typeof err !== 'object') {
return false
}
// ssh2 surfaces SFTP errors with `code === 2` (SSH_FX_NO_SUCH_FILE).
return (err as { code?: unknown }).code === 2
}
function isAlreadyExistsError(err: unknown): boolean {
if (!err || typeof err !== 'object') {
return false
}
// SSH_FX_FAILURE (4) is OpenSSH's catch-all for "exists" alongside other
// mkdir failures; we accept the ambiguity and let the next readdir prove
// success.
return (err as { code?: unknown }).code === 4
}
function isUnsupportedExtensionError(err: unknown): boolean {
if (!err || typeof err !== 'object') {
return false
}
const code = (err as { code?: unknown }).code
const message = (err as { message?: unknown }).message
return code === 8 || (typeof message === 'string' && /unsupported/i.test(message))
}
@@ -0,0 +1,47 @@
import type { MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types'
type MigrationUnsupportedPtyEvent =
| { type: 'set'; entry: MigrationUnsupportedPtyEntry }
| { type: 'clear'; ptyId: string }
const entriesByPtyId = new Map<string, MigrationUnsupportedPtyEntry>()
let listener: ((event: MigrationUnsupportedPtyEvent) => void) | null = null
let persistenceListener: ((entries: MigrationUnsupportedPtyEntry[]) => void) | null = null
export function setMigrationUnsupportedPtyListener(
nextListener: ((event: MigrationUnsupportedPtyEvent) => void) | null
): void {
listener = nextListener
}
export function getMigrationUnsupportedPtySnapshot(): MigrationUnsupportedPtyEntry[] {
return [...entriesByPtyId.values()]
}
export function setMigrationUnsupportedPtyPersistenceListener(
nextListener: ((entries: MigrationUnsupportedPtyEntry[]) => void) | null
): void {
persistenceListener = nextListener
}
export function setMigrationUnsupportedPty(entry: MigrationUnsupportedPtyEntry): void {
entriesByPtyId.set(entry.ptyId, entry)
listener?.({ type: 'set', entry })
persistenceListener?.(getMigrationUnsupportedPtySnapshot())
}
export function clearMigrationUnsupportedPty(ptyId: string): void {
if (!entriesByPtyId.delete(ptyId)) {
return
}
listener?.({ type: 'clear', ptyId })
persistenceListener?.(getMigrationUnsupportedPtySnapshot())
}
export function clearMigrationUnsupportedPtysForPaneKey(paneKey: string): void {
for (const [ptyId, entry] of entriesByPtyId) {
if (entry.paneKey === paneKey) {
clearMigrationUnsupportedPty(ptyId)
}
}
}
@@ -0,0 +1,251 @@
import { describe, expect, it, vi } from 'vitest'
import type { SFTPWrapper } from 'ssh2'
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-user-data'
}
}))
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'
type FakeFs = {
files: Map<string, string>
dirs: Set<string>
modes: Map<string, number>
failRenameTo: Set<string>
}
function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } {
const fs: FakeFs = {
files: new Map(),
dirs: new Set(['/']),
modes: new Map(),
failRenameTo: new Set()
}
const noEntryError = (path: string): { code: number; message: string } => ({
code: 2,
message: `ENOENT ${path}`
})
const fakeStats = (mode: number): { mode: number } => ({ mode })
const sftp = {
readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
const v = fs.files.get(path)
if (v === undefined) {
cb(noEntryError(path))
return
}
cb(null, v)
},
writeFile: (
path: string,
content: string,
options: string | { mode?: number },
cb: (err: unknown) => void
): void => {
fs.files.set(path, content)
if (typeof options !== 'string' && options.mode !== undefined) {
fs.modes.set(path, options.mode)
}
cb(null)
},
rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
if (fs.failRenameTo.has(dst)) {
cb({ code: 4, message: `rename failed ${dst}` })
return
}
const v = fs.files.get(src)
if (v === undefined) {
cb(noEntryError(src))
return
}
fs.files.set(dst, v)
fs.files.delete(src)
const mode = fs.modes.get(src)
if (mode !== undefined) {
fs.modes.set(dst, mode)
fs.modes.delete(src)
}
cb(null)
},
unlink: (path: string, cb: (err: unknown) => void): void => {
fs.files.delete(path)
fs.modes.delete(path)
cb(null)
},
chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
fs.modes.set(path, mode)
cb(null)
},
stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
if (!fs.files.has(path)) {
cb(noEntryError(path))
return
}
cb(null, fakeStats(fs.modes.get(path) ?? 0o100644))
},
readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
if (fs.dirs.has(path)) {
cb(null, [])
return
}
cb(noEntryError(path))
},
mkdir: (path: string, cb: (err: unknown) => void): void => {
fs.dirs.add(path)
cb(null)
}
} as unknown as SFTPWrapper
return { sftp, fs }
}
describe('remote hook service installers', () => {
it('always writes POSIX scripts for SSH remotes even from a Windows host', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
const installers = [
{
path: '/home/dev/.orca/agent-hooks/claude-hook.sh',
install: (sftp: SFTPWrapper) => new ClaudeHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.orca/agent-hooks/codex-hook.sh',
install: (sftp: SFTPWrapper) => new CodexHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.orca/agent-hooks/gemini-hook.sh',
install: (sftp: SFTPWrapper) => new GeminiHookService().installRemote(sftp, '/home/dev')
},
{
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')
}
]
for (const { install, path } of installers) {
const { sftp, fs } = createFakeSftp()
const status = await install(sftp)
expect(status.state).toBe('installed')
const script = fs.files.get(path)
expect(script).toMatch(/^#!\/bin\/sh\n/)
expect(script).not.toContain('@echo off')
expect(script).not.toContain('powershell -NoProfile')
}
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('installs remote Codex hooks with matching trust entries', async () => {
const { sftp, fs } = createFakeSftp()
const status = await new CodexHookService().installRemote(sftp, '/home/dev/')
expect(status.state).toBe('installed')
expect(status.configPath).toBe('/home/dev/.codex/hooks.json')
const hooks = JSON.parse(fs.files.get('/home/dev/.codex/hooks.json')!) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
}
for (const eventName of [
'SessionStart',
'UserPromptSubmit',
'PreToolUse',
'PermissionRequest',
'PostToolUse',
'Stop'
]) {
const command = hooks.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toContain('/home/dev/.orca/agent-hooks/codex-hook.sh')
expect(command).toMatch(/^if \[ -x /)
}
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
expect(fs.modes.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toBe(0o755)
const toml = fs.files.get('/home/dev/.codex/config.toml')
expect(toml).toContain('/home/dev/.codex/hooks.json:permission_request:0:0')
expect(toml).toContain('trusted_hash = "sha256:')
})
it('reports Codex trust-write failures without rolling back installed hooks', async () => {
const { sftp, fs } = createFakeSftp()
fs.failRenameTo.add('/home/dev/.codex/config.toml')
const status = await new CodexHookService().installRemote(sftp, '/home/dev')
expect(status.state).toBe('error')
expect(status.managedHooksPresent).toBe(true)
expect(status.detail).toContain('trust entries could not be written')
expect(fs.files.get('/home/dev/.codex/hooks.json')).toContain('codex-hook.sh')
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
})
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 }[] }[]>
}
for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse']) {
const command = geminiConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toContain('/home/dev/.orca/agent-hooks/gemini-hook.sh')
expect(command).toMatch(/^if \[ -x /)
}
const cursorConfig = JSON.parse(cursor.fs.files.get('/home/dev/.cursor/hooks.json')!) as {
version: number
hooks: Record<string, { command?: string; hooks?: unknown[] }[]>
}
expect(cursorConfig.version).toBe(1)
for (const eventName of [
'beforeSubmitPrompt',
'stop',
'preToolUse',
'postToolUse',
'postToolUseFailure',
'beforeShellExecution',
'beforeMCPExecution',
'afterAgentResponse'
]) {
const definition = cursorConfig.hooks[eventName]?.[0]
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('*')
})
})
+213 -19
View File
@@ -18,6 +18,7 @@ import {
AGENT_STATUS_MAX_FIELD_LENGTH,
parseAgentStatusPayload
} from '../../shared/agent-status-types'
import { makePaneKey } from '../../shared/stable-pane-id'
const { trackMock } = vi.hoisted(() => ({
trackMock: vi.fn()
@@ -27,7 +28,16 @@ vi.mock('../telemetry/client', () => ({
track: trackMock
}))
const PANE = 'tab-1:0'
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
const LEAF_2 = '22222222-2222-4222-8222-222222222222'
const LEAF_3 = '33333333-3333-4333-8333-333333333333'
const LEAF_4 = '44444444-4444-4444-8444-444444444444'
const LEAF_5 = '55555555-5555-4555-8555-555555555555'
const PANE = makePaneKey('tab-1', LEAF_1)
const GOOD_PANE = makePaneKey('tab-good', LEAF_2)
const OLD_PANE = makePaneKey('tab-old', LEAF_3)
const FRESH_PANE = makePaneKey('tab-fresh', LEAF_4)
const TAB_A_PANE = makePaneKey('tab-A', LEAF_5)
type Body = {
paneKey: string
@@ -59,6 +69,151 @@ afterEach(() => {
})
describe('AgentHookServer listener replay', () => {
it('allows multiple status-change subscribers to observe the same update', () => {
const server = new AgentHookServer()
const first = vi.fn()
const second = vi.fn()
server.subscribeStatusChanges(first)
server.subscribeStatusChanges(second)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
expect(first).toHaveBeenCalledWith([
expect.objectContaining({
state: 'working',
receivedAt: expect.any(Number),
observedInCurrentRuntime: true
})
])
expect(second).toHaveBeenCalledWith([
expect.objectContaining({
state: 'working',
receivedAt: expect.any(Number),
observedInCurrentRuntime: true
})
])
})
it('keeps status-change subscribers when renderer fanout listener is cleared', () => {
const server = new AgentHookServer()
const statusChangeListener = vi.fn()
const rendererListener = vi.fn()
server.subscribeStatusChanges(statusChangeListener)
server.setListener(rendererListener)
server.setListener(null)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
expect(statusChangeListener).toHaveBeenCalledTimes(1)
expect(rendererListener).not.toHaveBeenCalled()
})
it('unsubscribes status-change listeners without removing the remaining listeners', () => {
const server = new AgentHookServer()
const removed = vi.fn()
const remaining = vi.fn()
const unsubscribe = server.subscribeStatusChanges(removed)
server.subscribeStatusChanges(remaining)
unsubscribe()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
expect(removed).not.toHaveBeenCalled()
expect(remaining).toHaveBeenCalledWith([
expect.objectContaining({
state: 'working',
observedInCurrentRuntime: true
})
])
})
it('notifies status-change subscribers when a working status is dropped or cleared', () => {
const server = new AgentHookServer()
const listener = vi.fn()
server.subscribeStatusChanges(listener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
server.dropStatusEntry(PANE)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
server.clearPaneState(PANE)
expect(listener).toHaveBeenNthCalledWith(2, [])
expect(listener).toHaveBeenNthCalledWith(4, [])
})
it('hydrates cached statuses as not observed in the current runtime', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-'))
const firstServer = new AgentHookServer()
const secondServer = new AgentHookServer()
try {
await firstServer.start({ env: 'production', userDataPath: dir })
firstServer.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
firstServer.flushStatusPersistSync()
firstServer.stop()
await secondServer.start({ env: 'production', userDataPath: dir })
expect(secondServer.getStatusChangeSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
observedInCurrentRuntime: false
})
])
} finally {
firstServer.stop()
secondServer.stop()
rmSync(dir, { recursive: true, force: true })
}
})
it('replays the latest retained pane status when a listener attaches after windowless events', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
@@ -213,14 +368,15 @@ describe('AgentHookServer listener replay', () => {
)
const warnsAfterFirst = warn.mock.calls.length
const secondPane = makePaneKey('tab-2', LEAF_2)
server.ingestRemote(
{
paneKey: 'tab-2:0',
paneKey: secondPane,
env: 'development',
version: '999',
payload: {
state: 'working',
paneKey: 'tab-2:0',
paneKey: secondPane,
updatedAt: Date.now(),
agentType: 'claude'
}
@@ -246,9 +402,10 @@ describe('AgentHookServer listener replay', () => {
server.setListener(listener)
const oversizedPrompt = 'x'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH + 50)
const remotePane = makePaneKey('tab-3', LEAF_3)
server.ingestRemote(
{
paneKey: ' tab-3:0 ',
paneKey: ` ${remotePane} `,
tabId: ' tab-3 ',
worktreeId: ' wt-3 ',
env: 'remote',
@@ -265,7 +422,7 @@ describe('AgentHookServer listener replay', () => {
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
paneKey: 'tab-3:0',
paneKey: remotePane,
tabId: 'tab-3',
worktreeId: 'wt-3',
connectionId: 'conn-9',
@@ -1617,9 +1774,10 @@ describe('Endpoint file lifecycle', () => {
})
})
try {
const remotePane = makePaneKey('tab-3', LEAF_3)
server.ingestRemote(
{
paneKey: 'tab-3:0',
paneKey: remotePane,
tabId: 'tab-3',
worktreeId: 'wt-3',
payload: {
@@ -1631,7 +1789,7 @@ describe('Endpoint file lifecycle', () => {
'conn-42'
)
expect(events).toHaveLength(1)
expect(events[0].paneKey).toBe('tab-3:0')
expect(events[0].paneKey).toBe(remotePane)
expect(events[0].connectionId).toBe('conn-42')
expect(events[0].payload).toMatchObject({
state: 'working',
@@ -1891,14 +2049,14 @@ describe('Last-status persistence', () => {
},
// Embedded paneKey mismatch — drop.
[PANE]: {
paneKey: 'tab-x:99',
paneKey: makePaneKey('tab-x', LEAF_2),
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000,
payload: { state: 'done', prompt: 'mismatch', agentType: 'claude' }
},
// Valid.
'tab-good:0': {
paneKey: 'tab-good:0',
[GOOD_PANE]: {
paneKey: GOOD_PANE,
tabId: 'tab-good',
receivedAt: recentTs(),
stateStartedAt: recentTs(-1000),
@@ -1919,7 +2077,7 @@ describe('Last-status persistence', () => {
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
paneKey: 'tab-good:0',
paneKey: GOOD_PANE,
payload: expect.objectContaining({ prompt: 'survived' })
})
)
@@ -1937,16 +2095,16 @@ describe('Last-status persistence', () => {
version: 2,
entries: {
// Stale — should be dropped.
'tab-old:0': {
paneKey: 'tab-old:0',
[OLD_PANE]: {
paneKey: OLD_PANE,
tabId: 'tab-old',
receivedAt: eightDaysAgoMs,
stateStartedAt: eightDaysAgoMs - 1000,
payload: { state: 'done', prompt: 'old', agentType: 'claude' }
},
// Recent — should survive.
'tab-fresh:0': {
paneKey: 'tab-fresh:0',
[FRESH_PANE]: {
paneKey: FRESH_PANE,
tabId: 'tab-fresh',
receivedAt: recentTs(),
stateStartedAt: recentTs(-1000),
@@ -1963,7 +2121,7 @@ describe('Last-status persistence', () => {
})
try {
const snapshot = server.getStatusSnapshot()
expect(snapshot.map((e) => e.paneKey)).toEqual(['tab-fresh:0'])
expect(snapshot.map((e) => e.paneKey)).toEqual([FRESH_PANE])
} finally {
server.stop()
}
@@ -1976,8 +2134,8 @@ describe('Last-status persistence', () => {
JSON.stringify({
version: 2,
entries: {
'tab-A:0': {
paneKey: 'tab-A:0',
[TAB_A_PANE]: {
paneKey: TAB_A_PANE,
// Why: deliberately divergent — paneKey says tab-A, the entry
// claims tab-B. Sanitizer must drop rather than hydrate this
// inconsistent row.
@@ -2043,7 +2201,7 @@ describe('Last-status persistence', () => {
// Why: a no-op clearPaneState on a paneKey not in the cache is a
// mutation site that should NOT trigger a redundant write. (clear was
// designed to bail when nothing was evicted.)
server.clearPaneState('non-existent:0')
server.clearPaneState(makePaneKey('non-existent', LEAF_5))
server.flushStatusPersistSync()
// Touch back to the same mtime would let the test pass spuriously, so
// assert no rewrite happened by checking that mtime is unchanged after
@@ -2142,6 +2300,42 @@ describe('AgentHookServer ingestRemote', () => {
expect(listener).not.toHaveBeenCalled()
})
it('drops remote relay envelopes with legacy numeric paneKeys before cache mutation', () => {
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.setListener(listener)
server.ingestRemote(
{ paneKey: 'tab-1:0', tabId: 'tab-1', worktreeId: 'wt-1', payload },
'conn-1'
)
expect(listener).not.toHaveBeenCalled()
expect(server.getStatusSnapshot()).toEqual([])
})
it('drops remote relay envelopes whose tabId disagrees with the paneKey tab', () => {
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.setListener(listener)
server.ingestRemote(
{ paneKey: PANE, tabId: 'tab-other', worktreeId: 'wt-1', payload },
'conn-1'
)
expect(listener).not.toHaveBeenCalled()
expect(server.getStatusSnapshot()).toEqual([])
})
it('rejects empty connectionId', () => {
const server = new AgentHookServer()
const payload = parseAgentStatusPayload(
+80 -27
View File
@@ -36,8 +36,10 @@ import {
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import {
type AgentStatusIpcPayload,
type AgentStatusState,
normalizeAgentStatusPayload
} from '../../shared/agent-status-types'
import { parsePaneKey } from '../../shared/stable-pane-id'
export type { AgentHookSource }
@@ -54,6 +56,14 @@ type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
stateStartedAt: number
}
export type AgentHookStatusChangeEntry = {
state: AgentStatusState
receivedAt: number
observedInCurrentRuntime: boolean
}
type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void
// 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.
@@ -86,26 +96,18 @@ type LastStatusFile = {
entries: Record<string, EnrichedAgentHookEventPayload>
}
// Why: paneKey is `${tabId}:${paneId}` — exactly one ':' with non-empty
// segments on either side. Used both at write time (defensive) and at
// hydrate time (drop on mismatch).
// Why: paneKey is `${tabId}:${leafUuid}` — validate the durable leaf suffix
// at write/hydrate time so legacy numeric rows fail closed.
export function isValidPaneKey(value: unknown): value is string {
if (typeof value !== 'string' || value.length === 0) {
return false
}
const colon = value.indexOf(':')
if (colon <= 0 || colon === value.length - 1) {
return false
}
// Why: exactly one colon. Anything weirder is corruption.
return !value.includes(':', colon + 1)
return typeof value === 'string' && parsePaneKey(value) !== null
}
function sanitizeHydratedEntry(
paneKey: string,
rawEntry: unknown
): EnrichedAgentHookEventPayload | null {
if (!isValidPaneKey(paneKey)) {
const parsedPaneKey = parsePaneKey(paneKey)
if (!parsedPaneKey) {
return null
}
if (typeof rawEntry !== 'object' || rawEntry === null) {
@@ -119,10 +121,10 @@ function sanitizeHydratedEntry(
if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) {
return null
}
// Why: paneKey is `${tabId}:${paneId}`; a stored entry whose tabId field
// Why: paneKey is `${tabId}:${leafUuid}`; a stored entry whose tabId field
// diverges from the key's tab segment is corruption (renamer bug, manual
// edit, future shape drift). Drop instead of hydrating an inconsistent row.
if (typeof tabId === 'string' && tabId !== paneKey.slice(0, paneKey.indexOf(':'))) {
if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) {
return null
}
const worktreeId = record.worktreeId
@@ -199,6 +201,7 @@ export class AgentHookServer {
// caller's knowledge of whether this is a packaged build.
private env = 'production'
private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null
private statusChangeListeners = new Set<StatusChangeListener>()
// Why: directory that holds the on-disk endpoint file. Set via start()'s
// `userDataPath` option so the class has no direct Electron dependency
// (keeps it mockable in the vitest node environment).
@@ -209,6 +212,9 @@ export class AgentHookServer {
// by paneKey). Held on the instance instead of as module-level Maps so
// tests can spin up multiple servers without state cross-contamination.
private state: HookListenerState = createHookListenerState()
// 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>()
// 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
@@ -243,6 +249,13 @@ export class AgentHookServer {
}
}
subscribeStatusChanges(listener: StatusChangeListener): () => void {
this.statusChangeListeners.add(listener)
return () => {
this.statusChangeListeners.delete(listener)
}
}
/** Snapshot of the current cached statuses, in the IPC-shaped form the
* renderer consumes. Used by the `agentStatus:getSnapshot` IPC after
* workspace tabs have hydrated, so the dashboard catches up on any
@@ -253,6 +266,31 @@ export class AgentHookServer {
)
}
getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] {
return Array.from(this.state.lastStatusByPaneKey.entries(), ([paneKey, entry]) => {
const enriched = entry as EnrichedAgentHookEventPayload
return {
state: enriched.payload.state,
receivedAt: enriched.receivedAt,
observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey)
}
})
}
private notifyStatusChangeListeners(): void {
if (this.statusChangeListeners.size === 0) {
return
}
const snapshot = this.getStatusChangeSnapshot()
for (const listener of this.statusChangeListeners) {
try {
listener(snapshot)
} catch (err) {
console.error('[agent-hooks] status-change listener threw', err)
}
}
}
private attachStatusTiming(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload {
const now = Date.now()
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
@@ -304,6 +342,7 @@ export class AgentHookServer {
// 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 parsedPaneKey = parsePaneKey(paneKey)
if (paneKey.length === 0) {
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
return
@@ -311,6 +350,9 @@ export class AgentHookServer {
if (paneKey.length > MAX_PANE_KEY_LEN) {
return
}
if (!parsedPaneKey) {
return
}
if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') {
return
}
@@ -324,6 +366,9 @@ export class AgentHookServer {
envelope.tabId !== undefined && envelope.tabId.trim().length > 0
? envelope.tabId.trim()
: undefined
if (tabId !== undefined && tabId !== parsedPaneKey.tabId) {
return
}
const worktreeId =
envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0
? envelope.worktreeId.trim()
@@ -354,8 +399,10 @@ export class AgentHookServer {
payload: normalizedPayload
}
const enriched = this.attachStatusTiming(event)
this.runtimeObservedStatusPaneKeys.add(paneKey)
this.state.lastStatusByPaneKey.set(paneKey, enriched)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.onAgentStatus?.(enriched)
}
@@ -416,8 +463,10 @@ export class AgentHookServer {
const normalized = normalizeHookPayload(this.state, source, body, 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)
}
@@ -477,7 +526,9 @@ export class AgentHookServer {
this.endpointFileWritten = false
this.lastStatusFilePath = null
this.lastWrittenJson = null
this.runtimeObservedStatusPaneKeys.clear()
clearAllListenerCaches(this.state)
this.notifyStatusChangeListeners()
}
/** Why: invoked from the renderer-driven agentStatus:drop IPC when a user
@@ -492,7 +543,9 @@ export class AgentHookServer {
return
}
this.state.lastStatusByPaneKey.delete(paneKey)
this.runtimeObservedStatusPaneKeys.delete(paneKey)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
clearPaneState(paneKey: string): void {
@@ -503,7 +556,9 @@ export class AgentHookServer {
const hadStatus = this.state.lastStatusByPaneKey.has(paneKey)
clearPaneCacheState(this.state, paneKey)
if (hadStatus) {
this.runtimeObservedStatusPaneKeys.delete(paneKey)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
}
@@ -709,6 +764,14 @@ export class AgentHookServer {
}
}
}
/** Test-only accessor for the per-instance listener state. The `_internals`
* shim needs to reach this without exposing `state` on the public surface
* to renderer/main callers. AGENTS.md disallows `as unknown as X` escapes,
* so we expose a narrow getter rather than casting the private field. */
_getStateForTests(): HookListenerState {
return this.state
}
}
export const agentHookServer = new AgentHookServer()
@@ -722,19 +785,9 @@ export const _internals = {
body: unknown,
expectedEnv: string
): AgentHookEventPayload | null =>
normalizeHookPayload(_singletonState(), source, body, expectedEnv),
normalizeHookPayload(agentHookServer._getStateForTests(), source, body, expectedEnv),
parseFormEncodedBody,
resetCachesForTests: (): void => {
clearAllListenerCaches(_singletonState())
clearAllListenerCaches(agentHookServer._getStateForTests())
}
}
// Why: ergonomic accessor so the `_internals` shim can reach the singleton's
// per-instance state without exposing `state` on the public class surface.
function _singletonState(): HookListenerState {
// The runtime field is private, but tests access this module exclusively
// through `_internals`, which only fires after the module-level
// `agentHookServer` is constructed. The cast keeps the compile-time
// private invariant intact.
return (agentHookServer as unknown as { state: HookListenerState }).state
}
@@ -31,30 +31,36 @@ describe('applyTerminalAttributionEnv', () => {
return tmpRoot
}
function stripInheritedAttributionPath(pathValue: string): string {
const pathDelimiter = process.platform === 'win32' ? ';' : ':'
return pathValue
.split(pathDelimiter)
.filter((entry) => !entry.includes('orca-terminal-attribution'))
.join(pathDelimiter)
}
function cleanAttributionEnv(env?: Record<string, string>): Record<string, string> {
const base = { ...process.env }
delete base.ORCA_ENABLE_GIT_ATTRIBUTION
delete base.ORCA_GIT_COMMIT_TRAILER
delete base.ORCA_GH_PR_FOOTER
delete base.ORCA_GH_ISSUE_FOOTER
delete base.ORCA_ATTRIBUTION_SHIM_DIR
delete base.ORCA_REAL_GIT
delete base.ORCA_REAL_GH
base.PATH = stripInheritedAttributionPath(base.PATH ?? '')
const next = { ...base, ...env }
return next as Record<string, string>
}
function runGit(repo: string, args: string[], env?: Record<string, string>): string {
return execFileSync('git', args, {
cwd: repo,
encoding: 'utf8',
env: { ...getCleanProcessEnv(), ...env }
env: cleanAttributionEnv(env)
})
}
function getCleanProcessEnv(): NodeJS.ProcessEnv {
const env = { ...process.env }
delete env.ORCA_ENABLE_GIT_ATTRIBUTION
delete env.ORCA_GIT_COMMIT_TRAILER
delete env.ORCA_GH_PR_FOOTER
delete env.ORCA_GH_ISSUE_FOOTER
delete env.ORCA_ATTRIBUTION_SHIM_DIR
delete env.ORCA_REAL_GIT
delete env.ORCA_REAL_GH
env.PATH = (env.PATH ?? '')
.split(process.platform === 'win32' ? ';' : ':')
.filter((entry) => !entry.replace(/\\/g, '/').includes('/orca-terminal-attribution/'))
.join(process.platform === 'win32' ? ';' : ':')
return env
}
it('does not amend HEAD when git commit --dry-run exits successfully', () => {
const root = makeTmpRoot()
const repo = join(root, 'repo')
@@ -66,7 +72,7 @@ describe('applyTerminalAttributionEnv', () => {
runGit(repo, ['add', 'README.md'])
runGit(repo, ['commit', '-m', 'initial'])
const attributionEnv = { PATH: process.env.PATH ?? '' }
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -99,7 +105,7 @@ describe('applyTerminalAttributionEnv', () => {
writeFileSync(join(repo, 'README.md'), 'initial\n')
runGit(repo, ['add', 'README.md'])
const attributionEnv = { PATH: process.env.PATH ?? '' }
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -124,7 +130,7 @@ describe('applyTerminalAttributionEnv', () => {
runGit(repo, ['commit', '-m', 'initial'])
writeFileSync(join(repo, 'README.md'), 'changed\n')
const attributionEnv = { PATH: process.env.PATH ?? '' }
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -147,7 +153,7 @@ describe('applyTerminalAttributionEnv', () => {
writeFileSync(join(repo, 'README.md'), 'initial\n')
runGit(repo, ['add', 'README.md'])
const attributionEnv = { PATH: process.env.PATH ?? '' }
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -172,7 +178,7 @@ describe('applyTerminalAttributionEnv', () => {
writeFileSync(messagePath, 'initial from file\n')
runGit(repo, ['add', 'README.md'])
const attributionEnv = { PATH: process.env.PATH ?? '' }
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -205,7 +211,9 @@ exit 1
)
chmodSync(join(binDir, 'git'), 0o755)
const attributionEnv = { PATH: `${binDir}:${process.env.PATH ?? ''}` }
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -214,7 +222,7 @@ exit 1
expect(() =>
execFileSync('git', ['commit', '-F', join(root, 'missing-message.txt')], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
).toThrow()
@@ -242,7 +250,9 @@ exit 1
)
chmodSync(join(binDir, 'git'), 0o755)
const attributionEnv = { PATH: `${binDir}:${process.env.PATH ?? ''}` }
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -250,15 +260,15 @@ exit 1
execFileSync('git', ['commit', '-C', 'HEAD'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
execFileSync('git', ['commit', '--fixup', 'HEAD'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
execFileSync('git', ['commit', '-F', messagePath, '--fixup', 'HEAD'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
expect(readFileSync(argsPath, 'utf8')).not.toContain('Co-authored-by: Orca')
@@ -290,7 +300,7 @@ grep -Fq 'Co-authored-by: Orca <help@stably.ai>' "$1"
writeFileSync(join(repo, 'README.md'), 'initial\n')
runGit(repo, ['add', 'README.md'])
const attributionEnv = { PATH: process.env.PATH ?? '' }
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -334,7 +344,9 @@ exit 1
)
chmodSync(join(binDir, 'git'), 0o755)
const attributionEnv = { PATH: `${binDir}:${process.env.PATH ?? ''}` }
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -342,7 +354,7 @@ exit 1
execFileSync('git', ['commit', '-m', 'signed commit'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
expect(existsSync(commitPath)).toBe(true)
@@ -369,7 +381,9 @@ exit 1
)
chmodSync(join(binDir, 'git'), 0o755)
const attributionEnv = { PATH: `${binDir}:${process.env.PATH ?? ''}` }
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -377,7 +391,7 @@ exit 1
execFileSync('git', ['commit'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
expect(readFileSync(argsPath, 'utf8')).toBe('commit\n')
@@ -413,7 +427,9 @@ exit 1
'utf8'
)
chmodSync(join(binDir, 'gh'), 0o755)
const attributionEnv = { PATH: `${binDir}:${process.env.PATH ?? ''}` }
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -421,7 +437,7 @@ exit 1
const output = execFileSync('gh', ['pr', 'create'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
expect(output).toBe('interactive create complete\n')
@@ -469,7 +485,9 @@ exit 1
'utf8'
)
chmodSync(join(binDir, 'gh'), 0o755)
const attributionEnv = { PATH: `${binDir}:${process.env.PATH ?? ''}` }
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -478,13 +496,13 @@ exit 1
expect(
execFileSync('gh', ['pr', 'create', '--fill'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
).toBe('https://github.com/stablyai/orca/pull/123\n')
expect(
execFileSync('gh', ['issue', 'create', '--title', 'Issue', '--body', 'Body'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
).toBe('https://github.com/stablyai/orca/issues/456\n')
@@ -532,7 +550,9 @@ exit 1
'utf8'
)
chmodSync(join(binDir, 'gh'), 0o755)
const attributionEnv = { PATH: `${binDir}:${process.env.PATH ?? ''}` }
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -540,13 +560,13 @@ exit 1
const output = execFileSync('gh', ['pr', 'create', '--help'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
expect(output).toBe('pr help\n')
const issueOutput = execFileSync('gh', ['issue', 'create', '--help'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
expect(issueOutput).toBe('issue help\n')
@@ -579,7 +599,9 @@ exit 1
'utf8'
)
chmodSync(join(binDir, 'gh'), 0o755)
const attributionEnv = { PATH: `${binDir}:${process.env.PATH ?? ''}` }
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -587,7 +609,7 @@ exit 1
const output = execFileSync('gh', ['issue', 'create'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
expect(output).toBe('interactive issue create complete\n')
@@ -619,7 +641,9 @@ exit 1
'utf8'
)
chmodSync(join(binDir, 'gh'), 0o755)
const attributionEnv = { PATH: `${binDir}:${process.env.PATH ?? ''}` }
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -627,7 +651,7 @@ exit 1
const output = execFileSync('gh', ['pr', 'create', '--fill'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
expect(output).toBe('https://github.com/stablyai/orca/pull/123\n')
@@ -658,7 +682,9 @@ exit 1
'utf8'
)
chmodSync(join(binDir, 'gh'), 0o755)
const attributionEnv = { PATH: `${binDir}:${process.env.PATH ?? ''}` }
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
@@ -666,7 +692,7 @@ exit 1
const output = execFileSync('gh', ['pr', 'create', '--fill'], {
encoding: 'utf8',
env: { ...process.env, ...attributionEnv }
env: cleanAttributionEnv(attributionEnv)
})
expect(output).toBe('https://github.com/stablyai/orca/pull/123\n')
@@ -689,7 +715,9 @@ exit 1
it('does not duplicate shim directories when applied to an already-injected env', () => {
const root = makeTmpRoot()
const baseEnv: Record<string, string> = { PATH: process.env.PATH ?? '' }
const baseEnv: Record<string, string> = {
PATH: stripInheritedAttributionPath(process.env.PATH ?? '')
}
const options = { enabled: true, userDataPath: join(root, 'user-data') }
const pathDelimiter = process.platform === 'win32' ? ';' : ':'
@@ -705,7 +733,7 @@ exit 1
it('writes PowerShell wrappers without raw-template backslash escapes', () => {
const root = makeTmpRoot()
applyTerminalAttributionEnv(
{ PATH: process.env.PATH ?? '' },
{ PATH: stripInheritedAttributionPath(process.env.PATH ?? '') },
{ enabled: true, userDataPath: join(root, 'user-data') }
)
+1 -1
View File
@@ -4,11 +4,11 @@ to the env injection code makes the attribution behavior auditable as one unit
instead of scattering generated shell fragments across files. */
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { ORCA_GIT_COMMIT_TRAILER } from '../../shared/orca-attribution'
const ATTRIBUTION_ROOT_DIR = 'orca-terminal-attribution'
const ATTRIBUTION_SHIM_VERSION = '6'
const ORCA_PRODUCT_URL = 'https://github.com/stablyai/orca'
const ORCA_GIT_COMMIT_TRAILER = 'Co-authored-by: Orca <help@stably.ai>'
const ORCA_GH_FOOTER = `Made with [Orca](${ORCA_PRODUCT_URL}) 🐋`
const SHELL_DOLLAR = '$'
const POWERSHELL_TICK = '`'
@@ -0,0 +1,164 @@
import type { ExternalAutomationJob } from '../../shared/automations-types'
type ExternalJobRecord = Record<string, unknown>
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value : null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function formatDurationMs(value: unknown): string {
const ms = asNumber(value)
if (!ms) {
return '?'
}
if (ms % 86_400_000 === 0) {
return `${ms / 86_400_000}d`
}
if (ms % 3_600_000 === 0) {
return `${ms / 3_600_000}h`
}
if (ms % 60_000 === 0) {
return `${ms / 60_000}m`
}
if (ms % 1000 === 0) {
return `${ms / 1000}s`
}
return `${ms}ms`
}
function isoFromMs(value: unknown): string | null {
const ms = asNumber(value)
if (!ms) {
return null
}
const date = new Date(ms)
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
function hermesScheduleDisplay(job: ExternalJobRecord): string {
const direct = asString(job.schedule_display)
if (direct) {
return direct
}
const schedule = job.schedule
if (isRecord(schedule)) {
return (
asString(schedule.display) ??
asString(schedule.value) ??
asString(schedule.expr) ??
asString(schedule.run_at) ??
'?'
)
}
return asString(schedule) ?? '?'
}
function hermesPromptPreview(job: ExternalJobRecord): string {
const prompt = asString(job.prompt) ?? ''
if (prompt) {
return prompt.length > 120 ? `${prompt.slice(0, 117)}...` : prompt
}
const script = asString(job.script)
if (script) {
return job.no_agent ? `Script: ${script}` : `Prompt uses script context: ${script}`
}
const skills = Array.isArray(job.skills) ? job.skills.map((skill) => String(skill)) : []
return skills.length > 0 ? `Skills: ${skills.join(', ')}` : ''
}
function openClawScheduleDisplay(job: ExternalJobRecord): string {
const schedule = job.schedule
if (!isRecord(schedule)) {
return asString(schedule) ?? '?'
}
const kind = asString(schedule.kind)
if (kind === 'at') {
return `at ${asString(schedule.at) ?? '?'}`
}
if (kind === 'every') {
return `every ${formatDurationMs(schedule.everyMs)}`
}
if (kind === 'cron') {
const expr = asString(schedule.expr) ?? asString(schedule.cron) ?? '?'
const tz = asString(schedule.tz)
return tz ? `cron ${expr} @ ${tz}` : `cron ${expr}`
}
return kind ?? '?'
}
function openClawPromptPreview(job: ExternalJobRecord): string {
const payload = job.payload
if (!isRecord(payload)) {
return ''
}
const text = asString(payload.message) ?? asString(payload.text) ?? ''
return text.length > 120 ? `${text.slice(0, 117)}...` : text
}
export function mapHermesJobs(managerId: string, rawJobs: unknown): ExternalAutomationJob[] {
if (!Array.isArray(rawJobs)) {
return []
}
return rawJobs.filter(isRecord).map((job) => {
const id = asString(job.id) ?? 'unknown'
const enabled = job.enabled !== false && job.state !== 'paused'
const preview = hermesPromptPreview(job)
return {
id,
managerId,
provider: 'hermes',
name: (asString(job.name) ?? preview) || id,
schedule: hermesScheduleDisplay(job),
enabled,
state: asString(job.state) ?? (enabled ? 'scheduled' : 'paused'),
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)
}
})
}
export function mapOpenClawJobs(managerId: string, rawJobs: unknown): ExternalAutomationJob[] {
const jobs = Array.isArray(rawJobs)
? rawJobs
: isRecord(rawJobs) && Array.isArray(rawJobs.jobs)
? rawJobs.jobs
: []
return jobs.filter(isRecord).map((job) => {
const id = asString(job.id) ?? 'unknown'
const enabled = job.enabled !== false
const state = isRecord(job.state) ? job.state : {}
const preview = openClawPromptPreview(job)
const lastStatus = asString(state.lastRunStatus) ?? asString(state.lastStatus)
return {
id,
managerId,
provider: 'openclaw',
name: (asString(job.name) ?? preview) || id,
schedule: openClawScheduleDisplay(job),
enabled,
state: !enabled
? 'disabled'
: asNumber(state.runningAtMs)
? 'running'
: (lastStatus ?? 'idle'),
promptPreview: preview,
nextRunAt: isoFromMs(state.nextRunAtMs),
lastRunAt: isoFromMs(state.lastRunAtMs),
lastStatus,
lastError: asString(state.lastError) ?? asString(state.lastDeliveryError),
workdir: null
}
})
}
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { runExternalAutomationAction } from './external-manager'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
const execFileMock = vi.hoisted(() =>
vi.fn((...args: unknown[]) => {
const callback = args.at(-1)
if (typeof callback === 'function') {
const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void
execCallback(null, '', '')
}
})
)
vi.mock('child_process', () => ({ execFile: execFileMock }))
vi.mock('../ipc/ssh', () => ({
getActiveMultiplexer: vi.fn()
}))
beforeEach(() => {
execFileMock.mockClear()
})
describe('mapHermesJobs', () => {
it('normalizes Hermes cron jobs into external automation rows', () => {
const jobs = mapHermesJobs('hermes:local', [
{
id: 'job-1',
name: 'Nightly audit',
prompt: 'Audit the repo for risky dependency changes',
schedule_display: '0 9 * * 1-5',
enabled: true,
state: 'scheduled',
next_run_at: '2026-05-16T09:00:00Z',
last_run_at: '2026-05-15T09:00:00Z',
last_status: 'ok',
workdir: '/repo'
}
])
expect(jobs).toEqual([
{
id: 'job-1',
managerId: 'hermes:local',
provider: 'hermes',
name: 'Nightly audit',
schedule: '0 9 * * 1-5',
enabled: true,
state: 'scheduled',
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'
}
])
})
it('falls back to script and schedule fields for older Hermes records', () => {
const jobs = mapHermesJobs('hermes:local', [
{
id: 'job-2',
script: 'disk-check.sh',
no_agent: true,
schedule: { display: 'every 30m' },
enabled: false,
state: 'paused',
last_delivery_error: 'home channel missing'
}
])
expect(jobs[0]).toMatchObject({
id: 'job-2',
name: 'Script: disk-check.sh',
schedule: 'every 30m',
enabled: false,
state: 'paused',
promptPreview: 'Script: disk-check.sh',
lastError: 'home channel missing'
})
})
})
describe('runExternalAutomationAction', () => {
it('runs local Hermes lifecycle actions through the CLI', async () => {
await runExternalAutomationAction({
managerId: 'hermes:local',
provider: 'hermes',
target: { type: 'local' },
jobId: 'job-1',
action: 'run'
})
expect(execFileMock).toHaveBeenCalledWith(
'hermes',
['cron', 'run', 'job-1'],
{ encoding: 'utf-8' },
expect.any(Function)
)
})
it('rejects job IDs that could be parsed as CLI options', async () => {
await expect(
runExternalAutomationAction({
managerId: 'hermes:local',
provider: 'hermes',
target: { type: 'local' },
jobId: '-help',
action: 'run'
})
).rejects.toThrow('Invalid external automation job ID.')
expect(execFileMock).not.toHaveBeenCalled()
})
it('maps OpenClaw lifecycle actions through its cron CLI names', async () => {
await runExternalAutomationAction({
managerId: 'openclaw:local',
provider: 'openclaw',
target: { type: 'local' },
jobId: 'job-1',
action: 'pause'
})
expect(execFileMock).toHaveBeenCalledWith(
'openclaw',
['cron', 'disable', 'job-1'],
{ encoding: 'utf-8' },
expect.any(Function)
)
})
})
describe('mapOpenClawJobs', () => {
it('normalizes OpenClaw cron jobs into external automation rows', () => {
const jobs = mapOpenClawJobs('openclaw:local', {
version: 1,
jobs: [
{
id: 'claw-1',
name: 'Morning report',
enabled: true,
schedule: { kind: 'cron', expr: '0 9 * * *', tz: 'America/Phoenix' },
payload: { kind: 'agentTurn', message: 'Summarize overnight alerts' },
state: {
nextRunAtMs: Date.parse('2026-05-16T16:00:00Z'),
lastRunAtMs: Date.parse('2026-05-15T16:00:00Z'),
lastRunStatus: 'ok'
}
}
]
})
expect(jobs[0]).toMatchObject({
id: 'claw-1',
managerId: 'openclaw:local',
provider: 'openclaw',
name: 'Morning report',
schedule: 'cron 0 9 * * * @ America/Phoenix',
enabled: true,
state: 'ok',
promptPreview: 'Summarize overnight alerts',
nextRunAt: '2026-05-16T16:00:00.000Z',
lastRunAt: '2026-05-15T16:00:00.000Z',
lastStatus: 'ok'
})
})
})
+250
View File
@@ -0,0 +1,250 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { promisify } from 'util'
import type {
ExternalAutomationAction,
ExternalAutomationActionInput,
ExternalAutomationManager,
ExternalAutomationProvider
} 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'
const execFileAsync = promisify(execFile)
const HERMES_JOBS_FILE = join(homedir(), '.hermes', 'cron', '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._:-]*$/
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
async function isCommandOnPath(command: string): Promise<boolean> {
const finder = process.platform === 'win32' ? 'where' : 'which'
try {
await execFileAsync(finder, [command], { encoding: 'utf-8' })
return true
} catch {
return false
}
}
async function readLocalHermesJobs(): Promise<unknown[]> {
if (!existsSync(HERMES_JOBS_FILE)) {
return []
}
const content = await readFile(HERMES_JOBS_FILE, 'utf-8')
const parsed = JSON.parse(content) as unknown
return Array.isArray(parsed) ? parsed : []
}
async function readLocalOpenClawJobs(): Promise<unknown[]> {
if (!existsSync(OPENCLAW_JOBS_FILE)) {
return []
}
const content = await readFile(OPENCLAW_JOBS_FILE, 'utf-8')
const parsed = JSON.parse(content) as unknown
return isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : []
}
async function listLocalHermesManager(): Promise<ExternalAutomationManager | null> {
const [hermesAvailableResult, jobsResult] = await Promise.allSettled([
isCommandOnPath('hermes'),
readLocalHermesJobs()
])
const hermesAvailable =
hermesAvailableResult.status === 'fulfilled' && hermesAvailableResult.value
const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : []
const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null
if (!hermesAvailable && jobs.length === 0 && !readError) {
return null
}
const managerId = 'hermes:local'
return {
id: managerId,
provider: 'hermes',
label: 'Hermes on this computer',
target: { type: 'local' },
status: readError ? 'unavailable' : 'available',
error:
readError ??
(hermesAvailable ? null : 'Hermes jobs were found, but the hermes CLI is not on PATH.'),
canManage: !readError && hermesAvailable,
jobs: mapHermesJobs(managerId, jobs)
}
}
async function listLocalOpenClawManager(): Promise<ExternalAutomationManager | null> {
const [openClawAvailableResult, jobsResult] = await Promise.allSettled([
isCommandOnPath('openclaw'),
readLocalOpenClawJobs()
])
const openClawAvailable =
openClawAvailableResult.status === 'fulfilled' && openClawAvailableResult.value
const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : []
const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null
if (!openClawAvailable && jobs.length === 0 && !readError) {
return null
}
const managerId = 'openclaw:local'
return {
id: managerId,
provider: 'openclaw',
label: 'OpenClaw on this computer',
target: { type: 'local' },
status: readError ? 'unavailable' : 'available',
error:
readError ??
(openClawAvailable ? null : 'OpenClaw jobs were found, but the openclaw CLI is not on PATH.'),
canManage: !readError && openClawAvailable,
jobs: mapOpenClawJobs(managerId, jobs)
}
}
function remoteRelayErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('-32601') || /method not found/i.test(message)) {
return 'Remote relay does not support external automation management. Reconnect the SSH target to deploy the latest relay.'
}
return message
}
async function listRemoteHermesManager(target: SshTarget): Promise<ExternalAutomationManager> {
return listRemoteManager(target, 'hermes')
}
async function listRemoteOpenClawManager(target: SshTarget): Promise<ExternalAutomationManager> {
return listRemoteManager(target, 'openclaw')
}
async function listRemoteManager(
target: SshTarget,
provider: ExternalAutomationProvider
): Promise<ExternalAutomationManager> {
const providerLabel = provider === 'hermes' ? 'Hermes' : 'OpenClaw'
const managerProviderId = `${provider}:ssh:${target.id}`
const mux = getActiveMultiplexer(target.id)
if (!mux || mux.isDisposed()) {
return {
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
target: { type: 'ssh', connectionId: target.id },
status: 'unavailable',
error: 'SSH target is not connected.',
canManage: false,
jobs: []
}
}
try {
const result = (await mux.request('externalAutomations.list', { provider })) as {
jobs?: unknown[]
hermesAvailable?: boolean
openclawAvailable?: boolean
error?: string | null
}
const commandAvailable =
provider === 'hermes' ? result.hermesAvailable === true : result.openclawAvailable === true
const readError = result.error ?? null
return {
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
target: { type: 'ssh', connectionId: target.id },
status: readError ? 'unavailable' : 'available',
error:
readError ?? (commandAvailable ? null : `${providerLabel} CLI is not on the remote PATH.`),
canManage: !readError && commandAvailable,
jobs:
provider === 'hermes'
? mapHermesJobs(managerProviderId, result.jobs ?? [])
: mapOpenClawJobs(managerProviderId, result.jobs ?? [])
}
} catch (error) {
return {
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
target: { type: 'ssh', connectionId: target.id },
status: 'unavailable',
error: remoteRelayErrorMessage(error),
canManage: false,
jobs: []
}
}
}
export async function listExternalAutomationManagers(
store: Store
): Promise<ExternalAutomationManager[]> {
const [localHermes, localOpenClaw, remote] = await Promise.all([
listLocalHermesManager(),
listLocalOpenClawManager(),
Promise.all(
store
.getSshTargets()
.flatMap((target) => [listRemoteHermesManager(target), listRemoteOpenClawManager(target)])
)
])
return [
...(localHermes ? [localHermes] : []),
...(localOpenClaw ? [localOpenClaw] : []),
...remote
]
}
function hermesCommandForAction(action: ExternalAutomationAction): string {
switch (action) {
case 'pause':
return 'pause'
case 'resume':
return 'resume'
case 'run':
return 'run'
case 'delete':
return 'remove'
}
}
function openClawCommandForAction(action: ExternalAutomationAction): string {
switch (action) {
case 'pause':
return 'disable'
case 'resume':
return 'enable'
case 'run':
return 'run'
case 'delete':
return 'rm'
}
}
export async function runExternalAutomationAction(
input: ExternalAutomationActionInput
): Promise<void> {
if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) {
throw new Error('Invalid external automation job ID.')
}
const command =
input.provider === 'hermes'
? hermesCommandForAction(input.action)
: openClawCommandForAction(input.action)
if (input.target.type === 'local') {
await execFileAsync(input.provider, ['cron', command, input.jobId], { encoding: 'utf-8' })
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.act', {
provider: input.provider,
action: input.action,
jobId: input.jobId
})
}
@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as childProcessModule from 'node:child_process'
import type * as fsModule from 'node:fs'
const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({
sessionFromPartitionMock: vi.fn(),
dialogShowOpenDialogMock: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: { fromWebContents: vi.fn() },
dialog: { showOpenDialog: dialogShowOpenDialogMock },
session: { fromPartition: sessionFromPartitionMock }
}))
import { BROWSER_FAMILY_LABELS } from '../../shared/constants'
describe('detectInstalledBrowsers — Comet', () => {
const originalPlatform = process.platform
const originalHome = process.env.HOME
beforeEach(() => {
// Why: browser-cookie-import.ts uses destructured named imports from
// 'node:fs' which are bound at module-load time. Without vi.resetModules(),
// only the first test's vi.doMock takes effect — subsequent tests see the
// cached module with the first mock still applied. resetModules must run
// BEFORE each doMock so the next import() picks up the fresh mock factory.
vi.resetModules()
Object.defineProperty(process, 'platform', { value: 'darwin' })
process.env.HOME = '/Users/test'
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
process.env.HOME = originalHome
vi.restoreAllMocks()
})
it('detects Comet when its data directory and Cookies DB exist', async () => {
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof fsModule>('node:fs')
return {
...actual,
existsSync: (p: string) => {
if (p.includes('Comet/Default/Network/Cookies')) {
return true
}
if (p.includes('Comet/Local State')) {
return true
}
return false
},
readFileSync: (p: string, enc?: string) => {
if (typeof p === 'string' && p.includes('Comet/Local State')) {
return JSON.stringify({ profile: { info_cache: { Default: { name: 'Default' } } } })
}
return actual.readFileSync(p as never, enc as never)
}
}
})
const { detectInstalledBrowsers } = await import('./browser-cookie-import')
const detected = detectInstalledBrowsers()
const comet = detected.find((b) => b.family === 'comet')
expect(comet).toBeDefined()
expect(comet?.label).toBe('Comet')
expect(comet?.cookiesPath).toContain('Comet/Default/Network/Cookies')
expect(comet?.keychainService).toBe('Comet Safe Storage')
})
it('does not list Comet when its data directory is absent', async () => {
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof fsModule>('node:fs')
return {
...actual,
existsSync: () => false
}
})
const { detectInstalledBrowsers } = await import('./browser-cookie-import')
const detected = detectInstalledBrowsers()
expect(detected.find((b) => b.family === 'comet')).toBeUndefined()
})
it('enumerates all Comet profiles from Local State info_cache', async () => {
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof fsModule>('node:fs')
return {
...actual,
existsSync: (p: string) => {
if (p.includes('Comet/Default/Network/Cookies')) {
return true
}
if (p.includes('Comet/Local State')) {
return true
}
return false
},
readFileSync: (p: string, enc?: string) => {
if (typeof p === 'string' && p.includes('Comet/Local State')) {
return JSON.stringify({
profile: {
info_cache: {
Default: { name: 'Personal' },
'Profile 1': { name: 'Work' },
'Profile 2': { name: 'Research' }
}
}
})
}
return actual.readFileSync(p as never, enc as never)
}
}
})
const { detectInstalledBrowsers } = await import('./browser-cookie-import')
const detected = detectInstalledBrowsers()
const comet = detected.find((b) => b.family === 'comet')
expect(comet).toBeDefined()
const directories = comet!.profiles.map((p) => p.directory).sort()
expect(directories).toEqual(['Default', 'Profile 1', 'Profile 2'])
const names = comet!.profiles.map((p) => p.name).sort()
expect(names).toEqual(['Personal', 'Research', 'Work'])
})
it('skips Comet when the data directory exists but no Cookies DB is present', async () => {
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof fsModule>('node:fs')
return {
...actual,
existsSync: (p: string) => {
if (p.includes('Comet/Local State')) {
return true
}
if (p.includes('Network/Cookies') || p.endsWith('/Cookies')) {
return false
}
return false
},
readFileSync: (p: string, enc?: string) => {
if (typeof p === 'string' && p.includes('Comet/Local State')) {
return JSON.stringify({ profile: { info_cache: { Default: { name: 'Default' } } } })
}
return actual.readFileSync(p as never, enc as never)
}
}
})
const { detectInstalledBrowsers } = await import('./browser-cookie-import')
const detected = detectInstalledBrowsers()
expect(detected.find((b) => b.family === 'comet')).toBeUndefined()
})
})
describe('getUserAgentForBrowser — Comet', () => {
const originalPlatform = process.platform
beforeEach(() => {
vi.resetModules()
Object.defineProperty(process, 'platform', { value: 'darwin' })
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
vi.restoreAllMocks()
})
it('returns a Chrome-shaped UA string when Comet plist version reads successfully', async () => {
vi.doMock('node:child_process', async () => {
const actual = await vi.importActual<typeof childProcessModule>('node:child_process')
return {
...actual,
execFileSync: (cmd: string, args: readonly string[]) => {
if (cmd === 'defaults' && args[1]?.includes('/Applications/Comet.app/Contents/Info')) {
return '120.0.6099.71\n'
}
return actual.execFileSync(cmd, args as never)
}
}
})
const { getUserAgentForBrowser } = await import('./browser-cookie-import')
const ua = getUserAgentForBrowser('comet')
expect(ua).not.toBeNull()
expect(ua).toContain('Macintosh; Intel Mac OS X 10_15_7')
expect(ua).toContain('AppleWebKit/537.36')
expect(ua).toContain('Chrome/120.0.6099.71')
expect(ua).toContain('Safari/537.36')
})
it('returns null when reading the Comet plist version throws', async () => {
vi.doMock('node:child_process', async () => {
const actual = await vi.importActual<typeof childProcessModule>('node:child_process')
return {
...actual,
execFileSync: () => {
throw new Error('defaults: domain not found')
}
}
})
const { getUserAgentForBrowser } = await import('./browser-cookie-import')
const ua = getUserAgentForBrowser('comet')
expect(ua).toBeNull()
})
it('returns null on non-darwin platforms regardless of family', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' })
const { getUserAgentForBrowser } = await import('./browser-cookie-import')
const ua = getUserAgentForBrowser('comet')
expect(ua).toBeNull()
})
})
describe('BROWSER_FAMILY_LABELS — Comet', () => {
it('maps the comet family key to the user-facing label "Comet"', () => {
expect(BROWSER_FAMILY_LABELS.comet).toBe('Comet')
})
})
@@ -227,7 +227,7 @@ describe('detectInstalledBrowsers', () => {
it('each detected browser has a valid family', () => {
const browsers = detectInstalledBrowsers()
const validFamilies = ['chrome', 'edge', 'arc', 'chromium', 'firefox', 'safari']
const validFamilies = ['chrome', 'edge', 'arc', 'chromium', 'firefox', 'safari', 'comet']
for (const browser of browsers) {
expect(validFamilies).toContain(browser.family)
}
+27 -5
View File
@@ -2,12 +2,13 @@
that must stay together so the encryption, schema, and staging steps remain in sync. */
import { app, type BrowserWindow, dialog, session } from 'electron'
import { execFileSync } from 'node:child_process'
import { createDecipheriv, pbkdf2Sync } from 'node:crypto'
import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto'
import {
appendFileSync,
copyFileSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
@@ -113,6 +114,15 @@ const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [
macRoot: 'BraveSoftware/Brave-Browser',
winRoot: 'BraveSoftware/Brave-Browser/User Data',
linuxRoot: 'BraveSoftware/Brave-Browser'
},
{
family: 'comet',
label: 'Comet',
keychainService: 'Comet Safe Storage',
keychainAccount: 'Comet',
macRoot: 'Comet',
winRoot: 'Comet/User Data'
// linuxRoot intentionally omitted — Comet does not ship a Linux build as of 2026-05-15
}
]
@@ -658,7 +668,7 @@ export async function importCookiesFromFile(
// Why: Google and other services bind auth cookies to the User-Agent that
// created them. We read the source browser's real version from its plist
// and construct a matching UA string so imported sessions aren't invalidated.
function getUserAgentForBrowser(
export function getUserAgentForBrowser(
family: BrowserSessionProfileSource['browserFamily']
): string | null {
// Why: UA spoofing uses macOS-specific plist reading. On other platforms,
@@ -703,6 +713,12 @@ function getUserAgentForBrowser(
const v = readBrowserVersion('/Applications/Brave Browser.app')
return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null
}
case 'comet': {
// Why: Comet is Chromium-based and ships a Chrome-shaped version in its plist.
// Use the same UA shape as Chrome itself so Google-bound auth cookies survive import.
const v = readBrowserVersion('/Applications/Comet.app')
return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null
}
default:
return null
}
@@ -1343,8 +1359,14 @@ export async function importCookiesFromBrowser(
return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' }
}
const stagingCookiesPath = join(app.getPath('userData'), 'Cookies-staged')
const stagingDir = join(app.getPath('userData'), 'cookie-import-staging')
const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_')
const stagingCookiesPath = join(
stagingDir,
`Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}`
)
try {
mkdirSync(stagingDir, { recursive: true })
copyFileSync(liveCookiesPath, stagingCookiesPath)
} catch {
rmSync(tmpDir, { recursive: true, force: true })
@@ -1565,7 +1587,7 @@ export async function importCookiesFromBrowser(
// Why: some cookies couldn't be loaded via cookies.set() (non-ASCII values
// or other validation failures). Keep the staging DB so the next cold start
// picks them up from SQLite where CookieMonster reads them without validation.
browserSessionRegistry.setPendingCookieImport(stagingCookiesPath)
browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath)
diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`)
} else {
try {
@@ -1580,7 +1602,7 @@ export async function importCookiesFromBrowser(
if (ua) {
targetSession.setUserAgent(ua)
setupClientHintsOverride(targetSession, ua)
browserSessionRegistry.persistUserAgent(ua)
browserSessionRegistry.persistUserAgent(targetPartition, ua)
diag(` set UA for partition: ${ua.substring(0, 80)}...`)
}
@@ -0,0 +1,245 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const USER_DATA = '/user-data'
const META_PATH = `${USER_DATA}/browser-session-meta.json`
type FsState = {
files: Map<string, string>
present: Set<string>
}
function createFsState(): FsState {
return { files: new Map(), present: new Set() }
}
function seedMeta(fsState: FsState, meta: unknown): void {
const raw = JSON.stringify(meta)
fsState.files.set(META_PATH, raw)
fsState.present.add(META_PATH)
}
function installModuleMocks(
fsState: FsState,
copyFailures: Set<string> = new Set()
): {
sessionFromPartitionMock: ReturnType<typeof vi.fn>
setupClientHintsOverrideMock: ReturnType<typeof vi.fn>
} {
const sessionFromPartitionMock = vi.fn((partition: string) => ({
partition,
setUserAgent: vi.fn(),
getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/31 Orca'),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setDisplayMediaRequestHandler: vi.fn(),
on: vi.fn(),
clearStorageData: vi.fn().mockResolvedValue(undefined),
clearCache: vi.fn().mockResolvedValue(undefined)
}))
const setupClientHintsOverrideMock = vi.fn()
vi.doMock('electron', () => ({
app: { getPath: vi.fn(() => USER_DATA) },
session: { fromPartition: sessionFromPartitionMock },
systemPreferences: {
askForMediaAccess: vi.fn().mockResolvedValue(true),
getMediaAccessStatus: vi.fn(() => 'granted')
}
}))
vi.doMock('node:fs', () => ({
copyFileSync: vi.fn((src: string, dst: string) => {
if (copyFailures.has(src)) {
throw new Error(`copy fail for ${src}`)
}
fsState.present.add(dst)
const value = fsState.files.get(src)
if (value !== undefined) {
fsState.files.set(dst, value)
}
}),
existsSync: vi.fn((p: string) => fsState.present.has(p)),
mkdirSync: vi.fn(),
readFileSync: vi.fn((p: string) => {
const v = fsState.files.get(p)
if (v === undefined) {
throw new Error('ENOENT')
}
return v
}),
renameSync: vi.fn((from: string, to: string) => {
const v = fsState.files.get(from)
if (v === undefined) {
throw new Error('ENOENT')
}
fsState.files.set(to, v)
fsState.present.add(to)
fsState.files.delete(from)
fsState.present.delete(from)
}),
unlinkSync: vi.fn((p: string) => {
fsState.present.delete(p)
fsState.files.delete(p)
}),
writeFileSync: vi.fn((p: string, data: string | Uint8Array) => {
const value = typeof data === 'string' ? data : Buffer.from(data).toString('utf-8')
fsState.files.set(p, value)
fsState.present.add(p)
})
}))
vi.doMock('./browser-manager', () => ({
browserManager: {
notifyPermissionDenied: vi.fn(),
handleGuestWillDownload: vi.fn()
}
}))
vi.doMock('./browser-media-access', () => ({
hasSystemMediaAccess: vi.fn(() => true),
requestSystemMediaAccess: vi.fn().mockResolvedValue(true)
}))
vi.doMock('./browser-session-ua', () => ({
cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')),
setupClientHintsOverride: setupClientHintsOverrideMock
}))
return { sessionFromPartitionMock, setupClientHintsOverrideMock }
}
describe('BrowserSessionRegistry persistence', () => {
beforeEach(() => {
vi.resetModules()
vi.restoreAllMocks()
})
it('migrates and consumes legacy pendingCookieDbPath into default partition replay', async () => {
const fsState = createFsState()
seedMeta(fsState, {
defaultSource: null,
userAgent: null,
pendingCookieDbPath: '/staged/legacy',
profiles: []
})
fsState.present.add('/staged/legacy')
installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.applyPendingCookieImport()
const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}')
expect(written.pendingCookieDbPath).toBeNull()
expect(written.pendingCookieImports).toEqual({})
expect(fsState.present.has('/user-data/Partitions/orca-browser/Cookies')).toBe(true)
})
it('merges partition-keyed pending entries without clobbering unrelated entries', async () => {
const fsState = createFsState()
seedMeta(fsState, {
defaultSource: null,
userAgent: null,
userAgentByPartition: {},
pendingCookieDbPath: null,
pendingCookieImports: {},
profiles: []
})
installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.setPendingCookieImport('persist:orca-browser', '/staged/default')
browserSessionRegistry.setPendingCookieImport(
'persist:orca-browser-session-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
'/staged/imported'
)
const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}')
expect(written.pendingCookieDbPath).toBe('/staged/default')
expect(written.pendingCookieImports).toEqual({
'persist:orca-browser': '/staged/default',
'persist:orca-browser-session-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa': '/staged/imported'
})
})
it('restores persisted UA for non-default partitions', async () => {
const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111'
const importedUa = 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36'
const defaultUa = 'Mozilla/5.0 Chrome/119.0.0.0 Safari/537.36'
const fsState = createFsState()
seedMeta(fsState, {
defaultSource: null,
userAgent: defaultUa,
userAgentByPartition: {
'persist:orca-browser': defaultUa,
[importedPartition]: importedUa
},
pendingCookieDbPath: null,
pendingCookieImports: {},
profiles: [
{
id: '11111111-1111-4111-8111-111111111111',
scope: 'imported',
partition: importedPartition,
label: 'Imported',
source: { browserFamily: 'comet', importedAt: 1 }
}
]
})
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.restorePersistedUserAgent()
const importedSessions = sessionFromPartitionMock.mock.results
.filter((_, idx) => sessionFromPartitionMock.mock.calls[idx]?.[0] === importedPartition)
.map((r) => r.value)
expect(importedSessions.length).toBeGreaterThan(0)
expect(
importedSessions.some((s) =>
s.setUserAgent.mock.calls.some((c: unknown[]) => c[0] === importedUa)
)
).toBe(true)
expect(
setupClientHintsOverrideMock.mock.calls.some(
(c: unknown[]) =>
(c[0] as { partition?: string } | undefined)?.partition === importedPartition &&
c[1] === importedUa
)
).toBe(true)
})
it('keeps failed partition replay pending and removes unrelated missing entries', async () => {
const importedPartition = 'persist:orca-browser-session-22222222-2222-4222-8222-222222222222'
const fsState = createFsState()
seedMeta(fsState, {
defaultSource: null,
userAgent: null,
userAgentByPartition: {},
pendingCookieDbPath: null,
pendingCookieImports: {
[importedPartition]: '/staged/imported',
'persist:orca-browser': '/staged/missing'
},
profiles: [
{
id: '22222222-2222-4222-8222-222222222222',
scope: 'imported',
partition: importedPartition,
label: 'Imported',
source: { browserFamily: 'comet', importedAt: 1 }
}
]
})
fsState.present.add('/staged/imported')
installModuleMocks(fsState, new Set(['/staged/imported']))
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.applyPendingCookieImport()
const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}')
expect(written.pendingCookieImports).toEqual({ [importedPartition]: '/staged/imported' })
expect(written.pendingCookieDbPath).toBeNull()
})
})
@@ -122,6 +122,17 @@ describe('BrowserSessionRegistry', () => {
expect(updated!.source?.browserFamily).toBe('edge')
})
it('updates profile source with comet family', () => {
const profile = browserSessionRegistry.createProfile('imported', 'Comet Source Test')
expect(profile).not.toBeNull()
const updated = browserSessionRegistry.updateProfileSource(profile!.id, {
browserFamily: 'comet',
importedAt: Date.now()
})
expect(updated).not.toBeNull()
expect(updated!.source?.browserFamily).toBe('comet')
})
it('deletes a non-default profile', async () => {
const profile = browserSessionRegistry.createProfile('isolated', 'Delete Test')
expect(profile).not.toBeNull()
+162 -55
View File
@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
@@ -22,7 +23,9 @@ import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-sess
type BrowserSessionMeta = {
defaultSource: BrowserSessionProfile['source']
userAgent: string | null
userAgentByPartition: Record<string, string>
pendingCookieDbPath: string | null
pendingCookieImports: Record<string, string>
profiles: BrowserSessionProfile[]
}
@@ -57,6 +60,11 @@ class BrowserSessionRegistry {
return this.loadPersistedMeta().defaultSource
}
private static partitionCookiesPath(partition: string): string {
const partitionName = partition.replace('persist:', '')
return join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies')
}
// Why: write-to-temp-then-rename is atomic on all supported platforms.
// A crash mid-write would only lose the temp file, not corrupt the live one.
private persistMeta(updates: Partial<BrowserSessionMeta>): void {
@@ -88,14 +96,41 @@ class BrowserSessionRegistry {
try {
const raw = readFileSync(this.metadataPath, 'utf-8')
const data = JSON.parse(raw)
const legacyUserAgent = typeof data?.userAgent === 'string' ? data.userAgent : null
const userAgentByPartition: Record<string, string> =
data && typeof data.userAgentByPartition === 'object' && data.userAgentByPartition
? { ...data.userAgentByPartition }
: {}
if (legacyUserAgent && !userAgentByPartition[ORCA_BROWSER_PARTITION]) {
userAgentByPartition[ORCA_BROWSER_PARTITION] = legacyUserAgent
}
const legacyPendingCookieDbPath =
typeof data?.pendingCookieDbPath === 'string' ? data.pendingCookieDbPath : null
const pendingCookieImports: Record<string, string> =
data && typeof data.pendingCookieImports === 'object' && data.pendingCookieImports
? { ...data.pendingCookieImports }
: {}
if (legacyPendingCookieDbPath && !pendingCookieImports[ORCA_BROWSER_PARTITION]) {
pendingCookieImports[ORCA_BROWSER_PARTITION] = legacyPendingCookieDbPath
}
return {
defaultSource: data?.defaultSource ?? null,
userAgent: data?.userAgent ?? null,
pendingCookieDbPath: data?.pendingCookieDbPath ?? null,
userAgent: legacyUserAgent,
userAgentByPartition,
pendingCookieDbPath: legacyPendingCookieDbPath,
pendingCookieImports,
profiles: Array.isArray(data?.profiles) ? data.profiles : []
}
} catch {
return { defaultSource: null, userAgent: null, pendingCookieDbPath: null, profiles: [] }
return {
defaultSource: null,
userAgent: null,
userAgentByPartition: {},
pendingCookieDbPath: null,
pendingCookieImports: {},
profiles: []
}
}
}
@@ -110,22 +145,6 @@ class BrowserSessionRegistry {
// after app is ready) ensures the default profile's source is populated.
restorePersistedUserAgent(): void {
const meta = this.loadPersistedMeta()
if (meta.userAgent) {
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
sess.setUserAgent(meta.userAgent)
setupClientHintsOverride(sess, meta.userAgent)
} else {
// Why: even without an imported session, the default Electron UA contains
// "Electron/X.X.X" and the app name which trip Cloudflare Turnstile.
try {
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
} catch {
/* session not available yet (e.g. unit tests or pre-ready) */
}
}
if (meta.defaultSource) {
const current = this.profiles.get('default')
if (current && current.source === null) {
@@ -135,6 +154,30 @@ class BrowserSessionRegistry {
if (meta.profiles.length > 0) {
this.hydrateFromPersisted(meta.profiles)
}
const partitions = new Set([
ORCA_BROWSER_PARTITION,
...this.listProfiles().map((p) => p.partition)
])
for (const partition of partitions) {
try {
const sess = session.fromPartition(partition)
const persistedUa = meta.userAgentByPartition[partition]
if (persistedUa) {
sess.setUserAgent(persistedUa)
setupClientHintsOverride(sess, persistedUa)
continue
}
// Why: even without an imported session, the default Electron UA contains
// "Electron/X.X.X" and the app name which trip Cloudflare Turnstile.
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
} catch {
/* session not available yet (e.g. unit tests or pre-ready) */
}
}
}
// Why: the import writes cookies to a staging DB because CookieMonster holds
@@ -144,56 +187,98 @@ class BrowserSessionRegistry {
applyPendingCookieImport(): void {
try {
const meta = this.loadPersistedMeta()
if (!meta.pendingCookieDbPath) {
return
}
if (!existsSync(meta.pendingCookieDbPath)) {
this.persistMeta({ pendingCookieDbPath: null })
const pendingEntries = Object.entries(meta.pendingCookieImports)
if (pendingEntries.length === 0) {
return
}
const knownPartitions = new Set([
ORCA_BROWSER_PARTITION,
...meta.profiles.map((p) => p.partition)
])
const remainingEntries = { ...meta.pendingCookieImports }
const partitionName = ORCA_BROWSER_PARTITION.replace('persist:', '')
const liveCookiesPath = join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies')
copyFileSync(meta.pendingCookieDbPath, liveCookiesPath)
// Why: SQLite WAL mode stores uncommitted data in sidecar files.
// Stale WAL/SHM from a previous session could corrupt CookieMonster's
// read of the freshly swapped DB.
for (const suffix of ['-wal', '-shm']) {
try {
unlinkSync(liveCookiesPath + suffix)
} catch {
/* may not exist */
for (const [partition, stagedPath] of pendingEntries) {
if (!knownPartitions.has(partition)) {
delete remainingEntries[partition]
continue
}
const stagingSidecar = meta.pendingCookieDbPath + suffix
if (existsSync(stagingSidecar)) {
try {
copyFileSync(stagingSidecar, liveCookiesPath + suffix)
} catch {
/* best-effort */
if (!existsSync(stagedPath)) {
delete remainingEntries[partition]
continue
}
const liveCookiesPath = BrowserSessionRegistry.partitionCookiesPath(partition)
try {
mkdirSync(join(liveCookiesPath, '..'), { recursive: true })
copyFileSync(stagedPath, liveCookiesPath)
// Why: SQLite WAL mode stores uncommitted data in sidecar files.
// Stale WAL/SHM from a previous session could corrupt CookieMonster's
// read of the freshly swapped DB.
let sidecarCopyFailed = false
for (const suffix of ['-wal', '-shm']) {
try {
unlinkSync(liveCookiesPath + suffix)
} catch {
/* may not exist */
}
const stagingSidecar = stagedPath + suffix
if (!existsSync(stagingSidecar)) {
continue
}
try {
copyFileSync(stagingSidecar, liveCookiesPath + suffix)
} catch {
sidecarCopyFailed = true
}
}
}
}
for (const ext of ['', '-wal', '-shm']) {
try {
unlinkSync(`${meta.pendingCookieDbPath}${ext}`)
if (sidecarCopyFailed) {
// Why: sidecar copy failures can leave an inconsistent replay state.
// Keep this entry for retry and preserve unrelated entries.
continue
}
for (const ext of ['', '-wal', '-shm']) {
try {
unlinkSync(`${stagedPath}${ext}`)
} catch {
/* best-effort */
}
}
delete remainingEntries[partition]
} catch {
/* best-effort */
// Why: failed replay for one partition should not drop unrelated entries.
// Keep this entry for retry next launch.
}
}
this.persistMeta({ pendingCookieDbPath: null })
this.persistMeta({
pendingCookieImports: remainingEntries,
pendingCookieDbPath: remainingEntries[ORCA_BROWSER_PARTITION] ?? null
})
} catch {
// best-effort — if this fails, CookieMonster loads the old DB
}
}
setPendingCookieImport(stagingDbPath: string): void {
this.persistMeta({ pendingCookieDbPath: stagingDbPath })
setPendingCookieImport(partition: string, stagingDbPath: string): void {
const meta = this.loadPersistedMeta()
const pendingCookieImports = { ...meta.pendingCookieImports, [partition]: stagingDbPath }
this.persistMeta({
pendingCookieImports,
pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null
})
}
persistUserAgent(userAgent: string | null): void {
const defaultProfile = this.profiles.get('default')
this.persistSource(defaultProfile?.source ?? null, userAgent)
persistUserAgent(partition: string, userAgent: string | null): void {
const meta = this.loadPersistedMeta()
const userAgentByPartition = { ...meta.userAgentByPartition }
if (userAgent) {
userAgentByPartition[partition] = userAgent
} else {
delete userAgentByPartition[partition]
}
this.persistMeta({
userAgentByPartition,
userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null
})
}
getDefaultProfile(): BrowserSessionProfile {
@@ -273,6 +358,17 @@ class BrowserSessionRegistry {
}
this.profiles.delete(profileId)
this.persistProfiles()
const meta = this.loadPersistedMeta()
const pendingCookieImports = { ...meta.pendingCookieImports }
delete pendingCookieImports[profile.partition]
const userAgentByPartition = { ...meta.userAgentByPartition }
delete userAgentByPartition[profile.partition]
this.persistMeta({
pendingCookieImports,
pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null,
userAgentByPartition,
userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null
})
// Why: clearing the partition's storage prevents orphaned cookies/cache from
// lingering after the user deletes an imported or isolated session profile.
@@ -298,7 +394,18 @@ class BrowserSessionRegistry {
if (defaultProfile) {
this.profiles.set('default', { ...defaultProfile, source: null })
}
this.persistMeta({ defaultSource: null, userAgent: null, pendingCookieDbPath: null })
const meta = this.loadPersistedMeta()
const pendingCookieImports = { ...meta.pendingCookieImports }
delete pendingCookieImports[ORCA_BROWSER_PARTITION]
const userAgentByPartition = { ...meta.userAgentByPartition }
delete userAgentByPartition[ORCA_BROWSER_PARTITION]
this.persistMeta({
defaultSource: null,
userAgent: null,
userAgentByPartition,
pendingCookieDbPath: null,
pendingCookieImports
})
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
await sess.clearStorageData({ storages: ['cookies'] })
@@ -9,6 +9,10 @@ export function markClaudePtyExited(ptyId: string): void {
liveClaudePtyIds.delete(ptyId)
}
export function hasLiveClaudePtys(): boolean {
return liveClaudePtyIds.size > 0
}
export function beginClaudeAuthSwitch(): void {
if (switchInProgress) {
throw new Error('A Claude account switch is already in progress.')
@@ -995,6 +995,80 @@ describe('ClaudeRuntimeAuthService', () => {
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials)
})
it('preserves rejected runtime refreshes while a Claude terminal is live on Windows', async () => {
setPlatform('win32')
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a')
const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-a')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
originalCredentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' })
],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
markClaudePtySpawned('live-claude-pty')
try {
writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8')
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials)
} finally {
markClaudePtyExited('live-claude-pty')
}
await service.syncForCurrentSelection()
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials)
})
it('does not persist live runtime refreshes with conflicting organization identity', async () => {
setPlatform('win32')
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a')
const conflictingCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-b')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
originalCredentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' })
],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
markClaudePtySpawned('live-claude-pty')
try {
writeFileSync(runtimeCredentialsPath, conflictingCredentials, 'utf-8')
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(conflictingCredentials)
} finally {
markClaudePtyExited('live-claude-pty')
}
})
it('rejects unverifiable refreshed runtime credentials', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const originalCredentials = createClaudeCredentialsWithoutEmail('original')
@@ -13,6 +13,7 @@ import {
resolveOwnedClaudeManagedAuthPath,
writeClaudeManagedAuthFile
} from './managed-auth-path'
import { hasLiveClaudePtys } from './live-pty-gate'
import { ClaudeRuntimePathResolver } from './runtime-paths'
import {
deleteActiveClaudeKeychainCredentialsStrict,
@@ -47,7 +48,9 @@ type ClaudeAuthIdentity = {
organizationUuid: string | null
}
type ClaudeReadBackResult = { status: 'unchanged' | 'persisted' | 'rejected' }
type ClaudeReadBackResult =
| { status: 'unchanged' | 'persisted' }
| { status: 'rejected'; runtimeCredentialsChanged: boolean; runtimeCredentialsJson?: string }
type ClaudeReadBackMatch =
| { kind: 'matched'; account: ClaudeManagedAccount; managedCredentialsJson: string }
| { kind: 'none' | 'ambiguous' }
@@ -250,6 +253,34 @@ export class ClaudeRuntimeAuthService {
if (updatedCredentialsJson && this.isValidCredentialsJsonObject(updatedCredentialsJson)) {
credentialsJson = updatedCredentialsJson
}
} else if (
readBackResult.status === 'rejected' &&
readBackResult.runtimeCredentialsChanged &&
hasLiveClaudePtys()
) {
if (
readBackResult.runtimeCredentialsJson &&
this.liveRuntimeCredentialsCanUpdateActiveAccount(
readBackResult.runtimeCredentialsJson,
activeAccount,
credentialsJson,
this.readManagedOauthAccount(activeAccount)
)
) {
// Why: this Claude process was launched under the active managed
// account, but persistence still needs positive account proof.
await this.writeManagedCredentials(activeAccount, readBackResult.runtimeCredentialsJson)
credentialsJson = readBackResult.runtimeCredentialsJson
} else {
// Why: while Claude is running, an unknown refresh can still belong
// to a live session. Rewriting stale managed auth logs that session out.
console.warn(
'[claude-runtime-auth] Preserving changed Claude runtime credentials while live Claude terminals are running'
)
this.lastSyncedAccountId = activeAccount.id
this.hasMaterializedRuntimeAuth = true
return
}
}
}
}
@@ -319,6 +350,7 @@ export class ClaudeRuntimeAuthService {
credentialsJson: string
match: Extract<ClaudeReadBackMatch, { kind: 'matched' }>
}[] = []
const ambiguousCandidates: string[] = []
let sawAmbiguousCandidate = false
for (const runtimeContents of changedCandidates) {
if (!this.isValidCredentialsJsonObject(runtimeContents)) {
@@ -327,6 +359,7 @@ export class ClaudeRuntimeAuthService {
const match = await this.findManagedAccountForRuntimeCredentials(runtimeContents)
if (match.kind === 'ambiguous') {
sawAmbiguousCandidate = true
ambiguousCandidates.push(runtimeContents)
continue
}
if (match.kind !== 'matched') {
@@ -348,7 +381,12 @@ export class ClaudeRuntimeAuthService {
if (sawAmbiguousCandidate) {
console.warn('[claude-runtime-auth] Refusing ambiguous Claude auth read-back')
}
return { status: 'rejected' }
return {
status: 'rejected',
runtimeCredentialsChanged: true,
runtimeCredentialsJson:
ambiguousCandidates.length === 1 ? ambiguousCandidates[0] : undefined
}
}
const { credentialsJson: runtimeContents, match } =
this.chooseFreshestReadBackCandidate(acceptedCandidates)
@@ -368,7 +406,11 @@ export class ClaudeRuntimeAuthService {
// forward sync path — the worst case is one more stale-token cycle, which
// is strictly better than failing the entire sync.
console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error)
return { status: 'rejected' }
return {
status: 'rejected',
runtimeCredentialsChanged:
this.runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson)
}
}
}
@@ -498,6 +540,36 @@ export class ClaudeRuntimeAuthService {
return 'match'
}
private liveRuntimeCredentialsCanUpdateActiveAccount(
runtimeCredentialsJson: string,
account: ClaudeManagedAccount,
managedCredentialsJson: string,
managedOauthAccount: unknown
): boolean {
const match = this.runtimeCredentialsMatchAccount(
runtimeCredentialsJson,
account,
managedCredentialsJson,
managedOauthAccount
)
if (match === 'match') {
return true
}
const identity = this.readIdentityFromCredentials(runtimeCredentialsJson)
const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson)
const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount)
const selectedOrganizationUuid = this.normalizeField(
account.organizationUuid ??
managedIdentity?.organizationUuid ??
managedOauthIdentity.organizationUuid
)
return (
match === 'unverifiable' &&
Boolean(selectedOrganizationUuid) &&
identity?.organizationUuid === selectedOrganizationUuid
)
}
private readIdentityFromCredentials(credentialsJson: string): ClaudeAuthIdentity | null {
let parsed: Record<string, unknown>
try {
@@ -1014,6 +1086,21 @@ export class ClaudeRuntimeAuthService {
return currentCredentialsJson === previouslyWrittenCredentialsJson
}
private runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson: string): boolean {
const paths = this.pathResolver.getRuntimePaths()
try {
const currentCredentialsJson = existsSync(paths.credentialsPath)
? readFileSync(paths.credentialsPath, 'utf-8')
: null
return (
currentCredentialsJson !== null &&
currentCredentialsJson !== (this.lastWrittenCredentialsJson ?? baselineCredentialsJson)
)
} catch {
return false
}
}
private restoreRuntimeCredentials(credentialsJson: string | null): void {
const paths = this.pathResolver.getRuntimePaths()
if (credentialsJson !== null) {
+133 -3
View File
@@ -3,6 +3,7 @@ import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as Os from 'os'
import type * as FsPromises from 'fs/promises'
const tempRoots: string[] = []
@@ -10,11 +11,13 @@ async function makeClaudeProjectsRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-claude-usage-'))
tempRoots.push(root)
await mkdir(join(root, '.claude', 'projects', 'project-a'), { recursive: true })
await mkdir(join(root, '.claude', 'transcripts'), { recursive: true })
return root
}
afterEach(async () => {
vi.doUnmock('os')
vi.doUnmock('fs/promises')
vi.resetModules()
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
@@ -23,8 +26,10 @@ describe('scanClaudeUsageFiles', () => {
it('scans transcript files from the configured Claude projects directory', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const transcriptsDir = join(root, '.claude', 'transcripts')
const firstFile = join(projectDir, 'a.jsonl')
const secondFile = join(projectDir, 'b.jsonl')
const transcriptFile = join(transcriptsDir, 'ses_123.jsonl')
await writeFile(
firstFile,
@@ -63,6 +68,22 @@ describe('scanClaudeUsageFiles', () => {
}
})
)
await writeFile(
transcriptFile,
JSON.stringify({
type: 'assistant',
timestamp: '2026-04-11T10:00:00.000Z',
cwd: '/workspace/repo-a/packages/app',
message: {
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 70,
output_tokens: 15,
cache_read_input_tokens: 20
}
}
})
)
vi.resetModules()
vi.doMock('os', async () => ({
@@ -82,10 +103,119 @@ describe('scanClaudeUsageFiles', () => {
expect(result.processedFiles.map((file) => [file.path, file.lineCount])).toEqual([
[firstFile, 2],
[secondFile, 1]
[secondFile, 1],
[transcriptFile, 1]
])
expect(result.sessions.map((session) => session.sessionId)).toEqual(['session-2', 'session-1'])
expect(result.dailyAggregates).toHaveLength(2)
expect(result.sessions.map((session) => session.sessionId)).toEqual([
'ses_123',
'session-2',
'session-1'
])
expect(result.dailyAggregates).toHaveLength(3)
expect(result.dailyAggregates[0]?.projectLabel).toBe('Repo A')
expect(result.dailyAggregates[2]?.projectLabel).toBe('Repo A')
})
it('reuses unchanged transcript projections from the previous scan', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const transcriptFile = join(projectDir, 'session-1.jsonl')
await writeFile(
transcriptFile,
JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
cwd: '/workspace/repo-a',
message: {
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 100,
output_tokens: 20
}
}
})
)
vi.resetModules()
vi.doMock('os', async () => ({
...(await vi.importActual<typeof Os>('os')),
homedir: () => root
}))
const { scanClaudeUsageFiles } = await import('./scanner')
const worktrees = [
{
repoId: 'repo-1',
worktreeId: 'worktree-1',
path: '/workspace/repo-a',
displayName: 'Repo A'
}
]
const first = await scanClaudeUsageFiles(worktrees)
const cachedFile = structuredClone(first.processedFiles[0]!)
cachedFile.sessions[0]!.totalInputTokens = 999
cachedFile.sessions[0]!.locationBreakdown[0]!.inputTokens = 999
cachedFile.dailyAggregates[0]!.inputTokens = 999
const second = await scanClaudeUsageFiles(worktrees, [cachedFile])
expect(second.processedFiles[0]?.sessions[0]?.totalInputTokens).toBe(999)
expect(second.dailyAggregates[0]?.inputTokens).toBe(999)
})
it('canonicalizes repeated cwd paths once per scan file', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const transcriptFile = join(projectDir, 'session-1.jsonl')
const repeatedCwd = '/workspace/repo-a/packages/app'
await writeFile(
transcriptFile,
[1, 2, 3]
.map((index) =>
JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
timestamp: `2026-04-09T10:0${index}:00.000Z`,
cwd: repeatedCwd,
message: {
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 100,
output_tokens: 20
}
}
})
)
.join('\n')
)
const realpathCalls: string[] = []
vi.resetModules()
vi.doMock('os', async () => ({
...(await vi.importActual<typeof Os>('os')),
homedir: () => root
}))
vi.doMock('fs/promises', async () => ({
...(await vi.importActual<typeof FsPromises>('fs/promises')),
realpath: vi.fn(async (pathValue: string) => {
realpathCalls.push(pathValue)
return pathValue
})
}))
const { scanClaudeUsageFiles } = await import('./scanner')
await scanClaudeUsageFiles([
{
repoId: 'repo-1',
worktreeId: 'worktree-1',
path: '/workspace/repo-a',
displayName: 'Repo A'
}
])
expect(realpathCalls.filter((pathValue) => pathValue === repeatedCwd)).toHaveLength(1)
})
})
+101 -1
View File
@@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest'
import { aggregateClaudeUsage, attributeClaudeUsageTurns, parseClaudeUsageRecord } from './scanner'
import {
aggregateClaudeUsage,
attributeClaudeUsageTurns,
parseClaudeUsageFile,
parseClaudeUsageRecord
} from './scanner'
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
describe('parseClaudeUsageRecord', () => {
it('extracts token usage from assistant transcript lines', () => {
@@ -34,6 +42,66 @@ describe('parseClaudeUsageRecord', () => {
cacheWriteTokens: 5
})
})
it('merges duplicate streamed assistant usage by message and request id', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-claude-dedupe-'))
const filePath = join(root, 'session.jsonl')
try {
await writeFile(
filePath,
[
JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
requestId: 'req-1',
message: {
id: 'msg-1',
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 100,
output_tokens: 25,
cache_read_input_tokens: 10,
cache_creation_input_tokens: 5
}
}
}),
JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:01.000Z',
requestId: 'req-1',
message: {
id: 'msg-1',
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 120,
output_tokens: 30,
cache_read_input_tokens: 12,
cache_creation_input_tokens: 7
}
}
})
].join('\n')
)
await expect(parseClaudeUsageFile(filePath)).resolves.toEqual([
{
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
model: 'claude-sonnet-4-6',
cwd: null,
gitBranch: null,
inputTokens: 120,
outputTokens: 30,
cacheReadTokens: 12,
cacheWriteTokens: 7
}
])
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
describe('Claude usage aggregation', () => {
@@ -104,4 +172,36 @@ describe('Claude usage aggregation', () => {
}
])
})
it('attributes nested cwd paths to the containing worktree', async () => {
const attributed = await attributeClaudeUsageTurns(
[
{
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
model: 'claude-sonnet-4-6',
cwd: '/workspace/repo-a/packages/app',
gitBranch: 'feature/a',
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 10,
cacheWriteTokens: 5
}
],
new Map([
[
'/workspace/repo-a',
{
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo-a',
path: '/workspace/repo-a',
displayName: 'Repo A'
}
]
])
)
expect(attributed[0]?.projectKey).toBe('worktree:repo-1::/workspace/repo-a')
expect(attributed[0]?.projectLabel).toBe('Repo A')
})
})
+290 -41
View File
@@ -10,6 +10,7 @@ import type {
ClaudeUsageDailyAggregate,
ClaudeUsageLocationBreakdown,
ClaudeUsageParsedTurn,
ClaudeUsagePersistedFile,
ClaudeUsageProcessedFile,
ClaudeUsageSession
} from './types'
@@ -27,7 +28,11 @@ type ClaudeUsageSourceRecord = {
timestamp?: string
cwd?: string
gitBranch?: string
requestId?: string
isSidechain?: boolean
agentId?: string
message?: {
id?: string
model?: string
usage?: {
input_tokens?: number
@@ -39,8 +44,20 @@ type ClaudeUsageSourceRecord = {
}
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
const CLAUDE_TRANSCRIPTS_DIR = join(homedir(), '.claude', 'transcripts')
const FILE_SCAN_BATCH_SIZE = 4
type ClaudeUsageParsedSourceTurn = ClaudeUsageParsedTurn & {
dedupeKey: string | null
}
type ClaudeUsageWorktreeEntry = [string, ClaudeUsageWorktreeRef]
const sortedWorktreeEntriesByLookup = new WeakMap<
Map<string, ClaudeUsageWorktreeRef>,
ClaudeUsageWorktreeEntry[]
>()
function getDefaultProjectLabel(cwd: string | null): string {
if (!cwd) {
return 'Unknown location'
@@ -66,6 +83,45 @@ function normalizeComparablePath(pathValue: string): string {
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
function isContainedPath(parentPath: string, childPath: string): boolean {
const parent = normalizeComparablePath(parentPath).replace(/\/+$/, '')
const child = normalizeComparablePath(childPath).replace(/\/+$/, '')
return child === parent || child.startsWith(`${parent}/`)
}
function findContainingWorktree(
cwd: string,
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): ClaudeUsageWorktreeRef | null {
const normalizedCwd = normalizeComparablePath(cwd)
const exact = worktreeLookup.get(normalizedCwd)
if (exact) {
return exact
}
for (const [worktreePath, worktree] of getSortedWorktreeEntries(worktreeLookup)) {
if (isContainedPath(worktreePath, normalizedCwd)) {
return worktree
}
}
return null
}
function getSortedWorktreeEntries(
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): ClaudeUsageWorktreeEntry[] {
const cached = sortedWorktreeEntriesByLookup.get(worktreeLookup)
if (cached) {
return cached
}
const sorted = [...worktreeLookup.entries()].sort(
([leftPath], [rightPath]) => rightPath.length - leftPath.length
)
sortedWorktreeEntriesByLookup.set(worktreeLookup, sorted)
return sorted
}
async function yieldToEventLoop(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
@@ -89,11 +145,17 @@ async function walkJsonlFiles(dirPath: string): Promise<string[]> {
}
export async function listClaudeTranscriptFiles(): Promise<string[]> {
try {
return (await walkJsonlFiles(CLAUDE_PROJECTS_DIR)).sort()
} catch {
return []
}
const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR]
const files = await Promise.all(
roots.map(async (root) => {
try {
return await walkJsonlFiles(root)
} catch {
return []
}
})
)
return [...new Set(files.flat())].sort()
}
export async function getProcessedFileInfo(filePath: string): Promise<ClaudeUsageProcessedFile> {
@@ -109,11 +171,69 @@ export async function getProcessedFileInfo(filePath: string): Promise<ClaudeUsag
return {
path: filePath,
mtimeMs: fileStat.mtimeMs,
size: fileStat.size,
lineCount
}
}
export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null {
async function getProcessedFileStat(
filePath: string
): Promise<Omit<ClaudeUsageProcessedFile, 'lineCount'>> {
const fileStat = await stat(filePath)
return {
path: filePath,
mtimeMs: fileStat.mtimeMs,
size: fileStat.size
}
}
function stripClaudeSourceMetadata(turn: ClaudeUsageParsedSourceTurn): ClaudeUsageParsedTurn {
return {
sessionId: turn.sessionId,
timestamp: turn.timestamp,
model: turn.model,
cwd: turn.cwd,
gitBranch: turn.gitBranch,
inputTokens: turn.inputTokens,
outputTokens: turn.outputTokens,
cacheReadTokens: turn.cacheReadTokens,
cacheWriteTokens: turn.cacheWriteTokens
}
}
function dedupeClaudeUsageTurns(turns: ClaudeUsageParsedSourceTurn[]): ClaudeUsageParsedTurn[] {
const dedupeIndexByKey = new Map<string, number>()
const deduped: ClaudeUsageParsedTurn[] = []
for (const turn of turns) {
if (turn.dedupeKey) {
const existingIndex = dedupeIndexByKey.get(turn.dedupeKey)
if (existingIndex !== undefined) {
const existing = deduped[existingIndex]
// Why: Claude Code streams repeated assistant rows with the same
// message/request IDs; later rows can contain more complete usage.
existing.inputTokens = Math.max(existing.inputTokens, turn.inputTokens)
existing.outputTokens = Math.max(existing.outputTokens, turn.outputTokens)
existing.cacheReadTokens = Math.max(existing.cacheReadTokens, turn.cacheReadTokens)
existing.cacheWriteTokens = Math.max(existing.cacheWriteTokens, turn.cacheWriteTokens)
continue
}
}
const stripped = stripClaudeSourceMetadata(turn)
deduped.push(stripped)
if (turn.dedupeKey) {
dedupeIndexByKey.set(turn.dedupeKey, deduped.length - 1)
}
}
return deduped
}
function parseClaudeUsageSourceRecord(
line: string,
fallbackSessionId: string | null = null
): ClaudeUsageParsedSourceTurn | null {
let parsed: ClaudeUsageSourceRecord
try {
parsed = JSON.parse(line) as ClaudeUsageSourceRecord
@@ -124,7 +244,8 @@ export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | nu
if (parsed.type !== 'assistant') {
return null
}
if (!parsed.sessionId || !parsed.timestamp) {
const sessionId = parsed.sessionId ?? fallbackSessionId
if (!sessionId || !parsed.timestamp) {
return null
}
@@ -139,11 +260,13 @@ export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | nu
}
return {
sessionId: parsed.sessionId,
sessionId,
timestamp: parsed.timestamp,
model: parsed.message?.model ?? null,
cwd: parsed.cwd ?? null,
gitBranch: parsed.gitBranch ?? null,
dedupeKey:
parsed.message?.id && parsed.requestId ? `${parsed.message.id}:${parsed.requestId}` : null,
inputTokens,
outputTokens,
cacheReadTokens,
@@ -151,21 +274,27 @@ export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | nu
}
}
export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null {
const parsed = parseClaudeUsageSourceRecord(line)
return parsed ? stripClaudeSourceMetadata(parsed) : null
}
export async function parseClaudeUsageFile(filePath: string): Promise<ClaudeUsageParsedTurn[]> {
const turns: ClaudeUsageParsedTurn[] = []
const turns: ClaudeUsageParsedSourceTurn[] = []
const fallbackSessionId = basename(filePath, '.jsonl')
const lines = createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const parsed = parseClaudeUsageRecord(line)
const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId)
if (parsed) {
turns.push(parsed)
}
}
return turns
return dedupeClaudeUsageTurns(turns)
}
async function readClaudeUsageScanFile(filePath: string): Promise<{
@@ -174,7 +303,8 @@ async function readClaudeUsageScanFile(filePath: string): Promise<{
}> {
const fileStat = await stat(filePath)
let lineCount = 0
const turns: ClaudeUsageParsedTurn[] = []
const turns: ClaudeUsageParsedSourceTurn[] = []
const fallbackSessionId = basename(filePath, '.jsonl')
const lines = createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
@@ -182,7 +312,7 @@ async function readClaudeUsageScanFile(filePath: string): Promise<{
for await (const line of lines) {
lineCount++
const parsed = parseClaudeUsageRecord(line)
const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId)
if (parsed) {
turns.push(parsed)
}
@@ -192,9 +322,10 @@ async function readClaudeUsageScanFile(filePath: string): Promise<{
processedFile: {
path: filePath,
mtimeMs: fileStat.mtimeMs,
size: fileStat.size,
lineCount
},
turns
turns: dedupeClaudeUsageTurns(turns)
}
}
@@ -224,6 +355,7 @@ export async function attributeClaudeUsageTurns(
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): Promise<ClaudeUsageAttributedTurn[]> {
const attributed: ClaudeUsageAttributedTurn[] = []
const canonicalCwdByPath = new Map<string, string>()
for (const turn of turns) {
const day = localDayFromTimestamp(turn.timestamp)
@@ -237,7 +369,14 @@ export async function attributeClaudeUsageTurns(
let projectLabel = getDefaultProjectLabel(turn.cwd)
if (turn.cwd) {
const worktree = worktreeLookup.get(await canonicalizePath(turn.cwd))
let canonicalCwd = canonicalCwdByPath.get(turn.cwd)
if (canonicalCwd === undefined) {
// Why: Claude transcripts repeat the same cwd for many consecutive
// turns. Cache realpath work so attribution scales with unique paths.
canonicalCwd = await canonicalizePath(turn.cwd)
canonicalCwdByPath.set(turn.cwd, canonicalCwd)
}
const worktree = findContainingWorktree(canonicalCwd, worktreeLookup)
if (worktree) {
repoId = worktree.repoId
worktreeId = worktree.worktreeId
@@ -261,6 +400,90 @@ export async function attributeClaudeUsageTurns(
return attributed
}
function mergeClaudeSessions(
target: Map<string, ClaudeUsageSession>,
sessions: ClaudeUsageSession[]
): void {
for (const session of sessions) {
const existing = target.get(session.sessionId)
if (!existing) {
target.set(session.sessionId, structuredClone(session))
continue
}
if (session.firstTimestamp < existing.firstTimestamp) {
existing.firstTimestamp = session.firstTimestamp
}
if (session.lastTimestamp > existing.lastTimestamp) {
existing.lastTimestamp = session.lastTimestamp
existing.lastCwd = session.lastCwd
existing.lastGitBranch = session.lastGitBranch
}
existing.model = session.model ?? existing.model
existing.turnCount += session.turnCount
existing.totalInputTokens += session.totalInputTokens
existing.totalOutputTokens += session.totalOutputTokens
existing.totalCacheReadTokens += session.totalCacheReadTokens
existing.totalCacheWriteTokens += session.totalCacheWriteTokens
for (const location of session.locationBreakdown) {
const existingLocation =
existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ??
null
if (existingLocation) {
existingLocation.turnCount += location.turnCount
existingLocation.inputTokens += location.inputTokens
existingLocation.outputTokens += location.outputTokens
existingLocation.cacheReadTokens += location.cacheReadTokens
existingLocation.cacheWriteTokens += location.cacheWriteTokens
} else {
existing.locationBreakdown.push({ ...location })
}
}
}
}
function mergeClaudeDailyAggregates(
target: Map<string, ClaudeUsageDailyAggregate>,
dailyAggregates: ClaudeUsageDailyAggregate[]
): void {
for (const aggregate of dailyAggregates) {
const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::')
const existing = target.get(key)
if (!existing) {
target.set(key, { ...aggregate })
continue
}
existing.turnCount += aggregate.turnCount
existing.zeroCacheReadTurnCount += aggregate.zeroCacheReadTurnCount
existing.inputTokens += aggregate.inputTokens
existing.outputTokens += aggregate.outputTokens
existing.cacheReadTokens += aggregate.cacheReadTokens
existing.cacheWriteTokens += aggregate.cacheWriteTokens
}
}
function finalizeClaudeSessions(
sessionsById: Map<string, ClaudeUsageSession>
): ClaudeUsageSession[] {
for (const session of sessionsById.values()) {
session.locationBreakdown.sort((left, right) => {
const leftTotal = left.inputTokens + left.outputTokens
const rightTotal = right.inputTokens + right.outputTokens
return rightTotal - leftTotal
})
const primaryLocation = session.locationBreakdown[0] ?? null
if (primaryLocation) {
session.primaryRepoId = primaryLocation.repoId
session.primaryWorktreeId = primaryLocation.worktreeId
}
}
return [...sessionsById.values()].sort((left, right) =>
right.lastTimestamp.localeCompare(left.lastTimestamp)
)
}
export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
@@ -356,23 +579,8 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
}
}
for (const session of sessionsById.values()) {
session.locationBreakdown.sort((left, right) => {
const leftTotal = left.inputTokens + left.outputTokens
const rightTotal = right.inputTokens + right.outputTokens
return rightTotal - leftTotal
})
const primaryLocation = session.locationBreakdown[0] ?? null
if (primaryLocation) {
session.primaryRepoId = primaryLocation.repoId
session.primaryWorktreeId = primaryLocation.worktreeId
}
}
return {
sessions: [...sessionsById.values()].sort((left, right) =>
right.lastTimestamp.localeCompare(left.lastTimestamp)
),
sessions: finalizeClaudeSessions(sessionsById),
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
left.day === right.day
? left.projectLabel.localeCompare(right.projectLabel)
@@ -381,22 +589,55 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
}
}
export async function scanClaudeUsageFiles(worktrees: ClaudeUsageWorktreeRef[]): Promise<{
processedFiles: ClaudeUsageProcessedFile[]
async function parseClaudeUsagePersistedFile(
filePath: string,
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): Promise<ClaudeUsagePersistedFile> {
const { processedFile, turns } = await readClaudeUsageScanFile(filePath)
const attributed = await attributeClaudeUsageTurns(turns, worktreeLookup)
return {
...processedFile,
...aggregateClaudeUsage(attributed)
}
}
export async function scanClaudeUsageFiles(
worktrees: ClaudeUsageWorktreeRef[],
previousProcessedFiles: ClaudeUsagePersistedFile[] = []
): Promise<{
processedFiles: ClaudeUsagePersistedFile[]
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
}> {
const files = await listClaudeTranscriptFiles()
const processedFiles: ClaudeUsageProcessedFile[] = []
const allTurns: ClaudeUsageParsedTurn[] = []
const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file]))
const processedFiles: ClaudeUsagePersistedFile[] = []
const worktreeLookup = await buildWorktreeLookup(worktrees)
const sessionsById = new Map<string, ClaudeUsageSession>()
const dailyByKey = new Map<string, ClaudeUsageDailyAggregate>()
for (let index = 0; index < files.length; index += FILE_SCAN_BATCH_SIZE) {
const batch = files.slice(index, index + FILE_SCAN_BATCH_SIZE)
const results = await Promise.all(batch.map((filePath) => readClaudeUsageScanFile(filePath)))
for (const result of results) {
processedFiles.push(result.processedFile)
allTurns.push(...result.turns)
const results = await Promise.all(
batch.map(async (filePath) => {
const fileInfo = await getProcessedFileStat(filePath)
const previous = previousByPath.get(filePath)
// Why: Claude histories can be gigabytes. Unchanged files should pay
// only stat cost on refresh while preserving exactly the old projection.
const canReuse =
previous &&
previous.mtimeMs === fileInfo.mtimeMs &&
previous.size === fileInfo.size &&
Array.isArray(previous.sessions) &&
Array.isArray(previous.dailyAggregates)
return canReuse ? previous : parseClaudeUsagePersistedFile(filePath, worktreeLookup)
})
)
for (const processed of results) {
processedFiles.push(processed)
mergeClaudeSessions(sessionsById, processed.sessions)
mergeClaudeDailyAggregates(dailyByKey, processed.dailyAggregates)
}
// Why: transcript scans run in Electron's main process. Small parallel
// batches cut independent file I/O without letting Settings stay blocked.
@@ -405,10 +646,14 @@ export async function scanClaudeUsageFiles(worktrees: ClaudeUsageWorktreeRef[]):
}
}
const attributed = await attributeClaudeUsageTurns(allTurns, worktreeLookup)
return {
processedFiles,
...aggregateClaudeUsage(attributed)
sessions: finalizeClaudeSessions(sessionsById),
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
left.day === right.day
? left.projectLabel.localeCompare(right.projectLabel)
: left.day.localeCompare(right.day)
)
}
}
@@ -416,6 +661,10 @@ export function getClaudeProjectsDirectory(): string {
return CLAUDE_PROJECTS_DIR
}
export function getClaudeTranscriptsDirectory(): string {
return CLAUDE_TRANSCRIPTS_DIR
}
export function createWorktreeRefs(
repos: Repo[],
worktreesByRepo: Map<string, { path: string; worktreeId: string; displayName: string }[]>
+26
View File
@@ -17,6 +17,7 @@ function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): Claude
;(store as unknown as { state: ClaudeUsagePersistedState }).state = {
schemaVersion: 1,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
dailyAggregates: [],
@@ -231,4 +232,29 @@ describe('ClaudeUsageStore', () => {
expect(summary.estimatedCostUsd).toBeCloseTo(110.25)
})
it('prices Sonnet long-context usage with threshold rates', async () => {
const store = createStoreWithState({
dailyAggregates: [
{
day: '2026-04-09',
model: 'claude-sonnet-4-6',
projectKey: 'worktree:repo-1::/workspace/repo-a',
projectLabel: 'Repo A',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo-a',
turnCount: 1,
zeroCacheReadTurnCount: 0,
inputTokens: 300_000,
outputTokens: 300_000,
cacheReadTokens: 300_000,
cacheWriteTokens: 300_000
}
]
})
const summary = await store.getSummary('orca', '30d')
expect(summary.estimatedCostUsd).toBeCloseTo(8.07)
})
})
+146 -18
View File
@@ -13,38 +13,87 @@ import type {
ClaudeUsageSummary
} from '../../shared/claude-usage-types'
import type { Store } from '../persistence'
import { loadKnownUsageWorktreesByRepo } from '../usage-worktree-metadata'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { ClaudeUsagePersistedState } from './types'
import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
const SCHEMA_VERSION = 1
const SCHEMA_VERSION = 3
const STALE_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.
let _claudeUsageFile: string | null = null
const MODEL_PRICING: Record<
string,
{ input: number; output: number; cacheRead: number; cacheWrite: number }
> = {
type ClaudeModelPricing = {
input: number
output: number
cacheRead: number
cacheWrite: number
thresholdTokens?: number
inputAboveThreshold?: number
outputAboveThreshold?: number
cacheReadAboveThreshold?: number
cacheWriteAboveThreshold?: number
}
const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000
const SONNET_LONG_CONTEXT_PRICING = {
thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS,
inputAboveThreshold: 6,
outputAboveThreshold: 22.5,
cacheReadAboveThreshold: 0.6,
cacheWriteAboveThreshold: 7.5
} satisfies Partial<ClaudeModelPricing>
const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-4-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-4': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-4-6': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-4-5': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-4': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 }
}
const MODEL_ALIASES: Record<string, string> = {
model_placeholder_m26: 'claude-opus-4-6',
model_placeholder_m35: 'claude-sonnet-4-6',
'claude-opus-4.6': 'claude-opus-4-6',
'claude-sonnet-4.6': 'claude-sonnet-4-6',
'claude-opus-4.6-thinking': 'claude-opus-4-6',
'claude-sonnet-4.6-thinking': 'claude-sonnet-4-6',
'claude-opus-4-6-thinking': 'claude-opus-4-6',
'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6'
}
function getDefaultState(): ClaudeUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
dailyAggregates: [],
@@ -72,7 +121,14 @@ function normalizeModelForPricing(model: string | null): string | null {
if (!model) {
return null
}
const lower = model.toLowerCase()
const lower = model
.toLowerCase()
.trim()
.replace(/^anthropic[/:]/, '')
const alias = MODEL_ALIASES[lower]
if (alias) {
return alias
}
if (lower.includes('opus-4-7')) {
return 'claude-opus-4-7'
}
@@ -103,8 +159,13 @@ function normalizeModelForPricing(model: string | null): string | null {
// Why: legacy version-first IDs like `claude-3-5-sonnet-20241022` are still
// present in historical Claude Code/SDK logs read off disk. Match them so
// their cost is not silently dropped from the breakdown.
if (lower.includes('3-5-sonnet') || lower.includes('3.5-sonnet')) {
return 'claude-sonnet-3-7'
if (
lower.includes('sonnet-3-5') ||
lower.includes('sonnet-3.5') ||
lower.includes('3-5-sonnet') ||
lower.includes('3.5-sonnet')
) {
return 'claude-sonnet-3-5'
}
if (lower.includes('haiku-4-5')) {
return 'claude-haiku-4-5'
@@ -121,6 +182,20 @@ function normalizeModelForPricing(model: string | null): string | null {
return null
}
function calculateTieredCost(
tokens: number,
basePrice: number,
abovePrice?: number,
threshold?: number
): number {
if (threshold === undefined || abovePrice === undefined) {
return tokens * basePrice
}
const belowTokens = Math.min(tokens, threshold)
const aboveTokens = Math.max(tokens - threshold, 0)
return belowTokens * basePrice + aboveTokens * abovePrice
}
function estimateCostUsd(
model: string | null,
inputTokens: number,
@@ -134,10 +209,30 @@ function estimateCostUsd(
}
const pricing = MODEL_PRICING[normalized]
return (
(inputTokens * pricing.input +
outputTokens * pricing.output +
cacheReadTokens * pricing.cacheRead +
cacheWriteTokens * pricing.cacheWrite) /
(calculateTieredCost(
inputTokens,
pricing.input,
pricing.inputAboveThreshold,
pricing.thresholdTokens
) +
calculateTieredCost(
outputTokens,
pricing.output,
pricing.outputAboveThreshold,
pricing.thresholdTokens
) +
calculateTieredCost(
cacheReadTokens,
pricing.cacheRead,
pricing.cacheReadAboveThreshold,
pricing.thresholdTokens
) +
calculateTieredCost(
cacheWriteTokens,
pricing.cacheWrite,
pricing.cacheWriteAboveThreshold,
pricing.thresholdTokens
)) /
1_000_000
)
}
@@ -167,6 +262,22 @@ function getLocalDay(timestamp: string): string | null {
return `${year}-${month}-${day}`
}
function getWorktreeFingerprint(worktreesByRepo: Map<string, UsageWorktreeRef[]>): string {
const rows = [...worktreesByRepo.entries()]
.flatMap(([repoId, worktrees]) =>
worktrees.map((worktree) =>
JSON.stringify({
repoId,
worktreeId: worktree.worktreeId,
path: worktree.path,
displayName: worktree.displayName
})
)
)
.sort()
return JSON.stringify(rows)
}
export class ClaudeUsageStore {
private state: ClaudeUsagePersistedState
private readonly store: Store
@@ -184,6 +295,11 @@ export class ClaudeUsageStore {
return getDefaultState()
}
const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState
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()
}
return {
...getDefaultState(),
...parsed,
@@ -233,9 +349,10 @@ export class ClaudeUsageStore {
if (!this.state.scanState.enabled) {
return this.getScanState()
}
const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint()
if (!force && this.state.scanState.lastScanCompletedAt) {
const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt
if (ageMs < STALE_MS) {
if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) {
return this.getScanState()
}
}
@@ -257,10 +374,15 @@ export class ClaudeUsageStore {
try {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
const result = await scanClaudeUsageFiles(createWorktreeRefs(repos, worktreesByRepo))
const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo)
const result = await scanClaudeUsageFiles(
createWorktreeRefs(repos, worktreesByRepo),
this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : []
)
this.state.processedFiles = result.processedFiles
this.state.sessions = result.sessions
this.state.dailyAggregates = result.dailyAggregates
this.state.worktreeFingerprint = worktreeFingerprint
this.state.scanState.lastScanCompletedAt = Date.now()
this.state.scanState.lastScanError = null
this.writeToDisk()
@@ -532,4 +654,10 @@ export class ClaudeUsageStore {
return true
})
}
private async getCurrentWorktreeFingerprint(): Promise<string> {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
return getWorktreeFingerprint(worktreesByRepo)
}
}
+8 -1
View File
@@ -1,6 +1,7 @@
export type ClaudeUsageProcessedFile = {
path: string
mtimeMs: number
size: number
lineCount: number
}
@@ -50,7 +51,8 @@ export type ClaudeUsageDailyAggregate = {
export type ClaudeUsagePersistedState = {
schemaVersion: number
processedFiles: ClaudeUsageProcessedFile[]
worktreeFingerprint: string | null
processedFiles: ClaudeUsagePersistedFile[]
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
scanState: {
@@ -61,6 +63,11 @@ export type ClaudeUsagePersistedState = {
}
}
export type ClaudeUsagePersistedFile = ClaudeUsageProcessedFile & {
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
}
export type ClaudeUsageParsedTurn = {
sessionId: string
timestamp: string
+166
View File
@@ -0,0 +1,166 @@
// Why: locks in the remote-install contract so a refactor cannot silently
// drift the produced settings.json shape, the wrapper-quoted command path,
// or the script body that lands on the remote box. Local install behavior
// is exercised through `installer-utils.test.ts` and the per-CLI status
// audit; this file covers ONLY the SFTP-backed path added in commit #8.
import { vi, describe, expect, it } from 'vitest'
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/userData'
}
}))
import type { SFTPWrapper } from 'ssh2'
import { ClaudeHookService } from './hook-service'
type FakeFs = {
files: Map<string, string>
dirs: Set<string>
modes: Map<string, number>
}
function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } {
const fs: FakeFs = {
files: new Map(),
dirs: new Set(['/']),
modes: new Map()
}
const noEntryError = (path: string): { code: number; message: string } => ({
code: 2,
message: `ENOENT ${path}`
})
const fakeStats = (mode: number): { mode: number } => ({ mode })
const sftp = {
readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
const v = fs.files.get(path)
if (v === undefined) {
cb(noEntryError(path))
return
}
cb(null, v)
},
writeFile: (
path: string,
content: string,
options: string | { mode?: number },
cb: (err: unknown) => void
): void => {
fs.files.set(path, content)
if (typeof options !== 'string' && options.mode !== undefined) {
fs.modes.set(path, options.mode)
}
cb(null)
},
rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
const v = fs.files.get(src)
if (v === undefined) {
cb(noEntryError(src))
return
}
fs.files.set(dst, v)
fs.files.delete(src)
const mode = fs.modes.get(src)
if (mode !== undefined) {
fs.modes.set(dst, mode)
fs.modes.delete(src)
}
cb(null)
},
unlink: (path: string, cb: (err: unknown) => void): void => {
fs.files.delete(path)
fs.modes.delete(path)
cb(null)
},
chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
fs.modes.set(path, mode)
cb(null)
},
stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
if (!fs.files.has(path)) {
cb(noEntryError(path))
return
}
cb(null, fakeStats(fs.modes.get(path) ?? 0o100644))
},
readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
if (fs.dirs.has(path)) {
cb(null, [])
return
}
cb(noEntryError(path))
},
mkdir: (path: string, cb: (err: unknown) => void): void => {
fs.dirs.add(path)
cb(null)
}
} as unknown as SFTPWrapper
return { sftp, fs }
}
describe('ClaudeHookService.installRemote', () => {
it('writes settings.json + managed script under the remote $HOME', async () => {
const svc = new ClaudeHookService()
const { sftp, fs } = createFakeSftp()
const status = await svc.installRemote(sftp, '/home/dev')
expect(status.state).toBe('installed')
expect(status.configPath).toBe('/home/dev/.claude/settings.json')
const settings = fs.files.get('/home/dev/.claude/settings.json')
expect(settings).toBeTruthy()
const parsed = JSON.parse(settings!)
// Why: every load-bearing event must be present and point at the
// remote-shaped script path with the `if [ -x ... ]; then ... fi`
// wrapper applied. Drift in any of these is a real bug — Claude
// Code rejects unknown shapes silently and the agent-hooks pipeline
// goes dark.
for (const event of [
'UserPromptSubmit',
'Stop',
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'PermissionRequest'
]) {
expect(parsed.hooks[event]).toBeTruthy()
const cmd = parsed.hooks[event][0].hooks[0].command as string
expect(cmd).toContain('/home/dev/.orca/agent-hooks/claude-hook.sh')
expect(cmd).toMatch(/^if \[ -x /)
}
// Managed script body
expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toContain('#!/bin/sh')
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
})
it('reports parse error when remote settings.json is malformed', async () => {
const svc = new ClaudeHookService()
const { sftp, fs } = createFakeSftp()
fs.files.set('/home/dev/.claude/settings.json', 'not json')
const status = await svc.installRemote(sftp, '/home/dev')
expect(status.state).toBe('error')
expect(status.detail).toContain('Could not parse')
})
it('preserves user-authored hook entries on a fresh install', async () => {
const svc = new ClaudeHookService()
const { sftp, fs } = createFakeSftp()
fs.files.set(
'/home/dev/.claude/settings.json',
JSON.stringify({
hooks: {
Stop: [
{
hooks: [{ type: 'command', command: '/usr/local/bin/my-user-hook' }]
}
]
}
})
)
await svc.installRemote(sftp, '/home/dev')
const parsed = JSON.parse(fs.files.get('/home/dev/.claude/settings.json')!)
// Original user-authored entry survives alongside the new managed entry.
const stopDefs = parsed.hooks.Stop as { hooks: { command: string }[] }[]
const userCmds = stopDefs.flatMap((d) => d.hooks.map((h) => h.command))
expect(userCmds).toContain('/usr/local/bin/my-user-hook')
expect(userCmds.some((c) => c.includes('claude-hook.sh'))).toBe(true)
})
})
+85 -2
View File
@@ -1,5 +1,6 @@
import { homedir } from 'os'
import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
createManagedCommandMatcher,
@@ -11,6 +12,11 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
const CLAUDE_EVENTS = [
{ eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } },
@@ -61,8 +67,8 @@ function getManagedCommand(scriptPath: string): string {
return wrapPosixHookCommand(scriptPath)
}
function getManagedScript(): string {
if (process.platform === 'win32') {
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
@@ -215,6 +221,83 @@ export class ClaudeHookService {
return this.getStatus()
}
// Why: install Orca's managed Claude hooks on the remote box rather than
// the local Mac/Linux machine. Caller passes the user's SFTP handle from
// the SshConnection plus the resolved remote `$HOME` (used to compute
// ~/.claude/settings.json on the target). POSIX-only by design — see
// docs/design/agent-status-over-ssh.md §3 / §6 (Windows-remote deferred).
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
// Why: remote-Windows is out of scope for v1 — we ship POSIX-shaped paths
// (`~/.claude/settings.json`) and a `.sh` managed script body. The remote
// platform is gated by the relay's capability RPC at a higher layer; we
// cannot detect it from `process.platform` here (that's the local box).
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.claude/settings.json`
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/claude-hook.sh`
// Why: SFTP reads/writes fail far more often than local fs (network drops,
// EACCES on remote dirs, disk full, channel closed). Wrap the entire
// install flow in try/catch so a transient I/O failure surfaces as a
// structured `state: 'error'` result for the UI, not an unstructured
// rejection the caller has to remember to handle. A `null` config
// specifically means "file present but unparseable" — keep that branch
// distinct so the user sees an actionable message.
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
return {
agent: 'claude',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: 'Could not parse remote Claude settings.json'
}
}
// Why: the POSIX wrapper is identical regardless of where the script
// lands; only the path differs. Reuse the same wrapper helper.
const command = wrapPosixHookCommand(remoteScriptPath)
const nextHooks = { ...config.hooks }
const isManagedCommand = createManagedCommandMatcher('claude-hook.sh')
for (const event of CLAUDE_EVENTS) {
const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
hooks: [{ type: 'command', command }]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
config.hooks = nextHooks
// Why: write the script first, then the settings — settings.json
// referencing a missing script body would fire `command not found` on
// every tool call until the user re-runs install. Doing it in this
// order means a partial-failure mid-install at worst leaves the user
// with a working script no settings.json points at (a no-op), instead
// of broken settings.json.
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
// running on Windows; never derive remote script syntax from local OS.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
return {
agent: 'claude',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: null
}
} catch (err) {
return {
agent: 'claude',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
}
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
const config = readHooksJson(configPath)
+19
View File
@@ -0,0 +1,19 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const builderConfig = require('../../../config/electron-builder.config.cjs') as {
asarUnpack?: string[]
}
describe('packaged CLI assets', () => {
it('unpacks runtime dependencies used before Electron asar integration is available', () => {
expect(builderConfig.asarUnpack).toEqual(
expect.arrayContaining([
'node_modules/ws/**',
'node_modules/tweetnacl/**',
'node_modules/zod/**'
])
)
})
})
@@ -42,6 +42,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
editorAutoSave: false,
editorAutoSaveDelayMs: 1000,
editorMinimapEnabled: false,
markdownReviewToolsEnabled: true,
terminalFontSize: 14,
terminalFontFamily: 'JetBrains Mono',
terminalFontWeight: 500,
@@ -92,12 +93,14 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
visibleTaskProviders: ['github', 'gitlab', 'linear'],
defaultRepoSelection: null,
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
opencodeWorkspaceId: '',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalMobile: false,
+3
View File
@@ -35,6 +35,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
editorAutoSave: false,
editorAutoSaveDelayMs: 1000,
editorMinimapEnabled: false,
markdownReviewToolsEnabled: true,
terminalFontSize: 14,
terminalFontFamily: 'JetBrains Mono',
terminalFontWeight: 500,
@@ -85,12 +86,14 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
visibleTaskProviders: ['github', 'gitlab', 'linear'],
defaultRepoSelection: null,
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
opencodeWorkspaceId: '',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalMobile: false,
+6 -3
View File
@@ -121,7 +121,10 @@ function getVersionManagerDirectories(
return directories
}
function resolveCommand(commandName: string, options: ResolveCommandOptions = {}): string {
export function resolveCliCommand(
commandName: string,
options: ResolveCommandOptions = {}
): string {
const platform = options.platform ?? process.platform
const executableNames = getExecutableNames(platform, commandName)
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
@@ -139,11 +142,11 @@ function resolveCommand(commandName: string, options: ResolveCommandOptions = {}
}
export function resolveCodexCommand(options: ResolveCommandOptions = {}): string {
return resolveCommand('codex', options)
return resolveCliCommand('codex', options)
}
export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string {
return resolveCommand('claude', options)
return resolveCliCommand('claude', options)
}
// Why: GUI-launched Electron apps inherit a minimal PATH that excludes Node
+48 -3
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { attributeCodexUsageEvent, parseCodexUsageRecord } from './scanner'
describe('parseCodexUsageRecord', () => {
it('uses cumulative totals to avoid double-counting repeated token snapshots', () => {
it('uses token totals only as a duplicate baseline', () => {
const context = {
sessionId: 'session-1',
sessionCwd: null,
@@ -100,7 +100,7 @@ describe('parseCodexUsageRecord', () => {
expect(duplicate).toBeNull()
})
it('falls back to inferred gpt-5 pricing when model metadata is missing', () => {
it('preserves unknown model metadata instead of assigning fallback pricing', () => {
const context = {
sessionId: 'session-1',
sessionCwd: '/workspace/repo',
@@ -129,9 +129,54 @@ describe('parseCodexUsageRecord', () => {
context
)
expect(parsed?.model).toBe('gpt-5')
expect(parsed?.model).toBeNull()
expect(parsed?.hasInferredPricing).toBe(true)
})
it('uses last token usage for the first resumed-session event', () => {
const context = {
sessionId: 'session-1',
sessionCwd: '/workspace/repo',
currentCwd: '/workspace/repo',
currentModel: 'gpt-5.5',
previousTotals: null
}
const parsed = parseCodexUsageRecord(
JSON.stringify({
timestamp: '2026-04-09T10:00:00.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: {
total_token_usage: {
input_tokens: 50_000,
cached_input_tokens: 40_000,
output_tokens: 5_000,
reasoning_output_tokens: 1_000,
total_tokens: 55_000
},
last_token_usage: {
input_tokens: 2_000,
cached_input_tokens: 1_500,
output_tokens: 500,
reasoning_output_tokens: 100,
total_tokens: 2_500
}
}
}
}),
context
)
expect(parsed).toMatchObject({
inputTokens: 2_000,
cachedInputTokens: 1_500,
outputTokens: 500,
reasoningOutputTokens: 100,
totalTokens: 2_500
})
})
})
describe('attributeCodexUsageEvent', () => {

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