Add Orca runtime CLI and bundled install support (#273)
This commit is contained in:
parent
ccc1fa7b8b
commit
182ba156dd
|
|
@ -24,7 +24,6 @@ jobs:
|
|||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ jobs:
|
|||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
# Orca CLI Bundled Distribution
|
||||
|
||||
## Goal
|
||||
|
||||
Ship `orca` as a companion CLI that is bundled with the Orca desktop app.
|
||||
|
||||
`orca` will **not** be separately distributed on npm for the initial public release.
|
||||
|
||||
## Product Direction
|
||||
|
||||
The CLI is version-coupled to the running Orca app because it depends on the app's local runtime RPC contract.
|
||||
|
||||
That means the primary user story should be:
|
||||
|
||||
1. Install Orca desktop.
|
||||
2. Register the `orca` command using the platform-native path:
|
||||
- macOS: from Orca Settings, like VS Code's shell-command install
|
||||
- Linux package builds: from package install scripts
|
||||
- Windows installer builds: from installer-managed PATH registration
|
||||
3. The user can run `orca ...` from any terminal while Orca is running.
|
||||
|
||||
This is closer to VS Code's platform-specific model than to a standalone npm package:
|
||||
|
||||
- the app is the primary product
|
||||
- the CLI is an app capability
|
||||
- CLI installation is explicit and opt-in
|
||||
- version drift between app and CLI is minimized
|
||||
|
||||
## UX
|
||||
|
||||
Add a Settings section for CLI installation, likely under Advanced or Developer.
|
||||
|
||||
This Settings UI is primarily for macOS and for status/help across platforms.
|
||||
|
||||
Suggested UX:
|
||||
|
||||
- Setting label: `Command line interface`
|
||||
- Description: `Allow terminal tools and coding agents to interact with this running Orca app.`
|
||||
- Toggle:
|
||||
- off: CLI not registered
|
||||
- on: Orca prompts to install/register `orca` where that is app-managed
|
||||
|
||||
When toggled on for the first time, show a modal like:
|
||||
|
||||
- Title: `Set up CLI to work in the terminal`
|
||||
- Body: `Register "orca" in PATH to enable accessing the "orca" command anywhere from your terminal.`
|
||||
- Actions:
|
||||
- `Cancel`
|
||||
- `Register`
|
||||
|
||||
Platform note:
|
||||
|
||||
- on macOS, registration may require an administrator prompt because Orca installs `/usr/local/bin/orca` as a symlink to the app-bundled launcher, following the same general pattern VS Code uses for `code`
|
||||
- on Windows and Linux package builds, registration should prefer installer/package integration over post-install GUI mutation
|
||||
|
||||
After success:
|
||||
|
||||
- show the installed path
|
||||
- provide `Reinstall`
|
||||
- provide `Remove from PATH`
|
||||
- optionally provide `Copy setup instructions`
|
||||
|
||||
If installation fails:
|
||||
|
||||
- show the exact install location
|
||||
- show the exact shell snippet or manual step needed
|
||||
|
||||
## Distribution Model
|
||||
|
||||
The packaged app should contain the CLI artifact and launcher files in stable internal locations.
|
||||
|
||||
Registration should follow the verified VS Code model by platform:
|
||||
|
||||
- macOS: app-driven shell command install
|
||||
- Linux package builds: package-managed symlink
|
||||
- Windows installer builds: installer-managed PATH registration
|
||||
|
||||
Why:
|
||||
|
||||
- simpler macOS story
|
||||
- clearer user consent where the app owns registration
|
||||
- more robust Linux/Windows behavior by using package/installer hooks
|
||||
- keeps the CLI tied to the installed app version
|
||||
|
||||
## Installation Strategy
|
||||
|
||||
### macOS
|
||||
|
||||
Follow the VS Code pattern:
|
||||
|
||||
- ship an app-bundled launcher script
|
||||
- install `/usr/local/bin/orca` as a symlink to that launcher
|
||||
- if needed, prompt for elevation explicitly from the app
|
||||
|
||||
This avoids shell rc editing and gives a stable command location.
|
||||
|
||||
### Linux
|
||||
|
||||
For package-managed builds, follow the VS Code pattern:
|
||||
|
||||
- ship an app-bundled launcher script
|
||||
- install `/usr/bin/orca` from the package as a symlink to that launcher
|
||||
|
||||
For AppImage and other non-package-managed distributions:
|
||||
|
||||
- do not assume a robust global PATH install exists
|
||||
- either fall back to a user-level wrapper with manual instructions
|
||||
- or disable CLI install in v1 if path stability is not good enough
|
||||
|
||||
### Windows
|
||||
|
||||
Follow the VS Code pattern:
|
||||
|
||||
- ship `orca.cmd` and related launcher files under `<install dir>\\bin`
|
||||
- let the installer add `<install dir>\\bin` to PATH
|
||||
- optionally register Windows App Paths for Explorer/address bar launching
|
||||
|
||||
This is more robust than trying to add PATH from the running GUI app after install.
|
||||
|
||||
## Runtime Expectations
|
||||
|
||||
The bundled `orca` command remains a thin client:
|
||||
|
||||
- it reads Orca runtime metadata
|
||||
- it connects to the local Orca runtime endpoint
|
||||
- it fails clearly if Orca is not running or is incompatible
|
||||
|
||||
Runtime startup rules:
|
||||
|
||||
- ordinary `orca ...` commands do not auto-launch Orca
|
||||
- `orca open` explicitly launches Orca and waits for the runtime
|
||||
- the CLI must detect stale runtime metadata before trusting a local runtime
|
||||
- the CLI should only proceed once it observes a healthy current runtime, not just the existence of a metadata file
|
||||
- `orca open` should be idempotent and cheap when Orca is already running
|
||||
|
||||
Error and preflight rules:
|
||||
|
||||
- when the runtime is missing, ordinary commands should explicitly tell the user to run `orca open`
|
||||
- `orca status --json` should be the primary preflight command for agents and scripts
|
||||
- `orca status --json` should distinguish:
|
||||
- app not running
|
||||
- app starting
|
||||
- runtime reachable
|
||||
- runtime reachable but terminal graph not ready
|
||||
|
||||
This design does not turn `orca` into a standalone daemon or independently useful tool.
|
||||
The Orca desktop app remains the runtime owner even when the CLI launches it on demand.
|
||||
The Orca desktop app remains the runtime owner, and `orca open` is the explicit way to start it from the CLI.
|
||||
|
||||
## Compatibility Rules
|
||||
|
||||
Because the CLI is bundled with the app:
|
||||
|
||||
- CLI version should match app version
|
||||
- `orca version` should report both CLI and runtime compatibility details
|
||||
- incompatible runtime versions should fail with a precise error
|
||||
|
||||
This is one of the main reasons not to ship npm-first.
|
||||
|
||||
## Security Notes
|
||||
|
||||
Bundling the CLI with the app does not change the runtime security model.
|
||||
|
||||
The important properties remain:
|
||||
|
||||
- local-only IPC
|
||||
- runtime auth token
|
||||
- user-scoped metadata and endpoint permissions
|
||||
- no remote network listener by default
|
||||
|
||||
The launcher registration should only point to the bundled CLI. It should not expose any extra background service.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
For the initial version:
|
||||
|
||||
- no separate npm distribution
|
||||
- no remote CLI-to-Orca connectivity
|
||||
- no standalone daemon mode independent of the Orca app
|
||||
|
||||
## Recommended First Slice
|
||||
|
||||
1. Bundle the CLI artifact and launcher files into packaged app builds.
|
||||
2. macOS: add a Settings toggle and modal for shell-command registration.
|
||||
3. Linux package builds: register `/usr/bin/orca` from packaging.
|
||||
4. Windows installer builds: register `<install dir>\\bin` on PATH.
|
||||
5. Show install/status/help flows in Settings.
|
||||
|
||||
This is the smallest coherent implementation that matches the desired product story.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,783 @@
|
|||
# Orca CLI V1 Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Define the first strict `orca` CLI contract for agents.
|
||||
|
||||
This spec focuses on:
|
||||
|
||||
- exact commands
|
||||
- exact selector grammar
|
||||
- exact handle semantics
|
||||
- exact JSON contract
|
||||
- what is in v1 now
|
||||
- what is explicitly deferred because the current runtime does not yet support it cleanly
|
||||
|
||||
This document is intended to be implementation-facing.
|
||||
|
||||
## Scope
|
||||
|
||||
The CLI connects to a running Orca editor.
|
||||
|
||||
The v1 contract is split into two buckets:
|
||||
|
||||
- `v1-now`: can be grounded in current Orca persistence and IPC behavior with limited new plumbing
|
||||
- `v1-runtime-layer`: desirable v1 public contract, but requires a shared runtime/orchestration layer before it is safe to ship
|
||||
|
||||
## Global Rules
|
||||
|
||||
### Output modes
|
||||
|
||||
All agent-facing commands must support `--json`.
|
||||
|
||||
When `--json` is used:
|
||||
|
||||
- stdout contains exactly one JSON object
|
||||
- stdout contains no progress text, logs, or prose
|
||||
- stderr is reserved for failures and unexpected diagnostics
|
||||
- non-zero exit code indicates command failure
|
||||
|
||||
Human-readable output may exist without `--json`, but `--json` is the normative contract for agents.
|
||||
|
||||
### Metadata
|
||||
|
||||
All `--json` responses include:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"orcaVersion": "1.0.0",
|
||||
"requestId": "req_123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Commands that depend on the live runtime layer also include:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"runtimeId": "runtime_abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Errors
|
||||
|
||||
All failures in `--json` mode must return:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"requestId": "req_123"
|
||||
},
|
||||
"error": {
|
||||
"code": "selector_not_found",
|
||||
"message": "No worktree matched selector \"branch:feature/foo\".",
|
||||
"retryable": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Minimum standard error codes:
|
||||
|
||||
- `orca_not_running`
|
||||
- `runtime_unavailable`
|
||||
- `selector_not_found`
|
||||
- `selector_ambiguous`
|
||||
- `terminal_handle_stale`
|
||||
- `terminal_not_found`
|
||||
- `repo_not_found`
|
||||
- `worktree_not_found`
|
||||
- `not_supported_in_v1`
|
||||
- `invalid_argument`
|
||||
|
||||
## Selectors
|
||||
|
||||
Selectors are command-time identifiers.
|
||||
|
||||
### Repo selector grammar
|
||||
|
||||
Explicit forms:
|
||||
|
||||
- `id:<repo-id>`
|
||||
- `path:<absolute-path>`
|
||||
- `name:<display-name>`
|
||||
|
||||
Bare fallback order:
|
||||
|
||||
1. exact repo id match
|
||||
2. exact absolute path match
|
||||
3. exact display name match
|
||||
|
||||
If more than one repo matches a bare selector, fail with `selector_ambiguous`.
|
||||
|
||||
### Worktree selector grammar
|
||||
|
||||
Explicit forms:
|
||||
|
||||
- `id:<worktree-id>`
|
||||
- `path:<absolute-path>`
|
||||
- `branch:<branch-name>`
|
||||
- `issue:<number>`
|
||||
|
||||
Bare fallback order:
|
||||
|
||||
1. exact worktree id match
|
||||
2. exact absolute path match
|
||||
3. exact branch name match
|
||||
|
||||
If more than one worktree matches a bare selector, fail with `selector_ambiguous`.
|
||||
|
||||
`issue:<number>` must fail with `selector_ambiguous` if multiple worktrees share the same linked issue.
|
||||
|
||||
### Terminal selector grammar
|
||||
|
||||
There is no durable selector for repeated live interaction in v1.
|
||||
|
||||
Discovery returns runtime handles. Follow-up commands use:
|
||||
|
||||
- `--terminal <handle>`
|
||||
|
||||
Human-friendly targeting like `title:<name>` may be useful later, but it is not part of the strict repeated-interaction contract.
|
||||
|
||||
## Runtime Handles
|
||||
|
||||
Handles identify live terminal targets.
|
||||
|
||||
Rules:
|
||||
|
||||
- handles are opaque
|
||||
- handles are scoped to a specific `runtimeId`
|
||||
- handles are ephemeral by default
|
||||
- runtime restart or renderer reload may invalidate all existing handles
|
||||
- callers must reacquire handles after reconnect/reload unless the runtime explicitly guarantees continuity
|
||||
- stale handles must fail with `terminal_handle_stale`
|
||||
|
||||
Example stale-handle error:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"runtimeId": "runtime_new456",
|
||||
"requestId": "req_123"
|
||||
},
|
||||
"error": {
|
||||
"code": "terminal_handle_stale",
|
||||
"message": "The terminal handle is no longer valid for the current Orca runtime.",
|
||||
"retryable": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
## `orca status`
|
||||
|
||||
Purpose:
|
||||
|
||||
- confirm Orca is running
|
||||
- return current runtime identity
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-runtime-layer`
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
orca status --json
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"orcaVersion": "1.0.0",
|
||||
"runtimeId": "runtime_abc123",
|
||||
"requestId": "req_123"
|
||||
},
|
||||
"status": {
|
||||
"running": true,
|
||||
"runtimeAvailable": true,
|
||||
"capabilities": {
|
||||
"repo": true,
|
||||
"worktree": true,
|
||||
"file": true,
|
||||
"search": true,
|
||||
"git": true,
|
||||
"gh": true,
|
||||
"terminal": false,
|
||||
"worktreePs": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implementation note:
|
||||
|
||||
- a minimal “is Orca running” probe may be possible earlier
|
||||
- the strict `status` contract in this spec assumes the runtime layer exists and can issue a real `runtimeId`
|
||||
|
||||
## `orca repo list`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca repo list --json
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"orcaVersion": "1.0.0",
|
||||
"requestId": "req_123"
|
||||
},
|
||||
"repos": [
|
||||
{
|
||||
"id": "repo_1",
|
||||
"path": "/abs/repo",
|
||||
"displayName": "orca",
|
||||
"worktreeBaseRef": "origin/main"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## `orca repo add`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca repo add --path /abs/repo --json
|
||||
```
|
||||
|
||||
## `orca repo show`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca repo show --repo path:/abs/repo --json
|
||||
```
|
||||
|
||||
## `orca repo set-base-ref`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca repo set-base-ref --repo id:repo_1 --ref origin/main --json
|
||||
```
|
||||
|
||||
## `orca repo search-refs`
|
||||
|
||||
Renamed from `search-base-refs` for better verb consistency.
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca repo search-refs --repo id:repo_1 --query main --json
|
||||
```
|
||||
|
||||
## `orca worktree list`
|
||||
|
||||
Full listing command.
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca worktree list --repo id:repo_1 --json
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"orcaVersion": "1.0.0",
|
||||
"requestId": "req_123"
|
||||
},
|
||||
"worktrees": [
|
||||
{
|
||||
"id": "repo_1::/abs/wt",
|
||||
"repoId": "repo_1",
|
||||
"path": "/abs/wt",
|
||||
"branch": "refs/heads/feature/foo",
|
||||
"displayName": "Feature Foo",
|
||||
"linkedIssue": 123,
|
||||
"comment": "parser work"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## `orca worktree ps`
|
||||
|
||||
Compact orchestration summary command.
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-runtime-layer`
|
||||
|
||||
Rationale:
|
||||
|
||||
- desirable public contract
|
||||
- requires a shared live-runtime summary service, not just persisted state
|
||||
|
||||
```bash
|
||||
orca worktree ps --json
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"orcaVersion": "1.0.0",
|
||||
"runtimeId": "runtime_abc123",
|
||||
"requestId": "req_123"
|
||||
},
|
||||
"worktrees": [
|
||||
{
|
||||
"id": "repo_1::/abs/wt",
|
||||
"repo": "orca",
|
||||
"branch": "feature/foo",
|
||||
"linkedIssue": 123,
|
||||
"unread": false,
|
||||
"liveTerminals": 2,
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## `orca worktree show`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca worktree show --worktree branch:feature/foo --json
|
||||
```
|
||||
|
||||
## `orca worktree create`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
This must preserve current editor behavior:
|
||||
|
||||
- sanitize name
|
||||
- compute branch name from settings
|
||||
- reject branch conflicts
|
||||
- best-effort reject historical PR head-name reuse
|
||||
- compute path under workspace root
|
||||
- use chosen/default base ref
|
||||
- create worktree
|
||||
- best-effort apply linked issue/comment metadata
|
||||
|
||||
```bash
|
||||
orca worktree create --repo path:/abs/repo --name feature-foo --issue 123 --comment "parser work" --json
|
||||
```
|
||||
|
||||
## `orca worktree set`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca worktree set --worktree branch:feature/foo --display-name "Parser" --issue 123 --comment "parser work" --json
|
||||
```
|
||||
|
||||
## `orca worktree rm`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca worktree rm --worktree path:/abs/wt --force --json
|
||||
```
|
||||
|
||||
## `orca terminal list`
|
||||
|
||||
Purpose:
|
||||
|
||||
- discover live terminal handles for a worktree
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-runtime-layer`
|
||||
|
||||
Rationale:
|
||||
|
||||
- requires shared orchestration service over renderer-owned layout plus main-owned PTYs
|
||||
|
||||
```bash
|
||||
orca terminal list --worktree id:repo_1::/repo/.worktrees/feature-foo --json
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"orcaVersion": "1.0.0",
|
||||
"runtimeId": "runtime_abc123",
|
||||
"requestId": "req_123"
|
||||
},
|
||||
"terminals": [
|
||||
{
|
||||
"handle": "term_a2",
|
||||
"title": "claude",
|
||||
"status": "running",
|
||||
"worktree": "branch:feature/foo",
|
||||
"tabId": "tab_1",
|
||||
"tabTitle": "Claude Code",
|
||||
"leafId": "leaf_2",
|
||||
"preview": "I updated the parser. Do you want me to run the full suite?"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Optional:
|
||||
|
||||
- `--layout` may include secondary tab/layout context when the caller needs it
|
||||
|
||||
## `orca terminal show`
|
||||
|
||||
Metadata-only.
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-runtime-layer`
|
||||
|
||||
```bash
|
||||
orca terminal show --terminal term_a2 --json
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"orcaVersion": "1.0.0",
|
||||
"runtimeId": "runtime_abc123",
|
||||
"requestId": "req_123"
|
||||
},
|
||||
"terminal": {
|
||||
"handle": "term_a2",
|
||||
"title": "claude",
|
||||
"status": "running",
|
||||
"cwd": "/abs/wt",
|
||||
"tabId": "tab_1",
|
||||
"tabTitle": "Claude Code",
|
||||
"leafId": "leaf_2",
|
||||
"lastOutputAt": 1712345678,
|
||||
"lastInputAt": 1712345600,
|
||||
"preview": "I updated the parser. Do you want me to run the full suite?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `orca terminal read`
|
||||
|
||||
Content-only, bounded.
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-runtime-layer`
|
||||
|
||||
```bash
|
||||
orca terminal read --terminal term_a2 --json
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"orcaVersion": "1.0.0",
|
||||
"runtimeId": "runtime_abc123",
|
||||
"requestId": "req_123",
|
||||
"truncated": false
|
||||
},
|
||||
"terminal": {
|
||||
"handle": "term_a2",
|
||||
"status": "running",
|
||||
"tail": [
|
||||
"Running targeted tests...",
|
||||
"3 passed",
|
||||
"I updated the parser and fixed the failing snapshot."
|
||||
],
|
||||
"nextCursor": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `orca terminal send`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-runtime-layer`
|
||||
|
||||
Supported forms:
|
||||
|
||||
- `--text <text>`
|
||||
- `--enter`
|
||||
- `--interrupt`
|
||||
|
||||
```bash
|
||||
orca terminal send --terminal term_a2 --text "continue" --json
|
||||
```
|
||||
|
||||
## `orca terminal wait`
|
||||
|
||||
Status:
|
||||
|
||||
- `exit`: `v1-runtime-layer`
|
||||
- `input`: deferred
|
||||
- `idle`: deferred
|
||||
- `output`: deferred
|
||||
|
||||
Rationale:
|
||||
|
||||
- `exit` can be grounded in PTY exit events
|
||||
- the others require new runtime instrumentation and/or heuristics
|
||||
|
||||
```bash
|
||||
orca terminal wait --terminal term_a2 --for exit --json
|
||||
```
|
||||
|
||||
For unsupported wait modes in initial v1:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"requestId": "req_123"
|
||||
},
|
||||
"error": {
|
||||
"code": "not_supported_in_v1",
|
||||
"message": "terminal wait --for input requires runtime instrumentation that is not available in v1.",
|
||||
"retryable": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `orca terminal stop`
|
||||
|
||||
Stop live terminals for a worktree.
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-runtime-layer`
|
||||
|
||||
This replaces `worktree shutdown` in the primary surface because the action is terminal-oriented.
|
||||
|
||||
Initial supported target:
|
||||
|
||||
- `--worktree <selector>`
|
||||
|
||||
Later extension:
|
||||
|
||||
- `--terminal <handle>`
|
||||
|
||||
## `orca file ls`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca file ls --worktree id:repo_1::/repo/.worktrees/feature-foo --path src --json
|
||||
```
|
||||
|
||||
## `orca file read`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca file read --worktree id:repo_1::/repo/.worktrees/feature-foo --path src/main.ts --json
|
||||
```
|
||||
|
||||
## `orca file write`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca file write --worktree id:repo_1::/repo/.worktrees/feature-foo --path src/main.ts --stdin --json
|
||||
```
|
||||
|
||||
## `orca file create`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca file mkdir`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca file rename`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca file rm`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca file stat`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca search text`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
```bash
|
||||
orca search text --worktree id:repo_1::/repo/.worktrees/feature-foo --query worktree --json
|
||||
```
|
||||
|
||||
## `orca search files`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
Keep separate from `file ls --query` in v1.
|
||||
|
||||
Rationale:
|
||||
|
||||
- clearer distinction between tree listing and search behavior
|
||||
- aligns with existing product/search mental model
|
||||
|
||||
## `orca git status`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca git diff`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca git stage`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca git unstage`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca git discard`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca git branch-compare`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca gh pr`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca gh issue`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca gh issues`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## `orca gh checks`
|
||||
|
||||
Status:
|
||||
|
||||
- `v1-now`
|
||||
|
||||
## Explicitly Deferred From V1
|
||||
|
||||
Deferred:
|
||||
|
||||
- tab creation and closure
|
||||
- pane split and close
|
||||
- tab reordering
|
||||
- tab colors
|
||||
- unread/read metadata commands in the core surface
|
||||
- terminal wait modes other than `exit`
|
||||
|
||||
Rationale:
|
||||
|
||||
- either too UI-shaped
|
||||
- or not grounded in current backend ownership
|
||||
|
||||
## Recommended Implementation Order
|
||||
|
||||
1. repo/worktree/file/search/git/gh `v1-now` commands
|
||||
2. shared runtime/orchestration layer with `runtimeId`
|
||||
3. `status`
|
||||
4. terminal handle issuance and validation
|
||||
5. `terminal list/show/read/send`
|
||||
6. `worktree ps`
|
||||
7. `terminal wait --for exit`
|
||||
|
||||
## Recommendation
|
||||
|
||||
This spec is the contract to review next.
|
||||
|
||||
It is intentionally strict and narrower than the broader design docs:
|
||||
|
||||
- selectors are formal
|
||||
- handles are ephemeral by default
|
||||
- JSON is contractual
|
||||
- terminal features are split between `v1-now` and `v1-runtime-layer`
|
||||
|
||||
That should let us optimize for both agent clarity and implementation honesty.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
19
package.json
19
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",
|
||||
|
|
|
|||
|
|
@ -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" "$@"
|
||||
|
|
@ -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" "$@"
|
||||
|
|
@ -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%" %*
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
---
|
||||
name: orca-cli
|
||||
description: Use the Orca CLI to orchestrate worktrees and live terminals through a running Orca editor. Use when an agent needs to create, inspect, update, or remove Orca worktrees; inspect repo state known to Orca; or read, send to, wait on, or stop Orca-managed terminals. Triggers include "use orca cli", "manage Orca worktrees", "read Orca terminal", "reply to Claude Code in Orca", "create a worktree in Orca", or any task where the agent should operate through Orca instead of talking to git worktrees and terminal processes directly.
|
||||
---
|
||||
|
||||
# Orca CLI
|
||||
|
||||
Use this skill when the task should go through Orca's control plane rather than directly through `git`, shell PTYs, or ad hoc filesystem access.
|
||||
|
||||
## When To Use
|
||||
|
||||
Use `orca` for:
|
||||
|
||||
- worktree orchestration inside a running Orca app
|
||||
- reading and replying to Orca-managed terminals
|
||||
- stopping or waiting on Orca-managed terminals
|
||||
- accessing repos known to Orca
|
||||
|
||||
Do not use `orca` when plain shell tools are simpler and Orca state does not matter.
|
||||
|
||||
Examples:
|
||||
|
||||
- creating one Orca worktree per GitHub issue
|
||||
- finding the Claude Code terminal for a worktree and replying to it
|
||||
- checking which Orca worktrees have live terminal activity
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Prefer the public `orca` command first
|
||||
- Orca editor/runtime should already be running, or the agent should start it with `orca open`
|
||||
- Do not begin by inspecting Orca source files just to decide how to invoke the CLI. The first step is to check whether the installed `orca` command exists.
|
||||
|
||||
First verify the public CLI is installed:
|
||||
|
||||
```bash
|
||||
command -v orca
|
||||
```
|
||||
|
||||
Then use the public command:
|
||||
|
||||
```bash
|
||||
orca status --json
|
||||
```
|
||||
|
||||
If the task is about Orca worktrees or Orca terminals, do this before any codebase exploration:
|
||||
|
||||
```bash
|
||||
command -v orca
|
||||
orca status --json
|
||||
```
|
||||
|
||||
If `orca` is not on PATH, say so explicitly and stop or ask the user to install/register the CLI before continuing.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. Confirm Orca runtime availability:
|
||||
|
||||
```bash
|
||||
orca status --json
|
||||
```
|
||||
|
||||
If Orca is not running yet:
|
||||
|
||||
```bash
|
||||
orca open --json
|
||||
orca status --json
|
||||
```
|
||||
|
||||
2. Discover current Orca state:
|
||||
|
||||
```bash
|
||||
orca worktree ps --json
|
||||
orca terminal list --json
|
||||
```
|
||||
|
||||
3. Resolve a target worktree or terminal handle.
|
||||
|
||||
4. Act through Orca:
|
||||
|
||||
- `worktree create/set/rm`
|
||||
- `terminal read/send/wait/stop`
|
||||
|
||||
## Command Surface
|
||||
|
||||
### Repo
|
||||
|
||||
```bash
|
||||
orca repo list --json
|
||||
orca repo show --repo id:<repoId> --json
|
||||
orca repo add --path /abs/repo --json
|
||||
orca repo set-base-ref --repo id:<repoId> --ref origin/main --json
|
||||
orca repo search-refs --repo id:<repoId> --query main --limit 10 --json
|
||||
```
|
||||
|
||||
### Worktree
|
||||
|
||||
```bash
|
||||
orca worktree list --repo id:<repoId> --json
|
||||
orca worktree ps --json
|
||||
orca worktree show --worktree id:<worktreeId> --json
|
||||
orca worktree create --repo id:<repoId> --name my-task --issue 123 --comment "seed" --json
|
||||
orca worktree set --worktree id:<worktreeId> --display-name "My Task" --json
|
||||
orca worktree rm --worktree id:<worktreeId> --force --json
|
||||
```
|
||||
|
||||
Worktree selectors supported in focused v1:
|
||||
|
||||
- `id:<worktree-id>`
|
||||
- `path:<absolute-path>`
|
||||
- `branch:<branch-name>`
|
||||
- `issue:<number>`
|
||||
|
||||
### Terminal
|
||||
|
||||
Use selectors to discover terminals, then use the returned handle for repeated live interaction.
|
||||
|
||||
```bash
|
||||
orca terminal list --worktree id:<worktreeId> --json
|
||||
orca terminal show --terminal <handle> --json
|
||||
orca terminal read --terminal <handle> --json
|
||||
orca terminal send --terminal <handle> --text "continue" --enter --json
|
||||
orca terminal wait --terminal <handle> --for exit --timeout-ms 5000 --json
|
||||
orca terminal stop --worktree id:<worktreeId> --json
|
||||
```
|
||||
|
||||
Why: terminal handles are runtime-scoped and may go stale after reloads. If Orca returns `terminal_handle_stale`, reacquire a fresh handle with `terminal list`.
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- If the user says to create/manage an Orca worktree, use `orca worktree ...`, not raw `git worktree ...`.
|
||||
- Treat Orca as the source of truth for Orca worktree and terminal tasks. Do not mix Orca-managed state with ad hoc git worktree commands unless Orca explicitly cannot perform the requested action.
|
||||
- Prefer `--json` for all machine-driven use.
|
||||
- Use `worktree ps` as the first summary view when many worktrees may exist.
|
||||
- Use `terminal list` to reacquire handles after Orca reloads.
|
||||
- Use `terminal read` before `terminal send` unless the next input is obvious.
|
||||
- Use `terminal wait --for exit` only when the task actually depends on process completion.
|
||||
- Prefer Orca worktree selectors over hardcoded paths when Orca identity already exists.
|
||||
- If the user asks for CLI UX feedback, test the public `orca` command first. Only inspect `src/cli` or use `node out/cli/index.js` if the public command is missing or the task is explicitly about implementation internals.
|
||||
- If a command fails, prefer retrying with the public `orca` command before concluding the CLI is broken, unless the failure already came from `orca` itself.
|
||||
|
||||
## Important Constraints
|
||||
|
||||
- Orca CLI only talks to a running Orca editor.
|
||||
- Terminal handles are ephemeral and tied to the current Orca runtime.
|
||||
- `terminal wait` in focused v1 supports only `--for exit`.
|
||||
- Orca is the source of truth for worktree/terminal orchestration; do not duplicate that state with manual assumptions.
|
||||
- The public `orca` command is the interface users experience. Agents should validate and use that surface, not repo-local implementation entrypoints.
|
||||
|
||||
## References
|
||||
|
||||
See these docs in this repo when behavior is unclear:
|
||||
|
||||
- `docs/orca-cli-focused-v1-status.md`
|
||||
- `docs/orca-cli-v1-spec.md`
|
||||
- `docs/orca-runtime-layer-design.md`
|
||||
|
|
@ -0,0 +1,821 @@
|
|||
#!/usr/bin/env node
|
||||
/* eslint-disable max-lines -- Why: the public CLI entrypoint keeps command dispatch in one place so the bundled shell command and development fallback stay behaviorally identical. */
|
||||
|
||||
import type {
|
||||
CliStatusResult,
|
||||
RuntimeRepoList,
|
||||
RuntimeRepoSearchRefs,
|
||||
RuntimeWorktreeRecord,
|
||||
RuntimeWorktreePsResult,
|
||||
RuntimeWorktreeListResult,
|
||||
RuntimeTerminalRead,
|
||||
RuntimeTerminalListResult,
|
||||
RuntimeTerminalShow,
|
||||
RuntimeTerminalSend,
|
||||
RuntimeTerminalWait
|
||||
} from '../shared/runtime-types'
|
||||
import {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError,
|
||||
type RuntimeRpcSuccess
|
||||
} from './runtime-client'
|
||||
import type { RuntimeRpcFailure } from './runtime-client'
|
||||
|
||||
type ParsedArgs = {
|
||||
commandPath: string[]
|
||||
flags: Map<string, string | boolean>
|
||||
}
|
||||
|
||||
type CommandSpec = {
|
||||
path: string[]
|
||||
summary: string
|
||||
usage: string
|
||||
allowedFlags: string[]
|
||||
examples?: string[]
|
||||
notes?: string[]
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000
|
||||
const GLOBAL_FLAGS = ['help', 'json']
|
||||
const COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['open'],
|
||||
summary: 'Launch Orca and wait for the runtime to be reachable',
|
||||
usage: 'orca open [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS],
|
||||
examples: ['orca open', 'orca open --json']
|
||||
},
|
||||
{
|
||||
path: ['status'],
|
||||
summary: 'Show app/runtime/graph readiness',
|
||||
usage: 'orca status [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS],
|
||||
examples: ['orca status', 'orca status --json']
|
||||
},
|
||||
{
|
||||
path: ['repo', 'list'],
|
||||
summary: 'List repos registered in Orca',
|
||||
usage: 'orca repo list [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS]
|
||||
},
|
||||
{
|
||||
path: ['repo', 'add'],
|
||||
summary: 'Add a repo to Orca by filesystem path',
|
||||
usage: 'orca repo add --path <path> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'path']
|
||||
},
|
||||
{
|
||||
path: ['repo', 'show'],
|
||||
summary: 'Show one registered repo',
|
||||
usage: 'orca repo show --repo <selector> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'repo']
|
||||
},
|
||||
{
|
||||
path: ['repo', 'set-base-ref'],
|
||||
summary: "Set the repo's default base ref for future worktrees",
|
||||
usage: 'orca repo set-base-ref --repo <selector> --ref <ref> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'ref']
|
||||
},
|
||||
{
|
||||
path: ['repo', 'search-refs'],
|
||||
summary: 'Search branch/tag refs within a repo',
|
||||
usage: 'orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'query', 'limit']
|
||||
},
|
||||
{
|
||||
path: ['worktree', 'list'],
|
||||
summary: 'List Orca-managed worktrees',
|
||||
usage: 'orca worktree list [--repo <selector>] [--limit <n>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'limit']
|
||||
},
|
||||
{
|
||||
path: ['worktree', 'show'],
|
||||
summary: 'Show one worktree',
|
||||
usage: 'orca worktree show --worktree <selector> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
|
||||
},
|
||||
{
|
||||
path: ['worktree', 'create'],
|
||||
summary: 'Create a new Orca-managed worktree',
|
||||
usage:
|
||||
'orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment'],
|
||||
notes: ['By default this matches the Orca UI flow and activates the new worktree in the app.']
|
||||
},
|
||||
{
|
||||
path: ['worktree', 'set'],
|
||||
summary: 'Update Orca metadata for a worktree',
|
||||
usage:
|
||||
'orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'display-name', 'issue', 'comment']
|
||||
},
|
||||
{
|
||||
path: ['worktree', 'rm'],
|
||||
summary: 'Remove a worktree from Orca and git',
|
||||
usage: 'orca worktree rm --worktree <selector> [--force] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force']
|
||||
},
|
||||
{
|
||||
path: ['worktree', 'ps'],
|
||||
summary: 'Show a compact orchestration summary across worktrees',
|
||||
usage: 'orca worktree ps [--limit <n>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'limit']
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'list'],
|
||||
summary: 'List live Orca-managed terminals',
|
||||
usage: 'orca terminal list [--worktree <selector>] [--limit <n>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'limit']
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'show'],
|
||||
summary: 'Show terminal metadata and preview',
|
||||
usage: 'orca terminal show --terminal <handle> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'terminal']
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'read'],
|
||||
summary: 'Read bounded terminal output',
|
||||
usage: 'orca terminal read --terminal <handle> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'terminal']
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'send'],
|
||||
summary: 'Send input to a live terminal',
|
||||
usage:
|
||||
'orca terminal send --terminal <handle> [--text <text>] [--enter] [--interrupt] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'text', 'enter', 'interrupt']
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'wait'],
|
||||
summary: 'Wait for a terminal condition',
|
||||
usage: 'orca terminal wait --terminal <handle> --for exit [--timeout-ms <ms>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'for', 'timeout-ms']
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'stop'],
|
||||
summary: 'Stop terminals for a worktree',
|
||||
usage: 'orca terminal stop --worktree <selector> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
|
||||
}
|
||||
]
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const parsed = parseArgs(process.argv.slice(2))
|
||||
const helpPath = resolveHelpPath(parsed)
|
||||
if (helpPath !== null) {
|
||||
printHelp(helpPath)
|
||||
if (helpPath.length > 0 && !findCommandSpec(helpPath) && !isCommandGroup(helpPath)) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
if (parsed.commandPath.length === 0) {
|
||||
printHelp([])
|
||||
return
|
||||
}
|
||||
const json = parsed.flags.has('json')
|
||||
|
||||
try {
|
||||
// Why: CLI syntax and flag errors should be reported before any runtime
|
||||
// lookup so users do not get misleading "Orca is not running" failures for
|
||||
// simple command typos or unsupported flags.
|
||||
validateCommandAndFlags(parsed)
|
||||
|
||||
const client = new RuntimeClient()
|
||||
const { commandPath } = parsed
|
||||
|
||||
if (matches(commandPath, ['open'])) {
|
||||
const result = await client.openOrca()
|
||||
return printResult(result, json, formatCliStatus)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['status'])) {
|
||||
const result = await client.getCliStatus()
|
||||
if (!json && !result.result.runtime.reachable) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
return printResult(result, json, formatStatus)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['repo', 'list'])) {
|
||||
const result = await client.call<RuntimeRepoList>('repo.list')
|
||||
return printResult(result, json, formatRepoList)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['repo', 'add'])) {
|
||||
const result = await client.call<{ repo: Record<string, unknown> }>('repo.add', {
|
||||
path: getRequiredStringFlag(parsed.flags, 'path')
|
||||
})
|
||||
return printResult(result, json, formatRepoShow)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['repo', 'show'])) {
|
||||
const result = await client.call<{ repo: Record<string, unknown> }>('repo.show', {
|
||||
repo: getRequiredStringFlag(parsed.flags, 'repo')
|
||||
})
|
||||
return printResult(result, json, formatRepoShow)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['repo', 'set-base-ref'])) {
|
||||
const result = await client.call<{ repo: Record<string, unknown> }>('repo.setBaseRef', {
|
||||
repo: getRequiredStringFlag(parsed.flags, 'repo'),
|
||||
ref: getRequiredStringFlag(parsed.flags, 'ref')
|
||||
})
|
||||
return printResult(result, json, formatRepoShow)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['repo', 'search-refs'])) {
|
||||
const result = await client.call<RuntimeRepoSearchRefs>('repo.searchRefs', {
|
||||
repo: getRequiredStringFlag(parsed.flags, 'repo'),
|
||||
query: getRequiredStringFlag(parsed.flags, 'query'),
|
||||
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
|
||||
})
|
||||
return printResult(result, json, formatRepoRefs)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['terminal', 'list'])) {
|
||||
const result = await client.call<RuntimeTerminalListResult>('terminal.list', {
|
||||
worktree: getOptionalStringFlag(parsed.flags, 'worktree'),
|
||||
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
|
||||
})
|
||||
return printResult(result, json, formatTerminalList)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['terminal', 'show'])) {
|
||||
const result = await client.call<{ terminal: RuntimeTerminalShow }>('terminal.show', {
|
||||
terminal: getRequiredStringFlag(parsed.flags, 'terminal')
|
||||
})
|
||||
return printResult(result, json, formatTerminalShow)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['terminal', 'read'])) {
|
||||
const result = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', {
|
||||
terminal: getRequiredStringFlag(parsed.flags, 'terminal')
|
||||
})
|
||||
return printResult(result, json, formatTerminalRead)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['terminal', 'send'])) {
|
||||
const result = await client.call<{ send: RuntimeTerminalSend }>('terminal.send', {
|
||||
terminal: getRequiredStringFlag(parsed.flags, 'terminal'),
|
||||
text: getOptionalStringFlag(parsed.flags, 'text'),
|
||||
enter: parsed.flags.get('enter') === true,
|
||||
interrupt: parsed.flags.get('interrupt') === true
|
||||
})
|
||||
return printResult(result, json, formatTerminalSend)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['terminal', 'wait'])) {
|
||||
const timeoutMs = getOptionalPositiveIntegerFlag(parsed.flags, 'timeout-ms')
|
||||
const result = await client.call<{ wait: RuntimeTerminalWait }>(
|
||||
'terminal.wait',
|
||||
{
|
||||
terminal: getRequiredStringFlag(parsed.flags, 'terminal'),
|
||||
for: getRequiredStringFlag(parsed.flags, 'for'),
|
||||
timeoutMs
|
||||
},
|
||||
{
|
||||
// Why: terminal wait legitimately needs to outlive the CLI's default
|
||||
// RPC timeout. Even without an explicit server timeout, the client must
|
||||
// allow long waits instead of failing at the generic 15s transport cap.
|
||||
timeoutMs: timeoutMs ? timeoutMs + 5000 : DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS
|
||||
}
|
||||
)
|
||||
return printResult(result, json, formatTerminalWait)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['terminal', 'stop'])) {
|
||||
const result = await client.call<{ stopped: number }>('terminal.stop', {
|
||||
worktree: getRequiredStringFlag(parsed.flags, 'worktree')
|
||||
})
|
||||
return printResult(result, json, (value) => `Stopped ${value.stopped} terminals.`)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['worktree', 'ps'])) {
|
||||
const result = await client.call<RuntimeWorktreePsResult>('worktree.ps', {
|
||||
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
|
||||
})
|
||||
return printResult(result, json, formatWorktreePs)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['worktree', 'list'])) {
|
||||
const result = await client.call<RuntimeWorktreeListResult>('worktree.list', {
|
||||
repo: getOptionalStringFlag(parsed.flags, 'repo'),
|
||||
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
|
||||
})
|
||||
return printResult(result, json, formatWorktreeList)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['worktree', 'show'])) {
|
||||
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
|
||||
worktree: getRequiredStringFlag(parsed.flags, 'worktree')
|
||||
})
|
||||
return printResult(result, json, formatWorktreeShow)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['worktree', 'create'])) {
|
||||
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.create', {
|
||||
repo: getRequiredStringFlag(parsed.flags, 'repo'),
|
||||
name: getRequiredStringFlag(parsed.flags, 'name'),
|
||||
baseBranch: getOptionalStringFlag(parsed.flags, 'base-branch'),
|
||||
linkedIssue: getOptionalNumberFlag(parsed.flags, 'issue'),
|
||||
comment: getOptionalStringFlag(parsed.flags, 'comment')
|
||||
})
|
||||
return printResult(result, json, formatWorktreeShow)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['worktree', 'set'])) {
|
||||
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', {
|
||||
worktree: getRequiredStringFlag(parsed.flags, 'worktree'),
|
||||
displayName: getOptionalStringFlag(parsed.flags, 'display-name'),
|
||||
linkedIssue: getOptionalNullableNumberFlag(parsed.flags, 'issue'),
|
||||
comment: getOptionalStringFlag(parsed.flags, 'comment')
|
||||
})
|
||||
return printResult(result, json, formatWorktreeShow)
|
||||
}
|
||||
|
||||
if (matches(commandPath, ['worktree', 'rm'])) {
|
||||
const result = await client.call<{ removed: boolean }>('worktree.rm', {
|
||||
worktree: getRequiredStringFlag(parsed.flags, 'worktree'),
|
||||
force: parsed.flags.get('force') === true
|
||||
})
|
||||
return printResult(result, json, (value) => `removed: ${value.removed}`)
|
||||
}
|
||||
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown command: ${commandPath.join(' ')}`)
|
||||
} catch (error) {
|
||||
if (json) {
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
console.log(JSON.stringify(error.response, null, 2))
|
||||
} else {
|
||||
const response: RuntimeRpcFailure = {
|
||||
id: 'local',
|
||||
ok: false,
|
||||
error: {
|
||||
code: error instanceof RuntimeClientError ? error.code : 'runtime_error',
|
||||
message: formatCliError(error)
|
||||
},
|
||||
_meta: {
|
||||
runtimeId: null
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(response, null, 2))
|
||||
}
|
||||
} else {
|
||||
console.error(formatCliError(error))
|
||||
}
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const commandPath: string[] = []
|
||||
const flags = new Map<string, string | boolean>()
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i]
|
||||
if (!token.startsWith('--')) {
|
||||
commandPath.push(token)
|
||||
continue
|
||||
}
|
||||
|
||||
const flag = token.slice(2)
|
||||
const next = argv[i + 1]
|
||||
if (!next || next.startsWith('--')) {
|
||||
flags.set(flag, true)
|
||||
continue
|
||||
}
|
||||
flags.set(flag, next)
|
||||
i += 1
|
||||
}
|
||||
|
||||
return { commandPath, flags }
|
||||
}
|
||||
|
||||
function resolveHelpPath(parsed: ParsedArgs): string[] | null {
|
||||
if (parsed.commandPath[0] === 'help') {
|
||||
return parsed.commandPath.slice(1)
|
||||
}
|
||||
if (parsed.flags.has('help')) {
|
||||
return parsed.commandPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function validateCommandAndFlags(parsed: ParsedArgs): void {
|
||||
const spec = findCommandSpec(parsed.commandPath)
|
||||
if (!spec) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown command: ${parsed.commandPath.join(' ')}`
|
||||
)
|
||||
}
|
||||
|
||||
for (const flag of parsed.flags.keys()) {
|
||||
if (!spec.allowedFlags.includes(flag)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown flag --${flag} for command: ${spec.path.join(' ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findCommandSpec(commandPath: string[]): CommandSpec | undefined {
|
||||
return COMMAND_SPECS.find((spec) => matches(spec.path, commandPath))
|
||||
}
|
||||
|
||||
function isCommandGroup(commandPath: string[]): boolean {
|
||||
return commandPath.length === 1 && ['repo', 'worktree', 'terminal'].includes(commandPath[0])
|
||||
}
|
||||
|
||||
function getRequiredStringFlag(flags: Map<string, string | boolean>, name: string): string {
|
||||
const value = flags.get(name)
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return value
|
||||
}
|
||||
throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
|
||||
}
|
||||
|
||||
function getOptionalStringFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): string | undefined {
|
||||
const value = flags.get(name)
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function getOptionalNumberFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): number | undefined {
|
||||
const value = flags.get(name)
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Invalid numeric value for --${name}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function getOptionalPositiveIntegerFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): number | undefined {
|
||||
const value = getOptionalNumberFlag(flags, name)
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new RuntimeClientError('invalid_argument', `Invalid positive integer for --${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getOptionalNullableNumberFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): number | null | undefined {
|
||||
const value = flags.get(name)
|
||||
if (value === 'null') {
|
||||
return null
|
||||
}
|
||||
return getOptionalNumberFlag(flags, name)
|
||||
}
|
||||
|
||||
function matches(actual: string[], expected: string[]): boolean {
|
||||
return (
|
||||
actual.length === expected.length && actual.every((value, index) => value === expected[index])
|
||||
)
|
||||
}
|
||||
|
||||
function printResult<TResult>(
|
||||
response: RuntimeRpcSuccess<TResult>,
|
||||
json: boolean,
|
||||
formatter: (value: TResult) => string
|
||||
): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(response, null, 2))
|
||||
return
|
||||
}
|
||||
console.log(formatter(response.result))
|
||||
}
|
||||
|
||||
function formatStatus(status: CliStatusResult): string {
|
||||
return formatCliStatus(status)
|
||||
}
|
||||
|
||||
function formatCliStatus(status: CliStatusResult): string {
|
||||
return [
|
||||
`appRunning: ${status.app.running}`,
|
||||
`pid: ${status.app.pid ?? 'none'}`,
|
||||
`runtimeState: ${status.runtime.state}`,
|
||||
`runtimeReachable: ${status.runtime.reachable}`,
|
||||
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
|
||||
`graphState: ${status.graph.state}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function formatCliError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (
|
||||
error instanceof RuntimeClientError &&
|
||||
(error.code === 'runtime_unavailable' || error.code === 'runtime_timeout')
|
||||
) {
|
||||
return `${message}\nOrca is not running. Run 'orca open' first.`
|
||||
}
|
||||
if (
|
||||
error instanceof RuntimeRpcFailureError &&
|
||||
error.response.error.code === 'runtime_unavailable'
|
||||
) {
|
||||
return `${message}\nOrca is not running. Run 'orca open' first.`
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
function formatTerminalList(result: RuntimeTerminalListResult): string {
|
||||
if (result.terminals.length === 0) {
|
||||
return 'No live terminals.'
|
||||
}
|
||||
const body = result.terminals
|
||||
.map(
|
||||
(terminal) =>
|
||||
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
|
||||
)
|
||||
.join('\n\n')
|
||||
return result.truncated
|
||||
? `${body}\n\ntruncated: showing ${result.terminals.length} of ${result.totalCount}`
|
||||
: body
|
||||
}
|
||||
|
||||
function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): string {
|
||||
const terminal = result.terminal
|
||||
return [
|
||||
`handle: ${terminal.handle}`,
|
||||
`title: ${terminal.title ?? '(untitled)'}`,
|
||||
`worktree: ${terminal.worktreePath}`,
|
||||
`branch: ${terminal.branch}`,
|
||||
`leaf: ${terminal.leafId}`,
|
||||
`ptyId: ${terminal.ptyId ?? 'none'}`,
|
||||
`connected: ${terminal.connected}`,
|
||||
`writable: ${terminal.writable}`,
|
||||
`preview: ${terminal.preview || '<empty>'}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string {
|
||||
const terminal = result.terminal
|
||||
return [`handle: ${terminal.handle}`, `status: ${terminal.status}`, '', ...terminal.tail].join(
|
||||
'\n'
|
||||
)
|
||||
}
|
||||
|
||||
function formatTerminalSend(result: { send: RuntimeTerminalSend }): string {
|
||||
return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.`
|
||||
}
|
||||
|
||||
function formatTerminalWait(result: { wait: RuntimeTerminalWait }): string {
|
||||
return [
|
||||
`handle: ${result.wait.handle}`,
|
||||
`condition: ${result.wait.condition}`,
|
||||
`satisfied: ${result.wait.satisfied}`,
|
||||
`status: ${result.wait.status}`,
|
||||
`exitCode: ${result.wait.exitCode ?? 'null'}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function formatWorktreePs(result: RuntimeWorktreePsResult): string {
|
||||
if (result.worktrees.length === 0) {
|
||||
return 'No worktrees found.'
|
||||
}
|
||||
const body = result.worktrees
|
||||
.map(
|
||||
(worktree) =>
|
||||
`${worktree.repo} ${worktree.branch} live:${worktree.liveTerminalCount} pty:${worktree.hasAttachedPty ? 'yes' : 'no'} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}`
|
||||
)
|
||||
.join('\n\n')
|
||||
return result.truncated
|
||||
? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
|
||||
: body
|
||||
}
|
||||
|
||||
function formatRepoList(result: RuntimeRepoList): string {
|
||||
if (result.repos.length === 0) {
|
||||
return 'No repos found.'
|
||||
}
|
||||
return result.repos.map((repo) => `${repo.id} ${repo.displayName} ${repo.path}`).join('\n')
|
||||
}
|
||||
|
||||
function formatRepoShow(result: { repo: Record<string, unknown> }): string {
|
||||
return Object.entries(result.repo)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function formatRepoRefs(result: RuntimeRepoSearchRefs): string {
|
||||
if (result.refs.length === 0) {
|
||||
return 'No refs found.'
|
||||
}
|
||||
return result.truncated ? `${result.refs.join('\n')}\n\ntruncated: yes` : result.refs.join('\n')
|
||||
}
|
||||
|
||||
function formatWorktreeList(result: RuntimeWorktreeListResult): string {
|
||||
if (result.worktrees.length === 0) {
|
||||
return 'No worktrees found.'
|
||||
}
|
||||
const body = result.worktrees
|
||||
.map(
|
||||
(worktree) =>
|
||||
`${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}`
|
||||
)
|
||||
.join('\n\n')
|
||||
return result.truncated
|
||||
? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
|
||||
: body
|
||||
}
|
||||
|
||||
function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }): string {
|
||||
const worktree = result.worktree
|
||||
return Object.entries(worktree)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function printHelp(commandPath: string[] = []): void {
|
||||
const exactSpec = findCommandSpec(commandPath)
|
||||
if (exactSpec) {
|
||||
console.log(formatCommandHelp(exactSpec))
|
||||
return
|
||||
}
|
||||
|
||||
if (isCommandGroup(commandPath)) {
|
||||
console.log(formatGroupHelp(commandPath[0]))
|
||||
return
|
||||
}
|
||||
|
||||
if (commandPath.length > 0) {
|
||||
console.log(`Unknown command: ${commandPath.join(' ')}\n`)
|
||||
}
|
||||
|
||||
console.log(`orca
|
||||
|
||||
Usage: orca <command> [options]
|
||||
|
||||
Startup:
|
||||
open Launch Orca and wait for the runtime to be reachable
|
||||
status Show app/runtime/graph readiness
|
||||
|
||||
Repos:
|
||||
repo list List repos registered in Orca
|
||||
repo add Add a repo to Orca by filesystem path
|
||||
repo show Show one registered repo
|
||||
repo set-base-ref Set the repo's default base ref for future worktrees
|
||||
repo search-refs Search branch/tag refs within a repo
|
||||
|
||||
Worktrees:
|
||||
worktree list List Orca-managed worktrees
|
||||
worktree show Show one worktree
|
||||
worktree create Create a new Orca-managed worktree
|
||||
worktree set Update Orca metadata for a worktree
|
||||
worktree rm Remove a worktree from Orca and git
|
||||
worktree ps Show a compact orchestration summary across worktrees
|
||||
|
||||
Terminals:
|
||||
terminal list List live Orca-managed terminals
|
||||
terminal show Show terminal metadata and preview
|
||||
terminal read Read bounded terminal output
|
||||
terminal send Send input to a live terminal
|
||||
terminal wait Wait for a terminal condition
|
||||
terminal stop Stop terminals for a worktree
|
||||
|
||||
Common Commands:
|
||||
orca open [--json]
|
||||
orca status [--json]
|
||||
orca worktree list [--repo <selector>] [--limit <n>] [--json]
|
||||
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--json]
|
||||
orca worktree show --worktree <selector> [--json]
|
||||
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]
|
||||
orca worktree rm --worktree <selector> [--force] [--json]
|
||||
orca worktree ps [--limit <n>] [--json]
|
||||
orca terminal list [--worktree <selector>] [--limit <n>] [--json]
|
||||
orca terminal show --terminal <handle> [--json]
|
||||
orca terminal read --terminal <handle> [--json]
|
||||
orca terminal send --terminal <handle> [--text <text>] [--enter] [--interrupt] [--json]
|
||||
orca terminal wait --terminal <handle> --for exit [--timeout-ms <ms>] [--json]
|
||||
orca terminal stop --worktree <selector> [--json]
|
||||
orca repo list [--json]
|
||||
orca repo add --path <path> [--json]
|
||||
orca repo show --repo <selector> [--json]
|
||||
orca repo set-base-ref --repo <selector> --ref <ref> [--json]
|
||||
orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]
|
||||
|
||||
Selectors:
|
||||
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
|
||||
--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, or path:<path>
|
||||
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
|
||||
|
||||
Terminal Send Options:
|
||||
--text <text> Text to send to the terminal
|
||||
--enter Append Enter after sending text
|
||||
--interrupt Send as an interrupt-style input when supported
|
||||
|
||||
Wait Options:
|
||||
--for exit Wait until the target terminal exits
|
||||
--timeout-ms <ms> Maximum wait time before timing out
|
||||
|
||||
Output Options:
|
||||
--json Emit machine-readable JSON instead of human text
|
||||
--help Show this help message
|
||||
|
||||
Behavior:
|
||||
Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first.
|
||||
Use selectors for discovery and handles for repeated live terminal operations.
|
||||
|
||||
Examples:
|
||||
$ orca open
|
||||
$ orca status --json
|
||||
$ orca repo list
|
||||
$ orca worktree create --repo name:orca --name cli-test-1 --issue 273
|
||||
$ orca worktree show --worktree branch:Jinwoo-H/cli
|
||||
$ orca worktree ps --limit 10
|
||||
$ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json
|
||||
$ orca terminal send --terminal term_123 --text "hi" --enter
|
||||
$ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json`)
|
||||
}
|
||||
|
||||
function formatCommandHelp(spec: CommandSpec): string {
|
||||
const lines = [`orca ${spec.path.join(' ')}`, '', `Usage: ${spec.usage}`, '', spec.summary]
|
||||
|
||||
if (spec.allowedFlags.length > 0) {
|
||||
lines.push('', 'Options:')
|
||||
for (const flag of spec.allowedFlags) {
|
||||
lines.push(` ${formatFlagHelp(flag)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.notes && spec.notes.length > 0) {
|
||||
lines.push('', 'Notes:')
|
||||
for (const note of spec.notes) {
|
||||
lines.push(` ${note}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.examples && spec.examples.length > 0) {
|
||||
lines.push('', 'Examples:')
|
||||
for (const example of spec.examples) {
|
||||
lines.push(` $ ${example}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatGroupHelp(group: string): string {
|
||||
const specs = COMMAND_SPECS.filter((spec) => spec.path[0] === group)
|
||||
const lines = [`orca ${group}`, '', `Usage: orca ${group} <command> [options]`, '', 'Commands:']
|
||||
for (const spec of specs) {
|
||||
lines.push(` ${spec.path.slice(1).join(' ').padEnd(18)} ${spec.summary}`)
|
||||
}
|
||||
lines.push('', `Run \`orca ${group} <command> --help\` for command-specific usage.`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatFlagHelp(flag: string): string {
|
||||
const helpByFlag: Record<string, string> = {
|
||||
'base-branch': '--base-branch <ref> Base branch/ref to create the worktree from',
|
||||
comment: '--comment <text> Comment stored in Orca metadata',
|
||||
'display-name': '--display-name <name> Override the Orca display name',
|
||||
enter: '--enter Append Enter after sending text',
|
||||
force: '--force Force worktree removal when supported',
|
||||
for: '--for exit Wait condition to satisfy',
|
||||
help: '--help Show this help message',
|
||||
interrupt: '--interrupt Send as an interrupt-style input when supported',
|
||||
issue: '--issue <number|null> Linked GitHub issue number',
|
||||
json: '--json Emit machine-readable JSON',
|
||||
limit: '--limit <n> Maximum number of rows to return',
|
||||
name: '--name <name> Name for the new worktree',
|
||||
path: '--path <path> Filesystem path to the repo',
|
||||
query: '--query <text> Search text for matching refs',
|
||||
ref: '--ref <ref> Base ref to persist for the repo',
|
||||
repo: '--repo <selector> Repo selector such as id:<id>, name:<name>, or path:<path>',
|
||||
terminal: '--terminal <handle> Runtime-issued terminal handle',
|
||||
text: '--text <text> Text to send to the terminal',
|
||||
'timeout-ms': '--timeout-ms <ms> Maximum wait time before timing out',
|
||||
worktree:
|
||||
'--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, or path:<path>'
|
||||
}
|
||||
|
||||
return helpByFlag[flag] ?? `--${flag}`
|
||||
}
|
||||
|
||||
void main()
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
import { mkdtempSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { createServer, type Socket } from 'net'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client'
|
||||
|
||||
const servers = new Set<ReturnType<typeof createServer>>()
|
||||
const sockets = new Set<Socket>()
|
||||
|
||||
afterEach(async () => {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy()
|
||||
}
|
||||
sockets.clear()
|
||||
await Promise.all(
|
||||
[...servers].map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve())
|
||||
})
|
||||
)
|
||||
)
|
||||
servers.clear()
|
||||
})
|
||||
|
||||
function writeMetadata(userDataPath: string, endpoint: string, authToken = 'token'): void {
|
||||
writeFileSync(
|
||||
join(userDataPath, 'orca-runtime.json'),
|
||||
JSON.stringify({
|
||||
runtimeId: 'runtime-1',
|
||||
pid: 123,
|
||||
transport: {
|
||||
kind: 'unix',
|
||||
endpoint
|
||||
},
|
||||
authToken,
|
||||
startedAt: 1
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
describe('RuntimeClient', () => {
|
||||
it('returns the full RPC envelope for successful calls', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as { id: string }
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { running: true },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500)
|
||||
const response = await client.call<{ running: boolean }>('status.get')
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { running: true },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
expect(response.id).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports not_running when no runtime metadata exists', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const client = new RuntimeClient(userDataPath, 100)
|
||||
|
||||
const status = await client.getCliStatus()
|
||||
|
||||
expect(status.result).toEqual({
|
||||
app: {
|
||||
running: false,
|
||||
pid: null
|
||||
},
|
||||
runtime: {
|
||||
state: 'not_running',
|
||||
reachable: false,
|
||||
runtimeId: null
|
||||
},
|
||||
graph: {
|
||||
state: 'not_running'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('reports graph_not_ready when the runtime is reachable but graph is unavailable', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as { id: string }
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
rendererGraphEpoch: 0,
|
||||
graphStatus: 'unavailable',
|
||||
authoritativeWindowId: null,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 100)
|
||||
const status = await client.getCliStatus()
|
||||
|
||||
expect(status.result.runtime.state).toBe('graph_not_ready')
|
||||
expect(status.result.graph.state).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('openOrca succeeds immediately when the runtime is already reachable', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as { id: string }
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
rendererGraphEpoch: 0,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 1,
|
||||
liveLeafCount: 1
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 100)
|
||||
const status = await client.openOrca(100)
|
||||
|
||||
expect(status.result.runtime.state).toBe('ready')
|
||||
expect(status.result.runtime.reachable).toBe(true)
|
||||
})
|
||||
|
||||
it('times out if the runtime never responds', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
// Why: keep the socket open without replying so the client timeout path
|
||||
// is exercised against a real hung runtime connection.
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 25)
|
||||
|
||||
await expect(client.call('status.get')).rejects.toMatchObject({
|
||||
code: 'runtime_timeout'
|
||||
})
|
||||
})
|
||||
|
||||
it('allows a per-call timeout override for long runtime requests', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as { id: string }
|
||||
setTimeout(() => {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { satisfied: true },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}\n`
|
||||
)
|
||||
}, 40)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 25)
|
||||
const response = await client.call<{ satisfied: boolean }>('terminal.wait', undefined, {
|
||||
timeoutMs: 250
|
||||
})
|
||||
|
||||
expect(response.result).toEqual({ satisfied: true })
|
||||
})
|
||||
|
||||
it('preserves structured runtime failures', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as { id: string }
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: { code: 'selector_not_found', message: 'selector_not_found' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 100)
|
||||
|
||||
await expect(client.call('worktree.show')).rejects.toBeInstanceOf(RuntimeRpcFailureError)
|
||||
await expect(client.call('worktree.show')).rejects.toMatchObject({
|
||||
response: {
|
||||
ok: false,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid runtime response frames', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', () => {
|
||||
socket.write('not json\n')
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 100)
|
||||
|
||||
await expect(client.call('status.get')).rejects.toMatchObject({
|
||||
code: 'invalid_runtime_response'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects mismatched response ids from the runtime', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', () => {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: 'not-the-request-id',
|
||||
ok: true,
|
||||
result: { running: true },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 100)
|
||||
|
||||
await expect(client.call('status.get')).rejects.toMatchObject({
|
||||
code: 'invalid_runtime_response'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
/* eslint-disable max-lines -- Why: the runtime client owns the full local IPC contract, launch fallback, and response validation in one place so the CLI does not drift from the app runtime. */
|
||||
import { createConnection } from 'net'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import { readFileSync } from 'fs'
|
||||
import { spawn as spawnProcess } from 'child_process'
|
||||
import type { CliStatusResult, RuntimeStatus } from '../shared/runtime-types'
|
||||
|
||||
type RuntimeTransportMetadata =
|
||||
| {
|
||||
kind: 'unix'
|
||||
endpoint: string
|
||||
}
|
||||
| {
|
||||
kind: 'named-pipe'
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
type RuntimeMetadata = {
|
||||
runtimeId: string
|
||||
pid: number
|
||||
transport: RuntimeTransportMetadata | null
|
||||
authToken: string
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export type RuntimeRpcSuccess<TResult> = {
|
||||
id: string
|
||||
ok: true
|
||||
result: TResult
|
||||
_meta: {
|
||||
runtimeId: string
|
||||
}
|
||||
}
|
||||
|
||||
export type RuntimeRpcFailure = {
|
||||
id: string
|
||||
ok: false
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
_meta?: {
|
||||
runtimeId: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type RuntimeRpcResponse<TResult> = RuntimeRpcSuccess<TResult> | RuntimeRpcFailure
|
||||
|
||||
export class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
readonly response: RuntimeRpcFailure
|
||||
|
||||
constructor(response: RuntimeRpcFailure) {
|
||||
super(response.error.code, response.error.message)
|
||||
this.response = response
|
||||
}
|
||||
}
|
||||
|
||||
export class RuntimeClient {
|
||||
private readonly userDataPath: string
|
||||
private readonly requestTimeoutMs: number
|
||||
|
||||
constructor(userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 15000) {
|
||||
this.userDataPath = userDataPath
|
||||
this.requestTimeoutMs = requestTimeoutMs
|
||||
}
|
||||
|
||||
async call<TResult>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: {
|
||||
timeoutMs?: number
|
||||
}
|
||||
): Promise<RuntimeRpcSuccess<TResult>> {
|
||||
const metadata = this.readMetadata()
|
||||
const response = await this.sendRequest<TResult>(metadata, method, params, options?.timeoutMs)
|
||||
if (!response.ok) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async getCliStatus(): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
const metadata = this.tryReadMetadata()
|
||||
if (!metadata?.transport || !metadata.authToken) {
|
||||
return buildCliStatusResponse({
|
||||
app: {
|
||||
running: false,
|
||||
pid: null
|
||||
},
|
||||
runtime: {
|
||||
state: 'not_running',
|
||||
reachable: false,
|
||||
runtimeId: null
|
||||
},
|
||||
graph: {
|
||||
state: 'not_running'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.sendRequest<RuntimeStatus>(
|
||||
metadata,
|
||||
'status.get',
|
||||
undefined,
|
||||
1000
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
}
|
||||
const graphState = response.result.graphStatus
|
||||
return buildCliStatusResponse({
|
||||
app: {
|
||||
running: true,
|
||||
pid: metadata.pid
|
||||
},
|
||||
runtime: {
|
||||
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
|
||||
reachable: true,
|
||||
runtimeId: response.result.runtimeId
|
||||
},
|
||||
graph: {
|
||||
state: graphState
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
const running = isProcessRunning(metadata.pid)
|
||||
return buildCliStatusResponse({
|
||||
app: {
|
||||
running,
|
||||
pid: running ? metadata.pid : null
|
||||
},
|
||||
runtime: {
|
||||
state: running ? 'starting' : 'not_running',
|
||||
reachable: false,
|
||||
runtimeId: null
|
||||
},
|
||||
graph: {
|
||||
state: running ? 'starting' : 'not_running'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async openOrca(timeoutMs = 15_000): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
const initial = await this.getCliStatus()
|
||||
if (initial.result.runtime.reachable) {
|
||||
return initial
|
||||
}
|
||||
|
||||
launchOrcaApp()
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const status = await this.getCliStatus()
|
||||
if (status.result.runtime.reachable) {
|
||||
return status
|
||||
}
|
||||
await delay(250)
|
||||
}
|
||||
|
||||
throw new RuntimeClientError(
|
||||
'runtime_open_timeout',
|
||||
'Timed out waiting for Orca to start. Run the Orca app manually and try again.'
|
||||
)
|
||||
}
|
||||
|
||||
private readMetadata(): RuntimeMetadata {
|
||||
const metadataPath = getRuntimeMetadataPath(this.userDataPath)
|
||||
try {
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null
|
||||
if (!metadata?.transport || !metadata.authToken) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
`Orca runtime metadata is incomplete at ${metadataPath}`
|
||||
)
|
||||
}
|
||||
return metadata
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeClientError) {
|
||||
throw error
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
`Could not read Orca runtime metadata at ${metadataPath}. Start the Orca app first.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private tryReadMetadata(): RuntimeMetadata | null {
|
||||
const metadataPath = getRuntimeMetadataPath(this.userDataPath)
|
||||
try {
|
||||
return JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async sendRequest<TResult>(
|
||||
metadata: RuntimeMetadata,
|
||||
method: string,
|
||||
params?: unknown,
|
||||
timeoutMs?: number
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = createConnection(getTransportEndpoint(metadata.transport!))
|
||||
let buffer = ''
|
||||
const requestId = randomUUID()
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy()
|
||||
reject(
|
||||
new RuntimeClientError(
|
||||
'runtime_timeout',
|
||||
'Timed out waiting for the Orca runtime to respond.'
|
||||
)
|
||||
)
|
||||
}, timeoutMs ?? this.requestTimeoutMs)
|
||||
|
||||
socket.setEncoding('utf8')
|
||||
socket.once('error', () => {
|
||||
clearTimeout(timeout)
|
||||
reject(
|
||||
new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'Could not connect to the running Orca app. Restart Orca and try again.'
|
||||
)
|
||||
)
|
||||
})
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk
|
||||
const newlineIndex = buffer.indexOf('\n')
|
||||
if (newlineIndex === -1) {
|
||||
return
|
||||
}
|
||||
const message = buffer.slice(0, newlineIndex)
|
||||
socket.end()
|
||||
clearTimeout(timeout)
|
||||
try {
|
||||
const response = JSON.parse(message) as RuntimeRpcResponse<TResult>
|
||||
if (response.id !== requestId) {
|
||||
reject(
|
||||
new RuntimeClientError(
|
||||
'invalid_runtime_response',
|
||||
'The Orca runtime returned a mismatched response id.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (response._meta?.runtimeId && response._meta.runtimeId !== metadata.runtimeId) {
|
||||
reject(
|
||||
new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'The Orca runtime changed while the request was in flight. Retry the command.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
resolve(response)
|
||||
} catch {
|
||||
reject(
|
||||
new RuntimeClientError(
|
||||
'invalid_runtime_response',
|
||||
'The Orca runtime returned an invalid response frame.'
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
socket.on('connect', () => {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: requestId,
|
||||
authToken: metadata.authToken,
|
||||
method,
|
||||
params
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess<CliStatusResult> {
|
||||
return {
|
||||
id: 'local-status',
|
||||
ok: true,
|
||||
result,
|
||||
_meta: {
|
||||
runtimeId: result.runtime.runtimeId ?? 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number | null | undefined): boolean {
|
||||
if (!pid || pid <= 0) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function launchOrcaApp(): void {
|
||||
const overrideCommand = process.env.ORCA_OPEN_COMMAND
|
||||
if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) {
|
||||
spawnProcess(overrideCommand, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
shell: true
|
||||
}).unref()
|
||||
return
|
||||
}
|
||||
|
||||
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
|
||||
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
|
||||
spawnProcess(overrideExecutable, [], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: stripElectronRunAsNode(process.env)
|
||||
}).unref()
|
||||
return
|
||||
}
|
||||
|
||||
if (process.env.ELECTRON_RUN_AS_NODE === '1') {
|
||||
if (process.platform === 'darwin') {
|
||||
const appBundlePath = getMacAppBundlePath(process.execPath)
|
||||
if (appBundlePath) {
|
||||
// Why: launching the inner MacOS binary directly can trigger macOS app
|
||||
// launch failures and bypass normal bundle lifecycle. The public
|
||||
// packaged CLI should re-open the .app the same way Finder does.
|
||||
spawnProcess('open', [appBundlePath], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: stripElectronRunAsNode(process.env)
|
||||
}).unref()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
spawnProcess(process.execPath, [], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: stripElectronRunAsNode(process.env)
|
||||
}).unref()
|
||||
return
|
||||
}
|
||||
|
||||
throw new RuntimeClientError(
|
||||
'runtime_open_failed',
|
||||
'Could not determine how to launch Orca. Start Orca manually and try again.'
|
||||
)
|
||||
}
|
||||
|
||||
function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const next = { ...env }
|
||||
delete next.ELECTRON_RUN_AS_NODE
|
||||
return next
|
||||
}
|
||||
|
||||
function getMacAppBundlePath(execPath: string): string | null {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
const macOsDir = dirname(execPath)
|
||||
const contentsDir = dirname(macOsDir)
|
||||
const appBundlePath = dirname(contentsDir)
|
||||
return appBundlePath.endsWith('.app') ? appBundlePath : null
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function getTransportEndpoint(transport: RuntimeTransportMetadata): string {
|
||||
return transport.endpoint
|
||||
}
|
||||
|
||||
export function getDefaultUserDataPath(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
homeDir = homedir()
|
||||
): string {
|
||||
if (platform === 'darwin') {
|
||||
return join(homeDir, 'Library', 'Application Support', 'orca')
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
const appData = process.env.APPDATA
|
||||
if (!appData) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'APPDATA is not set, so the Orca runtime metadata path cannot be resolved.'
|
||||
)
|
||||
}
|
||||
return join(appData, 'orca')
|
||||
}
|
||||
// Why: the CLI must find the same metadata file Electron writes in packaged
|
||||
// runs, so this mirrors Electron's default userData base instead of inventing
|
||||
// a CLI-specific config path.
|
||||
return join(process.env.XDG_CONFIG_HOME || join(homeDir, '.config'), 'orca')
|
||||
}
|
||||
|
||||
function getRuntimeMetadataPath(userDataPath: string): string {
|
||||
return join(userDataPath, 'orca-runtime.json')
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,633 @@
|
|||
/* eslint-disable max-lines -- Why: this file centralizes cross-platform CLI install state, launcher resolution, and PATH registration so the public shell command stays consistent across packaged and development builds. */
|
||||
import { app } from 'electron'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { lstat, mkdir, readFile, readlink, symlink, unlink, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca'
|
||||
const DEV_LAUNCHER_DIR = ['cli', 'bin']
|
||||
|
||||
type CliInstallerOptions = {
|
||||
platform?: NodeJS.Platform
|
||||
isPackaged?: boolean
|
||||
userDataPath?: string
|
||||
resourcesPath?: string
|
||||
execPath?: string
|
||||
appPath?: string
|
||||
homePath?: string
|
||||
localAppDataPath?: string
|
||||
processPathEnv?: string | null
|
||||
commandPathOverride?: string | null
|
||||
privilegedRunner?: (command: string) => Promise<void>
|
||||
userPathReader?: () => Promise<string | null>
|
||||
userPathWriter?: (value: string) => Promise<void>
|
||||
}
|
||||
|
||||
type InstallSpec = {
|
||||
commandPath: string
|
||||
installMethod: CliInstallMethod
|
||||
}
|
||||
|
||||
export class CliInstaller {
|
||||
private readonly platform: NodeJS.Platform
|
||||
private readonly isPackaged: boolean
|
||||
private readonly userDataPath: string
|
||||
private readonly resourcesPath: string
|
||||
private readonly execPathValue: string
|
||||
private readonly appPathValue: string
|
||||
private readonly homePath: string
|
||||
private readonly localAppDataPath: string
|
||||
private readonly processPathEnv: string | null
|
||||
private readonly commandPathOverride: string | null
|
||||
private readonly privilegedRunner: (command: string) => Promise<void>
|
||||
private readonly userPathReader: () => Promise<string | null>
|
||||
private readonly userPathWriter: (value: string) => Promise<void>
|
||||
|
||||
constructor(options: CliInstallerOptions = {}) {
|
||||
this.platform = options.platform ?? process.platform
|
||||
this.isPackaged = options.isPackaged ?? app.isPackaged
|
||||
this.userDataPath = options.userDataPath ?? app.getPath('userData')
|
||||
this.resourcesPath = options.resourcesPath ?? process.resourcesPath
|
||||
this.execPathValue = options.execPath ?? process.execPath
|
||||
this.appPathValue = options.appPath ?? app.getAppPath()
|
||||
this.homePath = options.homePath ?? homedir()
|
||||
this.localAppDataPath =
|
||||
options.localAppDataPath ??
|
||||
process.env.LOCALAPPDATA ??
|
||||
join(this.homePath, 'AppData', 'Local')
|
||||
this.processPathEnv = options.processPathEnv ?? process.env.PATH ?? process.env.Path ?? null
|
||||
this.commandPathOverride =
|
||||
options.commandPathOverride ?? process.env.ORCA_CLI_INSTALL_PATH ?? null
|
||||
this.privilegedRunner = options.privilegedRunner ?? runMacPrivilegedCommand
|
||||
this.userPathReader = options.userPathReader ?? (() => readWindowsUserPath())
|
||||
this.userPathWriter = options.userPathWriter ?? ((value) => writeWindowsUserPath(value))
|
||||
}
|
||||
|
||||
async getStatus(): Promise<CliInstallStatus> {
|
||||
const spec = this.resolveInstallSpec()
|
||||
if (!spec) {
|
||||
return {
|
||||
platform: this.platform,
|
||||
commandName: 'orca',
|
||||
commandPath: null,
|
||||
pathDirectory: null,
|
||||
pathConfigured: false,
|
||||
launcherPath: null,
|
||||
installMethod: null,
|
||||
supported: false,
|
||||
state: 'unsupported',
|
||||
currentTarget: null,
|
||||
unsupportedReason: 'platform_not_supported',
|
||||
detail: 'CLI registration is not implemented on this platform.'
|
||||
}
|
||||
}
|
||||
|
||||
const launcherPath = await this.resolveLauncherPath()
|
||||
if (!launcherPath) {
|
||||
return {
|
||||
platform: this.platform,
|
||||
commandName: 'orca',
|
||||
commandPath: spec.commandPath,
|
||||
pathDirectory: dirname(spec.commandPath),
|
||||
pathConfigured: false,
|
||||
launcherPath: null,
|
||||
installMethod: spec.installMethod,
|
||||
supported: false,
|
||||
state: 'unsupported',
|
||||
currentTarget: null,
|
||||
unsupportedReason: this.isPackaged ? 'launcher_missing' : 'launch_mode_unavailable',
|
||||
detail: this.isPackaged
|
||||
? 'The bundled CLI launcher is missing from this Orca build.'
|
||||
: 'Development mode uses a generated launcher for validation only.'
|
||||
}
|
||||
}
|
||||
|
||||
const baseStatus =
|
||||
spec.installMethod === 'symlink'
|
||||
? await this.inspectSymlink(spec.commandPath, launcherPath)
|
||||
: await this.inspectWindowsWrapper(spec.commandPath, launcherPath)
|
||||
const pathDirectory = dirname(spec.commandPath)
|
||||
const pathConfigured = await this.isPathConfigured(pathDirectory)
|
||||
return this.withPathInfo(baseStatus, pathDirectory, pathConfigured)
|
||||
}
|
||||
|
||||
async install(): Promise<CliInstallStatus> {
|
||||
const status = await this.getStatus()
|
||||
if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) {
|
||||
throw new Error(status.detail ?? 'CLI registration is unavailable on this build.')
|
||||
}
|
||||
if (status.state === 'conflict') {
|
||||
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`)
|
||||
}
|
||||
|
||||
await mkdir(dirname(status.commandPath), { recursive: true })
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary.
|
||||
if (status.installMethod === 'symlink') {
|
||||
await this.installSymlink(status)
|
||||
} else {
|
||||
await this.installWindowsWrapper(status.commandPath, status.launcherPath)
|
||||
}
|
||||
|
||||
if (this.platform === 'win32') {
|
||||
// Why: Windows shells discover commands via the user PATH, not by walking
|
||||
// arbitrary app install directories. The CLI installer therefore owns the
|
||||
// user-scoped PATH entry instead of assuming the desktop installer did it.
|
||||
await this.ensureWindowsPathEntry(dirname(status.commandPath))
|
||||
}
|
||||
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async remove(): Promise<CliInstallStatus> {
|
||||
const status = await this.getStatus()
|
||||
if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) {
|
||||
return status
|
||||
}
|
||||
if (status.state === 'not_installed') {
|
||||
if (this.platform === 'win32') {
|
||||
await this.removeWindowsPathEntry(dirname(status.commandPath))
|
||||
return this.getStatus()
|
||||
}
|
||||
return status
|
||||
}
|
||||
if (status.state === 'conflict') {
|
||||
throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`)
|
||||
}
|
||||
if (status.state === 'stale') {
|
||||
throw new Error(`Refusing to remove a command not owned by Orca at ${status.commandPath}.`)
|
||||
}
|
||||
|
||||
if (status.installMethod === 'symlink') {
|
||||
await this.removeSymlink(status.commandPath)
|
||||
} else {
|
||||
await unlink(status.commandPath)
|
||||
await this.removeWindowsPathEntry(dirname(status.commandPath))
|
||||
}
|
||||
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
private resolveInstallSpec(): InstallSpec | null {
|
||||
const commandPath = this.resolveCommandPath()
|
||||
if (!commandPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.platform === 'darwin' || this.platform === 'linux') {
|
||||
return {
|
||||
commandPath,
|
||||
installMethod: 'symlink'
|
||||
}
|
||||
}
|
||||
|
||||
if (this.platform === 'win32') {
|
||||
return {
|
||||
commandPath,
|
||||
installMethod: 'wrapper'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveCommandPath(): string | null {
|
||||
if (this.commandPathOverride) {
|
||||
return this.commandPathOverride
|
||||
}
|
||||
|
||||
if (this.platform === 'darwin') {
|
||||
return DEFAULT_MAC_COMMAND_PATH
|
||||
}
|
||||
|
||||
if (this.platform === 'linux') {
|
||||
// Why: Linux does not have a single privileged global shell-command flow
|
||||
// equivalent to macOS's /usr/local/bin integration. ~/.local/bin is the
|
||||
// least surprising user-scoped location that many distros already expose.
|
||||
return join(this.homePath, '.local', 'bin', 'orca')
|
||||
}
|
||||
|
||||
if (this.platform === 'win32') {
|
||||
return join(this.localAppDataPath, 'Programs', 'Orca', 'bin', 'orca.cmd')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async resolveLauncherPath(): Promise<string | null> {
|
||||
if (!['darwin', 'linux', 'win32'].includes(this.platform)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.isPackaged) {
|
||||
const bundledPath = getBundledLauncherPath(this.platform, this.resourcesPath)
|
||||
return bundledPath && existsSync(bundledPath) ? bundledPath : null
|
||||
}
|
||||
|
||||
return ensureDevLauncher({
|
||||
platform: this.platform,
|
||||
userDataPath: this.userDataPath,
|
||||
execPath: this.execPathValue,
|
||||
cliEntryPath: join(this.appPathValue, 'out', 'cli', 'index.js')
|
||||
})
|
||||
}
|
||||
|
||||
private async installSymlink(status: CliInstallStatus): Promise<void> {
|
||||
try {
|
||||
if (status.state === 'installed') {
|
||||
return
|
||||
}
|
||||
if (status.state === 'stale') {
|
||||
await unlink(status.commandPath as string)
|
||||
}
|
||||
await symlink(status.launcherPath as string, status.commandPath as string)
|
||||
} catch (error) {
|
||||
if (this.platform !== 'darwin' || !isPermissionError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Why: macOS shell-command registration should behave like VS Code and
|
||||
// place a stable symlink in /usr/local/bin instead of rewriting shell rc
|
||||
// files. Fallback to an elevated shell command keeps the public command
|
||||
// stable even when the app lacks direct write access to that directory.
|
||||
await this.privilegedRunner(
|
||||
`mkdir -p ${quoteShell(dirname(status.commandPath as string))} && ` +
|
||||
`ln -sfn ${quoteShell(status.launcherPath as string)} ${quoteShell(status.commandPath as string)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async removeSymlink(commandPath: string): Promise<void> {
|
||||
try {
|
||||
await unlink(commandPath)
|
||||
} catch (error) {
|
||||
if (this.platform !== 'darwin' || !isPermissionError(error)) {
|
||||
throw error
|
||||
}
|
||||
await this.privilegedRunner(
|
||||
`if [ -L ${quoteShell(commandPath)} ]; then rm ${quoteShell(commandPath)}; fi`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async installWindowsWrapper(commandPath: string, launcherPath: string): Promise<void> {
|
||||
await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8')
|
||||
}
|
||||
|
||||
private async inspectSymlink(
|
||||
commandPath: string,
|
||||
launcherPath: string
|
||||
): Promise<CliInstallStatus> {
|
||||
try {
|
||||
const stats = await lstat(commandPath)
|
||||
if (!stats.isSymbolicLink()) {
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'symlink',
|
||||
supported: true,
|
||||
state: 'conflict',
|
||||
currentTarget: null,
|
||||
detail: `${commandPath} exists but is not an Orca symlink.`
|
||||
})
|
||||
}
|
||||
|
||||
const currentTarget = await readlink(commandPath)
|
||||
const resolvedCurrentTarget = resolve(dirname(commandPath), currentTarget)
|
||||
const resolvedLauncher = resolve(launcherPath)
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'symlink',
|
||||
supported: true,
|
||||
state: resolvedCurrentTarget === resolvedLauncher ? 'installed' : 'stale',
|
||||
currentTarget: resolvedCurrentTarget,
|
||||
detail:
|
||||
resolvedCurrentTarget === resolvedLauncher
|
||||
? `Registered at ${commandPath}.`
|
||||
: `${commandPath} points to a different launcher.`
|
||||
})
|
||||
} catch (error) {
|
||||
if (isMissingError(error)) {
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'symlink',
|
||||
supported: true,
|
||||
state: 'not_installed',
|
||||
currentTarget: null,
|
||||
detail: `Register ${commandPath} to use Orca from the terminal.`
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async inspectWindowsWrapper(
|
||||
commandPath: string,
|
||||
launcherPath: string
|
||||
): Promise<CliInstallStatus> {
|
||||
try {
|
||||
const stats = await lstat(commandPath)
|
||||
if (!stats.isFile()) {
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: 'conflict',
|
||||
currentTarget: null,
|
||||
detail: `${commandPath} exists but is not an Orca launcher script.`
|
||||
})
|
||||
}
|
||||
|
||||
const currentContent = await readFile(commandPath, 'utf8')
|
||||
const expectedContent = buildWindowsForwarder(launcherPath)
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: currentContent === expectedContent ? 'installed' : 'stale',
|
||||
currentTarget: launcherPath,
|
||||
detail:
|
||||
currentContent === expectedContent
|
||||
? `Registered at ${commandPath}.`
|
||||
: `${commandPath} points to a different launcher.`
|
||||
})
|
||||
} catch (error) {
|
||||
if (isMissingError(error)) {
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: 'not_installed',
|
||||
currentTarget: null,
|
||||
detail: `Register ${commandPath} to use Orca from Command Prompt or PowerShell.`
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private buildStatus(args: {
|
||||
commandPath: string
|
||||
launcherPath: string
|
||||
installMethod: CliInstallMethod
|
||||
supported: boolean
|
||||
state: CliInstallStatus['state']
|
||||
currentTarget: string | null
|
||||
detail: string | null
|
||||
}): CliInstallStatus {
|
||||
return {
|
||||
platform: this.platform,
|
||||
commandName: 'orca',
|
||||
commandPath: args.commandPath,
|
||||
pathDirectory: dirname(args.commandPath),
|
||||
pathConfigured: false,
|
||||
launcherPath: args.launcherPath,
|
||||
installMethod: args.installMethod,
|
||||
supported: args.supported,
|
||||
state: args.state,
|
||||
currentTarget: args.currentTarget,
|
||||
unsupportedReason: null,
|
||||
detail: args.detail
|
||||
}
|
||||
}
|
||||
|
||||
private async isPathConfigured(pathDirectory: string): Promise<boolean> {
|
||||
const pathValue =
|
||||
this.platform === 'win32' ? await this.userPathReader() : (this.processPathEnv ?? '')
|
||||
return splitPathEntries(this.platform, pathValue).some((entry) =>
|
||||
samePathEntry(this.platform, entry, pathDirectory)
|
||||
)
|
||||
}
|
||||
|
||||
private withPathInfo(
|
||||
status: CliInstallStatus,
|
||||
pathDirectory: string,
|
||||
pathConfigured: boolean
|
||||
): CliInstallStatus {
|
||||
if (status.state !== 'installed') {
|
||||
return {
|
||||
...status,
|
||||
pathDirectory,
|
||||
pathConfigured
|
||||
}
|
||||
}
|
||||
|
||||
if (pathConfigured) {
|
||||
return {
|
||||
...status,
|
||||
pathDirectory,
|
||||
pathConfigured
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...status,
|
||||
pathDirectory,
|
||||
pathConfigured,
|
||||
detail:
|
||||
this.platform === 'linux'
|
||||
? `${status.commandPath} is registered, but ${pathDirectory} is not on PATH for this shell.`
|
||||
: `${status.commandPath} is registered. Restart your shell if the command is not visible yet.`
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureWindowsPathEntry(pathDirectory: string): Promise<void> {
|
||||
const current = await this.userPathReader()
|
||||
const entries = splitPathEntries('win32', current)
|
||||
if (entries.some((entry) => samePathEntry('win32', entry, pathDirectory))) {
|
||||
return
|
||||
}
|
||||
entries.push(pathDirectory)
|
||||
await this.userPathWriter(entries.join(';'))
|
||||
}
|
||||
|
||||
private async removeWindowsPathEntry(pathDirectory: string): Promise<void> {
|
||||
if (this.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
const current = await this.userPathReader()
|
||||
const nextEntries = splitPathEntries('win32', current).filter(
|
||||
(entry) => !samePathEntry('win32', entry, pathDirectory)
|
||||
)
|
||||
await this.userPathWriter(nextEntries.join(';'))
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDevLauncher(args: {
|
||||
platform: NodeJS.Platform
|
||||
userDataPath: string
|
||||
execPath: string
|
||||
cliEntryPath: string
|
||||
}): Promise<string | null> {
|
||||
if (
|
||||
!isAbsoluteForPlatform(args.platform, args.execPath) ||
|
||||
!isAbsolute(args.cliEntryPath) ||
|
||||
!existsSync(args.cliEntryPath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const launcherPath = join(
|
||||
args.userDataPath,
|
||||
...DEV_LAUNCHER_DIR,
|
||||
args.platform === 'win32' ? 'orca.cmd' : 'orca'
|
||||
)
|
||||
await mkdir(dirname(launcherPath), { recursive: true })
|
||||
|
||||
// Why: packaged Orca ships real platform launchers under resources/bin, but
|
||||
// development builds do not have that stable asset layout. Generating a
|
||||
// launcher in userData lets us validate the shell-command flow without
|
||||
// changing the packaged registration contract.
|
||||
const content =
|
||||
args.platform === 'win32'
|
||||
? buildWindowsDevLauncher(args.execPath, args.cliEntryPath)
|
||||
: buildUnixDevLauncher(args.execPath, args.cliEntryPath)
|
||||
await writeFile(launcherPath, content, {
|
||||
encoding: 'utf8',
|
||||
mode: args.platform === 'win32' ? undefined : 0o755
|
||||
})
|
||||
return launcherPath
|
||||
}
|
||||
|
||||
function buildUnixDevLauncher(execPathValue: string, cliEntryPath: string): string {
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ELECTRON=${quoteShell(execPathValue)}
|
||||
CLI=${quoteShell(cliEntryPath)}
|
||||
export ORCA_NODE_OPTIONS="\${NODE_OPTIONS-}"
|
||||
export ORCA_NODE_REPL_EXTERNAL_MODULE="\${NODE_REPL_EXTERNAL_MODULE-}"
|
||||
unset NODE_OPTIONS
|
||||
unset NODE_REPL_EXTERNAL_MODULE
|
||||
ELECTRON_RUN_AS_NODE=1 "$ELECTRON" "$CLI" "$@"
|
||||
`
|
||||
}
|
||||
|
||||
function buildWindowsDevLauncher(execPathValue: string, cliEntryPath: string): string {
|
||||
return `@echo off
|
||||
setlocal
|
||||
set "ELECTRON=${escapeWindowsBatchValue(execPathValue)}"
|
||||
set "CLI=${escapeWindowsBatchValue(cliEntryPath)}"
|
||||
set "ORCA_NODE_OPTIONS=%NODE_OPTIONS%"
|
||||
set "ORCA_NODE_REPL_EXTERNAL_MODULE=%NODE_REPL_EXTERNAL_MODULE%"
|
||||
set NODE_OPTIONS=
|
||||
set NODE_REPL_EXTERNAL_MODULE=
|
||||
set ELECTRON_RUN_AS_NODE=1
|
||||
"%ELECTRON%" "%CLI%" %*
|
||||
`
|
||||
}
|
||||
|
||||
function buildWindowsForwarder(launcherPath: string): string {
|
||||
return `@echo off
|
||||
setlocal
|
||||
set "ORCA_LAUNCHER=${escapeWindowsBatchValue(launcherPath)}"
|
||||
"%ORCA_LAUNCHER%" %*
|
||||
`
|
||||
}
|
||||
|
||||
function splitPathEntries(platform: NodeJS.Platform, value: string | null): string[] {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
return value
|
||||
.split(platform === 'win32' ? ';' : ':')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function samePathEntry(platform: NodeJS.Platform, left: string, right: string): boolean {
|
||||
return platform === 'win32'
|
||||
? normalizeWindowsPath(left) === normalizeWindowsPath(right)
|
||||
: left === right
|
||||
}
|
||||
|
||||
function normalizeWindowsPath(value: string): string {
|
||||
return value.replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase()
|
||||
}
|
||||
|
||||
function escapeWindowsBatchValue(value: string): string {
|
||||
return value.replaceAll('"', '""')
|
||||
}
|
||||
|
||||
function isPermissionError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
((error as NodeJS.ErrnoException).code === 'EACCES' ||
|
||||
(error as NodeJS.ErrnoException).code === 'EPERM')
|
||||
)
|
||||
}
|
||||
|
||||
function isMissingError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
|
||||
function quoteShell(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`
|
||||
}
|
||||
|
||||
async function runMacPrivilegedCommand(command: string): Promise<void> {
|
||||
await execFileAsync('osascript', [
|
||||
'-e',
|
||||
`do shell script ${quoteAppleScript(command)} with administrator privileges`
|
||||
])
|
||||
}
|
||||
|
||||
function quoteAppleScript(value: string): string {
|
||||
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
|
||||
}
|
||||
|
||||
function isAbsoluteForPlatform(platform: NodeJS.Platform, value: string): boolean {
|
||||
if (platform === 'win32') {
|
||||
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')
|
||||
}
|
||||
return isAbsolute(value)
|
||||
}
|
||||
|
||||
async function readWindowsUserPath(): Promise<string | null> {
|
||||
const { stdout } = await execFileAsync('powershell', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
"[Environment]::GetEnvironmentVariable('Path','User')"
|
||||
])
|
||||
return stdout.trim() || null
|
||||
}
|
||||
|
||||
async function writeWindowsUserPath(value: string): Promise<void> {
|
||||
await execFileAsync('powershell', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
// Why: PATH registration must stay user-scoped on Windows so the Orca
|
||||
// desktop app can manage the public shell command without requiring
|
||||
// elevation or mutating machine-wide environment state.
|
||||
`[Environment]::SetEnvironmentVariable('Path', ${quotePowerShell(value)}, 'User')`
|
||||
])
|
||||
}
|
||||
|
||||
function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
export function getBundledLauncherPath(
|
||||
platform: NodeJS.Platform,
|
||||
resourcesPath: string
|
||||
): string | null {
|
||||
if (platform === 'darwin' || platform === 'linux') {
|
||||
return join(resourcesPath, 'bin', 'orca')
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return join(resourcesPath, 'bin', 'orca.cmd')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { CliInstaller } from '../cli/cli-installer'
|
||||
|
||||
export function registerCliHandlers(): void {
|
||||
ipcMain.handle('cli:getInstallStatus', async (): Promise<CliInstallStatus> => {
|
||||
return new CliInstaller().getStatus()
|
||||
})
|
||||
|
||||
ipcMain.handle('cli:install', async (): Promise<CliInstallStatus> => {
|
||||
return new CliInstaller().install()
|
||||
})
|
||||
|
||||
ipcMain.handle('cli:remove', async (): Promise<CliInstallStatus> => {
|
||||
return new CliInstaller().remove()
|
||||
})
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { type BrowserWindow, ipcMain } from 'electron'
|
||||
import * as pty from 'node-pty'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
||||
let ptyCounter = 0
|
||||
const ptyProcesses = new Map<string, pty.IPty>()
|
||||
|
|
@ -12,7 +13,7 @@ const ptyProcesses = new Map<string, pty.IPty>()
|
|||
let loadGeneration = 0
|
||||
const ptyLoadGeneration = new Map<string, number>()
|
||||
|
||||
export function registerPtyHandlers(mainWindow: BrowserWindow): void {
|
||||
export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRuntimeService): void {
|
||||
// Remove any previously registered handlers so we can re-register them
|
||||
// (e.g. when macOS re-activates the app and creates a new window).
|
||||
ipcMain.removeHandler('pty:spawn')
|
||||
|
|
@ -40,6 +41,32 @@ export function registerPtyHandlers(mainWindow: BrowserWindow): void {
|
|||
loadGeneration++
|
||||
})
|
||||
|
||||
runtime?.setPtyController({
|
||||
write: (ptyId, data) => {
|
||||
const proc = ptyProcesses.get(ptyId)
|
||||
if (!proc) {
|
||||
return false
|
||||
}
|
||||
proc.write(data)
|
||||
return true
|
||||
},
|
||||
kill: (ptyId) => {
|
||||
const proc = ptyProcesses.get(ptyId)
|
||||
if (!proc) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
ptyProcesses.delete(ptyId)
|
||||
ptyLoadGeneration.delete(ptyId)
|
||||
runtime?.onPtyExit(ptyId, -1)
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('pty:spawn', (_event, args: { cols: number; rows: number; cwd?: string }) => {
|
||||
const id = String(++ptyCounter)
|
||||
|
||||
|
|
@ -74,8 +101,10 @@ export function registerPtyHandlers(mainWindow: BrowserWindow): void {
|
|||
|
||||
ptyProcesses.set(id, ptyProcess)
|
||||
ptyLoadGeneration.set(id, loadGeneration)
|
||||
runtime?.onPtySpawned(id)
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
runtime?.onPtyData(id, data, Date.now())
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('pty:data', { id, data })
|
||||
}
|
||||
|
|
@ -84,6 +113,7 @@ export function registerPtyHandlers(mainWindow: BrowserWindow): void {
|
|||
ptyProcess.onExit(({ exitCode }) => {
|
||||
ptyProcesses.delete(id)
|
||||
ptyLoadGeneration.delete(id)
|
||||
runtime?.onPtyExit(id, exitCode)
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('pty:exit', { id, code: exitCode })
|
||||
}
|
||||
|
|
@ -116,6 +146,7 @@ export function registerPtyHandlers(mainWindow: BrowserWindow): void {
|
|||
}
|
||||
ptyProcesses.delete(args.id)
|
||||
ptyLoadGeneration.delete(args.id)
|
||||
runtime?.onPtyExit(args.id, -1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,491 @@
|
|||
/* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listWorktrees } from '../git/worktree'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const { MOCK_GIT_WORKTREES } = vi.hoisted(() => ({
|
||||
MOCK_GIT_WORKTREES: [
|
||||
{
|
||||
path: '/tmp/worktree-a',
|
||||
head: 'abc',
|
||||
branch: 'feature/foo',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES)
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
|
||||
})
|
||||
|
||||
const store = {
|
||||
getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id),
|
||||
getRepos: () => [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/tmp/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
}
|
||||
],
|
||||
addRepo: () => {},
|
||||
updateRepo: (id: string, updates: Record<string, unknown>) =>
|
||||
({
|
||||
...store.getRepo(id),
|
||||
...updates
|
||||
}) as never,
|
||||
getAllWorktreeMeta: () => ({
|
||||
'repo-1::/tmp/worktree-a': {
|
||||
displayName: 'foo',
|
||||
comment: '',
|
||||
linkedIssue: 123,
|
||||
linkedPR: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
}),
|
||||
getWorktreeMeta: (worktreeId: string) => store.getAllWorktreeMeta()[worktreeId],
|
||||
setWorktreeMeta: (_worktreeId: string, meta: Record<string, unknown>) =>
|
||||
({
|
||||
...store.getAllWorktreeMeta()['repo-1::/tmp/worktree-a'],
|
||||
...meta
|
||||
}) as never,
|
||||
removeWorktreeMeta: () => {},
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/tmp/workspaces',
|
||||
nestWorkspaces: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
})
|
||||
}
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('starts unavailable with no authoritative window', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
expect(runtime.getStatus()).toMatchObject({
|
||||
graphStatus: 'unavailable',
|
||||
authoritativeWindowId: null,
|
||||
rendererGraphEpoch: 0
|
||||
})
|
||||
expect(runtime.getRuntimeId()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('claims the first window as authoritative and ignores later windows', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.attachWindow(2)
|
||||
|
||||
expect(runtime.getStatus().authoritativeWindowId).toBe(1)
|
||||
})
|
||||
|
||||
it('bumps the epoch and enters reloading when the authoritative window reloads', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
runtime.markRendererReloading(1)
|
||||
|
||||
expect(runtime.getStatus()).toMatchObject({
|
||||
graphStatus: 'reloading',
|
||||
rendererGraphEpoch: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('can mark the graph ready for the authoritative window', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
runtime.markRendererReloading(1)
|
||||
runtime.markGraphReady(1)
|
||||
|
||||
expect(runtime.getStatus().graphStatus).toBe('ready')
|
||||
})
|
||||
|
||||
it('drops back to unavailable and clears authority when the window disappears', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
runtime.markRendererReloading(1)
|
||||
runtime.markGraphUnavailable(1)
|
||||
|
||||
expect(runtime.getStatus()).toMatchObject({
|
||||
graphStatus: 'unavailable',
|
||||
authoritativeWindowId: null,
|
||||
rendererGraphEpoch: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('stays unavailable during initial loads before a graph is published', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.markRendererReloading(1)
|
||||
|
||||
expect(runtime.getStatus()).toMatchObject({
|
||||
graphStatus: 'unavailable',
|
||||
rendererGraphEpoch: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('lists live terminals and issues stable handles for synced leaves', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData('pty-1', 'hello from terminal\n', 123)
|
||||
|
||||
const terminals = await runtime.listTerminals('branch:feature/foo')
|
||||
expect(terminals.terminals).toHaveLength(1)
|
||||
expect(terminals.terminals[0]).toMatchObject({
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
branch: 'feature/foo',
|
||||
title: 'Claude',
|
||||
preview: 'hello from terminal'
|
||||
})
|
||||
|
||||
const shown = await runtime.showTerminal(terminals.terminals[0].handle)
|
||||
expect(shown.handle).toBe(terminals.terminals[0].handle)
|
||||
expect(shown.ptyId).toBe('pty-1')
|
||||
})
|
||||
|
||||
it('reads bounded terminal output and writes through the PTY controller', async () => {
|
||||
const writes: string[] = []
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
return true
|
||||
},
|
||||
kill: () => true
|
||||
})
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData('pty-1', '\u001b[32mhello\u001b[0m\nworld\n', 123)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
expect(read).toMatchObject({
|
||||
handle: terminal.handle,
|
||||
status: 'running',
|
||||
tail: ['hello', 'world'],
|
||||
truncated: false,
|
||||
nextCursor: null
|
||||
})
|
||||
|
||||
const send = await runtime.sendTerminal(terminal.handle, {
|
||||
text: 'continue',
|
||||
enter: true
|
||||
})
|
||||
expect(send).toMatchObject({
|
||||
handle: terminal.handle,
|
||||
accepted: true
|
||||
})
|
||||
expect(writes).toEqual(['continue\r'])
|
||||
})
|
||||
|
||||
it('waits for terminal exit and resolves with the exit status', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const waitPromise = runtime.waitForTerminal(terminal.handle, { timeoutMs: 1000 })
|
||||
runtime.onPtyExit('pty-1', 7)
|
||||
|
||||
await expect(waitPromise).resolves.toMatchObject({
|
||||
handle: terminal.handle,
|
||||
condition: 'exit',
|
||||
satisfied: true,
|
||||
status: 'exited',
|
||||
exitCode: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('fails terminal waits closed when the handle goes stale during reload', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const waitPromise = runtime.waitForTerminal(terminal.handle, { timeoutMs: 1000 })
|
||||
runtime.markRendererReloading(1)
|
||||
|
||||
await expect(waitPromise).rejects.toThrow('terminal_handle_stale')
|
||||
})
|
||||
|
||||
it('builds a compact worktree summary from persisted and live runtime state', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData('pty-1', 'build green\n', 321)
|
||||
|
||||
const summaries = await runtime.getWorktreePs()
|
||||
expect(summaries).toEqual({
|
||||
worktrees: [
|
||||
{
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
repoId: 'repo-1',
|
||||
repo: 'repo',
|
||||
path: '/tmp/worktree-a',
|
||||
branch: 'feature/foo',
|
||||
linkedIssue: 123,
|
||||
unread: false,
|
||||
liveTerminalCount: 1,
|
||||
hasAttachedPty: true,
|
||||
lastOutputAt: 321,
|
||||
preview: 'build green'
|
||||
}
|
||||
],
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
})
|
||||
})
|
||||
|
||||
it('fails terminal stop closed while the renderer graph is reloading', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
let killed = false
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => {
|
||||
killed = true
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.markRendererReloading(1)
|
||||
|
||||
await expect(runtime.stopTerminalsForWorktree('id:repo-1::/tmp/worktree-a')).rejects.toThrow(
|
||||
'runtime_unavailable'
|
||||
)
|
||||
expect(killed).toBe(false)
|
||||
})
|
||||
|
||||
it('fails terminal listing closed if the graph reloads during selector resolution', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
let releaseListWorktrees = () => {}
|
||||
vi.mocked(listWorktrees).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseListWorktrees = () => resolve(MOCK_GIT_WORKTREES)
|
||||
})
|
||||
)
|
||||
|
||||
const listPromise = runtime.listTerminals('branch:feature/foo')
|
||||
runtime.markRendererReloading(1)
|
||||
releaseListWorktrees()
|
||||
|
||||
await expect(listPromise).rejects.toThrow('runtime_unavailable')
|
||||
})
|
||||
|
||||
it('fails terminal stop closed if the graph reloads during selector resolution', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
let killed = false
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => {
|
||||
killed = true
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
let releaseListWorktrees = () => {}
|
||||
vi.mocked(listWorktrees).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseListWorktrees = () => resolve(MOCK_GIT_WORKTREES)
|
||||
})
|
||||
)
|
||||
|
||||
const stopPromise = runtime.stopTerminalsForWorktree('branch:feature/foo')
|
||||
runtime.markRendererReloading(1)
|
||||
releaseListWorktrees()
|
||||
|
||||
await expect(stopPromise).rejects.toThrow('runtime_unavailable')
|
||||
expect(killed).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid positive limits for bounded list commands', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
await expect(runtime.getWorktreePs(-1)).rejects.toThrow('invalid_limit')
|
||||
await expect(runtime.listManagedWorktrees(undefined, 0)).rejects.toThrow('invalid_limit')
|
||||
await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
@ -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(/"$/, ''))
|
||||
}
|
||||
|
|
@ -0,0 +1,453 @@
|
|||
/* eslint-disable max-lines -- Why: this integration-style RPC test keeps the request/response contract together so regressions in the external CLI surface are easier to spot. */
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { createConnection } from 'net'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { readRuntimeMetadata } from './runtime-metadata'
|
||||
import { OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/worktree-a',
|
||||
head: 'abc',
|
||||
branch: 'feature/foo',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}))
|
||||
|
||||
async function sendRequest(
|
||||
endpoint: string,
|
||||
request: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = createConnection(endpoint)
|
||||
let buffer = ''
|
||||
socket.setEncoding('utf8')
|
||||
socket.once('error', reject)
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk
|
||||
const newlineIndex = buffer.indexOf('\n')
|
||||
if (newlineIndex === -1) {
|
||||
return
|
||||
}
|
||||
const message = buffer.slice(0, newlineIndex)
|
||||
socket.end()
|
||||
resolve(JSON.parse(message) as Record<string, unknown>)
|
||||
})
|
||||
socket.on('connect', () => {
|
||||
socket.write(`${JSON.stringify(request)}\n`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('OrcaRuntimeRpcServer', () => {
|
||||
const makeStore = (overrides?: { isUnread?: boolean }) => ({
|
||||
getRepo: (id: string) =>
|
||||
makeStore(overrides)
|
||||
.getRepos()
|
||||
.find((repo) => repo.id === id),
|
||||
getRepos: () => [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/tmp/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
}
|
||||
],
|
||||
addRepo: () => {},
|
||||
updateRepo: (id: string, updates: Record<string, unknown>) =>
|
||||
({
|
||||
...makeStore(overrides).getRepo(id),
|
||||
...updates
|
||||
}) as never,
|
||||
getAllWorktreeMeta: () => ({
|
||||
'repo-1::/tmp/worktree-a': {
|
||||
displayName: 'foo',
|
||||
comment: '',
|
||||
linkedIssue: 123,
|
||||
linkedPR: null,
|
||||
isArchived: false,
|
||||
isUnread: overrides?.isUnread ?? false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
}),
|
||||
getWorktreeMeta: (worktreeId: string) =>
|
||||
worktreeId === 'repo-1::/tmp/worktree-a'
|
||||
? (makeStore(overrides).getAllWorktreeMeta()[worktreeId] as never)
|
||||
: undefined,
|
||||
setWorktreeMeta: (_worktreeId: string, meta: Record<string, unknown>) =>
|
||||
({
|
||||
...makeStore(overrides).getAllWorktreeMeta()['repo-1::/tmp/worktree-a'],
|
||||
...meta
|
||||
}) as never,
|
||||
removeWorktreeMeta: () => {},
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/tmp/workspaces',
|
||||
nestWorkspaces: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('writes runtime metadata with transport details when started', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
expect(metadata?.runtimeId).toBe(runtime.getRuntimeId())
|
||||
expect(metadata?.authToken).toBeTruthy()
|
||||
expect(metadata?.transport?.endpoint).toBeTruthy()
|
||||
|
||||
await server.stop()
|
||||
expect(readRuntimeMetadata(userDataPath)).toBeNull()
|
||||
})
|
||||
|
||||
it('serves status.get for authenticated callers', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await sendRequest(metadata!.transport!.endpoint, {
|
||||
id: 'req_1',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'status.get'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
id: 'req_1',
|
||||
ok: true,
|
||||
_meta: {
|
||||
runtimeId: runtime.getRuntimeId()
|
||||
}
|
||||
})
|
||||
expect((response.result as { graphStatus: string }).graphStatus).toBe('unavailable')
|
||||
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
it('rejects requests with the wrong auth token', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await sendRequest(metadata!.transport!.endpoint, {
|
||||
id: 'req_1',
|
||||
authToken: 'wrong',
|
||||
method: 'status.get'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
id: 'req_1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'unauthorized'
|
||||
}
|
||||
})
|
||||
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
it('rejects malformed requests before dispatch', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await sendRequest(metadata!.transport!.endpoint, {
|
||||
authToken: metadata!.authToken,
|
||||
method: 'status.get'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
id: 'unknown',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'bad_request'
|
||||
}
|
||||
})
|
||||
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
it('serves terminal.list and terminal.show for live runtime terminals', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
const writes: string[] = []
|
||||
runtime.setPtyController({
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
return true
|
||||
},
|
||||
kill: () => true
|
||||
})
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData('pty-1', 'hello\n', 123)
|
||||
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const listResponse = await sendRequest(metadata!.transport!.endpoint, {
|
||||
id: 'req_list',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.list',
|
||||
params: {
|
||||
worktree: 'id:repo-1::/tmp/worktree-a'
|
||||
}
|
||||
})
|
||||
expect(listResponse).toMatchObject({
|
||||
id: 'req_list',
|
||||
ok: true
|
||||
})
|
||||
|
||||
const handle = (
|
||||
(
|
||||
listResponse.result as {
|
||||
terminals: { handle: string }[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}
|
||||
).terminals[0] ?? { handle: '' }
|
||||
).handle
|
||||
expect(handle).toBeTruthy()
|
||||
|
||||
const showResponse = await sendRequest(metadata!.transport!.endpoint, {
|
||||
id: 'req_show',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.show',
|
||||
params: {
|
||||
terminal: handle
|
||||
}
|
||||
})
|
||||
expect(showResponse).toMatchObject({
|
||||
id: 'req_show',
|
||||
ok: true
|
||||
})
|
||||
|
||||
const readResponse = await sendRequest(metadata!.transport!.endpoint, {
|
||||
id: 'req_read',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.read',
|
||||
params: {
|
||||
terminal: handle
|
||||
}
|
||||
})
|
||||
expect(readResponse).toMatchObject({
|
||||
id: 'req_read',
|
||||
ok: true
|
||||
})
|
||||
|
||||
const sendResponse = await sendRequest(metadata!.transport!.endpoint, {
|
||||
id: 'req_send',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.send',
|
||||
params: {
|
||||
terminal: handle,
|
||||
text: 'continue',
|
||||
enter: true
|
||||
}
|
||||
})
|
||||
expect(sendResponse).toMatchObject({
|
||||
id: 'req_send',
|
||||
ok: true
|
||||
})
|
||||
expect(writes).toEqual(['continue\r'])
|
||||
|
||||
const waitPromise = sendRequest(metadata!.transport!.endpoint, {
|
||||
id: 'req_wait',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.wait',
|
||||
params: {
|
||||
terminal: handle,
|
||||
for: 'exit',
|
||||
timeoutMs: 1000
|
||||
}
|
||||
})
|
||||
runtime.onPtyExit('pty-1', 9)
|
||||
const waitResponse = await waitPromise
|
||||
expect(waitResponse).toMatchObject({
|
||||
id: 'req_wait',
|
||||
ok: true,
|
||||
result: {
|
||||
wait: {
|
||||
handle,
|
||||
condition: 'exit',
|
||||
satisfied: true,
|
||||
status: 'exited',
|
||||
exitCode: 9
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
it('serves worktree.ps from the runtime summary builder', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never)
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.onPtyData('pty-1', 'hello\n', 555)
|
||||
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await sendRequest(metadata!.transport!.endpoint, {
|
||||
id: 'req_ps',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'worktree.ps'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
id: 'req_ps',
|
||||
ok: true,
|
||||
result: {
|
||||
worktrees: [
|
||||
{
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
repoId: 'repo-1',
|
||||
repo: 'repo',
|
||||
path: '/tmp/worktree-a',
|
||||
branch: 'feature/foo',
|
||||
linkedIssue: 123,
|
||||
unread: true,
|
||||
liveTerminalCount: 1,
|
||||
hasAttachedPty: true,
|
||||
lastOutputAt: 555,
|
||||
preview: 'hello'
|
||||
}
|
||||
],
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
}
|
||||
})
|
||||
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
it('bounds worktree.list responses with limit metadata', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never)
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await sendRequest(metadata!.transport!.endpoint, {
|
||||
id: 'req_worktrees',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'worktree.list',
|
||||
params: {
|
||||
limit: 1
|
||||
}
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
id: 'req_worktrees',
|
||||
ok: true,
|
||||
result: {
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
}
|
||||
})
|
||||
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
it('rejects oversized RPC frames instead of buffering them indefinitely', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const socket = createConnection(metadata!.transport!.endpoint)
|
||||
let buffer = ''
|
||||
socket.setEncoding('utf8')
|
||||
socket.once('error', reject)
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk
|
||||
const newlineIndex = buffer.indexOf('\n')
|
||||
if (newlineIndex === -1) {
|
||||
return
|
||||
}
|
||||
socket.end()
|
||||
resolve(JSON.parse(buffer.slice(0, newlineIndex)) as Record<string, unknown>)
|
||||
})
|
||||
socket.on('connect', () => {
|
||||
socket.write(`${'x'.repeat(1024 * 1024 + 1)}\n`)
|
||||
})
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'request_too_large'
|
||||
}
|
||||
})
|
||||
|
||||
await server.stop()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,748 @@
|
|||
/* eslint-disable max-lines -- Why: the local RPC server is a single security boundary for the bundled CLI, so transport validation and method routing are intentionally reviewed together. */
|
||||
import { randomBytes } from 'crypto'
|
||||
import { createServer, type Server, type Socket } from 'net'
|
||||
import { chmodSync, existsSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import {
|
||||
clearRuntimeMetadata,
|
||||
type RuntimeMetadata,
|
||||
type RuntimeTransportMetadata,
|
||||
writeRuntimeMetadata
|
||||
} from './runtime-metadata'
|
||||
|
||||
type RuntimeRpcRequest = {
|
||||
id: string
|
||||
authToken: string
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
type RuntimeRpcResponse =
|
||||
| {
|
||||
id: string
|
||||
ok: true
|
||||
result: unknown
|
||||
_meta: {
|
||||
runtimeId: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
ok: false
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
_meta: {
|
||||
runtimeId: string
|
||||
}
|
||||
}
|
||||
|
||||
type OrcaRuntimeRpcServerOptions = {
|
||||
runtime: OrcaRuntimeService
|
||||
userDataPath: string
|
||||
pid?: number
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024
|
||||
const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000
|
||||
const MAX_RUNTIME_RPC_CONNECTIONS = 32
|
||||
|
||||
export class OrcaRuntimeRpcServer {
|
||||
private readonly runtime: OrcaRuntimeService
|
||||
private readonly userDataPath: string
|
||||
private readonly pid: number
|
||||
private readonly platform: NodeJS.Platform
|
||||
private readonly authToken = randomBytes(24).toString('hex')
|
||||
private server: Server | null = null
|
||||
private transport: RuntimeTransportMetadata | null = null
|
||||
|
||||
constructor({
|
||||
runtime,
|
||||
userDataPath,
|
||||
pid = process.pid,
|
||||
platform = process.platform
|
||||
}: OrcaRuntimeRpcServerOptions) {
|
||||
this.runtime = runtime
|
||||
this.userDataPath = userDataPath
|
||||
this.pid = pid
|
||||
this.platform = platform
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.server) {
|
||||
return
|
||||
}
|
||||
|
||||
const transport = createRuntimeTransportMetadata(
|
||||
this.userDataPath,
|
||||
this.pid,
|
||||
this.platform,
|
||||
this.runtime.getRuntimeId()
|
||||
)
|
||||
if (transport.kind === 'unix' && existsSync(transport.endpoint)) {
|
||||
rmSync(transport.endpoint, { force: true })
|
||||
}
|
||||
|
||||
const server = createServer((socket) => {
|
||||
this.handleConnection(socket)
|
||||
})
|
||||
server.maxConnections = MAX_RUNTIME_RPC_CONNECTIONS
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(transport.endpoint, () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
if (transport.kind === 'unix') {
|
||||
chmodSync(transport.endpoint, 0o600)
|
||||
}
|
||||
|
||||
this.server = server
|
||||
this.transport = transport
|
||||
this.writeMetadata()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const server = this.server
|
||||
const transport = this.transport
|
||||
this.server = null
|
||||
this.transport = null
|
||||
clearRuntimeMetadata(this.userDataPath)
|
||||
if (!server) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
if (transport?.kind === 'unix' && existsSync(transport.endpoint)) {
|
||||
rmSync(transport.endpoint, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnection(socket: Socket): void {
|
||||
let buffer = ''
|
||||
|
||||
socket.setEncoding('utf8')
|
||||
socket.setNoDelay(true)
|
||||
socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => {
|
||||
socket.destroy()
|
||||
})
|
||||
socket.on('error', () => {
|
||||
socket.destroy()
|
||||
})
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk
|
||||
// Why: the Orca runtime lives in Electron main, so it must reject
|
||||
// oversized local RPC frames instead of letting a local client grow an
|
||||
// unbounded buffer and stall the app.
|
||||
if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) {
|
||||
socket.write(
|
||||
`${JSON.stringify(this.errorResponse('unknown', 'request_too_large', 'RPC request exceeds the maximum size'))}\n`
|
||||
)
|
||||
socket.end()
|
||||
return
|
||||
}
|
||||
let newlineIndex = buffer.indexOf('\n')
|
||||
while (newlineIndex !== -1) {
|
||||
const rawMessage = buffer.slice(0, newlineIndex).trim()
|
||||
buffer = buffer.slice(newlineIndex + 1)
|
||||
if (rawMessage) {
|
||||
void this.handleMessage(rawMessage).then((response) => {
|
||||
socket.write(`${JSON.stringify(response)}\n`)
|
||||
})
|
||||
}
|
||||
newlineIndex = buffer.indexOf('\n')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async handleMessage(rawMessage: string): Promise<RuntimeRpcResponse> {
|
||||
let request: RuntimeRpcRequest
|
||||
try {
|
||||
request = JSON.parse(rawMessage) as RuntimeRpcRequest
|
||||
} catch {
|
||||
return this.errorResponse('unknown', 'bad_request', 'Invalid JSON request')
|
||||
}
|
||||
|
||||
if (typeof request.id !== 'string' || request.id.length === 0) {
|
||||
return this.errorResponse('unknown', 'bad_request', 'Missing request id')
|
||||
}
|
||||
if (typeof request.method !== 'string' || request.method.length === 0) {
|
||||
return this.errorResponse(request.id, 'bad_request', 'Missing RPC method')
|
||||
}
|
||||
if (typeof request.authToken !== 'string' || request.authToken.length === 0) {
|
||||
return this.errorResponse(request.id, 'unauthorized', 'Missing auth token')
|
||||
}
|
||||
|
||||
if (request.authToken !== this.authToken) {
|
||||
return this.errorResponse(request.id, 'unauthorized', 'Invalid auth token')
|
||||
}
|
||||
|
||||
if (request.method === 'status.get') {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: this.runtime.getStatus(),
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'terminal.list') {
|
||||
try {
|
||||
const params =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? (request.params as { worktree?: unknown; limit?: unknown })
|
||||
: null
|
||||
const worktreeSelector = params?.worktree ?? null
|
||||
|
||||
const result = await this.runtime.listTerminals(
|
||||
typeof worktreeSelector === 'string' ? worktreeSelector : undefined,
|
||||
typeof params?.limit === 'number' && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: undefined
|
||||
)
|
||||
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result,
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'terminal.show') {
|
||||
try {
|
||||
const terminalHandle =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? ((request.params as { terminal?: unknown }).terminal ?? null)
|
||||
: null
|
||||
|
||||
if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle')
|
||||
}
|
||||
|
||||
const result = await this.runtime.showTerminal(terminalHandle)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { terminal: result },
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'terminal.read') {
|
||||
try {
|
||||
const terminalHandle =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? ((request.params as { terminal?: unknown }).terminal ?? null)
|
||||
: null
|
||||
|
||||
if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle')
|
||||
}
|
||||
|
||||
const result = await this.runtime.readTerminal(terminalHandle)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { terminal: result },
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'terminal.send') {
|
||||
try {
|
||||
const params =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? (request.params as {
|
||||
terminal?: unknown
|
||||
text?: unknown
|
||||
enter?: unknown
|
||||
interrupt?: unknown
|
||||
})
|
||||
: null
|
||||
|
||||
const terminalHandle = params?.terminal ?? null
|
||||
if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle')
|
||||
}
|
||||
|
||||
const result = await this.runtime.sendTerminal(terminalHandle, {
|
||||
text: typeof params?.text === 'string' ? params.text : undefined,
|
||||
enter: params?.enter === true,
|
||||
interrupt: params?.interrupt === true
|
||||
})
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { send: result },
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'terminal.wait') {
|
||||
try {
|
||||
const params =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? (request.params as {
|
||||
terminal?: unknown
|
||||
for?: unknown
|
||||
timeoutMs?: unknown
|
||||
})
|
||||
: null
|
||||
|
||||
const terminalHandle = params?.terminal ?? null
|
||||
if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle')
|
||||
}
|
||||
|
||||
if (params?.for !== 'exit') {
|
||||
return this.errorResponse(
|
||||
request.id,
|
||||
'not_supported_in_v1',
|
||||
'Only terminal wait --for exit is supported in focused v1'
|
||||
)
|
||||
}
|
||||
|
||||
const timeoutMs =
|
||||
typeof params?.timeoutMs === 'number' && Number.isFinite(params.timeoutMs)
|
||||
? params.timeoutMs
|
||||
: undefined
|
||||
|
||||
const result = await this.runtime.waitForTerminal(terminalHandle, { timeoutMs })
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { wait: result },
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'worktree.ps') {
|
||||
try {
|
||||
const limit =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? ((request.params as { limit?: unknown }).limit ?? null)
|
||||
: null
|
||||
const result = await this.runtime.getWorktreePs(
|
||||
typeof limit === 'number' && Number.isFinite(limit) ? limit : undefined
|
||||
)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result,
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'repo.list') {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { repos: this.runtime.listRepos() },
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'repo.add') {
|
||||
try {
|
||||
const pathValue =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? ((request.params as { path?: unknown }).path ?? null)
|
||||
: null
|
||||
if (typeof pathValue !== 'string' || pathValue.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing repo path')
|
||||
}
|
||||
const result = await this.runtime.addRepo(pathValue)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { repo: result },
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'repo.show') {
|
||||
try {
|
||||
const selector =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? ((request.params as { repo?: unknown }).repo ?? null)
|
||||
: null
|
||||
if (typeof selector !== 'string' || selector.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing repo selector')
|
||||
}
|
||||
const result = await this.runtime.showRepo(selector)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { repo: result },
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'repo.setBaseRef') {
|
||||
try {
|
||||
const params =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? (request.params as { repo?: unknown; ref?: unknown })
|
||||
: null
|
||||
const selector = params?.repo
|
||||
const ref = params?.ref
|
||||
if (typeof selector !== 'string' || selector.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing repo selector')
|
||||
}
|
||||
if (typeof ref !== 'string' || ref.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing base ref')
|
||||
}
|
||||
const result = await this.runtime.setRepoBaseRef(selector, ref)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { repo: result },
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'repo.searchRefs') {
|
||||
try {
|
||||
const params =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? (request.params as { repo?: unknown; query?: unknown; limit?: unknown })
|
||||
: null
|
||||
const selector = params?.repo
|
||||
const query = params?.query
|
||||
if (typeof selector !== 'string' || selector.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing repo selector')
|
||||
}
|
||||
if (typeof query !== 'string') {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing query')
|
||||
}
|
||||
const result = await this.runtime.searchRepoRefs(
|
||||
selector,
|
||||
query,
|
||||
typeof params?.limit === 'number' ? params.limit : undefined
|
||||
)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result,
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'worktree.list') {
|
||||
try {
|
||||
const params =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? (request.params as { repo?: unknown; limit?: unknown })
|
||||
: null
|
||||
const repoSelector = params?.repo ?? null
|
||||
const result = await this.runtime.listManagedWorktrees(
|
||||
typeof repoSelector === 'string' ? repoSelector : undefined,
|
||||
typeof params?.limit === 'number' && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: undefined
|
||||
)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result,
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'worktree.show') {
|
||||
try {
|
||||
const selector =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? ((request.params as { worktree?: unknown }).worktree ?? null)
|
||||
: null
|
||||
if (typeof selector !== 'string' || selector.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector')
|
||||
}
|
||||
const result = await this.runtime.showManagedWorktree(selector)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { worktree: result },
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'worktree.create') {
|
||||
try {
|
||||
const params =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? (request.params as {
|
||||
repo?: unknown
|
||||
name?: unknown
|
||||
baseBranch?: unknown
|
||||
linkedIssue?: unknown
|
||||
comment?: unknown
|
||||
})
|
||||
: null
|
||||
const repoSelector = params?.repo
|
||||
const name = params?.name
|
||||
if (typeof repoSelector !== 'string' || repoSelector.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing repo selector')
|
||||
}
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree name')
|
||||
}
|
||||
const result = await this.runtime.createManagedWorktree({
|
||||
repoSelector,
|
||||
name,
|
||||
baseBranch: typeof params?.baseBranch === 'string' ? params.baseBranch : undefined,
|
||||
linkedIssue:
|
||||
typeof params?.linkedIssue === 'number'
|
||||
? params.linkedIssue
|
||||
: params?.linkedIssue === null
|
||||
? null
|
||||
: undefined,
|
||||
comment: typeof params?.comment === 'string' ? params.comment : undefined
|
||||
})
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { worktree: result },
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'worktree.set') {
|
||||
try {
|
||||
const params =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? (request.params as {
|
||||
worktree?: unknown
|
||||
displayName?: unknown
|
||||
linkedIssue?: unknown
|
||||
comment?: unknown
|
||||
})
|
||||
: null
|
||||
const selector = params?.worktree
|
||||
if (typeof selector !== 'string' || selector.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector')
|
||||
}
|
||||
const result = await this.runtime.updateManagedWorktreeMeta(selector, {
|
||||
displayName: typeof params?.displayName === 'string' ? params.displayName : undefined,
|
||||
linkedIssue:
|
||||
typeof params?.linkedIssue === 'number'
|
||||
? params.linkedIssue
|
||||
: params?.linkedIssue === null
|
||||
? null
|
||||
: undefined,
|
||||
comment: typeof params?.comment === 'string' ? params.comment : undefined
|
||||
})
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { worktree: result },
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'worktree.rm') {
|
||||
try {
|
||||
const params =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? (request.params as { worktree?: unknown; force?: unknown })
|
||||
: null
|
||||
const selector = params?.worktree
|
||||
if (typeof selector !== 'string' || selector.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector')
|
||||
}
|
||||
await this.runtime.removeManagedWorktree(selector, params?.force === true)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { removed: true },
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'terminal.stop') {
|
||||
try {
|
||||
const params =
|
||||
request.params && typeof request.params === 'object' && request.params !== null
|
||||
? (request.params as { worktree?: unknown })
|
||||
: null
|
||||
const selector = params?.worktree
|
||||
if (typeof selector !== 'string' || selector.length === 0) {
|
||||
return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector')
|
||||
}
|
||||
const result = await this.runtime.stopTerminalsForWorktree(selector)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: result,
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return this.runtimeErrorResponse(request.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
return this.errorResponse(request.id, 'method_not_found', `Unknown method: ${request.method}`)
|
||||
}
|
||||
|
||||
private errorResponse(id: string, code: string, message: string): RuntimeRpcResponse {
|
||||
return {
|
||||
id,
|
||||
ok: false,
|
||||
error: {
|
||||
code,
|
||||
message
|
||||
},
|
||||
_meta: {
|
||||
runtimeId: this.runtime.getRuntimeId()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private runtimeErrorResponse(id: string, error: unknown): RuntimeRpcResponse {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (
|
||||
message === 'runtime_unavailable' ||
|
||||
message === 'selector_not_found' ||
|
||||
message === 'selector_ambiguous' ||
|
||||
message === 'terminal_handle_stale' ||
|
||||
message === 'terminal_not_writable' ||
|
||||
message === 'repo_not_found' ||
|
||||
message === 'timeout' ||
|
||||
message === 'invalid_limit'
|
||||
) {
|
||||
return this.errorResponse(id, message, message)
|
||||
}
|
||||
if (message === 'invalid_terminal_send') {
|
||||
return this.errorResponse(id, 'invalid_argument', 'Missing terminal send payload')
|
||||
}
|
||||
return this.errorResponse(id, 'runtime_error', message)
|
||||
}
|
||||
|
||||
private writeMetadata(): void {
|
||||
const metadata: RuntimeMetadata = {
|
||||
runtimeId: this.runtime.getRuntimeId(),
|
||||
pid: this.pid,
|
||||
transport: this.transport,
|
||||
authToken: this.authToken,
|
||||
startedAt: this.runtime.getStartedAt()
|
||||
}
|
||||
writeRuntimeMetadata(this.userDataPath, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
export function createRuntimeTransportMetadata(
|
||||
userDataPath: string,
|
||||
pid: number,
|
||||
platform: NodeJS.Platform,
|
||||
runtimeId = 'runtime'
|
||||
): RuntimeTransportMetadata {
|
||||
const endpointSuffix = runtimeId.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 4) || 'rt'
|
||||
if (platform === 'win32') {
|
||||
return {
|
||||
kind: 'named-pipe',
|
||||
// Why: Windows named pipes do not get the same chmod hardening path as
|
||||
// Unix sockets, so include a per-runtime suffix to avoid exposing a
|
||||
// stable, guessable control endpoint name across launches.
|
||||
endpoint: `\\\\.\\pipe\\orca-${pid}-${endpointSuffix}`
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'unix',
|
||||
endpoint: join(userDataPath, `o-${pid}-${endpointSuffix}.sock`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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' }]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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[] }) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import type {
|
||||
Repo,
|
||||
Worktree,
|
||||
|
|
@ -19,6 +20,7 @@ import type {
|
|||
SearchOptions,
|
||||
SearchResult
|
||||
} from '../../shared/types'
|
||||
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types'
|
||||
|
||||
type ReposApi = {
|
||||
list: () => Promise<Repo[]>
|
||||
|
|
@ -76,6 +78,12 @@ type SettingsApi = {
|
|||
listFonts: () => Promise<string[]>
|
||||
}
|
||||
|
||||
type CliApi = {
|
||||
getInstallStatus: () => Promise<CliInstallStatus>
|
||||
install: () => Promise<CliInstallStatus>
|
||||
remove: () => Promise<CliInstallStatus>
|
||||
}
|
||||
|
||||
type ShellApi = {
|
||||
openPath: (path: string) => Promise<void>
|
||||
openUrl: (url: string) => Promise<void>
|
||||
|
|
@ -121,6 +129,9 @@ type UIApi = {
|
|||
get: () => Promise<PersistedUIState>
|
||||
set: (args: Partial<PersistedUIState>) => Promise<void>
|
||||
onOpenSettings: (callback: () => void) => () => void
|
||||
onActivateWorktree: (
|
||||
callback: (data: { repoId: string; worktreeId: string }) => void
|
||||
) => () => void
|
||||
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
|
||||
readClipboardText: () => Promise<string>
|
||||
writeClipboardText: (text: string) => Promise<void>
|
||||
|
|
@ -129,6 +140,11 @@ type UIApi = {
|
|||
setZoomLevel: (level: number) => void
|
||||
}
|
||||
|
||||
type RuntimeApi = {
|
||||
syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise<RuntimeStatus>
|
||||
getStatus: () => Promise<RuntimeStatus>
|
||||
}
|
||||
|
||||
type FsApi = {
|
||||
readDir: (args: { dirPath: string }) => Promise<DirEntry[]>
|
||||
readFile: (args: {
|
||||
|
|
@ -185,6 +201,7 @@ type Api = {
|
|||
pty: PtyApi
|
||||
gh: GhApi
|
||||
settings: SettingsApi
|
||||
cli: CliApi
|
||||
shell: ShellApi
|
||||
hooks: HooksApi
|
||||
cache: CacheApi
|
||||
|
|
@ -193,6 +210,7 @@ type Api = {
|
|||
fs: FsApi
|
||||
git: GitApi
|
||||
ui: UIApi
|
||||
runtime: RuntimeApi
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type { CliInstallStatus } from '../shared/cli-install-types'
|
||||
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File drag-and-drop: handled here in the preload because webUtils (which
|
||||
|
|
@ -168,6 +170,12 @@ const api = {
|
|||
listFonts: (): Promise<string[]> => ipcRenderer.invoke('settings:listFonts')
|
||||
},
|
||||
|
||||
cli: {
|
||||
getInstallStatus: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:getInstallStatus'),
|
||||
install: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:install'),
|
||||
remove: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:remove')
|
||||
},
|
||||
|
||||
shell: {
|
||||
openPath: (path: string): Promise<void> => ipcRenderer.invoke('shell:openPath', path),
|
||||
|
||||
|
|
@ -299,6 +307,16 @@ const api = {
|
|||
ipcRenderer.on('ui:openSettings', listener)
|
||||
return () => ipcRenderer.removeListener('ui:openSettings', listener)
|
||||
},
|
||||
onActivateWorktree: (
|
||||
callback: (data: { repoId: string; worktreeId: string }) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { repoId: string; worktreeId: string }
|
||||
) => callback(data)
|
||||
ipcRenderer.on('ui:activateWorktree', listener)
|
||||
return () => ipcRenderer.removeListener('ui:activateWorktree', listener)
|
||||
},
|
||||
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') =>
|
||||
callback(direction)
|
||||
|
|
@ -315,6 +333,12 @@ const api = {
|
|||
},
|
||||
getZoomLevel: (): number => webFrame.getZoomLevel(),
|
||||
setZoomLevel: (level: number): void => webFrame.setZoomLevel(level)
|
||||
},
|
||||
|
||||
runtime: {
|
||||
syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise<RuntimeStatus> =>
|
||||
ipcRenderer.invoke('runtime:syncWindowGraph', graph),
|
||||
getStatus: (): Promise<RuntimeStatus> => ipcRenderer.invoke('runtime:getStatus')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,285 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Copy, FolderOpen, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
import { Label } from '../ui/label'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
|
||||
type CliSectionProps = {
|
||||
currentPlatform: string
|
||||
}
|
||||
|
||||
const ORCA_SKILL_INSTALL_COMMAND = 'npx skills add orca-cli'
|
||||
|
||||
function getRevealLabel(platform: string): string {
|
||||
if (platform === 'darwin') {
|
||||
return 'Show in Finder'
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return 'Show in Explorer'
|
||||
}
|
||||
return 'Show in File Manager'
|
||||
}
|
||||
|
||||
function getInstallDescription(platform: string): string {
|
||||
if (platform === 'darwin') {
|
||||
return 'Register `orca` in /usr/local/bin.'
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
return 'Register `orca` in ~/.local/bin.'
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return 'Register `orca` in your user PATH.'
|
||||
}
|
||||
return 'CLI registration is not yet available on this platform.'
|
||||
}
|
||||
|
||||
export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Element {
|
||||
const [status, setStatus] = useState<CliInstallStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null)
|
||||
|
||||
const refreshStatus = async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setStatus(await window.api.cli.getInstallStatus())
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to load CLI status.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus()
|
||||
}, [])
|
||||
|
||||
const isEnabled = status?.state === 'installed'
|
||||
const isSupported = status?.supported ?? false
|
||||
const revealLabel = getRevealLabel(currentPlatform)
|
||||
const canRevealCommandPath =
|
||||
status?.commandPath != null && ['installed', 'stale', 'conflict'].includes(status.state)
|
||||
|
||||
const handleInstall = async (): Promise<void> => {
|
||||
setBusyAction('install')
|
||||
try {
|
||||
const next = await window.api.cli.install()
|
||||
setStatus(next)
|
||||
setDialogOpen(false)
|
||||
toast.success('Registered `orca` in PATH.')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to register `orca` in PATH.')
|
||||
} finally {
|
||||
setBusyAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (): Promise<void> => {
|
||||
setBusyAction('remove')
|
||||
try {
|
||||
const next = await window.api.cli.remove()
|
||||
setStatus(next)
|
||||
setDialogOpen(false)
|
||||
toast.success('Removed `orca` from PATH.')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to remove `orca` from PATH.')
|
||||
} finally {
|
||||
setBusyAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopySkillInstallCommand = async (): Promise<void> => {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(ORCA_SKILL_INSTALL_COMMAND)
|
||||
toast.success('Copied Orca skill install command.')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to copy install command.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold">Orca CLI</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use Orca from your terminal to open the app, manage worktrees, and interact with Orca
|
||||
terminals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-border/60 bg-card/50 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Shell command</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading
|
||||
? 'Checking CLI registration…'
|
||||
: (status?.detail ?? getInstallDescription(currentPlatform))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={loading || busyAction !== null}
|
||||
aria-label="Refresh CLI status"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Refresh
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={isEnabled}
|
||||
disabled={loading || !isSupported || busyAction !== null}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
|
||||
isEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
} ${loading || !isSupported || busyAction !== null ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
isEnabled ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status?.commandPath ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Command path:{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{status.commandPath}</code>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{status?.state === 'stale' && status.currentTarget ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Existing launcher target: <code>{status.currentTarget}</code>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{status?.state === 'installed' && !status.pathConfigured && status.pathDirectory ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{status.pathDirectory} is not currently visible on PATH for this shell.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!loading && !isSupported && status?.detail ? (
|
||||
<p className="text-xs text-muted-foreground">{status.detail}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{status?.commandPath ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void window.api.shell.openPath(status.commandPath as string)}
|
||||
disabled={loading || !canRevealCommandPath}
|
||||
className="gap-2"
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
{revealLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Agent skill</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Install the Orca skill so agents know to use the{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">orca</code> CLI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Install command</p>
|
||||
<div className="inline-flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
|
||||
<code className="overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
|
||||
{ORCA_SKILL_INSTALL_COMMAND}
|
||||
</code>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => void handleCopySkillInstallCommand()}
|
||||
aria-label="Copy skill install command"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Copy
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEnabled ? 'Remove `orca` from PATH?' : 'Register `orca` in PATH?'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEnabled
|
||||
? 'This removes the shell command symlink. Orca itself remains installed.'
|
||||
: `Orca will register ${status?.commandPath ?? '`orca`'} so the command works from your terminal.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{status?.commandPath ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Target path:{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{status.commandPath}</code>
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={busyAction !== null}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void (isEnabled ? handleRemove() : handleInstall())}
|
||||
disabled={busyAction !== null || !isSupported}
|
||||
>
|
||||
{busyAction === 'remove'
|
||||
? 'Removing…'
|
||||
: busyAction === 'install'
|
||||
? 'Registering…'
|
||||
: isEnabled
|
||||
? 'Remove'
|
||||
: 'Register'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { Label } from '../ui/label'
|
|||
import { Separator } from '../ui/separator'
|
||||
import { Download, FolderOpen, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { CliSection } from './CliSection'
|
||||
|
||||
type GeneralPaneProps = {
|
||||
settings: GlobalSettings
|
||||
|
|
@ -95,6 +96,10 @@ export function GeneralPane({
|
|||
|
||||
<Separator />
|
||||
|
||||
<CliSection currentPlatform={navigator.userAgent.includes('Mac') ? 'darwin' : 'other'} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold">Branch Naming</h2>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type PtyTransport = {
|
|||
meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number }
|
||||
) => boolean
|
||||
isConnected: () => boolean
|
||||
getPtyId: () => string | null
|
||||
destroy?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
|
|
@ -220,6 +221,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
|||
return connected
|
||||
},
|
||||
|
||||
getPtyId() {
|
||||
return ptyId
|
||||
},
|
||||
|
||||
destroy() {
|
||||
destroyed = true
|
||||
this.disconnect()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import { paneLeafId, serializePaneTree } from '@/components/terminal-pane/layout-serialization'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { RuntimeSyncWindowGraph } from '../../../shared/runtime-types'
|
||||
|
||||
type RegisteredTerminalTab = {
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
getManager: () => PaneManager | null
|
||||
getContainer: () => HTMLDivElement | null
|
||||
getPtyIdForPane: (paneId: number) => string | null
|
||||
}
|
||||
|
||||
const registeredTabs = new Map<string, RegisteredTerminalTab>()
|
||||
let syncScheduled = false
|
||||
let syncEnabled = false
|
||||
let getStoreState: (() => AppState) | null = null
|
||||
|
||||
export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null): void {
|
||||
getStoreState = getter
|
||||
}
|
||||
|
||||
export function registerRuntimeTerminalTab(tab: RegisteredTerminalTab): () => void {
|
||||
registeredTabs.set(tab.tabId, tab)
|
||||
scheduleRuntimeGraphSync()
|
||||
return () => {
|
||||
registeredTabs.delete(tab.tabId)
|
||||
scheduleRuntimeGraphSync()
|
||||
}
|
||||
}
|
||||
|
||||
export function setRuntimeGraphSyncEnabled(enabled: boolean): void {
|
||||
syncEnabled = enabled
|
||||
if (enabled) {
|
||||
scheduleRuntimeGraphSync()
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleRuntimeGraphSync(): void {
|
||||
if (!syncEnabled || syncScheduled) {
|
||||
return
|
||||
}
|
||||
syncScheduled = true
|
||||
queueMicrotask(() => {
|
||||
syncScheduled = false
|
||||
void syncRuntimeGraph()
|
||||
})
|
||||
}
|
||||
|
||||
async function syncRuntimeGraph(): Promise<void> {
|
||||
if (!syncEnabled || !getStoreState) {
|
||||
return
|
||||
}
|
||||
// Why: the runtime graph helper cannot import the Zustand store directly
|
||||
// because the terminal slice also imports this module to schedule syncs.
|
||||
// Injecting the getter from App keeps the runtime graph path out of the
|
||||
// store construction cycle and avoids test-time partial initialization.
|
||||
const state = getStoreState()
|
||||
const graph: RuntimeSyncWindowGraph = {
|
||||
tabs: [],
|
||||
leaves: []
|
||||
}
|
||||
|
||||
for (const [tabId, registeredTab] of registeredTabs) {
|
||||
const tab = Object.values(state.tabsByWorktree)
|
||||
.flat()
|
||||
.find((candidate) => candidate.id === tabId)
|
||||
if (!tab) {
|
||||
continue
|
||||
}
|
||||
|
||||
const manager = registeredTab.getManager()
|
||||
const container = registeredTab.getContainer()
|
||||
const activePaneId = manager?.getActivePane()?.id ?? null
|
||||
const root =
|
||||
container?.firstElementChild instanceof HTMLElement ? container.firstElementChild : null
|
||||
|
||||
graph.tabs.push({
|
||||
tabId,
|
||||
worktreeId: registeredTab.worktreeId,
|
||||
title: tab.customTitle ?? tab.title,
|
||||
activeLeafId: activePaneId === null ? null : paneLeafId(activePaneId),
|
||||
layout: serializePaneTree(root)
|
||||
})
|
||||
|
||||
for (const pane of manager?.getPanes() ?? []) {
|
||||
graph.leaves.push({
|
||||
tabId,
|
||||
worktreeId: registeredTab.worktreeId,
|
||||
leafId: paneLeafId(pane.id),
|
||||
paneRuntimeId: pane.id,
|
||||
ptyId: registeredTab.getPtyIdForPane(pane.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await window.api.runtime.syncWindowGraph(graph)
|
||||
} catch (error) {
|
||||
console.error('[runtime] Failed to sync renderer graph:', error)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
TerminalTab,
|
||||
WorkspaceSessionState
|
||||
} from '../../../../shared/types'
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
|
||||
|
||||
export type TerminalSlice = {
|
||||
|
|
@ -166,6 +167,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
if (!changed) {
|
||||
return s
|
||||
}
|
||||
scheduleRuntimeGraphSync()
|
||||
// Agent status is derived from terminal titles and affects sort scoring,
|
||||
// so a title change is a meaningful event that should allow re-sort —
|
||||
// but only for background worktrees. Title changes in the active
|
||||
|
|
@ -186,6 +188,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
for (const wId of Object.keys(next)) {
|
||||
next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, customTitle: title } : t))
|
||||
}
|
||||
scheduleRuntimeGraphSync()
|
||||
return { tabsByWorktree: next }
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue