Add Orca runtime CLI and bundled install support (#273)

This commit is contained in:
Jinwoo Hong 2026-04-03 19:50:28 -04:00 committed by GitHub
parent ccc1fa7b8b
commit 182ba156dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 8725 additions and 76 deletions

View File

@ -24,7 +24,6 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
run_install: false
- name: Install dependencies

View File

@ -49,7 +49,6 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
run_install: false
- name: Install dependencies

21
docs/README.md Normal file
View File

@ -0,0 +1,21 @@
# Orca CLI Docs
Keep this folder focused on the durable references for Orca's public CLI and runtime model.
## Keep These Docs
- `orca-cli-focused-v1-status.md`
- What is actually implemented and intentionally in scope now.
- `orca-cli-v1-spec.md`
- The public CLI contract, selector grammar, JSON envelope, and command semantics.
- `orca-runtime-layer-design.md`
- Why the runtime layer exists and which boundaries it owns.
- `orca-cli-bundled-distribution.md`
- How the bundled desktop-app distribution and PATH registration model works.
## Why The Folder Is Small
Earlier design and implementation work produced several planning and evaluation docs.
Those were useful while the feature was taking shape, but they were intentionally removed
once the implementation converged so future readers are not forced to choose between
multiple overlapping sources of truth.

View File

@ -0,0 +1,189 @@
# Orca CLI Bundled Distribution
## Goal
Ship `orca` as a companion CLI that is bundled with the Orca desktop app.
`orca` will **not** be separately distributed on npm for the initial public release.
## Product Direction
The CLI is version-coupled to the running Orca app because it depends on the app's local runtime RPC contract.
That means the primary user story should be:
1. Install Orca desktop.
2. Register the `orca` command using the platform-native path:
- macOS: from Orca Settings, like VS Code's shell-command install
- Linux package builds: from package install scripts
- Windows installer builds: from installer-managed PATH registration
3. The user can run `orca ...` from any terminal while Orca is running.
This is closer to VS Code's platform-specific model than to a standalone npm package:
- the app is the primary product
- the CLI is an app capability
- CLI installation is explicit and opt-in
- version drift between app and CLI is minimized
## UX
Add a Settings section for CLI installation, likely under Advanced or Developer.
This Settings UI is primarily for macOS and for status/help across platforms.
Suggested UX:
- Setting label: `Command line interface`
- Description: `Allow terminal tools and coding agents to interact with this running Orca app.`
- Toggle:
- off: CLI not registered
- on: Orca prompts to install/register `orca` where that is app-managed
When toggled on for the first time, show a modal like:
- Title: `Set up CLI to work in the terminal`
- Body: `Register "orca" in PATH to enable accessing the "orca" command anywhere from your terminal.`
- Actions:
- `Cancel`
- `Register`
Platform note:
- on macOS, registration may require an administrator prompt because Orca installs `/usr/local/bin/orca` as a symlink to the app-bundled launcher, following the same general pattern VS Code uses for `code`
- on Windows and Linux package builds, registration should prefer installer/package integration over post-install GUI mutation
After success:
- show the installed path
- provide `Reinstall`
- provide `Remove from PATH`
- optionally provide `Copy setup instructions`
If installation fails:
- show the exact install location
- show the exact shell snippet or manual step needed
## Distribution Model
The packaged app should contain the CLI artifact and launcher files in stable internal locations.
Registration should follow the verified VS Code model by platform:
- macOS: app-driven shell command install
- Linux package builds: package-managed symlink
- Windows installer builds: installer-managed PATH registration
Why:
- simpler macOS story
- clearer user consent where the app owns registration
- more robust Linux/Windows behavior by using package/installer hooks
- keeps the CLI tied to the installed app version
## Installation Strategy
### macOS
Follow the VS Code pattern:
- ship an app-bundled launcher script
- install `/usr/local/bin/orca` as a symlink to that launcher
- if needed, prompt for elevation explicitly from the app
This avoids shell rc editing and gives a stable command location.
### Linux
For package-managed builds, follow the VS Code pattern:
- ship an app-bundled launcher script
- install `/usr/bin/orca` from the package as a symlink to that launcher
For AppImage and other non-package-managed distributions:
- do not assume a robust global PATH install exists
- either fall back to a user-level wrapper with manual instructions
- or disable CLI install in v1 if path stability is not good enough
### Windows
Follow the VS Code pattern:
- ship `orca.cmd` and related launcher files under `<install dir>\\bin`
- let the installer add `<install dir>\\bin` to PATH
- optionally register Windows App Paths for Explorer/address bar launching
This is more robust than trying to add PATH from the running GUI app after install.
## Runtime Expectations
The bundled `orca` command remains a thin client:
- it reads Orca runtime metadata
- it connects to the local Orca runtime endpoint
- it fails clearly if Orca is not running or is incompatible
Runtime startup rules:
- ordinary `orca ...` commands do not auto-launch Orca
- `orca open` explicitly launches Orca and waits for the runtime
- the CLI must detect stale runtime metadata before trusting a local runtime
- the CLI should only proceed once it observes a healthy current runtime, not just the existence of a metadata file
- `orca open` should be idempotent and cheap when Orca is already running
Error and preflight rules:
- when the runtime is missing, ordinary commands should explicitly tell the user to run `orca open`
- `orca status --json` should be the primary preflight command for agents and scripts
- `orca status --json` should distinguish:
- app not running
- app starting
- runtime reachable
- runtime reachable but terminal graph not ready
This design does not turn `orca` into a standalone daemon or independently useful tool.
The Orca desktop app remains the runtime owner even when the CLI launches it on demand.
The Orca desktop app remains the runtime owner, and `orca open` is the explicit way to start it from the CLI.
## Compatibility Rules
Because the CLI is bundled with the app:
- CLI version should match app version
- `orca version` should report both CLI and runtime compatibility details
- incompatible runtime versions should fail with a precise error
This is one of the main reasons not to ship npm-first.
## Security Notes
Bundling the CLI with the app does not change the runtime security model.
The important properties remain:
- local-only IPC
- runtime auth token
- user-scoped metadata and endpoint permissions
- no remote network listener by default
The launcher registration should only point to the bundled CLI. It should not expose any extra background service.
## Non-Goals
For the initial version:
- no separate npm distribution
- no remote CLI-to-Orca connectivity
- no standalone daemon mode independent of the Orca app
## Recommended First Slice
1. Bundle the CLI artifact and launcher files into packaged app builds.
2. macOS: add a Settings toggle and modal for shell-command registration.
3. Linux package builds: register `/usr/bin/orca` from packaging.
4. Windows installer builds: register `<install dir>\\bin` on PATH.
5. Show install/status/help flows in Settings.
This is the smallest coherent implementation that matches the desired product story.

View File

@ -0,0 +1,103 @@
# Orca CLI Focused V1 Status
## Purpose
This document records the focused Orca CLI v1 that is now implemented.
The broader design docs still describe a larger eventual CLI surface. This file exists so maintainers can see:
- what is actually shipped now
- what has been intentionally deferred
- the one remaining gap if we want to call the worktree/terminal surface fully complete
## Focused V1 Goal
The focused v1 CLI is intentionally narrow.
It optimizes for Orca's core differentiator:
- managing parallel worktrees from a running Orca editor
- discovering live terminals in those worktrees
- reading and replying to those terminals from an agent
This keeps the public surface centered on Orca's orchestration value instead of reimplementing every editor-adjacent capability in the first CLI release.
## Implemented Now
The following commands are implemented against the running Orca app:
- `orca status`
- `orca repo list`
- `orca repo add`
- `orca repo show`
- `orca repo set-base-ref`
- `orca repo search-refs`
- `orca worktree list`
- `orca worktree show`
- `orca worktree create`
- `orca worktree set`
- `orca worktree rm`
- `orca worktree ps`
- `orca terminal list`
- `orca terminal show`
- `orca terminal read`
- `orca terminal send`
- `orca terminal wait --for exit`
- `orca terminal stop`
## What These Commands Cover
Focused v1 supports the complete agent loop for worktree orchestration:
1. Inspect current Orca runtime availability.
2. Discover repos indirectly through existing worktrees and summary views.
3. Create a new worktree in a chosen repo.
4. Attach or update worktree metadata like display name, linked issue, and comment.
5. Inspect many worktrees at once with `worktree ps`.
6. Discover live terminal handles in a worktree.
7. Read terminal output with bounded token-efficient reads.
8. Send input back to the terminal.
9. Stop live terminals for a worktree when needed.
It also covers the adjacent setup tasks needed to make worktree creation usable:
- discover and inspect repos already known to Orca
- add a repo path to Orca
- set or inspect a repo base ref
## Intentionally Omitted Wait Modes
Focused v1 includes `terminal wait --for exit`.
The richer wait modes remain intentionally out of scope:
- `--for input`
- `--for idle`
- `--for output`
Those modes require stronger runtime instrumentation and should not be shipped as guesses.
## Intentionally Deferred Beyond Focused V1
These command groups are still deferred:
- `git`
- `gh`
They may still be useful later, but they are not required for the core Orca CLI story.
`git` and `gh` are still deferred because they would further expand the public runtime surface and deserve a separate pass on selector shape, output contracts, and failure handling.
The design reason is simple:
- agents often already have other tools for file, git, GitHub, and search access
- Orca is differentiated by worktree and live terminal orchestration
- broadening the CLI too early would increase surface area faster than it increases unique agent capability
## Relationship To Other Docs
- [orca-cli-v1-spec.md](./orca-cli-v1-spec.md) defines the stricter command contract and runtime assumptions.
- [orca-runtime-layer-design.md](./orca-runtime-layer-design.md) explains the runtime architecture that makes the live terminal surface safe.
- [orca-cli-bundled-distribution.md](./orca-cli-bundled-distribution.md) explains how the bundled desktop-app installation and PATH registration model works.
This status file is the source of truth for the currently implemented focused v1 scope.

783
docs/orca-cli-v1-spec.md Normal file
View File

@ -0,0 +1,783 @@
# Orca CLI V1 Spec
## Goal
Define the first strict `orca` CLI contract for agents.
This spec focuses on:
- exact commands
- exact selector grammar
- exact handle semantics
- exact JSON contract
- what is in v1 now
- what is explicitly deferred because the current runtime does not yet support it cleanly
This document is intended to be implementation-facing.
## Scope
The CLI connects to a running Orca editor.
The v1 contract is split into two buckets:
- `v1-now`: can be grounded in current Orca persistence and IPC behavior with limited new plumbing
- `v1-runtime-layer`: desirable v1 public contract, but requires a shared runtime/orchestration layer before it is safe to ship
## Global Rules
### Output modes
All agent-facing commands must support `--json`.
When `--json` is used:
- stdout contains exactly one JSON object
- stdout contains no progress text, logs, or prose
- stderr is reserved for failures and unexpected diagnostics
- non-zero exit code indicates command failure
Human-readable output may exist without `--json`, but `--json` is the normative contract for agents.
### Metadata
All `--json` responses include:
```json
{
"_meta": {
"orcaVersion": "1.0.0",
"requestId": "req_123"
}
}
```
Commands that depend on the live runtime layer also include:
```json
{
"_meta": {
"runtimeId": "runtime_abc123"
}
}
```
### Errors
All failures in `--json` mode must return:
```json
{
"_meta": {
"requestId": "req_123"
},
"error": {
"code": "selector_not_found",
"message": "No worktree matched selector \"branch:feature/foo\".",
"retryable": false
}
}
```
Minimum standard error codes:
- `orca_not_running`
- `runtime_unavailable`
- `selector_not_found`
- `selector_ambiguous`
- `terminal_handle_stale`
- `terminal_not_found`
- `repo_not_found`
- `worktree_not_found`
- `not_supported_in_v1`
- `invalid_argument`
## Selectors
Selectors are command-time identifiers.
### Repo selector grammar
Explicit forms:
- `id:<repo-id>`
- `path:<absolute-path>`
- `name:<display-name>`
Bare fallback order:
1. exact repo id match
2. exact absolute path match
3. exact display name match
If more than one repo matches a bare selector, fail with `selector_ambiguous`.
### Worktree selector grammar
Explicit forms:
- `id:<worktree-id>`
- `path:<absolute-path>`
- `branch:<branch-name>`
- `issue:<number>`
Bare fallback order:
1. exact worktree id match
2. exact absolute path match
3. exact branch name match
If more than one worktree matches a bare selector, fail with `selector_ambiguous`.
`issue:<number>` must fail with `selector_ambiguous` if multiple worktrees share the same linked issue.
### Terminal selector grammar
There is no durable selector for repeated live interaction in v1.
Discovery returns runtime handles. Follow-up commands use:
- `--terminal <handle>`
Human-friendly targeting like `title:<name>` may be useful later, but it is not part of the strict repeated-interaction contract.
## Runtime Handles
Handles identify live terminal targets.
Rules:
- handles are opaque
- handles are scoped to a specific `runtimeId`
- handles are ephemeral by default
- runtime restart or renderer reload may invalidate all existing handles
- callers must reacquire handles after reconnect/reload unless the runtime explicitly guarantees continuity
- stale handles must fail with `terminal_handle_stale`
Example stale-handle error:
```json
{
"_meta": {
"runtimeId": "runtime_new456",
"requestId": "req_123"
},
"error": {
"code": "terminal_handle_stale",
"message": "The terminal handle is no longer valid for the current Orca runtime.",
"retryable": true
}
}
```
## Commands
## `orca status`
Purpose:
- confirm Orca is running
- return current runtime identity
Status:
- `v1-runtime-layer`
Example:
```bash
orca status --json
```
Response:
```json
{
"_meta": {
"orcaVersion": "1.0.0",
"runtimeId": "runtime_abc123",
"requestId": "req_123"
},
"status": {
"running": true,
"runtimeAvailable": true,
"capabilities": {
"repo": true,
"worktree": true,
"file": true,
"search": true,
"git": true,
"gh": true,
"terminal": false,
"worktreePs": false
}
}
}
```
Implementation note:
- a minimal “is Orca running” probe may be possible earlier
- the strict `status` contract in this spec assumes the runtime layer exists and can issue a real `runtimeId`
## `orca repo list`
Status:
- `v1-now`
```bash
orca repo list --json
```
Response:
```json
{
"_meta": {
"orcaVersion": "1.0.0",
"requestId": "req_123"
},
"repos": [
{
"id": "repo_1",
"path": "/abs/repo",
"displayName": "orca",
"worktreeBaseRef": "origin/main"
}
]
}
```
## `orca repo add`
Status:
- `v1-now`
```bash
orca repo add --path /abs/repo --json
```
## `orca repo show`
Status:
- `v1-now`
```bash
orca repo show --repo path:/abs/repo --json
```
## `orca repo set-base-ref`
Status:
- `v1-now`
```bash
orca repo set-base-ref --repo id:repo_1 --ref origin/main --json
```
## `orca repo search-refs`
Renamed from `search-base-refs` for better verb consistency.
Status:
- `v1-now`
```bash
orca repo search-refs --repo id:repo_1 --query main --json
```
## `orca worktree list`
Full listing command.
Status:
- `v1-now`
```bash
orca worktree list --repo id:repo_1 --json
```
Response:
```json
{
"_meta": {
"orcaVersion": "1.0.0",
"requestId": "req_123"
},
"worktrees": [
{
"id": "repo_1::/abs/wt",
"repoId": "repo_1",
"path": "/abs/wt",
"branch": "refs/heads/feature/foo",
"displayName": "Feature Foo",
"linkedIssue": 123,
"comment": "parser work"
}
]
}
```
## `orca worktree ps`
Compact orchestration summary command.
Status:
- `v1-runtime-layer`
Rationale:
- desirable public contract
- requires a shared live-runtime summary service, not just persisted state
```bash
orca worktree ps --json
```
Response:
```json
{
"_meta": {
"orcaVersion": "1.0.0",
"runtimeId": "runtime_abc123",
"requestId": "req_123"
},
"worktrees": [
{
"id": "repo_1::/abs/wt",
"repo": "orca",
"branch": "feature/foo",
"linkedIssue": 123,
"unread": false,
"liveTerminals": 2,
"status": "active"
}
]
}
```
## `orca worktree show`
Status:
- `v1-now`
```bash
orca worktree show --worktree branch:feature/foo --json
```
## `orca worktree create`
Status:
- `v1-now`
This must preserve current editor behavior:
- sanitize name
- compute branch name from settings
- reject branch conflicts
- best-effort reject historical PR head-name reuse
- compute path under workspace root
- use chosen/default base ref
- create worktree
- best-effort apply linked issue/comment metadata
```bash
orca worktree create --repo path:/abs/repo --name feature-foo --issue 123 --comment "parser work" --json
```
## `orca worktree set`
Status:
- `v1-now`
```bash
orca worktree set --worktree branch:feature/foo --display-name "Parser" --issue 123 --comment "parser work" --json
```
## `orca worktree rm`
Status:
- `v1-now`
```bash
orca worktree rm --worktree path:/abs/wt --force --json
```
## `orca terminal list`
Purpose:
- discover live terminal handles for a worktree
Status:
- `v1-runtime-layer`
Rationale:
- requires shared orchestration service over renderer-owned layout plus main-owned PTYs
```bash
orca terminal list --worktree id:repo_1::/repo/.worktrees/feature-foo --json
```
Response:
```json
{
"_meta": {
"orcaVersion": "1.0.0",
"runtimeId": "runtime_abc123",
"requestId": "req_123"
},
"terminals": [
{
"handle": "term_a2",
"title": "claude",
"status": "running",
"worktree": "branch:feature/foo",
"tabId": "tab_1",
"tabTitle": "Claude Code",
"leafId": "leaf_2",
"preview": "I updated the parser. Do you want me to run the full suite?"
}
]
}
```
Optional:
- `--layout` may include secondary tab/layout context when the caller needs it
## `orca terminal show`
Metadata-only.
Status:
- `v1-runtime-layer`
```bash
orca terminal show --terminal term_a2 --json
```
Response:
```json
{
"_meta": {
"orcaVersion": "1.0.0",
"runtimeId": "runtime_abc123",
"requestId": "req_123"
},
"terminal": {
"handle": "term_a2",
"title": "claude",
"status": "running",
"cwd": "/abs/wt",
"tabId": "tab_1",
"tabTitle": "Claude Code",
"leafId": "leaf_2",
"lastOutputAt": 1712345678,
"lastInputAt": 1712345600,
"preview": "I updated the parser. Do you want me to run the full suite?"
}
}
```
## `orca terminal read`
Content-only, bounded.
Status:
- `v1-runtime-layer`
```bash
orca terminal read --terminal term_a2 --json
```
Response:
```json
{
"_meta": {
"orcaVersion": "1.0.0",
"runtimeId": "runtime_abc123",
"requestId": "req_123",
"truncated": false
},
"terminal": {
"handle": "term_a2",
"status": "running",
"tail": [
"Running targeted tests...",
"3 passed",
"I updated the parser and fixed the failing snapshot."
],
"nextCursor": null
}
}
```
## `orca terminal send`
Status:
- `v1-runtime-layer`
Supported forms:
- `--text <text>`
- `--enter`
- `--interrupt`
```bash
orca terminal send --terminal term_a2 --text "continue" --json
```
## `orca terminal wait`
Status:
- `exit`: `v1-runtime-layer`
- `input`: deferred
- `idle`: deferred
- `output`: deferred
Rationale:
- `exit` can be grounded in PTY exit events
- the others require new runtime instrumentation and/or heuristics
```bash
orca terminal wait --terminal term_a2 --for exit --json
```
For unsupported wait modes in initial v1:
```json
{
"_meta": {
"requestId": "req_123"
},
"error": {
"code": "not_supported_in_v1",
"message": "terminal wait --for input requires runtime instrumentation that is not available in v1.",
"retryable": false
}
}
```
## `orca terminal stop`
Stop live terminals for a worktree.
Status:
- `v1-runtime-layer`
This replaces `worktree shutdown` in the primary surface because the action is terminal-oriented.
Initial supported target:
- `--worktree <selector>`
Later extension:
- `--terminal <handle>`
## `orca file ls`
Status:
- `v1-now`
```bash
orca file ls --worktree id:repo_1::/repo/.worktrees/feature-foo --path src --json
```
## `orca file read`
Status:
- `v1-now`
```bash
orca file read --worktree id:repo_1::/repo/.worktrees/feature-foo --path src/main.ts --json
```
## `orca file write`
Status:
- `v1-now`
```bash
orca file write --worktree id:repo_1::/repo/.worktrees/feature-foo --path src/main.ts --stdin --json
```
## `orca file create`
Status:
- `v1-now`
## `orca file mkdir`
Status:
- `v1-now`
## `orca file rename`
Status:
- `v1-now`
## `orca file rm`
Status:
- `v1-now`
## `orca file stat`
Status:
- `v1-now`
## `orca search text`
Status:
- `v1-now`
```bash
orca search text --worktree id:repo_1::/repo/.worktrees/feature-foo --query worktree --json
```
## `orca search files`
Status:
- `v1-now`
Keep separate from `file ls --query` in v1.
Rationale:
- clearer distinction between tree listing and search behavior
- aligns with existing product/search mental model
## `orca git status`
Status:
- `v1-now`
## `orca git diff`
Status:
- `v1-now`
## `orca git stage`
Status:
- `v1-now`
## `orca git unstage`
Status:
- `v1-now`
## `orca git discard`
Status:
- `v1-now`
## `orca git branch-compare`
Status:
- `v1-now`
## `orca gh pr`
Status:
- `v1-now`
## `orca gh issue`
Status:
- `v1-now`
## `orca gh issues`
Status:
- `v1-now`
## `orca gh checks`
Status:
- `v1-now`
## Explicitly Deferred From V1
Deferred:
- tab creation and closure
- pane split and close
- tab reordering
- tab colors
- unread/read metadata commands in the core surface
- terminal wait modes other than `exit`
Rationale:
- either too UI-shaped
- or not grounded in current backend ownership
## Recommended Implementation Order
1. repo/worktree/file/search/git/gh `v1-now` commands
2. shared runtime/orchestration layer with `runtimeId`
3. `status`
4. terminal handle issuance and validation
5. `terminal list/show/read/send`
6. `worktree ps`
7. `terminal wait --for exit`
## Recommendation
This spec is the contract to review next.
It is intentionally strict and narrower than the broader design docs:
- selectors are formal
- handles are ephemeral by default
- JSON is contractual
- terminal features are split between `v1-now` and `v1-runtime-layer`
That should let us optimize for both agent clarity and implementation honesty.

View File

@ -0,0 +1,897 @@
# Orca Runtime Layer Design
## Goal
Define the shared runtime/orchestration layer that makes the Orca CLI's live terminal contract implementable.
This layer is required because the current codebase splits ownership across:
- Electron main process:
- PTY process lifecycle
- PTY IDs
- PTY data and exit events
- Renderer:
- tabs
- split-pane layout
- active pane
- terminal titles
- buffered offscreen writes
- unread/activity side effects
- Persistence:
- repo config
- worktree metadata
- saved terminal layout snapshots
- saved tab state
That split is fine for the editor UI, but it is not enough for a CLI that needs:
- a stable `runtimeId`
- live terminal handles
- safe stale-handle rejection
- compact live summaries like `worktree ps`
- terminal reads and writes that do not depend on renderer-local pane IDs
- a real external transport path from the `orca` CLI into the running app
## Problem Statement
Today there is no single shared service that can answer:
- what live terminal targets currently exist
- which worktree/tab/leaf each target belongs to
- which PTY each target is connected to
- what a safe public handle for that live target should be
- whether a handle is still valid
Relevant current ownership:
- PTY ownership: [../src/main/ipc/pty.ts](../src/main/ipc/pty.ts)
- Renderer tab state: [../src/renderer/src/store/slices/terminals.ts](../src/renderer/src/store/slices/terminals.ts)
- Pane lifecycle and PTY connection: [../src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts](../src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts)
- Pane -> PTY wiring: [../src/renderer/src/components/terminal-pane/pty-connection.ts](../src/renderer/src/components/terminal-pane/pty-connection.ts)
- Leaf ID serialization: [../src/renderer/src/components/terminal-pane/layout-serialization.ts](../src/renderer/src/components/terminal-pane/layout-serialization.ts)
## Non-Goals
This runtime layer does not try to:
- replace the renderer store
- replace the PTY implementation
- make pane IDs durable across reloads
- implement every future terminal automation feature in one step
The first purpose is to provide a shared control plane for the current app and CLI.
## Current Constraints To Preserve
The design should stay honest about three existing realities:
1. Orca is effectively single-window today.
The current PTY IPC wiring is attached to one `mainWindow` and forwards PTY data back through that window's `webContents`.
2. Leaf-level terminal state is not yet first-class renderer state.
Today Orca persists tab-level state and layout snapshots, but it does not persist a canonical renderer-side record for each leaf's title, preview, or screen snapshot.
3. Hidden terminals already accumulate deferred output in the renderer.
The runtime layer cannot assume every hidden leaf has a continuously updated visible-screen model without adding new explicit publication behavior.
This means the first runtime layer should optimize for correctness in the current single-window app before trying to generalize further.
## Core Design Principle
The runtime layer should be a main-process service that maintains a live registry built from:
- main-process PTY events
- renderer lifecycle registrations
- persisted repo/worktree metadata when useful
It should be the only place that:
- issues live terminal handles
- validates or rejects handles
- answers live summary queries
- exposes terminal read/write operations to the CLI
This avoids editor/CLI drift.
## Source Of Truth Boundaries
The runtime layer must not replace existing durable sources of truth.
Durable truth remains:
- Git for worktree existence and branch state
- `Store` persistence for repo config, worktree metadata, and saved session snapshots
Live truth becomes:
- runtime layer for terminal handles, live leaf/PTy mappings, and live summaries
This means:
- the runtime layer may cache and index persisted state
- but it should not become the canonical persistence owner for repo or worktree metadata
- renderer/UI code should stop inventing separate live-terminal contracts once the runtime layer exists
## Why Main Process Ownership
The runtime layer should live in the main process, not the renderer.
Reasons:
- the CLI will need to call into it even when no renderer component currently has focus
- PTY ownership already lives in the main process
- handle validation and stale-handle rejection are security and correctness boundaries
- renderer reloads should not destroy the authoritative registry object itself, even if they invalidate live handles
The renderer should publish registrations and updates into the runtime layer, not own the runtime layer.
Scope note:
- v1 runtime-layer design assumes one active Orca window
- multi-window support should be treated as a later extension, not an implicit requirement of the first implementation
## CLI Transport Boundary
The runtime layer also needs a transport boundary for the external CLI.
Recommendation:
- expose a local-only RPC endpoint from the main process
- use:
- Unix domain socket on macOS/Linux
- named pipe on Windows
- persist connection metadata in Orca user data:
- `runtimeId`
- endpoint path
- auth token
- pid
Suggested flow:
1. Orca main process starts the runtime service.
2. Orca opens the local RPC endpoint.
3. Orca writes connection metadata.
4. CLI reads connection metadata.
5. CLI connects locally and authenticates.
6. Runtime service handles CLI requests against the live registry.
Security properties:
- local machine only
- random auth token required
- stale pid/socket detection on startup
Why this matters:
- Electron renderer IPC is not the CLI transport
- the main process runtime service is the right authority for requests coming from the external CLI
## Runtime Identity
The runtime layer must generate a `runtimeId` when Orca launches.
Rules:
- `runtimeId` is unique per Orca process lifetime
- any full app restart creates a new `runtimeId`
- renderer reloads do not necessarily require a new `runtimeId`, but they may invalidate all live handles
Recommendation:
- keep `runtimeId` stable for the lifetime of the main Electron process
- separately track a renderer graph epoch that increments only when the renderer graph is explicitly reset or replaced in a way that breaks existing leaf mappings
Why:
- `runtimeId` is the coarse session identity exposed in CLI responses
- the renderer graph epoch is the finer invalidation boundary for ephemeral handles
CLI-facing simplification:
- handles are treated as ephemeral by default
- if the live graph is rebuilt in a way that invalidates mappings, all prior handles become stale
## Public Responsibilities
The runtime layer must support:
1. `status`
2. live terminal discovery
3. canonical selector resolution for CLI-facing repo/worktree lookups
4. handle issuance
5. handle validation
6. handle-based terminal reads
7. handle-based terminal writes
8. compact worktree live summaries
## Internal Data Model
The runtime layer should maintain the following registry objects.
### RuntimeState
```ts
type RuntimeState = {
runtimeId: string
rendererGraphEpoch: number
graphStatus: 'ready' | 'reloading' | 'unavailable'
authoritativeWindowId: number | null
}
```
### RegisteredTab
```ts
type RegisteredTab = {
tabId: string
worktreeId: string
title: string | null
activeLeafId: string | null
layout: TerminalPaneLayoutNode | null
lastSeenAt: number
}
```
### RegisteredLeaf
```ts
type RegisteredLeaf = {
tabId: string
worktreeId: string
leafId: string
paneRuntimeId: number
ptyId: string | null
ptyGeneration: number
lastOutputAt: number | null
lastExitCode: number | null
preview: string
tailBuffer: string[]
connected: boolean
writable: boolean
lastSeenAt: number
}
```
### TerminalHandleRecord
```ts
type TerminalHandleRecord = {
handle: string
runtimeId: string
rendererGraphEpoch: number
worktreeId: string
tabId: string
leafId: string
ptyId: string | null
ptyGeneration: number
createdAt: number
}
```
Why these fields matter:
- `leafId` gives stable layout identity within the current renderer graph
- `ptyId` is needed for actual write routing
- `ptyGeneration` prevents a restarted PTY in the same leaf from inheriting an old handle
- `tailBuffer` powers `terminal read`
- `preview` powers cheap discovery and `worktree ps`
- `writable` prevents CLI writes from racing against renderer-driven close or detach flows
## Handle Semantics
Handles are synthetic public identifiers issued by the runtime layer.
Rules:
- handles are opaque
- handles bind to:
- `runtimeId`
- `rendererGraphEpoch`
- `worktreeId`
- `tabId`
- `leafId`
- current `ptyId`
- current `ptyGeneration`
- handles are invalid if:
- `runtimeId` no longer matches
- `rendererGraphEpoch` has advanced past the handle's epoch
- the leaf registration no longer exists
- the leaf now points at a different `ptyId` or `ptyGeneration`
- the handle's current target cannot be resolved
This is intentionally strict.
Why:
- the CLI must never silently retarget input to a different live terminal
- handle invalidation should happen only for real remapping events, not every routine reconciliation pass
Stale-handle ergonomics:
- stale-handle errors should include the current `runtimeId`
- if the target leaf still exists but the specific handle is stale, the runtime layer may include a rediscovery hint scoped to that worktree or leaf
- the runtime layer should not implement a magical handle refresh that silently retargets the caller
## Event Sources
The runtime layer needs two classes of inputs.
### A. Main-process PTY events
Current source:
- [../src/main/ipc/pty.ts](../src/main/ipc/pty.ts)
Add runtime-layer integration points for:
- PTY spawned
- PTY data
- PTY exit
- PTY kill
What the runtime layer should record:
- `ptyId`
- PTY generation changes for a leaf
- data arrival timestamps
- exit code
- a bounded text tail buffer
### B. Renderer graph publication
The renderer already knows:
- when a tab exists
- what the saved and current layout is
- which leaf is active
- which pane has which current PTY
- titles derived from OSC updates
The runtime layer needs renderer-published graph state like:
- which tabs currently exist
- which leaves currently exist
- which worktree each tab belongs to
- which PTY each leaf is currently attached to
- which leaf is active within each tab
- what the current layout tree is for each tab
These are not current public APIs. They should be introduced as an explicit internal IPC channel.
Important source-of-truth rule:
- leaf records in the runtime registry are authoritative only when published by the renderer's full-graph sync
- persisted session state and renderer store state remain advisory inputs for tabs and worktrees, not a substitute for live leaf publication
## Suggested Internal IPC Contract
These are not CLI commands. They are editor-runtime plumbing.
### Renderer -> Main
- `runtime:syncWindowGraph`
Recommendation:
- start with one idempotent full-graph message as the source of truth for renderer-owned tab and leaf structure
- allow the renderer to resend the full graph whenever tab, layout, active-leaf, or PTY attachment state changes
- add narrower incremental messages later only if performance proves it necessary
Suggested payloads:
```ts
type RuntimeSyncWindowGraph = {
windowId: number
tabs: Array<{
tabId: string
worktreeId: string
title: string | null
activeLeafId: string | null
layout: TerminalPaneLayoutNode | null
}>
leaves: Array<{
tabId: string
worktreeId: string
leafId: string
paneRuntimeId: number
ptyId: string | null
}>
}
```
Why payloads matter:
- this is where ownership boundaries become real
- if these messages stay vague, implementation will drift back into ad hoc IPC
- treat full-graph sync as both the normal publication path and the repair path if an earlier renderer event was missed
Single-window v1 rule:
- Orca should accept exactly one authoritative publishing window in v1
- if a second window starts publishing, the runtime layer should reject it or mark the graph unavailable until the conflict is resolved
- `windowId` exists to make that restriction explicit now and extensible later
### Main -> Renderer
Only if needed for editor features:
- `runtime:handleInvalidated`
- `runtime:statusChanged`
The initial version can keep the runtime layer mostly main-owned and query-driven.
## How The Renderer Should Integrate
The renderer should publish runtime graph snapshots from the same places that already own lifecycle.
Recommendation:
- start with event-driven full snapshot publication
- do not add granular register/update/remove messages unless profiling shows the full graph is too expensive
- build the sync payload in one renderer-side collector/helper, and let lifecycle sites only schedule that helper rather than hand-assembling payload fragments
Why:
- renderer lifecycle is complex
- split/close/reload sequences are easy places to lose one incremental event
- a full snapshot lets the main process repair drift instead of accumulating ghost leaves or stale mappings
- the initial implementation needs correctness more than minimal event chatter
- a single collector reduces the risk that `runtime:syncWindowGraph` logic gets duplicated across store and pane lifecycle code
### Tab lifecycle
Source:
- [../src/renderer/src/store/slices/terminals.ts](../src/renderer/src/store/slices/terminals.ts)
Integration:
- when a tab is created, changed, or closed, republish the full graph
- when layout snapshot changes, republish the full graph
### Leaf/pane lifecycle
Source:
- [../src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts](../src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts)
- [../src/renderer/src/components/terminal-pane/pty-connection.ts](../src/renderer/src/components/terminal-pane/pty-connection.ts)
Integration:
- on pane created or closed, republish the full graph
- on active pane change, republish the full graph
- on PTY spawn or detach, republish the full graph
- on PTY respawn for an existing leaf, republish the full graph and let the runtime layer advance `ptyGeneration`
### Why the code needs comments
When this runtime graph publication is added, it needs comments explaining why Orca duplicates renderer lifecycle into a main-process registry:
- the CLI needs a shared live control plane
- pane IDs are renderer-local and not safe as a public contract
- handle validation must not depend on renderer-local assumptions
Those are design-driven constraints and should be documented in code comments per `AGENTS.md`.
## How The Main PTY Layer Should Integrate
Current PTY code:
- [../src/main/ipc/pty.ts](../src/main/ipc/pty.ts)
Required additions:
- publish PTY spawn/exit/data events to the runtime service
- maintain a lightweight PTY registry accessible to the runtime service
Suggested PTY event shape:
```ts
type RuntimePtySpawned = {
ptyId: string
loadGeneration: number
}
type RuntimePtyData = {
ptyId: string
data: string
at: number
}
type RuntimePtyExit = {
ptyId: string
exitCode: number
at: number
}
```
The runtime service should not parse terminal DOM state. It should build read models from:
- PTY output bytes
- renderer registrations
## Selector Resolution Service
The runtime layer should own canonical selector resolution for repo and worktree selectors rather than leaving it to the CLI frontend.
Why:
- selector semantics are part of the public contract, not presentation glue
- if the CLI resolves selectors differently from editor-driven integrations, Orca will drift
This service should:
- accept tagged selectors like `id:`, `path:`, `branch:`, and `issue:`
- reject ambiguous bare values with structured ambiguity errors
- return stable repo or worktree identities that downstream runtime operations can use
The terminal layer should remain handle-first once discovery is complete, but selector resolution must still be runtime-owned for consistent discovery semantics.
## Single-Window Assumption In V1
The current app is effectively single-window, and the first runtime layer should embrace that instead of pretending multi-window support already exists.
Recommendation:
- one runtime service per app process
- one CLI target runtime per app process
- v1 should permit only one authoritative publishing window
- if multiple windows appear later, they may register into the same runtime service only after Orca has an explicit multi-window routing model
The runtime layer should not issue window-scoped handles.
## Terminal Read Model
`terminal show` and `terminal read` need cheap buffers.
The runtime layer should maintain:
- `preview`
- `tailBuffer`
### Preview
Purpose:
- cheap discovery
- worktree summary
Strategy:
- derived from most recent meaningful lines
- capped to a few hundred characters
- should be main-owned once PTY data reaches the runtime service
### Tail buffer
Purpose:
- powers `terminal read`
Strategy:
- bounded ring buffer by line count and char count
- updated from PTY output
### Visible screen snapshots
Visible screen snapshots should be treated as a later enhancement, not a required v1 runtime primitive.
Why:
- hidden panes currently accumulate deferred output in `pendingWritesRef`, so a renderer-owned "current screen" is not uniformly trustworthy across visible and hidden leaves
- the CLI needs an honest contract more than a more ambitious but misleading one
So:
- `terminal show` should rely on runtime-owned metadata plus preview
- initial `terminal read` should rely on runtime-owned PTY tail data only
- if Orca later adds explicit visible-screen publication, that can be layered on as an optional richer read mode rather than a v1 requirement
## Drift Recovery
The runtime layer should assume registrations can drift.
Examples:
- renderer reload before all leaf removals are delivered
- pane closes while PTY exit is also firing
- a restored tab graph replaces leaf IDs
Recovery strategy:
1. event-driven full graph sync for correctness
2. explicit epoch bump only when the renderer graph is reset or replaced incompatibly
3. reject stale handles instead of trying to preserve them through remaps
This is another reason handles should be treated as ephemeral by default.
## Reload And Unavailable States
The runtime layer needs an explicit graph-availability state rather than assuming the renderer graph is always present when PTYs exist.
Recommendation:
- enter `graphStatus: 'reloading'` when the authoritative renderer is tearing down or the window is reloading
- enter `graphStatus: 'unavailable'` if no authoritative renderer graph is available
- return to `graphStatus: 'ready'` only after a fresh successful `runtime:syncWindowGraph`
Why:
- the current PTY layer can briefly keep PTYs alive while the renderer graph is gone or rebuilding
- CLI calls should fail closed during that window instead of acting on stale registry state
Behavior:
- `terminal list`, `terminal show`, `terminal read`, and `terminal send` should reject with a distinct runtime-unavailable error while `graphStatus != 'ready'`
- `status` should still work and report why the live terminal graph is unavailable
## `worktree ps` Summary Model
`worktree ps` should be powered by the runtime layer, not persistence alone.
For each worktree, it should summarize:
- repo
- branch
- linked issue
- unread metadata
- live terminal count
- whether any terminal is attached to a live PTY
- last output time if known
- recent preview if useful
Recommendation:
- compute this in the runtime service from:
- persisted worktree metadata
- live tab/leaf registrations
- PTY connectivity
Batch-read note:
- `worktree ps` is the preferred cheap batched live summary for many worktrees in v1
- Orca should avoid a second overlapping batch-preview primitive until real usage shows `worktree ps` is insufficient
The runtime layer should expose a single summary builder used by both:
- CLI `worktree ps`
- any future editor surfaces that want the same live summary semantics
Why:
- the CLI needs a cheap orchestration summary across many worktrees
## Wait Semantics
`terminal wait` needs to be split by what is actually observable.
### Safe first support
- `exit`
This can be grounded in PTY exit events.
### Later support requiring instrumentation or heuristics
- `output`
- `idle`
- `input`
Why:
- current code does not expose a first-class “waiting for input” state
- title heuristics exist in the renderer, but they are not sufficient as a strong CLI contract
Recommendation:
- runtime layer v1 supports only `wait --for exit`
- later phases may add:
- output wait from PTY data arrival
- idle wait from time-based quiescence
- input wait from agent-specific instrumentation, not generic shell guessing
## Failure Modes And Safety Rules
### 1. Stale handles
Must fail explicitly.
Never silently redirect to:
- another leaf with the same title
- the current active leaf
- another PTY in the same tab
This includes PTY restarts inside the same leaf. A restarted process must not inherit an old handle.
### 2. Renderer reload
The current code already kills prior-generation PTYs on page reload in [../src/main/ipc/pty.ts](../src/main/ipc/pty.ts).
The runtime layer should treat renderer reload as a graph invalidation event:
- bump `rendererGraphEpoch`
- invalidate all old handles
- require fresh discovery
During the reload window:
- set `graphStatus` to `reloading`
- reject live terminal operations until a fresh graph sync completes
### 3. Missing renderer registrations
If the runtime layer has PTYs but no renderer graph for a target:
- `status` may report degraded runtime health
- but terminal discovery and live terminal operations must not surface orphan PTYs as valid targets
This should surface as capability truth, not silent omission.
### 4. Closing or detached targets
If a leaf is present in the graph but is no longer writable:
- mark it `writable: false`
- reject `terminal send`
- continue to allow metadata reads when useful
Why:
- current Orca shutdown and PTY replacement flows are partly renderer-driven
- the CLI should not race writes into a target that Orca is intentionally closing or detaching
V1 definition:
- `writable` should be computed from facts Orca can actually observe now
- a target is writable only when:
- `graphStatus === 'ready'`
- the leaf exists in the current authoritative graph
- `ptyId != null`
- the leaf is still marked `connected`
- if Orca later adds an explicit renderer-side closing or detaching marker, that can tighten `writable` further
## Proposed Implementation Phases
### Phase 1: Runtime identity and service skeleton
Deliver:
- `runtimeId`
- main-process runtime service object
- `status` support
- lifecycle wiring hooks only
### Phase 2: Local CLI RPC transport and runtime metadata
Deliver:
- local socket/pipe listener
- auth token bootstrap
- request/response envelope shared by the editor and CLI
- runtime metadata file in Orca user data
Why this comes early:
- the CLI contract depends on a real runtime transport boundary
- it is better to lock the transport and auth model before layering more command handlers on top
### Phase 3: Renderer graph sync and PTY event ingestion
Deliver:
- `runtime:syncWindowGraph`
- tab/leaf graph registry
- PTY attach/detach mapping
- tail buffer updates from PTY events
- preview generation
### Phase 4: Handle issuance and validation
Deliver:
- handle generation
- handle lookup
- stale-handle rejection
- replacement hints in stale-handle errors when safe
- `terminal list`
- `terminal show`
### Phase 5: Read and write surface
Deliver:
- main-owned tail ring buffer
- `terminal read`
- `terminal send`
- `graphStatus`-aware rejection during reload and unavailable windows
### Phase 6: Summary service
Deliver:
- `worktree ps`
### Phase 7: Optional richer terminal reads
Deliver:
- renderer-published visible screen snapshots if Orca proves it needs them
### Phase 8: Wait support beyond exit
Deliver:
- `wait --for exit`
- explicitly defer the rest until instrumentation exists
## Recommended File/Module Shape
Main process:
- `src/main/runtime/orca-runtime.ts`
- `src/main/ipc/runtime.ts`
Renderer integration:
- `src/renderer/src/runtime/sync-runtime-graph.ts`
- targeted calls from:
- `terminals.ts`
- `use-terminal-pane-lifecycle.ts`
- `pty-connection.ts`
Why separate files:
- start with one runtime service and one IPC entrypoint so the design stays easy to land
- split handle, registry, and buffer helpers into separate modules later only if the implementation earns that complexity
## Open Questions
1. Should `runtimeId` change only on app restart, or also on explicit renderer graph reset?
Recommendation:
- app restart only
- use `rendererGraphEpoch` for graph invalidation
2. Should handles encode any meaning, or be fully opaque?
Recommendation:
- fully opaque
3. Should visible screen snapshots be pushed continuously or only on demand if Orca adds them later?
Recommendation:
- defer this until after the tail-buffer-based runtime contract is stable
- if added later, start with on-demand or throttled publication for visible leaves only
4. Should terminal previews come from screen snapshots or tail buffers?
Recommendation:
- use tail buffer for preview generation
- reserve visible screen snapshots for an optional richer read mode later
5. Should Orca support more than one publishing window in v1?
Recommendation:
- no
- keep one authoritative publishing window until PTY routing and renderer graph ownership are explicitly multi-window-safe
## Recommendation
Build the runtime layer as a main-process orchestration service with:
- stable `runtimeId`
- renderer-published full tab/leaf graph sync
- PTY-event integration
- local CLI RPC transport
- opaque handle issuance
- strict stale-handle rejection
- bounded read models for discovery and terminal reads
That is the smallest honest architecture that can support the Orca CLI's live terminal contract without drifting from the editor.

View File

@ -0,0 +1,96 @@
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
/** @type {import('electron-builder').Configuration} */
module.exports = {
appId: 'com.stablyai.orca',
productName: 'Orca',
directories: {
buildResources: 'build'
},
files: [
'!**/.vscode/*',
'!src/*',
'!electron.vite.config.{js,ts,mjs,cjs}',
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}',
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
'!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
],
asarUnpack: ['out/cli/**', 'resources/**'],
win: {
executableName: 'Orca',
extraResources: [
{
from: 'resources/win32/bin/orca.cmd',
to: 'bin/orca.cmd'
}
]
},
nsis: {
artifactName: 'orca-windows-setup.${ext}',
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
createDesktopShortcut: 'always'
},
mac: {
icon: 'build/icon.icns',
entitlements: 'build/entitlements.mac.plist',
entitlementsInherit: 'build/entitlements.mac.plist',
extendInfo: {
NSCameraUsageDescription: "Application requests access to the device's camera.",
NSMicrophoneUsageDescription: "Application requests access to the device's microphone.",
NSDocumentsFolderUsageDescription:
"Application requests access to the user's Documents folder.",
NSDownloadsFolderUsageDescription:
"Application requests access to the user's Downloads folder."
},
// Why: local macOS validation builds should launch without Apple release
// credentials. Hardened runtime + notarization stay enabled only on the
// explicit release path so production artifacts remain strict while dev
// artifacts do not fail with broken ad-hoc launch behavior.
hardenedRuntime: isMacRelease,
notarize: isMacRelease,
extraResources: [
{
from: 'resources/darwin/bin/orca',
to: 'bin/orca'
}
],
target: [
{
target: 'dmg',
arch: ['x64', 'arm64']
},
{
target: 'zip',
arch: ['x64', 'arm64']
}
]
},
// Why: release builds should fail if signing is unavailable instead of
// silently downgrading to ad-hoc artifacts that look shippable in CI logs.
forceCodeSigning: isMacRelease,
dmg: {
artifactName: 'orca-macos-${arch}.${ext}'
},
linux: {
extraResources: [
{
from: 'resources/linux/bin/orca',
to: 'bin/orca'
}
],
target: ['AppImage', 'deb'],
maintainer: 'stablyai',
category: 'Utility'
},
appImage: {
artifactName: 'orca-linux.${ext}'
},
npmRebuild: false,
publish: {
provider: 'github',
owner: 'stablyai',
repo: 'orca',
releaseType: 'release'
}
}

View File

@ -1,56 +0,0 @@
appId: com.stablyai.orca
productName: Orca
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: Orca
nsis:
artifactName: orca-windows-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
icon: build/icon.icns
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
hardenedRuntime: true
notarize: true
target:
- target: dmg
arch:
- x64
- arm64
- target: zip
arch:
- x64
- arm64
dmg:
artifactName: orca-macos-${arch}.${ext}
linux:
target:
- AppImage
- deb
maintainer: stablyai
category: Utility
appImage:
artifactName: orca-linux.${ext}
npmRebuild: false
publish:
provider: github
owner: stablyai
repo: orca
releaseType: release

View File

@ -4,6 +4,9 @@
"description": "An Electron application with React and TypeScript",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
"bin": {
"orca": "./out/cli/index.js"
},
"main": "./out/main/index.js",
"scripts": {
"format": "oxfmt --write .",
@ -11,18 +14,21 @@
"prepare": "husky",
"test": "vitest run",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:cli": "tsc --noEmit -p tsconfig.cli.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"typecheck": "tsc --noEmit -p tsconfig.node.json --composite false && tsc --noEmit -p tsconfig.cli.json --composite false && tsc --noEmit -p tsconfig.web.json --composite false",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build:cli": "tsc -p tsconfig.cli.json --outDir out --composite false --incremental false",
"build:electron-vite": "node scripts/run-electron-vite-build.mjs",
"build": "pnpm run typecheck && pnpm run build:electron-vite",
"build": "pnpm run typecheck && pnpm run build:electron-vite && pnpm run build:cli",
"postinstall": "pnpm rebuild electron && electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:unpack": "pnpm run build && electron-builder --config electron-builder.config.cjs --dir",
"build:win": "pnpm run build && electron-builder --config electron-builder.config.cjs --win",
"build:icons": "bash icon/generate.sh",
"build:mac": "pnpm run build:electron-vite && electron-builder --mac",
"build:linux": "pnpm run build:electron-vite && electron-builder --linux",
"build:mac": "pnpm run build && electron-builder --config electron-builder.config.cjs --mac",
"build:mac:release": "node scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build && ORCA_MAC_RELEASE=1 electron-builder --config electron-builder.config.cjs --mac",
"build:linux": "pnpm run build && electron-builder --config electron-builder.config.cjs --linux",
"release:rc": "npm version prerelease --preid=rc && git push --follow-tags",
"release:patch": "npm version patch && git push --follow-tags",
"release:minor": "npm version minor && git push --follow-tags",
@ -106,6 +112,7 @@
"oxfmt --write"
]
},
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
"pnpm": {
"onlyBuiltDependencies": [
"electron",

33
resources/darwin/bin/orca Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
function app_realpath() {
SOURCE=$1
while [ -h "$SOURCE" ]; do
DIR=$(dirname "$SOURCE")
SOURCE=$(readlink "$SOURCE")
[[ $SOURCE != /* ]] && SOURCE=$DIR/$SOURCE
done
SOURCE_DIR="$( cd -P "$( dirname "$SOURCE" )" >/dev/null 2>&1 && pwd )"
echo "${SOURCE_DIR%%${SOURCE_DIR#*.app}}"
}
APP_PATH="$(app_realpath "${BASH_SOURCE[0]}")"
if [ -z "$APP_PATH" ]; then
echo "Unable to determine Orca.app path from symlink: ${BASH_SOURCE[0]}"
exit 1
fi
CONTENTS="$APP_PATH/Contents"
ELECTRON="$CONTENTS/MacOS/Orca"
# Why: Orca packages the CLI entrypoint outside app.asar so the public shell
# command can execute it directly with ELECTRON_RUN_AS_NODE, mirroring VS Code's
# launcher model instead of requiring a separate npm-distributed binary.
CLI="$CONTENTS/Resources/app.asar.unpacked/out/cli/index.js"
export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}"
export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}"
unset NODE_OPTIONS
unset NODE_REPL_EXTERNAL_MODULE
ELECTRON_RUN_AS_NODE=1 "$ELECTRON" "$CLI" "$@"

27
resources/linux/bin/orca Normal file
View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
RESOURCES_DIR="$(cd "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd)"
APP_DIR="$(cd "$RESOURCES_DIR/.." >/dev/null 2>&1 && pwd)"
if [ -x "$APP_DIR/orca" ]; then
ELECTRON="$APP_DIR/orca"
elif [ -x "$APP_DIR/Orca" ]; then
ELECTRON="$APP_DIR/Orca"
else
echo "Unable to locate the Orca executable next to $RESOURCES_DIR" >&2
exit 1
fi
# Why: Orca packages the CLI entrypoint outside app.asar so the public shell
# command can execute it directly with ELECTRON_RUN_AS_NODE, mirroring the same
# launcher model used on macOS instead of maintaining a separate Node binary.
CLI="$RESOURCES_DIR/app.asar.unpacked/out/cli/index.js"
export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}"
export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}"
unset NODE_OPTIONS
unset NODE_REPL_EXTERNAL_MODULE
ELECTRON_RUN_AS_NODE=1 "$ELECTRON" "$CLI" "$@"

View File

@ -0,0 +1,24 @@
@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
for %%I in ("%SCRIPT_DIR%..") do set "RESOURCES_DIR=%%~fI"
for %%I in ("%RESOURCES_DIR%..") do set "APP_DIR=%%~fI"
set "ELECTRON=%APP_DIR%\Orca.exe"
if not exist "%ELECTRON%" (
echo Unable to locate Orca.exe next to "%RESOURCES_DIR%" 1>&2
exit /b 1
)
REM Why: Orca packages the CLI entrypoint outside app.asar so the public shell
REM command can execute it directly with ELECTRON_RUN_AS_NODE instead of
REM depending on a separately installed Node CLI.
set "CLI=%RESOURCES_DIR%\app.asar.unpacked\out\cli\index.js"
set "ORCA_NODE_OPTIONS=%NODE_OPTIONS%"
set "ORCA_NODE_REPL_EXTERNAL_MODULE=%NODE_REPL_EXTERNAL_MODULE%"
set NODE_OPTIONS=
set NODE_REPL_EXTERNAL_MODULE=
set ELECTRON_RUN_AS_NODE=1
"%ELECTRON%" "%CLI%" %*

View File

@ -0,0 +1,29 @@
#!/usr/bin/env node
const required = [
'APPLE_ID',
'APPLE_APP_SPECIFIC_PASSWORD',
'APPLE_TEAM_ID',
'CSC_LINK',
'CSC_KEY_PASSWORD'
]
const missing = required.filter((key) => {
const value = process.env[key]
return typeof value !== 'string' || value.trim().length === 0
})
if (missing.length > 0) {
// Why: local developers still need ad-hoc builds for validation, but the
// production release path must fail fast instead of silently shipping an
// unsigned, unnotarized app that only looked successful in CI logs.
console.error('Missing required macOS release signing environment variables:')
for (const key of missing) {
console.error(`- ${key}`)
}
console.error('')
console.error('Use `pnpm build:mac` for local ad-hoc builds, or provide the')
console.error('Developer ID + notarization credentials before running the')
console.error('production release build.')
process.exit(1)
}

155
skills/orca-cli/SKILL.md Normal file
View File

@ -0,0 +1,155 @@
---
name: orca-cli
description: Use the Orca CLI to orchestrate worktrees and live terminals through a running Orca editor. Use when an agent needs to create, inspect, update, or remove Orca worktrees; inspect repo state known to Orca; or read, send to, wait on, or stop Orca-managed terminals. Triggers include "use orca cli", "manage Orca worktrees", "read Orca terminal", "reply to Claude Code in Orca", "create a worktree in Orca", or any task where the agent should operate through Orca instead of talking to git worktrees and terminal processes directly.
---
# Orca CLI
Use this skill when the task should go through Orca's control plane rather than directly through `git`, shell PTYs, or ad hoc filesystem access.
## When To Use
Use `orca` for:
- worktree orchestration inside a running Orca app
- reading and replying to Orca-managed terminals
- stopping or waiting on Orca-managed terminals
- accessing repos known to Orca
Do not use `orca` when plain shell tools are simpler and Orca state does not matter.
Examples:
- creating one Orca worktree per GitHub issue
- finding the Claude Code terminal for a worktree and replying to it
- checking which Orca worktrees have live terminal activity
## Preconditions
- Prefer the public `orca` command first
- Orca editor/runtime should already be running, or the agent should start it with `orca open`
- Do not begin by inspecting Orca source files just to decide how to invoke the CLI. The first step is to check whether the installed `orca` command exists.
First verify the public CLI is installed:
```bash
command -v orca
```
Then use the public command:
```bash
orca status --json
```
If the task is about Orca worktrees or Orca terminals, do this before any codebase exploration:
```bash
command -v orca
orca status --json
```
If `orca` is not on PATH, say so explicitly and stop or ask the user to install/register the CLI before continuing.
## Core Workflow
1. Confirm Orca runtime availability:
```bash
orca status --json
```
If Orca is not running yet:
```bash
orca open --json
orca status --json
```
2. Discover current Orca state:
```bash
orca worktree ps --json
orca terminal list --json
```
3. Resolve a target worktree or terminal handle.
4. Act through Orca:
- `worktree create/set/rm`
- `terminal read/send/wait/stop`
## Command Surface
### Repo
```bash
orca repo list --json
orca repo show --repo id:<repoId> --json
orca repo add --path /abs/repo --json
orca repo set-base-ref --repo id:<repoId> --ref origin/main --json
orca repo search-refs --repo id:<repoId> --query main --limit 10 --json
```
### Worktree
```bash
orca worktree list --repo id:<repoId> --json
orca worktree ps --json
orca worktree show --worktree id:<worktreeId> --json
orca worktree create --repo id:<repoId> --name my-task --issue 123 --comment "seed" --json
orca worktree set --worktree id:<worktreeId> --display-name "My Task" --json
orca worktree rm --worktree id:<worktreeId> --force --json
```
Worktree selectors supported in focused v1:
- `id:<worktree-id>`
- `path:<absolute-path>`
- `branch:<branch-name>`
- `issue:<number>`
### Terminal
Use selectors to discover terminals, then use the returned handle for repeated live interaction.
```bash
orca terminal list --worktree id:<worktreeId> --json
orca terminal show --terminal <handle> --json
orca terminal read --terminal <handle> --json
orca terminal send --terminal <handle> --text "continue" --enter --json
orca terminal wait --terminal <handle> --for exit --timeout-ms 5000 --json
orca terminal stop --worktree id:<worktreeId> --json
```
Why: terminal handles are runtime-scoped and may go stale after reloads. If Orca returns `terminal_handle_stale`, reacquire a fresh handle with `terminal list`.
## Agent Guidance
- If the user says to create/manage an Orca worktree, use `orca worktree ...`, not raw `git worktree ...`.
- Treat Orca as the source of truth for Orca worktree and terminal tasks. Do not mix Orca-managed state with ad hoc git worktree commands unless Orca explicitly cannot perform the requested action.
- Prefer `--json` for all machine-driven use.
- Use `worktree ps` as the first summary view when many worktrees may exist.
- Use `terminal list` to reacquire handles after Orca reloads.
- Use `terminal read` before `terminal send` unless the next input is obvious.
- Use `terminal wait --for exit` only when the task actually depends on process completion.
- Prefer Orca worktree selectors over hardcoded paths when Orca identity already exists.
- If the user asks for CLI UX feedback, test the public `orca` command first. Only inspect `src/cli` or use `node out/cli/index.js` if the public command is missing or the task is explicitly about implementation internals.
- If a command fails, prefer retrying with the public `orca` command before concluding the CLI is broken, unless the failure already came from `orca` itself.
## Important Constraints
- Orca CLI only talks to a running Orca editor.
- Terminal handles are ephemeral and tied to the current Orca runtime.
- `terminal wait` in focused v1 supports only `--for exit`.
- Orca is the source of truth for worktree/terminal orchestration; do not duplicate that state with manual assumptions.
- The public `orca` command is the interface users experience. Agents should validate and use that surface, not repo-local implementation entrypoints.
## References
See these docs in this repo when behavior is unclear:
- `docs/orca-cli-focused-v1-status.md`
- `docs/orca-cli-v1-spec.md`
- `docs/orca-runtime-layer-design.md`

821
src/cli/index.ts Normal file
View File

@ -0,0 +1,821 @@
#!/usr/bin/env node
/* eslint-disable max-lines -- Why: the public CLI entrypoint keeps command dispatch in one place so the bundled shell command and development fallback stay behaviorally identical. */
import type {
CliStatusResult,
RuntimeRepoList,
RuntimeRepoSearchRefs,
RuntimeWorktreeRecord,
RuntimeWorktreePsResult,
RuntimeWorktreeListResult,
RuntimeTerminalRead,
RuntimeTerminalListResult,
RuntimeTerminalShow,
RuntimeTerminalSend,
RuntimeTerminalWait
} from '../shared/runtime-types'
import {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError,
type RuntimeRpcSuccess
} from './runtime-client'
import type { RuntimeRpcFailure } from './runtime-client'
type ParsedArgs = {
commandPath: string[]
flags: Map<string, string | boolean>
}
type CommandSpec = {
path: string[]
summary: string
usage: string
allowedFlags: string[]
examples?: string[]
notes?: string[]
}
const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000
const GLOBAL_FLAGS = ['help', 'json']
const COMMAND_SPECS: CommandSpec[] = [
{
path: ['open'],
summary: 'Launch Orca and wait for the runtime to be reachable',
usage: 'orca open [--json]',
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca open', 'orca open --json']
},
{
path: ['status'],
summary: 'Show app/runtime/graph readiness',
usage: 'orca status [--json]',
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca status', 'orca status --json']
},
{
path: ['repo', 'list'],
summary: 'List repos registered in Orca',
usage: 'orca repo list [--json]',
allowedFlags: [...GLOBAL_FLAGS]
},
{
path: ['repo', 'add'],
summary: 'Add a repo to Orca by filesystem path',
usage: 'orca repo add --path <path> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'path']
},
{
path: ['repo', 'show'],
summary: 'Show one registered repo',
usage: 'orca repo show --repo <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'repo']
},
{
path: ['repo', 'set-base-ref'],
summary: "Set the repo's default base ref for future worktrees",
usage: 'orca repo set-base-ref --repo <selector> --ref <ref> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'ref']
},
{
path: ['repo', 'search-refs'],
summary: 'Search branch/tag refs within a repo',
usage: 'orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'query', 'limit']
},
{
path: ['worktree', 'list'],
summary: 'List Orca-managed worktrees',
usage: 'orca worktree list [--repo <selector>] [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'limit']
},
{
path: ['worktree', 'show'],
summary: 'Show one worktree',
usage: 'orca worktree show --worktree <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['worktree', 'create'],
summary: 'Create a new Orca-managed worktree',
usage:
'orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment'],
notes: ['By default this matches the Orca UI flow and activates the new worktree in the app.']
},
{
path: ['worktree', 'set'],
summary: 'Update Orca metadata for a worktree',
usage:
'orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'display-name', 'issue', 'comment']
},
{
path: ['worktree', 'rm'],
summary: 'Remove a worktree from Orca and git',
usage: 'orca worktree rm --worktree <selector> [--force] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force']
},
{
path: ['worktree', 'ps'],
summary: 'Show a compact orchestration summary across worktrees',
usage: 'orca worktree ps [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'limit']
},
{
path: ['terminal', 'list'],
summary: 'List live Orca-managed terminals',
usage: 'orca terminal list [--worktree <selector>] [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'limit']
},
{
path: ['terminal', 'show'],
summary: 'Show terminal metadata and preview',
usage: 'orca terminal show --terminal <handle> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal']
},
{
path: ['terminal', 'read'],
summary: 'Read bounded terminal output',
usage: 'orca terminal read --terminal <handle> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal']
},
{
path: ['terminal', 'send'],
summary: 'Send input to a live terminal',
usage:
'orca terminal send --terminal <handle> [--text <text>] [--enter] [--interrupt] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'text', 'enter', 'interrupt']
},
{
path: ['terminal', 'wait'],
summary: 'Wait for a terminal condition',
usage: 'orca terminal wait --terminal <handle> --for exit [--timeout-ms <ms>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'for', 'timeout-ms']
},
{
path: ['terminal', 'stop'],
summary: 'Stop terminals for a worktree',
usage: 'orca terminal stop --worktree <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
}
]
async function main(): Promise<void> {
const parsed = parseArgs(process.argv.slice(2))
const helpPath = resolveHelpPath(parsed)
if (helpPath !== null) {
printHelp(helpPath)
if (helpPath.length > 0 && !findCommandSpec(helpPath) && !isCommandGroup(helpPath)) {
process.exitCode = 1
}
return
}
if (parsed.commandPath.length === 0) {
printHelp([])
return
}
const json = parsed.flags.has('json')
try {
// Why: CLI syntax and flag errors should be reported before any runtime
// lookup so users do not get misleading "Orca is not running" failures for
// simple command typos or unsupported flags.
validateCommandAndFlags(parsed)
const client = new RuntimeClient()
const { commandPath } = parsed
if (matches(commandPath, ['open'])) {
const result = await client.openOrca()
return printResult(result, json, formatCliStatus)
}
if (matches(commandPath, ['status'])) {
const result = await client.getCliStatus()
if (!json && !result.result.runtime.reachable) {
process.exitCode = 1
}
return printResult(result, json, formatStatus)
}
if (matches(commandPath, ['repo', 'list'])) {
const result = await client.call<RuntimeRepoList>('repo.list')
return printResult(result, json, formatRepoList)
}
if (matches(commandPath, ['repo', 'add'])) {
const result = await client.call<{ repo: Record<string, unknown> }>('repo.add', {
path: getRequiredStringFlag(parsed.flags, 'path')
})
return printResult(result, json, formatRepoShow)
}
if (matches(commandPath, ['repo', 'show'])) {
const result = await client.call<{ repo: Record<string, unknown> }>('repo.show', {
repo: getRequiredStringFlag(parsed.flags, 'repo')
})
return printResult(result, json, formatRepoShow)
}
if (matches(commandPath, ['repo', 'set-base-ref'])) {
const result = await client.call<{ repo: Record<string, unknown> }>('repo.setBaseRef', {
repo: getRequiredStringFlag(parsed.flags, 'repo'),
ref: getRequiredStringFlag(parsed.flags, 'ref')
})
return printResult(result, json, formatRepoShow)
}
if (matches(commandPath, ['repo', 'search-refs'])) {
const result = await client.call<RuntimeRepoSearchRefs>('repo.searchRefs', {
repo: getRequiredStringFlag(parsed.flags, 'repo'),
query: getRequiredStringFlag(parsed.flags, 'query'),
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
})
return printResult(result, json, formatRepoRefs)
}
if (matches(commandPath, ['terminal', 'list'])) {
const result = await client.call<RuntimeTerminalListResult>('terminal.list', {
worktree: getOptionalStringFlag(parsed.flags, 'worktree'),
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
})
return printResult(result, json, formatTerminalList)
}
if (matches(commandPath, ['terminal', 'show'])) {
const result = await client.call<{ terminal: RuntimeTerminalShow }>('terminal.show', {
terminal: getRequiredStringFlag(parsed.flags, 'terminal')
})
return printResult(result, json, formatTerminalShow)
}
if (matches(commandPath, ['terminal', 'read'])) {
const result = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', {
terminal: getRequiredStringFlag(parsed.flags, 'terminal')
})
return printResult(result, json, formatTerminalRead)
}
if (matches(commandPath, ['terminal', 'send'])) {
const result = await client.call<{ send: RuntimeTerminalSend }>('terminal.send', {
terminal: getRequiredStringFlag(parsed.flags, 'terminal'),
text: getOptionalStringFlag(parsed.flags, 'text'),
enter: parsed.flags.get('enter') === true,
interrupt: parsed.flags.get('interrupt') === true
})
return printResult(result, json, formatTerminalSend)
}
if (matches(commandPath, ['terminal', 'wait'])) {
const timeoutMs = getOptionalPositiveIntegerFlag(parsed.flags, 'timeout-ms')
const result = await client.call<{ wait: RuntimeTerminalWait }>(
'terminal.wait',
{
terminal: getRequiredStringFlag(parsed.flags, 'terminal'),
for: getRequiredStringFlag(parsed.flags, 'for'),
timeoutMs
},
{
// Why: terminal wait legitimately needs to outlive the CLI's default
// RPC timeout. Even without an explicit server timeout, the client must
// allow long waits instead of failing at the generic 15s transport cap.
timeoutMs: timeoutMs ? timeoutMs + 5000 : DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS
}
)
return printResult(result, json, formatTerminalWait)
}
if (matches(commandPath, ['terminal', 'stop'])) {
const result = await client.call<{ stopped: number }>('terminal.stop', {
worktree: getRequiredStringFlag(parsed.flags, 'worktree')
})
return printResult(result, json, (value) => `Stopped ${value.stopped} terminals.`)
}
if (matches(commandPath, ['worktree', 'ps'])) {
const result = await client.call<RuntimeWorktreePsResult>('worktree.ps', {
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
})
return printResult(result, json, formatWorktreePs)
}
if (matches(commandPath, ['worktree', 'list'])) {
const result = await client.call<RuntimeWorktreeListResult>('worktree.list', {
repo: getOptionalStringFlag(parsed.flags, 'repo'),
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
})
return printResult(result, json, formatWorktreeList)
}
if (matches(commandPath, ['worktree', 'show'])) {
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
worktree: getRequiredStringFlag(parsed.flags, 'worktree')
})
return printResult(result, json, formatWorktreeShow)
}
if (matches(commandPath, ['worktree', 'create'])) {
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.create', {
repo: getRequiredStringFlag(parsed.flags, 'repo'),
name: getRequiredStringFlag(parsed.flags, 'name'),
baseBranch: getOptionalStringFlag(parsed.flags, 'base-branch'),
linkedIssue: getOptionalNumberFlag(parsed.flags, 'issue'),
comment: getOptionalStringFlag(parsed.flags, 'comment')
})
return printResult(result, json, formatWorktreeShow)
}
if (matches(commandPath, ['worktree', 'set'])) {
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', {
worktree: getRequiredStringFlag(parsed.flags, 'worktree'),
displayName: getOptionalStringFlag(parsed.flags, 'display-name'),
linkedIssue: getOptionalNullableNumberFlag(parsed.flags, 'issue'),
comment: getOptionalStringFlag(parsed.flags, 'comment')
})
return printResult(result, json, formatWorktreeShow)
}
if (matches(commandPath, ['worktree', 'rm'])) {
const result = await client.call<{ removed: boolean }>('worktree.rm', {
worktree: getRequiredStringFlag(parsed.flags, 'worktree'),
force: parsed.flags.get('force') === true
})
return printResult(result, json, (value) => `removed: ${value.removed}`)
}
throw new RuntimeClientError('invalid_argument', `Unknown command: ${commandPath.join(' ')}`)
} catch (error) {
if (json) {
if (error instanceof RuntimeRpcFailureError) {
console.log(JSON.stringify(error.response, null, 2))
} else {
const response: RuntimeRpcFailure = {
id: 'local',
ok: false,
error: {
code: error instanceof RuntimeClientError ? error.code : 'runtime_error',
message: formatCliError(error)
},
_meta: {
runtimeId: null
}
}
console.log(JSON.stringify(response, null, 2))
}
} else {
console.error(formatCliError(error))
}
process.exitCode = 1
}
}
function parseArgs(argv: string[]): ParsedArgs {
const commandPath: string[] = []
const flags = new Map<string, string | boolean>()
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i]
if (!token.startsWith('--')) {
commandPath.push(token)
continue
}
const flag = token.slice(2)
const next = argv[i + 1]
if (!next || next.startsWith('--')) {
flags.set(flag, true)
continue
}
flags.set(flag, next)
i += 1
}
return { commandPath, flags }
}
function resolveHelpPath(parsed: ParsedArgs): string[] | null {
if (parsed.commandPath[0] === 'help') {
return parsed.commandPath.slice(1)
}
if (parsed.flags.has('help')) {
return parsed.commandPath
}
return null
}
function validateCommandAndFlags(parsed: ParsedArgs): void {
const spec = findCommandSpec(parsed.commandPath)
if (!spec) {
throw new RuntimeClientError(
'invalid_argument',
`Unknown command: ${parsed.commandPath.join(' ')}`
)
}
for (const flag of parsed.flags.keys()) {
if (!spec.allowedFlags.includes(flag)) {
throw new RuntimeClientError(
'invalid_argument',
`Unknown flag --${flag} for command: ${spec.path.join(' ')}`
)
}
}
}
function findCommandSpec(commandPath: string[]): CommandSpec | undefined {
return COMMAND_SPECS.find((spec) => matches(spec.path, commandPath))
}
function isCommandGroup(commandPath: string[]): boolean {
return commandPath.length === 1 && ['repo', 'worktree', 'terminal'].includes(commandPath[0])
}
function getRequiredStringFlag(flags: Map<string, string | boolean>, name: string): string {
const value = flags.get(name)
if (typeof value === 'string' && value.length > 0) {
return value
}
throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
}
function getOptionalStringFlag(
flags: Map<string, string | boolean>,
name: string
): string | undefined {
const value = flags.get(name)
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function getOptionalNumberFlag(
flags: Map<string, string | boolean>,
name: string
): number | undefined {
const value = flags.get(name)
if (typeof value !== 'string' || value.length === 0) {
return undefined
}
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
throw new RuntimeClientError('invalid_argument', `Invalid numeric value for --${name}`)
}
return parsed
}
function getOptionalPositiveIntegerFlag(
flags: Map<string, string | boolean>,
name: string
): number | undefined {
const value = getOptionalNumberFlag(flags, name)
if (value === undefined) {
return undefined
}
if (!Number.isInteger(value) || value <= 0) {
throw new RuntimeClientError('invalid_argument', `Invalid positive integer for --${name}`)
}
return value
}
function getOptionalNullableNumberFlag(
flags: Map<string, string | boolean>,
name: string
): number | null | undefined {
const value = flags.get(name)
if (value === 'null') {
return null
}
return getOptionalNumberFlag(flags, name)
}
function matches(actual: string[], expected: string[]): boolean {
return (
actual.length === expected.length && actual.every((value, index) => value === expected[index])
)
}
function printResult<TResult>(
response: RuntimeRpcSuccess<TResult>,
json: boolean,
formatter: (value: TResult) => string
): void {
if (json) {
console.log(JSON.stringify(response, null, 2))
return
}
console.log(formatter(response.result))
}
function formatStatus(status: CliStatusResult): string {
return formatCliStatus(status)
}
function formatCliStatus(status: CliStatusResult): string {
return [
`appRunning: ${status.app.running}`,
`pid: ${status.app.pid ?? 'none'}`,
`runtimeState: ${status.runtime.state}`,
`runtimeReachable: ${status.runtime.reachable}`,
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
`graphState: ${status.graph.state}`
].join('\n')
}
function formatCliError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
if (
error instanceof RuntimeClientError &&
(error.code === 'runtime_unavailable' || error.code === 'runtime_timeout')
) {
return `${message}\nOrca is not running. Run 'orca open' first.`
}
if (
error instanceof RuntimeRpcFailureError &&
error.response.error.code === 'runtime_unavailable'
) {
return `${message}\nOrca is not running. Run 'orca open' first.`
}
return message
}
function formatTerminalList(result: RuntimeTerminalListResult): string {
if (result.terminals.length === 0) {
return 'No live terminals.'
}
const body = result.terminals
.map(
(terminal) =>
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
)
.join('\n\n')
return result.truncated
? `${body}\n\ntruncated: showing ${result.terminals.length} of ${result.totalCount}`
: body
}
function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): string {
const terminal = result.terminal
return [
`handle: ${terminal.handle}`,
`title: ${terminal.title ?? '(untitled)'}`,
`worktree: ${terminal.worktreePath}`,
`branch: ${terminal.branch}`,
`leaf: ${terminal.leafId}`,
`ptyId: ${terminal.ptyId ?? 'none'}`,
`connected: ${terminal.connected}`,
`writable: ${terminal.writable}`,
`preview: ${terminal.preview || '<empty>'}`
].join('\n')
}
function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string {
const terminal = result.terminal
return [`handle: ${terminal.handle}`, `status: ${terminal.status}`, '', ...terminal.tail].join(
'\n'
)
}
function formatTerminalSend(result: { send: RuntimeTerminalSend }): string {
return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.`
}
function formatTerminalWait(result: { wait: RuntimeTerminalWait }): string {
return [
`handle: ${result.wait.handle}`,
`condition: ${result.wait.condition}`,
`satisfied: ${result.wait.satisfied}`,
`status: ${result.wait.status}`,
`exitCode: ${result.wait.exitCode ?? 'null'}`
].join('\n')
}
function formatWorktreePs(result: RuntimeWorktreePsResult): string {
if (result.worktrees.length === 0) {
return 'No worktrees found.'
}
const body = result.worktrees
.map(
(worktree) =>
`${worktree.repo} ${worktree.branch} live:${worktree.liveTerminalCount} pty:${worktree.hasAttachedPty ? 'yes' : 'no'} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}`
)
.join('\n\n')
return result.truncated
? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
: body
}
function formatRepoList(result: RuntimeRepoList): string {
if (result.repos.length === 0) {
return 'No repos found.'
}
return result.repos.map((repo) => `${repo.id} ${repo.displayName} ${repo.path}`).join('\n')
}
function formatRepoShow(result: { repo: Record<string, unknown> }): string {
return Object.entries(result.repo)
.map(
([key, value]) =>
`${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`
)
.join('\n')
}
function formatRepoRefs(result: RuntimeRepoSearchRefs): string {
if (result.refs.length === 0) {
return 'No refs found.'
}
return result.truncated ? `${result.refs.join('\n')}\n\ntruncated: yes` : result.refs.join('\n')
}
function formatWorktreeList(result: RuntimeWorktreeListResult): string {
if (result.worktrees.length === 0) {
return 'No worktrees found.'
}
const body = result.worktrees
.map(
(worktree) =>
`${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}`
)
.join('\n\n')
return result.truncated
? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
: body
}
function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }): string {
const worktree = result.worktree
return Object.entries(worktree)
.map(
([key, value]) =>
`${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`
)
.join('\n')
}
function printHelp(commandPath: string[] = []): void {
const exactSpec = findCommandSpec(commandPath)
if (exactSpec) {
console.log(formatCommandHelp(exactSpec))
return
}
if (isCommandGroup(commandPath)) {
console.log(formatGroupHelp(commandPath[0]))
return
}
if (commandPath.length > 0) {
console.log(`Unknown command: ${commandPath.join(' ')}\n`)
}
console.log(`orca
Usage: orca <command> [options]
Startup:
open Launch Orca and wait for the runtime to be reachable
status Show app/runtime/graph readiness
Repos:
repo list List repos registered in Orca
repo add Add a repo to Orca by filesystem path
repo show Show one registered repo
repo set-base-ref Set the repo's default base ref for future worktrees
repo search-refs Search branch/tag refs within a repo
Worktrees:
worktree list List Orca-managed worktrees
worktree show Show one worktree
worktree create Create a new Orca-managed worktree
worktree set Update Orca metadata for a worktree
worktree rm Remove a worktree from Orca and git
worktree ps Show a compact orchestration summary across worktrees
Terminals:
terminal list List live Orca-managed terminals
terminal show Show terminal metadata and preview
terminal read Read bounded terminal output
terminal send Send input to a live terminal
terminal wait Wait for a terminal condition
terminal stop Stop terminals for a worktree
Common Commands:
orca open [--json]
orca status [--json]
orca worktree list [--repo <selector>] [--limit <n>] [--json]
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--json]
orca worktree show --worktree <selector> [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]
orca worktree rm --worktree <selector> [--force] [--json]
orca worktree ps [--limit <n>] [--json]
orca terminal list [--worktree <selector>] [--limit <n>] [--json]
orca terminal show --terminal <handle> [--json]
orca terminal read --terminal <handle> [--json]
orca terminal send --terminal <handle> [--text <text>] [--enter] [--interrupt] [--json]
orca terminal wait --terminal <handle> --for exit [--timeout-ms <ms>] [--json]
orca terminal stop --worktree <selector> [--json]
orca repo list [--json]
orca repo add --path <path> [--json]
orca repo show --repo <selector> [--json]
orca repo set-base-ref --repo <selector> --ref <ref> [--json]
orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]
Selectors:
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, or path:<path>
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
Terminal Send Options:
--text <text> Text to send to the terminal
--enter Append Enter after sending text
--interrupt Send as an interrupt-style input when supported
Wait Options:
--for exit Wait until the target terminal exits
--timeout-ms <ms> Maximum wait time before timing out
Output Options:
--json Emit machine-readable JSON instead of human text
--help Show this help message
Behavior:
Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first.
Use selectors for discovery and handles for repeated live terminal operations.
Examples:
$ orca open
$ orca status --json
$ orca repo list
$ orca worktree create --repo name:orca --name cli-test-1 --issue 273
$ orca worktree show --worktree branch:Jinwoo-H/cli
$ orca worktree ps --limit 10
$ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json
$ orca terminal send --terminal term_123 --text "hi" --enter
$ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json`)
}
function formatCommandHelp(spec: CommandSpec): string {
const lines = [`orca ${spec.path.join(' ')}`, '', `Usage: ${spec.usage}`, '', spec.summary]
if (spec.allowedFlags.length > 0) {
lines.push('', 'Options:')
for (const flag of spec.allowedFlags) {
lines.push(` ${formatFlagHelp(flag)}`)
}
}
if (spec.notes && spec.notes.length > 0) {
lines.push('', 'Notes:')
for (const note of spec.notes) {
lines.push(` ${note}`)
}
}
if (spec.examples && spec.examples.length > 0) {
lines.push('', 'Examples:')
for (const example of spec.examples) {
lines.push(` $ ${example}`)
}
}
return lines.join('\n')
}
function formatGroupHelp(group: string): string {
const specs = COMMAND_SPECS.filter((spec) => spec.path[0] === group)
const lines = [`orca ${group}`, '', `Usage: orca ${group} <command> [options]`, '', 'Commands:']
for (const spec of specs) {
lines.push(` ${spec.path.slice(1).join(' ').padEnd(18)} ${spec.summary}`)
}
lines.push('', `Run \`orca ${group} <command> --help\` for command-specific usage.`)
return lines.join('\n')
}
function formatFlagHelp(flag: string): string {
const helpByFlag: Record<string, string> = {
'base-branch': '--base-branch <ref> Base branch/ref to create the worktree from',
comment: '--comment <text> Comment stored in Orca metadata',
'display-name': '--display-name <name> Override the Orca display name',
enter: '--enter Append Enter after sending text',
force: '--force Force worktree removal when supported',
for: '--for exit Wait condition to satisfy',
help: '--help Show this help message',
interrupt: '--interrupt Send as an interrupt-style input when supported',
issue: '--issue <number|null> Linked GitHub issue number',
json: '--json Emit machine-readable JSON',
limit: '--limit <n> Maximum number of rows to return',
name: '--name <name> Name for the new worktree',
path: '--path <path> Filesystem path to the repo',
query: '--query <text> Search text for matching refs',
ref: '--ref <ref> Base ref to persist for the repo',
repo: '--repo <selector> Repo selector such as id:<id>, name:<name>, or path:<path>',
terminal: '--terminal <handle> Runtime-issued terminal handle',
text: '--text <text> Text to send to the terminal',
'timeout-ms': '--timeout-ms <ms> Maximum wait time before timing out',
worktree:
'--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, or path:<path>'
}
return helpByFlag[flag] ?? `--${flag}`
}
void main()

View File

@ -0,0 +1,305 @@
import { mkdtempSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { createServer, type Socket } from 'net'
import { afterEach, describe, expect, it } from 'vitest'
import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client'
const servers = new Set<ReturnType<typeof createServer>>()
const sockets = new Set<Socket>()
afterEach(async () => {
for (const socket of sockets) {
socket.destroy()
}
sockets.clear()
await Promise.all(
[...servers].map(
(server) =>
new Promise<void>((resolve) => {
server.close(() => resolve())
})
)
)
servers.clear()
})
function writeMetadata(userDataPath: string, endpoint: string, authToken = 'token'): void {
writeFileSync(
join(userDataPath, 'orca-runtime.json'),
JSON.stringify({
runtimeId: 'runtime-1',
pid: 123,
transport: {
kind: 'unix',
endpoint
},
authToken,
startedAt: 1
}),
'utf8'
)
}
describe('RuntimeClient', () => {
it('returns the full RPC envelope for successful calls', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
const request = JSON.parse(String(data).trim()) as { id: string }
socket.write(
`${JSON.stringify({
id: request.id,
ok: true,
result: { running: true },
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint)
const client = new RuntimeClient(userDataPath, 500)
const response = await client.call<{ running: boolean }>('status.get')
expect(response).toMatchObject({
ok: true,
result: { running: true },
_meta: { runtimeId: 'runtime-1' }
})
expect(response.id).toBeTruthy()
})
it('reports not_running when no runtime metadata exists', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const client = new RuntimeClient(userDataPath, 100)
const status = await client.getCliStatus()
expect(status.result).toEqual({
app: {
running: false,
pid: null
},
runtime: {
state: 'not_running',
reachable: false,
runtimeId: null
},
graph: {
state: 'not_running'
}
})
})
it('reports graph_not_ready when the runtime is reachable but graph is unavailable', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
const request = JSON.parse(String(data).trim()) as { id: string }
socket.write(
`${JSON.stringify({
id: request.id,
ok: true,
result: {
runtimeId: 'runtime-1',
rendererGraphEpoch: 0,
graphStatus: 'unavailable',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0
},
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint)
const client = new RuntimeClient(userDataPath, 100)
const status = await client.getCliStatus()
expect(status.result.runtime.state).toBe('graph_not_ready')
expect(status.result.graph.state).toBe('unavailable')
})
it('openOrca succeeds immediately when the runtime is already reachable', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
const request = JSON.parse(String(data).trim()) as { id: string }
socket.write(
`${JSON.stringify({
id: request.id,
ok: true,
result: {
runtimeId: 'runtime-1',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 1,
liveLeafCount: 1
},
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint)
const client = new RuntimeClient(userDataPath, 100)
const status = await client.openOrca(100)
expect(status.result.runtime.state).toBe('ready')
expect(status.result.runtime.reachable).toBe(true)
})
it('times out if the runtime never responds', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
// Why: keep the socket open without replying so the client timeout path
// is exercised against a real hung runtime connection.
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint)
const client = new RuntimeClient(userDataPath, 25)
await expect(client.call('status.get')).rejects.toMatchObject({
code: 'runtime_timeout'
})
})
it('allows a per-call timeout override for long runtime requests', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
const request = JSON.parse(String(data).trim()) as { id: string }
setTimeout(() => {
socket.write(
`${JSON.stringify({
id: request.id,
ok: true,
result: { satisfied: true },
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
}, 40)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint)
const client = new RuntimeClient(userDataPath, 25)
const response = await client.call<{ satisfied: boolean }>('terminal.wait', undefined, {
timeoutMs: 250
})
expect(response.result).toEqual({ satisfied: true })
})
it('preserves structured runtime failures', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
const request = JSON.parse(String(data).trim()) as { id: string }
socket.write(
`${JSON.stringify({
id: request.id,
ok: false,
error: { code: 'selector_not_found', message: 'selector_not_found' },
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint)
const client = new RuntimeClient(userDataPath, 100)
await expect(client.call('worktree.show')).rejects.toBeInstanceOf(RuntimeRpcFailureError)
await expect(client.call('worktree.show')).rejects.toMatchObject({
response: {
ok: false,
_meta: { runtimeId: 'runtime-1' }
}
})
})
it('rejects invalid runtime response frames', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', () => {
socket.write('not json\n')
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint)
const client = new RuntimeClient(userDataPath, 100)
await expect(client.call('status.get')).rejects.toMatchObject({
code: 'invalid_runtime_response'
})
})
it('rejects mismatched response ids from the runtime', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', () => {
socket.write(
`${JSON.stringify({
id: 'not-the-request-id',
ok: true,
result: { running: true },
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint)
const client = new RuntimeClient(userDataPath, 100)
await expect(client.call('status.get')).rejects.toMatchObject({
code: 'invalid_runtime_response'
})
})
})

417
src/cli/runtime-client.ts Normal file
View File

@ -0,0 +1,417 @@
/* eslint-disable max-lines -- Why: the runtime client owns the full local IPC contract, launch fallback, and response validation in one place so the CLI does not drift from the app runtime. */
import { createConnection } from 'net'
import { randomUUID } from 'crypto'
import { homedir } from 'os'
import { dirname, join } from 'path'
import { readFileSync } from 'fs'
import { spawn as spawnProcess } from 'child_process'
import type { CliStatusResult, RuntimeStatus } from '../shared/runtime-types'
type RuntimeTransportMetadata =
| {
kind: 'unix'
endpoint: string
}
| {
kind: 'named-pipe'
endpoint: string
}
type RuntimeMetadata = {
runtimeId: string
pid: number
transport: RuntimeTransportMetadata | null
authToken: string
startedAt: number
}
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
}
}
type RuntimeRpcResponse<TResult> = RuntimeRpcSuccess<TResult> | RuntimeRpcFailure
export class RuntimeClientError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.code = code
}
}
export class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: RuntimeRpcFailure
constructor(response: RuntimeRpcFailure) {
super(response.error.code, response.error.message)
this.response = response
}
}
export class RuntimeClient {
private readonly userDataPath: string
private readonly requestTimeoutMs: number
constructor(userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 15000) {
this.userDataPath = userDataPath
this.requestTimeoutMs = requestTimeoutMs
}
async call<TResult>(
method: string,
params?: unknown,
options?: {
timeoutMs?: number
}
): Promise<RuntimeRpcSuccess<TResult>> {
const metadata = this.readMetadata()
const response = await this.sendRequest<TResult>(metadata, method, params, options?.timeoutMs)
if (!response.ok) {
throw new RuntimeRpcFailureError(response)
}
return response
}
async getCliStatus(): Promise<RuntimeRpcSuccess<CliStatusResult>> {
const metadata = this.tryReadMetadata()
if (!metadata?.transport || !metadata.authToken) {
return buildCliStatusResponse({
app: {
running: false,
pid: null
},
runtime: {
state: 'not_running',
reachable: false,
runtimeId: null
},
graph: {
state: 'not_running'
}
})
}
try {
const response = await this.sendRequest<RuntimeStatus>(
metadata,
'status.get',
undefined,
1000
)
if (!response.ok) {
throw new RuntimeRpcFailureError(response)
}
const graphState = response.result.graphStatus
return buildCliStatusResponse({
app: {
running: true,
pid: metadata.pid
},
runtime: {
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
reachable: true,
runtimeId: response.result.runtimeId
},
graph: {
state: graphState
}
})
} catch {
const running = isProcessRunning(metadata.pid)
return buildCliStatusResponse({
app: {
running,
pid: running ? metadata.pid : null
},
runtime: {
state: running ? 'starting' : 'not_running',
reachable: false,
runtimeId: null
},
graph: {
state: running ? 'starting' : 'not_running'
}
})
}
}
async openOrca(timeoutMs = 15_000): Promise<RuntimeRpcSuccess<CliStatusResult>> {
const initial = await this.getCliStatus()
if (initial.result.runtime.reachable) {
return initial
}
launchOrcaApp()
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const status = await this.getCliStatus()
if (status.result.runtime.reachable) {
return status
}
await delay(250)
}
throw new RuntimeClientError(
'runtime_open_timeout',
'Timed out waiting for Orca to start. Run the Orca app manually and try again.'
)
}
private readMetadata(): RuntimeMetadata {
const metadataPath = getRuntimeMetadataPath(this.userDataPath)
try {
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null
if (!metadata?.transport || !metadata.authToken) {
throw new RuntimeClientError(
'runtime_unavailable',
`Orca runtime metadata is incomplete at ${metadataPath}`
)
}
return metadata
} catch (error) {
if (error instanceof RuntimeClientError) {
throw error
}
throw new RuntimeClientError(
'runtime_unavailable',
`Could not read Orca runtime metadata at ${metadataPath}. Start the Orca app first.`
)
}
}
private tryReadMetadata(): RuntimeMetadata | null {
const metadataPath = getRuntimeMetadataPath(this.userDataPath)
try {
return JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null
} catch {
return null
}
}
private async sendRequest<TResult>(
metadata: RuntimeMetadata,
method: string,
params?: unknown,
timeoutMs?: number
): Promise<RuntimeRpcResponse<TResult>> {
return await new Promise((resolve, reject) => {
const socket = createConnection(getTransportEndpoint(metadata.transport!))
let buffer = ''
const requestId = randomUUID()
const timeout = setTimeout(() => {
socket.destroy()
reject(
new RuntimeClientError(
'runtime_timeout',
'Timed out waiting for the Orca runtime to respond.'
)
)
}, timeoutMs ?? this.requestTimeoutMs)
socket.setEncoding('utf8')
socket.once('error', () => {
clearTimeout(timeout)
reject(
new RuntimeClientError(
'runtime_unavailable',
'Could not connect to the running Orca app. Restart Orca and try again.'
)
)
})
socket.on('data', (chunk) => {
buffer += chunk
const newlineIndex = buffer.indexOf('\n')
if (newlineIndex === -1) {
return
}
const message = buffer.slice(0, newlineIndex)
socket.end()
clearTimeout(timeout)
try {
const response = JSON.parse(message) as RuntimeRpcResponse<TResult>
if (response.id !== requestId) {
reject(
new RuntimeClientError(
'invalid_runtime_response',
'The Orca runtime returned a mismatched response id.'
)
)
return
}
if (response._meta?.runtimeId && response._meta.runtimeId !== metadata.runtimeId) {
reject(
new RuntimeClientError(
'runtime_unavailable',
'The Orca runtime changed while the request was in flight. Retry the command.'
)
)
return
}
resolve(response)
} catch {
reject(
new RuntimeClientError(
'invalid_runtime_response',
'The Orca runtime returned an invalid response frame.'
)
)
}
})
socket.on('connect', () => {
socket.write(
`${JSON.stringify({
id: requestId,
authToken: metadata.authToken,
method,
params
})}\n`
)
})
})
}
}
function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess<CliStatusResult> {
return {
id: 'local-status',
ok: true,
result,
_meta: {
runtimeId: result.runtime.runtimeId ?? 'none'
}
}
}
function isProcessRunning(pid: number | null | undefined): boolean {
if (!pid || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function launchOrcaApp(): void {
const overrideCommand = process.env.ORCA_OPEN_COMMAND
if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) {
spawnProcess(overrideCommand, {
detached: true,
stdio: 'ignore',
shell: true
}).unref()
return
}
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
spawnProcess(overrideExecutable, [], {
detached: true,
stdio: 'ignore',
env: stripElectronRunAsNode(process.env)
}).unref()
return
}
if (process.env.ELECTRON_RUN_AS_NODE === '1') {
if (process.platform === 'darwin') {
const appBundlePath = getMacAppBundlePath(process.execPath)
if (appBundlePath) {
// Why: launching the inner MacOS binary directly can trigger macOS app
// launch failures and bypass normal bundle lifecycle. The public
// packaged CLI should re-open the .app the same way Finder does.
spawnProcess('open', [appBundlePath], {
detached: true,
stdio: 'ignore',
env: stripElectronRunAsNode(process.env)
}).unref()
return
}
}
spawnProcess(process.execPath, [], {
detached: true,
stdio: 'ignore',
env: stripElectronRunAsNode(process.env)
}).unref()
return
}
throw new RuntimeClientError(
'runtime_open_failed',
'Could not determine how to launch Orca. Start Orca manually and try again.'
)
}
function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next = { ...env }
delete next.ELECTRON_RUN_AS_NODE
return next
}
function getMacAppBundlePath(execPath: string): string | null {
if (process.platform !== 'darwin') {
return null
}
const macOsDir = dirname(execPath)
const contentsDir = dirname(macOsDir)
const appBundlePath = dirname(contentsDir)
return appBundlePath.endsWith('.app') ? appBundlePath : null
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function getTransportEndpoint(transport: RuntimeTransportMetadata): string {
return transport.endpoint
}
export function getDefaultUserDataPath(
platform: NodeJS.Platform = process.platform,
homeDir = homedir()
): string {
if (platform === 'darwin') {
return join(homeDir, 'Library', 'Application Support', 'orca')
}
if (platform === 'win32') {
const appData = process.env.APPDATA
if (!appData) {
throw new RuntimeClientError(
'runtime_unavailable',
'APPDATA is not set, so the Orca runtime metadata path cannot be resolved.'
)
}
return join(appData, 'orca')
}
// Why: the CLI must find the same metadata file Electron writes in packaged
// runs, so this mirrors Electron's default userData base instead of inventing
// a CLI-specific config path.
return join(process.env.XDG_CONFIG_HOME || join(homeDir, '.config'), 'orca')
}
function getRuntimeMetadataPath(userDataPath: string): string {
return join(userDataPath, 'orca-runtime.json')
}

View File

@ -0,0 +1,140 @@
import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: {
isPackaged: false,
getPath: () => tmpdir(),
getAppPath: () => tmpdir()
}
}))
import { CliInstaller } from './cli-installer'
async function makeFixture(): Promise<{
root: string
userDataPath: string
appPath: string
}> {
const root = await mkdtemp(join(tmpdir(), 'orca-cli-installer-'))
const userDataPath = join(root, 'userData')
const appPath = join(root, 'app')
const cliEntryPath = join(appPath, 'out', 'cli', 'index.js')
await mkdir(join(appPath, 'out', 'cli'), { recursive: true })
await writeFile(cliEntryPath, 'console.log("orca")\n', 'utf8')
return { root, userDataPath, appPath }
}
describe('CliInstaller', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('creates a dev launcher and installs a macOS symlink in the requested path', async () => {
const fixture = await makeFixture()
const installPath = join(fixture.root, 'bin', 'orca')
const installer = new CliInstaller({
platform: 'darwin',
isPackaged: false,
userDataPath: fixture.userDataPath,
execPath: '/Applications/Orca.app/Contents/MacOS/Orca',
appPath: fixture.appPath,
commandPathOverride: installPath,
processPathEnv: join(fixture.root, 'bin')
})
const initial = await installer.getStatus()
expect(initial.state).toBe('not_installed')
expect(initial.launcherPath).toContain(join('userData', 'cli', 'bin', 'orca'))
const installed = await installer.install()
expect(installed.state).toBe('installed')
expect(installed.pathConfigured).toBe(true)
const launcherContent = await readFile(installed.launcherPath as string, 'utf8')
expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1')
expect(launcherContent).toContain(join(fixture.appPath, 'out', 'cli', 'index.js'))
const removed = await installer.remove()
expect(removed.state).toBe('not_installed')
})
it('creates a linux symlink under the requested path and warns when PATH is missing', async () => {
const fixture = await makeFixture()
const installPath = join(fixture.root, '.local', 'bin', 'orca')
const installer = new CliInstaller({
platform: 'linux',
isPackaged: false,
userDataPath: fixture.userDataPath,
execPath: '/opt/Orca/orca',
appPath: fixture.appPath,
commandPathOverride: installPath,
processPathEnv: '/usr/bin'
})
const installed = await installer.install()
expect(installed.state).toBe('installed')
expect(installed.pathConfigured).toBe(false)
expect(installed.detail).toContain('.local')
const launcherContent = await readFile(installed.launcherPath as string, 'utf8')
expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1')
const removed = await installer.remove()
expect(removed.state).toBe('not_installed')
})
it('creates a windows wrapper and updates the user PATH', async () => {
const fixture = await makeFixture()
const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd')
let userPath = 'C:\\Windows\\System32'
const installer = new CliInstaller({
platform: 'win32',
isPackaged: false,
userDataPath: fixture.userDataPath,
execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe',
appPath: fixture.appPath,
commandPathOverride: installPath,
userPathReader: async () => userPath,
userPathWriter: async (value) => {
userPath = value
}
})
const installed = await installer.install()
expect(installed.state).toBe('installed')
expect(installed.pathConfigured).toBe(true)
expect(userPath).toContain(join(fixture.root, 'Programs', 'Orca', 'bin'))
const wrapperContent = await readFile(installPath, 'utf8')
expect(wrapperContent).toContain('ORCA_LAUNCHER=')
expect(wrapperContent).toContain('orca.cmd')
const removed = await installer.remove()
expect(removed.state).toBe('not_installed')
expect(userPath).not.toContain(join(fixture.root, 'Programs', 'Orca', 'bin'))
})
it('reports stale when a different symlink already exists', async () => {
const fixture = await makeFixture()
const installPath = join(fixture.root, 'bin', 'orca')
await mkdir(join(fixture.root, 'bin'), { recursive: true })
await symlink('/tmp/not-orca', installPath)
const installer = new CliInstaller({
platform: 'darwin',
isPackaged: false,
userDataPath: fixture.userDataPath,
execPath: '/Applications/Orca.app/Contents/MacOS/Orca',
appPath: fixture.appPath,
commandPathOverride: installPath
})
await expect(installer.getStatus()).resolves.toMatchObject({
state: 'stale',
supported: true
})
})
})

View File

@ -0,0 +1,633 @@
/* eslint-disable max-lines -- Why: this file centralizes cross-platform CLI install state, launcher resolution, and PATH registration so the public shell command stays consistent across packaged and development builds. */
import { app } from 'electron'
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { lstat, mkdir, readFile, readlink, symlink, unlink, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types'
const execFileAsync = promisify(execFile)
const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca'
const DEV_LAUNCHER_DIR = ['cli', 'bin']
type CliInstallerOptions = {
platform?: NodeJS.Platform
isPackaged?: boolean
userDataPath?: string
resourcesPath?: string
execPath?: string
appPath?: string
homePath?: string
localAppDataPath?: string
processPathEnv?: string | null
commandPathOverride?: string | null
privilegedRunner?: (command: string) => Promise<void>
userPathReader?: () => Promise<string | null>
userPathWriter?: (value: string) => Promise<void>
}
type InstallSpec = {
commandPath: string
installMethod: CliInstallMethod
}
export class CliInstaller {
private readonly platform: NodeJS.Platform
private readonly isPackaged: boolean
private readonly userDataPath: string
private readonly resourcesPath: string
private readonly execPathValue: string
private readonly appPathValue: string
private readonly homePath: string
private readonly localAppDataPath: string
private readonly processPathEnv: string | null
private readonly commandPathOverride: string | null
private readonly privilegedRunner: (command: string) => Promise<void>
private readonly userPathReader: () => Promise<string | null>
private readonly userPathWriter: (value: string) => Promise<void>
constructor(options: CliInstallerOptions = {}) {
this.platform = options.platform ?? process.platform
this.isPackaged = options.isPackaged ?? app.isPackaged
this.userDataPath = options.userDataPath ?? app.getPath('userData')
this.resourcesPath = options.resourcesPath ?? process.resourcesPath
this.execPathValue = options.execPath ?? process.execPath
this.appPathValue = options.appPath ?? app.getAppPath()
this.homePath = options.homePath ?? homedir()
this.localAppDataPath =
options.localAppDataPath ??
process.env.LOCALAPPDATA ??
join(this.homePath, 'AppData', 'Local')
this.processPathEnv = options.processPathEnv ?? process.env.PATH ?? process.env.Path ?? null
this.commandPathOverride =
options.commandPathOverride ?? process.env.ORCA_CLI_INSTALL_PATH ?? null
this.privilegedRunner = options.privilegedRunner ?? runMacPrivilegedCommand
this.userPathReader = options.userPathReader ?? (() => readWindowsUserPath())
this.userPathWriter = options.userPathWriter ?? ((value) => writeWindowsUserPath(value))
}
async getStatus(): Promise<CliInstallStatus> {
const spec = this.resolveInstallSpec()
if (!spec) {
return {
platform: this.platform,
commandName: 'orca',
commandPath: null,
pathDirectory: null,
pathConfigured: false,
launcherPath: null,
installMethod: null,
supported: false,
state: 'unsupported',
currentTarget: null,
unsupportedReason: 'platform_not_supported',
detail: 'CLI registration is not implemented on this platform.'
}
}
const launcherPath = await this.resolveLauncherPath()
if (!launcherPath) {
return {
platform: this.platform,
commandName: 'orca',
commandPath: spec.commandPath,
pathDirectory: dirname(spec.commandPath),
pathConfigured: false,
launcherPath: null,
installMethod: spec.installMethod,
supported: false,
state: 'unsupported',
currentTarget: null,
unsupportedReason: this.isPackaged ? 'launcher_missing' : 'launch_mode_unavailable',
detail: this.isPackaged
? 'The bundled CLI launcher is missing from this Orca build.'
: 'Development mode uses a generated launcher for validation only.'
}
}
const baseStatus =
spec.installMethod === 'symlink'
? await this.inspectSymlink(spec.commandPath, launcherPath)
: await this.inspectWindowsWrapper(spec.commandPath, launcherPath)
const pathDirectory = dirname(spec.commandPath)
const pathConfigured = await this.isPathConfigured(pathDirectory)
return this.withPathInfo(baseStatus, pathDirectory, pathConfigured)
}
async install(): Promise<CliInstallStatus> {
const status = await this.getStatus()
if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) {
throw new Error(status.detail ?? 'CLI registration is unavailable on this build.')
}
if (status.state === 'conflict') {
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`)
}
await mkdir(dirname(status.commandPath), { recursive: true })
// eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary.
if (status.installMethod === 'symlink') {
await this.installSymlink(status)
} else {
await this.installWindowsWrapper(status.commandPath, status.launcherPath)
}
if (this.platform === 'win32') {
// Why: Windows shells discover commands via the user PATH, not by walking
// arbitrary app install directories. The CLI installer therefore owns the
// user-scoped PATH entry instead of assuming the desktop installer did it.
await this.ensureWindowsPathEntry(dirname(status.commandPath))
}
return this.getStatus()
}
async remove(): Promise<CliInstallStatus> {
const status = await this.getStatus()
if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) {
return status
}
if (status.state === 'not_installed') {
if (this.platform === 'win32') {
await this.removeWindowsPathEntry(dirname(status.commandPath))
return this.getStatus()
}
return status
}
if (status.state === 'conflict') {
throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`)
}
if (status.state === 'stale') {
throw new Error(`Refusing to remove a command not owned by Orca at ${status.commandPath}.`)
}
if (status.installMethod === 'symlink') {
await this.removeSymlink(status.commandPath)
} else {
await unlink(status.commandPath)
await this.removeWindowsPathEntry(dirname(status.commandPath))
}
return this.getStatus()
}
private resolveInstallSpec(): InstallSpec | null {
const commandPath = this.resolveCommandPath()
if (!commandPath) {
return null
}
if (this.platform === 'darwin' || this.platform === 'linux') {
return {
commandPath,
installMethod: 'symlink'
}
}
if (this.platform === 'win32') {
return {
commandPath,
installMethod: 'wrapper'
}
}
return null
}
private resolveCommandPath(): string | null {
if (this.commandPathOverride) {
return this.commandPathOverride
}
if (this.platform === 'darwin') {
return DEFAULT_MAC_COMMAND_PATH
}
if (this.platform === 'linux') {
// Why: Linux does not have a single privileged global shell-command flow
// equivalent to macOS's /usr/local/bin integration. ~/.local/bin is the
// least surprising user-scoped location that many distros already expose.
return join(this.homePath, '.local', 'bin', 'orca')
}
if (this.platform === 'win32') {
return join(this.localAppDataPath, 'Programs', 'Orca', 'bin', 'orca.cmd')
}
return null
}
private async resolveLauncherPath(): Promise<string | null> {
if (!['darwin', 'linux', 'win32'].includes(this.platform)) {
return null
}
if (this.isPackaged) {
const bundledPath = getBundledLauncherPath(this.platform, this.resourcesPath)
return bundledPath && existsSync(bundledPath) ? bundledPath : null
}
return ensureDevLauncher({
platform: this.platform,
userDataPath: this.userDataPath,
execPath: this.execPathValue,
cliEntryPath: join(this.appPathValue, 'out', 'cli', 'index.js')
})
}
private async installSymlink(status: CliInstallStatus): Promise<void> {
try {
if (status.state === 'installed') {
return
}
if (status.state === 'stale') {
await unlink(status.commandPath as string)
}
await symlink(status.launcherPath as string, status.commandPath as string)
} catch (error) {
if (this.platform !== 'darwin' || !isPermissionError(error)) {
throw error
}
// Why: macOS shell-command registration should behave like VS Code and
// place a stable symlink in /usr/local/bin instead of rewriting shell rc
// files. Fallback to an elevated shell command keeps the public command
// stable even when the app lacks direct write access to that directory.
await this.privilegedRunner(
`mkdir -p ${quoteShell(dirname(status.commandPath as string))} && ` +
`ln -sfn ${quoteShell(status.launcherPath as string)} ${quoteShell(status.commandPath as string)}`
)
}
}
private async removeSymlink(commandPath: string): Promise<void> {
try {
await unlink(commandPath)
} catch (error) {
if (this.platform !== 'darwin' || !isPermissionError(error)) {
throw error
}
await this.privilegedRunner(
`if [ -L ${quoteShell(commandPath)} ]; then rm ${quoteShell(commandPath)}; fi`
)
}
}
private async installWindowsWrapper(commandPath: string, launcherPath: string): Promise<void> {
await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8')
}
private async inspectSymlink(
commandPath: string,
launcherPath: string
): Promise<CliInstallStatus> {
try {
const stats = await lstat(commandPath)
if (!stats.isSymbolicLink()) {
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'symlink',
supported: true,
state: 'conflict',
currentTarget: null,
detail: `${commandPath} exists but is not an Orca symlink.`
})
}
const currentTarget = await readlink(commandPath)
const resolvedCurrentTarget = resolve(dirname(commandPath), currentTarget)
const resolvedLauncher = resolve(launcherPath)
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'symlink',
supported: true,
state: resolvedCurrentTarget === resolvedLauncher ? 'installed' : 'stale',
currentTarget: resolvedCurrentTarget,
detail:
resolvedCurrentTarget === resolvedLauncher
? `Registered at ${commandPath}.`
: `${commandPath} points to a different launcher.`
})
} catch (error) {
if (isMissingError(error)) {
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'symlink',
supported: true,
state: 'not_installed',
currentTarget: null,
detail: `Register ${commandPath} to use Orca from the terminal.`
})
}
throw error
}
}
private async inspectWindowsWrapper(
commandPath: string,
launcherPath: string
): Promise<CliInstallStatus> {
try {
const stats = await lstat(commandPath)
if (!stats.isFile()) {
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'wrapper',
supported: true,
state: 'conflict',
currentTarget: null,
detail: `${commandPath} exists but is not an Orca launcher script.`
})
}
const currentContent = await readFile(commandPath, 'utf8')
const expectedContent = buildWindowsForwarder(launcherPath)
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'wrapper',
supported: true,
state: currentContent === expectedContent ? 'installed' : 'stale',
currentTarget: launcherPath,
detail:
currentContent === expectedContent
? `Registered at ${commandPath}.`
: `${commandPath} points to a different launcher.`
})
} catch (error) {
if (isMissingError(error)) {
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'wrapper',
supported: true,
state: 'not_installed',
currentTarget: null,
detail: `Register ${commandPath} to use Orca from Command Prompt or PowerShell.`
})
}
throw error
}
}
private buildStatus(args: {
commandPath: string
launcherPath: string
installMethod: CliInstallMethod
supported: boolean
state: CliInstallStatus['state']
currentTarget: string | null
detail: string | null
}): CliInstallStatus {
return {
platform: this.platform,
commandName: 'orca',
commandPath: args.commandPath,
pathDirectory: dirname(args.commandPath),
pathConfigured: false,
launcherPath: args.launcherPath,
installMethod: args.installMethod,
supported: args.supported,
state: args.state,
currentTarget: args.currentTarget,
unsupportedReason: null,
detail: args.detail
}
}
private async isPathConfigured(pathDirectory: string): Promise<boolean> {
const pathValue =
this.platform === 'win32' ? await this.userPathReader() : (this.processPathEnv ?? '')
return splitPathEntries(this.platform, pathValue).some((entry) =>
samePathEntry(this.platform, entry, pathDirectory)
)
}
private withPathInfo(
status: CliInstallStatus,
pathDirectory: string,
pathConfigured: boolean
): CliInstallStatus {
if (status.state !== 'installed') {
return {
...status,
pathDirectory,
pathConfigured
}
}
if (pathConfigured) {
return {
...status,
pathDirectory,
pathConfigured
}
}
return {
...status,
pathDirectory,
pathConfigured,
detail:
this.platform === 'linux'
? `${status.commandPath} is registered, but ${pathDirectory} is not on PATH for this shell.`
: `${status.commandPath} is registered. Restart your shell if the command is not visible yet.`
}
}
private async ensureWindowsPathEntry(pathDirectory: string): Promise<void> {
const current = await this.userPathReader()
const entries = splitPathEntries('win32', current)
if (entries.some((entry) => samePathEntry('win32', entry, pathDirectory))) {
return
}
entries.push(pathDirectory)
await this.userPathWriter(entries.join(';'))
}
private async removeWindowsPathEntry(pathDirectory: string): Promise<void> {
if (this.platform !== 'win32') {
return
}
const current = await this.userPathReader()
const nextEntries = splitPathEntries('win32', current).filter(
(entry) => !samePathEntry('win32', entry, pathDirectory)
)
await this.userPathWriter(nextEntries.join(';'))
}
}
async function ensureDevLauncher(args: {
platform: NodeJS.Platform
userDataPath: string
execPath: string
cliEntryPath: string
}): Promise<string | null> {
if (
!isAbsoluteForPlatform(args.platform, args.execPath) ||
!isAbsolute(args.cliEntryPath) ||
!existsSync(args.cliEntryPath)
) {
return null
}
const launcherPath = join(
args.userDataPath,
...DEV_LAUNCHER_DIR,
args.platform === 'win32' ? 'orca.cmd' : 'orca'
)
await mkdir(dirname(launcherPath), { recursive: true })
// Why: packaged Orca ships real platform launchers under resources/bin, but
// development builds do not have that stable asset layout. Generating a
// launcher in userData lets us validate the shell-command flow without
// changing the packaged registration contract.
const content =
args.platform === 'win32'
? buildWindowsDevLauncher(args.execPath, args.cliEntryPath)
: buildUnixDevLauncher(args.execPath, args.cliEntryPath)
await writeFile(launcherPath, content, {
encoding: 'utf8',
mode: args.platform === 'win32' ? undefined : 0o755
})
return launcherPath
}
function buildUnixDevLauncher(execPathValue: string, cliEntryPath: string): string {
return `#!/usr/bin/env bash
set -euo pipefail
ELECTRON=${quoteShell(execPathValue)}
CLI=${quoteShell(cliEntryPath)}
export ORCA_NODE_OPTIONS="\${NODE_OPTIONS-}"
export ORCA_NODE_REPL_EXTERNAL_MODULE="\${NODE_REPL_EXTERNAL_MODULE-}"
unset NODE_OPTIONS
unset NODE_REPL_EXTERNAL_MODULE
ELECTRON_RUN_AS_NODE=1 "$ELECTRON" "$CLI" "$@"
`
}
function buildWindowsDevLauncher(execPathValue: string, cliEntryPath: string): string {
return `@echo off
setlocal
set "ELECTRON=${escapeWindowsBatchValue(execPathValue)}"
set "CLI=${escapeWindowsBatchValue(cliEntryPath)}"
set "ORCA_NODE_OPTIONS=%NODE_OPTIONS%"
set "ORCA_NODE_REPL_EXTERNAL_MODULE=%NODE_REPL_EXTERNAL_MODULE%"
set NODE_OPTIONS=
set NODE_REPL_EXTERNAL_MODULE=
set ELECTRON_RUN_AS_NODE=1
"%ELECTRON%" "%CLI%" %*
`
}
function buildWindowsForwarder(launcherPath: string): string {
return `@echo off
setlocal
set "ORCA_LAUNCHER=${escapeWindowsBatchValue(launcherPath)}"
"%ORCA_LAUNCHER%" %*
`
}
function splitPathEntries(platform: NodeJS.Platform, value: string | null): string[] {
if (!value) {
return []
}
return value
.split(platform === 'win32' ? ';' : ':')
.map((entry) => entry.trim())
.filter(Boolean)
}
function samePathEntry(platform: NodeJS.Platform, left: string, right: string): boolean {
return platform === 'win32'
? normalizeWindowsPath(left) === normalizeWindowsPath(right)
: left === right
}
function normalizeWindowsPath(value: string): string {
return value.replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase()
}
function escapeWindowsBatchValue(value: string): string {
return value.replaceAll('"', '""')
}
function isPermissionError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
((error as NodeJS.ErrnoException).code === 'EACCES' ||
(error as NodeJS.ErrnoException).code === 'EPERM')
)
}
function isMissingError(error: unknown): boolean {
return (
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
)
}
function quoteShell(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`
}
async function runMacPrivilegedCommand(command: string): Promise<void> {
await execFileAsync('osascript', [
'-e',
`do shell script ${quoteAppleScript(command)} with administrator privileges`
])
}
function quoteAppleScript(value: string): string {
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
}
function isAbsoluteForPlatform(platform: NodeJS.Platform, value: string): boolean {
if (platform === 'win32') {
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')
}
return isAbsolute(value)
}
async function readWindowsUserPath(): Promise<string | null> {
const { stdout } = await execFileAsync('powershell', [
'-NoProfile',
'-Command',
"[Environment]::GetEnvironmentVariable('Path','User')"
])
return stdout.trim() || null
}
async function writeWindowsUserPath(value: string): Promise<void> {
await execFileAsync('powershell', [
'-NoProfile',
'-Command',
// Why: PATH registration must stay user-scoped on Windows so the Orca
// desktop app can manage the public shell command without requiring
// elevation or mutating machine-wide environment state.
`[Environment]::SetEnvironmentVariable('Path', ${quotePowerShell(value)}, 'User')`
])
}
function quotePowerShell(value: string): string {
return `'${value.replaceAll("'", "''")}'`
}
export function getBundledLauncherPath(
platform: NodeJS.Platform,
resourcesPath: string
): string | null {
if (platform === 'darwin' || platform === 'linux') {
return join(resourcesPath, 'bin', 'orca')
}
if (platform === 'win32') {
return join(resourcesPath, 'bin', 'orca.cmd')
}
return null
}

View File

@ -4,6 +4,9 @@ import devIcon from '../../resources/icon-dev.png?asset'
import { Store } from './persistence'
import { killAllPty } from './ipc/pty'
import { registerCoreHandlers } from './ipc/register-core-handlers'
import { OrcaRuntimeService } from './runtime/orca-runtime'
import { writeRuntimeMetadata } from './runtime/runtime-metadata'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
import { registerAppMenu } from './menu/register-app-menu'
import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater'
import {
@ -16,6 +19,8 @@ import { createMainWindow } from './window/createMainWindow'
let mainWindow: BrowserWindow | null = null
let store: Store | null = null
let runtime: OrcaRuntimeService | null = null
let runtimeRpc: OrcaRuntimeRpcServer | null = null
installUncaughtPipeErrorGuard()
patchPackagedProcessPath()
@ -25,9 +30,12 @@ function openMainWindow(): BrowserWindow {
if (!store) {
throw new Error('Store must be initialized before opening the main window')
}
if (!runtime) {
throw new Error('Runtime must be initialized before opening the main window')
}
const window = createMainWindow(store)
attachMainWindowServices(window, store)
attachMainWindowServices(window, store, runtime)
window.on('closed', () => {
if (mainWindow === window) {
mainWindow = null
@ -37,7 +45,7 @@ function openMainWindow(): BrowserWindow {
return window
}
app.whenReady().then(() => {
app.whenReady().then(async () => {
electronApp.setAppUserModelId('com.stablyai.orca')
app.setName('Orca')
@ -51,6 +59,14 @@ app.whenReady().then(() => {
})
store = new Store()
runtime = new OrcaRuntimeService(store)
writeRuntimeMetadata(app.getPath('userData'), {
runtimeId: runtime.getRuntimeId(),
pid: process.pid,
transport: null,
authToken: null,
startedAt: runtime.getStartedAt()
})
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
registerAppMenu({
@ -59,7 +75,18 @@ app.whenReady().then(() => {
mainWindow?.webContents.send('ui:openSettings')
}
})
registerCoreHandlers(store)
registerCoreHandlers(store, runtime)
runtimeRpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: app.getPath('userData')
})
try {
await runtimeRpc.start()
} catch (error) {
// Why: the local RPC transport enables the future CLI, but Orca should
// still boot as an editor if the socket cannot be opened on this launch.
console.error('[runtime] Failed to start local RPC transport:', error)
}
openMainWindow()
app.on('activate', () => {
@ -74,6 +101,11 @@ app.whenReady().then(() => {
app.on('before-quit', () => {
killAllPty()
if (runtimeRpc) {
void runtimeRpc.stop().catch((error) => {
console.error('[runtime] Failed to stop local RPC transport:', error)
})
}
store?.flush()
})

17
src/main/ipc/cli.ts Normal file
View File

@ -0,0 +1,17 @@
import { ipcMain } from 'electron'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { CliInstaller } from '../cli/cli-installer'
export function registerCliHandlers(): void {
ipcMain.handle('cli:getInstallStatus', async (): Promise<CliInstallStatus> => {
return new CliInstaller().getStatus()
})
ipcMain.handle('cli:install', async (): Promise<CliInstallStatus> => {
return new CliInstaller().install()
})
ipcMain.handle('cli:remove', async (): Promise<CliInstallStatus> => {
return new CliInstaller().remove()
})
}

View File

@ -1,5 +1,6 @@
import { type BrowserWindow, ipcMain } from 'electron'
import * as pty from 'node-pty'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
let ptyCounter = 0
const ptyProcesses = new Map<string, pty.IPty>()
@ -12,7 +13,7 @@ const ptyProcesses = new Map<string, pty.IPty>()
let loadGeneration = 0
const ptyLoadGeneration = new Map<string, number>()
export function registerPtyHandlers(mainWindow: BrowserWindow): void {
export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRuntimeService): void {
// Remove any previously registered handlers so we can re-register them
// (e.g. when macOS re-activates the app and creates a new window).
ipcMain.removeHandler('pty:spawn')
@ -40,6 +41,32 @@ export function registerPtyHandlers(mainWindow: BrowserWindow): void {
loadGeneration++
})
runtime?.setPtyController({
write: (ptyId, data) => {
const proc = ptyProcesses.get(ptyId)
if (!proc) {
return false
}
proc.write(data)
return true
},
kill: (ptyId) => {
const proc = ptyProcesses.get(ptyId)
if (!proc) {
return false
}
try {
proc.kill()
} catch {
return false
}
ptyProcesses.delete(ptyId)
ptyLoadGeneration.delete(ptyId)
runtime?.onPtyExit(ptyId, -1)
return true
}
})
ipcMain.handle('pty:spawn', (_event, args: { cols: number; rows: number; cwd?: string }) => {
const id = String(++ptyCounter)
@ -74,8 +101,10 @@ export function registerPtyHandlers(mainWindow: BrowserWindow): void {
ptyProcesses.set(id, ptyProcess)
ptyLoadGeneration.set(id, loadGeneration)
runtime?.onPtySpawned(id)
ptyProcess.onData((data) => {
runtime?.onPtyData(id, data, Date.now())
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:data', { id, data })
}
@ -84,6 +113,7 @@ export function registerPtyHandlers(mainWindow: BrowserWindow): void {
ptyProcess.onExit(({ exitCode }) => {
ptyProcesses.delete(id)
ptyLoadGeneration.delete(id)
runtime?.onPtyExit(id, exitCode)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:exit', { id, code: exitCode })
}
@ -116,6 +146,7 @@ export function registerPtyHandlers(mainWindow: BrowserWindow): void {
}
ptyProcesses.delete(args.id)
ptyLoadGeneration.delete(args.id)
runtime?.onPtyExit(args.id, -1)
}
})
}

View File

@ -1,25 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
registerCliHandlersMock,
registerGitHubHandlersMock,
registerSettingsHandlersMock,
registerShellHandlersMock,
registerSessionHandlersMock,
registerUIHandlersMock,
registerFilesystemHandlersMock,
registerRuntimeHandlersMock,
registerClipboardHandlersMock,
registerUpdaterHandlersMock
} = vi.hoisted(() => ({
registerCliHandlersMock: vi.fn(),
registerGitHubHandlersMock: vi.fn(),
registerSettingsHandlersMock: vi.fn(),
registerShellHandlersMock: vi.fn(),
registerSessionHandlersMock: vi.fn(),
registerUIHandlersMock: vi.fn(),
registerFilesystemHandlersMock: vi.fn(),
registerRuntimeHandlersMock: vi.fn(),
registerClipboardHandlersMock: vi.fn(),
registerUpdaterHandlersMock: vi.fn()
}))
vi.mock('./cli', () => ({
registerCliHandlers: registerCliHandlersMock
}))
vi.mock('./github', () => ({
registerGitHubHandlers: registerGitHubHandlersMock
}))
@ -44,6 +52,10 @@ vi.mock('./filesystem', () => ({
registerFilesystemHandlers: registerFilesystemHandlersMock
}))
vi.mock('./runtime', () => ({
registerRuntimeHandlers: registerRuntimeHandlersMock
}))
vi.mock('../window/attach-main-window-services', () => ({
registerClipboardHandlers: registerClipboardHandlersMock,
registerUpdaterHandlers: registerUpdaterHandlersMock
@ -53,26 +65,31 @@ import { registerCoreHandlers } from './register-core-handlers'
describe('registerCoreHandlers', () => {
beforeEach(() => {
registerCliHandlersMock.mockReset()
registerGitHubHandlersMock.mockReset()
registerSettingsHandlersMock.mockReset()
registerShellHandlersMock.mockReset()
registerSessionHandlersMock.mockReset()
registerUIHandlersMock.mockReset()
registerFilesystemHandlersMock.mockReset()
registerRuntimeHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
registerUpdaterHandlersMock.mockReset()
})
it('passes the store through to handler registrars that need it', () => {
const store = { marker: 'store' }
const runtime = { marker: 'runtime' }
registerCoreHandlers(store as never)
registerCoreHandlers(store as never, runtime as never)
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store)
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store)
expect(registerSessionHandlersMock).toHaveBeenCalledWith(store)
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)
expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store)
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerCliHandlersMock).toHaveBeenCalled()
expect(registerShellHandlersMock).toHaveBeenCalled()
expect(registerClipboardHandlersMock).toHaveBeenCalled()
expect(registerUpdaterHandlersMock).toHaveBeenCalled()

View File

@ -1,6 +1,9 @@
import { registerCliHandlers } from './cli'
import type { Store } from '../persistence'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { registerFilesystemHandlers } from './filesystem'
import { registerGitHubHandlers } from './github'
import { registerRuntimeHandlers } from './runtime'
import { registerSessionHandlers } from './session'
import { registerSettingsHandlers } from './settings'
import { registerShellHandlers } from './shell'
@ -11,13 +14,15 @@ import {
registerUpdaterHandlers
} from '../window/attach-main-window-services'
export function registerCoreHandlers(store: Store): void {
export function registerCoreHandlers(store: Store, runtime: OrcaRuntimeService): void {
registerCliHandlers()
registerGitHubHandlers(store)
registerSettingsHandlers(store)
registerShellHandlers()
registerSessionHandlers(store)
registerUIHandlers(store)
registerFilesystemHandlers(store)
registerRuntimeHandlers(runtime)
registerClipboardHandlers()
registerUpdaterHandlers(store)
warmSystemFontFamilies()

View File

@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, removeHandlerMock, fromWebContentsMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
fromWebContentsMock: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: {
fromWebContents: fromWebContentsMock
},
ipcMain: {
handle: handleMock,
removeHandler: removeHandlerMock
}
}))
import { registerRuntimeHandlers } from './runtime'
describe('registerRuntimeHandlers', () => {
beforeEach(() => {
handleMock.mockReset()
removeHandlerMock.mockReset()
fromWebContentsMock.mockReset()
})
it('routes sync requests through the authoritative browser window id', () => {
const runtime = {
syncWindowGraph: vi.fn().mockReturnValue({ graphStatus: 'ready' }),
getStatus: vi.fn().mockReturnValue({ graphStatus: 'unavailable' })
}
registerRuntimeHandlers(runtime as never)
const syncRegistration = handleMock.mock.calls.find(
([channel]) => channel === 'runtime:syncWindowGraph'
)
expect(syncRegistration).toBeTruthy()
fromWebContentsMock.mockReturnValue({ id: 17 })
const handler = syncRegistration![1]
const result = handler({ sender: {} }, { tabs: [], leaves: [] })
expect(runtime.syncWindowGraph).toHaveBeenCalledWith(17, { tabs: [], leaves: [] })
expect(result).toEqual({ graphStatus: 'ready' })
})
})

23
src/main/ipc/runtime.ts Normal file
View File

@ -0,0 +1,23 @@
import { BrowserWindow, ipcMain } from 'electron'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types'
export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
ipcMain.removeHandler('runtime:syncWindowGraph')
ipcMain.removeHandler('runtime:getStatus')
ipcMain.handle(
'runtime:syncWindowGraph',
(event, graph: RuntimeSyncWindowGraph): RuntimeStatus => {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window) {
throw new Error('Runtime graph sync must originate from a BrowserWindow')
}
return runtime.syncWindowGraph(window.id, graph)
}
)
ipcMain.handle('runtime:getStatus', (): RuntimeStatus => {
return runtime.getStatus()
})
}

View File

@ -0,0 +1,491 @@
/* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { listWorktrees } from '../git/worktree'
import { OrcaRuntimeService } from './orca-runtime'
const { MOCK_GIT_WORKTREES } = vi.hoisted(() => ({
MOCK_GIT_WORKTREES: [
{
path: '/tmp/worktree-a',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
}
]
}))
vi.mock('../git/worktree', () => ({
listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES)
}))
afterEach(() => {
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
})
const store = {
getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id),
getRepos: () => [
{
id: 'repo-1',
path: '/tmp/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1
}
],
addRepo: () => {},
updateRepo: (id: string, updates: Record<string, unknown>) =>
({
...store.getRepo(id),
...updates
}) as never,
getAllWorktreeMeta: () => ({
'repo-1::/tmp/worktree-a': {
displayName: 'foo',
comment: '',
linkedIssue: 123,
linkedPR: null,
isArchived: false,
isUnread: false,
sortOrder: 0,
lastActivityAt: 0
}
}),
getWorktreeMeta: (worktreeId: string) => store.getAllWorktreeMeta()[worktreeId],
setWorktreeMeta: (_worktreeId: string, meta: Record<string, unknown>) =>
({
...store.getAllWorktreeMeta()['repo-1::/tmp/worktree-a'],
...meta
}) as never,
removeWorktreeMeta: () => {},
getSettings: () => ({
workspaceDir: '/tmp/workspaces',
nestWorkspaces: false,
branchPrefix: 'none',
branchPrefixCustom: ''
})
}
describe('OrcaRuntimeService', () => {
it('starts unavailable with no authoritative window', () => {
const runtime = new OrcaRuntimeService(store)
expect(runtime.getStatus()).toMatchObject({
graphStatus: 'unavailable',
authoritativeWindowId: null,
rendererGraphEpoch: 0
})
expect(runtime.getRuntimeId()).toBeTruthy()
})
it('claims the first window as authoritative and ignores later windows', () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.attachWindow(2)
expect(runtime.getStatus().authoritativeWindowId).toBe(1)
})
it('bumps the epoch and enters reloading when the authoritative window reloads', () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.markRendererReloading(1)
expect(runtime.getStatus()).toMatchObject({
graphStatus: 'reloading',
rendererGraphEpoch: 1
})
})
it('can mark the graph ready for the authoritative window', () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.markRendererReloading(1)
runtime.markGraphReady(1)
expect(runtime.getStatus().graphStatus).toBe('ready')
})
it('drops back to unavailable and clears authority when the window disappears', () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.markRendererReloading(1)
runtime.markGraphUnavailable(1)
expect(runtime.getStatus()).toMatchObject({
graphStatus: 'unavailable',
authoritativeWindowId: null,
rendererGraphEpoch: 2
})
})
it('stays unavailable during initial loads before a graph is published', () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.markRendererReloading(1)
expect(runtime.getStatus()).toMatchObject({
graphStatus: 'unavailable',
rendererGraphEpoch: 0
})
})
it('lists live terminals and issues stable handles for synced leaves', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
runtime.onPtyData('pty-1', 'hello from terminal\n', 123)
const terminals = await runtime.listTerminals('branch:feature/foo')
expect(terminals.terminals).toHaveLength(1)
expect(terminals.terminals[0]).toMatchObject({
worktreeId: 'repo-1::/tmp/worktree-a',
branch: 'feature/foo',
title: 'Claude',
preview: 'hello from terminal'
})
const shown = await runtime.showTerminal(terminals.terminals[0].handle)
expect(shown.handle).toBe(terminals.terminals[0].handle)
expect(shown.ptyId).toBe('pty-1')
})
it('reads bounded terminal output and writes through the PTY controller', async () => {
const writes: string[] = []
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: () => true
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
runtime.onPtyData('pty-1', '\u001b[32mhello\u001b[0m\nworld\n', 123)
const [terminal] = (await runtime.listTerminals()).terminals
const read = await runtime.readTerminal(terminal.handle)
expect(read).toMatchObject({
handle: terminal.handle,
status: 'running',
tail: ['hello', 'world'],
truncated: false,
nextCursor: null
})
const send = await runtime.sendTerminal(terminal.handle, {
text: 'continue',
enter: true
})
expect(send).toMatchObject({
handle: terminal.handle,
accepted: true
})
expect(writes).toEqual(['continue\r'])
})
it('waits for terminal exit and resolves with the exit status', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
const [terminal] = (await runtime.listTerminals()).terminals
const waitPromise = runtime.waitForTerminal(terminal.handle, { timeoutMs: 1000 })
runtime.onPtyExit('pty-1', 7)
await expect(waitPromise).resolves.toMatchObject({
handle: terminal.handle,
condition: 'exit',
satisfied: true,
status: 'exited',
exitCode: 7
})
})
it('fails terminal waits closed when the handle goes stale during reload', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
const [terminal] = (await runtime.listTerminals()).terminals
const waitPromise = runtime.waitForTerminal(terminal.handle, { timeoutMs: 1000 })
runtime.markRendererReloading(1)
await expect(waitPromise).rejects.toThrow('terminal_handle_stale')
})
it('builds a compact worktree summary from persisted and live runtime state', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
runtime.onPtyData('pty-1', 'build green\n', 321)
const summaries = await runtime.getWorktreePs()
expect(summaries).toEqual({
worktrees: [
{
worktreeId: 'repo-1::/tmp/worktree-a',
repoId: 'repo-1',
repo: 'repo',
path: '/tmp/worktree-a',
branch: 'feature/foo',
linkedIssue: 123,
unread: false,
liveTerminalCount: 1,
hasAttachedPty: true,
lastOutputAt: 321,
preview: 'build green'
}
],
totalCount: 1,
truncated: false
})
})
it('fails terminal stop closed while the renderer graph is reloading', async () => {
const runtime = new OrcaRuntimeService(store)
let killed = false
runtime.setPtyController({
write: () => true,
kill: () => {
killed = true
return true
}
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
runtime.markRendererReloading(1)
await expect(runtime.stopTerminalsForWorktree('id:repo-1::/tmp/worktree-a')).rejects.toThrow(
'runtime_unavailable'
)
expect(killed).toBe(false)
})
it('fails terminal listing closed if the graph reloads during selector resolution', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
let releaseListWorktrees = () => {}
vi.mocked(listWorktrees).mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseListWorktrees = () => resolve(MOCK_GIT_WORKTREES)
})
)
const listPromise = runtime.listTerminals('branch:feature/foo')
runtime.markRendererReloading(1)
releaseListWorktrees()
await expect(listPromise).rejects.toThrow('runtime_unavailable')
})
it('fails terminal stop closed if the graph reloads during selector resolution', async () => {
const runtime = new OrcaRuntimeService(store)
let killed = false
runtime.setPtyController({
write: () => true,
kill: () => {
killed = true
return true
}
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
let releaseListWorktrees = () => {}
vi.mocked(listWorktrees).mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseListWorktrees = () => resolve(MOCK_GIT_WORKTREES)
})
)
const stopPromise = runtime.stopTerminalsForWorktree('branch:feature/foo')
runtime.markRendererReloading(1)
releaseListWorktrees()
await expect(stopPromise).rejects.toThrow('runtime_unavailable')
expect(killed).toBe(false)
})
it('rejects invalid positive limits for bounded list commands', async () => {
const runtime = new OrcaRuntimeService(store)
await expect(runtime.getWorktreePs(-1)).rejects.toThrow('invalid_limit')
await expect(runtime.listManagedWorktrees(undefined, 0)).rejects.toThrow('invalid_limit')
await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit')
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,90 @@
import { mkdtempSync, statSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import {
clearRuntimeMetadata,
getRuntimeMetadataPath,
readRuntimeMetadata,
writeRuntimeMetadata
} from './runtime-metadata'
const tempDirs: string[] = []
describe('runtime metadata', () => {
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
clearRuntimeMetadata(dir)
}
})
it('writes and reads runtime metadata atomically', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-metadata-'))
tempDirs.push(userDataPath)
writeRuntimeMetadata(userDataPath, {
runtimeId: 'rt_123',
pid: 42,
transport: {
kind: 'unix',
endpoint: '/tmp/orca.sock'
},
authToken: 'secret',
startedAt: 100
})
expect(readRuntimeMetadata(userDataPath)).toEqual({
runtimeId: 'rt_123',
pid: 42,
transport: {
kind: 'unix',
endpoint: '/tmp/orca.sock'
},
authToken: 'secret',
startedAt: 100
})
})
it('clears the runtime metadata file', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-metadata-'))
tempDirs.push(userDataPath)
writeRuntimeMetadata(userDataPath, {
runtimeId: 'rt_123',
pid: 42,
transport: null,
authToken: null,
startedAt: 100
})
clearRuntimeMetadata(userDataPath)
expect(readRuntimeMetadata(userDataPath)).toBeNull()
expect(getRuntimeMetadataPath(userDataPath)).toContain('orca-runtime.json')
})
it.runIf(process.platform !== 'win32')(
'restricts runtime metadata permissions to the current user on Unix',
() => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-metadata-'))
tempDirs.push(userDataPath)
writeRuntimeMetadata(userDataPath, {
runtimeId: 'rt_123',
pid: 42,
transport: {
kind: 'unix',
endpoint: '/tmp/orca.sock'
},
authToken: 'secret',
startedAt: 100
})
const metadataMode = statSync(getRuntimeMetadataPath(userDataPath)).mode & 0o777
const directoryMode = statSync(userDataPath).mode & 0o777
expect(metadataMode).toBe(0o600)
expect(directoryMode).toBe(0o700)
}
)
})

View File

@ -0,0 +1,128 @@
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync
} from 'fs'
import { execFileSync } from 'child_process'
import { dirname, join } from 'path'
export type RuntimeTransportMetadata = {
kind: 'unix' | 'named-pipe'
endpoint: string
}
export type RuntimeMetadata = {
runtimeId: string
pid: number
transport: RuntimeTransportMetadata | null
authToken: string | null
startedAt: number
}
const RUNTIME_METADATA_FILE = 'orca-runtime.json'
let cachedWindowsUserSid: string | null | undefined
export function getRuntimeMetadataPath(userDataPath: string): string {
return join(userDataPath, RUNTIME_METADATA_FILE)
}
export function writeRuntimeMetadata(userDataPath: string, metadata: RuntimeMetadata): void {
const metadataPath = getRuntimeMetadataPath(userDataPath)
const dir = dirname(metadataPath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 })
}
hardenRuntimePath(dir, { isDirectory: true, platform: process.platform })
const tmpFile = `${metadataPath}.tmp`
writeFileSync(tmpFile, JSON.stringify(metadata, null, 2), {
encoding: 'utf-8',
mode: 0o600
})
hardenRuntimePath(tmpFile, { isDirectory: false, platform: process.platform })
renameSync(tmpFile, metadataPath)
// Why: the runtime auth token is stored on disk so the local CLI can attach
// to the running app. Restricting file permissions keeps that token scoped
// to the current user on local machines.
hardenRuntimePath(metadataPath, { isDirectory: false, platform: process.platform })
}
export function readRuntimeMetadata(userDataPath: string): RuntimeMetadata | null {
const metadataPath = getRuntimeMetadataPath(userDataPath)
if (!existsSync(metadataPath)) {
return null
}
return JSON.parse(readFileSync(metadataPath, 'utf-8')) as RuntimeMetadata
}
export function clearRuntimeMetadata(userDataPath: string): void {
rmSync(getRuntimeMetadataPath(userDataPath), { force: true })
}
function hardenRuntimePath(
targetPath: string,
options: {
isDirectory: boolean
platform: NodeJS.Platform
}
): void {
if (options.platform === 'win32') {
bestEffortRestrictWindowsPath(targetPath)
return
}
chmodSync(targetPath, options.isDirectory ? 0o700 : 0o600)
}
function bestEffortRestrictWindowsPath(targetPath: string): void {
const currentUserSid = getCurrentWindowsUserSid()
if (!currentUserSid) {
return
}
try {
execFileSync(
'icacls',
[
targetPath,
'/inheritance:r',
'/grant:r',
`*${currentUserSid}:(F)`,
'*S-1-5-18:(F)',
'*S-1-5-32-544:(F)'
],
{
stdio: 'ignore',
windowsHide: true,
timeout: 5000
}
)
} catch {
// Why: runtime metadata hardening should not prevent Orca from starting on
// Windows machines where icacls is unavailable or locked down differently.
}
}
function getCurrentWindowsUserSid(): string | null {
if (cachedWindowsUserSid !== undefined) {
return cachedWindowsUserSid
}
try {
const output = execFileSync('whoami', ['/user', '/fo', 'csv', '/nh'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
timeout: 5000
}).trim()
const columns = parseCsvLine(output)
cachedWindowsUserSid = columns[1] ?? null
} catch {
cachedWindowsUserSid = null
}
return cachedWindowsUserSid
}
function parseCsvLine(line: string): string[] {
return line.split(/","/).map((part) => part.replace(/^"/, '').replace(/"$/, ''))
}

View File

@ -0,0 +1,453 @@
/* eslint-disable max-lines -- Why: this integration-style RPC test keeps the request/response contract together so regressions in the external CLI surface are easier to spot. */
import { mkdtempSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { createConnection } from 'net'
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { readRuntimeMetadata } from './runtime-metadata'
import { OrcaRuntimeRpcServer } from './runtime-rpc'
vi.mock('../git/worktree', () => ({
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/tmp/worktree-a',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
}
])
}))
async function sendRequest(
endpoint: string,
request: Record<string, unknown>
): Promise<Record<string, unknown>> {
return await new Promise((resolve, reject) => {
const socket = createConnection(endpoint)
let buffer = ''
socket.setEncoding('utf8')
socket.once('error', reject)
socket.on('data', (chunk) => {
buffer += chunk
const newlineIndex = buffer.indexOf('\n')
if (newlineIndex === -1) {
return
}
const message = buffer.slice(0, newlineIndex)
socket.end()
resolve(JSON.parse(message) as Record<string, unknown>)
})
socket.on('connect', () => {
socket.write(`${JSON.stringify(request)}\n`)
})
})
}
describe('OrcaRuntimeRpcServer', () => {
const makeStore = (overrides?: { isUnread?: boolean }) => ({
getRepo: (id: string) =>
makeStore(overrides)
.getRepos()
.find((repo) => repo.id === id),
getRepos: () => [
{
id: 'repo-1',
path: '/tmp/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1
}
],
addRepo: () => {},
updateRepo: (id: string, updates: Record<string, unknown>) =>
({
...makeStore(overrides).getRepo(id),
...updates
}) as never,
getAllWorktreeMeta: () => ({
'repo-1::/tmp/worktree-a': {
displayName: 'foo',
comment: '',
linkedIssue: 123,
linkedPR: null,
isArchived: false,
isUnread: overrides?.isUnread ?? false,
sortOrder: 0,
lastActivityAt: 0
}
}),
getWorktreeMeta: (worktreeId: string) =>
worktreeId === 'repo-1::/tmp/worktree-a'
? (makeStore(overrides).getAllWorktreeMeta()[worktreeId] as never)
: undefined,
setWorktreeMeta: (_worktreeId: string, meta: Record<string, unknown>) =>
({
...makeStore(overrides).getAllWorktreeMeta()['repo-1::/tmp/worktree-a'],
...meta
}) as never,
removeWorktreeMeta: () => {},
getSettings: () => ({
workspaceDir: '/tmp/workspaces',
nestWorkspaces: false,
branchPrefix: 'none',
branchPrefixCustom: ''
})
})
it('writes runtime metadata with transport details when started', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
expect(metadata?.runtimeId).toBe(runtime.getRuntimeId())
expect(metadata?.authToken).toBeTruthy()
expect(metadata?.transport?.endpoint).toBeTruthy()
await server.stop()
expect(readRuntimeMetadata(userDataPath)).toBeNull()
})
it('serves status.get for authenticated callers', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const response = await sendRequest(metadata!.transport!.endpoint, {
id: 'req_1',
authToken: metadata!.authToken,
method: 'status.get'
})
expect(response).toMatchObject({
id: 'req_1',
ok: true,
_meta: {
runtimeId: runtime.getRuntimeId()
}
})
expect((response.result as { graphStatus: string }).graphStatus).toBe('unavailable')
await server.stop()
})
it('rejects requests with the wrong auth token', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const response = await sendRequest(metadata!.transport!.endpoint, {
id: 'req_1',
authToken: 'wrong',
method: 'status.get'
})
expect(response).toMatchObject({
id: 'req_1',
ok: false,
error: {
code: 'unauthorized'
}
})
await server.stop()
})
it('rejects malformed requests before dispatch', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const response = await sendRequest(metadata!.transport!.endpoint, {
authToken: metadata!.authToken,
method: 'status.get'
})
expect(response).toMatchObject({
id: 'unknown',
ok: false,
error: {
code: 'bad_request'
}
})
await server.stop()
})
it('serves terminal.list and terminal.show for live runtime terminals', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService(makeStore() as never)
const writes: string[] = []
runtime.setPtyController({
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: () => true
})
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
runtime.onPtyData('pty-1', 'hello\n', 123)
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const listResponse = await sendRequest(metadata!.transport!.endpoint, {
id: 'req_list',
authToken: metadata!.authToken,
method: 'terminal.list',
params: {
worktree: 'id:repo-1::/tmp/worktree-a'
}
})
expect(listResponse).toMatchObject({
id: 'req_list',
ok: true
})
const handle = (
(
listResponse.result as {
terminals: { handle: string }[]
totalCount: number
truncated: boolean
}
).terminals[0] ?? { handle: '' }
).handle
expect(handle).toBeTruthy()
const showResponse = await sendRequest(metadata!.transport!.endpoint, {
id: 'req_show',
authToken: metadata!.authToken,
method: 'terminal.show',
params: {
terminal: handle
}
})
expect(showResponse).toMatchObject({
id: 'req_show',
ok: true
})
const readResponse = await sendRequest(metadata!.transport!.endpoint, {
id: 'req_read',
authToken: metadata!.authToken,
method: 'terminal.read',
params: {
terminal: handle
}
})
expect(readResponse).toMatchObject({
id: 'req_read',
ok: true
})
const sendResponse = await sendRequest(metadata!.transport!.endpoint, {
id: 'req_send',
authToken: metadata!.authToken,
method: 'terminal.send',
params: {
terminal: handle,
text: 'continue',
enter: true
}
})
expect(sendResponse).toMatchObject({
id: 'req_send',
ok: true
})
expect(writes).toEqual(['continue\r'])
const waitPromise = sendRequest(metadata!.transport!.endpoint, {
id: 'req_wait',
authToken: metadata!.authToken,
method: 'terminal.wait',
params: {
terminal: handle,
for: 'exit',
timeoutMs: 1000
}
})
runtime.onPtyExit('pty-1', 9)
const waitResponse = await waitPromise
expect(waitResponse).toMatchObject({
id: 'req_wait',
ok: true,
result: {
wait: {
handle,
condition: 'exit',
satisfied: true,
status: 'exited',
exitCode: 9
}
}
})
await server.stop()
})
it('serves worktree.ps from the runtime summary builder', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never)
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
runtime.onPtyData('pty-1', 'hello\n', 555)
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const response = await sendRequest(metadata!.transport!.endpoint, {
id: 'req_ps',
authToken: metadata!.authToken,
method: 'worktree.ps'
})
expect(response).toMatchObject({
id: 'req_ps',
ok: true,
result: {
worktrees: [
{
worktreeId: 'repo-1::/tmp/worktree-a',
repoId: 'repo-1',
repo: 'repo',
path: '/tmp/worktree-a',
branch: 'feature/foo',
linkedIssue: 123,
unread: true,
liveTerminalCount: 1,
hasAttachedPty: true,
lastOutputAt: 555,
preview: 'hello'
}
],
totalCount: 1,
truncated: false
}
})
await server.stop()
})
it('bounds worktree.list responses with limit metadata', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never)
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const response = await sendRequest(metadata!.transport!.endpoint, {
id: 'req_worktrees',
authToken: metadata!.authToken,
method: 'worktree.list',
params: {
limit: 1
}
})
expect(response).toMatchObject({
id: 'req_worktrees',
ok: true,
result: {
totalCount: 1,
truncated: false
}
})
await server.stop()
})
it('rejects oversized RPC frames instead of buffering them indefinitely', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const response = await new Promise<Record<string, unknown>>((resolve, reject) => {
const socket = createConnection(metadata!.transport!.endpoint)
let buffer = ''
socket.setEncoding('utf8')
socket.once('error', reject)
socket.on('data', (chunk) => {
buffer += chunk
const newlineIndex = buffer.indexOf('\n')
if (newlineIndex === -1) {
return
}
socket.end()
resolve(JSON.parse(buffer.slice(0, newlineIndex)) as Record<string, unknown>)
})
socket.on('connect', () => {
socket.write(`${'x'.repeat(1024 * 1024 + 1)}\n`)
})
})
expect(response).toMatchObject({
ok: false,
error: {
code: 'request_too_large'
}
})
await server.stop()
})
})

View File

@ -0,0 +1,748 @@
/* eslint-disable max-lines -- Why: the local RPC server is a single security boundary for the bundled CLI, so transport validation and method routing are intentionally reviewed together. */
import { randomBytes } from 'crypto'
import { createServer, type Server, type Socket } from 'net'
import { chmodSync, existsSync, rmSync } from 'fs'
import { join } from 'path'
import type { OrcaRuntimeService } from './orca-runtime'
import {
clearRuntimeMetadata,
type RuntimeMetadata,
type RuntimeTransportMetadata,
writeRuntimeMetadata
} from './runtime-metadata'
type RuntimeRpcRequest = {
id: string
authToken: string
method: string
params?: unknown
}
type RuntimeRpcResponse =
| {
id: string
ok: true
result: unknown
_meta: {
runtimeId: string
}
}
| {
id: string
ok: false
error: {
code: string
message: string
data?: unknown
}
_meta: {
runtimeId: string
}
}
type OrcaRuntimeRpcServerOptions = {
runtime: OrcaRuntimeService
userDataPath: string
pid?: number
platform?: NodeJS.Platform
}
const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024
const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000
const MAX_RUNTIME_RPC_CONNECTIONS = 32
export class OrcaRuntimeRpcServer {
private readonly runtime: OrcaRuntimeService
private readonly userDataPath: string
private readonly pid: number
private readonly platform: NodeJS.Platform
private readonly authToken = randomBytes(24).toString('hex')
private server: Server | null = null
private transport: RuntimeTransportMetadata | null = null
constructor({
runtime,
userDataPath,
pid = process.pid,
platform = process.platform
}: OrcaRuntimeRpcServerOptions) {
this.runtime = runtime
this.userDataPath = userDataPath
this.pid = pid
this.platform = platform
}
async start(): Promise<void> {
if (this.server) {
return
}
const transport = createRuntimeTransportMetadata(
this.userDataPath,
this.pid,
this.platform,
this.runtime.getRuntimeId()
)
if (transport.kind === 'unix' && existsSync(transport.endpoint)) {
rmSync(transport.endpoint, { force: true })
}
const server = createServer((socket) => {
this.handleConnection(socket)
})
server.maxConnections = MAX_RUNTIME_RPC_CONNECTIONS
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(transport.endpoint, () => {
server.off('error', reject)
resolve()
})
})
if (transport.kind === 'unix') {
chmodSync(transport.endpoint, 0o600)
}
this.server = server
this.transport = transport
this.writeMetadata()
}
async stop(): Promise<void> {
const server = this.server
const transport = this.transport
this.server = null
this.transport = null
clearRuntimeMetadata(this.userDataPath)
if (!server) {
return
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
if (transport?.kind === 'unix' && existsSync(transport.endpoint)) {
rmSync(transport.endpoint, { force: true })
}
}
private handleConnection(socket: Socket): void {
let buffer = ''
socket.setEncoding('utf8')
socket.setNoDelay(true)
socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => {
socket.destroy()
})
socket.on('error', () => {
socket.destroy()
})
socket.on('data', (chunk: string) => {
buffer += chunk
// Why: the Orca runtime lives in Electron main, so it must reject
// oversized local RPC frames instead of letting a local client grow an
// unbounded buffer and stall the app.
if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) {
socket.write(
`${JSON.stringify(this.errorResponse('unknown', 'request_too_large', 'RPC request exceeds the maximum size'))}\n`
)
socket.end()
return
}
let newlineIndex = buffer.indexOf('\n')
while (newlineIndex !== -1) {
const rawMessage = buffer.slice(0, newlineIndex).trim()
buffer = buffer.slice(newlineIndex + 1)
if (rawMessage) {
void this.handleMessage(rawMessage).then((response) => {
socket.write(`${JSON.stringify(response)}\n`)
})
}
newlineIndex = buffer.indexOf('\n')
}
})
}
private async handleMessage(rawMessage: string): Promise<RuntimeRpcResponse> {
let request: RuntimeRpcRequest
try {
request = JSON.parse(rawMessage) as RuntimeRpcRequest
} catch {
return this.errorResponse('unknown', 'bad_request', 'Invalid JSON request')
}
if (typeof request.id !== 'string' || request.id.length === 0) {
return this.errorResponse('unknown', 'bad_request', 'Missing request id')
}
if (typeof request.method !== 'string' || request.method.length === 0) {
return this.errorResponse(request.id, 'bad_request', 'Missing RPC method')
}
if (typeof request.authToken !== 'string' || request.authToken.length === 0) {
return this.errorResponse(request.id, 'unauthorized', 'Missing auth token')
}
if (request.authToken !== this.authToken) {
return this.errorResponse(request.id, 'unauthorized', 'Invalid auth token')
}
if (request.method === 'status.get') {
return {
id: request.id,
ok: true,
result: this.runtime.getStatus(),
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
}
if (request.method === 'terminal.list') {
try {
const params =
request.params && typeof request.params === 'object' && request.params !== null
? (request.params as { worktree?: unknown; limit?: unknown })
: null
const worktreeSelector = params?.worktree ?? null
const result = await this.runtime.listTerminals(
typeof worktreeSelector === 'string' ? worktreeSelector : undefined,
typeof params?.limit === 'number' && Number.isFinite(params.limit)
? params.limit
: undefined
)
return {
id: request.id,
ok: true,
result,
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'terminal.show') {
try {
const terminalHandle =
request.params && typeof request.params === 'object' && request.params !== null
? ((request.params as { terminal?: unknown }).terminal ?? null)
: null
if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle')
}
const result = await this.runtime.showTerminal(terminalHandle)
return {
id: request.id,
ok: true,
result: { terminal: result },
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'terminal.read') {
try {
const terminalHandle =
request.params && typeof request.params === 'object' && request.params !== null
? ((request.params as { terminal?: unknown }).terminal ?? null)
: null
if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle')
}
const result = await this.runtime.readTerminal(terminalHandle)
return {
id: request.id,
ok: true,
result: { terminal: result },
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'terminal.send') {
try {
const params =
request.params && typeof request.params === 'object' && request.params !== null
? (request.params as {
terminal?: unknown
text?: unknown
enter?: unknown
interrupt?: unknown
})
: null
const terminalHandle = params?.terminal ?? null
if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle')
}
const result = await this.runtime.sendTerminal(terminalHandle, {
text: typeof params?.text === 'string' ? params.text : undefined,
enter: params?.enter === true,
interrupt: params?.interrupt === true
})
return {
id: request.id,
ok: true,
result: { send: result },
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'terminal.wait') {
try {
const params =
request.params && typeof request.params === 'object' && request.params !== null
? (request.params as {
terminal?: unknown
for?: unknown
timeoutMs?: unknown
})
: null
const terminalHandle = params?.terminal ?? null
if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle')
}
if (params?.for !== 'exit') {
return this.errorResponse(
request.id,
'not_supported_in_v1',
'Only terminal wait --for exit is supported in focused v1'
)
}
const timeoutMs =
typeof params?.timeoutMs === 'number' && Number.isFinite(params.timeoutMs)
? params.timeoutMs
: undefined
const result = await this.runtime.waitForTerminal(terminalHandle, { timeoutMs })
return {
id: request.id,
ok: true,
result: { wait: result },
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'worktree.ps') {
try {
const limit =
request.params && typeof request.params === 'object' && request.params !== null
? ((request.params as { limit?: unknown }).limit ?? null)
: null
const result = await this.runtime.getWorktreePs(
typeof limit === 'number' && Number.isFinite(limit) ? limit : undefined
)
return {
id: request.id,
ok: true,
result,
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'repo.list') {
return {
id: request.id,
ok: true,
result: { repos: this.runtime.listRepos() },
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
}
if (request.method === 'repo.add') {
try {
const pathValue =
request.params && typeof request.params === 'object' && request.params !== null
? ((request.params as { path?: unknown }).path ?? null)
: null
if (typeof pathValue !== 'string' || pathValue.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing repo path')
}
const result = await this.runtime.addRepo(pathValue)
return {
id: request.id,
ok: true,
result: { repo: result },
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'repo.show') {
try {
const selector =
request.params && typeof request.params === 'object' && request.params !== null
? ((request.params as { repo?: unknown }).repo ?? null)
: null
if (typeof selector !== 'string' || selector.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing repo selector')
}
const result = await this.runtime.showRepo(selector)
return {
id: request.id,
ok: true,
result: { repo: result },
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'repo.setBaseRef') {
try {
const params =
request.params && typeof request.params === 'object' && request.params !== null
? (request.params as { repo?: unknown; ref?: unknown })
: null
const selector = params?.repo
const ref = params?.ref
if (typeof selector !== 'string' || selector.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing repo selector')
}
if (typeof ref !== 'string' || ref.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing base ref')
}
const result = await this.runtime.setRepoBaseRef(selector, ref)
return {
id: request.id,
ok: true,
result: { repo: result },
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'repo.searchRefs') {
try {
const params =
request.params && typeof request.params === 'object' && request.params !== null
? (request.params as { repo?: unknown; query?: unknown; limit?: unknown })
: null
const selector = params?.repo
const query = params?.query
if (typeof selector !== 'string' || selector.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing repo selector')
}
if (typeof query !== 'string') {
return this.errorResponse(request.id, 'invalid_argument', 'Missing query')
}
const result = await this.runtime.searchRepoRefs(
selector,
query,
typeof params?.limit === 'number' ? params.limit : undefined
)
return {
id: request.id,
ok: true,
result,
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'worktree.list') {
try {
const params =
request.params && typeof request.params === 'object' && request.params !== null
? (request.params as { repo?: unknown; limit?: unknown })
: null
const repoSelector = params?.repo ?? null
const result = await this.runtime.listManagedWorktrees(
typeof repoSelector === 'string' ? repoSelector : undefined,
typeof params?.limit === 'number' && Number.isFinite(params.limit)
? params.limit
: undefined
)
return {
id: request.id,
ok: true,
result,
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'worktree.show') {
try {
const selector =
request.params && typeof request.params === 'object' && request.params !== null
? ((request.params as { worktree?: unknown }).worktree ?? null)
: null
if (typeof selector !== 'string' || selector.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector')
}
const result = await this.runtime.showManagedWorktree(selector)
return {
id: request.id,
ok: true,
result: { worktree: result },
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'worktree.create') {
try {
const params =
request.params && typeof request.params === 'object' && request.params !== null
? (request.params as {
repo?: unknown
name?: unknown
baseBranch?: unknown
linkedIssue?: unknown
comment?: unknown
})
: null
const repoSelector = params?.repo
const name = params?.name
if (typeof repoSelector !== 'string' || repoSelector.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing repo selector')
}
if (typeof name !== 'string' || name.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree name')
}
const result = await this.runtime.createManagedWorktree({
repoSelector,
name,
baseBranch: typeof params?.baseBranch === 'string' ? params.baseBranch : undefined,
linkedIssue:
typeof params?.linkedIssue === 'number'
? params.linkedIssue
: params?.linkedIssue === null
? null
: undefined,
comment: typeof params?.comment === 'string' ? params.comment : undefined
})
return {
id: request.id,
ok: true,
result: { worktree: result },
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'worktree.set') {
try {
const params =
request.params && typeof request.params === 'object' && request.params !== null
? (request.params as {
worktree?: unknown
displayName?: unknown
linkedIssue?: unknown
comment?: unknown
})
: null
const selector = params?.worktree
if (typeof selector !== 'string' || selector.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector')
}
const result = await this.runtime.updateManagedWorktreeMeta(selector, {
displayName: typeof params?.displayName === 'string' ? params.displayName : undefined,
linkedIssue:
typeof params?.linkedIssue === 'number'
? params.linkedIssue
: params?.linkedIssue === null
? null
: undefined,
comment: typeof params?.comment === 'string' ? params.comment : undefined
})
return {
id: request.id,
ok: true,
result: { worktree: result },
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'worktree.rm') {
try {
const params =
request.params && typeof request.params === 'object' && request.params !== null
? (request.params as { worktree?: unknown; force?: unknown })
: null
const selector = params?.worktree
if (typeof selector !== 'string' || selector.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector')
}
await this.runtime.removeManagedWorktree(selector, params?.force === true)
return {
id: request.id,
ok: true,
result: { removed: true },
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
if (request.method === 'terminal.stop') {
try {
const params =
request.params && typeof request.params === 'object' && request.params !== null
? (request.params as { worktree?: unknown })
: null
const selector = params?.worktree
if (typeof selector !== 'string' || selector.length === 0) {
return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector')
}
const result = await this.runtime.stopTerminalsForWorktree(selector)
return {
id: request.id,
ok: true,
result: result,
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
} catch (error) {
return this.runtimeErrorResponse(request.id, error)
}
}
return this.errorResponse(request.id, 'method_not_found', `Unknown method: ${request.method}`)
}
private errorResponse(id: string, code: string, message: string): RuntimeRpcResponse {
return {
id,
ok: false,
error: {
code,
message
},
_meta: {
runtimeId: this.runtime.getRuntimeId()
}
}
}
private runtimeErrorResponse(id: string, error: unknown): RuntimeRpcResponse {
const message = error instanceof Error ? error.message : String(error)
if (
message === 'runtime_unavailable' ||
message === 'selector_not_found' ||
message === 'selector_ambiguous' ||
message === 'terminal_handle_stale' ||
message === 'terminal_not_writable' ||
message === 'repo_not_found' ||
message === 'timeout' ||
message === 'invalid_limit'
) {
return this.errorResponse(id, message, message)
}
if (message === 'invalid_terminal_send') {
return this.errorResponse(id, 'invalid_argument', 'Missing terminal send payload')
}
return this.errorResponse(id, 'runtime_error', message)
}
private writeMetadata(): void {
const metadata: RuntimeMetadata = {
runtimeId: this.runtime.getRuntimeId(),
pid: this.pid,
transport: this.transport,
authToken: this.authToken,
startedAt: this.runtime.getStartedAt()
}
writeRuntimeMetadata(this.userDataPath, metadata)
}
}
export function createRuntimeTransportMetadata(
userDataPath: string,
pid: number,
platform: NodeJS.Platform,
runtimeId = 'runtime'
): RuntimeTransportMetadata {
const endpointSuffix = runtimeId.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 4) || 'rt'
if (platform === 'win32') {
return {
kind: 'named-pipe',
// Why: Windows named pipes do not get the same chmod hardening path as
// Unix sockets, so include a per-runtime suffix to avoid exposing a
// stable, guessable control endpoint name across launches.
endpoint: `\\\\.\\pipe\\orca-${pid}-${endpointSuffix}`
}
}
return {
kind: 'unix',
endpoint: join(userDataPath, `o-${pid}-${endpointSuffix}.sock`)
}
}

View File

@ -63,15 +63,23 @@ describe('attachMainWindowServices', () => {
it('only allows the explicit permission allowlist', () => {
const mainWindow = {
on: vi.fn(),
webContents: {
on: vi.fn(),
session: {
setPermissionRequestHandler: setPermissionRequestHandlerMock
}
}
}
const store = { flush: vi.fn() }
const runtime = {
attachWindow: vi.fn(),
setNotifier: vi.fn(),
markRendererReloading: vi.fn(),
markGraphUnavailable: vi.fn()
}
attachMainWindowServices(mainWindow as never, store as never)
attachMainWindowServices(mainWindow as never, store as never, runtime as never)
expect(setPermissionRequestHandlerMock).toHaveBeenCalledTimes(1)
const permissionHandler = setPermissionRequestHandlerMock.mock.calls[0][0]
@ -84,4 +92,47 @@ describe('attachMainWindowServices', () => {
expect(callback.mock.calls).toEqual([[true], [true], [true], [false]])
})
it('forwards runtime notifier events to the renderer', () => {
const sendMock = vi.fn()
const webContentsOnMock = vi.fn()
const mainWindowOnMock = vi.fn()
const mainWindow = {
isDestroyed: vi.fn(() => false),
on: mainWindowOnMock,
webContents: {
on: webContentsOnMock,
send: sendMock,
session: {
setPermissionRequestHandler: setPermissionRequestHandlerMock
}
}
}
const store = { flush: vi.fn() }
const runtime = {
attachWindow: vi.fn(),
setNotifier: vi.fn(),
markRendererReloading: vi.fn(),
markGraphUnavailable: vi.fn()
}
attachMainWindowServices(mainWindow as never, store as never, runtime as never)
expect(runtime.setNotifier).toHaveBeenCalledTimes(1)
const notifier = runtime.setNotifier.mock.calls[0][0] as {
worktreesChanged: (repoId: string) => void
reposChanged: () => void
activateWorktree: (repoId: string, worktreeId: string) => void
}
notifier.worktreesChanged('repo-1')
notifier.reposChanged()
notifier.activateWorktree('repo-1', 'wt-1')
expect(sendMock.mock.calls).toEqual([
['worktrees:changed', { repoId: 'repo-1' }],
['repos:changed'],
['ui:activateWorktree', { repoId: 'repo-1', worktreeId: 'wt-1' }]
])
})
})

View File

@ -4,6 +4,7 @@ import type { Store } from '../persistence'
import { registerRepoHandlers } from '../ipc/repos'
import { registerWorktreeHandlers } from '../ipc/worktrees'
import { registerPtyHandlers } from '../ipc/pty'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import {
checkForUpdatesFromMenu,
downloadUpdate,
@ -12,10 +13,14 @@ import {
setupAutoUpdater
} from '../updater'
export function attachMainWindowServices(mainWindow: BrowserWindow, store: Store): void {
export function attachMainWindowServices(
mainWindow: BrowserWindow,
store: Store,
runtime: OrcaRuntimeService
): void {
registerRepoHandlers(mainWindow, store)
registerWorktreeHandlers(mainWindow, store)
registerPtyHandlers(mainWindow)
registerPtyHandlers(mainWindow, runtime)
registerFileDropRelay(mainWindow)
setupAutoUpdater(mainWindow, {
getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt,
@ -24,6 +29,7 @@ export function attachMainWindowServices(mainWindow: BrowserWindow, store: Store
store.updateUI({ lastUpdateCheckAt: timestamp })
}
})
registerRuntimeWindowLifecycle(mainWindow, runtime)
const allowedPermissions = new Set(['media', 'fullscreen', 'pointerLock'])
mainWindow.webContents.session.setPermissionRequestHandler(
@ -33,6 +39,39 @@ export function attachMainWindowServices(mainWindow: BrowserWindow, store: Store
)
}
function registerRuntimeWindowLifecycle(
mainWindow: BrowserWindow,
runtime: OrcaRuntimeService
): void {
runtime.attachWindow(mainWindow.id)
runtime.setNotifier({
worktreesChanged: (repoId) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('worktrees:changed', { repoId })
}
},
reposChanged: () => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('repos:changed')
}
},
activateWorktree: (repoId, worktreeId) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('ui:activateWorktree', { repoId, worktreeId })
}
}
})
// Why: the runtime must fail closed while the renderer graph is being torn
// down or rebuilt, otherwise future CLI calls could act on stale terminal
// mappings during reload transitions.
mainWindow.webContents.on('did-start-loading', () => {
runtime.markRendererReloading(mainWindow.id)
})
mainWindow.on('closed', () => {
runtime.markGraphUnavailable(mainWindow.id)
})
}
function registerFileDropRelay(mainWindow: BrowserWindow): void {
ipcMain.removeAllListeners('terminal:file-dropped-from-preload')
ipcMain.on('terminal:file-dropped-from-preload', (_event, args: { paths: string[] }) => {

View File

@ -1,4 +1,5 @@
import type { ElectronAPI } from '@electron-toolkit/preload'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import type {
Repo,
Worktree,
@ -19,6 +20,7 @@ import type {
SearchOptions,
SearchResult
} from '../../shared/types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types'
type ReposApi = {
list: () => Promise<Repo[]>
@ -76,6 +78,12 @@ type SettingsApi = {
listFonts: () => Promise<string[]>
}
type CliApi = {
getInstallStatus: () => Promise<CliInstallStatus>
install: () => Promise<CliInstallStatus>
remove: () => Promise<CliInstallStatus>
}
type ShellApi = {
openPath: (path: string) => Promise<void>
openUrl: (url: string) => Promise<void>
@ -121,6 +129,9 @@ type UIApi = {
get: () => Promise<PersistedUIState>
set: (args: Partial<PersistedUIState>) => Promise<void>
onOpenSettings: (callback: () => void) => () => void
onActivateWorktree: (
callback: (data: { repoId: string; worktreeId: string }) => void
) => () => void
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
readClipboardText: () => Promise<string>
writeClipboardText: (text: string) => Promise<void>
@ -129,6 +140,11 @@ type UIApi = {
setZoomLevel: (level: number) => void
}
type RuntimeApi = {
syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise<RuntimeStatus>
getStatus: () => Promise<RuntimeStatus>
}
type FsApi = {
readDir: (args: { dirPath: string }) => Promise<DirEntry[]>
readFile: (args: {
@ -185,6 +201,7 @@ type Api = {
pty: PtyApi
gh: GhApi
settings: SettingsApi
cli: CliApi
shell: ShellApi
hooks: HooksApi
cache: CacheApi
@ -193,6 +210,7 @@ type Api = {
fs: FsApi
git: GitApi
ui: UIApi
runtime: RuntimeApi
}
declare global {

View File

@ -1,5 +1,7 @@
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import type { CliInstallStatus } from '../shared/cli-install-types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types'
// ---------------------------------------------------------------------------
// File drag-and-drop: handled here in the preload because webUtils (which
@ -168,6 +170,12 @@ const api = {
listFonts: (): Promise<string[]> => ipcRenderer.invoke('settings:listFonts')
},
cli: {
getInstallStatus: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:getInstallStatus'),
install: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:install'),
remove: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:remove')
},
shell: {
openPath: (path: string): Promise<void> => ipcRenderer.invoke('shell:openPath', path),
@ -299,6 +307,16 @@ const api = {
ipcRenderer.on('ui:openSettings', listener)
return () => ipcRenderer.removeListener('ui:openSettings', listener)
},
onActivateWorktree: (
callback: (data: { repoId: string; worktreeId: string }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { repoId: string; worktreeId: string }
) => callback(data)
ipcRenderer.on('ui:activateWorktree', listener)
return () => ipcRenderer.removeListener('ui:activateWorktree', listener)
},
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') =>
callback(direction)
@ -315,6 +333,12 @@ const api = {
},
getZoomLevel: (): number => webFrame.getZoomLevel(),
setZoomLevel: (level: number): void => webFrame.setZoomLevel(level)
},
runtime: {
syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise<RuntimeStatus> =>
ipcRenderer.invoke('runtime:syncWindowGraph', graph),
getStatus: (): Promise<RuntimeStatus> => ipcRenderer.invoke('runtime:getStatus')
}
}

View File

@ -16,6 +16,10 @@ import RightSidebar from './components/right-sidebar'
import QuickOpen from './components/QuickOpen'
import UpdateReminder from './components/UpdateReminder'
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
import {
setRuntimeGraphStoreStateGetter,
setRuntimeGraphSyncEnabled
} from './runtime/sync-runtime-graph'
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
@ -139,11 +143,24 @@ function App(): React.JSX.Element {
hydrateWorkspaceSession
])
useEffect(() => {
setRuntimeGraphStoreStateGetter(useAppStore.getState)
return () => {
setRuntimeGraphStoreStateGetter(null)
}
}, [])
useEffect(() => {
setRuntimeGraphSyncEnabled(workspaceSessionReady)
return () => {
setRuntimeGraphSyncEnabled(false)
}
}, [workspaceSessionReady])
useEffect(() => {
if (!workspaceSessionReady) {
return
}
const timer = window.setTimeout(() => {
void window.api.session.set({
activeRepoId,

View File

@ -0,0 +1,285 @@
import { useEffect, useState } from 'react'
import { Copy, FolderOpen, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { Label } from '../ui/label'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
type CliSectionProps = {
currentPlatform: string
}
const ORCA_SKILL_INSTALL_COMMAND = 'npx skills add orca-cli'
function getRevealLabel(platform: string): string {
if (platform === 'darwin') {
return 'Show in Finder'
}
if (platform === 'win32') {
return 'Show in Explorer'
}
return 'Show in File Manager'
}
function getInstallDescription(platform: string): string {
if (platform === 'darwin') {
return 'Register `orca` in /usr/local/bin.'
}
if (platform === 'linux') {
return 'Register `orca` in ~/.local/bin.'
}
if (platform === 'win32') {
return 'Register `orca` in your user PATH.'
}
return 'CLI registration is not yet available on this platform.'
}
export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Element {
const [status, setStatus] = useState<CliInstallStatus | null>(null)
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null)
const refreshStatus = async (): Promise<void> => {
setLoading(true)
try {
setStatus(await window.api.cli.getInstallStatus())
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to load CLI status.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void refreshStatus()
}, [])
const isEnabled = status?.state === 'installed'
const isSupported = status?.supported ?? false
const revealLabel = getRevealLabel(currentPlatform)
const canRevealCommandPath =
status?.commandPath != null && ['installed', 'stale', 'conflict'].includes(status.state)
const handleInstall = async (): Promise<void> => {
setBusyAction('install')
try {
const next = await window.api.cli.install()
setStatus(next)
setDialogOpen(false)
toast.success('Registered `orca` in PATH.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to register `orca` in PATH.')
} finally {
setBusyAction(null)
}
}
const handleRemove = async (): Promise<void> => {
setBusyAction('remove')
try {
const next = await window.api.cli.remove()
setStatus(next)
setDialogOpen(false)
toast.success('Removed `orca` from PATH.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to remove `orca` from PATH.')
} finally {
setBusyAction(null)
}
}
const handleCopySkillInstallCommand = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(ORCA_SKILL_INSTALL_COMMAND)
toast.success('Copied Orca skill install command.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to copy install command.')
}
}
return (
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Orca CLI</h2>
<p className="text-xs text-muted-foreground">
Use Orca from your terminal to open the app, manage worktrees, and interact with Orca
terminals.
</p>
</div>
<div className="space-y-3 rounded-xl border border-border/60 bg-card/50 p-4">
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label>Shell command</Label>
<p className="text-xs text-muted-foreground">
{loading
? 'Checking CLI registration…'
: (status?.detail ?? getInstallDescription(currentPlatform))}
</p>
</div>
<div className="flex items-center gap-2">
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void refreshStatus()}
disabled={loading || busyAction !== null}
aria-label="Refresh CLI status"
>
<RefreshCw className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Refresh
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
role="switch"
aria-checked={isEnabled}
disabled={loading || !isSupported || busyAction !== null}
onClick={() => setDialogOpen(true)}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
isEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
} ${loading || !isSupported || busyAction !== null ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
isEnabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</div>
{status?.commandPath ? (
<p className="text-xs text-muted-foreground">
Command path:{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{status.commandPath}</code>
</p>
) : null}
{status?.state === 'stale' && status.currentTarget ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
Existing launcher target: <code>{status.currentTarget}</code>
</p>
) : null}
{status?.state === 'installed' && !status.pathConfigured && status.pathDirectory ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
{status.pathDirectory} is not currently visible on PATH for this shell.
</p>
) : null}
{!loading && !isSupported && status?.detail ? (
<p className="text-xs text-muted-foreground">{status.detail}</p>
) : null}
<div className="flex items-center gap-2">
{status?.commandPath ? (
<Button
variant="ghost"
size="sm"
onClick={() => void window.api.shell.openPath(status.commandPath as string)}
disabled={loading || !canRevealCommandPath}
className="gap-2"
>
<FolderOpen className="size-3.5" />
{revealLabel}
</Button>
) : null}
</div>
<div className="border-t border-border/60 pt-3">
<div className="space-y-0.5">
<Label>Agent skill</Label>
<p className="text-xs text-muted-foreground">
Install the Orca skill so agents know to use the{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">orca</code> CLI.
</p>
</div>
<div className="mt-3 space-y-1">
<p className="text-xs text-muted-foreground">Install command</p>
<div className="inline-flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
<code className="overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
{ORCA_SKILL_INSTALL_COMMAND}
</code>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void handleCopySkillInstallCommand()}
aria-label="Copy skill install command"
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Copy
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEnabled ? 'Remove `orca` from PATH?' : 'Register `orca` in PATH?'}
</DialogTitle>
<DialogDescription>
{isEnabled
? 'This removes the shell command symlink. Orca itself remains installed.'
: `Orca will register ${status?.commandPath ?? '`orca`'} so the command works from your terminal.`}
</DialogDescription>
</DialogHeader>
{status?.commandPath ? (
<p className="text-xs text-muted-foreground">
Target path:{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{status.commandPath}</code>
</p>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={busyAction !== null}
>
Cancel
</Button>
<Button
onClick={() => void (isEnabled ? handleRemove() : handleInstall())}
disabled={busyAction !== null || !isSupported}
>
{busyAction === 'remove'
? 'Removing…'
: busyAction === 'install'
? 'Registering…'
: isEnabled
? 'Remove'
: 'Register'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
)
}

View File

@ -6,6 +6,7 @@ import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { Download, FolderOpen, Loader2, RefreshCw } from 'lucide-react'
import { useAppStore } from '../../store'
import { CliSection } from './CliSection'
type GeneralPaneProps = {
settings: GlobalSettings
@ -95,6 +96,10 @@ export function GeneralPane({
<Separator />
<CliSection currentPlatform={navigator.userAgent.includes('Mac') ? 'darwin' : 'other'} />
<Separator />
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Branch Naming</h2>

View File

@ -1,5 +1,6 @@
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
import { isGeminiTerminalTitle } from '@/lib/agent-status'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import type { PtyTransport } from './pty-transport'
import { createIpcPtyTransport } from './pty-transport'
@ -24,6 +25,10 @@ export function connectPanePty(
): void {
const onExit = (ptyId: string): void => {
deps.clearTabPtyId(deps.tabId, ptyId)
// The runtime graph is the CLI's source for live terminal bindings, so
// we must republish when a pane loses its PTY instead of waiting for a
// broader layout change that may never happen.
scheduleRuntimeGraphSync()
manager.setPaneGpuRendering(pane.id, true)
const panes = manager.getPanes()
if (panes.length <= 1) {
@ -38,7 +43,12 @@ export function connectPanePty(
deps.updateTabTitle(deps.tabId, title)
}
const onPtySpawn = (ptyId: string): void => deps.updateTabPtyId(deps.tabId, ptyId)
const onPtySpawn = (ptyId: string): void => {
deps.updateTabPtyId(deps.tabId, ptyId)
// Spawn completion is when a pane gains a concrete PTY ID. The initial
// frame-level sync often runs before that async result arrives.
scheduleRuntimeGraphSync()
}
const onBell = (): void => deps.markWorktreeUnread(deps.worktreeId)
const onAgentBecameIdle = (): void => deps.markWorktreeUnread(deps.worktreeId)
@ -86,5 +96,6 @@ export function connectPanePty(
}
}
})
scheduleRuntimeGraphSync()
})
}

View File

@ -27,6 +27,7 @@ export type PtyTransport = {
meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number }
) => boolean
isConnected: () => boolean
getPtyId: () => string | null
destroy?: () => void | Promise<void>
}
@ -220,6 +221,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
return connected
},
getPtyId() {
return ptyId
},
destroy() {
destroyed = true
this.disconnect()

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: terminal pane lifecycle wiring is intentionally co-located so PTY attach, theme sync, and runtime graph publication remain consistent for live terminals. */
import { useEffect, useRef } from 'react'
import type { IDisposable } from '@xterm/xterm'
import { PaneManager } from '@/lib/pane-manager/pane-manager'
@ -16,6 +17,7 @@ import { applyTerminalAppearance } from './terminal-appearance'
import { connectPanePty } from './pty-connection'
import type { PtyTransport } from './pty-transport'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
type UseTerminalPaneLifecycleDeps = {
tabId: string
@ -159,6 +161,14 @@ export function useTerminalPaneLifecycle({
markWorktreeUnread
}
const unregisterRuntimeTab = registerRuntimeTerminalTab({
tabId,
worktreeId,
getManager: () => managerRef.current,
getContainer: () => containerRef.current,
getPtyIdForPane: (paneId) => paneTransportsRef.current.get(paneId)?.getPtyId() ?? null
})
const isMac = navigator.userAgent.includes('Mac')
const openLinkHint = isMac ? '⌘+click to open' : 'Ctrl+click to open'
@ -184,6 +194,7 @@ export function useTerminalPaneLifecycle({
}
applyAppearance(manager)
connectPanePty(pane, manager, ptyDeps)
scheduleRuntimeGraphSync()
queueResizeAll(true)
},
onPaneClosed: (paneId) => {
@ -199,13 +210,16 @@ export function useTerminalPaneLifecycle({
}
paneFontSizesRef.current.delete(paneId)
pendingWritesRef.current.delete(paneId)
scheduleRuntimeGraphSync()
},
onActivePaneChange: () => {
scheduleRuntimeGraphSync()
if (shouldPersistLayout) {
persistLayoutSnapshot()
}
},
onLayoutChanged: () => {
scheduleRuntimeGraphSync()
syncExpandedLayout()
syncCanExpandState()
queueResizeAll(false)
@ -259,7 +273,6 @@ export function useTerminalPaneLifecycle({
if (restoredActivePaneId !== null) {
manager.setActivePane(restoredActivePaneId, { focus: isActive })
}
const restoredExpandedPaneId = initialLayoutRef.current.expandedLeafId
? (restoredPaneByLeafId.get(initialLayoutRef.current.expandedLeafId) ?? null)
: null
@ -278,8 +291,10 @@ export function useTerminalPaneLifecycle({
applyAppearance(manager)
queueResizeAll(isActive)
persistLayoutSnapshot()
scheduleRuntimeGraphSync()
return () => {
unregisterRuntimeTab()
if (resizeRaf !== null) {
cancelAnimationFrame(resizeRaf)
}

View File

@ -33,6 +33,25 @@ export function useIpcEvents(): void {
})
)
unsubs.push(
window.api.ui.onActivateWorktree(({ repoId, worktreeId }) => {
void (async () => {
const store = useAppStore.getState()
await store.fetchWorktrees(repoId)
// Why: CLI-created worktrees should feel identical to UI-created
// worktrees. The renderer owns the "active worktree -> first tab"
// behavior today, so we explicitly replay that activation sequence
// after the runtime creates a worktree outside the renderer.
store.setActiveRepo(repoId)
store.setActiveView('terminal')
store.setActiveWorktree(worktreeId)
store.revealWorktreeInSidebar(worktreeId)
})().catch((error) => {
console.error('Failed to activate CLI-created worktree:', error)
})
})
)
// Hydrate initial update status then subscribe to changes
window.api.updater.getStatus().then((status) => {
useAppStore.getState().setUpdateStatus(status as UpdateStatus)

View File

@ -0,0 +1,102 @@
import { paneLeafId, serializePaneTree } from '@/components/terminal-pane/layout-serialization'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { AppState } from '@/store/types'
import type { RuntimeSyncWindowGraph } from '../../../shared/runtime-types'
type RegisteredTerminalTab = {
tabId: string
worktreeId: string
getManager: () => PaneManager | null
getContainer: () => HTMLDivElement | null
getPtyIdForPane: (paneId: number) => string | null
}
const registeredTabs = new Map<string, RegisteredTerminalTab>()
let syncScheduled = false
let syncEnabled = false
let getStoreState: (() => AppState) | null = null
export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null): void {
getStoreState = getter
}
export function registerRuntimeTerminalTab(tab: RegisteredTerminalTab): () => void {
registeredTabs.set(tab.tabId, tab)
scheduleRuntimeGraphSync()
return () => {
registeredTabs.delete(tab.tabId)
scheduleRuntimeGraphSync()
}
}
export function setRuntimeGraphSyncEnabled(enabled: boolean): void {
syncEnabled = enabled
if (enabled) {
scheduleRuntimeGraphSync()
}
}
export function scheduleRuntimeGraphSync(): void {
if (!syncEnabled || syncScheduled) {
return
}
syncScheduled = true
queueMicrotask(() => {
syncScheduled = false
void syncRuntimeGraph()
})
}
async function syncRuntimeGraph(): Promise<void> {
if (!syncEnabled || !getStoreState) {
return
}
// Why: the runtime graph helper cannot import the Zustand store directly
// because the terminal slice also imports this module to schedule syncs.
// Injecting the getter from App keeps the runtime graph path out of the
// store construction cycle and avoids test-time partial initialization.
const state = getStoreState()
const graph: RuntimeSyncWindowGraph = {
tabs: [],
leaves: []
}
for (const [tabId, registeredTab] of registeredTabs) {
const tab = Object.values(state.tabsByWorktree)
.flat()
.find((candidate) => candidate.id === tabId)
if (!tab) {
continue
}
const manager = registeredTab.getManager()
const container = registeredTab.getContainer()
const activePaneId = manager?.getActivePane()?.id ?? null
const root =
container?.firstElementChild instanceof HTMLElement ? container.firstElementChild : null
graph.tabs.push({
tabId,
worktreeId: registeredTab.worktreeId,
title: tab.customTitle ?? tab.title,
activeLeafId: activePaneId === null ? null : paneLeafId(activePaneId),
layout: serializePaneTree(root)
})
for (const pane of manager?.getPanes() ?? []) {
graph.leaves.push({
tabId,
worktreeId: registeredTab.worktreeId,
leafId: paneLeafId(pane.id),
paneRuntimeId: pane.id,
ptyId: registeredTab.getPtyIdForPane(pane.id)
})
}
}
try {
await window.api.runtime.syncWindowGraph(graph)
} catch (error) {
console.error('[runtime] Failed to sync renderer graph:', error)
}
}

View File

@ -6,6 +6,7 @@ import type {
TerminalTab,
WorkspaceSessionState
} from '../../../../shared/types'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
export type TerminalSlice = {
@ -166,6 +167,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
if (!changed) {
return s
}
scheduleRuntimeGraphSync()
// Agent status is derived from terminal titles and affects sort scoring,
// so a title change is a meaningful event that should allow re-sort —
// but only for background worktrees. Title changes in the active
@ -186,6 +188,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
for (const wId of Object.keys(next)) {
next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, customTitle: title } : t))
}
scheduleRuntimeGraphSync()
return { tabsByWorktree: next }
})
},

View File

@ -0,0 +1,23 @@
export type CliInstallState = 'installed' | 'not_installed' | 'stale' | 'conflict' | 'unsupported'
export type CliInstallUnsupportedReason =
| 'platform_not_supported'
| 'launcher_missing'
| 'launch_mode_unavailable'
export type CliInstallMethod = 'symlink' | 'wrapper'
export type CliInstallStatus = {
platform: NodeJS.Platform
commandName: string
commandPath: string | null
pathDirectory: string | null
pathConfigured: boolean
launcherPath: string | null
installMethod: CliInstallMethod | null
supported: boolean
state: CliInstallState
currentTarget: string | null
unsupportedReason: CliInstallUnsupportedReason | null
detail: string | null
}

149
src/shared/runtime-types.ts Normal file
View File

@ -0,0 +1,149 @@
import type { TerminalPaneLayoutNode } from './types'
import type { GitWorktreeInfo, Repo } from './types'
export type RuntimeGraphStatus = 'ready' | 'reloading' | 'unavailable'
export type RuntimeStatus = {
runtimeId: string
rendererGraphEpoch: number
graphStatus: RuntimeGraphStatus
authoritativeWindowId: number | null
liveTabCount: number
liveLeafCount: number
}
export type CliRuntimeState = 'not_running' | 'starting' | 'ready' | 'graph_not_ready'
export type CliStatusResult = {
app: {
running: boolean
pid: number | null
}
runtime: {
state: CliRuntimeState
reachable: boolean
runtimeId: string | null
}
graph: {
state: RuntimeGraphStatus | 'not_running' | 'starting'
}
}
export type RuntimeSyncedTab = {
tabId: string
worktreeId: string
title: string | null
activeLeafId: string | null
layout: TerminalPaneLayoutNode | null
}
export type RuntimeSyncedLeaf = {
tabId: string
worktreeId: string
leafId: string
paneRuntimeId: number
ptyId: string | null
}
export type RuntimeSyncWindowGraph = {
tabs: RuntimeSyncedTab[]
leaves: RuntimeSyncedLeaf[]
}
export type RuntimeTerminalSummary = {
handle: string
worktreeId: string
worktreePath: string
branch: string
tabId: string
leafId: string
title: string | null
connected: boolean
writable: boolean
lastOutputAt: number | null
preview: string
}
export type RuntimeTerminalListResult = {
terminals: RuntimeTerminalSummary[]
totalCount: number
truncated: boolean
}
export type RuntimeTerminalShow = RuntimeTerminalSummary & {
paneRuntimeId: number
ptyId: string | null
rendererGraphEpoch: number
}
export type RuntimeTerminalState = 'running' | 'exited' | 'unknown'
export type RuntimeTerminalRead = {
handle: string
status: RuntimeTerminalState
tail: string[]
truncated: boolean
nextCursor: string | null
}
export type RuntimeTerminalSend = {
handle: string
accepted: boolean
bytesWritten: number
}
export type RuntimeTerminalWaitCondition = 'exit'
export type RuntimeTerminalWait = {
handle: string
condition: RuntimeTerminalWaitCondition
satisfied: boolean
status: RuntimeTerminalState
exitCode: number | null
}
export type RuntimeWorktreePsSummary = {
worktreeId: string
repoId: string
repo: string
path: string
branch: string
linkedIssue: number | null
unread: boolean
liveTerminalCount: number
hasAttachedPty: boolean
lastOutputAt: number | null
preview: string
}
export type RuntimeWorktreeRecord = {
id: string
repoId: string
path: string
branch: string
linkedIssue: number | null
git: GitWorktreeInfo
displayName: string
comment: string
}
export type RuntimeWorktreePsResult = {
worktrees: RuntimeWorktreePsSummary[]
totalCount: number
truncated: boolean
}
export type RuntimeRepoList = {
repos: Repo[]
}
export type RuntimeRepoSearchRefs = {
refs: string[]
truncated: boolean
}
export type RuntimeWorktreeListResult = {
worktrees: RuntimeWorktreeRecord[]
totalCount: number
truncated: boolean
}

11
tsconfig.cli.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["src/cli/**/*", "src/shared/**/*", "src/main/runtime/runtime-metadata.ts"],
"compilerOptions": {
"composite": true,
"module": "CommonJS",
"moduleResolution": "Node",
"rootDir": "src",
"outDir": "out"
}
}