diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2fc5b67b7..1316ffd26 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,7 +24,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 with: - version: latest run_install: false - name: Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bae580201..f5b43cc1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 with: - version: latest run_install: false - name: Install dependencies diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..4235c3b8c --- /dev/null +++ b/docs/README.md @@ -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. diff --git a/docs/orca-cli-bundled-distribution.md b/docs/orca-cli-bundled-distribution.md new file mode 100644 index 000000000..a46fde660 --- /dev/null +++ b/docs/orca-cli-bundled-distribution.md @@ -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 `\\bin` +- let the installer add `\\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 `\\bin` on PATH. +5. Show install/status/help flows in Settings. + +This is the smallest coherent implementation that matches the desired product story. diff --git a/docs/orca-cli-focused-v1-status.md b/docs/orca-cli-focused-v1-status.md new file mode 100644 index 000000000..9884fb0ed --- /dev/null +++ b/docs/orca-cli-focused-v1-status.md @@ -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. diff --git a/docs/orca-cli-v1-spec.md b/docs/orca-cli-v1-spec.md new file mode 100644 index 000000000..dbd055346 --- /dev/null +++ b/docs/orca-cli-v1-spec.md @@ -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:` +- `path:` +- `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:` +- `path:` +- `branch:` +- `issue:` + +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:` 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 ` + +Human-friendly targeting like `title:` 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 ` +- `--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 ` + +Later extension: + +- `--terminal ` + +## `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. diff --git a/docs/orca-runtime-layer-design.md b/docs/orca-runtime-layer-design.md new file mode 100644 index 000000000..3ca438535 --- /dev/null +++ b/docs/orca-runtime-layer-design.md @@ -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. diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs new file mode 100644 index 000000000..f3052b00b --- /dev/null +++ b/electron-builder.config.cjs @@ -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' + } +} diff --git a/electron-builder.yml b/electron-builder.yml deleted file mode 100644 index 3402568ae..000000000 --- a/electron-builder.yml +++ /dev/null @@ -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 diff --git a/package.json b/package.json index d5edbe509..4f230c188 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/darwin/bin/orca b/resources/darwin/bin/orca new file mode 100755 index 000000000..b5488efe2 --- /dev/null +++ b/resources/darwin/bin/orca @@ -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" "$@" diff --git a/resources/linux/bin/orca b/resources/linux/bin/orca new file mode 100644 index 000000000..e8f6d80e6 --- /dev/null +++ b/resources/linux/bin/orca @@ -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" "$@" diff --git a/resources/win32/bin/orca.cmd b/resources/win32/bin/orca.cmd new file mode 100644 index 000000000..bce7919f7 --- /dev/null +++ b/resources/win32/bin/orca.cmd @@ -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%" %* diff --git a/scripts/verify-macos-release-env.mjs b/scripts/verify-macos-release-env.mjs new file mode 100644 index 000000000..c96248f5e --- /dev/null +++ b/scripts/verify-macos-release-env.mjs @@ -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) +} diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md new file mode 100644 index 000000000..37b0b4b86 --- /dev/null +++ b/skills/orca-cli/SKILL.md @@ -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: --json +orca repo add --path /abs/repo --json +orca repo set-base-ref --repo id: --ref origin/main --json +orca repo search-refs --repo id: --query main --limit 10 --json +``` + +### Worktree + +```bash +orca worktree list --repo id: --json +orca worktree ps --json +orca worktree show --worktree id: --json +orca worktree create --repo id: --name my-task --issue 123 --comment "seed" --json +orca worktree set --worktree id: --display-name "My Task" --json +orca worktree rm --worktree id: --force --json +``` + +Worktree selectors supported in focused v1: + +- `id:` +- `path:` +- `branch:` +- `issue:` + +### Terminal + +Use selectors to discover terminals, then use the returned handle for repeated live interaction. + +```bash +orca terminal list --worktree id: --json +orca terminal show --terminal --json +orca terminal read --terminal --json +orca terminal send --terminal --text "continue" --enter --json +orca terminal wait --terminal --for exit --timeout-ms 5000 --json +orca terminal stop --worktree id: --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` diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 000000000..40d62f6ca --- /dev/null +++ b/src/cli/index.ts @@ -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 +} + +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 [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'path'] + }, + { + path: ['repo', 'show'], + summary: 'Show one registered repo', + usage: 'orca repo show --repo [--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 --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 --query [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'repo', 'query', 'limit'] + }, + { + path: ['worktree', 'list'], + summary: 'List Orca-managed worktrees', + usage: 'orca worktree list [--repo ] [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'repo', 'limit'] + }, + { + path: ['worktree', 'show'], + summary: 'Show one worktree', + usage: 'orca worktree show --worktree [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['worktree', 'create'], + summary: 'Create a new Orca-managed worktree', + usage: + 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--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 [--display-name ] [--issue ] [--comment ] [--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 [--force] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force'] + }, + { + path: ['worktree', 'ps'], + summary: 'Show a compact orchestration summary across worktrees', + usage: 'orca worktree ps [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'limit'] + }, + { + path: ['terminal', 'list'], + summary: 'List live Orca-managed terminals', + usage: 'orca terminal list [--worktree ] [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'limit'] + }, + { + path: ['terminal', 'show'], + summary: 'Show terminal metadata and preview', + usage: 'orca terminal show --terminal [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal'] + }, + { + path: ['terminal', 'read'], + summary: 'Read bounded terminal output', + usage: 'orca terminal read --terminal [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal'] + }, + { + path: ['terminal', 'send'], + summary: 'Send input to a live terminal', + usage: + 'orca terminal send --terminal [--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 --for exit [--timeout-ms ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'for', 'timeout-ms'] + }, + { + path: ['terminal', 'stop'], + summary: 'Stop terminals for a worktree', + usage: 'orca terminal stop --worktree [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + } +] + +async function main(): Promise { + 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('repo.list') + return printResult(result, json, formatRepoList) + } + + if (matches(commandPath, ['repo', 'add'])) { + const result = await client.call<{ repo: Record }>('repo.add', { + path: getRequiredStringFlag(parsed.flags, 'path') + }) + return printResult(result, json, formatRepoShow) + } + + if (matches(commandPath, ['repo', 'show'])) { + const result = await client.call<{ repo: Record }>('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 }>('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('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('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('worktree.ps', { + limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit') + }) + return printResult(result, json, formatWorktreePs) + } + + if (matches(commandPath, ['worktree', 'list'])) { + const result = await client.call('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() + + 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, 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, + name: string +): string | undefined { + const value = flags.get(name) + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function getOptionalNumberFlag( + flags: Map, + 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, + 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, + 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( + response: RuntimeRpcSuccess, + 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: '}` + ) + .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 || ''}` + ].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 { + 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 [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 ] [--limit ] [--json] + orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--json] + orca worktree show --worktree [--json] + orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json] + orca worktree rm --worktree [--force] [--json] + orca worktree ps [--limit ] [--json] + orca terminal list [--worktree ] [--limit ] [--json] + orca terminal show --terminal [--json] + orca terminal read --terminal [--json] + orca terminal send --terminal [--text ] [--enter] [--interrupt] [--json] + orca terminal wait --terminal --for exit [--timeout-ms ] [--json] + orca terminal stop --worktree [--json] + orca repo list [--json] + orca repo add --path [--json] + orca repo show --repo [--json] + orca repo set-base-ref --repo --ref [--json] + orca repo search-refs --repo --query [--limit ] [--json] + +Selectors: + --repo Registered repo selector such as id:, name:, or path: + --worktree Worktree selector such as id:, branch:, issue:, or path: + --terminal Runtime-issued terminal handle returned by \`orca terminal list --json\` + +Terminal Send Options: + --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 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} [options]`, '', 'Commands:'] + for (const spec of specs) { + lines.push(` ${spec.path.slice(1).join(' ').padEnd(18)} ${spec.summary}`) + } + lines.push('', `Run \`orca ${group} --help\` for command-specific usage.`) + return lines.join('\n') +} + +function formatFlagHelp(flag: string): string { + const helpByFlag: Record = { + 'base-branch': '--base-branch Base branch/ref to create the worktree from', + comment: '--comment Comment stored in Orca metadata', + 'display-name': '--display-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 Linked GitHub issue number', + json: '--json Emit machine-readable JSON', + limit: '--limit Maximum number of rows to return', + name: '--name Name for the new worktree', + path: '--path Filesystem path to the repo', + query: '--query Search text for matching refs', + ref: '--ref Base ref to persist for the repo', + repo: '--repo Repo selector such as id:, name:, or path:', + terminal: '--terminal Runtime-issued terminal handle', + text: '--text Text to send to the terminal', + 'timeout-ms': '--timeout-ms Maximum wait time before timing out', + worktree: + '--worktree Worktree selector such as id:, branch:, issue:, or path:' + } + + return helpByFlag[flag] ?? `--${flag}` +} + +void main() diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts new file mode 100644 index 000000000..f2ee34b32 --- /dev/null +++ b/src/cli/runtime-client.test.ts @@ -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>() +const sockets = new Set() + +afterEach(async () => { + for (const socket of sockets) { + socket.destroy() + } + sockets.clear() + await Promise.all( + [...servers].map( + (server) => + new Promise((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((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((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((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((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((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((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((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((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' + }) + }) +}) diff --git a/src/cli/runtime-client.ts b/src/cli/runtime-client.ts new file mode 100644 index 000000000..70d91f7bb --- /dev/null +++ b/src/cli/runtime-client.ts @@ -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 = { + 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 = RuntimeRpcSuccess | 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( + method: string, + params?: unknown, + options?: { + timeoutMs?: number + } + ): Promise> { + const metadata = this.readMetadata() + const response = await this.sendRequest(metadata, method, params, options?.timeoutMs) + if (!response.ok) { + throw new RuntimeRpcFailureError(response) + } + return response + } + + async getCliStatus(): Promise> { + 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( + 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> { + 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( + metadata: RuntimeMetadata, + method: string, + params?: unknown, + timeoutMs?: number + ): Promise> { + 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 + 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 { + 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 { + 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') +} diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts new file mode 100644 index 000000000..195d2386d --- /dev/null +++ b/src/main/cli/cli-installer.test.ts @@ -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 + }) + }) +}) diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts new file mode 100644 index 000000000..a3dde42ac --- /dev/null +++ b/src/main/cli/cli-installer.ts @@ -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 + userPathReader?: () => Promise + userPathWriter?: (value: string) => Promise +} + +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 + private readonly userPathReader: () => Promise + private readonly userPathWriter: (value: string) => Promise + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8') + } + + private async inspectSymlink( + commandPath: string, + launcherPath: string + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const { stdout } = await execFileAsync('powershell', [ + '-NoProfile', + '-Command', + "[Environment]::GetEnvironmentVariable('Path','User')" + ]) + return stdout.trim() || null +} + +async function writeWindowsUserPath(value: string): Promise { + 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 +} diff --git a/src/main/index.ts b/src/main/index.ts index c581e4452..b3ba8517f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() }) diff --git a/src/main/ipc/cli.ts b/src/main/ipc/cli.ts new file mode 100644 index 000000000..b91e6e80f --- /dev/null +++ b/src/main/ipc/cli.ts @@ -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 => { + return new CliInstaller().getStatus() + }) + + ipcMain.handle('cli:install', async (): Promise => { + return new CliInstaller().install() + }) + + ipcMain.handle('cli:remove', async (): Promise => { + return new CliInstaller().remove() + }) +} diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index eceb3dc43..84b6abfba 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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() @@ -12,7 +13,7 @@ const ptyProcesses = new Map() let loadGeneration = 0 const ptyLoadGeneration = new Map() -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) } }) } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 8ca93b905..06d845024 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 1bf63f52c..992471f86 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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() diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts new file mode 100644 index 000000000..29c96559c --- /dev/null +++ b/src/main/ipc/runtime.test.ts @@ -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' }) + }) +}) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts new file mode 100644 index 000000000..1460d6c95 --- /dev/null +++ b/src/main/ipc/runtime.ts @@ -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() + }) +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts new file mode 100644 index 000000000..a56737a04 --- /dev/null +++ b/src/main/runtime/orca-runtime.test.ts @@ -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) => + ({ + ...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) => + ({ + ...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') + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts new file mode 100644 index 000000000..c17772121 --- /dev/null +++ b/src/main/runtime/orca-runtime.ts @@ -0,0 +1,1166 @@ +/* eslint-disable max-lines -- Why: the Orca runtime is the authoritative live control plane for the CLI, so handle validation, selector resolution, wait state, and summaries are kept together to avoid split-brain behavior. */ +/* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ +/* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ +import { execFileSync } from 'child_process' +import { randomUUID } from 'crypto' +import { rm } from 'fs/promises' +import type { Repo } from '../../shared/types' +import type { + RuntimeGraphStatus, + RuntimeRepoSearchRefs, + RuntimeTerminalRead, + RuntimeTerminalSend, + RuntimeTerminalListResult, + RuntimeTerminalState, + RuntimeStatus, + RuntimeTerminalWait, + RuntimeWorktreePsSummary, + RuntimeTerminalShow, + RuntimeTerminalSummary, + RuntimeSyncedLeaf, + RuntimeSyncedTab, + RuntimeSyncWindowGraph, + RuntimeWorktreeListResult +} from '../../shared/runtime-types' +import { listWorktrees } from '../git/worktree' +import { getPRForBranch } from '../github/client' +import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' +import { addWorktree, removeWorktree } from '../git/worktree' +import { getEffectiveHooks, runHook } from '../hooks' +import { REPO_COLORS } from '../../shared/constants' +import { isGitRepo, getRepoName, searchBaseRefs } from '../git/repo' +import type { Store } from '../persistence' +import { + computeBranchName, + computeWorktreePath, + ensurePathWithinWorkspace, + formatWorktreeRemovalError, + isOrphanedWorktreeError, + mergeWorktree, + sanitizeWorktreeName, + shouldSetDisplayName +} from '../ipc/worktree-logic' + +type RuntimeStore = { + getRepos: Store['getRepos'] + getRepo: Store['getRepo'] + addRepo: Store['addRepo'] + updateRepo: Store['updateRepo'] + getAllWorktreeMeta: Store['getAllWorktreeMeta'] + getWorktreeMeta: Store['getWorktreeMeta'] + setWorktreeMeta: Store['setWorktreeMeta'] + removeWorktreeMeta: Store['removeWorktreeMeta'] + getSettings(): { + workspaceDir: string + nestWorkspaces: boolean + branchPrefix: string + branchPrefixCustom: string + } +} + +type RuntimeLeafRecord = RuntimeSyncedLeaf & { + ptyGeneration: number + connected: boolean + writable: boolean + lastOutputAt: number | null + lastExitCode: number | null + tailBuffer: string[] + tailPartialLine: string + tailTruncated: boolean + preview: string +} + +type RuntimePtyController = { + write(ptyId: string, data: string): boolean + kill(ptyId: string): boolean +} + +type RuntimeNotifier = { + worktreesChanged(repoId: string): void + reposChanged(): void + activateWorktree(repoId: string, worktreeId: string): void +} + +type TerminalHandleRecord = { + handle: string + runtimeId: string + rendererGraphEpoch: number + worktreeId: string + tabId: string + leafId: string + ptyId: string | null + ptyGeneration: number +} + +type TerminalWaiter = { + handle: string + resolve: (result: RuntimeTerminalWait) => void + reject: (error: Error) => void + timeout: NodeJS.Timeout | null +} + +type ResolvedWorktree = { + id: string + repoId: string + path: string + branch: string + linkedIssue: number | null + git: { + path: string + head: string + branch: string + isBare: boolean + isMainWorktree: boolean + } + displayName: string + comment: string +} + +type ResolvedWorktreeCache = { + expiresAt: number + worktrees: ResolvedWorktree[] +} + +export class OrcaRuntimeService { + private readonly runtimeId = randomUUID() + private readonly startedAt = Date.now() + private readonly store: RuntimeStore | null + private rendererGraphEpoch = 0 + private graphStatus: RuntimeGraphStatus = 'unavailable' + private authoritativeWindowId: number | null = null + private tabs = new Map() + private leaves = new Map() + private handles = new Map() + private handleByLeafKey = new Map() + private waitersByHandle = new Map>() + private ptyController: RuntimePtyController | null = null + private notifier: RuntimeNotifier | null = null + private resolvedWorktreeCache: ResolvedWorktreeCache | null = null + + constructor(store: RuntimeStore | null = null) { + this.store = store + } + + getRuntimeId(): string { + return this.runtimeId + } + + getStartedAt(): number { + return this.startedAt + } + + getStatus(): RuntimeStatus { + return { + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + graphStatus: this.graphStatus, + authoritativeWindowId: this.authoritativeWindowId, + liveTabCount: this.tabs.size, + liveLeafCount: this.leaves.size + } + } + + setPtyController(controller: RuntimePtyController | null): void { + // Why: CLI terminal writes must go through the main-owned PTY registry + // instead of tunneling back through renderer IPC, or live handles could + // drift from the process they are supposed to control during reloads. + this.ptyController = controller + } + + setNotifier(notifier: RuntimeNotifier | null): void { + this.notifier = notifier + } + + attachWindow(windowId: number): void { + if (this.authoritativeWindowId === null) { + this.authoritativeWindowId = windowId + } + } + + syncWindowGraph(windowId: number, graph: RuntimeSyncWindowGraph): RuntimeStatus { + if (this.authoritativeWindowId === null) { + this.authoritativeWindowId = windowId + } + if (windowId !== this.authoritativeWindowId) { + throw new Error('Runtime graph publisher does not match the authoritative window') + } + + this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) + const nextLeaves = new Map() + + for (const leaf of graph.leaves) { + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + const existing = this.leaves.get(leafKey) + const ptyGeneration = + existing && existing.ptyId !== leaf.ptyId + ? existing.ptyGeneration + 1 + : (existing?.ptyGeneration ?? 0) + + nextLeaves.set(leafKey, { + ...leaf, + ptyGeneration, + connected: leaf.ptyId !== null, + writable: this.graphStatus === 'ready' && leaf.ptyId !== null, + lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null, + lastExitCode: existing?.ptyId === leaf.ptyId ? existing.lastExitCode : null, + tailBuffer: existing?.ptyId === leaf.ptyId ? existing.tailBuffer : [], + tailPartialLine: existing?.ptyId === leaf.ptyId ? existing.tailPartialLine : '', + tailTruncated: existing?.ptyId === leaf.ptyId ? existing.tailTruncated : false, + preview: existing?.ptyId === leaf.ptyId ? existing.preview : '' + }) + + if (existing && (existing.ptyId !== leaf.ptyId || existing.ptyGeneration !== ptyGeneration)) { + this.invalidateLeafHandle(leafKey) + } + } + + for (const oldLeafKey of this.leaves.keys()) { + if (!nextLeaves.has(oldLeafKey)) { + this.invalidateLeafHandle(oldLeafKey) + } + } + + this.leaves = nextLeaves + this.graphStatus = 'ready' + this.refreshWritableFlags() + return this.getStatus() + } + + onPtySpawned(ptyId: string): void { + for (const leaf of this.leaves.values()) { + if (leaf.ptyId === ptyId) { + leaf.connected = true + leaf.writable = this.graphStatus === 'ready' + } + } + } + + onPtyData(ptyId: string, data: string, at: number): void { + for (const leaf of this.leaves.values()) { + if (leaf.ptyId !== ptyId) { + continue + } + leaf.connected = true + leaf.writable = this.graphStatus === 'ready' + leaf.lastOutputAt = at + const nextTail = appendToTailBuffer(leaf.tailBuffer, leaf.tailPartialLine, data) + leaf.tailBuffer = nextTail.lines + leaf.tailPartialLine = nextTail.partialLine + leaf.tailTruncated = leaf.tailTruncated || nextTail.truncated + leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) + } + } + + onPtyExit(ptyId: string, exitCode: number): void { + for (const leaf of this.leaves.values()) { + if (leaf.ptyId !== ptyId) { + continue + } + leaf.connected = false + leaf.writable = false + leaf.lastExitCode = exitCode + this.resolveExitWaiters(leaf) + } + } + + async listTerminals( + worktreeSelector?: string, + limit = DEFAULT_TERMINAL_LIST_LIMIT + ): Promise { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const graphEpoch = this.captureReadyGraphEpoch() + const targetWorktreeId = worktreeSelector + ? (await this.resolveWorktreeSelector(worktreeSelector)).id + : null + const worktreesById = await this.getResolvedWorktreeMap() + this.assertStableReadyGraph(graphEpoch) + + const terminals: RuntimeTerminalSummary[] = [] + for (const leaf of this.leaves.values()) { + if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { + continue + } + terminals.push(this.buildTerminalSummary(leaf, worktreesById)) + } + return { + terminals: terminals.slice(0, limit), + totalCount: terminals.length, + truncated: terminals.length > limit + } + } + + async showTerminal(handle: string): Promise { + const graphEpoch = this.captureReadyGraphEpoch() + const worktreesById = await this.getResolvedWorktreeMap() + this.assertStableReadyGraph(graphEpoch) + const { leaf } = this.getLiveLeafForHandle(handle) + const summary = this.buildTerminalSummary(leaf, worktreesById) + return { + ...summary, + paneRuntimeId: leaf.paneRuntimeId, + ptyId: leaf.ptyId, + rendererGraphEpoch: this.rendererGraphEpoch + } + } + + async readTerminal(handle: string): Promise { + const { leaf } = this.getLiveLeafForHandle(handle) + const tail = buildTailLines(leaf.tailBuffer, leaf.tailPartialLine) + return { + handle, + status: getTerminalState(leaf), + // Why: Orca does not have a truthful main-owned screen model yet, + // especially for hidden panes. Focused v1 therefore returns the bounded + // tail lines directly instead of duplicating the same text in a fake + // screen field that would waste agent tokens. + tail, + truncated: leaf.tailTruncated, + nextCursor: null + } + } + + async sendTerminal( + handle: string, + action: { + text?: string + enter?: boolean + interrupt?: boolean + } + ): Promise { + const { leaf } = this.getLiveLeafForHandle(handle) + if (!leaf.writable || !leaf.ptyId) { + throw new Error('terminal_not_writable') + } + const payload = buildSendPayload(action) + if (payload === null) { + throw new Error('invalid_terminal_send') + } + const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } + return { + handle, + accepted: true, + bytesWritten: Buffer.byteLength(payload, 'utf8') + } + } + + async waitForTerminal( + handle: string, + options?: { + timeoutMs?: number + } + ): Promise { + const { leaf } = this.getLiveLeafForHandle(handle) + if (getTerminalState(leaf) === 'exited') { + return buildTerminalWaitResult(handle, leaf) + } + + return await new Promise((resolve, reject) => { + const waiter: TerminalWaiter = { + handle, + resolve, + reject, + timeout: null + } + + if (typeof options?.timeoutMs === 'number' && options.timeoutMs > 0) { + waiter.timeout = setTimeout(() => { + this.removeWaiter(waiter) + reject(new Error('timeout')) + }, options.timeoutMs) + } + + let waiters = this.waitersByHandle.get(handle) + if (!waiters) { + waiters = new Set() + this.waitersByHandle.set(handle, waiters) + } + waiters.add(waiter) + + // Why: the handle may go stale or exit in the small gap between the first + // validation and waiter registration. Re-checking here keeps wait --for + // exit honest instead of hanging on a terminal that already changed. + try { + const live = this.getLiveLeafForHandle(handle) + if (getTerminalState(live.leaf) === 'exited') { + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, live.leaf)) + } + } catch (error) { + this.removeWaiter(waiter) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + async getWorktreePs(limit = DEFAULT_WORKTREE_PS_LIMIT): Promise<{ + worktrees: RuntimeWorktreePsSummary[] + totalCount: number + truncated: boolean + }> { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const resolvedWorktrees = await this.listResolvedWorktrees() + const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) + const summaries = new Map() + + for (const worktree of resolvedWorktrees) { + const meta = + this.store?.getWorktreeMeta?.(worktree.id) ?? this.store?.getAllWorktreeMeta()[worktree.id] + summaries.set(worktree.id, { + worktreeId: worktree.id, + repoId: worktree.repoId, + repo: repoById.get(worktree.repoId)?.displayName ?? worktree.repoId, + path: worktree.path, + branch: worktree.branch, + linkedIssue: worktree.linkedIssue, + unread: meta?.isUnread ?? false, + liveTerminalCount: 0, + hasAttachedPty: false, + lastOutputAt: null, + preview: '' + }) + } + + for (const leaf of this.leaves.values()) { + const summary = summaries.get(leaf.worktreeId) + if (!summary) { + continue + } + const previousLastOutputAt = summary.lastOutputAt + summary.liveTerminalCount += 1 + summary.hasAttachedPty = summary.hasAttachedPty || leaf.connected + summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt) + if ( + leaf.preview && + (summary.preview.length === 0 || (leaf.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) + ) { + summary.preview = leaf.preview + } + } + + const sorted = [...summaries.values()].sort(compareWorktreePs) + return { + worktrees: sorted.slice(0, limit), + totalCount: sorted.length, + truncated: sorted.length > limit + } + } + + listRepos(): Repo[] { + return this.store?.getRepos() ?? [] + } + + async addRepo(path: string): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + if (!isGitRepo(path)) { + throw new Error(`Not a valid git repository: ${path}`) + } + + const existing = this.store.getRepos().find((repo) => repo.path === path) + if (existing) { + return existing + } + + const repo: Repo = { + id: randomUUID(), + path, + displayName: getRepoName(path), + badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length], + addedAt: Date.now() + } + this.store.addRepo(repo) + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + return this.store.getRepo(repo.id) ?? repo + } + + async showRepo(repoSelector: string): Promise { + return await this.resolveRepoSelector(repoSelector) + } + + async setRepoBaseRef(repoSelector: string, baseRef: string): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + const updated = this.store.updateRepo(repo.id, { worktreeBaseRef: baseRef }) + if (!updated) { + throw new Error('repo_not_found') + } + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + return updated + } + + async searchRepoRefs( + repoSelector: string, + query: string, + limit = DEFAULT_REPO_SEARCH_REFS_LIMIT + ): Promise { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const repo = await this.resolveRepoSelector(repoSelector) + const refs = await searchBaseRefs(repo.path, query, limit + 1) + return { + refs: refs.slice(0, limit), + truncated: refs.length > limit + } + } + + async listManagedWorktrees( + repoSelector?: string, + limit = DEFAULT_WORKTREE_LIST_LIMIT + ): Promise { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const resolved = await this.listResolvedWorktrees() + const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null + const worktrees = resolved.filter((worktree) => !repoId || worktree.repoId === repoId) + return { + worktrees: worktrees.slice(0, limit), + totalCount: worktrees.length, + truncated: worktrees.length > limit + } + } + + async showManagedWorktree(worktreeSelector: string) { + return await this.resolveWorktreeSelector(worktreeSelector) + } + + async createManagedWorktree(args: { + repoSelector: string + name: string + baseBranch?: string + linkedIssue?: number | null + comment?: string + }) { + if (!this.store) { + throw new Error('runtime_unavailable') + } + + const repo = await this.resolveRepoSelector(args.repoSelector) + const settings = this.store.getSettings() + const requestedName = args.name + const sanitizedName = sanitizeWorktreeName(args.name) + const username = getGitUsername(repo.path) + const branchName = computeBranchName(sanitizedName, settings, username) + + const branchConflictKind = await getBranchConflictKind(repo.path, branchName) + if (branchConflictKind) { + throw new Error( + `Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}.` + ) + } + + let existingPR: Awaited> | null = null + try { + existingPR = await getPRForBranch(repo.path, branchName) + } catch { + // Why: worktree creation should not hard-fail on transient GitHub reachability + // issues because git state is still the source of truth for whether the + // worktree can be created locally. + } + if (existingPR) { + throw new Error(`Branch "${branchName}" already has PR #${existingPR.number}.`) + } + + let worktreePath = computeWorktreePath(sanitizedName, repo.path, settings) + worktreePath = ensurePathWithinWorkspace(worktreePath, settings.workspaceDir) + const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) + + const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' + try { + execFileSync('git', ['fetch', remote], { + cwd: repo.path, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'] + }) + } catch { + // Why: matching the editor behavior keeps CLI creation usable offline. + } + + addWorktree(repo.path, worktreePath, branchName, baseBranch) + const gitWorktrees = await listWorktrees(repo.path) + const created = gitWorktrees.find((gw) => gw.path === worktreePath) + if (!created) { + throw new Error('Worktree created but not found in listing') + } + + const worktreeId = `${repo.id}::${worktreePath}` + const meta = this.store.setWorktreeMeta(worktreeId, { + lastActivityAt: Date.now(), + ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) + ? { displayName: requestedName } + : {}), + ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), + ...(args.comment !== undefined ? { comment: args.comment } : {}) + }) + const worktree = mergeWorktree(repo.id, created, meta) + + const hooks = getEffectiveHooks(repo) + if (hooks?.scripts.setup) { + void runHook('setup', worktreePath, repo).then((result) => { + if (!result.success) { + console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output) + } + }) + } + + this.notifier?.worktreesChanged(repo.id) + // Why: the editor currently creates the first Orca-managed terminal as a + // renderer-side consequence of activating a worktree. CLI-created + // worktrees must trigger that same activation path or they will exist on + // disk without becoming the active workspace in the UI. + this.notifier?.activateWorktree(repo.id, worktree.id) + this.invalidateResolvedWorktreeCache() + return worktree + } + + async updateManagedWorktreeMeta( + worktreeSelector: string, + updates: { + displayName?: string + linkedIssue?: number | null + comment?: string + } + ) { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const meta = this.store.setWorktreeMeta(worktree.id, { + ...(updates.displayName !== undefined ? { displayName: updates.displayName } : {}), + ...(updates.linkedIssue !== undefined ? { linkedIssue: updates.linkedIssue } : {}), + ...(updates.comment !== undefined ? { comment: updates.comment } : {}) + }) + // Why: unlike renderer-initiated optimistic updates, CLI callers need an + // explicit push so the editor refreshes metadata changed outside the UI. + this.invalidateResolvedWorktreeCache() + this.notifier?.worktreesChanged(worktree.repoId) + return mergeWorktree(worktree.repoId, worktree.git, meta) + } + + async removeManagedWorktree(worktreeSelector: string, force = false): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store.getRepo(worktree.repoId) + if (!repo) { + throw new Error('repo_not_found') + } + + const hooks = getEffectiveHooks(repo) + if (hooks?.scripts.archive) { + const result = await runHook('archive', worktree.path, repo) + if (!result.success) { + console.error(`[hooks] archive hook failed for ${worktree.path}:`, result.output) + } + } + + try { + await removeWorktree(repo.path, worktree.path, force) + } catch (error) { + if (isOrphanedWorktreeError(error)) { + await rm(worktree.path, { recursive: true, force: true }).catch(() => {}) + this.store.removeWorktreeMeta(worktree.id) + this.invalidateResolvedWorktreeCache() + this.notifier?.worktreesChanged(repo.id) + return + } + throw new Error(formatWorktreeRemovalError(error, worktree.path, force)) + } + + this.store.removeWorktreeMeta(worktree.id) + this.invalidateResolvedWorktreeCache() + this.notifier?.worktreesChanged(repo.id) + } + + async stopTerminalsForWorktree(worktreeSelector: string): Promise<{ stopped: number }> { + // Why: this mutates live PTYs, so the runtime must reject it while the + // renderer graph is reloading instead of acting on cached leaf ownership. + const graphEpoch = this.captureReadyGraphEpoch() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + this.assertStableReadyGraph(graphEpoch) + const ptyIds = new Set() + for (const leaf of this.leaves.values()) { + if (leaf.worktreeId === worktree.id && leaf.ptyId) { + ptyIds.add(leaf.ptyId) + } + } + + let stopped = 0 + for (const ptyId of ptyIds) { + if (this.ptyController?.kill(ptyId)) { + stopped += 1 + } + } + return { stopped } + } + + markRendererReloading(windowId: number): void { + if (windowId !== this.authoritativeWindowId) { + return + } + if (this.graphStatus !== 'ready') { + return + } + // Why: any renderer reload tears down the published live graph, so live + // terminal handles must become stale immediately instead of being reused + // against whatever the renderer rebuilds next. + this.rendererGraphEpoch += 1 + this.graphStatus = 'reloading' + this.handles.clear() + this.handleByLeafKey.clear() + this.rejectAllWaiters('terminal_handle_stale') + this.refreshWritableFlags() + } + + markGraphReady(windowId: number): void { + if (windowId !== this.authoritativeWindowId) { + return + } + this.graphStatus = 'ready' + this.refreshWritableFlags() + } + + markGraphUnavailable(windowId: number): void { + if (windowId !== this.authoritativeWindowId) { + return + } + // Why: once the authoritative renderer graph disappears, Orca must fail + // closed for live-terminal operations instead of guessing from old state. + if (this.graphStatus !== 'unavailable') { + this.rendererGraphEpoch += 1 + } + this.graphStatus = 'unavailable' + this.authoritativeWindowId = null + this.tabs.clear() + this.leaves.clear() + this.handles.clear() + this.handleByLeafKey.clear() + this.rejectAllWaiters('terminal_handle_stale') + } + + private assertGraphReady(): void { + if (this.graphStatus !== 'ready') { + throw new Error('runtime_unavailable') + } + } + + private captureReadyGraphEpoch(): number { + this.assertGraphReady() + return this.rendererGraphEpoch + } + + private assertStableReadyGraph(expectedGraphEpoch: number): void { + if (this.graphStatus !== 'ready' || this.rendererGraphEpoch !== expectedGraphEpoch) { + throw new Error('runtime_unavailable') + } + } + + private async resolveWorktreeSelector(selector: string): Promise { + const worktrees = await this.listResolvedWorktrees() + let candidates: ResolvedWorktree[] + + if (selector === 'active') { + throw new Error('selector_not_found') + } + + if (selector.startsWith('id:')) { + candidates = worktrees.filter((worktree) => worktree.id === selector.slice(3)) + } else if (selector.startsWith('path:')) { + candidates = worktrees.filter((worktree) => worktree.path === selector.slice(5)) + } else if (selector.startsWith('branch:')) { + candidates = worktrees.filter((worktree) => worktree.branch === selector.slice(7)) + } else if (selector.startsWith('issue:')) { + candidates = worktrees.filter( + (worktree) => + worktree.linkedIssue !== null && String(worktree.linkedIssue) === selector.slice(6) + ) + } else { + candidates = worktrees.filter( + (worktree) => + worktree.id === selector || worktree.path === selector || worktree.branch === selector + ) + } + + if (candidates.length === 1) { + return candidates[0] + } + if (candidates.length > 1) { + throw new Error('selector_ambiguous') + } + throw new Error('selector_not_found') + } + + private async resolveRepoSelector(selector: string): Promise { + if (!this.store) { + throw new Error('repo_not_found') + } + const repos = this.store.getRepos() + let candidates: Repo[] + + if (selector.startsWith('id:')) { + candidates = repos.filter((repo) => repo.id === selector.slice(3)) + } else if (selector.startsWith('path:')) { + candidates = repos.filter((repo) => repo.path === selector.slice(5)) + } else if (selector.startsWith('name:')) { + candidates = repos.filter((repo) => repo.displayName === selector.slice(5)) + } else { + candidates = repos.filter( + (repo) => repo.id === selector || repo.path === selector || repo.displayName === selector + ) + } + + if (candidates.length === 1) { + return candidates[0] + } + if (candidates.length > 1) { + throw new Error('selector_ambiguous') + } + throw new Error('repo_not_found') + } + + private async listResolvedWorktrees(): Promise { + if (!this.store) { + return [] + } + const now = Date.now() + if (this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > now) { + return this.resolvedWorktreeCache.worktrees + } + + const metaById = this.store.getAllWorktreeMeta() + const worktrees: ResolvedWorktree[] = [] + for (const repo of this.store.getRepos()) { + const gitWorktrees = await listWorktrees(repo.path) + for (const gitWorktree of gitWorktrees) { + const worktreeId = `${repo.id}::${gitWorktree.path}` + const merged = mergeWorktree(repo.id, gitWorktree, metaById[worktreeId]) + worktrees.push({ + id: merged.id, + repoId: repo.id, + path: merged.path, + branch: merged.branch, + linkedIssue: metaById[worktreeId]?.linkedIssue ?? null, + git: { + path: gitWorktree.path, + head: gitWorktree.head, + branch: gitWorktree.branch, + isBare: gitWorktree.isBare, + isMainWorktree: gitWorktree.isMainWorktree + }, + displayName: merged.displayName, + comment: merged.comment + }) + } + } + // Why: terminal polling can be frequent, but git worktree state is still + // allowed to change outside Orca. A short TTL avoids shelling out on every + // read without pretending the cache is authoritative for long. + this.resolvedWorktreeCache = { + worktrees, + expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS + } + return worktrees + } + + private async getResolvedWorktreeMap(): Promise> { + return new Map((await this.listResolvedWorktrees()).map((worktree) => [worktree.id, worktree])) + } + + private invalidateResolvedWorktreeCache(): void { + this.resolvedWorktreeCache = null + } + + private buildTerminalSummary( + leaf: RuntimeLeafRecord, + worktreesById: Map + ): RuntimeTerminalSummary { + const worktree = worktreesById.get(leaf.worktreeId) + const tab = this.tabs.get(leaf.tabId) ?? null + + return { + handle: this.issueHandle(leaf), + worktreeId: leaf.worktreeId, + worktreePath: worktree?.path ?? '', + branch: worktree?.branch ?? '', + tabId: leaf.tabId, + leafId: leaf.leafId, + title: tab?.title ?? null, + connected: leaf.connected, + writable: leaf.writable, + lastOutputAt: leaf.lastOutputAt, + preview: leaf.preview + } + } + + private getLiveLeafForHandle(handle: string): { + record: TerminalHandleRecord + leaf: RuntimeLeafRecord + } { + this.assertGraphReady() + const record = this.handles.get(handle) + if (!record || record.runtimeId !== this.runtimeId) { + throw new Error('terminal_handle_stale') + } + if (record.rendererGraphEpoch !== this.rendererGraphEpoch) { + throw new Error('terminal_handle_stale') + } + + const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) + if (!leaf || leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) { + throw new Error('terminal_handle_stale') + } + return { record, leaf } + } + + private issueHandle(leaf: RuntimeLeafRecord): string { + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + const existingHandle = this.handleByLeafKey.get(leafKey) + if (existingHandle) { + const existingRecord = this.handles.get(existingHandle) + if ( + existingRecord && + existingRecord.rendererGraphEpoch === this.rendererGraphEpoch && + existingRecord.ptyId === leaf.ptyId && + existingRecord.ptyGeneration === leaf.ptyGeneration + ) { + return existingHandle + } + } + + const handle = `term_${randomUUID()}` + this.handles.set(handle, { + handle, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: leaf.worktreeId, + tabId: leaf.tabId, + leafId: leaf.leafId, + ptyId: leaf.ptyId, + ptyGeneration: leaf.ptyGeneration + }) + this.handleByLeafKey.set(leafKey, handle) + return handle + } + + private refreshWritableFlags(): void { + for (const leaf of this.leaves.values()) { + leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null + } + } + + private invalidateLeafHandle(leafKey: string): void { + const handle = this.handleByLeafKey.get(leafKey) + if (!handle) { + return + } + this.handleByLeafKey.delete(leafKey) + this.handles.delete(handle) + this.rejectWaitersForHandle(handle, 'terminal_handle_stale') + } + + private resolveExitWaiters(leaf: RuntimeLeafRecord): void { + const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (!handle) { + return + } + const waiters = this.waitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, leaf)) + } + } + + private resolveWaiter(waiter: TerminalWaiter, result: RuntimeTerminalWait): void { + this.removeWaiter(waiter) + waiter.resolve(result) + } + + private rejectWaitersForHandle(handle: string, code: string): void { + const waiters = this.waitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + this.removeWaiter(waiter) + waiter.reject(new Error(code)) + } + } + + private rejectAllWaiters(code: string): void { + for (const handle of [...this.waitersByHandle.keys()]) { + this.rejectWaitersForHandle(handle, code) + } + } + + private removeWaiter(waiter: TerminalWaiter): void { + if (waiter.timeout) { + clearTimeout(waiter.timeout) + } + const waiters = this.waitersByHandle.get(waiter.handle) + if (!waiters) { + return + } + waiters.delete(waiter) + if (waiters.size === 0) { + this.waitersByHandle.delete(waiter.handle) + } + } + + private getLeafKey(tabId: string, leafId: string): string { + return `${tabId}::${leafId}` + } +} + +const MAX_TAIL_LINES = 120 +const MAX_TAIL_CHARS = 4000 +const MAX_PREVIEW_LINES = 6 +const MAX_PREVIEW_CHARS = 300 +const DEFAULT_REPO_SEARCH_REFS_LIMIT = 25 +const DEFAULT_TERMINAL_LIST_LIMIT = 200 +const DEFAULT_WORKTREE_LIST_LIMIT = 200 +const DEFAULT_WORKTREE_PS_LIMIT = 200 +const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000 +function buildPreview(lines: string[], partialLine: string): string { + const previewLines = buildTailLines(lines, partialLine) + .map((line) => line.trim()) + .filter(Boolean) + .slice(-MAX_PREVIEW_LINES) + const preview = previewLines.join('\n') + return preview.length > MAX_PREVIEW_CHARS + ? preview.slice(preview.length - MAX_PREVIEW_CHARS) + : preview +} + +function appendToTailBuffer( + previousLines: string[], + previousPartialLine: string, + chunk: string +): { + lines: string[] + partialLine: string + truncated: boolean +} { + const normalizedChunk = normalizeTerminalChunk(chunk) + if (normalizedChunk.length === 0) { + return { + lines: previousLines, + partialLine: previousPartialLine, + truncated: false + } + } + + const pieces = `${previousPartialLine}${normalizedChunk}`.split('\n') + const nextPartialLine = (pieces.pop() ?? '').replace(/[ \t]+$/g, '') + const nextLines = [...previousLines, ...pieces.map((line) => line.replace(/[ \t]+$/g, ''))] + let truncated = false + + while (nextLines.length > MAX_TAIL_LINES) { + nextLines.shift() + truncated = true + } + + let totalChars = nextLines.reduce((sum, line) => sum + line.length, 0) + nextPartialLine.length + while (nextLines.length > 0 && totalChars > MAX_TAIL_CHARS) { + totalChars -= nextLines.shift()!.length + truncated = true + } + + return { + lines: nextLines, + partialLine: nextPartialLine.slice(-MAX_TAIL_CHARS), + truncated + } +} + +function buildTailLines(lines: string[], partialLine: string): string[] { + return partialLine.length > 0 ? [...lines, partialLine] : lines +} + +function getTerminalState(leaf: RuntimeLeafRecord): RuntimeTerminalState { + if (leaf.connected) { + return 'running' + } + if (leaf.lastExitCode !== null) { + return 'exited' + } + return 'unknown' +} + +function buildSendPayload(action: { + text?: string + enter?: boolean + interrupt?: boolean +}): string | null { + let payload = '' + if (typeof action.text === 'string' && action.text.length > 0) { + payload += action.text + } + if (action.enter) { + payload += '\r' + } + if (action.interrupt) { + payload += '\x03' + } + return payload.length > 0 ? payload : null +} + +function buildTerminalWaitResult(handle: string, leaf: RuntimeLeafRecord): RuntimeTerminalWait { + return { + handle, + condition: 'exit', + satisfied: true, + status: getTerminalState(leaf), + exitCode: leaf.lastExitCode + } +} + +function normalizeTerminalChunk(chunk: string): string { + return chunk + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\x1b[@-_]/g, '') + .replace(/\u0008/g, '') + .replace(/[^\x09\x0a\x20-\x7e]/g, '') +} + +function maxTimestamp(left: number | null, right: number | null): number | null { + if (left === null) { + return right + } + if (right === null) { + return left + } + return Math.max(left, right) +} + +function compareWorktreePs( + left: RuntimeWorktreePsSummary, + right: RuntimeWorktreePsSummary +): number { + const leftLast = left.lastOutputAt ?? -1 + const rightLast = right.lastOutputAt ?? -1 + if (leftLast !== rightLast) { + return rightLast - leftLast + } + if (left.liveTerminalCount !== right.liveTerminalCount) { + return right.liveTerminalCount - left.liveTerminalCount + } + return left.path.localeCompare(right.path) +} diff --git a/src/main/runtime/runtime-metadata.test.ts b/src/main/runtime/runtime-metadata.test.ts new file mode 100644 index 000000000..b46d8c907 --- /dev/null +++ b/src/main/runtime/runtime-metadata.test.ts @@ -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) + } + ) +}) diff --git a/src/main/runtime/runtime-metadata.ts b/src/main/runtime/runtime-metadata.ts new file mode 100644 index 000000000..7ce464c7d --- /dev/null +++ b/src/main/runtime/runtime-metadata.ts @@ -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(/"$/, '')) +} diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts new file mode 100644 index 000000000..e0945c635 --- /dev/null +++ b/src/main/runtime/runtime-rpc.test.ts @@ -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 +): Promise> { + 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) + }) + 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) => + ({ + ...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) => + ({ + ...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>((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) + }) + socket.on('connect', () => { + socket.write(`${'x'.repeat(1024 * 1024 + 1)}\n`) + }) + }) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'request_too_large' + } + }) + + await server.stop() + }) +}) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts new file mode 100644 index 000000000..baa4ecc8f --- /dev/null +++ b/src/main/runtime/runtime-rpc.ts @@ -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 { + 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((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 { + const server = this.server + const transport = this.transport + this.server = null + this.transport = null + clearRuntimeMetadata(this.userDataPath) + if (!server) { + return + } + await new Promise((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 { + 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`) + } +} diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 7576b86a7..2883010f6 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -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' }] + ]) + }) }) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 78f2a035b..0ffb75ec9 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -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[] }) => { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index b01793f81..1537d559b 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 @@ -76,6 +78,12 @@ type SettingsApi = { listFonts: () => Promise } +type CliApi = { + getInstallStatus: () => Promise + install: () => Promise + remove: () => Promise +} + type ShellApi = { openPath: (path: string) => Promise openUrl: (url: string) => Promise @@ -121,6 +129,9 @@ type UIApi = { get: () => Promise set: (args: Partial) => Promise onOpenSettings: (callback: () => void) => () => void + onActivateWorktree: ( + callback: (data: { repoId: string; worktreeId: string }) => void + ) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void readClipboardText: () => Promise writeClipboardText: (text: string) => Promise @@ -129,6 +140,11 @@ type UIApi = { setZoomLevel: (level: number) => void } +type RuntimeApi = { + syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise + getStatus: () => Promise +} + type FsApi = { readDir: (args: { dirPath: string }) => Promise 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 { diff --git a/src/preload/index.ts b/src/preload/index.ts index b8e22ce8f..d2a42214f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('settings:listFonts') }, + cli: { + getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), + install: (): Promise => ipcRenderer.invoke('cli:install'), + remove: (): Promise => ipcRenderer.invoke('cli:remove') + }, + shell: { openPath: (path: string): Promise => 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 => + ipcRenderer.invoke('runtime:syncWindowGraph', graph), + getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus') } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7995ebced..851edfef0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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, diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx new file mode 100644 index 000000000..612395ae0 --- /dev/null +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [dialogOpen, setDialogOpen] = useState(false) + const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null) + + const refreshStatus = async (): Promise => { + 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 => { + 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 => { + 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 => { + 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 ( +
+
+

Orca CLI

+

+ Use Orca from your terminal to open the app, manage worktrees, and interact with Orca + terminals. +

+
+ +
+
+
+ +

+ {loading + ? 'Checking CLI registration…' + : (status?.detail ?? getInstallDescription(currentPlatform))} +

+
+
+ + + + + + + Refresh + + + + +
+
+ + {status?.commandPath ? ( +

+ Command path:{' '} + {status.commandPath} +

+ ) : null} + + {status?.state === 'stale' && status.currentTarget ? ( +

+ Existing launcher target: {status.currentTarget} +

+ ) : null} + + {status?.state === 'installed' && !status.pathConfigured && status.pathDirectory ? ( +

+ {status.pathDirectory} is not currently visible on PATH for this shell. +

+ ) : null} + + {!loading && !isSupported && status?.detail ? ( +

{status.detail}

+ ) : null} + +
+ {status?.commandPath ? ( + + ) : null} +
+ +
+
+ +

+ Install the Orca skill so agents know to use the{' '} + orca CLI. +

+
+ +
+

Install command

+
+ + {ORCA_SKILL_INSTALL_COMMAND} + + + + + + + + Copy + + + +
+
+
+
+ + + + + + {isEnabled ? 'Remove `orca` from PATH?' : 'Register `orca` in PATH?'} + + + {isEnabled + ? 'This removes the shell command symlink. Orca itself remains installed.' + : `Orca will register ${status?.commandPath ?? '`orca`'} so the command works from your terminal.`} + + + {status?.commandPath ? ( +

+ Target path:{' '} + {status.commandPath} +

+ ) : null} + + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index b3214ed72..954af39f6 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -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({ + + + +

Branch Naming

diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 83f00325a..e42369c91 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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() }) } diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index fae1e205f..b7be64689 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -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 } @@ -220,6 +221,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return connected }, + getPtyId() { + return ptyId + }, + destroy() { destroyed = true this.disconnect() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 2ef6386f7..dcf4f1db7 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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) } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index d1c4a08e2..9a94e211a 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts new file mode 100644 index 000000000..46dc9722f --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -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() +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 { + 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) + } +} diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index f65ee46ae..b3e3eceba 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -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 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 for (const wId of Object.keys(next)) { next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, customTitle: title } : t)) } + scheduleRuntimeGraphSync() return { tabsByWorktree: next } }) }, diff --git a/src/shared/cli-install-types.ts b/src/shared/cli-install-types.ts new file mode 100644 index 000000000..e47bf3323 --- /dev/null +++ b/src/shared/cli-install-types.ts @@ -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 +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts new file mode 100644 index 000000000..2580e7e7c --- /dev/null +++ b/src/shared/runtime-types.ts @@ -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 +} diff --git a/tsconfig.cli.json b/tsconfig.cli.json new file mode 100644 index 000000000..3976a3725 --- /dev/null +++ b/tsconfig.cli.json @@ -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" + } +}