feat(plugins): host API 1.7 — workspace ids, project/terminal list, terminal focus/close

Plugins that track workspaces and terminals across Orca had to read the private runtime
token or shell out to the CLI. Host API 1.7 gives them the same through consented methods:

- workspace.readContext (and workspace.changed) carry `workspaceId`, the id workspace.list
  gives; workspace.created/removed and agent.status.changed add `workspaceListId` beside
  their existing raw `workspaceId`, which keeps its meaning.
- workspace.list rows name their `projectId`; opt-in `includeStatus` joins each row's agent
  state, unread flag, live terminal count and last activity from worktree.ps.
- project.list (workspace:read), terminal.list (workspace:read), terminal.focus
  (workspace:write, desktop only, steers only this window) and terminal.close
  (terminal:send, reports whether the process is known to have stopped).
- Runtime refusals (unknown or ambiguous workspace, stale or exited terminal) reach plugins
  as fixed messages instead of runtime wording.
- Consent copy for workspace:read, workspace:write and terminal:send says what they now
  allow; terminal.focus and terminal.close are audited by terminal id.
- PLUGIN_SCHEMA_NEWEST_SINCE covers the editor typography and theme video floors it missed,
  and the provisional 1.7 floor, so development builds accept plugins requiring them.
This commit is contained in:
Neil
2026-11-12 02:56:40 -08:00
parent d1f4021716
commit 7b41b92062
42 changed files with 1365 additions and 299 deletions
+47 -11
View File
@@ -2,7 +2,7 @@
<!-- Generated by `pnpm generate:plugin-schemas` from src/shared/plugins. Do not edit. -->
Host API 1.6 (`"pluginApi": 1`). Workers call a method with `await orca.host.call(name, params)`; panels and custom editors call the same methods through `window.orca.host.call(name, params)`. Each method lists the surfaces allowed to call it; any other surface gets `not_permitted`. `orca.host.has(name)` answers synchronously whether this Orca offers a method on the current surface, and `orca.api` is `{ major, minor }`. See [Plugin authoring](./plugin-authoring.md) for the frame API.
Host API 1.7 (`"pluginApi": 1`). Workers call a method with `await orca.host.call(name, params)`; panels and custom editors call the same methods through `window.orca.host.call(name, params)`. Each method lists the surfaces allowed to call it; any other surface gets `not_permitted`. `orca.host.has(name)` answers synchronously whether this Orca offers a method on the current surface, and `orca.api` is `{ major, minor }`. See [Plugin authoring](./plugin-authoring.md) for the frame API.
Every call is checked against the capabilities the user approved, and params are validated before Orca runs anything: params are strict, so a field this Orca does not know is refused with `invalid_params` instead of being dropped. Mutations are recorded in the audit log as `plugin:<publisher>.<id>`.
@@ -12,7 +12,7 @@ Methods whose capability is `none` need no capability but still need the plugin
| Method | Capability | Surfaces | Mutation | Since | Summary |
| --- | --- | --- | --- | --- | --- |
| [`workspace.readContext`](#workspacereadcontext) | `workspace:read` | worker, panel, editor | no | 1.0 | Branch, name and terminal ids of the focused workspace, or null when none is focused. |
| [`workspace.readContext`](#workspacereadcontext) | `workspace:read` | worker, panel, editor | no | 1.0 | Id, branch, name and terminal ids of the focused workspace, or null when none is focused. |
| [`terminal.sendText`](#terminalsendtext) | `terminal:send` | worker | yes | 1.0 | Types text into one terminal named by id, optionally pressing Enter. |
| [`notifications.show`](#notificationsshow) | `notifications:show` | worker, panel, editor | yes | 1.0 | Shows a desktop notification. |
| [`storage.get`](#storageget) | `storage:own` | worker, panel, editor | no | 1.0 | Reads a JSON value from the plugin's private store; null when absent. |
@@ -33,7 +33,8 @@ Methods whose capability is `none` need no capability but still need the plugin
| [`statusBar.update`](#statusbarupdate) | none | worker | no | 1.2 | Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled. A plugin with `main` and status bar items has its worker started when it is enabled and kept running while it stays enabled, so a worker can poll and update its items without an event or a hook. |
| [`appearance.read`](#appearanceread) | `appearance:write` | worker | no | 1.4 | Reads the current desktop appearance revision for a conditional update. |
| [`appearance.apply`](#appearanceapply) | `appearance:write` | worker | yes | 1.4 | Publishes owned app/editor and terminal colors and selects them in the desktop. Returns publication, not renderer acknowledgement. |
| [`workspace.list`](#workspacelist) | `workspace:read` | worker | no | 1.3 | Lists every workspace, Git worktrees and folders, on every host Orca reaches. |
| [`workspace.list`](#workspacelist) | `workspace:read` | worker | no | 1.3 | Lists every workspace, Git worktrees and folders, on every host Orca reaches; `includeStatus` (host API 1.7) adds each one's agent status. |
| [`project.list`](#projectlist) | `workspace:read` | worker | no | 1.7 | Lists your projects (repositories and folders) on every host; `id` is what `workspace.list` rows name as `projectId`. |
| [`workspace.open`](#workspaceopen) | `workspace:write` | worker | yes | 1.3 | Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app. |
| [`workspace.rename`](#workspacerename) | `workspace:write` | worker | yes | 1.5 | Renames a workspace in Orca's sidebar, as renaming it by hand does; the name stays until it is renamed again. |
| [`terminal.create`](#terminalcreate) | `terminal:send` | worker | yes | 1.3 | Opens a terminal in a workspace, optionally running a command, in the background. |
@@ -41,14 +42,17 @@ Methods whose capability is `none` need no capability but still need the plugin
| [`agent.launch`](#agentlaunch) | `agent:launch` | worker | yes | 1.3 | Starts an agent's CLI in a new terminal of a workspace, in the background, and returns as soon as it starts; a `prompt` is sent once the agent is ready. |
| [`editor.open`](#editoropen) | `workspace:write` | worker | yes | 1.3 | Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace. |
| [`commands.run`](#commandsrun) | `workspace:write` | worker | yes | 1.3 | Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only. |
| [`terminal.list`](#terminallist) | `workspace:read` | worker | no | 1.7 | Lists a workspace's terminals, at most 50; `workspace.readContext` lists the focused one's. |
| [`terminal.focus`](#terminalfocus) | `workspace:write` | worker | yes | 1.7 | Shows a terminal's workspace and focuses its pane, as clicking it does; desktop app only. |
| [`terminal.close`](#terminalclose) | `terminal:send` | worker | yes | 1.7 | Closes a terminal, stopping what runs in it (local or SSH), as `orca terminal close` does; closing its last pane closes the tab. |
| [`editor.readTypography`](#editorreadtypography) | none | worker, panel, editor | no | 1.6 | The font family, size, weight, line height, ligatures, word wrap and minimap Orca's file editor uses now, editor zoom included. `editor.typography.changed` reports each change after. |
### `workspace.readContext`
Branch, name and terminal ids of the focused workspace, or null when none is focused.
Id, branch, name and terminal ids of the focused workspace, or null when none is focused.
```ts
(params?: {}) => { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null
(params?: {}) => { workspaceId?: string; branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null
```
### `terminal.sendText`
@@ -213,10 +217,18 @@ Publishes owned app/editor and terminal colors and selects them in the desktop.
### `workspace.list`
Lists every workspace, Git worktrees and folders, on every host Orca reaches.
Lists every workspace, Git worktrees and folders, on every host Orca reaches; `includeStatus` (host API 1.7) adds each one's agent status.
```ts
(params?: {}) => { workspaces: { id: string; kind: "worktree" | "folder"; name: string; project?: string | null; path: string; branch: string | null; hostId: string | null }[]; truncated: boolean }
(params?: { includeStatus?: boolean }) => { workspaces: { id: string; kind: "worktree" | "folder"; name: string; project?: string | null; projectId?: string | null; path: string; branch: string | null; hostId: string | null; status?: { state: "working" | "permission" | "done" | "active" | "inactive"; unread: boolean; terminalCount: number; lastActivityAt: number | null } }[]; truncated: boolean }
```
### `project.list`
Lists your projects (repositories and folders) on every host; `id` is what `workspace.list` rows name as `projectId`.
```ts
(params?: {}) => { projects: { id: string; name: string; kind: "git" | "folder"; hostId: string | null; path: string }[]; truncated: boolean }
```
### `workspace.open`
@@ -275,6 +287,30 @@ Runs one of Orca's built-in actions in its window, never a plugin's replacement
(params: { action: "worktree.history.back" | "worktree.history.forward" | "sidebar.left.toggle" | "sidebar.sleepingWorkspaces.toggle" | "floatingWorkspace.maximize" | "tab.rename" | "workspace.rename" | "workspace.openBoard" | "view.tasks" | "sidebar.right.toggle" | "sidebar.explorer.toggle" | "sidebar.search.toggle" | "sidebar.sourceControl.toggle" | "sidebar.checks.toggle" | "sidebar.ports.toggle" }) => { ran: true }
```
### `terminal.list`
Lists a workspace's terminals, at most 50; `workspace.readContext` lists the focused one's.
```ts
(params: { workspaceId: string }) => { terminals: { id: string; title?: string }[]; truncated: boolean }
```
### `terminal.focus`
Shows a terminal's workspace and focuses its pane, as clicking it does; desktop app only.
```ts
(params: { terminalId: string }) => { focused: true }
```
### `terminal.close`
Closes a terminal, stopping what runs in it (local or SSH), as `orca terminal close` does; closing its last pane closes the tab.
```ts
(params: { terminalId: string }) => { closed: true; processExited: boolean }
```
### `editor.readTypography`
The font family, size, weight, line height, ligatures, word wrap and minimap Orca's file editor uses now, editor zoom included. `editor.typography.changed` reports each change after.
@@ -289,10 +325,10 @@ A worker receives the events its manifest lists in `contributes.events` through
| Event | Payload | Summary |
| --- | --- | --- |
| `workspace.created` | `{ workspaceId: string; kind: "worktree" \| "folder"; path: string; branch: string }` | A worktree workspace was created. |
| `workspace.removed` | `{ workspaceId: string; kind: "worktree" \| "folder"; path: string }` | A worktree workspace was removed. |
| `agent.status.changed` | `{ workspaceId: string \| null; paneKey: string; terminalId?: string; title?: string; state: "working" \| "blocked" \| "waiting" \| "done"; receivedAt: number; mainAgent?: { state: "working" \| "blocked" \| "waiting" \| "done"; outcome?: "success" \| "failure" \| "cancellation" \| "superseded" \| "interruption" \| "unconfirmed"; stateStartedAt: number } }` | An agent in a terminal pane changed status; `workspaceId` is null when Orca cannot tie the pane to a workspace, and `terminalId` names its terminal when Orca lists it. |
| `workspace.changed` | `{ context: { branch: string; displayName: string; terminals: { id: string; title?: string }[] } \| null }` | The focused workspace changed, or its terminals were opened, closed or renamed; `context` is what `workspace.readContext` now answers. Each new subscriber first gets the current context. |
| `workspace.created` | `{ workspaceId: string; workspaceListId?: string; kind: "worktree" \| "folder"; path: string; branch: string }` | A worktree workspace was created; `workspaceListId` (host API 1.7) is the id `workspace.list` gives it. |
| `workspace.removed` | `{ workspaceId: string; workspaceListId?: string; kind: "worktree" \| "folder"; path: string }` | A worktree workspace was removed; `workspaceListId` (host API 1.7) is the id `workspace.list` gave it. |
| `agent.status.changed` | `{ workspaceId: string \| null; workspaceListId?: string; paneKey: string; terminalId?: string; title?: string; state: "working" \| "blocked" \| "waiting" \| "done"; receivedAt: number; mainAgent?: { state: "working" \| "blocked" \| "waiting" \| "done"; outcome?: "success" \| "failure" \| "cancellation" \| "superseded" \| "interruption" \| "unconfirmed"; stateStartedAt: number } }` | An agent in a terminal pane changed status; `workspaceId` is null when Orca cannot tie the pane to a workspace, `workspaceListId` (host API 1.7) is the id `workspace.list` gives that workspace, and `terminalId` names its terminal when Orca lists it. |
| `workspace.changed` | `{ context: { workspaceId?: string; branch: string; displayName: string; terminals: { id: string; title?: string }[] } \| null }` | The focused workspace changed, or its terminals were opened, closed or renamed; `context` is what `workspace.readContext` now answers, its `workspaceId` included. Each new subscriber first gets the current context. |
| `editor.typography.changed` | `{ fontFamily: string; cssFontFamily: string; fontSize: number; fontWeight: string; lineHeight: number; fontLigatures: boolean; wordWrap: "on" \| "off"; minimap: boolean }` | What Orca's file editor uses changed (a setting or the editor zoom); the payload is what `editor.readTypography` now answers. Needs no capability. |
## Hooks
File diff suppressed because one or more lines are too long
+10 -10
View File
@@ -79,8 +79,8 @@ declare module 'orca' {
export type HostError = Error & { code: string; retryable: boolean; retryAfterMs?: number }
// <generated: host methods, from plugin-host-api.ts>
export type HostMethods = {
/** Branch, name and terminal ids of the focused workspace, or null when none is focused. */
"workspace.readContext": { params: Record<string, never> | undefined; result: { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }
/** Id, branch, name and terminal ids of the focused workspace, or null when none is focused. */
"workspace.readContext": { params: Record<string, never> | undefined; result: { workspaceId?: string; branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }
/** Shows a desktop notification. */
"notifications.show": { params: { title: string; body?: string }; result: { delivered: boolean } }
/** Reads a JSON value from the plugin's private store; null when absent. */
@@ -118,14 +118,14 @@ declare module 'orca' {
}
/** Workspace and agent events `on(event)` delivers, with the read capability of their domain. */
export type PluginEvents = {
/** A worktree workspace was created. */
"workspace.created": { workspaceId: string; kind: "worktree" | "folder"; path: string; branch: string }
/** A worktree workspace was removed. */
"workspace.removed": { workspaceId: string; kind: "worktree" | "folder"; path: string }
/** An agent in a terminal pane changed status; `workspaceId` is null when Orca cannot tie the pane to a workspace, and `terminalId` names its terminal when Orca lists it. */
"agent.status.changed": { workspaceId: string | null; paneKey: string; terminalId?: string; title?: string; state: "working" | "blocked" | "waiting" | "done"; receivedAt: number; mainAgent?: { state: "working" | "blocked" | "waiting" | "done"; outcome?: "success" | "failure" | "cancellation" | "superseded" | "interruption" | "unconfirmed"; stateStartedAt: number } }
/** The focused workspace changed, or its terminals were opened, closed or renamed; `context` is what `workspace.readContext` now answers. Each new subscriber first gets the current context. */
"workspace.changed": { context: { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }
/** A worktree workspace was created; `workspaceListId` (host API 1.7) is the id `workspace.list` gives it. */
"workspace.created": { workspaceId: string; workspaceListId?: string; kind: "worktree" | "folder"; path: string; branch: string }
/** A worktree workspace was removed; `workspaceListId` (host API 1.7) is the id `workspace.list` gave it. */
"workspace.removed": { workspaceId: string; workspaceListId?: string; kind: "worktree" | "folder"; path: string }
/** An agent in a terminal pane changed status; `workspaceId` is null when Orca cannot tie the pane to a workspace, `workspaceListId` (host API 1.7) is the id `workspace.list` gives that workspace, and `terminalId` names its terminal when Orca lists it. */
"agent.status.changed": { workspaceId: string | null; workspaceListId?: string; paneKey: string; terminalId?: string; title?: string; state: "working" | "blocked" | "waiting" | "done"; receivedAt: number; mainAgent?: { state: "working" | "blocked" | "waiting" | "done"; outcome?: "success" | "failure" | "cancellation" | "superseded" | "interruption" | "unconfirmed"; stateStartedAt: number } }
/** The focused workspace changed, or its terminals were opened, closed or renamed; `context` is what `workspace.readContext` now answers, its `workspaceId` included. Each new subscriber first gets the current context. */
"workspace.changed": { context: { workspaceId?: string; branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }
/** What Orca's file editor uses changed (a setting or the editor zoom); the payload is what `editor.readTypography` now answers. Needs no capability. */
"editor.typography.changed": { fontFamily: string; cssFontFamily: string; fontSize: number; fontWeight: string; lineHeight: number; fontLigatures: boolean; wordWrap: "on" | "off"; minimap: boolean }
}
+37 -13
View File
@@ -1,5 +1,5 @@
// Generated by `pnpm generate:plugin-author-typings` from src/shared/plugins. Do not edit.
// Host API 1.6 types shared by frames (`window.orca`) and workers (`activate(orca)`).
// Host API 1.7 types shared by frames (`window.orca`) and workers (`activate(orca)`).
declare module 'orca/host' {
// Why from 'orca': authors declare `PluginRequests` and `PluginPanelEvents` there once, and
@@ -34,10 +34,10 @@ declare module 'orca/host' {
export type HostSurface = 'worker' | 'panel' | 'editor'
export type HostMethods = {
/** Branch, name and terminal ids of the focused workspace, or null when none is focused. Since host API 1.0. */
/** Id, branch, name and terminal ids of the focused workspace, or null when none is focused. Since host API 1.0. */
"workspace.readContext": {
params: Record<string, never> | undefined
result: { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null
result: { workspaceId?: string; branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null
surfaces: "worker" | "panel" | "editor"
}
/** Types text into one terminal named by id, optionally pressing Enter. Since host API 1.0. */
@@ -160,10 +160,16 @@ declare module 'orca/host' {
result: { revision: string; themeId: string; mode: "dark" | "light"; delivery: "published" }
surfaces: "worker"
}
/** Lists every workspace, Git worktrees and folders, on every host Orca reaches. Since host API 1.3. */
/** Lists every workspace, Git worktrees and folders, on every host Orca reaches; `includeStatus` (host API 1.7) adds each one's agent status. Since host API 1.3. */
"workspace.list": {
params: { includeStatus?: boolean } | undefined
result: { workspaces: { id: string; kind: "worktree" | "folder"; name: string; project?: string | null; projectId?: string | null; path: string; branch: string | null; hostId: string | null; status?: { state: "working" | "permission" | "done" | "active" | "inactive"; unread: boolean; terminalCount: number; lastActivityAt: number | null } }[]; truncated: boolean }
surfaces: "worker"
}
/** Lists your projects (repositories and folders) on every host; `id` is what `workspace.list` rows name as `projectId`. Since host API 1.7. */
"project.list": {
params: Record<string, never> | undefined
result: { workspaces: { id: string; kind: "worktree" | "folder"; name: string; project?: string | null; path: string; branch: string | null; hostId: string | null }[]; truncated: boolean }
result: { projects: { id: string; name: string; kind: "git" | "folder"; hostId: string | null; path: string }[]; truncated: boolean }
surfaces: "worker"
}
/** Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app. Since host API 1.3. */
@@ -208,6 +214,24 @@ declare module 'orca/host' {
result: { ran: true }
surfaces: "worker"
}
/** Lists a workspace's terminals, at most 50; `workspace.readContext` lists the focused one's. Since host API 1.7. */
"terminal.list": {
params: { workspaceId: string }
result: { terminals: { id: string; title?: string }[]; truncated: boolean }
surfaces: "worker"
}
/** Shows a terminal's workspace and focuses its pane, as clicking it does; desktop app only. Since host API 1.7. */
"terminal.focus": {
params: { terminalId: string }
result: { focused: true }
surfaces: "worker"
}
/** Closes a terminal, stopping what runs in it (local or SSH), as `orca terminal close` does; closing its last pane closes the tab. Since host API 1.7. */
"terminal.close": {
params: { terminalId: string }
result: { closed: true; processExited: boolean }
surfaces: "worker"
}
/** The font family, size, weight, line height, ligatures, word wrap and minimap Orca's file editor uses now, editor zoom included. `editor.typography.changed` reports each change after. Since host API 1.6. */
"editor.readTypography": {
params: Record<string, never> | undefined
@@ -234,14 +258,14 @@ declare module 'orca/host' {
/** Worker `orca.events.on(event, handler)` payloads. */
export type PluginEvents = {
/** A worktree workspace was created. */
"workspace.created": { workspaceId: string; kind: "worktree" | "folder"; path: string; branch: string }
/** A worktree workspace was removed. */
"workspace.removed": { workspaceId: string; kind: "worktree" | "folder"; path: string }
/** An agent in a terminal pane changed status; `workspaceId` is null when Orca cannot tie the pane to a workspace, and `terminalId` names its terminal when Orca lists it. */
"agent.status.changed": { workspaceId: string | null; paneKey: string; terminalId?: string; title?: string; state: "working" | "blocked" | "waiting" | "done"; receivedAt: number; mainAgent?: { state: "working" | "blocked" | "waiting" | "done"; outcome?: "success" | "failure" | "cancellation" | "superseded" | "interruption" | "unconfirmed"; stateStartedAt: number } }
/** The focused workspace changed, or its terminals were opened, closed or renamed; `context` is what `workspace.readContext` now answers. Each new subscriber first gets the current context. */
"workspace.changed": { context: { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }
/** A worktree workspace was created; `workspaceListId` (host API 1.7) is the id `workspace.list` gives it. */
"workspace.created": { workspaceId: string; workspaceListId?: string; kind: "worktree" | "folder"; path: string; branch: string }
/** A worktree workspace was removed; `workspaceListId` (host API 1.7) is the id `workspace.list` gave it. */
"workspace.removed": { workspaceId: string; workspaceListId?: string; kind: "worktree" | "folder"; path: string }
/** An agent in a terminal pane changed status; `workspaceId` is null when Orca cannot tie the pane to a workspace, `workspaceListId` (host API 1.7) is the id `workspace.list` gives that workspace, and `terminalId` names its terminal when Orca lists it. */
"agent.status.changed": { workspaceId: string | null; workspaceListId?: string; paneKey: string; terminalId?: string; title?: string; state: "working" | "blocked" | "waiting" | "done"; receivedAt: number; mainAgent?: { state: "working" | "blocked" | "waiting" | "done"; outcome?: "success" | "failure" | "cancellation" | "superseded" | "interruption" | "unconfirmed"; stateStartedAt: number } }
/** The focused workspace changed, or its terminals were opened, closed or renamed; `context` is what `workspace.readContext` now answers, its `workspaceId` included. Each new subscriber first gets the current context. */
"workspace.changed": { context: { workspaceId?: string; branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }
/** What Orca's file editor uses changed (a setting or the editor zoom); the payload is what `editor.readTypography` now answers. Needs no capability. */
"editor.typography.changed": { fontFamily: string; cssFontFamily: string; fontSize: number; fontWeight: string; lineHeight: number; fontLigatures: boolean; wordWrap: "on" | "off"; minimap: boolean }
}
@@ -6,14 +6,14 @@
List each in `contributes.events` (`{ "on": "<event>" }`), with `main`, the `trusted-worker` profile and the capability shown; then `orca.events.on(event, handler)` in `activate(orca)` returns `{ dispose() }`. Payloads:
- `workspace.created` (capability `workspace:read`; since 1.4.245): A worktree workspace was created.
`{ workspaceId: string; kind: "worktree" | "folder"; path: string; branch: string }`
- `workspace.removed` (capability `workspace:read`; since 1.4.245): A worktree workspace was removed.
`{ workspaceId: string; kind: "worktree" | "folder"; path: string }`
- `agent.status.changed` (capability `agent:read`; since 1.4.160): An agent in a terminal pane changed status; `workspaceId` is null when Orca cannot tie the pane to a workspace, and `terminalId` names its terminal when Orca lists it.
`{ workspaceId: string | null; paneKey: string; terminalId?: string; title?: string; state: "working" | "blocked" | "waiting" | "done"; receivedAt: number; mainAgent?: { state: "working" | "blocked" | "waiting" | "done"; outcome?: "success" | "failure" | "cancellation" | "superseded" | "interruption" | "unconfirmed"; stateStartedAt: number } }`
- `workspace.changed` (capability `workspace:read`; since 1.4.246): The focused workspace changed, or its terminals were opened, closed or renamed; `context` is what `workspace.readContext` now answers. Each new subscriber first gets the current context.
`{ context: { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }`
- `workspace.created` (capability `workspace:read`; since 1.4.245): A worktree workspace was created; `workspaceListId` (host API 1.7) is the id `workspace.list` gives it.
`{ workspaceId: string; workspaceListId?: string; kind: "worktree" | "folder"; path: string; branch: string }`
- `workspace.removed` (capability `workspace:read`; since 1.4.245): A worktree workspace was removed; `workspaceListId` (host API 1.7) is the id `workspace.list` gave it.
`{ workspaceId: string; workspaceListId?: string; kind: "worktree" | "folder"; path: string }`
- `agent.status.changed` (capability `agent:read`; since 1.4.160): An agent in a terminal pane changed status; `workspaceId` is null when Orca cannot tie the pane to a workspace, `workspaceListId` (host API 1.7) is the id `workspace.list` gives that workspace, and `terminalId` names its terminal when Orca lists it.
`{ workspaceId: string | null; workspaceListId?: string; paneKey: string; terminalId?: string; title?: string; state: "working" | "blocked" | "waiting" | "done"; receivedAt: number; mainAgent?: { state: "working" | "blocked" | "waiting" | "done"; outcome?: "success" | "failure" | "cancellation" | "superseded" | "interruption" | "unconfirmed"; stateStartedAt: number } }`
- `workspace.changed` (capability `workspace:read`; since 1.4.246): The focused workspace changed, or its terminals were opened, closed or renamed; `context` is what `workspace.readContext` now answers, its `workspaceId` included. Each new subscriber first gets the current context.
`{ context: { workspaceId?: string; branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }`
- `editor.typography.changed` (no capability; since 1.4.256): What Orca's file editor uses changed (a setting or the editor zoom); the payload is what `editor.readTypography` now answers. Needs no capability.
`{ fontFamily: string; cssFontFamily: string; fontSize: number; fontWeight: string; lineHeight: number; fontLigatures: boolean; wordWrap: "on" | "off"; minimap: boolean }`
@@ -2,11 +2,11 @@
<!-- Generated by config/scripts/generate-plugin-authoring-skill-references.mjs. Do not edit. -->
Host API 1.6 (`"pluginApi": 1`). Call `host.call(method, params, { signal })`; a surface not listed for a method gets `not_permitted`. Params are strict: a field this Orca does not know is refused with `invalid_params`, so check `host.has(method)` or `api.minor` before using a method newer than your `engines.orca` floor. Each call also needs the manifest capability and the user's approval.
Host API 1.7 (`"pluginApi": 1`). Call `host.call(method, params, { signal })`; a surface not listed for a method gets `not_permitted`. Params are strict: a field this Orca does not know is refused with `invalid_params`, so check `host.has(method)` or `api.minor` before using a method newer than your `engines.orca` floor. Each call also needs the manifest capability and the user's approval.
## Methods per surface
- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`, `statusBar.update`, `appearance.read`, `appearance.apply`, `workspace.list`, `workspace.open`, `workspace.rename`, `terminal.create`, `terminal.read`, `agent.launch`, `editor.open`, `commands.run`, `editor.readTypography`
- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`, `statusBar.update`, `appearance.read`, `appearance.apply`, `workspace.list`, `project.list`, `workspace.open`, `workspace.rename`, `terminal.create`, `terminal.read`, `agent.launch`, `editor.open`, `commands.run`, `terminal.list`, `terminal.focus`, `terminal.close`, `editor.readTypography`
- Panel: global `orca` in an HTML panel; `host` imported from `orca` in a components panel: `workspace.readContext`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `shell.openExternal`, `files.saveAs`, `plugin.request`, `agents.list`, `events.subscribe`, `editor.readTypography`
- Custom editor: global `orca` in the editor frame: `workspace.readContext`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `shell.openExternal`, `files.saveAs`, `plugin.request`, `agents.list`, `events.subscribe`, `editor.readTypography`
@@ -69,8 +69,8 @@ HTML panels and custom editors get these CSS variables from Orca's active theme,
## Methods
- `workspace.readContext` (capability `workspace:read`; worker, panel, editor; since 1.0): Branch, name and terminal ids of the focused workspace, or null when none is focused.
`(params?: {}) => { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null`
- `workspace.readContext` (capability `workspace:read`; worker, panel, editor; since 1.0): Id, branch, name and terminal ids of the focused workspace, or null when none is focused.
`(params?: {}) => { workspaceId?: string; branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null`
- `terminal.sendText` (capability `terminal:send`; worker; since 1.0): Types text into one terminal named by id, optionally pressing Enter.
`(params: { terminalId: string; text: string; enter?: boolean }) => { accepted: boolean }`
- `notifications.show` (capability `notifications:show`; worker, panel, editor; since 1.0): Shows a desktop notification.
@@ -111,8 +111,10 @@ HTML panels and custom editors get these CSS variables from Orca's active theme,
`(params?: {}) => { revision: string }`
- `appearance.apply` (capability `appearance:write`; worker; since 1.4): Publishes owned app/editor and terminal colors and selects them in the desktop. Returns publication, not renderer acknowledgement.
`(params: { expectedRevision: string; app: string; terminal: string }) => { revision: string; themeId: string; mode: "dark" | "light"; delivery: "published" }`
- `workspace.list` (capability `workspace:read`; worker; since 1.3): Lists every workspace, Git worktrees and folders, on every host Orca reaches.
`(params?: {}) => { workspaces: { id: string; kind: "worktree" | "folder"; name: string; project?: string | null; path: string; branch: string | null; hostId: string | null }[]; truncated: boolean }`
- `workspace.list` (capability `workspace:read`; worker; since 1.3): Lists every workspace, Git worktrees and folders, on every host Orca reaches; `includeStatus` (host API 1.7) adds each one's agent status.
`(params?: { includeStatus?: boolean }) => { workspaces: { id: string; kind: "worktree" | "folder"; name: string; project?: string | null; projectId?: string | null; path: string; branch: string | null; hostId: string | null; status?: { state: "working" | "permission" | "done" | "active" | "inactive"; unread: boolean; terminalCount: number; lastActivityAt: number | null } }[]; truncated: boolean }`
- `project.list` (capability `workspace:read`; worker; since 1.7): Lists your projects (repositories and folders) on every host; `id` is what `workspace.list` rows name as `projectId`.
`(params?: {}) => { projects: { id: string; name: string; kind: "git" | "folder"; hostId: string | null; path: string }[]; truncated: boolean }`
- `workspace.open` (capability `workspace:write`; worker; since 1.3): Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app.
`(params: { workspaceId: string }) => { opened: true }`
- `workspace.rename` (capability `workspace:write`; worker; since 1.5): Renames a workspace in Orca's sidebar, as renaming it by hand does; the name stays until it is renamed again.
@@ -127,6 +129,12 @@ HTML panels and custom editors get these CSS variables from Orca's active theme,
`(params: { workspaceId: string; path: string }) => { opened: true }`
- `commands.run` (capability `workspace:write`; worker; since 1.3): Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only.
`(params: { action: "worktree.history.back" | "worktree.history.forward" | "sidebar.left.toggle" | "sidebar.sleepingWorkspaces.toggle" | "floatingWorkspace.maximize" | "tab.rename" | "workspace.rename" | "workspace.openBoard" | "view.tasks" | "sidebar.right.toggle" | "sidebar.explorer.toggle" | "sidebar.search.toggle" | "sidebar.sourceControl.toggle" | "sidebar.checks.toggle" | "sidebar.ports.toggle" }) => { ran: true }`
- `terminal.list` (capability `workspace:read`; worker; since 1.7): Lists a workspace's terminals, at most 50; `workspace.readContext` lists the focused one's.
`(params: { workspaceId: string }) => { terminals: { id: string; title?: string }[]; truncated: boolean }`
- `terminal.focus` (capability `workspace:write`; worker; since 1.7): Shows a terminal's workspace and focuses its pane, as clicking it does; desktop app only.
`(params: { terminalId: string }) => { focused: true }`
- `terminal.close` (capability `terminal:send`; worker; since 1.7): Closes a terminal, stopping what runs in it (local or SSH), as `orca terminal close` does; closing its last pane closes the tab.
`(params: { terminalId: string }) => { closed: true; processExited: boolean }`
- `editor.readTypography` (no capability; worker, panel, editor; since 1.6): The font family, size, weight, line height, ligatures, word wrap and minimap Orca's file editor uses now, editor zoom included. `editor.typography.changed` reports each change after.
`(params?: {}) => { fontFamily: string; cssFontFamily: string; fontSize: number; fontWeight: string; lineHeight: number; fontLigatures: boolean; wordWrap: "on" | "off"; minimap: boolean }`
@@ -153,11 +161,11 @@ List each in `contributes.hooks` (`{ "event", "onError"? }`; `main` and `trusted
## Capabilities
- `workspace:read` (since 1.4.160): Read the name, branch, and terminal list of your focused workspace, and get notified when workspaces are created or removed
- `workspace:write` (since 1.4.248): Open and rename your workspaces, open their files in Orca, and run its built-in commands
- `workspace:read` (since 1.4.160): Read your projects and workspaces (names, branches, paths and agent status) and the terminals in them, and get notified when they change
- `workspace:write` (since 1.4.248): Open and rename your workspaces, focus their terminals, open their files in Orca, and run its built-in commands
- `agent:read` (since 1.4.245): Get notified when agent status changes
- `agent:launch` (since 1.4.248): Start agents in your workspaces, local and SSH
- `terminal:send` (since 1.4.160): Type and run commands in your terminals, local and SSH
- `terminal:send` (since 1.4.160): Open and close your terminals, and type and run commands in them, local and SSH
- `terminal:read` (since 1.4.248): Read the output of your terminals, local and SSH
- `notifications:show` (since 1.4.160): Show desktop notifications labeled with the plugin name
- `storage:own` (since 1.4.245): Store data in the plugin's own storage folder
+25 -17
View File
@@ -2977,14 +2977,14 @@ const ORCA_PLUGIN_AUTHORING_EVENTS_REFERENCE_MARKDOWN = [
"",
"List each in `contributes.events` (`{ \"on\": \"<event>\" }`), with `main`, the `trusted-worker` profile and the capability shown; then `orca.events.on(event, handler)` in `activate(orca)` returns `{ dispose() }`. Payloads:",
"",
"- `workspace.created` (capability `workspace:read`; since 1.4.245): A worktree workspace was created.",
" `{ workspaceId: string; kind: \"worktree\" | \"folder\"; path: string; branch: string }`",
"- `workspace.removed` (capability `workspace:read`; since 1.4.245): A worktree workspace was removed.",
" `{ workspaceId: string; kind: \"worktree\" | \"folder\"; path: string }`",
"- `agent.status.changed` (capability `agent:read`; since 1.4.160): An agent in a terminal pane changed status; `workspaceId` is null when Orca cannot tie the pane to a workspace, and `terminalId` names its terminal when Orca lists it.",
" `{ workspaceId: string | null; paneKey: string; terminalId?: string; title?: string; state: \"working\" | \"blocked\" | \"waiting\" | \"done\"; receivedAt: number; mainAgent?: { state: \"working\" | \"blocked\" | \"waiting\" | \"done\"; outcome?: \"success\" | \"failure\" | \"cancellation\" | \"superseded\" | \"interruption\" | \"unconfirmed\"; stateStartedAt: number } }`",
"- `workspace.changed` (capability `workspace:read`; since 1.4.246): The focused workspace changed, or its terminals were opened, closed or renamed; `context` is what `workspace.readContext` now answers. Each new subscriber first gets the current context.",
" `{ context: { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }`",
"- `workspace.created` (capability `workspace:read`; since 1.4.245): A worktree workspace was created; `workspaceListId` (host API 1.7) is the id `workspace.list` gives it.",
" `{ workspaceId: string; workspaceListId?: string; kind: \"worktree\" | \"folder\"; path: string; branch: string }`",
"- `workspace.removed` (capability `workspace:read`; since 1.4.245): A worktree workspace was removed; `workspaceListId` (host API 1.7) is the id `workspace.list` gave it.",
" `{ workspaceId: string; workspaceListId?: string; kind: \"worktree\" | \"folder\"; path: string }`",
"- `agent.status.changed` (capability `agent:read`; since 1.4.160): An agent in a terminal pane changed status; `workspaceId` is null when Orca cannot tie the pane to a workspace, `workspaceListId` (host API 1.7) is the id `workspace.list` gives that workspace, and `terminalId` names its terminal when Orca lists it.",
" `{ workspaceId: string | null; workspaceListId?: string; paneKey: string; terminalId?: string; title?: string; state: \"working\" | \"blocked\" | \"waiting\" | \"done\"; receivedAt: number; mainAgent?: { state: \"working\" | \"blocked\" | \"waiting\" | \"done\"; outcome?: \"success\" | \"failure\" | \"cancellation\" | \"superseded\" | \"interruption\" | \"unconfirmed\"; stateStartedAt: number } }`",
"- `workspace.changed` (capability `workspace:read`; since 1.4.246): The focused workspace changed, or its terminals were opened, closed or renamed; `context` is what `workspace.readContext` now answers, its `workspaceId` included. Each new subscriber first gets the current context.",
" `{ context: { workspaceId?: string; branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }`",
"- `editor.typography.changed` (no capability; since 1.4.256): What Orca's file editor uses changed (a setting or the editor zoom); the payload is what `editor.readTypography` now answers. Needs no capability.",
" `{ fontFamily: string; cssFontFamily: string; fontSize: number; fontWeight: string; lineHeight: number; fontLigatures: boolean; wordWrap: \"on\" | \"off\"; minimap: boolean }`",
"",
@@ -3007,11 +3007,11 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
"",
"<!-- Generated by config/scripts/generate-plugin-authoring-skill-references.mjs. Do not edit. -->",
"",
"Host API 1.6 (`\"pluginApi\": 1`). Call `host.call(method, params, { signal })`; a surface not listed for a method gets `not_permitted`. Params are strict: a field this Orca does not know is refused with `invalid_params`, so check `host.has(method)` or `api.minor` before using a method newer than your `engines.orca` floor. Each call also needs the manifest capability and the user's approval.",
"Host API 1.7 (`\"pluginApi\": 1`). Call `host.call(method, params, { signal })`; a surface not listed for a method gets `not_permitted`. Params are strict: a field this Orca does not know is refused with `invalid_params`, so check `host.has(method)` or `api.minor` before using a method newer than your `engines.orca` floor. Each call also needs the manifest capability and the user's approval.",
"",
"## Methods per surface",
"",
"- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`, `statusBar.update`, `appearance.read`, `appearance.apply`, `workspace.list`, `workspace.open`, `workspace.rename`, `terminal.create`, `terminal.read`, `agent.launch`, `editor.open`, `commands.run`, `editor.readTypography`",
"- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`, `statusBar.update`, `appearance.read`, `appearance.apply`, `workspace.list`, `project.list`, `workspace.open`, `workspace.rename`, `terminal.create`, `terminal.read`, `agent.launch`, `editor.open`, `commands.run`, `terminal.list`, `terminal.focus`, `terminal.close`, `editor.readTypography`",
"- Panel: global `orca` in an HTML panel; `host` imported from `orca` in a components panel: `workspace.readContext`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `shell.openExternal`, `files.saveAs`, `plugin.request`, `agents.list`, `events.subscribe`, `editor.readTypography`",
"- Custom editor: global `orca` in the editor frame: `workspace.readContext`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `shell.openExternal`, `files.saveAs`, `plugin.request`, `agents.list`, `events.subscribe`, `editor.readTypography`",
"",
@@ -3074,8 +3074,8 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
"",
"## Methods",
"",
"- `workspace.readContext` (capability `workspace:read`; worker, panel, editor; since 1.0): Branch, name and terminal ids of the focused workspace, or null when none is focused.",
" `(params?: {}) => { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null`",
"- `workspace.readContext` (capability `workspace:read`; worker, panel, editor; since 1.0): Id, branch, name and terminal ids of the focused workspace, or null when none is focused.",
" `(params?: {}) => { workspaceId?: string; branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null`",
"- `terminal.sendText` (capability `terminal:send`; worker; since 1.0): Types text into one terminal named by id, optionally pressing Enter.",
" `(params: { terminalId: string; text: string; enter?: boolean }) => { accepted: boolean }`",
"- `notifications.show` (capability `notifications:show`; worker, panel, editor; since 1.0): Shows a desktop notification.",
@@ -3116,8 +3116,10 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
" `(params?: {}) => { revision: string }`",
"- `appearance.apply` (capability `appearance:write`; worker; since 1.4): Publishes owned app/editor and terminal colors and selects them in the desktop. Returns publication, not renderer acknowledgement.",
" `(params: { expectedRevision: string; app: string; terminal: string }) => { revision: string; themeId: string; mode: \"dark\" | \"light\"; delivery: \"published\" }`",
"- `workspace.list` (capability `workspace:read`; worker; since 1.3): Lists every workspace, Git worktrees and folders, on every host Orca reaches.",
" `(params?: {}) => { workspaces: { id: string; kind: \"worktree\" | \"folder\"; name: string; project?: string | null; path: string; branch: string | null; hostId: string | null }[]; truncated: boolean }`",
"- `workspace.list` (capability `workspace:read`; worker; since 1.3): Lists every workspace, Git worktrees and folders, on every host Orca reaches; `includeStatus` (host API 1.7) adds each one's agent status.",
" `(params?: { includeStatus?: boolean }) => { workspaces: { id: string; kind: \"worktree\" | \"folder\"; name: string; project?: string | null; projectId?: string | null; path: string; branch: string | null; hostId: string | null; status?: { state: \"working\" | \"permission\" | \"done\" | \"active\" | \"inactive\"; unread: boolean; terminalCount: number; lastActivityAt: number | null } }[]; truncated: boolean }`",
"- `project.list` (capability `workspace:read`; worker; since 1.7): Lists your projects (repositories and folders) on every host; `id` is what `workspace.list` rows name as `projectId`.",
" `(params?: {}) => { projects: { id: string; name: string; kind: \"git\" | \"folder\"; hostId: string | null; path: string }[]; truncated: boolean }`",
"- `workspace.open` (capability `workspace:write`; worker; since 1.3): Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app.",
" `(params: { workspaceId: string }) => { opened: true }`",
"- `workspace.rename` (capability `workspace:write`; worker; since 1.5): Renames a workspace in Orca's sidebar, as renaming it by hand does; the name stays until it is renamed again.",
@@ -3132,6 +3134,12 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
" `(params: { workspaceId: string; path: string }) => { opened: true }`",
"- `commands.run` (capability `workspace:write`; worker; since 1.3): Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only.",
" `(params: { action: \"worktree.history.back\" | \"worktree.history.forward\" | \"sidebar.left.toggle\" | \"sidebar.sleepingWorkspaces.toggle\" | \"floatingWorkspace.maximize\" | \"tab.rename\" | \"workspace.rename\" | \"workspace.openBoard\" | \"view.tasks\" | \"sidebar.right.toggle\" | \"sidebar.explorer.toggle\" | \"sidebar.search.toggle\" | \"sidebar.sourceControl.toggle\" | \"sidebar.checks.toggle\" | \"sidebar.ports.toggle\" }) => { ran: true }`",
"- `terminal.list` (capability `workspace:read`; worker; since 1.7): Lists a workspace's terminals, at most 50; `workspace.readContext` lists the focused one's.",
" `(params: { workspaceId: string }) => { terminals: { id: string; title?: string }[]; truncated: boolean }`",
"- `terminal.focus` (capability `workspace:write`; worker; since 1.7): Shows a terminal's workspace and focuses its pane, as clicking it does; desktop app only.",
" `(params: { terminalId: string }) => { focused: true }`",
"- `terminal.close` (capability `terminal:send`; worker; since 1.7): Closes a terminal, stopping what runs in it (local or SSH), as `orca terminal close` does; closing its last pane closes the tab.",
" `(params: { terminalId: string }) => { closed: true; processExited: boolean }`",
"- `editor.readTypography` (no capability; worker, panel, editor; since 1.6): The font family, size, weight, line height, ligatures, word wrap and minimap Orca's file editor uses now, editor zoom included. `editor.typography.changed` reports each change after.",
" `(params?: {}) => { fontFamily: string; cssFontFamily: string; fontSize: number; fontWeight: string; lineHeight: number; fontLigatures: boolean; wordWrap: \"on\" | \"off\"; minimap: boolean }`",
"",
@@ -3158,11 +3166,11 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
"",
"## Capabilities",
"",
"- `workspace:read` (since 1.4.160): Read the name, branch, and terminal list of your focused workspace, and get notified when workspaces are created or removed",
"- `workspace:write` (since 1.4.248): Open and rename your workspaces, open their files in Orca, and run its built-in commands",
"- `workspace:read` (since 1.4.160): Read your projects and workspaces (names, branches, paths and agent status) and the terminals in them, and get notified when they change",
"- `workspace:write` (since 1.4.248): Open and rename your workspaces, focus their terminals, open their files in Orca, and run its built-in commands",
"- `agent:read` (since 1.4.245): Get notified when agent status changes",
"- `agent:launch` (since 1.4.248): Start agents in your workspaces, local and SSH",
"- `terminal:send` (since 1.4.160): Type and run commands in your terminals, local and SSH",
"- `terminal:send` (since 1.4.160): Open and close your terminals, and type and run commands in them, local and SSH",
"- `terminal:read` (since 1.4.248): Read the output of your terminals, local and SSH",
"- `notifications:show` (since 1.4.160): Show desktop notifications labeled with the plugin name",
"- `storage:own` (since 1.4.245): Store data in the plugin's own storage folder",
@@ -11,6 +11,7 @@ import { isLocalOnlyWorktreeMetaId } from '../../shared/worktree/local-only-work
import type { Repo } from '../../shared/repo-types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import { rememberRemovedWorktreeIdentity } from './removed-worktree-identities'
import {
hostWorktreeTarget,
type HostWorktreeTarget
@@ -118,7 +119,12 @@ export function removeWorktreeMetaRow(
worktreeId: string,
hostId: ExecutionHostId | undefined
): void {
const owner = hostId ?? store.getWorktreeMeta(worktreeId)?.hostId
const meta = store.getWorktreeMeta(worktreeId)
const owner = hostId ?? meta?.hostId
// A same-id row of another host is not the one removed; its identity must not be borrowed.
if (meta?.hostId === owner) {
rememberRemovedWorktreeIdentity(worktreeId, meta)
}
if (owner) {
store.removeWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: owner }))
} else {
@@ -0,0 +1,46 @@
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import { canonicalWorktreeIdentity } from '../../shared/worktree/identity'
/** The identity key a worktree's metadata gives it, as `mergeWorktree` derives `identity.key`. */
export function worktreeMetaIdentityKey(
worktreeId: string,
meta: Pick<WorktreeMeta, 'instanceId' | 'hostId'> | undefined
): string | undefined {
return meta?.instanceId && meta.hostId
? canonicalWorktreeIdentity({
worktreeId,
executionHostId: meta.hostId,
instanceId: meta.instanceId
})
: undefined
}
const REMEMBERED_REMOVALS = 64
const removedIdentityKeys = new Map<string, string>()
/**
* Keeps the identity of a metadata row being removed. Why: a removal's lifecycle event is emitted
* after its row is purged, yet listeners still need the identity the row gave the worktree.
*/
export function rememberRemovedWorktreeIdentity(
worktreeId: string,
meta: Pick<WorktreeMeta, 'instanceId' | 'hostId'> | undefined
): void {
const key = worktreeMetaIdentityKey(worktreeId, meta)
if (!key) {
return
}
removedIdentityKeys.delete(worktreeId)
removedIdentityKeys.set(worktreeId, key)
if (removedIdentityKeys.size > REMEMBERED_REMOVALS) {
const oldest = removedIdentityKeys.keys().next()
if (!oldest.done) {
removedIdentityKeys.delete(oldest.value)
}
}
}
/** The identity key of the most recently removed row with this id, if still remembered. */
export function removedWorktreeIdentityKey(worktreeId: string): string | undefined {
return removedIdentityKeys.get(worktreeId)
}
@@ -30,7 +30,12 @@ function setup(capabilities: PluginCapabilityKind[] = ['workspace:read']) {
return { bridge, unfocus: () => (active = null) }
}
const context = { branch: 'main', displayName: 'w', terminals: [{ id: 'term_a', title: 'zsh' }] }
const context = {
workspaceId: 'repo::/w',
branch: 'main',
displayName: 'w',
terminals: [{ id: 'term_a', title: 'zsh' }]
}
describe('workspace.changed replay on subscribe', () => {
it('gives a panel the current context as soon as it subscribes', async () => {
+12 -2
View File
@@ -1,19 +1,29 @@
import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server/server-types'
import type { PluginAgentStatusChangedPayload } from '../../shared/plugins/events/plugin-events'
import {
pluginEventWorkspaceListId,
type PluginWorktreeMetaSource
} from './plugin-event-workspace-id'
/** Exclude restored rows from live automation triggers; optional mainAgent preserves old-host compatibility. */
/** Exclude restored rows from live automation triggers; optional mainAgent preserves old-host compatibility.
* `store` names each worktree by the id `workspace.list` gives it. */
export function projectPluginAgentStatusChangedPayload(
enriched: Pick<
EnrichedAgentHookEventPayload,
'worktreeId' | 'paneKey' | 'receivedAt' | 'restoredUnconfirmed' | 'payload'
>
>,
store: PluginWorktreeMetaSource | null | undefined
): PluginAgentStatusChangedPayload | null {
if (enriched.restoredUnconfirmed) {
return null
}
const mainAgent = enriched.payload.mainAgent
const listId = enriched.worktreeId
? pluginEventWorkspaceListId(store, enriched.worktreeId)
: undefined
return {
workspaceId: enriched.worktreeId ?? null,
...(listId ? { workspaceListId: listId } : {}),
paneKey: enriched.paneKey,
state: enriched.payload.state,
receivedAt: enriched.receivedAt,
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { removeWorktreeMetaRow } from '../persistence/host-qualified-worktree-meta'
import { pluginEventWorkspaceListId } from './plugin-event-workspace-id'
import { projectPluginAgentStatusChangedPayload } from './plugin-agent-status-event'
function metaStore(rows: Record<string, WorktreeMeta>) {
return {
getWorktreeMeta: (worktreeId: string) => rows[worktreeId],
removeWorktreeMeta: ({ id }: { id: string }) => {
delete rows[id]
}
}
}
const LOCAL: ExecutionHostId = 'local'
const BASE_META: WorktreeMeta = {
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
const meta = (instanceId: string, hostId: ExecutionHostId = LOCAL): WorktreeMeta => ({
...BASE_META,
instanceId,
hostId
})
describe('workspaceListId on workspace and agent events', () => {
it('is the identity form for a live worktree, and the raw id without an identity', () => {
const store = metaStore({ 'r::/a': meta('i1'), 'r::/b': BASE_META })
expect(pluginEventWorkspaceListId(store, 'r::/a')).toBe('identity:wt2:local:i1')
expect(pluginEventWorkspaceListId(store, 'r::/b')).toBe('r::/b')
})
it('keeps a removed worktree identity after its row is purged', () => {
const store = metaStore({ 'r::/gone': meta('i2') })
removeWorktreeMetaRow(store, 'r::/gone', undefined)
expect(store.getWorktreeMeta('r::/gone')).toBeUndefined()
expect(pluginEventWorkspaceListId(store, 'r::/gone', { removed: true })).toBe(
'identity:wt2:local:i2'
)
})
it("never borrows a same-id row's identity from another host", () => {
const store = metaStore({ 'r::/twin': meta('i3', 'ssh:box') })
removeWorktreeMetaRow(store, 'r::/twin', LOCAL)
expect(pluginEventWorkspaceListId(store, 'r::/twin', { removed: true })).toBe('r::/twin')
})
it('rides on agent.status.changed only when the pane has a workspace', () => {
const enriched = {
paneKey: 'tab:leaf',
receivedAt: 1,
payload: { state: 'working' as const, prompt: '', agentType: 'claude' as const }
}
const store = metaStore({ 'r::/a': meta('i4') })
expect(
projectPluginAgentStatusChangedPayload({ ...enriched, worktreeId: 'r::/a' }, store)
).toMatchObject({ workspaceId: 'r::/a', workspaceListId: 'identity:wt2:local:i4' })
expect(
projectPluginAgentStatusChangedPayload({ ...enriched, worktreeId: undefined }, store)
).not.toHaveProperty('workspaceListId')
})
})
@@ -0,0 +1,26 @@
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import {
removedWorktreeIdentityKey,
worktreeMetaIdentityKey
} from '../persistence/removed-worktree-identities'
import { pluginListedWorkspaceId } from './workers/host-api/plugin-workspace-projection'
export type PluginWorktreeMetaSource = {
getWorktreeMeta(worktreeId: string): Pick<WorktreeMeta, 'instanceId' | 'hostId'> | undefined
}
/**
* The `workspaceListId` an event carries for a raw worktree id: the id `workspace.list` gives it.
* A removed worktree's row is already gone, so its identity comes from the removal; without one
* the raw id stands, as it does in `workspace.list`.
*/
export function pluginEventWorkspaceListId(
store: PluginWorktreeMetaSource | null | undefined,
worktreeId: string,
options: { removed?: boolean } = {}
): string | undefined {
const identityKey = options.removed
? removedWorktreeIdentityKey(worktreeId)
: worktreeMetaIdentityKey(worktreeId, store?.getWorktreeMeta(worktreeId))
return pluginListedWorkspaceId({ id: worktreeId, identityKey })
}
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { createPluginAgentStatusForwarder, pluginRuntimeDelegate } from './plugin-runtime-binding'
import {
createPluginAgentStatusForwarder,
pluginRuntimeDelegate,
readPluginWorkspaceChange
} from './plugin-runtime-binding'
import { PluginPanelBridge } from './panels/plugin-panel-bridge'
import type { ValidDiscoveredPlugin } from './plugin-discovery'
@@ -79,3 +83,20 @@ describe('agent.status.changed for plugins', () => {
expect(panels[1]).toHaveBeenCalledTimes(2)
})
})
describe('workspace.changed for plugins', () => {
it('carries the same workspace id workspace.readContext answers', async () => {
const source = {
...runtime([]),
resolveActiveWorktreeContext: async () => ({
worktreeId: 'repo::/w',
identityKey: 'wt2:local:i1',
path: '/w',
branch: 'refs/heads/main',
displayName: 'w'
})
}
const { change } = await readPluginWorkspaceChange(pluginRuntimeDelegate(source, () => []))
expect(change.context).toMatchObject({ workspaceId: 'identity:wt2:local:i1', branch: 'main' })
})
})
+1 -1
View File
@@ -9,7 +9,7 @@ import { installPluginResidentWorkerWarmer } from './workers/worker-admission/pl
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { pluginRuntimeRpc } from './plugin-runtime-rpc'
import type { DispatcherOptions } from '../runtime/rpc/dispatcher'
import type { PluginRuntimeRpc } from './workers/host-api/plugin-host-workspace-method-bindings'
import type { PluginRuntimeRpc } from './workers/host-api/plugin-host-runtime-call'
import {
pluginTerminalTitle,
projectPluginWorkspaceContext,
+1 -1
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { RpcDispatcher, type DispatcherOptions } from '../runtime/rpc/dispatcher'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { PluginRuntimeRpc } from './workers/host-api/plugin-host-workspace-method-bindings'
import type { PluginRuntimeRpc } from './workers/host-api/plugin-host-runtime-call'
/** An error the runtime answered, with its code (`selector_not_found`, ...). */
export class PluginRuntimeRpcError extends Error {
@@ -0,0 +1,48 @@
import { vi } from 'vitest'
import type { PluginCapabilityKind } from '../../../../../shared/plugins/capabilities/plugin-capabilities'
import { executePluginHostCall } from '../plugin-host-methods'
import type { PluginHostServices } from '../plugin-host-method-bindings'
import type { PluginAuditLog } from '../../../plugin-audit-log'
/** Host services with inert stores and the given runtime RPC; `overrides` replaces any of them. */
export function pluginHostTestServices(
runtimeRpc: PluginHostServices['runtimeRpc'],
overrides: Partial<PluginHostServices> = {}
): PluginHostServices {
return {
resolveActiveWorktreeContext: vi.fn().mockResolvedValue(null),
listWorktreeTerminals: vi.fn().mockResolvedValue([]),
sendTerminalText: vi.fn().mockResolvedValue({ accepted: true }),
listAgentStatuses: () => [],
dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }),
storage: { get: vi.fn(), set: vi.fn(), delete: vi.fn(), keys: vi.fn().mockReturnValue([]) },
secrets: {
get: vi.fn().mockReturnValue({ ok: true, value: null }),
set: vi.fn().mockReturnValue({ ok: true }),
delete: vi.fn(),
protection: vi.fn().mockReturnValue('unavailable')
},
settings: { getAll: vi.fn().mockReturnValue({}), set: vi.fn().mockReturnValue({ ok: true }) },
runtimeRpc,
...overrides
}
}
/** A worker's call through the one chokepoint every transport uses, with an audit sink. */
export function callPluginHost(input: {
method: string
params: unknown
granted: PluginCapabilityKind[]
services: PluginHostServices
audit?: Pick<PluginAuditLog, 'record'>
}) {
return executePluginHostCall({
pluginId: 'samples.demo',
method: input.method,
params: input.params,
surface: 'worker',
grantedCapabilities: input.granted,
services: input.services,
audit: input.audit ?? { record: vi.fn().mockResolvedValue(undefined) }
})
}
@@ -23,16 +23,17 @@ import {
PLUGIN_STATUS_BAR_HANDLERS,
type PluginStatusBarServices
} from './plugin-host-status-bar-method-bindings'
import {
PLUGIN_WORKSPACE_ACTION_HANDLERS,
type PluginRuntimeRpc
} from './plugin-host-workspace-method-bindings'
import { PLUGIN_WORKSPACE_ACTION_HANDLERS } from './plugin-host-workspace-method-bindings'
import { PLUGIN_TERMINAL_ACTION_HANDLERS } from './plugin-host-terminal-method-bindings'
import type { PluginRuntimeRpc } from './plugin-host-runtime-call'
import type { PluginAppearanceService } from '../../appearance/plugin-appearance-service'
import type { PluginEditorTypographySource } from '../../appearance/plugin-editor-typography'
import { PluginHostCallError } from './plugin-host-call-scope'
export type PluginWorktreeContext = {
worktreeId: string
/** Host-qualified identity key, which `workspace.list` ids carry; absent without one. */
identityKey?: string
branch: string
displayName: string
}
@@ -230,7 +231,8 @@ const HANDLERS: Record<PluginHostMethodName, BoundPluginHostMethod> = {
}),
...PLUGIN_PANEL_BRIDGE_HANDLERS,
...PLUGIN_STATUS_BAR_HANDLERS,
...PLUGIN_WORKSPACE_ACTION_HANDLERS
...PLUGIN_WORKSPACE_ACTION_HANDLERS,
...PLUGIN_TERMINAL_ACTION_HANDLERS
}
function requireDesktop(services: PluginHostServices): PluginDesktopFrameServices {
@@ -71,3 +71,37 @@ describe('workspace.readContext host reachability', () => {
})
})
})
describe('workspace.readContext workspace id', () => {
it('is the id workspace.list gives: identity form, folder key, else the raw id', async () => {
const contexts = [
{ worktreeId: 'repo::/w', identityKey: 'wt2:local:i1' },
{ worktreeId: 'folder:f1' },
// A remote workspace known only from stored rows has no identity.
{ worktreeId: 'remote-repo::/home/user/repo' }
]
const outcomes = await Promise.all(
contexts.map((context) => {
const services = createServices(vi.fn())
vi.mocked(services.resolveActiveWorktreeContext).mockResolvedValue({
...context,
branch: '',
displayName: 'w'
})
return executePluginHostCall({
pluginId: 'samples.demo',
method: 'workspace.readContext',
params: {},
surface: 'worker',
grantedCapabilities: ['workspace:read'],
services
})
})
)
expect(outcomes).toMatchObject([
{ ok: true, value: { workspaceId: 'identity:wt2:local:i1' } },
{ ok: true, value: { workspaceId: 'folder:f1' } },
{ ok: true, value: { workspaceId: 'remote-repo::/home/user/repo' } }
])
})
})
@@ -133,6 +133,9 @@ function summarizeParams(method: string, params: unknown): string {
const text = typeof record.text === 'string' ? record.text : ''
return `terminal=${String(record.terminalId)} bytes=${Buffer.byteLength(text, 'utf8')} enter=${record.enter === true}`
}
case 'terminal.focus':
case 'terminal.close':
return `terminal=${String(record.terminalId)}`
case 'notifications.show': {
const title = typeof record.title === 'string' ? record.title : ''
return `titleChars=${title.length}`
@@ -0,0 +1,84 @@
import type { PluginErrorCode } from '../../../../shared/plugins/frames/plugin-protocol'
import type { PluginHostMethodContext } from './plugin-host-method-bind'
import type { PluginHostServices } from './plugin-host-method-bindings'
import { PluginHostCallError } from './plugin-host-call-scope'
import type { PluginHostRendererActions } from '../../view/plugin-host-renderer-actions'
/** The runtime RPC the `orca` CLI calls, run in process; rejects with the runtime's error.
* `beforeEffect` runs right before the runtime acts, after its own waits; a throw refuses it. */
export type PluginRuntimeRpc = (
method: string,
params: unknown,
options?: { signal?: AbortSignal; beforeEffect?: () => void }
) => Promise<unknown>
const NO_LIVE_TERMINAL: [PluginErrorCode, string] = [
'action_failed',
'no live terminal has this id'
]
// Runtime refusals whose wording names Orca internals; a plugin gets a fixed message instead.
const RUNTIME_FAILURES: ReadonlyMap<string, [PluginErrorCode, string]> = new Map([
['selector_not_found', ['action_failed', 'no workspace has this id']],
[
'selector_ambiguous',
['action_failed', 'two hosts have a workspace with this id; use the id workspace.list gives']
],
['terminal_not_found', NO_LIVE_TERMINAL],
['terminal_handle_stale', NO_LIVE_TERMINAL],
['terminal_exited', NO_LIVE_TERMINAL],
['terminal_gone', NO_LIVE_TERMINAL],
['runtime_unavailable', ['unavailable', 'this Orca host cannot run workspace actions now']]
])
/** A runtime refusal as the plugin sees it; anything unrecognized passes through unchanged. */
export function pluginRuntimeFailure(error: unknown): unknown {
if (!(error instanceof Error) || error instanceof PluginHostCallError) {
return error
}
// The runtime answers a code (`selector_not_found`); some refusals arrive as a bare message.
const code = 'code' in error && typeof error.code === 'string' ? error.code : undefined
const known = (code && RUNTIME_FAILURES.get(code)) || RUNTIME_FAILURES.get(error.message)
return known ? new PluginHostCallError(known[0], known[1]) : error
}
export function pluginHostRenderer(services: PluginHostServices): PluginHostRendererActions {
if (!services.renderer) {
throw new PluginHostCallError('unavailable', 'only the Orca desktop app can do this')
}
return services.renderer
}
/** The runtime RPC, with its refusals mapped by `pluginRuntimeFailure`. */
export function pluginHostRpc(services: PluginHostServices): PluginRuntimeRpc {
const call = services.runtimeRpc
if (!call) {
throw new PluginHostCallError('unavailable', 'this Orca host cannot run workspace actions')
}
return (method, params, options) =>
call(method, params, options).catch((error: unknown) => {
throw pluginRuntimeFailure(error)
})
}
/** A runtime call that changes something: refused if the plugin lost approval, or its worker
* cancelled the call, at any point before the runtime acts. */
export function pluginHostEffect(
ctx: PluginHostMethodContext,
method: string,
params: unknown
): Promise<unknown> {
ctx.scope.assertActive()
return pluginHostRpc(ctx.services)(method, params, {
...(ctx.scope.signal ? { signal: ctx.scope.signal } : {}),
beforeEffect: () => ctx.scope.assertActive()
})
}
/**
* The selector a listed workspace id names. Ids carry their host (`identity:`, unique across
* twins on two hosts) or are folder keys; a bare id from elsewhere is taken as an exact id.
*/
export function pluginWorkspaceSelector(workspaceId: string): string {
return workspaceId.startsWith('identity:') ? workspaceId : `id:${workspaceId}`
}
@@ -2,7 +2,7 @@ import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../../../shared/plugins/work
import type { PluginHostServices, PluginDesktopFrameServices } from './plugin-host-method-bindings'
import type { PluginPanelBridgeServices } from './plugin-host-panel-method-bindings'
import type { PluginStatusBarState } from '../../status-bar/plugin-status-bar-state'
import type { PluginRuntimeRpc } from './plugin-host-workspace-method-bindings'
import type { PluginRuntimeRpc } from './plugin-host-runtime-call'
import type { PluginHostRendererActions } from '../../view/plugin-host-renderer-actions'
import {
pluginSettingValueIssue,
@@ -19,6 +19,7 @@ import type { PluginEditorTypographySource } from '../../appearance/plugin-edito
export type PluginRuntimeDelegate = {
resolveActiveWorktreeContext(): Promise<{
worktreeId: string
identityKey?: string
path: string
branch: string
displayName: string
@@ -84,9 +85,11 @@ export function bindPluginHostServices(input: {
if (!context) {
return null
}
// Retain path-bearing worktree IDs for host membership checks; public projections exclude them.
// Retain path-bearing worktree IDs for host membership checks; public projections expose
// the identity form instead when there is one.
return {
worktreeId: context.worktreeId,
...(context.identityKey ? { identityKey: context.identityKey } : {}),
branch: context.branch,
displayName: context.displayName
}
@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from 'vitest'
import type { PluginHostRendererActions } from '../../view/plugin-host-renderer-actions'
import type { PluginRuntimeRpc } from './plugin-host-runtime-call'
import { callPluginHost, pluginHostTestServices } from './__mocks__/plugin-host-services-fixture'
// The runtime's refusal of a handle it no longer knows, as the in-process RPC raises it.
const runtimeError = (code: string) =>
Object.assign(new Error(`${code}: internal detail`), { code })
const renderer: PluginHostRendererActions = {
openFile: vi.fn(),
runAction: vi.fn(),
openFolderWorkspace: vi.fn()
}
describe('terminal.list', () => {
it("lists any workspace's terminals by the selector its id names", async () => {
const rpc = vi.fn().mockResolvedValue({
terminals: [
{ handle: 'term_a', title: 'zsh', paneTitle: 'Build' },
{ handle: 'term_b', title: null }
],
totalCount: 60,
truncated: false
})
const outcome = await callPluginHost({
method: 'terminal.list',
params: { workspaceId: 'identity:wt2:local:abc' },
granted: ['workspace:read'],
services: pluginHostTestServices(rpc)
})
expect(outcome).toEqual({
ok: true,
value: { terminals: [{ id: 'term_a', title: 'Build' }, { id: 'term_b' }], truncated: true }
})
expect(rpc).toHaveBeenCalledWith(
'terminal.list',
{
worktree: 'identity:wt2:local:abc',
limit: 50,
includeVisualLayouts: false,
includePreview: false
},
{}
)
})
it('names an unknown workspace without the runtime wording', async () => {
const rpc = vi.fn().mockRejectedValue(runtimeError('selector_not_found'))
expect(
await callPluginHost({
method: 'terminal.list',
params: { workspaceId: 'gone' },
granted: ['workspace:read'],
services: pluginHostTestServices(rpc)
})
).toEqual({ ok: false, code: 'action_failed', error: 'no workspace has this id' })
})
})
describe('terminal.focus', () => {
it("focuses in this Orca's window only, never steering paired clients", async () => {
const rpc = vi.fn().mockResolvedValue({ focus: { handle: 'term_a', navigated: true } })
const audit = { record: vi.fn().mockResolvedValue(undefined) }
const outcome = await callPluginHost({
method: 'terminal.focus',
params: { terminalId: 'term_a' },
granted: ['workspace:write'],
services: pluginHostTestServices(rpc, { renderer }),
audit
})
expect(outcome).toEqual({ ok: true, value: { focused: true } })
expect(rpc).toHaveBeenCalledWith(
'terminal.focus',
{ terminal: 'term_a', navigation: 'host' },
expect.anything()
)
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ method: 'terminal.focus', summary: 'terminal=term_a' })
)
})
it('is unavailable without a window, before asking the runtime', async () => {
const rpc = vi.fn()
expect(
await callPluginHost({
method: 'terminal.focus',
params: { terminalId: 'term_a' },
granted: ['workspace:write'],
services: pluginHostTestServices(rpc)
})
).toMatchObject({ ok: false, code: 'unavailable' })
expect(rpc).not.toHaveBeenCalled()
})
})
describe('terminal.close', () => {
const close = (rpc: PluginRuntimeRpc) =>
callPluginHost({
method: 'terminal.close',
params: { terminalId: 'term_a' },
granted: ['terminal:send'],
services: pluginHostTestServices(rpc)
})
it('reports whether the process is known to have stopped', async () => {
const answers = [
{ ptyKilled: true },
{ ptyKilled: false },
{ ptyKilled: false, ptyStopVerdict: 'live' },
{ ptyKilled: false, ptyStopVerdict: 'unverifiable' }
]
const outcomes = await Promise.all(
answers.map((answer) =>
close(vi.fn().mockResolvedValue({ close: { handle: 'term_a', tabId: 't', ...answer } }))
)
)
expect(outcomes.map((outcome) => outcome.ok && outcome.value)).toEqual([
{ closed: true, processExited: true },
{ closed: true, processExited: true },
{ closed: true, processExited: false },
{ closed: true, processExited: false }
])
})
it('names a stale or exited terminal without the runtime wording', async () => {
const outcomes = await Promise.all(
[runtimeError('terminal_handle_stale'), new Error('terminal_not_found')].map((error) =>
close(vi.fn().mockRejectedValue(error))
)
)
for (const outcome of outcomes) {
expect(outcome).toEqual({
ok: false,
code: 'action_failed',
error: 'no live terminal has this id'
})
}
})
it('is refused before any runtime call without terminal:send', async () => {
const rpc = vi.fn()
expect(
await callPluginHost({
method: 'terminal.close',
params: { terminalId: 'term_a' },
granted: ['workspace:write'],
services: pluginHostTestServices(rpc)
})
).toMatchObject({ ok: false, code: 'capability_denied' })
expect(rpc).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,74 @@
import { z } from 'zod'
import { PLUGIN_HOST_API } from '../../../../shared/plugins/workers/plugin-host-api'
import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../../../shared/plugins/workers/plugin-host-api-workspace-context'
import { bind, type BoundPluginHostMethod } from './plugin-host-method-bind'
import { pluginTerminalTitle } from './plugin-workspace-projection'
import {
pluginHostEffect as effect,
pluginHostRenderer as renderer,
pluginHostRpc as rpc,
pluginWorkspaceSelector as workspaceSelector
} from './plugin-host-runtime-call'
const api = PLUGIN_HOST_API
// The parts of each runtime answer a host method reads; anything else is dropped.
const terminalListAnswer = z.object({
terminals: z.array(
z.object({
handle: z.string(),
title: z.string().nullable(),
paneTitle: z.string().optional()
})
),
totalCount: z.number().optional(),
truncated: z.boolean().optional()
})
const terminalCloseAnswer = z.object({
close: z.object({
ptyKilled: z.boolean(),
ptyStopVerdict: z.enum(['live', 'unverifiable']).optional()
})
})
export const PLUGIN_TERMINAL_ACTION_HANDLERS = {
'terminal.list': bind(api['terminal.list'], async ({ workspaceId }, { services, scope }) => {
const answer = await rpc(services)(
'terminal.list',
{
worktree: workspaceSelector(workspaceId),
limit: PLUGIN_WORKSPACE_TERMINAL_LIMIT,
includeVisualLayouts: false,
includePreview: false
},
scope.signal ? { signal: scope.signal } : {}
)
const { terminals, totalCount, truncated } = terminalListAnswer.parse(answer)
const listed = terminals.slice(0, PLUGIN_WORKSPACE_TERMINAL_LIMIT)
return {
terminals: listed.map((terminal) => {
const title = pluginTerminalTitle(terminal)
return title ? { id: terminal.handle, title } : { id: terminal.handle }
}),
truncated: truncated === true || (totalCount ?? terminals.length) > listed.length
}
}),
'terminal.focus': bind(api['terminal.focus'], async ({ terminalId }, ctx) => {
// Focus means Orca's window; headless serve has none to show.
renderer(ctx.services)
// `navigated: false` in the answer means a later request for this terminal won; it is shown.
await effect(ctx, 'terminal.focus', {
terminal: terminalId,
// This window only: paired phones and other clients are not steered by a plugin.
navigation: 'host'
})
return { focused: true as const }
}),
'terminal.close': bind(api['terminal.close'], async ({ terminalId }, ctx) => {
const { close } = terminalCloseAnswer.parse(
await effect(ctx, 'terminal.close', { terminal: terminalId })
)
// No verdict and no kill: the terminal had no running process (it already exited).
return { closed: true as const, processExited: close.ptyKilled || !close.ptyStopVerdict }
})
} satisfies Record<string, BoundPluginHostMethod>
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest'
import { callPluginHost, pluginHostTestServices } from './__mocks__/plugin-host-services-fixture'
const ANSWERS: Record<string, unknown> = {
'worktree.list': {
worktrees: [
{
id: 'r1::/src/app',
repoId: 'r1',
displayName: 'app',
path: '/src/app',
branch: 'refs/heads/main',
hostId: 'local',
identity: { key: 'wt2:local:i1' }
},
{ id: 'r1::/src/old', repoId: 'r1', displayName: 'old', path: '/src/old' }
]
},
'repo.list': {
repos: [
{ id: 'r1', displayName: 'App', path: '/src/app', executionHostId: 'local' },
{ id: 'r2', kind: 'folder', displayName: 'Docs', path: '/docs', connectionId: 'box' },
{ id: 'r3', displayName: 'Lib', path: '/lib' }
]
},
'folderWorkspace.list': {
folderWorkspaces: [{ id: 'f1', name: 'Notes', folderPath: '/notes' }]
},
'worktree.ps': {
worktrees: [
{
worktreeId: 'r1::/src/app',
identity: { key: 'wt2:local:i1' },
status: 'working',
unread: true,
liveTerminalCount: 2,
lastOutputAt: 20,
lastActivityAt: 10
},
{
worktreeId: 'folder:f1',
status: 'inactive',
unread: false,
liveTerminalCount: 0,
lastOutputAt: null
}
]
}
}
const runtime = () => vi.fn(async (method: string) => ANSWERS[method])
const list = (rpc: ReturnType<typeof runtime>, params: unknown) =>
callPluginHost({
method: 'workspace.list',
params,
granted: ['workspace:read'],
services: pluginHostTestServices(rpc)
})
describe('workspace.list', () => {
it('names each row by its listed id and project, joining status only when asked', async () => {
const rpc = runtime()
const outcome = await list(rpc, { includeStatus: true })
expect(outcome).toMatchObject({ ok: true })
expect(outcome.ok && outcome.value).toMatchObject({
workspaces: [
{
id: 'identity:wt2:local:i1',
projectId: 'r1',
status: { state: 'working', unread: true, terminalCount: 2, lastActivityAt: 20 }
},
{ id: 'r1::/src/old', projectId: 'r1' },
{
id: 'folder:f1',
projectId: null,
status: { state: 'inactive', unread: false, terminalCount: 0, lastActivityAt: null }
}
]
})
// A workspace Orca has no status for has none, rather than a guessed one.
expect(outcome.ok && outcome.value).not.toHaveProperty('workspaces.1.status')
const plain = runtime()
const without = await list(plain, {})
expect(without.ok && without.value).not.toHaveProperty('workspaces.0.status')
// Why it matters: worktree.ps refreshes every terminal host.
expect(plain.mock.calls.map(([method]) => method)).not.toContain('worktree.ps')
})
it('refuses unknown params, as older hosts refuse includeStatus', async () => {
expect(await list(runtime(), { includeStatus: true, extra: 1 })).toMatchObject({
ok: false,
code: 'invalid_params'
})
})
})
describe('project.list', () => {
it('lists repositories and folders with the host each runs on', async () => {
const outcome = await callPluginHost({
method: 'project.list',
params: {},
granted: ['workspace:read'],
services: pluginHostTestServices(runtime())
})
expect(outcome).toEqual({
ok: true,
value: {
projects: [
{ id: 'r1', name: 'App', kind: 'git', hostId: 'local', path: '/src/app' },
{ id: 'r2', name: 'Docs', kind: 'folder', hostId: 'ssh:box', path: '/docs' },
{ id: 'r3', name: 'Lib', kind: 'git', hostId: null, path: '/lib' }
],
truncated: false
}
})
})
})
@@ -0,0 +1,168 @@
import { z } from 'zod'
import type { PluginHostResult } from '../../../../shared/plugins/workers/plugin-host-api'
import {
PLUGIN_PROJECT_LIST_LIMIT,
PLUGIN_WORKSPACE_LIST_LIMIT,
pluginWorkspaceId
} from '../../../../shared/plugins/workers/plugin-host-api-workspace-methods'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import type { PluginRuntimeRpc } from './plugin-host-runtime-call'
type WorkspaceList = PluginHostResult<'workspace.list'>
type WorkspaceStatus = NonNullable<WorkspaceList['workspaces'][number]['status']>
// The parts of each runtime answer `workspace.list` and `project.list` read; the rest is dropped.
const worktreeListAnswer = z.object({
worktrees: z.array(
z.object({
id: z.string(),
repoId: z.string(),
displayName: z.string(),
path: z.string(),
branch: z.string().optional(),
hostId: z.string().optional(),
identity: z.object({ key: z.string() }).optional()
})
),
totalCount: z.number().optional(),
truncated: z.boolean().optional()
})
const repoRow = z.object({
id: z.string(),
kind: z.string().optional(),
displayName: z.string().optional()
})
const repoListAnswer = z.object({ repos: z.array(repoRow) })
const projectListAnswer = z.object({
repos: z.array(
repoRow.extend({
path: z.string(),
executionHostId: z.string().nullable().optional(),
connectionId: z.string().nullable().optional()
})
)
})
const folderListAnswer = z.object({
folderWorkspaces: z.array(
z.object({
id: z.string(),
name: z.string(),
folderPath: z.string(),
executionHostId: z.string().nullable().optional()
})
)
})
const worktreePsAnswer = z.object({
worktrees: z.array(
z.object({
worktreeId: z.string(),
identity: z.object({ key: z.string() }).optional(),
status: z.enum(['working', 'permission', 'done', 'active', 'inactive']),
unread: z.boolean(),
liveTerminalCount: z.number().int().nonnegative(),
lastOutputAt: z.number().finite().nullable().optional(),
lastActivityAt: z.number().finite().optional()
})
)
})
/** `refs/heads/main` reads as `main`; a detached or folder workspace has none. */
function branchName(branch: string | undefined): string | null {
const name = branch?.replace(/^refs\/heads\//, '') ?? ''
return name || null
}
/** Each workspace's status by the id `workspace.list` gives it. */
async function workspaceStatuses(call: PluginRuntimeRpc): Promise<Map<string, WorkspaceStatus>> {
const { worktrees } = worktreePsAnswer.parse(
await call('worktree.ps', { limit: PLUGIN_WORKSPACE_LIST_LIMIT })
)
return new Map(
worktrees.map((row) => [
// Folder workspace rows carry their `folder:` key as worktreeId, as list rows do.
pluginWorkspaceId({ id: row.worktreeId, identityKey: row.identity?.key }),
{
state: row.status,
unread: row.unread,
terminalCount: row.liveTerminalCount,
lastActivityAt: row.lastOutputAt ?? row.lastActivityAt ?? null
}
])
)
}
/** `workspace.list`: Git worktrees (and folder projects), then project-group folders. */
export async function listPluginWorkspaces(
call: PluginRuntimeRpc,
includeStatus: boolean
): Promise<WorkspaceList> {
const [listed, repos, folderList, statuses] = await Promise.all([
call('worktree.list', { limit: PLUGIN_WORKSPACE_LIST_LIMIT }),
call('repo.list', {}),
call('folderWorkspace.list', undefined),
includeStatus ? workspaceStatuses(call) : null
])
const { worktrees, totalCount, truncated } = worktreeListAnswer.parse(listed)
const repoRows = repoListAnswer.parse(repos).repos
const folderRepos = new Set(
repoRows.filter((repo) => repo.kind === 'folder').map((repo) => repo.id)
)
const projectNames = new Map(repoRows.map((repo) => [repo.id, repo.displayName || null]))
const withStatus = <Row extends { id: string }>(row: Row) => {
const status = statuses?.get(row.id)
return status ? { ...row, status } : row
}
const worktreeRows = worktrees.slice(0, PLUGIN_WORKSPACE_LIST_LIMIT).map((worktree) => {
const folder = folderRepos.has(worktree.repoId)
return withStatus({
id: pluginWorkspaceId({ id: worktree.id, identityKey: worktree.identity?.key }),
kind: folder ? ('folder' as const) : ('worktree' as const),
name: worktree.displayName,
project: projectNames.get(worktree.repoId)?.slice(0, 512) ?? null,
projectId: worktree.repoId,
path: worktree.path,
branch: folder ? null : branchName(worktree.branch),
hostId: worktree.hostId ?? null
})
})
// Project-group folders have no repo row; their key is what selectors take.
const projectFolders = folderListAnswer.parse(folderList).folderWorkspaces.map((folder) =>
withStatus({
id: folderWorkspaceKey(folder.id),
kind: 'folder' as const,
name: folder.name,
project: null,
projectId: null,
path: folder.folderPath,
branch: null,
hostId: folder.executionHostId ?? null
})
)
const room = PLUGIN_WORKSPACE_LIST_LIMIT - worktreeRows.length
return {
workspaces: [...worktreeRows, ...projectFolders.slice(0, room)],
truncated:
truncated === true ||
(totalCount ?? worktrees.length) > worktreeRows.length ||
projectFolders.length > room
}
}
/** `project.list`: the repositories and folders `repo.list` shows, which hides retired rows. */
export async function listPluginProjects(
call: PluginRuntimeRpc
): Promise<PluginHostResult<'project.list'>> {
const { repos } = projectListAnswer.parse(await call('repo.list', {}))
return {
projects: repos.slice(0, PLUGIN_PROJECT_LIST_LIMIT).map((repo) => ({
id: repo.id,
name: (repo.displayName ?? '').slice(0, 512),
kind: repo.kind === 'folder' ? ('folder' as const) : ('git' as const),
// The same host a list row names: null when the repo records none (this machine).
hostId: repo.executionHostId || repo.connectionId ? getRepoExecutionHostId(repo) : null,
path: repo.path
})),
truncated: repos.length > PLUGIN_PROJECT_LIST_LIMIT
}
}
@@ -1,62 +1,22 @@
import { z } from 'zod'
import { PLUGIN_HOST_API } from '../../../../shared/plugins/workers/plugin-host-api'
import {
PLUGIN_TERMINAL_READ_MAX_LINES,
PLUGIN_WORKSPACE_LIST_LIMIT
} from '../../../../shared/plugins/workers/plugin-host-api-workspace-methods'
import {
bind,
type BoundPluginHostMethod,
type PluginHostMethodContext
} from './plugin-host-method-bind'
import type { PluginHostServices } from './plugin-host-method-bindings'
import { PLUGIN_TERMINAL_READ_MAX_LINES } from '../../../../shared/plugins/workers/plugin-host-api-workspace-methods'
import { bind, type BoundPluginHostMethod } from './plugin-host-method-bind'
import { PluginHostCallError } from './plugin-host-call-scope'
import { claimPluginReplacedActionRun } from '../../plugin-command-replacement-deadline'
import type { PluginHostRendererActions } from '../../view/plugin-host-renderer-actions'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
/** The runtime RPC the `orca` CLI calls, run in process; rejects with the runtime's error.
* `beforeEffect` runs right before the runtime acts, after its own waits; a throw refuses it. */
export type PluginRuntimeRpc = (
method: string,
params: unknown,
options?: { signal?: AbortSignal; beforeEffect?: () => void }
) => Promise<unknown>
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import {
pluginHostEffect as effect,
pluginHostRenderer as renderer,
pluginHostRpc as rpc,
pluginWorkspaceSelector as workspaceSelector
} from './plugin-host-runtime-call'
import { listPluginProjects, listPluginWorkspaces } from './plugin-host-workspace-list'
const api = PLUGIN_HOST_API
const DEFAULT_READ_LINES = 200
// The parts of each runtime answer a host method reads; anything else is dropped.
const worktreeListAnswer = z.object({
worktrees: z.array(
z.object({
id: z.string(),
repoId: z.string(),
displayName: z.string(),
path: z.string(),
branch: z.string().optional(),
hostId: z.string().optional(),
identity: z.object({ key: z.string() }).optional()
})
),
totalCount: z.number().optional(),
truncated: z.boolean().optional()
})
const repoListAnswer = z.object({
repos: z.array(
z.object({ id: z.string(), kind: z.string().optional(), displayName: z.string().optional() })
)
})
const folderListAnswer = z.object({
folderWorkspaces: z.array(
z.object({
id: z.string(),
name: z.string(),
folderPath: z.string(),
executionHostId: z.string().nullable().optional()
})
)
})
const folderUpdateAnswer = z.object({
folderWorkspace: z.object({ name: z.string() }).nullable()
})
@@ -73,89 +33,13 @@ const agentLaunchAnswer = z.object({
outcome: z.object({ kind: z.enum(['structured', 'terminal']), handle: z.string() })
})
function renderer(services: PluginHostServices): PluginHostRendererActions {
if (!services.renderer) {
throw new PluginHostCallError('unavailable', 'only the Orca desktop app can do this')
}
return services.renderer
}
function rpc(services: PluginHostServices): PluginRuntimeRpc {
if (!services.runtimeRpc) {
throw new PluginHostCallError('unavailable', 'this Orca host cannot run workspace actions')
}
return services.runtimeRpc
}
/** A runtime call that changes something: refused if the plugin lost approval, or its worker
* cancelled the call, at any point before the runtime acts. */
function effect(ctx: PluginHostMethodContext, method: string, params: unknown): Promise<unknown> {
ctx.scope.assertActive()
return rpc(ctx.services)(method, params, {
...(ctx.scope.signal ? { signal: ctx.scope.signal } : {}),
beforeEffect: () => ctx.scope.assertActive()
})
}
/**
* The selector a listed workspace id names. Ids carry their host (`identity:`, unique across
* twins on two hosts) or are folder keys; a bare id from elsewhere is taken as an exact id.
*/
function workspaceSelector(workspaceId: string): string {
return workspaceId.startsWith('identity:') ? workspaceId : `id:${workspaceId}`
}
/** `refs/heads/main` reads as `main`; a detached or folder workspace has none. */
function branchName(branch: string | undefined): string | null {
const name = branch?.replace(/^refs\/heads\//, '') ?? ''
return name || null
}
export const PLUGIN_WORKSPACE_ACTION_HANDLERS = {
'workspace.list': bind(api['workspace.list'], async (_params, { services }) => {
const call = rpc(services)
const [listed, repos, folderList] = await Promise.all([
call('worktree.list', { limit: PLUGIN_WORKSPACE_LIST_LIMIT }),
call('repo.list', {}),
call('folderWorkspace.list', undefined)
])
const { worktrees, totalCount, truncated } = worktreeListAnswer.parse(listed)
const repoRows = repoListAnswer.parse(repos).repos
const folderRepos = new Set(
repoRows.filter((repo) => repo.kind === 'folder').map((repo) => repo.id)
)
const projectNames = new Map(repoRows.map((repo) => [repo.id, repo.displayName || null]))
const worktreeRows = worktrees.slice(0, PLUGIN_WORKSPACE_LIST_LIMIT).map((worktree) => {
const folder = folderRepos.has(worktree.repoId)
return {
id: worktree.identity ? `identity:${worktree.identity.key}` : worktree.id,
kind: folder ? ('folder' as const) : ('worktree' as const),
name: worktree.displayName,
project: projectNames.get(worktree.repoId)?.slice(0, 512) ?? null,
path: worktree.path,
branch: folder ? null : branchName(worktree.branch),
hostId: worktree.hostId ?? null
}
})
// Project-group folders have no repo row; their key is what selectors take.
const projectFolders = folderListAnswer.parse(folderList).folderWorkspaces.map((folder) => ({
id: folderWorkspaceKey(folder.id),
kind: 'folder' as const,
name: folder.name,
project: null,
path: folder.folderPath,
branch: null,
hostId: folder.executionHostId ?? null
}))
const room = PLUGIN_WORKSPACE_LIST_LIMIT - worktreeRows.length
return {
workspaces: [...worktreeRows, ...projectFolders.slice(0, room)],
truncated:
truncated === true ||
(totalCount ?? worktrees.length) > worktreeRows.length ||
projectFolders.length > room
}
}),
'workspace.list': bind(api['workspace.list'], async (params, { services }) =>
listPluginWorkspaces(rpc(services), params?.includeStatus === true)
),
'project.list': bind(api['project.list'], async (_params, { services }) =>
listPluginProjects(rpc(services))
),
'workspace.open': bind(api['workspace.open'], async ({ workspaceId }, ctx) => {
const scope = parseWorkspaceKey(workspaceId)
if (scope?.type === 'folder') {
@@ -7,6 +7,10 @@ import {
type pluginWorkspaceContextSchema
} from '../../../../shared/plugins/workers/plugin-host-api'
import { PLUGIN_AGENT_LIST_LIMIT } from '../../../../shared/plugins/workers/plugin-host-api-panel-methods'
import {
PLUGIN_HOST_ID_MAX_LENGTH,
pluginWorkspaceId
} from '../../../../shared/plugins/workers/plugin-host-api-workspace-methods'
import type { AgentStatusState } from '../../../../shared/agent-status/contracts/agent-status-types'
/** One terminal of a workspace, as main's runtime lists it. */
@@ -31,7 +35,7 @@ export type PluginAgentStatusRow = {
export type PluginWorkspaceContext = z.output<typeof pluginWorkspaceContextSchema>
type Context = { worktreeId: string; branch: string; displayName: string }
type Context = { worktreeId: string; identityKey?: string; branch: string; displayName: string }
/** A pane's own name wins over the title its program sets. */
export function pluginTerminalTitle(terminal: {
@@ -42,7 +46,16 @@ export function pluginTerminalTitle(terminal: {
return title ? title.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH) : undefined
}
/** Worktree ids embed provider paths: only these public fields reach plugins. */
/** The id `workspace.list` gives, or undefined when it exceeds the public bound. */
export function pluginListedWorkspaceId(row: {
id: string
identityKey?: string | null
}): string | undefined {
const id = pluginWorkspaceId(row)
return id.length <= PLUGIN_HOST_ID_MAX_LENGTH ? id : undefined
}
/** Only these public fields reach plugins; the id is the one `workspace.list` gives. */
export function projectPluginWorkspaceContext(
context: Context | null,
terminals: readonly PluginWorkspaceTerminal[]
@@ -50,7 +63,12 @@ export function projectPluginWorkspaceContext(
if (!context) {
return null
}
const workspaceId = pluginListedWorkspaceId({
id: context.worktreeId,
identityKey: context.identityKey
})
return {
...(workspaceId ? { workspaceId } : {}),
branch: shortPluginBranchName(context.branch).slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
displayName: context.displayName.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
terminals: terminals
@@ -10,6 +10,8 @@ import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id'
type WorktreeMetaLabel = { displayName?: string }
export type ActiveWorkspaceContext = {
worktreeId: string
/** Host-qualified identity key; absent for folders and rows known only from storage. */
identityKey?: string
path: string
branch: string
displayName: string
@@ -171,6 +171,7 @@ export abstract class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRunt
const resolved = await this.resolveWorkspaceSelector(`id:${worktreeId}`, scope)
return {
worktreeId: resolved.id,
...(resolved.identity ? { identityKey: resolved.identity.key } : {}),
path: resolved.git.path,
branch: resolved.git.branch,
displayName: resolved.displayName
+1 -1
View File
@@ -298,7 +298,7 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService):
// Filter hook/worktree events server-side by each plugin subscription.
const forwardAgentStatus = createPluginAgentStatusForwarder(pluginDelegate, pluginService)
agentHookServer.subscribeEnrichedStatus((enriched) => {
const payload = projectPluginAgentStatusChangedPayload(enriched)
const payload = projectPluginAgentStatusChangedPayload(enriched, store)
if (payload) {
forwardAgentStatus(payload, enriched.terminalHandle)
}
+10 -1
View File
@@ -40,9 +40,9 @@ import type { RuntimeWorktreeLifecycleEvent } from '../runtime/workspaces/lifecy
import { mainProcessState as state } from './main-process-state'
import { shortPluginBranchName } from '../../shared/plugins/events/plugin-events'
import { notifyWorkspaceCreatedPluginHook } from '../plugins/hooks/workspace-create-plugin-hook'
import { pluginEventWorkspaceListId } from '../plugins/plugin-event-workspace-id'
export function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void {
const identity = { workspaceId: event.worktreeId, kind: 'worktree', path: event.path }
const folder = parseWorkspaceKey(event.worktreeId)?.type === 'folder'
if (event.kind === 'created') {
notifyWorkspaceCreatedPluginHook({ ...event, folder })
@@ -51,6 +51,15 @@ export function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent
if (folder) {
return
}
const workspaceListId = pluginEventWorkspaceListId(state.store, event.worktreeId, {
removed: event.kind === 'removed'
})
const identity = {
workspaceId: event.worktreeId,
...(workspaceListId ? { workspaceListId } : {}),
kind: 'worktree',
path: event.path
}
state.pluginService?.emitEvent(
event.kind === 'created' ? 'workspace.created' : 'workspace.removed',
event.kind === 'created'
@@ -67,12 +67,13 @@ export type PluginCapability = z.infer<typeof pluginCapabilitySchema>
/** Consent copy shown verbatim; describe only what the host actually enforces. */
export const PLUGIN_CAPABILITY_DESCRIPTIONS: Record<PluginCapabilityKind, string> = {
'workspace:read':
'Read the name, branch, and terminal list of your focused workspace, and get notified when workspaces are created or removed',
'Read your projects and workspaces (names, branches, paths and agent status) and the terminals in them, and get notified when they change',
'workspace:write':
'Open and rename your workspaces, open their files in Orca, and run its built-in commands',
'Open and rename your workspaces, focus their terminals, open their files in Orca, and run its built-in commands',
'agent:read': 'Get notified when agent status changes',
'agent:launch': 'Start agents in your workspaces, local and SSH',
'terminal:send': 'Type and run commands in your terminals, local and SSH',
'terminal:send':
'Open and close your terminals, and type and run commands in them, local and SSH',
'terminal:read': 'Read the output of your terminals, local and SSH',
'notifications:show': 'Show desktop notifications labeled with the plugin name',
'storage:own': "Store data in the plugin's own storage folder",
@@ -59,6 +59,8 @@ const DEFAULT_RESULTS: Record<string, unknown> = {
'shell.openExternal': { opened: true },
'files.saveAs': { status: 'saved' },
'agents.list': { agents: [] },
'project.list': { projects: [], truncated: false },
'terminal.list': { terminals: [], truncated: false },
// Orca's default size: the editor follows a 14 px terminal font, unzoomed (the family is platform-specific).
'editor.readTypography': resolvePluginEditorTypography({ terminalFontSize: 14 }, 0)
}
+15 -4
View File
@@ -8,6 +8,7 @@ import {
pluginWorkspaceContextSchema
} from '../workers/plugin-host-api'
import { pluginEditorTypographySchema } from '../workers/plugin-host-api-editor-typography'
import { pluginWorkspaceIdSchema } from '../workers/plugin-host-api-workspace-methods'
/**
* Payload contracts for the v0 plugin event set (workspace lifecycle + agent
@@ -21,8 +22,14 @@ export function shortPluginBranchName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
/** The id `workspace.list` gives the workspace, which every workspace method takes; `workspaceId`
* is Orca's raw id, which they take too but which differs from it for most worktrees. Absent
* before host API 1.7. */
const workspaceListId = pluginWorkspaceIdSchema.optional()
const workspaceIdentity = {
workspaceId: z.string().min(1).max(2048),
workspaceListId,
/** Folder workspaces have no branch; only worktrees emit lifecycle events today. */
kind: z.enum(['worktree', 'folder'])
}
@@ -46,6 +53,8 @@ export const workspaceRemovedPayloadSchema = z.object({
export const agentStatusChangedPayloadSchema = z.object({
workspaceId: z.string().min(1).max(2048).nullable(),
/** Absent too while `workspaceId` is null. */
workspaceListId,
paneKey: z.string().min(1).max(2048),
/** The pane's terminal, as `workspace.readContext` and `terminal.sendText` name it; absent when
* Orca cannot tie the pane to a terminal it lists. */
@@ -84,12 +93,14 @@ export const PLUGIN_EVENT_PAYLOAD_SCHEMAS = {
/** One line per event for the generated reference and author typings. */
export const PLUGIN_EVENT_SUMMARIES: Record<PluginEventName, string> = {
'workspace.created': 'A worktree workspace was created.',
'workspace.removed': 'A worktree workspace was removed.',
'workspace.created':
'A worktree workspace was created; `workspaceListId` (host API 1.7) is the id `workspace.list` gives it.',
'workspace.removed':
'A worktree workspace was removed; `workspaceListId` (host API 1.7) is the id `workspace.list` gave it.',
'agent.status.changed':
'An agent in a terminal pane changed status; `workspaceId` is null when Orca cannot tie the pane to a workspace, and `terminalId` names its terminal when Orca lists it.',
'An agent in a terminal pane changed status; `workspaceId` is null when Orca cannot tie the pane to a workspace, `workspaceListId` (host API 1.7) is the id `workspace.list` gives that workspace, and `terminalId` names its terminal when Orca lists it.',
'workspace.changed':
'The focused workspace changed, or its terminals were opened, closed or renamed; `context` is what `workspace.readContext` now answers. Each new subscriber first gets the current context.',
'The focused workspace changed, or its terminals were opened, closed or renamed; `context` is what `workspace.readContext` now answers, its `workspaceId` included. Each new subscriber first gets the current context.',
'editor.typography.changed':
"What Orca's file editor uses changed (a setting or the editor zoom); the payload is what `editor.readTypography` now answers. Needs no capability."
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { satisfiesOrcaEngineRange } from '../manifest/plugin-manifest-engine-range'
import { PLUGIN_THEME_VIDEO_BACKGROUND_SINCE } from '../manifest/plugin-schema-field-docs'
import { PLUGIN_EDITOR_TYPOGRAPHY_SINCE } from '../workers/plugin-host-api-editor-typography'
import { PLUGIN_HOST_API_1_7_SINCE } from '../workers/plugin-host-api-workspace-methods'
import { pluginHostVersion } from './plugin-host-version'
describe('pluginHostVersion', () => {
it('lets a development build run plugins that require what its schemas define', () => {
const hostVersion = pluginHostVersion('1.4.214')
for (const since of [
PLUGIN_THEME_VIDEO_BACKGROUND_SINCE,
PLUGIN_EDITOR_TYPOGRAPHY_SINCE,
PLUGIN_HOST_API_1_7_SINCE
]) {
expect(satisfiesOrcaEngineRange(hostVersion, `>=${since}`)).toBe(true)
}
})
})
@@ -8,12 +8,15 @@ import {
PLUGIN_HOST_ACTIONS_SINCE,
PLUGIN_MANIFEST_V2_SINCE,
PLUGIN_PANEL_UI_SINCE,
PLUGIN_SCHEMA_SINCE_V1
PLUGIN_SCHEMA_SINCE_V1,
PLUGIN_THEME_VIDEO_BACKGROUND_SINCE
} from '../manifest/plugin-schema-field-docs'
import { PLUGIN_STATUS_BAR_SINCE } from '../contributions/plugin-status-bar-item-contribution'
import { PLUGIN_COMMAND_REPLACES_SINCE } from '../contributions/plugin-command-contribution'
import { PLUGIN_APPEARANCE_SINCE } from '../workers/plugin-host-api-appearance'
import { PLUGIN_FONTS_SINCE } from '../contributions/plugin-font-contribution'
import { PLUGIN_EDITOR_TYPOGRAPHY_SINCE } from '../workers/plugin-host-api-editor-typography'
import { PLUGIN_HOST_API_1_7_SINCE } from '../workers/plugin-host-api-workspace-methods'
/** The newest "since" this build's schemas cite: everything up to it is readable here. */
export const PLUGIN_SCHEMA_NEWEST_SINCE = [
@@ -30,7 +33,10 @@ export const PLUGIN_SCHEMA_NEWEST_SINCE = [
PLUGIN_HOST_ACTIONS_SINCE,
PLUGIN_COMMAND_REPLACES_SINCE,
PLUGIN_APPEARANCE_SINCE,
PLUGIN_FONTS_SINCE
PLUGIN_FONTS_SINCE,
PLUGIN_THEME_VIDEO_BACKGROUND_SINCE,
PLUGIN_EDITOR_TYPOGRAPHY_SINCE,
PLUGIN_HOST_API_1_7_SINCE
].reduce((newest, version) => (satisfiesOrcaEngineRange(version, `>=${newest}`) ? version : newest))
/**
@@ -0,0 +1,72 @@
import { z } from 'zod'
import {
pluginTerminalIdSchema,
pluginWorkspaceIdSchema
} from './plugin-host-api-workspace-methods'
import {
PLUGIN_WORKSPACE_LABEL_MAX_LENGTH,
PLUGIN_WORKSPACE_TERMINAL_LIMIT
} from './plugin-host-api-workspace-context'
import { spec, WORKER_ONLY } from './plugin-host-method-spec'
/** Terminals of any workspace, by the ids `terminal.read` and `terminal.sendText` take. Each runs
* the runtime method `orca terminal list|focus|close` runs, on the host that owns the terminal. */
const terminalListParams = z.strictObject({ workspaceId: pluginWorkspaceIdSchema })
const terminalListResult = z.object({
terminals: z
.array(
z.object({
id: pluginTerminalIdSchema,
/** The pane's name, or its terminal title; absent when it has neither. */
title: z.string().max(PLUGIN_WORKSPACE_LABEL_MAX_LENGTH).optional()
})
)
.max(PLUGIN_WORKSPACE_TERMINAL_LIMIT),
/** The workspace has more terminals than the list holds. */
truncated: z.boolean()
})
const terminalTargetParams = z.strictObject({ terminalId: pluginTerminalIdSchema })
const terminalFocusResult = z.object({ focused: z.literal(true) })
const terminalCloseResult = z.object({
closed: z.literal(true),
/** Nothing runs in the terminal any more. False when Orca could not confirm its process
* stopped: it may still run, as on an unreachable SSH host, where Orca stops it on reconnect. */
processExited: z.boolean()
})
export const PLUGIN_TERMINAL_ACTIONS_API = {
'terminal.list': spec({
summary: `Lists a workspace's terminals, at most ${PLUGIN_WORKSPACE_TERMINAL_LIMIT}; \`workspace.readContext\` lists the focused one's.`,
since: 7,
scope: 'any-workspace',
capability: 'workspace:read',
mutation: false,
surfaces: WORKER_ONLY,
params: terminalListParams,
result: terminalListResult
}),
'terminal.focus': spec({
summary:
"Shows a terminal's workspace and focuses its pane, as clicking it does; desktop app only.",
since: 7,
scope: 'explicit-terminal',
capability: 'workspace:write',
mutation: true,
surfaces: WORKER_ONLY,
params: terminalTargetParams,
result: terminalFocusResult
}),
'terminal.close': spec({
summary:
'Closes a terminal, stopping what runs in it (local or SSH), as `orca terminal close` does; closing its last pane closes the tab.',
since: 7,
scope: 'explicit-terminal',
capability: 'terminal:send',
mutation: true,
surfaces: WORKER_ONLY,
params: terminalTargetParams,
result: terminalCloseResult
})
}
@@ -1,11 +1,13 @@
import { PLUGIN_STATUS_BAR_API } from './plugin-host-api-status-bar'
import { PLUGIN_WORKSPACE_ACTIONS_API } from './plugin-host-api-workspace-methods'
import { PLUGIN_APPEARANCE_API } from './plugin-host-api-appearance'
import { PLUGIN_TERMINAL_ACTIONS_API } from './plugin-host-api-terminal-methods'
/** Worker-only methods that act on Orca: its status bar, workspaces, terminals and agents.
* Spread into PLUGIN_HOST_API. */
export const PLUGIN_WORKER_ACTIONS_API = {
...PLUGIN_STATUS_BAR_API,
...PLUGIN_APPEARANCE_API,
...PLUGIN_WORKSPACE_ACTIONS_API
...PLUGIN_WORKSPACE_ACTIONS_API,
...PLUGIN_TERMINAL_ACTIONS_API
}
@@ -0,0 +1,32 @@
import { z } from 'zod'
import { pluginWorkspaceIdSchema } from './plugin-host-api-workspace-methods'
export const PLUGIN_WORKSPACE_TERMINAL_LIMIT = 50
export const PLUGIN_WORKSPACE_LABEL_MAX_LENGTH = 512
export const PLUGIN_TERMINAL_ID_MAX_LENGTH = 1024
/** What `workspace.readContext` answers and `workspace.changed` carries. */
export const pluginWorkspaceContextSchema = z
.object({
/** The id `workspace.list` gives this workspace, which every workspace method takes. A remote
* workspace Orca knows only from stored rows has its raw id. Absent before host API 1.7; a
* panel checks for the field, since its `api.minor` counts only the methods it is offered. */
workspaceId: pluginWorkspaceIdSchema.optional(),
branch: z.string().max(PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
displayName: z.string().max(PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
/** Terminals of the focused workspace, so callers can address a specific
* terminal id — the API has no "active terminal" write target. */
terminals: z
.array(
z
.object({
id: z.string().min(1).max(PLUGIN_TERMINAL_ID_MAX_LENGTH),
/** The pane's name, or its terminal title; absent when it has neither. */
title: z.string().max(PLUGIN_WORKSPACE_LABEL_MAX_LENGTH).optional()
})
.strict()
)
.max(PLUGIN_WORKSPACE_TERMINAL_LIMIT)
})
.strict()
.nullable()
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { isTuiAgent } from '../../agent-launch/catalog/tui-agent-config'
import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../contributions/plugin-command-actions'
import { parseWorkspaceKey } from '../../workspace-scope'
import { spec, WORKER_ONLY } from './plugin-host-method-spec'
/**
@@ -9,15 +10,50 @@ import { spec, WORKER_ONLY } from './plugin-host-method-spec'
* workspace (local, WSL or SSH), so a mod acts exactly as `orca terminal create` would.
*/
/** Provisional release floor for host API 1.7, for plugins that require it in `engines.orca`
* instead of checking `host.has`; not a stock-release claim. */
export const PLUGIN_HOST_API_1_7_SINCE = '1.4.258'
export const PLUGIN_WORKSPACE_LIST_LIMIT = 500
export const PLUGIN_PROJECT_LIST_LIMIT = 500
export const PLUGIN_TERMINAL_READ_MAX_LINES = 2000
const ID_MAX = 1024
export const PLUGIN_HOST_ID_MAX_LENGTH = 1024
const ID_MAX = PLUGIN_HOST_ID_MAX_LENGTH
const TEXT_MAX = 4096
const workspaceId = z.string().min(1).max(ID_MAX)
const terminalId = z.string().min(1).max(ID_MAX)
export const pluginWorkspaceIdSchema = z.string().min(1).max(ID_MAX)
export const pluginTerminalIdSchema = z.string().min(1).max(ID_MAX)
const workspaceId = pluginWorkspaceIdSchema
const terminalId = pluginTerminalIdSchema
const workspaceListParams = z.strictObject({}).optional()
/**
* The id `workspace.list` gives a workspace: its host-qualified identity when it has one (no path,
* and it survives the folder moving), else the raw id. Folder workspaces keep their `folder:` key.
*/
export function pluginWorkspaceId(row: { id: string; identityKey?: string | null }): string {
return row.identityKey && parseWorkspaceKey(row.id)?.type !== 'folder'
? `identity:${row.identityKey}`
: row.id
}
const workspaceListParams = z
.strictObject({
/** Also report each workspace's agent and terminal status. Why opt-in: it refreshes every
* terminal host. Hosts before 1.7 refuse it with `invalid_params`. */
includeStatus: z.boolean().optional()
})
.optional()
const workspaceStatus = z.object({
/** `working`: an agent works; `permission`: one waits for input; `done`: an agent finished;
* `active`: a live terminal and no agent working; `inactive`: no live terminal. */
state: z.enum(['working', 'permission', 'done', 'active', 'inactive']),
/** Output arrived since the person last looked, as the sidebar's dot shows. */
unread: z.boolean(),
/** Live terminals. */
terminalCount: z.number().int().nonnegative(),
/** Milliseconds since the epoch: the last terminal output, else the last activity; null when none. */
lastActivityAt: z.number().finite().nullable()
})
const workspaceListResult = z.object({
workspaces: z
.array(
@@ -31,11 +67,16 @@ const workspaceListResult = z.object({
/** Its project's (repository's) name, or null when it has none, as for project folders.
* Absent before host API 1.5. */
project: z.string().max(512).nullable().optional(),
/** The project's id, as `project.list` gives it; null for folders of a project group.
* Absent before host API 1.7. */
projectId: z.string().max(ID_MAX).nullable().optional(),
/** The path on the host that owns it, which may be WSL or an SSH machine. */
path: z.string().max(ID_MAX),
branch: z.string().max(512).nullable(),
/** The execution host; null for this machine on hosts that do not record one. */
hostId: z.string().max(ID_MAX).nullable()
hostId: z.string().max(ID_MAX).nullable(),
/** Present only with `includeStatus`, for workspaces Orca has a status for. */
status: workspaceStatus.optional()
})
)
.max(PLUGIN_WORKSPACE_LIST_LIMIT),
@@ -43,6 +84,27 @@ const workspaceListResult = z.object({
truncated: z.boolean()
})
const projectListParams = z.strictObject({}).optional()
const projectListResult = z.object({
projects: z
.array(
z.object({
/** What `workspace.list` rows name as `projectId`. */
id: z.string().min(1).max(ID_MAX),
name: z.string().max(512),
/** `folder`: a plain folder, with no Git repository. */
kind: z.enum(['git', 'folder']),
/** The execution host; null for this machine on hosts that do not record one. */
hostId: z.string().max(ID_MAX).nullable(),
/** The path on the host that owns it, which may be WSL or an SSH machine. */
path: z.string().max(ID_MAX)
})
)
.max(PLUGIN_PROJECT_LIST_LIMIT),
/** More projects exist than the list holds. */
truncated: z.boolean()
})
const workspaceOpenParams = z.strictObject({ workspaceId })
const workspaceOpenResult = z.object({ opened: z.literal(true) })
@@ -149,7 +211,8 @@ const commandsRunResult = z.object({ ran: z.literal(true) })
export const PLUGIN_WORKSPACE_ACTIONS_API = {
'workspace.list': spec({
summary: 'Lists every workspace, Git worktrees and folders, on every host Orca reaches.',
summary:
"Lists every workspace, Git worktrees and folders, on every host Orca reaches; `includeStatus` (host API 1.7) adds each one's agent status.",
since: 3,
scope: 'any-workspace',
capability: 'workspace:read',
@@ -158,6 +221,17 @@ export const PLUGIN_WORKSPACE_ACTIONS_API = {
params: workspaceListParams,
result: workspaceListResult
}),
'project.list': spec({
summary:
'Lists your projects (repositories and folders) on every host; `id` is what `workspace.list` rows name as `projectId`.',
since: 7,
scope: 'any-workspace',
capability: 'workspace:read',
mutation: false,
surfaces: WORKER_ONLY,
params: projectListParams,
result: projectListResult
}),
'workspace.open': spec({
summary:
'Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app.',
+11 -26
View File
@@ -4,6 +4,10 @@ import * as stored from './plugin-host-api-storage-schemas'
import { PLUGIN_PANEL_BRIDGE_API } from './plugin-host-api-panel-methods'
import { PLUGIN_WORKER_ACTIONS_API } from './plugin-host-api-worker-actions'
import { PLUGIN_EDITOR_TYPOGRAPHY_API } from './plugin-host-api-editor-typography'
import {
PLUGIN_TERMINAL_ID_MAX_LENGTH,
pluginWorkspaceContextSchema
} from './plugin-host-api-workspace-context'
import {
ALL_SURFACES,
FRAMES_ONLY,
@@ -14,6 +18,12 @@ import {
} from './plugin-host-method-spec'
export type { PluginHostMethodSpec } from './plugin-host-method-spec'
export {
PLUGIN_TERMINAL_ID_MAX_LENGTH,
PLUGIN_WORKSPACE_LABEL_MAX_LENGTH,
PLUGIN_WORKSPACE_TERMINAL_LIMIT,
pluginWorkspaceContextSchema
} from './plugin-host-api-workspace-context'
/**
* Host API 1 (`pluginApi: 1`) — the separately-versioned public facade plugins call. Every
@@ -33,32 +43,7 @@ export type { PluginHostMethodSpec } from './plugin-host-method-spec'
*/
export const PANEL_ACTION_TEXT_MAX_LENGTH = 4096
export const PLUGIN_WORKSPACE_TERMINAL_LIMIT = 50
export const PLUGIN_WORKSPACE_LABEL_MAX_LENGTH = 512
export const PLUGIN_TERMINAL_ID_MAX_LENGTH = 1024
const workspaceReadContextParams = z.strictObject({}).optional()
/** What `workspace.readContext` answers and `workspace.changed` carries. */
export const pluginWorkspaceContextSchema = z
.object({
branch: z.string().max(PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
displayName: z.string().max(PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
/** Terminals of the focused workspace, so callers can address a specific
* terminal id — the API has no "active terminal" write target. */
terminals: z
.array(
z
.object({
id: z.string().min(1).max(PLUGIN_TERMINAL_ID_MAX_LENGTH),
/** The pane's name, or its terminal title; absent when it has neither. */
title: z.string().max(PLUGIN_WORKSPACE_LABEL_MAX_LENGTH).optional()
})
.strict()
)
.max(PLUGIN_WORKSPACE_TERMINAL_LIMIT)
})
.strict()
.nullable()
const terminalSendTextParams = z.strictObject({
/** Explicit target prevents delayed writes following focus changes into another pane. */
@@ -135,7 +120,7 @@ export function isPluginExternalUrl(url: string): boolean {
export const PLUGIN_HOST_API = {
'workspace.readContext': spec({
summary:
'Branch, name and terminal ids of the focused workspace, or null when none is focused.',
'Id, branch, name and terminal ids of the focused workspace, or null when none is focused.',
since: 0,
scope: 'active-workspace',
capability: 'workspace:read',