Plugin panels talk to their worker; panel storage, settings and agent events (#37013)

* feat(plugins): panels talk to their own worker, read storage, settings and events

- plugin.request (host API 1.2): a panel or editor asks its plugin's worker, which answers with
  orca.panels.handle(name); the worker pushes with orca.panels.post. Same plugin only, JSON
  checked, 1 MiB answers, 256 KiB/200-per-10s posts, a deadline and the panel's lifetime.
- Panels and editors get storage.*, settings.* and secrets.* behind the same capabilities, and
  storage/settings onDidChange on every surface; events.subscribe delivers workspace and agent
  events to panels that hold the read capability.
- contributes.settings: typed settings (a JSON Schema subset) with defaults and value checks.
- Frames get orca.plugin/storage/settings/secrets; components panels import plugin, storage,
  settings, secrets from 'orca', and atom(key, init, { persist: 'storage' }) shares a value with
  the worker. Workers get orca.panels/storage/settings/secrets sugar.
- One typed contract: declare module 'orca' { interface PluginRequests / PluginPanelEvents }.
- Desktop push channel per open panel session (plugins:openPanelPush).

* feat(plugins): Orca Settings shows and edits a plugin's contributes.settings

The plugin page lists declared settings with Orca's own controls (switch, picker, text and
number fields, Reset to default). Edits are checked against the declaration in main, written to
the plugin's settings, and reach open panels and the running worker through onDidChange.

* docs(plugins): hello-tsx talks to its worker; authoring guide covers panels, workers and data

The example now uses one of each: plugin.request answered by orca.panels.handle, a worker post
heard with plugin.on, a persist: 'storage' atom the worker watches, two contributes.settings and
agent.status.changed in the panel, all typed by one src/protocol.ts. The build-less TSX e2e
drives the whole loop.

* fix(plugins): typed bridge overloads, panel call budget, settings IPC and catalog keys

- PluginBridgeApi/PanelsApi use overloads: declared names are typed (and complete), any other
  name carries JSON; no never-unions for the type-aware lint.
- The per-plugin panel call budget is 120 per 10 s, since a panel loading storage atoms,
  settings and its first requests makes a burst; the hostile fixture floods past it.
- Settings IPC builds its host per call; the settings section's strings are in en.json.

* test(plugins): the example's declared settings show under the plugin with the person's value

* refactor(plugins): keep hello-tsx the minimal starter; the bridge demo is hello-worker-panel

hello-tsx goes back to a sandboxed-ui panel with no worker or capabilities. The worker, protocol,
storage atom, settings and events move to examples/plugins/hello-worker-panel, with their own
e2e (plugin-worker-panel) and PR route.

* feat(plugins): test kit covers the panel bridge and plugin data

mockHost keeps the plugin's storage, settings and secrets in memory (seeded with storage,
settings, secrets; read with host.data()) and fakes the worker with requests, so plugin.request,
storage/settings/secrets and persist: 'storage' atoms work under mount(), writes reach
onDidChange, and panel.post(event, payload) is what plugin.on hears. Typed by PluginRequests and
PluginPanelEvents.

* test(plugins): focusInGuest waits for the guest and retries focusing as one step

Under load the custom-editor guest could still be attaching, so executeJavaScript threw
'dom-ready' before the focus poll began. Wait for waitForGuestReady, then retry focusing the
webview and the element together, tolerating a guest that is still attaching or remounts.

* docs(plugins): the authoring skill covers panels, workers and data; persistence leaves the renderer

- skill-guides/orca-plugin-authoring.md gains Panels, workers and data (plugin.request/on,
  orca.panels.handle/post, persist: 'storage', contributes.settings, panel events, test kit), and
  its generated references read orca-services.d.ts for shell, files, on and the bridge types.
- After the dev-loop rebase, persisted-atom wiring (queue plus storage sync) moves to
  createDeclarativePersistence so the renderer stays under max-lines.

* feat(plugins): panels show agents: agents.list, terminal ids on agent events, workspace.changed

- agents.list (agent:read, every surface): the focused workspace's agents with terminalId,
  title, agent kind, state and stateStartedAt, from the hook server's store.
- agent.status.changed carries terminalId and title: paneKey mapped through the runtime's
  terminal list (tabId:leafId) or the status's terminal handle; looked up only while a plugin
  listens, in arrival order.
- workspace.readContext terminals carry title; workspace.changed (workspace:read) fires after
  workspace session writes when the focused workspace or its terminals change.
- Event subscriptions are kept per panel session, so one made before the push channel opens
  still counts.
- shell.openExternal never prompts on a hidden or minimized window: it settles
  { opened: false, reason: 'consent-unavailable' } and says so in the plugin log, which also
  notes 'waiting for the person to confirm' while a prompt is up; the rate budget is spent only
  when Orca acts. e2e launches never open a real browser or mail client.
- A worker may export activate as default or by name; docs, typings and the authoring skill
  document the entry, the agent events and a live agent status recipe.

* fix(plugins): the plugin page names the workspace.changed subscription

* fix(plugins): workers activate only by default export, and say so when it is named

Orca calls the worker module's default export. A module with a named `activate` and no default
now fails to start with an error naming the fix (`export default function activate(orca)`),
instead of being called by a fallback; docs, typings and the skill say default export.
hello-worker-panel gains a picker setting (suggestionOrder) the worker reads.
This commit is contained in:
Neil
2026-11-01 04:40:17 -08:00
committed by GitHub
parent 3a983d06fd
commit ec0d3cedd1
141 changed files with 6049 additions and 735 deletions
@@ -320,6 +320,7 @@ describe('electron-builder config', () => {
join('/app', 'resources', 'plugins', 'types', 'orca-host.d.ts'),
join('/app', 'resources', 'plugins', 'types', 'orca-frame.d.ts'),
join('/app', 'resources', 'plugins', 'types', 'orca-worker.d.ts'),
join('/app', 'resources', 'plugins', 'types', 'orca-services.d.ts'),
join('/app', 'resources', 'plugins', 'types', 'orca-testing.d.ts'),
// `orca plugin test` and `check` run these from a Node process.
join('/app', 'resources', 'plugins', 'declarative', 'orca-runtime.mjs'),
@@ -12,6 +12,8 @@ const GENERATED_NOTE =
'<!-- Generated by config/scripts/generate-plugin-authoring-skill-references.mjs. Do not edit. -->'
const TYPINGS = {
components: 'resources/plugins/declarative/orca.d.ts',
// Merges into module 'orca': shell, files, on, the worker bridge and plugin data.
services: 'resources/plugins/types/orca-services.d.ts',
frame: 'resources/plugins/types/orca-frame.d.ts',
worker: 'resources/plugins/types/orca-worker.d.ts'
}
@@ -39,6 +41,7 @@ async function readTypings(repoRoot) {
const read = (file) => readFile(path.join(repoRoot, ...file.split('/')), 'utf8')
return {
components: readTypingsModule(await read(TYPINGS.components), TYPINGS.components, 'orca'),
services: readTypingsModule(await read(TYPINGS.services), TYPINGS.services, 'orca'),
frame: readTypingsModule(await read(TYPINGS.frame), TYPINGS.frame, 'orca/frame'),
worker: readTypingsModule(await read(TYPINGS.worker), TYPINGS.worker, 'orca/worker')
}
@@ -67,7 +70,7 @@ function childrenLabel(policy, catalog) {
return labels[policy]
}
function renderComponents({ catalog, limits }, { components }) {
function renderComponents({ catalog, limits }, { components, services }) {
const vocabulary = ['Gap', 'Align', 'Tone', 'Size', 'Variant', 'Press'].map(
(name) => `- ${code(name)}: ${code(components.typeText(name))}`
)
@@ -82,13 +85,30 @@ function renderComponents({ catalog, limits }, { components }) {
return `- ${code(name)} (children: ${children}): ${props.length > 0 ? code(props.join('; ')) : 'no props'}`
})
const runtime = [
...components.functions.map((fn) => ({ ...fn, text: fn.text.replace(/^function /u, '') })),
...['host', 'api', 'env', 'shell', 'files', 'ui'].flatMap((name) => components.value(name))
...[...components.functions, ...services.functions].map((fn) => ({
...fn,
text: fn.text.replace(/^function /u, '')
})),
...['host', 'api', 'env', 'ui'].flatMap((name) => components.value(name)),
...['shell', 'files', 'plugin', 'storage', 'settings', 'secrets'].flatMap((name) =>
services.value(name)
)
].map(bullet)
const types = ['PersistScope', 'AtomOptions', 'PanelContext', 'PanelSetup', 'HostError'].map(
(name) =>
`- ${withSummary(code(components.typeDeclaration(name)), components.typeSummary(name))}`
)
const typeLine = (module, name) =>
`- ${withSummary(code(module.typeDeclaration(name)), module.typeSummary(name))}`
const types = [
...['PersistScope', 'AtomOptions', 'PanelContext', 'PanelSetup', 'HostError'].map((name) =>
typeLine(components, name)
),
...[
'PluginRequestOptions',
'PluginBridgeApi',
'DataChange',
'StorageApi',
'SettingsApi',
'SecretsApi'
].map((name) => typeLine(services, name))
]
const limit = limits.PLUGIN_DECLARATIVE_LIMITS
return document(
'Components panel API',
@@ -101,6 +121,12 @@ function renderComponents({ catalog, limits }, { components }) {
[`- ${code('IconName')}: ${icons.join(', ')}. Any other name draws a fallback glyph.`, ''],
['## Components', '', ...entries, ''],
['## Runtime', '', ...runtime, ''],
[
'## Panel ↔ worker contract',
'',
"Declare requests and worker posts once with declaration merging, in a file both the panel and the worker include: `declare module 'orca' { interface PluginRequests { 'todos.list': { input: { done?: boolean }; output: Todo[] } } interface PluginPanelEvents { 'todos.changed': { count: number } } }`. Declared names are typed end to end (`plugin.request`, `plugin.on`, the worker's `orca.panels.handle` and `orca.panels.post`, and `orca/testing`'s `requests` and `panel.post`); any other name carries JSON.",
''
],
['## Types', '', ...types, ''],
[
'## Limits',
@@ -190,7 +216,7 @@ function renderHostApi(sources, { frame, worker }) {
)
}
function renderEvents({ events, eventContribution, typeText }, { components, frame }) {
function renderEvents({ events, eventContribution, typeText }, { services, frame }) {
const { REFERENCE_TYPE_TEXT, zodTypeText } = typeText
const workerEvents = eventContribution.PLUGIN_EVENT_NAMES.map(
(name) =>
@@ -217,7 +243,8 @@ function renderEvents({ events, eventContribution, typeText }, { components, fra
[
'## Components panel events (`on(event, handler)` from `orca`)',
'',
...components.literalMembers('OrcaEvents', { flatten: false }).map(bullet)
...services.literalMembers('OrcaEvents', { flatten: false }).map(bullet),
'- Every worker event above, with the same payload and capability; a panel needs no `contributes.events` entry, it subscribes while it is open. HTML panels get them on `orca.on` too.'
]
]
)
@@ -78,6 +78,9 @@ describe('plugin PR E2E routing', () => {
['src/main/plugins/plugin-dev-watcher.ts', 'plugin-components-hot-reload'],
['resources/plugins/declarative/orca-runtime.mjs', 'plugin-components-hot-reload'],
['examples/plugins/hello-orca/main.mjs', 'plugin-demo'],
['examples/plugins/hello-worker-panel/worker/main.mjs', 'plugin-worker-panel'],
['src/main/plugins/panels/plugin-panel-bridge.ts', 'plugin-worker-panel'],
['src/main/plugins/plugin-runtime-binding.ts', 'plugin-panel-agents'],
['examples/plugins/test-fixtures/hostile-panel/panel.html', 'plugin-panel-containment']
])('routes %s to %s', (file, spec) => {
expect(selectPrE2eSpecs([file])).toContain(`tests/e2e/${spec}.spec.ts`)
@@ -170,6 +170,31 @@ const SPECIFIC_PLUGIN_ROUTES = [
].join('|'),
'plugin-frame-guest|plugin-smoke'
),
// Agents and workspace changes in panels: agents.list, terminal ids on agent events.
pluginRoute(
'plugins.panel-agents',
['plugin-panel-agents'],
[
'src\\/main\\/plugins\\/(?:plugin-runtime-binding|plugin-agent-status-event|workers\\/plugin-workspace-projection)',
'src\\/shared\\/plugins\\/events\\/plugin-events'
].join('|'),
'plugin-frame-guest|plugin-smoke|agent-hook-endpoint'
),
// A panel and its own worker: requests, posts, storage, settings and pushes.
pluginRoute(
'plugins.worker-panel',
['plugin-worker-panel'],
[
'src\\/main\\/plugins\\/panels\\/plugin-panel-bridge',
'src\\/main\\/plugins\\/(?:plugin-declared-settings|workers\\/plugin-(?:worker-orca-api|host-panel-method-bindings|worker-requests))',
'src\\/main\\/ipc\\/plugin-(?:panel-push|settings)-ipc',
'src\\/shared\\/plugins\\/(?:workers\\/plugin-panel-bridge-protocol|frames\\/plugin-panel-shell-facades|contributions\\/plugin-settings-)',
'src\\/shared\\/plugins\\/declarative\\/runtime\\/declarative-(?:data-sdk|storage-sync)',
'src\\/renderer\\/src\\/components\\/plugins\\/(?:page\\/PluginSettingsSection|state\\/use-plugin-declared-settings)',
'examples\\/plugins\\/hello-worker-panel\\/'
].join('|'),
'plugin-frame-guest|plugin-smoke'
),
pluginRoute(
'plugins.vm-recipes',
['plugin-marketplace-content'],
+131 -9
View File
@@ -12,12 +12,13 @@ A plugin is a folder (in practice its own Git repository) with `orca-plugin.json
Start from one of these, or scaffold one with `orca plugin create` (see [Validate](#3-validate)).
Add the `$schema` line from `orca-gleam-syntax` to your manifest for editor completion.
| Example | Execution | Shows |
| ------------------------------------ | ---------------- | ---------------------------------------------------------------------------- |
| `examples/plugins/orca-gleam-syntax` | `data` | A syntax, no code |
| `examples/plugins/hello-tsx` | `sandboxed-ui` | A [components](plugin-declarative-ui.md) TSX panel with no build step |
| `examples/plugins/hello-components` | `sandboxed-ui` | The same kind of [components](plugin-declarative-ui.md) panel, bundled first |
| `examples/plugins/hello-orca` | `trusted-worker` | A panel, a worker command and events |
| Example | Execution | Shows |
| ------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `examples/plugins/orca-gleam-syntax` | `data` | A syntax, no code |
| `examples/plugins/hello-tsx` | `sandboxed-ui` | A [components](plugin-declarative-ui.md) TSX panel with no build step |
| `examples/plugins/hello-worker-panel` | `trusted-worker` | A TSX panel with its own worker, storage atom, settings and events ([Panels, workers and data](#panels-workers-and-data)) |
| `examples/plugins/hello-components` | `sandboxed-ui` | The same kind of [components](plugin-declarative-ui.md) panel, bundled first |
| `examples/plugins/hello-orca` | `trusted-worker` | A panel, a worker command and events |
Leave `publisher` out. Orca names the publisher after where the plugin comes from, the way a
[Go module path](https://go.dev/ref/mod#module-path) is its repository path: the owner of the
@@ -68,7 +69,7 @@ Before any plugin code runs, the frame shell defines a frozen `window.orca`. It
supported way to talk to Orca; the messages underneath are internal and change without notice.
```js
orca.api // { major: 1, minor: 1 }: the host API this Orca implements
orca.api // { major: 1, minor: 2 }: the host API this Orca implements
if (orca.host.has('workspace.readContext')) {
// Synchronous: is the method offered to this surface by this Orca?
const controller = new AbortController()
@@ -132,10 +133,28 @@ Browser clients (`orca serve` in a web browser) still load a frame as one sandbo
with an opaque origin and `connect-src 'none'`: inline your scripts and styles, use `data:` URLs
for images and fonts, and do not rely on storage, workers or WASM loaded by URL there.
A worker's `main` module's **default export** is `activate(orca)`, which Orca calls once when the
worker starts; it may be `async`. A named `export function activate` is not called: the worker
fails to start with an error saying so.
An optional `export function deactivate()` runs (and is awaited) before the worker stops, for
flushing state; the worker may also stop when it has been idle for a while, and starts again on
its next command, event or panel request.
```ts
import type { OrcaWorkerApi } from 'orca/worker'
export default async function activate(orca: OrcaWorkerApi) {
orca.commands.register('hello', () => 'Hello from the worker')
}
export async function deactivate() {}
```
Workers get the same calls on the `orca` object passed to `activate(orca)`:
`orca.api`, `orca.host.call(method, params, { signal })`, `orca.host.has(method)`, plus
`orca.commands.register(id, (args, { signal }) => …)` and `orca.events.on(event, handler)`, which
both return `{ dispose() }`. A command's `signal` aborts when Orca stops waiting for it.
both return `{ dispose() }`, and the panel and data APIs in
[Panels, workers and data](#panels-workers-and-data). A command's `signal` aborts when Orca stops waiting for it.
`orca.log(message)` writes to `orca plugin logs`. `orca.grantedCapabilities` is the frozen list of
capability kinds the user approved (such as `['storage:own']`), fixed when the worker starts; the
host still checks each call. In `agent.status.changed`, `state` and `mainAgent.state` are one of `working`, `blocked`, `waiting`
@@ -150,6 +169,110 @@ Everything else, such as `node:` builtins, resolves exactly as Node resolves it
worker. `orca/worker` holds only types, so import it with `import type`; a value import fails when
the worker starts.
### Panels, workers and data
A plugin's panels, custom editors and worker are one program. A panel asks its own worker, the
worker pushes back, and both read the same storage, settings and secrets (with the same
capabilities). Every surface spells it the same way: frames on `window.orca`, components panels
as imports from `orca`, workers on the `orca` from `activate`.
Declare the contract once, in a file both sides include, and both type-check against it:
```ts
// src/protocol.ts
export {}
declare module 'orca' {
interface PluginRequests {
'todos.list': { input: { done?: boolean }; output: Todo[] }
}
interface PluginPanelEvents {
'todos.changed': { count: number }
}
}
```
The worker answers and posts:
```js
export default function activate(orca) {
orca.panels.handle('todos.list', async ({ done }, { panel, signal }) => list(done))
// To every open panel of this plugin, or one: { panel: id } from a request's context.
orca.panels.post('todos.changed', { count: 3 })
}
```
A panel asks and listens (in a frame, the same methods are on `orca.plugin`):
```ts
import { plugin } from 'orca'
const todos = await plugin.request('todos.list', { done: false }, { timeoutMs: 5000 })
plugin.on('todos.changed', ({ count }) => {})
```
- `plugin.request` starts the worker if it is not running, so a panel-only plugin can keep its
logic in a worker that runs only while someone uses it. It needs `main` (`trusted-worker`).
- It rejects with `unknown_method` when no handler has that name, `timeout` past `timeoutMs`
(default 30 s, at most 120 s; the handler's `signal` aborts then, and when the panel closes),
`unavailable` when the worker cannot start, or the `code` the handler threw (`invalid_params`,
`conflict` and the other host error codes; anything else is `action_failed`).
- Inputs and answers are JSON: an input up to 64 KiB, an answer up to 1 MiB. Posts are fire and
forget, up to 256 KiB each and 200 per 10 seconds per plugin.
- Only the same plugin's panels and worker can reach each other.
Storage, settings and secrets are the plugin's own, shared by every surface:
```ts
import { atom, storage, settings, secrets } from 'orca'
await storage.set('filters', { done: false }) // storage:own
storage.onDidChange(({ key }) => {}) // any surface's write, the person's included
const url = await settings.get('apiUrl') // settings:own; declared ones read as their default
await secrets.set('token', value) // secrets:own
// A components atom kept in storage: the worker reads storage.get('todos') and its writes
// update the atom.
const todos = atom('todos', [], { persist: 'storage' })
```
Declare settings the person edits in `contributes.settings`; Orca shows them under the plugin in
its Settings and checks every value against the declaration:
```json
"settings": [
{ "key": "apiUrl", "title": "Server URL", "type": "string", "default": "https://example.com" },
{ "key": "pageSize", "title": "Page size", "type": "integer", "default": 20, "minimum": 1 }
],
"capabilities": [{ "kind": "settings:own" }]
```
`type` is `string`, `number`, `integer` or `boolean`; `enum` (with `enumLabels`), `minimum`,
`maximum`, `maxLength`, `multiline` and `placeholder` shape the control. A setting needs the
`settings:own` capability, which is also how code reads it.
Panels hear workspace and agent events the same way workers do, with the same capability:
`on('agent.status.changed', handler)` needs `agent:read`, the workspace events `workspace:read`.
A panel needs no `contributes.events` entry for that; it subscribes while it is open.
To show the workspace's agents, list them once with `agents.list` (`agent:read`), then follow
`agent.status.changed`. Both name each agent's terminal with `terminalId`, the id
`workspace.readContext` lists (with each terminal's `title`) and `terminal.sendText` takes; an
event's `paneKey` is Orca's own pane id. `workspace.changed` (`workspace:read`) carries the new
`workspace.readContext` answer whenever focus moves to another workspace or its terminals open,
close or are renamed, so a panel never polls.
```ts
import { atom, host, on, read, set, update } from 'orca'
const agents = atom<{ terminalId: string | null; title: string | null; state: string }[]>([])
export async function setup() {
set(agents, (await host.call('agents.list')).agents)
on('agent.status.changed', ({ terminalId, title, state }) =>
update(agents, (list) => [
{ terminalId: terminalId ?? null, title: title ?? null, state },
...list.filter((agent) => agent.terminalId !== terminalId)
])
)
on('workspace.changed', async () => set(agents, (await host.call('agents.list')).agents))
}
```
### Custom editors
A custom editor opens files that end in one of its `extensions` (with the dot, such as
@@ -376,7 +499,6 @@ arbitrary plugin UI:
- `mcpServers`: MCP server definitions offered to the agents Orca launches.
- `skills`: agent skills shipped as files.
- `hooks`: agent lifecycle hooks for the agents Orca launches.
- `settings`: typed plugin settings shown in Orca's settings UI.
`skills`, `mcpServers` and `hooks` will mean what they mean in agent-plugin formats such as
Claude Code's. Orca does not reserve `agents`, because those formats use it for subagent files.
+1 -1
View File
@@ -108,7 +108,7 @@ One thing the release adds to Orca.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `kind` | `"panels"` \| `"commands"` \| `"events"` \| `"languagePacks"` \| `"keybindings"` \| `"vmRecipes"` \| `"themes"` \| `"terminalThemes"` \| `"iconThemes"` \| `"syntaxes"` \| `"customEditors"` \| `"newFiles"` | yes | 1.4.245 | Contribution point. Example: `"themes"` |
| `kind` | `"panels"` \| `"commands"` \| `"events"` \| `"languagePacks"` \| `"keybindings"` \| `"vmRecipes"` \| `"themes"` \| `"terminalThemes"` \| `"iconThemes"` \| `"syntaxes"` \| `"customEditors"` \| `"newFiles"` \| `"settings"` | yes | 1.4.245 | Contribution point. Example: `"themes"` |
| `name` | string | yes | 1.4.245 | What the item is called in Orca. Example: `"Dusk"` |
| `detail` | string | no | 1.4.245 | Extra context, such as file extensions. |
+21 -5
View File
@@ -61,6 +61,10 @@ export default function Panel() {
first render, are saved shortly after they change, and live in the plugin's own data folder; no
capability is needed. When the stored shape changes, bump `version`; `migrate(stored, fromVersion)` turns
an older value into the new shape, and without it the atom starts from `initial`.
- `persist: 'storage'` keeps the atom in the plugin's own storage under its key (the `storage:own`
capability): the worker reads the same value with `orca.storage.get(key)`, and a write from the
worker or another panel updates the atom. Values are stored as they are, so `version` and
`migrate` do not apply; atoms the module creates on load have their value before the first render.
- `derived(() => ...)` computes from other atoms; it is read-only.
- `read(atom)` reads; reading while rendering subscribes the panel to it.
- `set(atom, value)` stores a value as is, functions included; `update(atom, (previous) => next)` derives
@@ -107,6 +111,10 @@ The `orca` module has the same facade as frame panels:
are not events and `on()` refuses them.
- `ui.catalog` is a number that grows with the component catalog; `ui.has(component, prop?)` says
whether this Orca draws a component or accepts a prop on it.
- `plugin.request(name, input)` and `plugin.on(event)` talk to the plugin's own worker;
`storage`, `settings` and `secrets` are the plugin's own data; `on('agent.status.changed')` and
the workspace events arrive with their read capability. See
[Panels, workers and data](./plugin-authoring.md#panels-workers-and-data).
## Hot reload
@@ -207,14 +215,22 @@ test('adds a task', async () => {
same edit protocol as Orca.
- Inspect `tree()` (plain data for snapshots, handlers shown as `[handler]`), `errors` (what the panel
threw, with its stack, and renders Orca would refuse), `warnings` (props and components Orca would
drop) and `persisted`. `emit('theme', ...)`, `setVisible`, `rerender` and `settled` drive the rest;
`unmount()` ends the run, running `setup` cleanup.
drop) and `persisted` (`plugin`, `workspace`, and the plugin's `storage`). `emit(event, payload)`
delivers `theme` or a workspace or agent event, `post(event, payload)` is the worker's
`orca.panels.post` (what `plugin.on` hears), and `setVisible`, `rerender` and `settled` drive the
rest; `unmount()` ends the run, running `setup` cleanup.
- `mockHost({ capabilities, results })` stands in for Orca's host. It refuses what Orca refuses, with
the same error codes: a method panels cannot call, a capability the manifest does not declare
(capabilities default to the manifest's), bad params, and `shell.openExternal` or `files.saveAs`
without a press first (each `press` grants one). `results` maps a method to a value or to a function
of its params, which may throw `hostError(code)`; `calls` and `callsOf(method)` record every call.
The stationary-control rule for gestures is not simulated.
- The mock host keeps the plugin's storage, settings and secrets in memory (seed them with
`mockHost({ storage, settings, secrets })`, read them with `host.data()`), so `storage`,
`settings`, `secrets` and `persist: 'storage'` atoms work under `mount()`, and a write reaches
`onDidChange` as in Orca. `requests` stands in for the worker: each `plugin.request` name maps to
its answer or a function of the input (which may throw `hostError(code)`); an unlisted name is
`unknown_method`. Both follow your `PluginRequests` types.
`orca plugin check [dir]` validates the plugin like `orca plugin validate`, then renders every
components panel once against `mockHost()` and reports what the panel threw (with `file:line`),
@@ -314,7 +330,7 @@ without granting a second gesture.
## Isolation
The module runs in a hidden, sandboxed guest of the plugin's own partition, with the same isolation as frame
panels: no network (requests outside the plugin's own files are cancelled; Electron 43 still resolves
`dns-prefetch` hostnames), no Node, no popups, no DOM the person sees. It reaches Orca only through
`host.call`, `on`, and the UI it renders. Components panels need the desktop app: browser clients
panels: no network (requests outside the plugin's own files are cancelled), no Node, no popups, no DOM
the person sees. It reaches Orca only through `host.call` (which `plugin`, `storage`, `settings` and
`secrets` use), `on`, and the UI it renders. Components panels need the desktop app: browser clients
(`orca serve` in a web browser) show that instead of the panel.
+42 -14
View File
@@ -2,7 +2,7 @@
<!-- Generated by `pnpm generate:plugin-schemas` from src/shared/plugins. Do not edit. -->
Host API 1.1 (`"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.2 (`"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>`.
@@ -15,25 +15,28 @@ Methods whose capability is `none` need no capability but still need the plugin
| [`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. |
| [`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 | no | 1.0 | Reads a JSON value from the plugin's private store; null when absent. |
| [`storage.set`](#storageset) | `storage:own` | worker | yes | 1.0 | Writes a JSON value to the plugin's private store. |
| [`storage.delete`](#storagedelete) | `storage:own` | worker | yes | 1.0 | Removes a key from the plugin's private store. |
| [`storage.keys`](#storagekeys) | `storage:own` | worker | no | 1.0 | Lists the keys in the plugin's private store. |
| [`secrets.get`](#secretsget) | `secrets:own` | worker | no | 1.0 | Reads a secret from the plugin's encrypted secrets file; null when absent. |
| [`secrets.set`](#secretsset) | `secrets:own` | worker | yes | 1.0 | Stores a secret in the plugin's encrypted secrets file. |
| [`secrets.delete`](#secretsdelete) | `secrets:own` | worker | yes | 1.0 | Removes a secret from the plugin's encrypted secrets file. |
| [`secrets.protection`](#secretsprotection) | `secrets:own` | worker | no | 1.1 | How secrets are protected at rest: `os-keyring`, or `unavailable` (no OS key store, or Linux `basic_text`), where `secrets.set` fails. |
| [`settings.get`](#settingsget) | `settings:own` | worker | no | 1.0 | Reads every setting the plugin owns. |
| [`settings.set`](#settingsset) | `settings:own` | worker | yes | 1.0 | Writes one setting the plugin owns. |
| [`storage.get`](#storageget) | `storage:own` | worker, panel, editor | no | 1.0 | Reads a JSON value from the plugin's private store; null when absent. |
| [`storage.set`](#storageset) | `storage:own` | worker, panel, editor | yes | 1.0 | Writes a JSON value to the plugin's private store. |
| [`storage.delete`](#storagedelete) | `storage:own` | worker, panel, editor | yes | 1.0 | Removes a key from the plugin's private store. |
| [`storage.keys`](#storagekeys) | `storage:own` | worker, panel, editor | no | 1.0 | Lists the keys in the plugin's private store. |
| [`secrets.get`](#secretsget) | `secrets:own` | worker, panel, editor | no | 1.0 | Reads a secret from the plugin's encrypted secrets file; null when absent. |
| [`secrets.set`](#secretsset) | `secrets:own` | worker, panel, editor | yes | 1.0 | Stores a secret in the plugin's encrypted secrets file. |
| [`secrets.delete`](#secretsdelete) | `secrets:own` | worker, panel, editor | yes | 1.0 | Removes a secret from the plugin's encrypted secrets file. |
| [`secrets.protection`](#secretsprotection) | `secrets:own` | worker, panel, editor | no | 1.1 | How secrets are protected at rest: `os-keyring`, or `unavailable` (no OS key store, or Linux `basic_text`), where `secrets.set` fails. |
| [`settings.get`](#settingsget) | `settings:own` | worker, panel, editor | no | 1.0 | Reads every setting the plugin owns. |
| [`settings.set`](#settingsset) | `settings:own` | worker, panel, editor | yes | 1.0 | Writes one setting the plugin owns. |
| [`shell.openExternal`](#shellopenexternal) | none | panel, editor | no | 1.1 | Opens an http:, https: or mailto: URL in the default browser. Needs a click or key press in the frame; desktop app only. Orca asks the person before an http(s) host the plugin was not allowed; `opened` is false if they cancel. |
| [`files.saveAs`](#filessaveas) | none | panel, editor | yes | 1.1 | Offers data (a string or ArrayBuffer, up to 64 MiB) in Orca's save dialog; the plugin never learns the path. Needs a click or key press in the frame; desktop app only. |
| [`plugin.request`](#pluginrequest) | none | panel, editor | no | 1.2 | Asks the plugin's own worker to run the handler it registered with `orca.panels.handle(name)`, starting the worker if needed; resolves with what the handler returned. `unknown_method` when no handler has that name. |
| [`agents.list`](#agentslist) | `agent:read` | worker, panel, editor | no | 1.2 | The agents in the focused workspace: their terminal, title, agent kind and status. `agent.status.changed` reports each change after. |
| [`events.subscribe`](#eventssubscribe) | none | panel, editor | no | 1.2 | Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the event's read capability. The frame APIs call it for you. |
### `workspace.readContext`
Branch, name and terminal ids of the focused workspace, or null when none is focused.
```ts
(params?: {}) => { branch: string; displayName: string; terminals: { id: string }[] } | null
(params?: {}) => { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null
```
### `terminal.sendText`
@@ -137,7 +140,7 @@ Writes one setting the plugin owns.
Opens an http:, https: or mailto: URL in the default browser. Needs a click or key press in the frame; desktop app only. Orca asks the person before an http(s) host the plugin was not allowed; `opened` is false if they cancel.
```ts
(params: { url: string }) => { opened: boolean }
(params: { url: string }) => { opened: boolean; reason?: "cancelled" | "consent-unavailable" }
```
### `files.saveAs`
@@ -148,6 +151,30 @@ Offers data (a string or ArrayBuffer, up to 64 MiB) in Orca's save dialog; the p
(params: { suggestedName?: string; mimeType?: string; data: string | ArrayBuffer }) => { status: "saved" | "cancelled" }
```
### `plugin.request`
Asks the plugin's own worker to run the handler it registered with `orca.panels.handle(name)`, starting the worker if needed; resolves with what the handler returned. `unknown_method` when no handler has that name.
```ts
(params: { name: string; input?: JSON; timeoutMs?: number }) => { output: JSON }
```
### `agents.list`
The agents in the focused workspace: their terminal, title, agent kind and status. `agent.status.changed` reports each change after.
```ts
(params?: {}) => { agents: { terminalId: string | null; title: string | null; agent: string | null; state: "working" | "blocked" | "waiting" | "done"; stateStartedAt: number }[] }
```
### `events.subscribe`
Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the event's read capability. The frame APIs call it for you.
```ts
(params: { event: "workspace.created" | "workspace.removed" | "agent.status.changed" | "workspace.changed" }) => { ok: true }
```
## Events
A worker receives the events its manifest lists in `contributes.events` through `orca.events.on(event, handler)`, which returns `{ dispose() }`. Each needs `main`, the `trusted-worker` profile and its domain's capability (`workspace:read` or `agent:read`). `orca plugin types` writes the payloads as `PluginEvents` in `orca/host`.
@@ -156,7 +183,8 @@ A worker receives the events its manifest lists in `contributes.events` through
| --- | --- | --- |
| `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; 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. |
| `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. |
## Errors
+23 -5
View File
@@ -32,7 +32,7 @@ Add `"$schema": "https://raw.githubusercontent.com/stablyai/orca/main/docs/schem
| `contributes` | object | no | 1.4.160 | What the plugin adds to Orca. Every point defaults to empty. |
| `contributes.panels` | [PanelContribution](#panelcontribution)[] | no | 1.4.160 | Sidebar panels. At most 64. |
| `contributes.commands` | [CommandContribution](#commandcontribution)[] | no | 1.4.160 | Command palette entries. At most 256. |
| `contributes.events` | [EventContribution](#eventcontribution)[] | no | 1.4.160 | Events delivered to the plugin worker. At most 3. |
| `contributes.events` | [EventContribution](#eventcontribution)[] | no | 1.4.160 | Events delivered to the plugin worker. At most 4. |
| `contributes.languagePacks` | [LanguagePackContribution](#languagepackcontribution)[] | no | 1.4.160 | Interface translations. At most 16. |
| `contributes.keybindings` | [KeybindingContribution](#keybindingcontribution)[] | no | 1.4.160 | Keyboard shortcuts for contributed commands. At most 256. |
| `contributes.vmRecipes` | [VmRecipeContribution](#vmrecipecontribution)[] | no | 1.4.160 | Virtual machine environment recipes. At most 64. |
@@ -42,13 +42,13 @@ Add `"$schema": "https://raw.githubusercontent.com/stablyai/orca/main/docs/schem
| `contributes.syntaxes` | [SyntaxContribution](#syntaxcontribution)[] | no | 1.4.245 | Syntax highlighting for editor languages. At most 16. |
| `contributes.customEditors` | [CustomEditorContribution](#customeditorcontribution)[] | no | 1.4.245 | Editors that open files of given types in the editor tab. At most 16. |
| `contributes.newFiles` | [NewFileContribution](#newfilecontribution)[] | no | 1.4.245 | Untitled files the user can create from the + menu and the command palette. At most 16. |
| `contributes.settings` | [SettingContribution](#settingcontribution)[] | no | 1.4.246 | Settings Orca shows under the plugin in Settings; code reads them with `settings.get` (`settings:own`). At most 64. |
| `contributes.agentHarnesses` | any[] | no | 1.4.245 | Reserved for coding-agent harness definitions; must be empty until their format is defined. |
| `contributes.skills` | any[] | no | 1.4.245 | Reserved for agent skills; must be empty until their format is defined. |
| `contributes.mcpServers` | any[] | no | 1.4.245 | Reserved for MCP server definitions; must be empty until their format is defined. |
| `contributes.hooks` | any[] | no | 1.4.245 | Reserved for agent hooks; must be empty until their format is defined. |
| `contributes.taskSources` | any[] | no | 1.4.245 | Reserved for task sources; must be empty until their format is defined. |
| `contributes.connections` | any[] | no | 1.4.245 | Reserved for account connections; must be empty until their format is defined. |
| `contributes.settings` | any[] | no | 1.4.245 | Reserved for plugin settings; must be empty until their format is defined. |
| `contributes.statusBarItems` | any[] | no | 1.4.245 | Reserved for status bar items; must be empty until their format is defined. |
| `contributes.sidebarCards` | any[] | no | 1.4.245 | Reserved for sidebar cards; must be empty until their format is defined. |
| `capabilities` | [Capability](#capability)[] | no | 1.4.160 | Host APIs the plugin asks the user to grant. At most 32. |
@@ -87,7 +87,7 @@ An event subscription. Requires `main`, the `trusted-worker` execution profile a
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `on` | `"workspace.created"` \| `"workspace.removed"` \| `"agent.status.changed"` | yes | 1.4.160 | Event delivered to the plugin worker. |
| `on` | `"workspace.created"` \| `"workspace.removed"` \| `"agent.status.changed"` \| `"workspace.changed"` | yes | 1.4.160 | Event delivered to the plugin worker. |
## LanguagePackContribution
@@ -168,6 +168,25 @@ A kind of untitled file the user can create from the tab bar + menu or the comma
| `template` | string | no | 1.4.245 | UTF-8 file copied into the new file, at most 256 KB. Without one the file starts empty. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"templates/empty.excalidraw"` |
| `icon` | string | no | 1.4.245 | Square image shown beside the menu item. Defaults to the plugin icon. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"images/editor-icon.png"` |
## SettingContribution
A setting shown under the plugin in Settings. Requires the `settings:own` capability, which is also how code reads it.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `key` | string | yes | 1.4.246 | Key code reads and writes, unique in this plugin. Example: `"apiUrl"` |
| `title` | string | yes | 1.4.246 | Label shown in Settings. Example: `"Server URL"` |
| `description` | string | no | 1.4.246 | Help text shown under the label. |
| `type` | `"string"` \| `"number"` \| `"integer"` \| `"boolean"` | yes | 1.4.246 | JSON Schema type of the value. Example: `"string"` |
| `default` | string \| number \| boolean | no | 1.4.246 | Value read while the person has not set one. |
| `enum` | (string \| number)[] | no | 1.4.246 | Allowed values; Settings shows a picker. |
| `enumLabels` | string[] | no | 1.4.246 | Picker labels, one per `enum` value. |
| `minimum` | number | no | 1.4.246 | Numbers: smallest value. |
| `maximum` | number | no | 1.4.246 | Numbers: largest value. |
| `maxLength` | integer | no | 1.4.246 | Strings: most characters. |
| `multiline` | boolean | no | 1.4.246 | Strings: a multi-line text box. |
| `placeholder` | string | no | 1.4.246 | Strings and numbers: hint shown while empty. |
## Capability
A host API the plugin may call once the user consents.
@@ -195,7 +214,7 @@ One screenshot of the plugin in use.
- **`terminal:send` needs `trusted-worker`.** Panels get no host method that types into a terminal, so Orca refuses the capability on any other profile instead of asking the user to grant something the plugin can never use.
- **New fields, contribution points and values are additive.** They arrive with a `Since` version; nothing else changes. `manifestVersion` rises only if an existing field changes meaning.
- **Deprecation.** A field to be removed is marked deprecated here and keeps working for at least one release; it is removed only together with a higher `engines.orca` requirement, and its name is never reused.
- **Reserved contribution points.** `contributes.agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `settings`, `statusBarItems` and `sidebarCards` are held for planned features and must stay empty until their format is defined. Each will arrive as an additive point with its own `Since`, without a new manifest format. `skills`, `mcpServers` and `hooks` keep the meaning agent-plugin formats such as Claude Code's give them; `agents` stays unclaimed because those formats use it for subagent files.
- **Reserved contribution points.** `contributes.agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `statusBarItems` and `sidebarCards` are held for planned features and must stay empty until their format is defined. Each will arrive as an additive point with its own `Since`, without a new manifest format. `skills`, `mcpServers` and `hooks` keep the meaning agent-plugin formats such as Claude Code's give them; `agents` stays unclaimed because those formats use it for subagent files.
## Planned contribution points
@@ -209,7 +228,6 @@ Each reserved point is planned as declared data that Orca renders and enforces,
- `mcpServers`: MCP server definitions that Orca offers to the agents it launches.
- `skills`: Agent skills shipped as files that Orca installs for the agents it launches.
- `hooks`: Agent lifecycle hooks, such as before and after a tool call, for the agents Orca launches.
- `settings`: Typed plugin settings that Orca renders in its own settings UI.
## Legacy format 1
@@ -562,7 +562,8 @@
"iconThemes",
"syntaxes",
"customEditors",
"newFiles"
"newFiles",
"settings"
],
"description": "Contribution point.",
"x-orca-since": "1.4.245",
+140 -11
View File
@@ -208,9 +208,9 @@
},
"events": {
"default": [],
"description": "Events delivered to the plugin worker. At most 3.",
"description": "Events delivered to the plugin worker. At most 4.",
"x-orca-since": "1.4.160",
"maxItems": 3,
"maxItems": 4,
"type": "array",
"items": {
"$ref": "#/$defs/EventContribution"
@@ -305,6 +305,16 @@
"$ref": "#/$defs/NewFileContribution"
}
},
"settings": {
"default": [],
"description": "Settings Orca shows under the plugin in Settings; code reads them with `settings.get` (`settings:own`). At most 64.",
"x-orca-since": "1.4.246",
"maxItems": 64,
"type": "array",
"items": {
"$ref": "#/$defs/SettingContribution"
}
},
"agentHarnesses": {
"description": "Reserved for coding-agent harness definitions; must be empty until their format is defined.",
"x-orca-since": "1.4.245",
@@ -347,13 +357,6 @@
"type": "array",
"items": {}
},
"settings": {
"description": "Reserved for plugin settings; must be empty until their format is defined.",
"x-orca-since": "1.4.245",
"maxItems": 0,
"type": "array",
"items": {}
},
"statusBarItems": {
"description": "Reserved for status bar items; must be empty until their format is defined.",
"x-orca-since": "1.4.245",
@@ -688,14 +691,16 @@
"enum": [
"workspace.created",
"workspace.removed",
"agent.status.changed"
"agent.status.changed",
"workspace.changed"
],
"description": "Event delivered to the plugin worker.",
"x-orca-since": "1.4.160",
"x-orca-enum-since": {
"workspace.created": "1.4.245",
"workspace.removed": "1.4.245",
"agent.status.changed": "1.4.160"
"agent.status.changed": "1.4.160",
"workspace.changed": "1.4.246"
}
}
},
@@ -1131,6 +1136,130 @@
"additionalProperties": false,
"description": "A kind of untitled file the user can create from the tab bar + menu or the command palette, in the active workspace."
},
"SettingContribution": {
"type": "object",
"properties": {
"key": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_.-]{0,63}$",
"description": "Key code reads and writes, unique in this plugin.",
"x-orca-since": "1.4.246",
"examples": [
"apiUrl"
]
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 120,
"description": "Label shown in Settings.",
"x-orca-since": "1.4.246",
"examples": [
"Server URL"
]
},
"description": {
"description": "Help text shown under the label.",
"x-orca-since": "1.4.246",
"type": "string",
"maxLength": 1000
},
"type": {
"type": "string",
"enum": [
"string",
"number",
"integer",
"boolean"
],
"description": "JSON Schema type of the value.",
"x-orca-since": "1.4.246",
"examples": [
"string"
]
},
"default": {
"description": "Value read while the person has not set one.",
"x-orca-since": "1.4.246",
"anyOf": [
{
"type": "string",
"maxLength": 10000
},
{
"type": "number"
},
{
"type": "boolean"
}
]
},
"enum": {
"description": "Allowed values; Settings shows a picker.",
"x-orca-since": "1.4.246",
"minItems": 1,
"maxItems": 64,
"type": "array",
"items": {
"anyOf": [
{
"type": "string",
"maxLength": 256
},
{
"type": "number"
}
]
}
},
"enumLabels": {
"description": "Picker labels, one per `enum` value.",
"x-orca-since": "1.4.246",
"maxItems": 64,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 120
}
},
"minimum": {
"description": "Numbers: smallest value.",
"x-orca-since": "1.4.246",
"type": "number"
},
"maximum": {
"description": "Numbers: largest value.",
"x-orca-since": "1.4.246",
"type": "number"
},
"maxLength": {
"description": "Strings: most characters.",
"x-orca-since": "1.4.246",
"type": "integer",
"minimum": 1,
"maximum": 10000
},
"multiline": {
"description": "Strings: a multi-line text box.",
"x-orca-since": "1.4.246",
"type": "boolean"
},
"placeholder": {
"description": "Strings and numbers: hint shown while empty.",
"x-orca-since": "1.4.246",
"type": "string",
"maxLength": 120
}
},
"required": [
"key",
"title",
"type"
],
"additionalProperties": false,
"description": "A setting shown under the plugin in Settings. Requires the `settings:own` capability, which is also how code reads it."
},
"Capability": {
"type": "object",
"properties": {
@@ -0,0 +1 @@
* -text
@@ -0,0 +1,2 @@
node_modules/
.orca/
@@ -0,0 +1,30 @@
# Hello Worker Panel
A components panel written in TSX with no build step, and the plugin's own worker. Start from
[hello-tsx](../hello-tsx) for a panel alone; this one adds everything a panel can reach beyond its
own UI, so it runs as a full-access (`trusted-worker`) plugin. Orca transpiles `src/*.tsx` and
`src/*.ts` when the panel loads (types stripped, JSX compiled for `orca`, relative imports such as
`./todos` resolved to their files).
```sh
orca plugin dev load . # also writes .orca/types, the typings tsconfig.json extends
npx tsc -p . # optional type check
```
Import only `orca` and relative files: there is no bundler, so `import 'react'` fails with an
error naming the file and the import.
It shows one of each part of the API:
- **Components**: keyed atoms, `derived` values, `setup(ctx)` and `ui.has` feature detection.
- **Panel → worker**: Suggest calls `plugin.request('tasks.suggest')`, answered by
`orca.panels.handle` in `worker/main.mjs`, which starts on the panel's first request.
- **Worker → panel**: the worker `orca.panels.post`s `tasks.allDone` when the last task is done;
the panel hears it with `plugin.on`.
- **Shared storage**: the task list is `atom('todos', ..., { persist: 'storage' })`, so the
worker reads the same value with `orca.storage.get('todos')` and hears each change.
- **Settings**: `contributes.settings` declares `celebrate`, `suggestions` and `suggestionOrder` (a switch, a text box and a picker); change them under
the plugin in Orca's Settings and the worker reads them with `orca.settings.get`.
- **Events**: `on('agent.status.changed')` counts working agents (the `agent:read` capability).
- **One typed contract**: `src/protocol.ts` declares `PluginRequests` and `PluginPanelEvents` in
`declare module 'orca'`, so both sides type-check against it.
@@ -0,0 +1,61 @@
{
"manifestVersion": 2,
"execution": "trusted-worker",
"id": "hello-worker-panel",
"name": "Hello Worker Panel",
"version": "1.0.0",
"description": "A build-less TSX panel and its own worker: requests and posts between them, a storage-backed atom, declared settings and agent events, typed by one src/protocol.ts.",
"engines": {
"orca": ">=1.4.246"
},
"pluginApi": 1,
"main": "worker/main.mjs",
"contributes": {
"panels": [
{
"id": "tasks",
"title": "Hello Worker Panel",
"icon": "blocks",
"entry": "src/panel.tsx",
"ui": "components"
}
],
"settings": [
{
"key": "celebrate",
"title": "Celebrate finished lists",
"description": "The worker tells the panel when every task is done.",
"type": "boolean",
"default": true
},
{
"key": "suggestions",
"title": "Task ideas",
"description": "One per line; Suggest picks the next one.",
"type": "string",
"multiline": true,
"default": "Write a test\nReview a pull request\nTake a break"
},
{
"key": "suggestionOrder",
"title": "Suggestion order",
"description": "Which end of Task ideas Suggest starts from.",
"type": "string",
"enum": ["listed", "reversed"],
"enumLabels": ["As listed", "Last first"],
"default": "listed"
}
]
},
"capabilities": [
{
"kind": "storage:own"
},
{
"kind": "settings:own"
},
{
"kind": "agent:read"
}
]
}
@@ -0,0 +1,100 @@
// No build step: Orca strips the types, compiles the JSX and resolves `./todos` when it loads.
import {
atom,
Badge,
Button,
Heading,
IconButton,
Input,
on,
plugin,
Progress,
read,
Row,
set,
Stack,
Text,
ui,
update,
type PanelContext
} from 'orca'
import { TodoList } from './todo-list'
import { add, clearDone, draft, openCount, percentDone, todos } from './todos'
const themeSwitches = atom(0)
const celebration = atom<string | null>(null)
/** Agent panes working right now, from `agent.status.changed` (the `agent:read` capability). */
const workingAgents = atom<ReadonlySet<string>>(new Set())
async function suggest(): Promise<void> {
// Starts the plugin's worker if it is not running; typed by src/protocol.ts.
const { title } = await plugin.request('tasks.suggest', {
taken: read(todos).map((todo) => todo.title)
})
set(draft, title ?? '')
}
/** Runs once before the first render; what it pushes to `subscriptions` ends with the panel. */
export function setup(ctx: PanelContext): void {
ctx.subscriptions.push(
on('theme', () => update(themeSwitches, (n) => n + 1)),
plugin.on('tasks.allDone', ({ count }) => set(celebration, `All ${count} done!`)),
on('agent.status.changed', ({ paneKey, state }) =>
update(workingAgents, (panes) => {
const next = new Set(panes)
if (state === 'working') {
next.add(paneKey)
} else {
next.delete(paneKey)
}
return next
})
)
)
// Wakes the worker so it sees the list change; the suggestion fills an empty field.
void suggest().catch(() => undefined)
}
export default function Panel() {
const open = read(openCount)
const done = read(celebration)
return (
<Stack gap="md">
<Row justify="between">
<Heading>Tasks</Heading>
<Row gap="xs">
{read(workingAgents).size > 0 ? (
<Badge tone="warning">{read(workingAgents).size} agents working</Badge>
) : null}
<Badge tone={open > 0 ? 'accent' : 'success'}>{open} open</Badge>
</Row>
</Row>
{done ? <Text tone="success">{done}</Text> : null}
{/* Feature-detect: an older Orca without Progress still renders the rest. */}
{ui.has('Progress') ? <Progress label="Done" labelHidden value={read(percentDone)} /> : null}
<Row gap="xs">
<Input
label="New task"
labelHidden
placeholder="New task"
value={read(draft)}
onChange={(value) => set(draft, value)}
onSubmit={add}
/>
<IconButton icon="add" label="Add task" onPress={add} disabled={!read(draft).trim()} />
</Row>
<TodoList items={read(todos)} />
<Row justify="between">
<Button variant="ghost" icon="lightbulb" onPress={() => void suggest()}>
Suggest
</Button>
<Button variant="ghost" onPress={clearDone}>
Clear done
</Button>
</Row>
<Text muted size="xs">
Written in TSX with no build step. Theme switches seen: {read(themeSwitches)}.
</Text>
</Stack>
)
}
@@ -0,0 +1,15 @@
// The panel ↔ worker contract, declared once: src/panel.tsx and worker/main.mjs both read it.
export {}
declare module 'orca' {
// oxlint-disable-next-line typescript/consistent-type-definitions -- declaration merging needs an interface
interface PluginRequests {
/** The next idea from the `suggestions` setting, skipping titles already on the list. */
'tasks.suggest': { input: { taken: string[] }; output: { title: string | null } }
}
// oxlint-disable-next-line typescript/consistent-type-definitions -- declaration merging needs an interface
interface PluginPanelEvents {
/** Posted by the worker when the stored list goes from open tasks to none. */
'tasks.allDone': { count: number }
}
}
@@ -0,0 +1,21 @@
import { EmptyState, List, ListItem } from 'orca'
import { toggle, type Todo } from './todos'
export function TodoList({ items }: { items: Todo[] }) {
if (items.length === 0) {
return <EmptyState icon="check" title="Nothing to do" description="Add a task above." />
}
return (
<List label="Tasks">
{items.map((todo) => (
<ListItem
key={String(todo.id)}
icon={todo.done ? 'check' : 'stop'}
title={todo.title}
description={todo.done ? 'Done' : 'Open'}
onPress={() => toggle(todo.id)}
/>
))}
</List>
)
}
@@ -0,0 +1,41 @@
import { atom, derived, read, set, update } from 'orca'
export type Todo = { id: number; title: string; done: boolean }
// Kept in the plugin's own storage under `todos`: it survives restarts, and the worker reads it.
export const todos = atom<Todo[]>(
'todos',
[
{ id: 1, title: 'Read the components guide', done: true },
{ id: 2, title: 'Edit src/panel.tsx and save', done: false }
],
{ persist: 'storage' }
)
export const draft = atom('')
export const openCount = derived(() => read(todos).filter((todo) => !todo.done).length)
export const percentDone = derived(() => {
const list = read(todos)
return list.length === 0 ? 100 : Math.round((100 * (list.length - read(openCount))) / list.length)
})
export function add(): void {
const title = read(draft).trim()
if (!title) {
return
}
update(todos, (list) => [
...list,
{ id: Math.max(0, ...list.map((todo) => todo.id)) + 1, title, done: false }
])
set(draft, '')
}
export function toggle(id: number): void {
update(todos, (list) =>
list.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo))
)
}
export function clearDone(): void {
update(todos, (list) => list.filter((todo) => !todo.done))
}
@@ -0,0 +1,4 @@
{
"extends": "./.orca/types/tsconfig.json",
"include": ["src"]
}
@@ -0,0 +1,42 @@
// The plugin's own worker, started by the panel's first `plugin.request`. It answers
// `tasks.suggest` from the `suggestions` setting, and watches the task list the panel keeps in
// storage (`persist: 'storage'`) to post `tasks.allDone`.
/** @param {unknown} todos */
const openCount = (todos) => (Array.isArray(todos) ? todos.filter((todo) => !todo.done).length : 0)
/** @type {import('orca/worker').Activate} */
export default async function activate(orca) {
// Null until the panel first stores its list, so finishing the starting tasks still counts.
const stored = await orca.storage.get('todos')
let open = stored === null ? null : openCount(stored)
orca.panels.handle('tasks.suggest', async ({ taken }) => {
const ideas = String((await orca.settings.get('suggestions')) ?? '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
if ((await orca.settings.get('suggestionOrder')) === 'reversed') {
ideas.reverse()
}
return { title: ideas.find((idea) => !taken.includes(idea)) ?? null }
})
orca.storage.onDidChange(async ({ key }) => {
if (key !== 'todos') {
return
}
const todos = await orca.storage.get('todos')
const nowOpen = openCount(todos)
const count = Array.isArray(todos) ? todos.length : 0
if (
open !== 0 &&
nowOpen === 0 &&
count > 0 &&
(await orca.settings.get('celebrate')) !== false
) {
orca.panels.post('tasks.allDone', { count })
}
open = nowOpen
})
}
@@ -157,7 +157,8 @@
// iframe can post raw invalid, pong and binary messages; an isolated guest (its own top
// window, no raw channel) can only flood through orca.host.call.
setTimeout(function () {
for (var i = 0; i < 40; i++) {
// Past the 120-per-10-seconds panel budget.
for (var i = 0; i < 150; i++) {
if (window.parent !== window) {
var payload =
i % 3 === 0
+180 -44
View File
@@ -13,6 +13,7 @@ var snapshot = {
};
var onPersistedChange = null;
var onMemoryChange = null;
var onStorageAtom = null;
var writableAtoms = /* @__PURE__ */ new Set();
function create(initial, fields = {}) {
const created = Object.freeze({
@@ -39,7 +40,7 @@ function resetAtoms() {
}
}
function stored(inner) {
if (!inner.key) {
if (!inner.key || inner.persist === "storage") {
return { found: false };
}
const entry = snapshot[inner.persist].get(inner.key);
@@ -63,8 +64,8 @@ function atom(...args) {
throw new Error(`atom key "${key}" is already used`);
}
const persist = options.persist ?? "memory";
if (persist !== "memory" && persist !== "plugin" && persist !== "workspace") {
throw new TypeError('persist is "memory", "plugin" or "workspace"');
if (!["memory", "plugin", "workspace", "storage"].includes(persist)) {
throw new TypeError('persist is "memory", "plugin", "workspace" or "storage"');
}
const result = create(initial, {
key,
@@ -78,6 +79,9 @@ function atom(...args) {
inner.value = hydrated.value;
}
keyed.set(key, result);
if (persist === "storage") {
onStorageAtom?.(key);
}
return result;
}
function derived(compute) {
@@ -167,6 +171,20 @@ function memoryEntry(target) {
function watchMemoryAtoms(listener) {
onMemoryChange = listener;
}
function watchStorageAtoms(listener) {
onStorageAtom = listener;
for (const [key, target] of keyed) {
if (listener && target[ATOM].persist === "storage") {
listener(key);
}
}
}
function applyStorageValue(key, value) {
const inner = keyed.get(key)?.[ATOM];
if (inner?.persist === "storage") {
assign(inner, value === void 0 ? inner.initial : value, false);
}
}
function hydrateAtoms(scope, values) {
snapshot[scope] = new Map(values);
for (const target of keyed.values()) {
@@ -433,43 +451,6 @@ function evaluateOrcaTree(component, editsSeen = /* @__PURE__ */ new Map()) {
return { tree, callbacks };
}
// src/shared/plugins/declarative/runtime/declarative-persist-queue.ts
var PERSIST_DEBOUNCE_MS = 400;
function createPersistQueue(options) {
const pending = /* @__PURE__ */ new Set();
let timer = null;
function flush() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
const entries = [];
for (const target of pending) {
const entry = persistedEntry(target);
if (!entry) {
continue;
}
try {
assertPlainJson(entry.value, `persisted atom "${entry.key}"`);
entries.push(entry);
} catch (error) {
options.report(error);
}
}
pending.clear();
if (entries.length > 0) {
options.post(entries);
}
}
return {
add(target) {
pending.add(target);
timer ??= setTimeout(flush, PERSIST_DEBOUNCE_MS);
},
flush
};
}
// src/shared/plugins/declarative/runtime/declarative-memory-retention.ts
var RETAIN_DEBOUNCE_MS = 50;
var RETAIN_BATCH = 64;
@@ -671,6 +652,114 @@ function createPanelContext(onRetain) {
};
}
// src/shared/plugins/declarative/runtime/declarative-persist-queue.ts
var PERSIST_DEBOUNCE_MS = 400;
function createPersistQueue(options) {
const pending = /* @__PURE__ */ new Set();
let timer = null;
function flush() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
const entries = [];
for (const target of pending) {
const entry = persistedEntry(target);
if (!entry) {
continue;
}
try {
assertPlainJson(entry.value, `persisted atom "${entry.key}"`);
entries.push(entry);
} catch (error) {
options.report(error);
}
}
pending.clear();
if (entries.length > 0) {
options.post(entries);
}
}
return {
add(target) {
pending.add(target);
timer ??= setTimeout(flush, PERSIST_DEBOUNCE_MS);
},
flush
};
}
// src/shared/plugins/declarative/runtime/declarative-storage-sync.ts
function createDeclarativeStorageSync(options) {
const storage2 = options.bridge?.storage;
const loads = /* @__PURE__ */ new Set();
const writtenSince = /* @__PURE__ */ new Map();
let generation = 0;
function load(key) {
if (!storage2) {
return;
}
const startedAt = generation;
const task = storage2.get(key).then((value) => {
if ((writtenSince.get(key) ?? -1) < startedAt) {
applyStorageValue(key, value ?? void 0);
}
}).catch(options.report).finally(() => loads.delete(task));
loads.add(task);
}
watchStorageAtoms((key) => {
generation += 1;
load(key);
});
const subscription = storage2?.onDidChange(({ key }) => {
generation += 1;
load(key);
});
return {
write(entries) {
const rest = [];
for (const entry of entries) {
if (entry.scope !== "storage") {
rest.push(entry);
continue;
}
generation += 1;
writtenSince.set(entry.key, generation);
void storage2?.set(entry.key, entry.value).catch(options.report);
}
return rest;
},
loaded: async () => {
await Promise.all(loads);
},
dispose() {
watchStorageAtoms(null);
subscription?.dispose();
}
};
}
function createDeclarativePersistence(options) {
const storageSync = createDeclarativeStorageSync(options);
const queue = createPersistQueue({
report: options.report,
post: (all) => {
const entries = storageSync.write(all);
if (entries.length > 0) {
options.post(entries);
}
}
});
watchPersistedAtoms(queue.add);
return {
flush: queue.flush,
loaded: storageSync.loaded,
dispose() {
watchPersistedAtoms(null);
storageSync.dispose();
}
};
}
// src/shared/plugins/declarative/runtime/declarative-renderer.ts
function createOrcaDeclarativeRuntime(options) {
const { post } = options;
@@ -805,12 +894,12 @@ function createOrcaDeclarativeRuntime(options) {
invalidate(retryAfterMs);
}
}
const persistQueue = createPersistQueue({
const persistence = createDeclarativePersistence({
bridge,
report,
post: (entries) => post(PLUGIN_DECLARATIVE_MESSAGES.persist, { document: document2, entries })
});
const flushPersist = persistQueue.flush;
watchPersistedAtoms(persistQueue.add);
const flushPersist = persistence.flush;
const bridgeSubscriptions = bridge?.on ? [
bridge.on(PLUGIN_DECLARATIVE_MESSAGES.event, dispatchEvent),
bridge.on(PLUGIN_DECLARATIVE_MESSAGES.ack, acknowledge),
@@ -840,6 +929,7 @@ function createOrcaDeclarativeRuntime(options) {
document: document2,
async boot(module) {
await panel.hydrated;
await persistence.loaded();
if (isCallable(module.setup)) {
const cleanup = module.setup(context);
setupCleanup = isCallable(cleanup) ? () => void cleanup() : null;
@@ -900,7 +990,7 @@ function createOrcaDeclarativeRuntime(options) {
component = null;
scheduled = false;
unsubscribeAll();
watchPersistedAtoms(null);
persistence.dispose();
for (const subscription of [...bridgeSubscriptions, ...context.subscriptions]) {
try {
subscription.dispose();
@@ -1014,6 +1104,45 @@ function createOrcaSdk(source) {
return { host: host2, api: api2, env: env2, shell: shell2, files: files2, on: on2, ui: ui2 };
}
// src/shared/plugins/declarative/runtime/declarative-data-sdk.ts
function unavailable2(what) {
return Promise.reject(
Object.assign(new Error(`${what} is not available in this Orca`), {
code: "unavailable",
retryable: false
})
);
}
var NOTHING = { dispose: () => void 0 };
function createOrcaDataSdk(bridge) {
const plugin2 = Object.freeze({
request(name, input, options) {
return bridge()?.plugin?.request(name, input, options) ?? unavailable2("plugin.request");
},
on(event, handler) {
return bridge()?.plugin?.on(event, handler) ?? NOTHING;
}
});
const storage2 = Object.freeze({
get: (key) => bridge()?.storage?.get(key) ?? unavailable2("storage"),
set: (key, value) => bridge()?.storage?.set(key, value) ?? unavailable2("storage"),
delete: (key) => bridge()?.storage?.delete(key) ?? unavailable2("storage"),
keys: () => bridge()?.storage?.keys() ?? unavailable2("storage"),
onDidChange: (handler) => bridge()?.storage?.onDidChange(handler) ?? NOTHING
});
const settings2 = Object.freeze({
get: (key) => bridge()?.settings?.get(key) ?? unavailable2("settings"),
set: (key, value) => bridge()?.settings?.set(key, value) ?? unavailable2("settings"),
onDidChange: (handler) => bridge()?.settings?.onDidChange(handler) ?? NOTHING
});
const secrets2 = Object.freeze({
get: (key) => bridge()?.secrets?.get(key) ?? unavailable2("secrets"),
set: (key, value) => bridge()?.secrets?.set(key, value) ?? unavailable2("secrets"),
delete: (key) => bridge()?.secrets?.delete(key) ?? unavailable2("secrets")
});
return { plugin: plugin2, storage: storage2, settings: settings2, secrets: secrets2 };
}
// src/shared/plugins/declarative/runtime/orca-module.ts
var runtime = null;
var pendingMount = null;
@@ -1063,6 +1192,9 @@ var sdk = createOrcaSdk({
theme: () => runtime ? read(runtime.themeAtom) : { colorScheme: documentColorScheme() ?? "dark" }
});
var { host, api, env, shell, files, on, ui } = sdk;
var { plugin, storage, settings, secrets } = createOrcaDataSdk(
() => runtime?.bridge ?? globalBridge()
);
var Stack = "Stack";
var Row = "Row";
var Text = "Text";
@@ -1116,9 +1248,13 @@ export {
jsx as jsxs,
mount,
on,
plugin,
read,
secrets,
set,
settings,
shell,
storage,
ui,
update
};
File diff suppressed because one or more lines are too long
+51 -26
View File
@@ -1,6 +1,7 @@
// Author typings for Orca components panels (`"ui": "components"`). Copy this file into a plugin
// project and list it in tsconfig `files`/`include`. Mirrors src/shared/plugins/declarative/
// (checked by plugin-declarative-typings.test.ts); host methods are generated from the host API.
// Author typings for Orca components panels (`"ui": "components"`); `orca plugin types` installs
// them with types/orca-services.d.ts, which adds `shell`, `files`, `on`, `plugin`, `storage`,
// `settings` and `secrets` to this module. Mirrors src/shared/plugins/declarative/ (checked by
// plugin-declarative-typings.test.ts); host methods are generated from the host API.
declare module 'orca' {
export type OrcaElement = { readonly $$typeof: symbol }
@@ -209,10 +210,15 @@ declare module 'orca' {
export const Fragment: unique symbol
export function h(type: unknown, props: object | null, ...children: OrcaNode[]): OrcaElement
/** Where a keyed atom survives: this panel run, every run of the plugin, or per workspace. */
export type PersistScope = 'memory' | 'plugin' | 'workspace'
/**
* Where a keyed atom survives: this panel run, every run of the plugin, per workspace, or the
* plugin's own storage under the atom's key (`storage:own`), shared with its worker and other
* panels and kept current when they change it.
*/
export type PersistScope = 'memory' | 'plugin' | 'workspace' | 'storage'
export type AtomOptions<T> = {
/** `plugin` and `workspace` values must be plain JSON; they load before the first render. */
/** Persisted values must be plain JSON; they load before the first render. `storage` values
* are stored as they are, so `version` and `migrate` do not apply to them. */
persist?: PersistScope
/** Bump when the stored shape changes; `migrate` then receives the older value. */
version?: number
@@ -251,13 +257,50 @@ declare module 'orca' {
// <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 }[] } | null }
"workspace.readContext": { params: Record<string, never> | undefined; result: { 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. */
"storage.get": { params: { key: string }; result: { value: JsonValue } }
/** Writes a JSON value to the plugin's private store. */
"storage.set": { params: { key: string; value: JsonValue }; result: { ok: true } }
/** Removes a key from the plugin's private store. */
"storage.delete": { params: { key: string }; result: { ok: true } }
/** Lists the keys in the plugin's private store. */
"storage.keys": { params: Record<string, never> | undefined; result: { keys: string[] } }
/** Reads a secret from the plugin's encrypted secrets file; null when absent. */
"secrets.get": { params: { key: string }; result: { value: string | null } }
/** Stores a secret in the plugin's encrypted secrets file. */
"secrets.set": { params: { key: string; value: string }; result: { ok: true } }
/** Removes a secret from the plugin's encrypted secrets file. */
"secrets.delete": { params: { key: string }; result: { ok: true } }
/** How secrets are protected at rest: `os-keyring`, or `unavailable` (no OS key store, or Linux `basic_text`), where `secrets.set` fails. */
"secrets.protection": { params: Record<string, never> | undefined; result: { status: "os-keyring" | "unavailable" } }
/** Reads every setting the plugin owns. */
"settings.get": { params: Record<string, never> | undefined; result: { settings: Record<string, JsonValue> } }
/** Writes one setting the plugin owns. */
"settings.set": { params: { key: string; value: JsonValue }; result: { ok: true } }
/** Opens an http:, https: or mailto: URL in the default browser. Needs a click or key press in the frame; desktop app only. Orca asks the person before an http(s) host the plugin was not allowed; `opened` is false if they cancel. */
"shell.openExternal": { params: { url: string }; result: { opened: boolean } }
"shell.openExternal": { params: { url: string }; result: { opened: boolean; reason?: "cancelled" | "consent-unavailable" } }
/** Offers data (a string or ArrayBuffer, up to 64 MiB) in Orca's save dialog; the plugin never learns the path. Needs a click or key press in the frame; desktop app only. */
"files.saveAs": { params: { suggestedName?: string; mimeType?: string; data: string | ArrayBuffer }; result: { status: "saved" | "cancelled" } }
/** Asks the plugin's own worker to run the handler it registered with `orca.panels.handle(name)`, starting the worker if needed; resolves with what the handler returned. `unknown_method` when no handler has that name. */
"plugin.request": { params: { name: string; input?: JsonValue; timeoutMs?: number }; result: { output: JsonValue } }
/** The agents in the focused workspace: their terminal, title, agent kind and status. `agent.status.changed` reports each change after. */
"agents.list": { params: Record<string, never> | undefined; result: { agents: { terminalId: string | null; title: string | null; agent: string | null; state: "working" | "blocked" | "waiting" | "done"; stateStartedAt: number }[] } }
/** Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the event's read capability. The frame APIs call it for you. */
"events.subscribe": { params: { event: "workspace.created" | "workspace.removed" | "agent.status.changed" | "workspace.changed" }; result: { ok: true } }
}
/** 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. */
"workspace.changed": { context: { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }
}
// </generated>
export type HostMethod = keyof HostMethods
@@ -280,24 +323,6 @@ declare module 'orca' {
readonly theme: Theme
readonly client: 'desktop'
}
/** Needs a press on one of the panel's controls; see "User gestures". */
export const shell: {
openExternal(url: string): Promise<HostMethods['shell.openExternal']['result']>
}
/** Needs a press on one of the panel's controls; see "User gestures". */
export const files: {
saveAs(
options: HostMethods['files.saveAs']['params']
): Promise<HostMethods['files.saveAs']['result']>
}
/** Events a panel can subscribe to. */
export type OrcaEvents = {
theme: { colorScheme: 'light' | 'dark' }
}
export function on<E extends keyof OrcaEvents>(
event: E,
handler: (payload: OrcaEvents[E]) => void
): Disposable
export type ComponentName =
| 'Stack'
| 'Row'
+27 -4
View File
@@ -2,7 +2,16 @@
// Mirrors src/shared/plugins/frames/plugin-panel-shell.ts (checked by plugin-frame-typings.test.ts).
declare module 'orca/frame' {
import type { Disposable, HostApi, HostApiVersion } from 'orca/host'
import type {
Disposable,
HostApi,
HostApiVersion,
PluginBridgeApi,
PluginEvents,
SecretsApi,
SettingsApi,
StorageApi
} from 'orca/host'
export type FrameTheme = { colorScheme: 'light' | 'dark' }
/** `bytes` documents come from editors whose manifest sets `"contentKind": "bytes"`. */
@@ -17,8 +26,9 @@ declare module 'orca/frame' {
}
/** The whole new content, made against document `baseVersion`. */
export type EditorEdit = { baseVersion: number; content: EditorContent }
/** Payloads of `orca.on(event, handler)`. */
export type FrameEvents = {
/** Payloads of `orca.on(event, handler)`: frame events, and workspace and agent events (each
* needs the read capability of its domain: `workspace:read` or `agent:read`). */
export type FrameEvents = PluginEvents & {
/** The `--orca-*` variables and the `light`/`dark` class on `<html>` already changed. */
theme: FrameTheme
/** Custom editors: a new document; `orca.document.onDidChange` is the usual way to listen. */
@@ -47,8 +57,21 @@ declare module 'orca/frame' {
): Disposable
/** Orca's UI language as a BCP-47 tag. */
readonly locale: string
/** Your plugin's own worker: `request` its handlers, hear what it `post`s. */
readonly plugin: PluginBridgeApi
/** The plugin's own storage, the same its worker uses (`storage:own`). */
readonly storage: StorageApi
/** The plugin's settings, declared ones with their defaults (`settings:own`). */
readonly settings: SettingsApi
/** The plugin's encrypted secrets (`secrets:own`). */
readonly secrets: SecretsApi
/** Desktop isolated frames only; needs a click or key press in the frame. */
readonly shell?: { openExternal(url: string): Promise<{ opened: boolean }> }
readonly shell?: {
/** `reason`: the person cancelled, or Orca's window was hidden so nobody could confirm. */
openExternal(
url: string
): Promise<{ opened: boolean; reason?: 'cancelled' | 'consent-unavailable' }>
}
/** Desktop isolated frames only; `cancelled` when the person closes the dialog. */
readonly files?: {
saveAs(options: SaveAsOptions): Promise<{ status: 'saved' | 'cancelled' }>
+61 -25
View File
@@ -1,7 +1,23 @@
// Generated by `pnpm generate:plugin-author-typings` from src/shared/plugins. Do not edit.
// Host API 1.1 types shared by frames (`window.orca`) and workers (`activate(orca)`).
// Host API 1.2 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
// workers, frames and components panels all read the same contract.
export type {
DataChange,
PluginBridgeApi,
PluginPanelEvents,
PluginRequestArgs,
PluginRequestInput,
PluginRequestOptions,
PluginRequestOutput,
PluginRequests,
SecretsApi,
SettingsApi,
StorageApi,
Undeclared
} from 'orca'
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
export type Disposable = { dispose(): void }
/** `orca.api`: check `minor` (or `host.has`) before calling a method newer than your oldest Orca. */
@@ -21,7 +37,7 @@ declare module 'orca/host' {
/** 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 }[] } | null
result: { 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. */
@@ -36,70 +52,70 @@ declare module 'orca/host' {
result: { delivered: boolean }
surfaces: "worker" | "panel" | "editor"
}
/** Reads a JSON value from the plugin's private store; null when absent. Since host API 1.0. */
/** Reads a JSON value from the plugin's private store; null when absent. Since host API 1.0 (panels and editors: 1.2). */
"storage.get": {
params: { key: string }
result: { value: JsonValue }
surfaces: "worker"
surfaces: "worker" | "panel" | "editor"
}
/** Writes a JSON value to the plugin's private store. Since host API 1.0. */
/** Writes a JSON value to the plugin's private store. Since host API 1.0 (panels and editors: 1.2). */
"storage.set": {
params: { key: string; value: JsonValue }
result: { ok: true }
surfaces: "worker"
surfaces: "worker" | "panel" | "editor"
}
/** Removes a key from the plugin's private store. Since host API 1.0. */
/** Removes a key from the plugin's private store. Since host API 1.0 (panels and editors: 1.2). */
"storage.delete": {
params: { key: string }
result: { ok: true }
surfaces: "worker"
surfaces: "worker" | "panel" | "editor"
}
/** Lists the keys in the plugin's private store. Since host API 1.0. */
/** Lists the keys in the plugin's private store. Since host API 1.0 (panels and editors: 1.2). */
"storage.keys": {
params: Record<string, never> | undefined
result: { keys: string[] }
surfaces: "worker"
surfaces: "worker" | "panel" | "editor"
}
/** Reads a secret from the plugin's encrypted secrets file; null when absent. Since host API 1.0. */
/** Reads a secret from the plugin's encrypted secrets file; null when absent. Since host API 1.0 (panels and editors: 1.2). */
"secrets.get": {
params: { key: string }
result: { value: string | null }
surfaces: "worker"
surfaces: "worker" | "panel" | "editor"
}
/** Stores a secret in the plugin's encrypted secrets file. Since host API 1.0. */
/** Stores a secret in the plugin's encrypted secrets file. Since host API 1.0 (panels and editors: 1.2). */
"secrets.set": {
params: { key: string; value: string }
result: { ok: true }
surfaces: "worker"
surfaces: "worker" | "panel" | "editor"
}
/** Removes a secret from the plugin's encrypted secrets file. Since host API 1.0. */
/** Removes a secret from the plugin's encrypted secrets file. Since host API 1.0 (panels and editors: 1.2). */
"secrets.delete": {
params: { key: string }
result: { ok: true }
surfaces: "worker"
surfaces: "worker" | "panel" | "editor"
}
/** How secrets are protected at rest: `os-keyring`, or `unavailable` (no OS key store, or Linux `basic_text`), where `secrets.set` fails. Since host API 1.1. */
/** How secrets are protected at rest: `os-keyring`, or `unavailable` (no OS key store, or Linux `basic_text`), where `secrets.set` fails. Since host API 1.1 (panels and editors: 1.2). */
"secrets.protection": {
params: Record<string, never> | undefined
result: { status: "os-keyring" | "unavailable" }
surfaces: "worker"
surfaces: "worker" | "panel" | "editor"
}
/** Reads every setting the plugin owns. Since host API 1.0. */
/** Reads every setting the plugin owns. Since host API 1.0 (panels and editors: 1.2). */
"settings.get": {
params: Record<string, never> | undefined
result: { settings: Record<string, JsonValue> }
surfaces: "worker"
surfaces: "worker" | "panel" | "editor"
}
/** Writes one setting the plugin owns. Since host API 1.0. */
/** Writes one setting the plugin owns. Since host API 1.0 (panels and editors: 1.2). */
"settings.set": {
params: { key: string; value: JsonValue }
result: { ok: true }
surfaces: "worker"
surfaces: "worker" | "panel" | "editor"
}
/** Opens an http:, https: or mailto: URL in the default browser. Needs a click or key press in the frame; desktop app only. Orca asks the person before an http(s) host the plugin was not allowed; `opened` is false if they cancel. Since host API 1.1. */
"shell.openExternal": {
params: { url: string }
result: { opened: boolean }
result: { opened: boolean; reason?: "cancelled" | "consent-unavailable" }
surfaces: "panel" | "editor"
}
/** Offers data (a string or ArrayBuffer, up to 64 MiB) in Orca's save dialog; the plugin never learns the path. Needs a click or key press in the frame; desktop app only. Since host API 1.1. */
@@ -108,6 +124,24 @@ declare module 'orca/host' {
result: { status: "saved" | "cancelled" }
surfaces: "panel" | "editor"
}
/** Asks the plugin's own worker to run the handler it registered with `orca.panels.handle(name)`, starting the worker if needed; resolves with what the handler returned. `unknown_method` when no handler has that name. Since host API 1.2. */
"plugin.request": {
params: { name: string; input?: JsonValue; timeoutMs?: number }
result: { output: JsonValue }
surfaces: "panel" | "editor"
}
/** The agents in the focused workspace: their terminal, title, agent kind and status. `agent.status.changed` reports each change after. Since host API 1.2. */
"agents.list": {
params: Record<string, never> | undefined
result: { agents: { terminalId: string | null; title: string | null; agent: string | null; state: "working" | "blocked" | "waiting" | "done"; stateStartedAt: number }[] }
surfaces: "worker" | "panel" | "editor"
}
/** Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the event's read capability. The frame APIs call it for you. Since host API 1.2. */
"events.subscribe": {
params: { event: "workspace.created" | "workspace.removed" | "agent.status.changed" | "workspace.changed" }
result: { ok: true }
surfaces: "panel" | "editor"
}
}
export type HostMethodName = keyof HostMethods
/** Methods a surface may call; any other gets `not_permitted`. */
@@ -132,8 +166,10 @@ declare module 'orca/host' {
"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. */
"agent.status.changed": { workspaceId: string | null; paneKey: 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. */
"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. */
"workspace.changed": { context: { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }
}
export type PluginEventName = keyof PluginEvents
}
+115
View File
@@ -0,0 +1,115 @@
// Author typings for what a plugin reaches outside its own UI: shell and files, events, the panel
// ↔ worker contract and the plugin's own storage, settings and secrets. Merges into module 'orca'
// (orca.d.ts), which components panels import; 'orca/host' re-exports the shared types for frames
// and workers. Mirrors declarative-sdk.ts, plugin-panel-shell-facades.ts and
// plugin-worker-orca-api.ts.
declare module 'orca' {
/** Needs a press on one of the panel's controls; see "User gestures". */
export const shell: {
openExternal(url: string): Promise<HostMethods['shell.openExternal']['result']>
}
/** Needs a press on one of the panel's controls; see "User gestures". */
export const files: {
saveAs(
options: HostMethods['files.saveAs']['params']
): Promise<HostMethods['files.saveAs']['result']>
}
/** Events a panel can subscribe to: the theme, and workspace and agent events (each needs the
* read capability of its domain: `workspace:read` or `agent:read`). */
export type OrcaEvents = {
theme: { colorScheme: 'light' | 'dark' }
} & PluginEvents
export function on<E extends keyof OrcaEvents>(
event: E,
handler: (payload: OrcaEvents[E]) => void
): Disposable
/**
* Your panel ↔ worker contract, declared once with declaration merging and imported by both
* sides, so requests and their answers are typed end to end:
*
* declare module 'orca' {
* interface PluginRequests { 'todos.list': { input: { done?: boolean }; output: Todo[] } }
* interface PluginPanelEvents { 'todos.changed': { count: number } }
* }
*/
// oxlint-disable-next-line typescript/consistent-type-definitions -- declaration merging needs an interface
export interface PluginRequests {}
/** Events your worker sends with `orca.panels.post(event, payload)`. */
// oxlint-disable-next-line typescript/consistent-type-definitions -- declaration merging needs an interface
export interface PluginPanelEvents {}
/** What a declared request takes; `undefined` when it declares no `input`. */
export type PluginRequestInput<K extends keyof PluginRequests> = PluginRequests[K] extends {
input: infer I
}
? I
: undefined
/** What a declared request answers with; `null` when it declares no `output`. */
export type PluginRequestOutput<K extends keyof PluginRequests> = PluginRequests[K] extends {
output: infer O
}
? O
: null
/** `timeoutMs` (at most 120000, default 30000) is how long the worker may take. */
export type PluginRequestOptions = { signal?: AbortSignal; timeoutMs?: number }
export type PluginRequestArgs<K extends keyof PluginRequests> =
undefined extends PluginRequestInput<K>
? [input?: PluginRequestInput<K>, options?: PluginRequestOptions]
: [input: PluginRequestInput<K>, options?: PluginRequestOptions]
/** A name the plugin did not declare, which then carries any JSON. */
export type Undeclared<N extends string, Declared> = N extends keyof Declared ? never : N
/** Your plugin's own worker. */
export type PluginBridgeApi = {
/** Runs the worker handler `orca.panels.handle(name)` registered, starting the worker if it
* is not running. Rejects with `unknown_method` (no such handler), `timeout`, `unavailable`
* (no worker), or the `code` the handler threw. */
request<K extends keyof PluginRequests>(
name: K,
...args: PluginRequestArgs<K>
): Promise<PluginRequestOutput<K>>
request<N extends string>(
name: Undeclared<N, PluginRequests>,
input?: JsonValue,
options?: PluginRequestOptions
): Promise<JsonValue>
/** What the worker posts with `orca.panels.post(event, payload)`. */
on<K extends keyof PluginPanelEvents>(
event: K,
handler: (payload: PluginPanelEvents[K]) => void
): Disposable
on<N extends string>(
event: Undeclared<N, PluginPanelEvents>,
handler: (payload: JsonValue) => void
): Disposable
}
/** A key someone (you, another panel, the worker or the person in Settings) changed. */
export type DataChange = { key: string }
/** The plugin's own JSON store (`storage:own`), the same one its worker uses. */
export type StorageApi = {
/** `null` when the key is absent. */
get(key: string): Promise<JsonValue | null>
set(key: string, value: JsonValue): Promise<void>
delete(key: string): Promise<void>
keys(): Promise<string[]>
onDidChange(listener: (change: DataChange) => void): Disposable
}
/** The plugin's settings (`settings:own`): those it declares in `contributes.settings` read as
* their `default` until the person changes them in Settings. */
export type SettingsApi = {
get(): Promise<Record<string, JsonValue>>
get(key: string): Promise<JsonValue | undefined>
/** Declared settings refuse a value of the wrong type with `action_failed`. */
set(key: string, value: JsonValue): Promise<void>
onDidChange(listener: (change: DataChange) => void): Disposable
}
/** The plugin's encrypted secrets (`secrets:own`). */
export type SecretsApi = {
get(key: string): Promise<string | null>
set(key: string, value: string): Promise<void>
delete(key: string): Promise<void>
}
export const plugin: PluginBridgeApi
export const storage: StorageApi
export const settings: SettingsApi
export const secrets: SecretsApi
}
+45 -4
View File
@@ -1,6 +1,16 @@
// Test kit for components panels: `orca plugin test` runs *.test.ts(x) files in plain Node.
declare module 'orca/testing' {
import type { OrcaEvents, OrcaNode, PanelContext } from 'orca'
import type {
JsonValue,
OrcaEvents,
OrcaNode,
PanelContext,
PluginPanelEvents,
PluginRequestInput,
PluginRequestOutput,
PluginRequests,
Undeclared
} from 'orca'
/** One element of the drawn panel. Actions take it, or a query that finds it. */
export type TestElement = {
@@ -39,12 +49,32 @@ declare module 'orca/testing' {
readonly calls: readonly { method: string; params: unknown }[]
callsOf(method: string): unknown[]
respond(method: string, result: unknown): void
/** The plugin's storage, settings and secrets as they are now. */
data(): {
storage: Record<string, unknown>
settings: Record<string, unknown>
secrets: Record<string, unknown>
}
}
/** A stand-in for the plugin's worker: each `plugin.request` name's answer, or a function of
* its input (it may throw `hostError(...)`). An unlisted name rejects with `unknown_method`. */
export type MockRequests = {
[K in keyof PluginRequests]?:
| PluginRequestOutput<K>
| ((input: PluginRequestInput<K>) => PluginRequestOutput<K> | Promise<PluginRequestOutput<K>>)
} & Record<string, unknown>
export type MockHostOptions = {
/** Granted capabilities; defaults to those the plugin's orca-plugin.json declares. */
capabilities?: readonly string[]
/** Each method's answer: a value, or a function of the params that may throw `hostError(...)`. */
/** Each method's answer: a value, or a function of the params that may throw `hostError(...)`.
* Overrides the built-in storage, settings, secrets and `requests`. */
results?: Record<string, unknown>
requests?: MockRequests
/** What the plugin's storage, settings and secrets hold before the panel runs. Writes land
* here, and tell the panel's `onDidChange` listeners and `persist: 'storage'` atoms. */
storage?: Record<string, JsonValue>
settings?: Record<string, JsonValue>
secrets?: Record<string, string>
}
/**
* A stand-in Orca host that refuses what Orca refuses, with the same codes: a missing
@@ -76,8 +106,12 @@ declare module 'orca/testing' {
readonly errors: readonly PanelIssue[]
/** Props and components Orca would drop, as Orca would warn. */
readonly warnings: readonly string[]
/** What persisted atoms hold now, by scope and key. */
readonly persisted: { plugin: Record<string, unknown>; workspace: Record<string, unknown> }
/** What persisted atoms hold now, by scope and key; `storage` is the plugin's storage. */
readonly persisted: {
plugin: Record<string, unknown>
workspace: Record<string, unknown>
storage: Record<string, unknown>
}
/** The innermost match, or null. */
find(query: TestQuery): TestElement | null
findAll(query: TestQuery): TestElement[]
@@ -96,7 +130,14 @@ declare module 'orca/testing' {
select(target: Target, value: string): Promise<void>
/** What Orca shows in an Input, Switch or Select. */
valueOf(target: Target): unknown
/** An Orca event: `theme`, or a workspace or agent event the panel subscribed to. */
emit<E extends keyof OrcaEvents>(event: E, payload: OrcaEvents[E]): Promise<void>
/** What the plugin's worker posts with `orca.panels.post`; `plugin.on` handlers hear it. */
post<K extends keyof PluginPanelEvents>(event: K, payload: PluginPanelEvents[K]): Promise<void>
post<N extends string>(
event: Undeclared<N, PluginPanelEvents>,
payload?: JsonValue
): Promise<void>
setVisible(visible: boolean): Promise<void>
rerender(): Promise<void>
settled(): Promise<void>
+53 -2
View File
@@ -7,8 +7,17 @@ declare module 'orca/worker' {
Disposable,
HostApi,
HostApiVersion,
JsonValue,
PluginEventName,
PluginEvents
PluginEvents,
PluginPanelEvents,
PluginRequestInput,
PluginRequestOutput,
PluginRequests,
SecretsApi,
SettingsApi,
StorageApi,
Undeclared
} from 'orca/host'
export type {
@@ -22,6 +31,38 @@ declare module 'orca/worker' {
/** `signal` aborts when Orca stops waiting for the command. */
export type CommandContext = { signal: AbortSignal }
/** The open panel or editor that asked; `id` stays the same while it stays open. */
export type PanelCaller = { id: string; surface: 'panel' | 'editor'; contributionId: string }
/** `signal` aborts when the request times out or its panel closes. */
export type PanelRequestContext = { panel: PanelCaller; signal: AbortSignal }
export type PanelsApi = {
/** Answers a panel's `plugin.request(name, input)` with a JSON value. A thrown error whose
* `code` is a host error code (`invalid_params`, `conflict`, ...) reaches the panel with it;
* any other is `action_failed`. */
handle<K extends keyof PluginRequests>(
name: K,
handler: (
input: PluginRequestInput<K>,
context: PanelRequestContext
) => PluginRequestOutput<K> | Promise<PluginRequestOutput<K>>
): Disposable
handle<N extends string>(
name: Undeclared<N, PluginRequests>,
handler: (input: JsonValue | undefined, context: PanelRequestContext) => unknown
): Disposable
/** To every open panel of this plugin, or one (`panel` from a request's context). Fire and
* forget: at most 256 KiB, 200 per 10 s. */
post<K extends keyof PluginPanelEvents>(
event: K,
payload: PluginPanelEvents[K],
options?: { panel?: string }
): void
post<N extends string>(
event: Undeclared<N, PluginPanelEvents>,
payload?: JsonValue,
options?: { panel?: string }
): void
}
export type OrcaWorkerApi = {
readonly api: HostApiVersion
readonly commands: {
@@ -39,11 +80,21 @@ declare module 'orca/worker' {
): Disposable
}
readonly host: HostApi<'worker'>
/** This plugin's panels and custom editors. */
readonly panels: PanelsApi
/** Same store as panels' `storage` (`storage:own`); `onDidChange` hears their writes too. */
readonly storage: StorageApi
/** `settings:own`; `onDidChange` also hears the person changing them in Settings. */
readonly settings: SettingsApi
/** `secrets:own`. */
readonly secrets: SecretsApi
/** Consented capability kinds; informational, the host re-checks every call. */
readonly grantedCapabilities: readonly string[]
/** Lands in `orca plugin logs`. */
log(message: string): void
}
/** The default export of a worker's `main` module; `deactivate` may be exported beside it. */
/** A worker's `main` must export it as the DEFAULT export (`export default function
* activate(orca)`); Orca calls it once when the worker starts and never calls a named
* `activate`. An optional `export function deactivate()` is awaited before it stops. */
export type Activate = (orca: OrcaWorkerApi) => void | Promise<void>
}
+60 -5
View File
@@ -21,8 +21,9 @@ Pass `--json` to every `ORCA plugin` command and branch on `error.code`, not on
runs Orca; with `--environment` pass an absolute path on that server.
- **Types:** `ORCA plugin types <dir>` (also run by `dev load` and `dev watch`) writes
`.orca/types/`: `orca` (components panels), `orca-frame` (the global `orca` in HTML panels and
editors), `orca-worker` (`import type { OrcaWorkerApi } from 'orca/worker'`) and `orca-host`
(host methods and event payloads). The plugin's `tsconfig.json` extends
editors), `orca-worker` (`import type { OrcaWorkerApi } from 'orca/worker'`), `orca-host`
(host methods and event payloads), `orca-services` (the panel ↔ worker contract and plugin data,
merged into `orca`) and `orca-testing`. The plugin's `tsconfig.json` extends
`./.orca/types/tsconfig.json`. Never edit `.orca/`; it ignores itself in Git.
- **Ground truth:** the generated references below describe the Orca this CLI belongs to. Read
them instead of guessing props, methods or fields.
@@ -43,14 +44,63 @@ Use the lowest `execution` profile that covers the request; it decides what the
| A sidebar panel needing its own HTML, canvas or libraries | `sandboxed-ui`; `panels` entry `.html` (or `.ts`/`.tsx`) | `--template panel` |
| A palette command or shortcut for a built-in Orca action | `declarative`; `commands[].action`, `keybindings` | hand-written; fields in `references/manifest.md` |
| A command that runs code, storage, secrets, terminal text | `trusted-worker`; `main`, `commands` (no `action`), `capabilities` | example `hello-orca` |
| A panel backed by its own Node code, storage or settings | `trusted-worker`; `main`, a panel, `capabilities`, `settings` | example `hello-worker-panel` |
| Settings the user edits in Orca's Settings | `contributes.settings` and `settings:own` | `references/manifest.md` |
| Reacting to workspace or agent status changes | `trusted-worker`; `main`, `contributes.events`, `*:read` capability | example `hello-orca` |
| A virtual machine environment recipe | `declarative`; `contributes.vmRecipes` | hand-written; fields in `references/manifest.md` |
Examples are in `examples/plugins/` of github.com/stablyai/orca. Status bar items, sidebar cards,
task sources, connections, MCP servers, skills, hooks and plugin settings UI are reserved and not
buildable yet: say so instead of faking them. A panel cannot type into a terminal; only a worker
task sources, connections, MCP servers, skills and hooks are reserved and not buildable yet: say
so instead of faking them. A panel cannot type into a terminal; only a worker
with `terminal:send` can.
## Panels, workers and data
A plugin's panels and its worker are one program. Use this when a panel needs Node, long work,
secrets or state the worker also uses; a panel alone needs none of it.
- Declare the contract once in a file both include (`src/protocol.ts`):
`declare module 'orca' { interface PluginRequests { 'todos.list': { input: { done?: boolean }; output: Todo[] } } interface PluginPanelEvents { 'todos.changed': { count: number } } }`.
Declared names are typed on both sides; any other name carries JSON.
- Worker: `orca.panels.handle(name, async (input, { panel, signal }) => output)` answers;
`orca.panels.post(event, payload, { panel? })` pushes to every open panel of the plugin, or to
`panel.id` from a request. Answers are JSON (1 MiB); posts 256 KiB, 200 per 10 s.
- Panel: `plugin.request(name, input, { signal, timeoutMs })` (frames: `orca.plugin.request`)
starts the worker when needed; `plugin.on(event, handler)` hears posts. It rejects with
`unknown_method` (no handler), `timeout` (default 30 s, max 120 s; the handler's `signal`
aborts then and when the panel closes), `unavailable` (no worker), or the host error `code` the
handler threw. It needs `main`, so the plugin is `trusted-worker`.
- Every surface shares the plugin's `storage` (`storage:own`), `settings` (`settings:own`) and
`secrets` (`secrets:own`), each with `onDidChange(({ key }) => ...)` heard on all surfaces.
`atom('todos', [], { persist: 'storage' })` keeps an atom in storage under its key: the worker
reads `orca.storage.get('todos')`, and its writes update the atom.
- `contributes.settings` (with `settings:own`) declares settings the user edits under the plugin
in Orca's Settings: `{ key, title, type: 'string'|'number'|'integer'|'boolean', default, enum,
minimum, maximum, multiline, ... }`. `settings.get(key)` reads the user's value or the default.
- Panels hear workspace and agent events with `on('agent.status.changed', ...)` (frames:
`orca.on`) and the same `*:read` capability as workers, without `contributes.events`.
- Test with `mockHost({ requests, storage, settings, secrets })` and `panel.post(event, payload)`
(see Test and check).
- The worker's `main` must `export default function activate(orca)`; a named
`export function activate` is never called (the worker fails to start, saying so). It runs once
when the worker starts; an optional `export function deactivate()` is awaited before it stops.
An idle worker stops and restarts on its next command, event or panel request.
### Recipe: live agent status in a panel
`sandboxed-ui` is enough; capabilities `agent:read` and `workspace:read`.
1. In `setup(ctx)`, `host.call('agents.list')` once: each agent's `terminalId`, `title`, `agent`,
`state` and `stateStartedAt` in the focused workspace.
2. Push `on('agent.status.changed', ({ terminalId, title, state }) => ...)` into
`ctx.subscriptions` and update the row with that `terminalId` (the id
`workspace.readContext` lists and `terminal.sendText` takes; `paneKey` is Orca's own id).
3. Push `on('workspace.changed', ...)` too and re-run `agents.list`: it fires when focus moves
or terminals open, close or are renamed, with the new `readContext` answer in `context`.
4. Keep the rows in an atom and render them as `List`/`ListItem`. Never poll.
5. Test it with `mockHost({ results: { 'agents.list': { agents: [...] } } })` and
`panel.emit('agent.status.changed', payload)`.
## No build step
- `.ts`, `.tsx`, `.mts` and `.jsx` load as written; transpiling strips types and never
@@ -126,6 +176,10 @@ test('adds a task', async () => {
- `mockHost({ capabilities, results })` (pass as `host`) refuses what Orca refuses: undeclared
capabilities, bad params, and `shell.openExternal`/`files.saveAs` without a `press` first.
`results` values or functions may throw `hostError(code)`; `callsOf(method)` records calls.
- It keeps `storage`, `settings` and `secrets` in memory (seed them in `mockHost({ ... })`, read
them with `host.data()`), so `persist: 'storage'` atoms and `onDidChange` work under `mount`.
`requests: { name: answer | (input) => answer }` fakes the worker for `plugin.request`;
`panel.post(event, payload)` is a worker post (`plugin.on`); `panel.emit` is an Orca event.
- Mount the module, never call its default export: without `mount` there is no runtime. A
control with `value` but no `onChange` is read-only, so `type`/`toggle` throw on it.
@@ -161,7 +215,8 @@ Settings → Plugins (on a windowless `orca serve` host, `ORCA plugin system on`
- `update(atom, fn)` takes an updater function only; `set(atom, value)` stores a value.
- Keyed atoms (`atom('draft', '')`) survive a hot reload; unkeyed `atom(0)`, module variables and
`setup` start over. `persist: 'plugin'` or `'workspace'` survive restarts and must be plain
JSON; bump `version` (with `migrate`) when the stored shape changes.
JSON; bump `version` (with `migrate`) when the stored shape changes. `persist: 'storage'` lives
in the plugin's storage (`storage:own`), unversioned, shared with the worker.
- Components panels have no hooks and no DOM: state lives in atoms, and `read()` while rendering
subscribes. `set` and `update` throw while rendering. Give reordering list items a `key`.
- A save that fails to load keeps the last good render with an inline `file:line:column` error.
@@ -53,18 +53,32 @@ Everything a components panel (`"ui": "components"`) imports comes from `orca`.
- `env.locale: string`: Orca's UI language (BCP-47).
- `env.theme: Theme`: Reading it while rendering re-renders on light/dark switches.
- `env.client: 'desktop'`
- `shell.openExternal(url: string)`: Needs a press on one of the panel's controls; see "User gestures".
- `files.saveAs(options: HostMethods['files.saveAs']['params'])`: Needs a press on one of the panel's controls; see "User gestures".
- `ui.catalog: number`: Grows with the component catalog.
- `ui.has(component: string, prop?: string)`: Whether this Orca draws `component` (and accepts `prop` on it, when given).
- `shell.openExternal(url: string)`: Needs a press on one of the panel's controls; see "User gestures".
- `files.saveAs(options: HostMethods['files.saveAs']['params'])`: Needs a press on one of the panel's controls; see "User gestures".
- `plugin: PluginBridgeApi`
- `storage: StorageApi`
- `settings: SettingsApi`
- `secrets: SecretsApi`
## Panel ↔ worker contract
Declare requests and worker posts once with declaration merging, in a file both the panel and the worker include: `declare module 'orca' { interface PluginRequests { 'todos.list': { input: { done?: boolean }; output: Todo[] } } interface PluginPanelEvents { 'todos.changed': { count: number } } }`. Declared names are typed end to end (`plugin.request`, `plugin.on`, the worker's `orca.panels.handle` and `orca.panels.post`, and `orca/testing`'s `requests` and `panel.post`); any other name carries JSON.
## Types
- `PersistScope = 'memory' | 'plugin' | 'workspace'`: Where a keyed atom survives: this panel run, every run of the plugin, or per workspace.
- `PersistScope = 'memory' | 'plugin' | 'workspace' | 'storage'`: Where a keyed atom survives: this panel run, every run of the plugin, per workspace, or the plugin's own storage under the atom's key (`storage:own`), shared with its worker and other panels and kept current when they change it.
- `AtomOptions<T> = { persist?: PersistScope; version?: number; migrate?: (stored: JsonValue, fromVersion: number) => T }`
- `PanelContext = { subscriptions: Disposable[]; signal: AbortSignal; visible: ReadonlyAtom<boolean>; panel: { id: string } }`: Passed to the module's optional `export function setup(ctx)`.
- `PanelSetup = (ctx: PanelContext) => void | (() => void)`: Runs once, after persisted atoms load and before the first render.
- `HostError = Error & { code: string; retryable: boolean; retryAfterMs?: number }`: Rejections carry a `code` (`not_permitted`, `capability_denied`, `timeout`, ...).
- `PluginRequestOptions = { signal?: AbortSignal; timeoutMs?: number }`: `timeoutMs` (at most 120000, default 30000) is how long the worker may take.
- `PluginBridgeApi = { request<K extends keyof PluginRequests>(name: K, ...args: PluginRequestArgs<K>): Promise<PluginRequestOutput<K>>; request<N extends string>(name: Undeclared<N, PluginRequests>, input?: JsonValue, options?: PluginRequestOptions): Promise<JsonValue>; on<K extends keyof PluginPanelEvents>(event: K, handler: (payload: PluginPanelEvents[K]) => void): Disposable; on<N extends string>(event: Undeclared<N, PluginPanelEvents>, handler: (payload: JsonValue) => void): Disposable }`: Your plugin's own worker.
- `DataChange = { key: string }`: A key someone (you, another panel, the worker or the person in Settings) changed.
- `StorageApi = { get(key: string): Promise<JsonValue | null>; set(key: string, value: JsonValue): Promise<void>; delete(key: string): Promise<void>; keys(): Promise<string[]>; onDidChange(listener: (change: DataChange) => void): Disposable }`: The plugin's own JSON store (`storage:own`), the same one its worker uses.
- `SettingsApi = { get(): Promise<Record<string, JsonValue>>; get(key: string): Promise<JsonValue | undefined>; set(key: string, value: JsonValue): Promise<void>; onDidChange(listener: (change: DataChange) => void): Disposable }`: The plugin's settings (`settings:own`): those it declares in `contributes.settings` read as their `default` until the person changes them in Settings.
- `SecretsApi = { get(key: string): Promise<string | null>; set(key: string, value: string): Promise<void>; delete(key: string): Promise<void> }`: The plugin's encrypted secrets (`secrets:own`).
## Limits
@@ -10,8 +10,10 @@ List each in `contributes.events` (`{ "on": "<event>" }`), with `main`, the `tru
`{ 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.
`{ workspaceId: string | null; paneKey: string; state: "working" | "blocked" | "waiting" | "done"; receivedAt: number; mainAgent?: { state: "working" | "blocked" | "waiting" | "done"; outcome?: "success" | "failure" | "cancellation" | "superseded" | "interruption" | "unconfirmed"; stateStartedAt: number } }`
- `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.
`{ context: { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }`
## Frame events (`orca.on(event, handler)` in HTML panels and custom editors)
@@ -22,3 +24,4 @@ List each in `contributes.events` (`{ "on": "<event>" }`), with `main`, the `tru
## Components panel events (`on(event, handler)` from `orca`)
- `theme: { colorScheme: 'light' | 'dark' }`
- Every worker event above, with the same payload and capability; a panel needs no `contributes.events` entry, it subscribes while it is open. HTML panels get them on `orca.on` too.
@@ -2,13 +2,13 @@
<!-- Generated by config/scripts/generate-plugin-authoring-skill-references.mjs. Do not edit. -->
Host API 1.1 (`"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.2 (`"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`
- Panel: global `orca` in an HTML panel; `host` imported from `orca` in a components panel: `workspace.readContext`, `notifications.show`, `shell.openExternal`, `files.saveAs`
- Custom editor: global `orca` in the editor frame: `workspace.readContext`, `notifications.show`, `shell.openExternal`, `files.saveAs`
- 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`
- 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`
- 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`
## Worker API (`import type { OrcaWorkerApi } from 'orca/worker'`)
@@ -16,6 +16,10 @@ Host API 1.1 (`"pluginApi": 1`). Call `host.call(method, params, { signal })`; a
- `commands.register(commandId: string, handler: (args: unknown, context: CommandContext) => unknown)`: A string result is shown as-is in Orca; anything else as JSON.
- `events.on(event: E, handler: (payload: PluginEvents[E]) => void | Promise<void>)`: Only events listed in the manifest's `contributes.events` arrive.
- `host: HostApi<'worker'>`
- `panels: PanelsApi`: This plugin's panels and custom editors.
- `storage: StorageApi`: Same store as panels' `storage` (`storage:own`); `onDidChange` hears their writes too.
- `settings: SettingsApi`: `settings:own`; `onDidChange` also hears the person changing them in Settings.
- `secrets: SecretsApi`: `secrets:own`.
- `grantedCapabilities: readonly string[]`: Consented capability kinds; informational, the host re-checks every call.
- `log(message: string)`: Lands in `orca plugin logs`.
@@ -25,7 +29,11 @@ Host API 1.1 (`"pluginApi": 1`). Call `host.call(method, params, { signal })`; a
- `host: HostApi<'panel' | 'editor'>`
- `on(event: E, handler: (payload: FrameEvents[E]) => unknown)`
- `locale: string`: Orca's UI language as a BCP-47 tag.
- `shell.openExternal(url: string)`: Desktop isolated frames only; needs a click or key press in the frame.
- `plugin: PluginBridgeApi`: Your plugin's own worker: `request` its handlers, hear what it `post`s.
- `storage: StorageApi`: The plugin's own storage, the same its worker uses (`storage:own`).
- `settings: SettingsApi`: The plugin's settings, declared ones with their defaults (`settings:own`).
- `secrets: SecretsApi`: The plugin's encrypted secrets (`secrets:own`).
- `shell.openExternal(url: string)`: `reason`: the person cancelled, or Orca's window was hidden so nobody could confirm.
- `files.saveAs(options: SaveAsOptions)`: Desktop isolated frames only; `cancelled` when the person closes the dialog.
- `document?: EditorDocumentApi`: Custom editors only.
- `document.get()`
@@ -35,35 +43,41 @@ Host API 1.1 (`"pluginApi": 1`). Call `host.call(method, params, { signal })`; a
## 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 }[] } | null`
`(params?: {}) => { 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.
`(params: { title: string; body?: string }) => { delivered: boolean }`
- `storage.get` (capability `storage:own`; worker; since 1.0): Reads a JSON value from the plugin's private store; null when absent.
- `storage.get` (capability `storage:own`; worker, panel, editor; since 1.0): Reads a JSON value from the plugin's private store; null when absent.
`(params: { key: string }) => { value: JSON }`
- `storage.set` (capability `storage:own`; worker; since 1.0): Writes a JSON value to the plugin's private store.
- `storage.set` (capability `storage:own`; worker, panel, editor; since 1.0): Writes a JSON value to the plugin's private store.
`(params: { key: string; value: JSON }) => { ok: true }`
- `storage.delete` (capability `storage:own`; worker; since 1.0): Removes a key from the plugin's private store.
- `storage.delete` (capability `storage:own`; worker, panel, editor; since 1.0): Removes a key from the plugin's private store.
`(params: { key: string }) => { ok: true }`
- `storage.keys` (capability `storage:own`; worker; since 1.0): Lists the keys in the plugin's private store.
- `storage.keys` (capability `storage:own`; worker, panel, editor; since 1.0): Lists the keys in the plugin's private store.
`(params?: {}) => { keys: string[] }`
- `secrets.get` (capability `secrets:own`; worker; since 1.0): Reads a secret from the plugin's encrypted secrets file; null when absent.
- `secrets.get` (capability `secrets:own`; worker, panel, editor; since 1.0): Reads a secret from the plugin's encrypted secrets file; null when absent.
`(params: { key: string }) => { value: string | null }`
- `secrets.set` (capability `secrets:own`; worker; since 1.0): Stores a secret in the plugin's encrypted secrets file.
- `secrets.set` (capability `secrets:own`; worker, panel, editor; since 1.0): Stores a secret in the plugin's encrypted secrets file.
`(params: { key: string; value: string }) => { ok: true }`
- `secrets.delete` (capability `secrets:own`; worker; since 1.0): Removes a secret from the plugin's encrypted secrets file.
- `secrets.delete` (capability `secrets:own`; worker, panel, editor; since 1.0): Removes a secret from the plugin's encrypted secrets file.
`(params: { key: string }) => { ok: true }`
- `secrets.protection` (capability `secrets:own`; worker; since 1.1): How secrets are protected at rest: `os-keyring`, or `unavailable` (no OS key store, or Linux `basic_text`), where `secrets.set` fails.
- `secrets.protection` (capability `secrets:own`; worker, panel, editor; since 1.1): How secrets are protected at rest: `os-keyring`, or `unavailable` (no OS key store, or Linux `basic_text`), where `secrets.set` fails.
`(params?: {}) => { status: "os-keyring" | "unavailable" }`
- `settings.get` (capability `settings:own`; worker; since 1.0): Reads every setting the plugin owns.
- `settings.get` (capability `settings:own`; worker, panel, editor; since 1.0): Reads every setting the plugin owns.
`(params?: {}) => { settings: Record<string, JSON> }`
- `settings.set` (capability `settings:own`; worker; since 1.0): Writes one setting the plugin owns.
- `settings.set` (capability `settings:own`; worker, panel, editor; since 1.0): Writes one setting the plugin owns.
`(params: { key: string; value: JSON }) => { ok: true }`
- `shell.openExternal` (no capability; panel, editor; since 1.1; needs a user gesture): Opens an http:, https: or mailto: URL in the default browser. Needs a click or key press in the frame; desktop app only. Orca asks the person before an http(s) host the plugin was not allowed; `opened` is false if they cancel.
`(params: { url: string }) => { opened: boolean }`
`(params: { url: string }) => { opened: boolean; reason?: "cancelled" | "consent-unavailable" }`
- `files.saveAs` (no capability; panel, editor; since 1.1; needs a user gesture): Offers data (a string or ArrayBuffer, up to 64 MiB) in Orca's save dialog; the plugin never learns the path. Needs a click or key press in the frame; desktop app only.
`(params: { suggestedName?: string; mimeType?: string; data: string | ArrayBuffer }) => { status: "saved" | "cancelled" }`
- `plugin.request` (no capability; panel, editor; since 1.2): Asks the plugin's own worker to run the handler it registered with `orca.panels.handle(name)`, starting the worker if needed; resolves with what the handler returned. `unknown_method` when no handler has that name.
`(params: { name: string; input?: JSON; timeoutMs?: number }) => { output: JSON }`
- `agents.list` (capability `agent:read`; worker, panel, editor; since 1.2): The agents in the focused workspace: their terminal, title, agent kind and status. `agent.status.changed` reports each change after.
`(params?: {}) => { agents: { terminalId: string | null; title: string | null; agent: string | null; state: "working" | "blocked" | "waiting" | "done"; stateStartedAt: number }[] }`
- `events.subscribe` (no capability; panel, editor; since 1.2): Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the event's read capability. The frame APIs call it for you.
`(params: { event: "workspace.created" | "workspace.removed" | "agent.status.changed" | "workspace.changed" }) => { ok: true }`
## Capabilities
@@ -35,7 +35,7 @@
- `panels` (PanelContribution[], since 1.4.160): Sidebar panels. At most 64.
- `commands` (CommandContribution[], since 1.4.160): Command palette entries. At most 256.
- `events` (EventContribution[], since 1.4.160): Events delivered to the plugin worker. At most 3.
- `events` (EventContribution[], since 1.4.160): Events delivered to the plugin worker. At most 4.
- `languagePacks` (LanguagePackContribution[], since 1.4.160): Interface translations. At most 16.
- `keybindings` (KeybindingContribution[], since 1.4.160): Keyboard shortcuts for contributed commands. At most 256.
- `vmRecipes` (VmRecipeContribution[], since 1.4.160): Virtual machine environment recipes. At most 64.
@@ -45,8 +45,9 @@
- `syntaxes` (SyntaxContribution[], since 1.4.245): Syntax highlighting for editor languages. At most 16.
- `customEditors` (CustomEditorContribution[], since 1.4.245): Editors that open files of given types in the editor tab. At most 16.
- `newFiles` (NewFileContribution[], since 1.4.245): Untitled files the user can create from the + menu and the command palette. At most 16.
- `settings` (SettingContribution[], since 1.4.246): Settings Orca shows under the plugin in Settings; code reads them with `settings.get` (`settings:own`). At most 64.
Reserved (must stay empty arrays): `agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `settings`, `statusBarItems`, `sidebarCards`.
Reserved (must stay empty arrays): `agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `statusBarItems`, `sidebarCards`.
## PanelContribution
@@ -71,7 +72,7 @@ A command palette entry. Requires the `declarative` execution profile or higher.
An event subscription. Requires `main`, the `trusted-worker` execution profile and the capability of the event's domain: `workspace:read` for workspace events, `agent:read` for agent events.
- `on` ("workspace.created" | "workspace.removed" | "agent.status.changed", required, since 1.4.160): Event delivered to the plugin worker.
- `on` ("workspace.created" | "workspace.removed" | "agent.status.changed" | "workspace.changed", required, since 1.4.160): Event delivered to the plugin worker.
## LanguagePackContribution
@@ -138,6 +139,23 @@ A kind of untitled file the user can create from the tab bar + menu or the comma
- `template` (string, since 1.4.245): UTF-8 file copied into the new file, at most 256 KB. Without one the file starts empty.
- `icon` (string, since 1.4.245): Square image shown beside the menu item. Defaults to the plugin icon. PNG, WEBP, static, at least 128×128, square, at most 256 KB.
## SettingContribution
A setting shown under the plugin in Settings. Requires the `settings:own` capability, which is also how code reads it.
- `key` (string, required, since 1.4.246): Key code reads and writes, unique in this plugin.
- `title` (string, required, since 1.4.246): Label shown in Settings.
- `description` (string, since 1.4.246): Help text shown under the label.
- `type` ("string" | "number" | "integer" | "boolean", required, since 1.4.246): JSON Schema type of the value.
- `default` (string | number | boolean, since 1.4.246): Value read while the person has not set one.
- `enum` (string | number[], since 1.4.246): Allowed values; Settings shows a picker.
- `enumLabels` (string[], since 1.4.246): Picker labels, one per `enum` value.
- `minimum` (number, since 1.4.246): Numbers: smallest value.
- `maximum` (number, since 1.4.246): Numbers: largest value.
- `maxLength` (integer, since 1.4.246): Strings: most characters.
- `multiline` (boolean, since 1.4.246): Strings: a multi-line text box.
- `placeholder` (string, since 1.4.246): Strings and numbers: hint shown while empty.
## Capability
A host API the plugin may call once the user consents.
+134 -30
View File
@@ -2527,8 +2527,9 @@ const ORCA_PLUGIN_AUTHORING_MARKDOWN = [
" runs Orca; with `--environment` pass an absolute path on that server.",
"- **Types:** `ORCA plugin types <dir>` (also run by `dev load` and `dev watch`) writes",
" `.orca/types/`: `orca` (components panels), `orca-frame` (the global `orca` in HTML panels and",
" editors), `orca-worker` (`import type { OrcaWorkerApi } from 'orca/worker'`) and `orca-host`",
" (host methods and event payloads). The plugin's `tsconfig.json` extends",
" editors), `orca-worker` (`import type { OrcaWorkerApi } from 'orca/worker'`), `orca-host`",
" (host methods and event payloads), `orca-services` (the panel ↔ worker contract and plugin data,",
" merged into `orca`) and `orca-testing`. The plugin's `tsconfig.json` extends",
" `./.orca/types/tsconfig.json`. Never edit `.orca/`; it ignores itself in Git.",
"- **Ground truth:** the generated references below describe the Orca this CLI belongs to. Read",
" them instead of guessing props, methods or fields.",
@@ -2549,14 +2550,63 @@ const ORCA_PLUGIN_AUTHORING_MARKDOWN = [
"| A sidebar panel needing its own HTML, canvas or libraries | `sandboxed-ui`; `panels` entry `.html` (or `.ts`/`.tsx`) | `--template panel` |",
"| A palette command or shortcut for a built-in Orca action | `declarative`; `commands[].action`, `keybindings` | hand-written; fields in `references/manifest.md` |",
"| A command that runs code, storage, secrets, terminal text | `trusted-worker`; `main`, `commands` (no `action`), `capabilities` | example `hello-orca` |",
"| A panel backed by its own Node code, storage or settings | `trusted-worker`; `main`, a panel, `capabilities`, `settings` | example `hello-worker-panel` |",
"| Settings the user edits in Orca's Settings | `contributes.settings` and `settings:own` | `references/manifest.md` |",
"| Reacting to workspace or agent status changes | `trusted-worker`; `main`, `contributes.events`, `*:read` capability | example `hello-orca` |",
"| A virtual machine environment recipe | `declarative`; `contributes.vmRecipes` | hand-written; fields in `references/manifest.md` |",
"",
"Examples are in `examples/plugins/` of github.com/stablyai/orca. Status bar items, sidebar cards,",
"task sources, connections, MCP servers, skills, hooks and plugin settings UI are reserved and not",
"buildable yet: say so instead of faking them. A panel cannot type into a terminal; only a worker",
"task sources, connections, MCP servers, skills and hooks are reserved and not buildable yet: say",
"so instead of faking them. A panel cannot type into a terminal; only a worker",
"with `terminal:send` can.",
"",
"## Panels, workers and data",
"",
"A plugin's panels and its worker are one program. Use this when a panel needs Node, long work,",
"secrets or state the worker also uses; a panel alone needs none of it.",
"",
"- Declare the contract once in a file both include (`src/protocol.ts`):",
" `declare module 'orca' { interface PluginRequests { 'todos.list': { input: { done?: boolean }; output: Todo[] } } interface PluginPanelEvents { 'todos.changed': { count: number } } }`.",
" Declared names are typed on both sides; any other name carries JSON.",
"- Worker: `orca.panels.handle(name, async (input, { panel, signal }) => output)` answers;",
" `orca.panels.post(event, payload, { panel? })` pushes to every open panel of the plugin, or to",
" `panel.id` from a request. Answers are JSON (1 MiB); posts 256 KiB, 200 per 10 s.",
"- Panel: `plugin.request(name, input, { signal, timeoutMs })` (frames: `orca.plugin.request`)",
" starts the worker when needed; `plugin.on(event, handler)` hears posts. It rejects with",
" `unknown_method` (no handler), `timeout` (default 30 s, max 120 s; the handler's `signal`",
" aborts then and when the panel closes), `unavailable` (no worker), or the host error `code` the",
" handler threw. It needs `main`, so the plugin is `trusted-worker`.",
"- Every surface shares the plugin's `storage` (`storage:own`), `settings` (`settings:own`) and",
" `secrets` (`secrets:own`), each with `onDidChange(({ key }) => ...)` heard on all surfaces.",
" `atom('todos', [], { persist: 'storage' })` keeps an atom in storage under its key: the worker",
" reads `orca.storage.get('todos')`, and its writes update the atom.",
"- `contributes.settings` (with `settings:own`) declares settings the user edits under the plugin",
" in Orca's Settings: `{ key, title, type: 'string'|'number'|'integer'|'boolean', default, enum,",
" minimum, maximum, multiline, ... }`. `settings.get(key)` reads the user's value or the default.",
"- Panels hear workspace and agent events with `on('agent.status.changed', ...)` (frames:",
" `orca.on`) and the same `*:read` capability as workers, without `contributes.events`.",
"- Test with `mockHost({ requests, storage, settings, secrets })` and `panel.post(event, payload)`",
" (see Test and check).",
"- The worker's `main` must `export default function activate(orca)`; a named",
" `export function activate` is never called (the worker fails to start, saying so). It runs once",
" when the worker starts; an optional `export function deactivate()` is awaited before it stops.",
" An idle worker stops and restarts on its next command, event or panel request.",
"",
"### Recipe: live agent status in a panel",
"",
"`sandboxed-ui` is enough; capabilities `agent:read` and `workspace:read`.",
"",
"1. In `setup(ctx)`, `host.call('agents.list')` once: each agent's `terminalId`, `title`, `agent`,",
" `state` and `stateStartedAt` in the focused workspace.",
"2. Push `on('agent.status.changed', ({ terminalId, title, state }) => ...)` into",
" `ctx.subscriptions` and update the row with that `terminalId` (the id",
" `workspace.readContext` lists and `terminal.sendText` takes; `paneKey` is Orca's own id).",
"3. Push `on('workspace.changed', ...)` too and re-run `agents.list`: it fires when focus moves",
" or terminals open, close or are renamed, with the new `readContext` answer in `context`.",
"4. Keep the rows in an atom and render them as `List`/`ListItem`. Never poll.",
"5. Test it with `mockHost({ results: { 'agents.list': { agents: [...] } } })` and",
" `panel.emit('agent.status.changed', payload)`.",
"",
"## No build step",
"",
"- `.ts`, `.tsx`, `.mts` and `.jsx` load as written; transpiling strips types and never",
@@ -2632,6 +2682,10 @@ const ORCA_PLUGIN_AUTHORING_MARKDOWN = [
"- `mockHost({ capabilities, results })` (pass as `host`) refuses what Orca refuses: undeclared",
" capabilities, bad params, and `shell.openExternal`/`files.saveAs` without a `press` first.",
" `results` values or functions may throw `hostError(code)`; `callsOf(method)` records calls.",
"- It keeps `storage`, `settings` and `secrets` in memory (seed them in `mockHost({ ... })`, read",
" them with `host.data()`), so `persist: 'storage'` atoms and `onDidChange` work under `mount`.",
" `requests: { name: answer | (input) => answer }` fakes the worker for `plugin.request`;",
" `panel.post(event, payload)` is a worker post (`plugin.on`); `panel.emit` is an Orca event.",
"- Mount the module, never call its default export: without `mount` there is no runtime. A",
" control with `value` but no `onChange` is read-only, so `type`/`toggle` throw on it.",
"",
@@ -2667,7 +2721,8 @@ const ORCA_PLUGIN_AUTHORING_MARKDOWN = [
"- `update(atom, fn)` takes an updater function only; `set(atom, value)` stores a value.",
"- Keyed atoms (`atom('draft', '')`) survive a hot reload; unkeyed `atom(0)`, module variables and",
" `setup` start over. `persist: 'plugin'` or `'workspace'` survive restarts and must be plain",
" JSON; bump `version` (with `migrate`) when the stored shape changes.",
" JSON; bump `version` (with `migrate`) when the stored shape changes. `persist: 'storage'` lives",
" in the plugin's storage (`storage:own`), unversioned, shared with the worker.",
"- Components panels have no hooks and no DOM: state lives in atoms, and `read()` while rendering",
" subscribes. `set` and `update` throw while rendering. Give reordering list items a `key`.",
"- A save that fails to load keeps the last good render with an inline `file:line:column` error.",
@@ -2786,18 +2841,32 @@ const ORCA_PLUGIN_AUTHORING_COMPONENTS_REFERENCE_MARKDOWN = [
"- `env.locale: string`: Orca's UI language (BCP-47).",
"- `env.theme: Theme`: Reading it while rendering re-renders on light/dark switches.",
"- `env.client: 'desktop'`",
"- `shell.openExternal(url: string)`: Needs a press on one of the panel's controls; see \"User gestures\".",
"- `files.saveAs(options: HostMethods['files.saveAs']['params'])`: Needs a press on one of the panel's controls; see \"User gestures\".",
"- `ui.catalog: number`: Grows with the component catalog.",
"- `ui.has(component: string, prop?: string)`: Whether this Orca draws `component` (and accepts `prop` on it, when given).",
"- `shell.openExternal(url: string)`: Needs a press on one of the panel's controls; see \"User gestures\".",
"- `files.saveAs(options: HostMethods['files.saveAs']['params'])`: Needs a press on one of the panel's controls; see \"User gestures\".",
"- `plugin: PluginBridgeApi`",
"- `storage: StorageApi`",
"- `settings: SettingsApi`",
"- `secrets: SecretsApi`",
"",
"## Panel ↔ worker contract",
"",
"Declare requests and worker posts once with declaration merging, in a file both the panel and the worker include: `declare module 'orca' { interface PluginRequests { 'todos.list': { input: { done?: boolean }; output: Todo[] } } interface PluginPanelEvents { 'todos.changed': { count: number } } }`. Declared names are typed end to end (`plugin.request`, `plugin.on`, the worker's `orca.panels.handle` and `orca.panels.post`, and `orca/testing`'s `requests` and `panel.post`); any other name carries JSON.",
"",
"## Types",
"",
"- `PersistScope = 'memory' | 'plugin' | 'workspace'`: Where a keyed atom survives: this panel run, every run of the plugin, or per workspace.",
"- `PersistScope = 'memory' | 'plugin' | 'workspace' | 'storage'`: Where a keyed atom survives: this panel run, every run of the plugin, per workspace, or the plugin's own storage under the atom's key (`storage:own`), shared with its worker and other panels and kept current when they change it.",
"- `AtomOptions<T> = { persist?: PersistScope; version?: number; migrate?: (stored: JsonValue, fromVersion: number) => T }`",
"- `PanelContext = { subscriptions: Disposable[]; signal: AbortSignal; visible: ReadonlyAtom<boolean>; panel: { id: string } }`: Passed to the module's optional `export function setup(ctx)`.",
"- `PanelSetup = (ctx: PanelContext) => void | (() => void)`: Runs once, after persisted atoms load and before the first render.",
"- `HostError = Error & { code: string; retryable: boolean; retryAfterMs?: number }`: Rejections carry a `code` (`not_permitted`, `capability_denied`, `timeout`, ...).",
"- `PluginRequestOptions = { signal?: AbortSignal; timeoutMs?: number }`: `timeoutMs` (at most 120000, default 30000) is how long the worker may take.",
"- `PluginBridgeApi = { request<K extends keyof PluginRequests>(name: K, ...args: PluginRequestArgs<K>): Promise<PluginRequestOutput<K>>; request<N extends string>(name: Undeclared<N, PluginRequests>, input?: JsonValue, options?: PluginRequestOptions): Promise<JsonValue>; on<K extends keyof PluginPanelEvents>(event: K, handler: (payload: PluginPanelEvents[K]) => void): Disposable; on<N extends string>(event: Undeclared<N, PluginPanelEvents>, handler: (payload: JsonValue) => void): Disposable }`: Your plugin's own worker.",
"- `DataChange = { key: string }`: A key someone (you, another panel, the worker or the person in Settings) changed.",
"- `StorageApi = { get(key: string): Promise<JsonValue | null>; set(key: string, value: JsonValue): Promise<void>; delete(key: string): Promise<void>; keys(): Promise<string[]>; onDidChange(listener: (change: DataChange) => void): Disposable }`: The plugin's own JSON store (`storage:own`), the same one its worker uses.",
"- `SettingsApi = { get(): Promise<Record<string, JsonValue>>; get(key: string): Promise<JsonValue | undefined>; set(key: string, value: JsonValue): Promise<void>; onDidChange(listener: (change: DataChange) => void): Disposable }`: The plugin's settings (`settings:own`): those it declares in `contributes.settings` read as their `default` until the person changes them in Settings.",
"- `SecretsApi = { get(key: string): Promise<string | null>; set(key: string, value: string): Promise<void>; delete(key: string): Promise<void> }`: The plugin's encrypted secrets (`secrets:own`).",
"",
"## Limits",
"",
@@ -2819,8 +2888,10 @@ const ORCA_PLUGIN_AUTHORING_EVENTS_REFERENCE_MARKDOWN = [
" `{ 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.",
" `{ workspaceId: string | null; paneKey: string; state: \"working\" | \"blocked\" | \"waiting\" | \"done\"; receivedAt: number; mainAgent?: { state: \"working\" | \"blocked\" | \"waiting\" | \"done\"; outcome?: \"success\" | \"failure\" | \"cancellation\" | \"superseded\" | \"interruption\" | \"unconfirmed\"; stateStartedAt: number } }`",
"- `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.",
" `{ context: { branch: string; displayName: string; terminals: { id: string; title?: string }[] } | null }`",
"",
"## Frame events (`orca.on(event, handler)` in HTML panels and custom editors)",
"",
@@ -2831,6 +2902,7 @@ const ORCA_PLUGIN_AUTHORING_EVENTS_REFERENCE_MARKDOWN = [
"## Components panel events (`on(event, handler)` from `orca`)",
"",
"- `theme: { colorScheme: 'light' | 'dark' }`",
"- Every worker event above, with the same payload and capability; a panel needs no `contributes.events` entry, it subscribes while it is open. HTML panels get them on `orca.on` too.",
"",
].join('\n')
@@ -2840,13 +2912,13 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
"",
"<!-- Generated by config/scripts/generate-plugin-authoring-skill-references.mjs. Do not edit. -->",
"",
"Host API 1.1 (`\"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.2 (`\"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`",
"- Panel: global `orca` in an HTML panel; `host` imported from `orca` in a components panel: `workspace.readContext`, `notifications.show`, `shell.openExternal`, `files.saveAs`",
"- Custom editor: global `orca` in the editor frame: `workspace.readContext`, `notifications.show`, `shell.openExternal`, `files.saveAs`",
"- 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`",
"- 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`",
"- 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`",
"",
"## Worker API (`import type { OrcaWorkerApi } from 'orca/worker'`)",
"",
@@ -2854,6 +2926,10 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
"- `commands.register(commandId: string, handler: (args: unknown, context: CommandContext) => unknown)`: A string result is shown as-is in Orca; anything else as JSON.",
"- `events.on(event: E, handler: (payload: PluginEvents[E]) => void | Promise<void>)`: Only events listed in the manifest's `contributes.events` arrive.",
"- `host: HostApi<'worker'>`",
"- `panels: PanelsApi`: This plugin's panels and custom editors.",
"- `storage: StorageApi`: Same store as panels' `storage` (`storage:own`); `onDidChange` hears their writes too.",
"- `settings: SettingsApi`: `settings:own`; `onDidChange` also hears the person changing them in Settings.",
"- `secrets: SecretsApi`: `secrets:own`.",
"- `grantedCapabilities: readonly string[]`: Consented capability kinds; informational, the host re-checks every call.",
"- `log(message: string)`: Lands in `orca plugin logs`.",
"",
@@ -2863,7 +2939,11 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
"- `host: HostApi<'panel' | 'editor'>`",
"- `on(event: E, handler: (payload: FrameEvents[E]) => unknown)`",
"- `locale: string`: Orca's UI language as a BCP-47 tag.",
"- `shell.openExternal(url: string)`: Desktop isolated frames only; needs a click or key press in the frame.",
"- `plugin: PluginBridgeApi`: Your plugin's own worker: `request` its handlers, hear what it `post`s.",
"- `storage: StorageApi`: The plugin's own storage, the same its worker uses (`storage:own`).",
"- `settings: SettingsApi`: The plugin's settings, declared ones with their defaults (`settings:own`).",
"- `secrets: SecretsApi`: The plugin's encrypted secrets (`secrets:own`).",
"- `shell.openExternal(url: string)`: `reason`: the person cancelled, or Orca's window was hidden so nobody could confirm.",
"- `files.saveAs(options: SaveAsOptions)`: Desktop isolated frames only; `cancelled` when the person closes the dialog.",
"- `document?: EditorDocumentApi`: Custom editors only.",
"- `document.get()`",
@@ -2873,35 +2953,41 @@ 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 }[] } | null`",
" `(params?: {}) => { 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.",
" `(params: { title: string; body?: string }) => { delivered: boolean }`",
"- `storage.get` (capability `storage:own`; worker; since 1.0): Reads a JSON value from the plugin's private store; null when absent.",
"- `storage.get` (capability `storage:own`; worker, panel, editor; since 1.0): Reads a JSON value from the plugin's private store; null when absent.",
" `(params: { key: string }) => { value: JSON }`",
"- `storage.set` (capability `storage:own`; worker; since 1.0): Writes a JSON value to the plugin's private store.",
"- `storage.set` (capability `storage:own`; worker, panel, editor; since 1.0): Writes a JSON value to the plugin's private store.",
" `(params: { key: string; value: JSON }) => { ok: true }`",
"- `storage.delete` (capability `storage:own`; worker; since 1.0): Removes a key from the plugin's private store.",
"- `storage.delete` (capability `storage:own`; worker, panel, editor; since 1.0): Removes a key from the plugin's private store.",
" `(params: { key: string }) => { ok: true }`",
"- `storage.keys` (capability `storage:own`; worker; since 1.0): Lists the keys in the plugin's private store.",
"- `storage.keys` (capability `storage:own`; worker, panel, editor; since 1.0): Lists the keys in the plugin's private store.",
" `(params?: {}) => { keys: string[] }`",
"- `secrets.get` (capability `secrets:own`; worker; since 1.0): Reads a secret from the plugin's encrypted secrets file; null when absent.",
"- `secrets.get` (capability `secrets:own`; worker, panel, editor; since 1.0): Reads a secret from the plugin's encrypted secrets file; null when absent.",
" `(params: { key: string }) => { value: string | null }`",
"- `secrets.set` (capability `secrets:own`; worker; since 1.0): Stores a secret in the plugin's encrypted secrets file.",
"- `secrets.set` (capability `secrets:own`; worker, panel, editor; since 1.0): Stores a secret in the plugin's encrypted secrets file.",
" `(params: { key: string; value: string }) => { ok: true }`",
"- `secrets.delete` (capability `secrets:own`; worker; since 1.0): Removes a secret from the plugin's encrypted secrets file.",
"- `secrets.delete` (capability `secrets:own`; worker, panel, editor; since 1.0): Removes a secret from the plugin's encrypted secrets file.",
" `(params: { key: string }) => { ok: true }`",
"- `secrets.protection` (capability `secrets:own`; worker; since 1.1): How secrets are protected at rest: `os-keyring`, or `unavailable` (no OS key store, or Linux `basic_text`), where `secrets.set` fails.",
"- `secrets.protection` (capability `secrets:own`; worker, panel, editor; since 1.1): How secrets are protected at rest: `os-keyring`, or `unavailable` (no OS key store, or Linux `basic_text`), where `secrets.set` fails.",
" `(params?: {}) => { status: \"os-keyring\" | \"unavailable\" }`",
"- `settings.get` (capability `settings:own`; worker; since 1.0): Reads every setting the plugin owns.",
"- `settings.get` (capability `settings:own`; worker, panel, editor; since 1.0): Reads every setting the plugin owns.",
" `(params?: {}) => { settings: Record<string, JSON> }`",
"- `settings.set` (capability `settings:own`; worker; since 1.0): Writes one setting the plugin owns.",
"- `settings.set` (capability `settings:own`; worker, panel, editor; since 1.0): Writes one setting the plugin owns.",
" `(params: { key: string; value: JSON }) => { ok: true }`",
"- `shell.openExternal` (no capability; panel, editor; since 1.1; needs a user gesture): Opens an http:, https: or mailto: URL in the default browser. Needs a click or key press in the frame; desktop app only. Orca asks the person before an http(s) host the plugin was not allowed; `opened` is false if they cancel.",
" `(params: { url: string }) => { opened: boolean }`",
" `(params: { url: string }) => { opened: boolean; reason?: \"cancelled\" | \"consent-unavailable\" }`",
"- `files.saveAs` (no capability; panel, editor; since 1.1; needs a user gesture): Offers data (a string or ArrayBuffer, up to 64 MiB) in Orca's save dialog; the plugin never learns the path. Needs a click or key press in the frame; desktop app only.",
" `(params: { suggestedName?: string; mimeType?: string; data: string | ArrayBuffer }) => { status: \"saved\" | \"cancelled\" }`",
"- `plugin.request` (no capability; panel, editor; since 1.2): Asks the plugin's own worker to run the handler it registered with `orca.panels.handle(name)`, starting the worker if needed; resolves with what the handler returned. `unknown_method` when no handler has that name.",
" `(params: { name: string; input?: JSON; timeoutMs?: number }) => { output: JSON }`",
"- `agents.list` (capability `agent:read`; worker, panel, editor; since 1.2): The agents in the focused workspace: their terminal, title, agent kind and status. `agent.status.changed` reports each change after.",
" `(params?: {}) => { agents: { terminalId: string | null; title: string | null; agent: string | null; state: \"working\" | \"blocked\" | \"waiting\" | \"done\"; stateStartedAt: number }[] }`",
"- `events.subscribe` (no capability; panel, editor; since 1.2): Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the event's read capability. The frame APIs call it for you.",
" `(params: { event: \"workspace.created\" | \"workspace.removed\" | \"agent.status.changed\" | \"workspace.changed\" }) => { ok: true }`",
"",
"## Capabilities",
"",
@@ -2972,7 +3058,7 @@ const ORCA_PLUGIN_AUTHORING_MANIFEST_REFERENCE_MARKDOWN = [
"",
"- `panels` (PanelContribution[], since 1.4.160): Sidebar panels. At most 64.",
"- `commands` (CommandContribution[], since 1.4.160): Command palette entries. At most 256.",
"- `events` (EventContribution[], since 1.4.160): Events delivered to the plugin worker. At most 3.",
"- `events` (EventContribution[], since 1.4.160): Events delivered to the plugin worker. At most 4.",
"- `languagePacks` (LanguagePackContribution[], since 1.4.160): Interface translations. At most 16.",
"- `keybindings` (KeybindingContribution[], since 1.4.160): Keyboard shortcuts for contributed commands. At most 256.",
"- `vmRecipes` (VmRecipeContribution[], since 1.4.160): Virtual machine environment recipes. At most 64.",
@@ -2982,8 +3068,9 @@ const ORCA_PLUGIN_AUTHORING_MANIFEST_REFERENCE_MARKDOWN = [
"- `syntaxes` (SyntaxContribution[], since 1.4.245): Syntax highlighting for editor languages. At most 16.",
"- `customEditors` (CustomEditorContribution[], since 1.4.245): Editors that open files of given types in the editor tab. At most 16.",
"- `newFiles` (NewFileContribution[], since 1.4.245): Untitled files the user can create from the + menu and the command palette. At most 16.",
"- `settings` (SettingContribution[], since 1.4.246): Settings Orca shows under the plugin in Settings; code reads them with `settings.get` (`settings:own`). At most 64.",
"",
"Reserved (must stay empty arrays): `agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `settings`, `statusBarItems`, `sidebarCards`.",
"Reserved (must stay empty arrays): `agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `statusBarItems`, `sidebarCards`.",
"",
"## PanelContribution",
"",
@@ -3008,7 +3095,7 @@ const ORCA_PLUGIN_AUTHORING_MANIFEST_REFERENCE_MARKDOWN = [
"",
"An event subscription. Requires `main`, the `trusted-worker` execution profile and the capability of the event's domain: `workspace:read` for workspace events, `agent:read` for agent events.",
"",
"- `on` (\"workspace.created\" | \"workspace.removed\" | \"agent.status.changed\", required, since 1.4.160): Event delivered to the plugin worker.",
"- `on` (\"workspace.created\" | \"workspace.removed\" | \"agent.status.changed\" | \"workspace.changed\", required, since 1.4.160): Event delivered to the plugin worker.",
"",
"## LanguagePackContribution",
"",
@@ -3075,6 +3162,23 @@ const ORCA_PLUGIN_AUTHORING_MANIFEST_REFERENCE_MARKDOWN = [
"- `template` (string, since 1.4.245): UTF-8 file copied into the new file, at most 256 KB. Without one the file starts empty.",
"- `icon` (string, since 1.4.245): Square image shown beside the menu item. Defaults to the plugin icon. PNG, WEBP, static, at least 128×128, square, at most 256 KB.",
"",
"## SettingContribution",
"",
"A setting shown under the plugin in Settings. Requires the `settings:own` capability, which is also how code reads it.",
"",
"- `key` (string, required, since 1.4.246): Key code reads and writes, unique in this plugin.",
"- `title` (string, required, since 1.4.246): Label shown in Settings.",
"- `description` (string, since 1.4.246): Help text shown under the label.",
"- `type` (\"string\" | \"number\" | \"integer\" | \"boolean\", required, since 1.4.246): JSON Schema type of the value.",
"- `default` (string | number | boolean, since 1.4.246): Value read while the person has not set one.",
"- `enum` (string | number[], since 1.4.246): Allowed values; Settings shows a picker.",
"- `enumLabels` (string[], since 1.4.246): Picker labels, one per `enum` value.",
"- `minimum` (number, since 1.4.246): Numbers: smallest value.",
"- `maximum` (number, since 1.4.246): Numbers: largest value.",
"- `maxLength` (integer, since 1.4.246): Strings: most characters.",
"- `multiline` (boolean, since 1.4.246): Strings: a multi-line text box.",
"- `placeholder` (string, since 1.4.246): Strings and numbers: hint shown while empty.",
"",
"## Capability",
"",
"A host API the plugin may call once the user consents.",
@@ -32,6 +32,7 @@ describe('orca plugin types', () => {
'.orca/types/orca-host/index.d.ts',
'.orca/types/orca-frame/index.d.ts',
'.orca/types/orca-worker/index.d.ts',
'.orca/types/orca-services/index.d.ts',
'.orca/types/orca-testing/index.d.ts',
'.orca/types/tsconfig.json'
])
@@ -47,13 +48,14 @@ describe('orca plugin types', () => {
expect(await read('orca-frame')).toContain('var orca: OrcaFrameApi')
expect(await read('orca-worker')).toContain("declare module 'orca/worker'")
expect(await read('orca-testing')).toContain("declare module 'orca/testing'")
expect(await read('orca-services')).toContain('export interface PluginRequests {}')
const tsconfig = JSON.parse(
await readFile(join(dir, '.orca', 'types', 'tsconfig.json'), 'utf8')
)
expect(tsconfig.compilerOptions).toMatchObject({
jsxImportSource: 'orca',
typeRoots: ['.'],
types: ['orca', 'orca-host', 'orca-frame', 'orca-worker', 'orca-testing']
types: ['orca', 'orca-host', 'orca-frame', 'orca-worker', 'orca-services', 'orca-testing']
})
expect(await readFile(join(dir, '.orca', '.gitignore'), 'utf8')).toBe('*\n')
})
@@ -13,6 +13,7 @@ const PLUGIN_SURFACE_TYPINGS = [
{ dir: 'orca-host', file: 'orca-host.d.ts' },
{ dir: 'orca-frame', file: 'orca-frame.d.ts' },
{ dir: 'orca-worker', file: 'orca-worker.d.ts' },
{ dir: 'orca-services', file: 'orca-services.d.ts' },
{ dir: 'orca-testing', file: 'orca-testing.d.ts' }
] as const
+53
View File
@@ -0,0 +1,53 @@
import { ipcMain } from 'electron'
import { z } from 'zod'
import { panelSessionTokenSchema } from '../../shared/plugins/frames/plugin-frame-bridge'
import type { PluginService } from '../plugins/plugin-service'
import { rendererPanelOwner } from './plugin-frame-entry-ipc'
const panelPushArgsSchema = z.object({ sessionToken: panelSessionTokenSchema }).strict()
/** main → renderer: one open panel's worker posts, events and data changes. */
export const PLUGIN_PANEL_PUSH_CHANNEL = 'plugins:panelPush'
/**
* Desktop push channel per open panel session. It closes by itself when the session ends (the
* window navigates or reloads, the plugin goes away); the renderer closes it when its last frame
* for that session unmounts.
*/
export function registerPluginPanelPushHandlers(pluginService: PluginService): void {
const stops = new Map<string, () => void>()
ipcMain.handle('plugins:openPanelPush', async (event, args: unknown): Promise<boolean> => {
await pluginService.whenReady()
const parsed = panelPushArgsSchema.safeParse(args)
const { sender } = event
const session = parsed.success
? pluginService.panels.sessionCaller(rendererPanelOwner(sender.id), parsed.data.sessionToken)
: null
if (!parsed.success || !session) {
return false
}
const { sessionToken } = parsed.data
const key = `${sender.id}\0${sessionToken}`
if (!stops.has(key)) {
sender.once('destroyed', () => stops.delete(key))
}
stops.get(key)?.()
stops.set(
key,
pluginService.panelBridge.open(session.caller, session.pluginKey, (push) => {
if (!sender.isDestroyed()) {
sender.send(PLUGIN_PANEL_PUSH_CHANNEL, { sessionToken, push })
}
})
)
return true
})
ipcMain.handle('plugins:closePanelPush', (event, args: unknown): void => {
const parsed = panelPushArgsSchema.safeParse(args)
if (parsed.success) {
const key = `${event.sender.id}\0${parsed.data.sessionToken}`
stops.get(key)?.()
stops.delete(key)
}
})
}
+33
View File
@@ -0,0 +1,33 @@
import { ipcMain } from 'electron'
import { z } from 'zod'
import { isQualifiedPluginKey } from '../../shared/plugins/manifest/plugin-manifest'
import type { PluginService } from '../plugins/plugin-service'
import {
readPluginDeclaredSettings,
writePluginDeclaredSetting
} from '../plugins/plugin-declared-settings'
const pluginKeySchema = z.string().refine(isQualifiedPluginKey, 'invalid plugin key')
const readArgsSchema = z.object({ pluginKey: pluginKeySchema }).strict()
const writeArgsSchema = z
.object({ pluginKey: pluginKeySchema, key: z.string().min(1).max(64), value: z.json() })
.strict()
/** The plugin settings Orca's Settings page shows and edits (`contributes.settings`). */
export function registerPluginSettingsHandlers(pluginService: PluginService): void {
const host = () => ({
findValidPlugin: (pluginKey: string) => pluginService.findValidPlugin(pluginKey),
userDataPath: pluginService.options.userDataPath,
dataChanged: (pluginKey: string, key: string) =>
pluginService.panelBridge.dataChanged(pluginKey, 'settings', key)
})
ipcMain.handle('plugins:getSettings', async (_event, args: unknown) => {
await pluginService.whenReady()
return readPluginDeclaredSettings(host(), readArgsSchema.parse(args).pluginKey)
})
ipcMain.handle('plugins:setSetting', async (_event, args: unknown) => {
await pluginService.whenReady()
const { pluginKey, key, value } = writeArgsSchema.parse(args)
return writePluginDeclaredSetting(host(), pluginKey, key, value)
})
}
+4
View File
@@ -15,6 +15,8 @@ import { readApprovedPluginNewFileTemplate } from '../plugins/plugin-new-file-te
import { handlePluginPanelStateRequest } from '../plugins/panels/plugin-panel-state'
import type { PluginService } from '../plugins/plugin-service'
import { registerPluginFrameEntryHandlers, rendererPanelOwner } from './plugin-frame-entry-ipc'
import { registerPluginPanelPushHandlers } from './plugin-panel-push-ipc'
import { registerPluginSettingsHandlers } from './plugin-settings-ipc'
import {
bindPluginFrameGuests,
clearPluginFrameStorage
@@ -148,6 +150,8 @@ export function registerPluginHandlers(
bindPluginFrameGuests(pluginService)
registerPluginFrameEntryHandlers(store, pluginService)
registerPluginPanelPushHandlers(pluginService)
registerPluginSettingsHandlers(pluginService)
ipcMain.handle('plugins:readNewFileTemplate', async (_event, args: unknown) => {
const parsed = readNewFileTemplateArgsSchema.parse(args)
@@ -19,15 +19,29 @@ function grantStore() {
}
}
function confirm(store: ReturnType<typeof grantStore>, url: string, pluginId = 'acme.draw') {
type FakeWindow = { visible: boolean; minimized: boolean }
function confirm(
store: ReturnType<typeof grantStore>,
url: string,
pluginId = 'acme.draw',
shown: FakeWindow = { visible: true, minimized: false },
log?: (line: string) => void
) {
const window = {
isDestroyed: () => false,
isVisible: () => shown.visible,
isMinimized: () => shown.minimized
}
return confirmPluginExternalOpen({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the window is only handed to the mocked dialog.
window: {} as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: consent reads only these three window methods and hands it to the mocked dialog.
window: window as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the consent reads and writes only pluginExternalHostGrants.
store: store as never,
pluginId,
pluginName: 'Draw',
url
url,
log
})
}
@@ -35,7 +49,7 @@ describe('confirmPluginExternalOpen', () => {
it('asks per host, shows the URL, and opens only on Open', async () => {
const store = grantStore()
showMessageBox.mockResolvedValueOnce({ response: 1, checkboxChecked: false })
await expect(confirm(store, 'https://Evil.example/?d=secret')).resolves.toBe(false)
await expect(confirm(store, 'https://Evil.example/?d=secret')).resolves.toBe('cancelled')
expect(showMessageBox).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({
@@ -47,29 +61,49 @@ describe('confirmPluginExternalOpen', () => {
})
)
showMessageBox.mockResolvedValueOnce({ response: 0, checkboxChecked: false })
await expect(confirm(store, 'https://evil.example/')).resolves.toBe(true)
await expect(confirm(store, 'https://evil.example/')).resolves.toBe('allowed')
expect(store.updateSettings).not.toHaveBeenCalled()
})
it('remembers "always allow" per plugin and host only', async () => {
const store = grantStore()
showMessageBox.mockResolvedValueOnce({ response: 0, checkboxChecked: true })
await expect(confirm(store, 'https://docs.example/a')).resolves.toBe(true)
await expect(confirm(store, 'https://docs.example/a')).resolves.toBe('allowed')
showMessageBox.mockClear()
await expect(confirm(store, 'https://docs.example/b')).resolves.toBe(true)
await expect(confirm(store, 'https://docs.example/b')).resolves.toBe('allowed')
expect(showMessageBox).not.toHaveBeenCalled()
showMessageBox.mockResolvedValue({ response: 1, checkboxChecked: false })
await expect(confirm(store, 'https://docs.example:8443/')).resolves.toBe(false)
await expect(confirm(store, 'https://docs.example/', 'acme.other')).resolves.toBe(false)
await expect(confirm(store, 'https://docs.example:8443/')).resolves.toBe('cancelled')
await expect(confirm(store, 'https://docs.example/', 'acme.other')).resolves.toBe('cancelled')
expect(store.getSettings().pluginExternalHostGrants).toEqual({ 'acme.draw': ['docs.example'] })
})
it('never asks about mailto:, and truncates long URLs', async () => {
showMessageBox.mockClear()
await expect(confirm(grantStore(), 'mailto:a@example.com?body=x')).resolves.toBe(true)
await expect(confirm(grantStore(), 'mailto:a@example.com?body=x')).resolves.toBe('allowed')
expect(showMessageBox).not.toHaveBeenCalled()
const long = truncatedUrl(`https://x.example/?d=${'a'.repeat(1000)}`)
expect(long).toHaveLength(300)
expect(long.endsWith('…')).toBe(true)
})
it('never prompts on a hidden or minimized window, and says why in the log', async () => {
showMessageBox.mockClear()
const log = vi.fn()
const hidden = { visible: false, minimized: false }
await expect(
confirm(grantStore(), 'https://a.example/', 'acme.draw', hidden, log)
).resolves.toBe('unavailable')
const minimized = { visible: true, minimized: true }
await expect(confirm(grantStore(), 'https://a.example/', 'acme.draw', minimized)).resolves.toBe(
'unavailable'
)
expect(showMessageBox).not.toHaveBeenCalled()
expect(log).toHaveBeenCalledWith(expect.stringContaining('window is hidden'))
showMessageBox.mockResolvedValueOnce({ response: 1, checkboxChecked: false })
await confirm(grantStore(), 'https://a.example/', 'acme.draw', undefined, log)
expect(log).toHaveBeenLastCalledWith(
expect.stringContaining('waiting for the person to confirm')
)
})
})
@@ -17,6 +17,8 @@ function rememberHost(store: GrantStore, pluginId: string, host: string): void {
store.updateSettings({ pluginExternalHostGrants: grants })
}
export type PluginExternalOpenConsent = 'allowed' | 'cancelled' | 'unavailable'
export function truncatedUrl(url: string): string {
return url.length > URL_DETAIL_MAX_LENGTH ? `${url.slice(0, URL_DETAIL_MAX_LENGTH - 1)}…` : url
}
@@ -32,16 +34,26 @@ export async function confirmPluginExternalOpen(input: {
pluginId: string
pluginName: string
url: string
}): Promise<boolean> {
log?: (line: string) => void
/** Spends the rate budget (it may throw): before an open or a prompt, never for nothing. */
admit?: () => void
}): Promise<PluginExternalOpenConsent> {
const parsed = new URL(input.url)
if (parsed.protocol === 'mailto:') {
return true
}
// Punycode host (`URL` normalizes it), so a lookalike name shows as what it is.
const host = parsed.host.toLowerCase()
if (grantedHosts(input.store, input.pluginId).includes(host)) {
return true
if (parsed.protocol === 'mailto:' || grantedHosts(input.store, input.pluginId).includes(host)) {
input.admit?.()
return 'allowed'
}
// A sheet on a hidden or minimized window would wait, unseen, forever.
if (input.window.isDestroyed() || !input.window.isVisible() || input.window.isMinimized()) {
input.log?.(
`shell.openExternal(${host}): not opened; Orca's window is hidden, so nobody can confirm it`
)
return 'unavailable'
}
input.admit?.()
input.log?.(`shell.openExternal(${host}): waiting for the person to confirm`)
const { response, checkboxChecked } = await dialog.showMessageBox(input.window, {
type: 'question',
buttons: [
@@ -67,10 +79,10 @@ export async function confirmPluginExternalOpen(input: {
)
})
if (response !== 0) {
return false
return 'cancelled'
}
if (checkboxChecked) {
rememberHost(input.store, input.pluginId, host)
}
return true
return 'allowed'
}
@@ -83,15 +83,26 @@ export function installPluginDesktopFrameServices(
throw new Error('url must be an http:, https: or mailto: URL')
}
const window = callingWindow(scope)
admit(window, pluginId)
const pluginName = scope.pluginName ?? pluginId
if (!(await confirmPluginExternalOpen({ window, store, pluginId, pluginName, url }))) {
return false
const consent = await confirmPluginExternalOpen({
window,
store,
pluginId,
pluginName,
url,
log: scope.log,
admit: () => admit(window, pluginId)
})
if (consent !== 'allowed') {
return {
opened: false,
reason: consent === 'cancelled' ? 'cancelled' : 'consent-unavailable'
}
}
// The prompt may have stayed open while the plugin was disabled or removed.
scope.assertActive()
await shell.openExternal(url)
return true
return { opened: true }
},
async saveFile(pluginId, input, scope) {
const parent = callingWindow(scope)
@@ -0,0 +1,174 @@
import { describe, expect, it, vi } from 'vitest'
import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/plugin-capabilities'
import type { ValidDiscoveredPlugin } from '../plugin-discovery'
import type { PluginWorkerHandle } from '../workers/plugin-host-process'
import { PluginPanelBridge } from './plugin-panel-bridge'
const caller = { id: 'panel-a', surface: 'panel' as const, contributionId: 'todos' }
function setup(options: { main?: string; capabilities?: PluginCapabilityKind[] } = {}) {
const worker = {
requestFromPanel: vi.fn(async (_request?: { signal?: AbortSignal }): Promise<unknown> => ({
ok: true
})),
notifyDataChanged: vi.fn()
}
const plugin = { pluginKey: 'acme.todos', manifest: { main: options.main ?? 'main.mjs' } }
const log = vi.fn()
const bridge = new PluginPanelBridge({
findApprovedPlugin: (key) =>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bridge reads only pluginKey and manifest.main.
key === plugin.pluginKey ? (plugin as never as ValidDiscoveredPlugin) : null,
grantedCapabilities: () => options.capabilities ?? [],
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bridge calls only requestFromPanel and notifyDataChanged.
ensureWorker: async () => worker as never as PluginWorkerHandle,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
runningWorker: () => worker as never as PluginWorkerHandle,
log: () => log,
now: () => 0
})
return { bridge, worker, log }
}
const request = (name = 'todos.list', input: unknown = { done: false }) => ({
pluginKey: 'acme.todos',
caller,
name,
input
})
describe('PluginPanelBridge requests', () => {
it("asks the plugin's own worker, with the panel, a deadline and the panel's lifetime", async () => {
const { bridge, worker } = setup()
await expect(bridge.request(request())).resolves.toEqual({ ok: true })
expect(worker.requestFromPanel).toHaveBeenCalledWith({
name: 'todos.list',
input: { done: false },
panel: caller,
timeoutMs: 30_000,
signal: expect.any(AbortSignal)
})
})
it('aborts in-flight requests when the panel session closes', async () => {
const { bridge, worker } = setup()
let signal: AbortSignal | undefined
worker.requestFromPanel.mockImplementation(async (sent?: { signal?: AbortSignal }) => {
signal = sent?.signal
return { ok: true }
})
await bridge.request(request())
bridge.close(caller.id)
expect(signal?.aborted).toBe(true)
})
it('keeps worker error codes, and refuses plugins without a worker', async () => {
const { bridge, worker } = setup()
worker.requestFromPanel.mockRejectedValueOnce(
Object.assign(new Error('no'), { code: 'unknown_method' })
)
await expect(bridge.request(request())).rejects.toMatchObject({ code: 'unknown_method' })
await expect(setup({ main: '' }).bridge.request(request())).rejects.toMatchObject({
code: 'unavailable'
})
})
it('refuses answers over 1 MiB or that are not JSON', async () => {
const { bridge, worker } = setup()
worker.requestFromPanel.mockResolvedValueOnce({ big: 'x'.repeat(1024 * 1024 + 1) })
await expect(bridge.request(request())).rejects.toMatchObject({ code: 'too_large' })
worker.requestFromPanel.mockResolvedValueOnce({ when: new Date(0) })
await expect(bridge.request(request())).rejects.toMatchObject({ code: 'action_failed' })
worker.requestFromPanel.mockResolvedValueOnce(undefined)
await expect(bridge.request(request())).resolves.toBeNull()
})
})
describe('PluginPanelBridge pushes', () => {
it('delivers worker posts to every open panel of that plugin, or the one named', () => {
const { bridge } = setup()
const a = vi.fn()
const b = vi.fn()
const other = vi.fn()
bridge.open(caller, 'acme.todos', a)
bridge.open({ ...caller, id: 'panel-b' }, 'acme.todos', b)
bridge.open({ ...caller, id: 'panel-c' }, 'acme.other', other)
bridge.onWorkerPost('acme.todos', { event: 'todos.changed', payload: { count: 1 } })
bridge.onWorkerPost('acme.todos', { event: 'todos.changed', payload: 2, panel: 'panel-b' })
expect(a).toHaveBeenCalledTimes(1)
expect(b).toHaveBeenLastCalledWith({ kind: 'message', event: 'todos.changed', payload: 2 })
expect(other).not.toHaveBeenCalled()
})
it('drops posts past the size or rate budget, and says so in the log', () => {
const { bridge, log } = setup()
const deliver = vi.fn()
bridge.open(caller, 'acme.todos', deliver)
bridge.onWorkerPost('acme.todos', { event: 'big', payload: 'x'.repeat(300 * 1024) })
for (let index = 0; index < 205; index += 1) {
bridge.onWorkerPost('acme.todos', { event: 'tick', payload: index })
}
expect(deliver).toHaveBeenCalledTimes(199)
expect(log).toHaveBeenCalledWith(expect.stringContaining('256 KiB'))
expect(log).toHaveBeenCalledWith(expect.stringContaining('too many posts'))
})
it('sends events only to panels that subscribed and hold the read capability', () => {
const denied = setup()
expect(() => denied.bridge.subscribe('acme.todos', caller.id, 'agent.status.changed')).toThrow(
expect.objectContaining({ code: 'capability_denied' })
)
const { bridge } = setup({ capabilities: ['agent:read'] })
const subscribed = vi.fn()
const quiet = vi.fn()
bridge.open(caller, 'acme.todos', subscribed)
bridge.open({ ...caller, id: 'panel-b' }, 'acme.todos', quiet)
bridge.subscribe('acme.todos', caller.id, 'agent.status.changed')
const payload = { workspaceId: null, paneKey: 'p', state: 'done', receivedAt: 1 }
bridge.publishEvent('agent.status.changed', payload)
bridge.publishEvent('agent.status.changed', { malformed: true })
expect(subscribed).toHaveBeenCalledOnce()
expect(subscribed).toHaveBeenCalledWith({
kind: 'event',
event: 'agent.status.changed',
payload
})
expect(quiet).not.toHaveBeenCalled()
})
it('keeps a subscription made before the renderer opens the channel', () => {
const { bridge } = setup({ capabilities: ['workspace:read'] })
bridge.subscribe('acme.todos', caller.id, 'workspace.changed')
expect(bridge.hasSubscribers('workspace.changed')).toBe(false)
const deliver = vi.fn()
bridge.open(caller, 'acme.todos', deliver)
expect(bridge.hasSubscribers('workspace.changed')).toBe(true)
bridge.publishEvent('workspace.changed', { context: null })
expect(deliver).toHaveBeenCalledWith({
kind: 'event',
event: 'workspace.changed',
payload: { context: null }
})
})
it('tells open panels and the running worker which key changed', () => {
const { bridge, worker } = setup()
const deliver = vi.fn()
bridge.open(caller, 'acme.todos', deliver)
bridge.dataChanged('acme.todos', 'storage', 'todos')
expect(deliver).toHaveBeenCalledWith({ kind: 'changed', area: 'storage', key: 'todos' })
expect(worker.notifyDataChanged).toHaveBeenCalledWith({ area: 'storage', key: 'todos' })
})
it('stops delivering once its channel is closed or the session ends', () => {
const { bridge } = setup()
const deliver = vi.fn()
const stop = bridge.open(caller, 'acme.todos', deliver)
stop()
bridge.onWorkerPost('acme.todos', { event: 'x', payload: 1 })
bridge.open(caller, 'acme.todos', deliver)
bridge.close(caller.id)
bridge.onWorkerPost('acme.todos', { event: 'x', payload: 1 })
expect(deliver).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,228 @@
import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/plugin-capabilities'
import { PLUGIN_EVENT_CAPABILITY } from '../../../shared/plugins/contributions/plugin-event-contribution'
import type { PluginEventName } from '../../../shared/plugins/manifest/plugin-manifest'
import { PLUGIN_EVENT_PAYLOAD_SCHEMAS } from '../../../shared/plugins/events/plugin-events'
import {
createPanelMessageBudget,
structuredCloneMessageBytes,
type PanelMessageBudget
} from '../../../shared/plugins/frames/plugin-panel-message-budget'
import {
PLUGIN_PANEL_POST_MAX_BYTES,
PLUGIN_PANEL_POST_RATE_LIMIT,
PLUGIN_PANEL_REQUEST_DEFAULT_TIMEOUT_MS,
PLUGIN_PANEL_RESULT_MAX_BYTES,
type PluginDataArea,
type PluginPanelCaller,
type PluginPanelPush,
type PluginWorkerPanelPost
} from '../../../shared/plugins/workers/plugin-panel-bridge-protocol'
import { normalizePluginErrorCode } from '../../../shared/plugins/frames/plugin-protocol'
import type { PluginJsonValue } from '../../../shared/plugins/workers/plugin-host-api'
import { z } from 'zod'
const PLUGIN_JSON_VALUE = z.json()
import type { ValidDiscoveredPlugin } from '../plugin-discovery'
import type { PluginWorkerHandle } from '../workers/plugin-host-process'
import { PluginHostCallError } from '../workers/plugin-host-call-scope'
type PushChannel = {
pluginKey: string
deliver: (push: PluginPanelPush) => void
}
export type PluginPanelBridgeDependencies = {
findApprovedPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null
grantedCapabilities: (pluginKey: string) => readonly PluginCapabilityKind[] | null
ensureWorker: (plugin: ValidDiscoveredPlugin) => Promise<PluginWorkerHandle>
runningWorker: (pluginKey: string) => PluginWorkerHandle | null
log: (pluginKey: string) => (line: string) => void
now?: () => number
}
export type PluginPanelRequest = {
pluginKey: string
caller: PluginPanelCaller
name: string
input: unknown
timeoutMs?: number
}
/**
* A plugin's open panels and its own worker. Requests go panel → worker and back; pushes (worker
* posts, events, data changes) go to the panels whose renderer opened a channel. One open panel is
* one session: its id names it to the worker, and closing it aborts its requests.
*/
export class PluginPanelBridge {
private readonly channels = new Map<string, PushChannel>()
private readonly lifetimes = new Map<string, AbortController>()
private readonly postBudgets = new Map<string, PanelMessageBudget>()
// By panel, not channel: a frame may subscribe before its renderer opens the channel.
private readonly subscriptions = new Map<string, Set<PluginEventName>>()
constructor(private readonly deps: PluginPanelBridgeDependencies) {}
/** A renderer listens to one open panel; the returned function stops it. */
open(caller: PluginPanelCaller, pluginKey: string, deliver: PushChannel['deliver']): () => void {
const channel: PushChannel = { pluginKey, deliver }
this.channels.set(caller.id, channel)
return () => {
if (this.channels.get(caller.id) === channel) {
this.channels.delete(caller.id)
}
}
}
/** The session ended: its channel goes and its in-flight requests see `signal` abort. */
close(panelId: string): void {
this.channels.delete(panelId)
this.subscriptions.delete(panelId)
this.lifetimes.get(panelId)?.abort()
this.lifetimes.delete(panelId)
}
subscribe(pluginKey: string, panelId: string, event: PluginEventName): void {
const required = PLUGIN_EVENT_CAPABILITY[event]
if (!this.deps.grantedCapabilities(pluginKey)?.includes(required)) {
throw new PluginHostCallError(
'capability_denied',
`plugin does not have the "${required}" capability`
)
}
const events = this.subscriptions.get(panelId) ?? new Set<PluginEventName>()
events.add(event)
this.subscriptions.set(panelId, events)
}
/** Whether an open panel subscribed to `event`. */
hasSubscribers(event: PluginEventName): boolean {
return [...this.channels.keys()].some((panelId) => this.subscriptions.get(panelId)?.has(event))
}
async request(request: PluginPanelRequest): Promise<PluginJsonValue> {
const plugin = this.deps.findApprovedPlugin(request.pluginKey)
if (!plugin?.manifest.main) {
throw new PluginHostCallError('unavailable', 'this plugin has no worker to answer requests')
}
let worker: PluginWorkerHandle
try {
worker = await this.deps.ensureWorker(plugin)
} catch (error) {
throw new PluginHostCallError('unavailable', errorText(error))
}
let output: unknown
try {
output = await worker.requestFromPanel({
name: request.name,
input: request.input,
panel: request.caller,
timeoutMs: request.timeoutMs ?? PLUGIN_PANEL_REQUEST_DEFAULT_TIMEOUT_MS,
signal: this.lifetime(request.caller.id).signal
})
} catch (error) {
const code =
typeof error === 'object' && error !== null && 'code' in error ? error.code : null
throw new PluginHostCallError(normalizePluginErrorCode(code), errorText(error))
}
if (
structuredCloneMessageBytes(output ?? null, PLUGIN_PANEL_RESULT_MAX_BYTES) >
PLUGIN_PANEL_RESULT_MAX_BYTES
) {
throw new PluginHostCallError('too_large', `${request.name} answered with more than 1 MiB`)
}
const json = PLUGIN_JSON_VALUE.safeParse(output ?? null)
if (!json.success) {
throw new PluginHostCallError(
'action_failed',
`${request.name} answered with a non-JSON value`
)
}
return json.data
}
/** `orca.panels.post`: dropped (and logged) past the plugin's size and rate budget. */
onWorkerPost(pluginKey: string, post: PluginWorkerPanelPost): void {
const budget = this.postBudget(pluginKey)
const bytes = structuredCloneMessageBytes(post.payload ?? null, PLUGIN_PANEL_POST_MAX_BYTES)
const refusal = budget.admit(this.deps.now?.() ?? Date.now(), bytes)
if (refusal) {
this.deps.log(pluginKey)(
`orca.panels.post("${post.event}") dropped: ${refusal === 'oversized' ? 'payload over 256 KiB' : 'too many posts'}`
)
return
}
const push: PluginPanelPush = {
kind: 'message',
event: post.event,
payload: post.payload ?? null
}
for (const [panelId, channel] of this.channels) {
if (channel.pluginKey === pluginKey && (post.panel === undefined || post.panel === panelId)) {
channel.deliver(push)
}
}
}
/** A projected workspace or agent event, to panels that subscribed and may read its domain. */
publishEvent(event: PluginEventName, raw: unknown): void {
// Why: payloads are bounded projections, so a malformed one never reaches a panel.
const projected = PLUGIN_EVENT_PAYLOAD_SCHEMAS[event].safeParse(raw)
if (!projected.success) {
return
}
const payload = projected.data
const required = PLUGIN_EVENT_CAPABILITY[event]
for (const [panelId, channel] of this.channels) {
if (
this.subscriptions.get(panelId)?.has(event) === true &&
this.deps.findApprovedPlugin(channel.pluginKey) &&
this.deps.grantedCapabilities(channel.pluginKey)?.includes(required)
) {
channel.deliver({ kind: 'event', event, payload })
}
}
}
/** Storage or settings changed: open panels and a running worker re-read the key. */
dataChanged(pluginKey: string, area: PluginDataArea, key: string): void {
for (const channel of this.channels.values()) {
if (channel.pluginKey === pluginKey) {
channel.deliver({ kind: 'changed', area, key })
}
}
this.deps.runningWorker(pluginKey)?.notifyDataChanged({ area, key })
}
dispose(): void {
for (const id of this.lifetimes.keys()) {
this.close(id)
}
this.channels.clear()
this.postBudgets.clear()
}
private lifetime(panelId: string): AbortController {
let controller = this.lifetimes.get(panelId)
if (!controller) {
controller = new AbortController()
this.lifetimes.set(panelId, controller)
}
return controller
}
private postBudget(pluginKey: string): PanelMessageBudget {
let budget = this.postBudgets.get(pluginKey)
if (!budget) {
budget = createPanelMessageBudget({
...PLUGIN_PANEL_POST_RATE_LIMIT,
maxBytes: PLUGIN_PANEL_POST_MAX_BYTES
})
this.postBudgets.set(pluginKey, budget)
}
return budget
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
@@ -76,7 +76,7 @@ describe('PluginPanelController identity binding', () => {
'notifications.show',
{ title: 'Hello' },
'panel',
undefined
{ panel: { id: expect.any(String), surface: 'panel', contributionId: 'dashboard' } }
)
await expect(
controller.execute('runtime:other', {
@@ -216,7 +216,10 @@ describe('PluginPanelController user-gesture methods', () => {
'files.saveAs',
call.params,
'panel',
{ webContentsId: 7 }
expect.objectContaining({
webContentsId: 7,
panel: expect.objectContaining({ surface: 'panel' })
})
)
})
})
@@ -23,7 +23,12 @@ import {
PLUGIN_PANEL_ENTRY_MAX_BYTES,
readContainedPluginArtifactText
} from '../plugin-artifact-validation'
import { PluginPanelSessions, type PluginPanelSessionBinding } from './plugin-panel-sessions'
import {
PluginPanelSessions,
type PluginPanelSessionBinding,
type ResolvedPluginPanelSession
} from './plugin-panel-sessions'
import type { PluginPanelCaller } from '../../../shared/plugins/workers/plugin-panel-bridge-protocol'
type PluginPanelControllerOptions = {
resolveApprovedPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null
@@ -37,6 +42,8 @@ type PluginPanelControllerOptions = {
) => Promise<PluginHostCallOutcome>
log: (pluginKey: string) => (line: string) => void
panelAdmission?: PluginPanelCallAdmission
/** A session's public id once it can no longer be used (its panel's requests stop). */
onSessionClosed?: (panelId: string) => void
}
type FrameOpenOptions = { shell?: boolean }
@@ -47,12 +54,24 @@ type LoadedPluginPanel = {
}
export class PluginPanelController {
private readonly sessions = new PluginPanelSessions()
private readonly sessions: PluginPanelSessions
private readonly boundOwnerSignals = new WeakSet<AbortSignal>()
private readonly panelAdmission: PluginPanelCallAdmission
constructor(private readonly options: PluginPanelControllerOptions) {
this.panelAdmission = options.panelAdmission ?? createPluginPanelCallAdmission()
this.sessions = new PluginPanelSessions((panelId) => options.onSessionClosed?.(panelId))
}
/** The live, still-approved panel behind an owner's session: what its push channel serves. */
sessionCaller(
ownerKey: string,
sessionToken: string
): { pluginKey: string; caller: PluginPanelCaller } | null {
const binding = this.sessions.resolve(ownerKey, sessionToken)
return binding && this.isCurrent(binding)
? { pluginKey: binding.pluginKey, caller: panelCaller(binding) }
: null
}
async readEntry(pluginKey: string, panelId: string): Promise<{ html: string } | null> {
@@ -135,13 +154,7 @@ export class PluginPanelController {
if (!parsed.success) {
return { ok: false, code: 'invalid_request', error: 'malformed panel action call' }
}
const plugin = this.options.resolveApprovedPlugin(binding.pluginKey)
if (
!plugin ||
plugin.rootDir !== binding.rootDir ||
JSON.stringify(plugin.manifest) !== binding.manifestRevision ||
!findFrameContribution(plugin, binding.surface, binding.contributionId)
) {
if (!this.isCurrent(binding)) {
return { ok: false, code: 'unavailable', error: 'panel session is no longer available' }
}
return this.options.executeHostCall(
@@ -149,7 +162,20 @@ export class PluginPanelController {
parsed.data.action,
parsed.data.params,
binding.surface,
origin.desktop ? { webContentsId: origin.callerWebContentsId } : undefined
{
...(origin.desktop ? { webContentsId: origin.callerWebContentsId } : {}),
panel: panelCaller(binding)
}
)
}
private isCurrent(binding: PluginPanelSessionBinding): boolean {
const plugin = this.options.resolveApprovedPlugin(binding.pluginKey)
return (
plugin !== null &&
plugin.rootDir === binding.rootDir &&
JSON.stringify(plugin.manifest) === binding.manifestRevision &&
findFrameContribution(plugin, binding.surface, binding.contributionId) !== null
)
}
@@ -283,6 +309,10 @@ export class PluginPanelController {
}
}
function panelCaller(binding: ResolvedPluginPanelSession): PluginPanelCaller {
return { id: binding.publicId, surface: binding.surface, contributionId: binding.contributionId }
}
function findFrameContribution(
plugin: ValidDiscoveredPlugin,
surface: PluginPanelSessionBinding['surface'],
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { PluginPanelSessions } from './plugin-panel-sessions'
const binding = {
@@ -15,7 +15,9 @@ describe('PluginPanelSessions', () => {
const token = sessions.issue('renderer:1', binding)
expect(token).toHaveLength(43)
expect(sessions.resolve('renderer:1', token)).toEqual(binding)
const resolved = sessions.resolve('renderer:1', token)
expect(resolved).toEqual({ ...binding, publicId: expect.stringMatching(/^[\w-]{12}$/) })
expect(resolved?.publicId).not.toContain(token.slice(0, 12))
expect(sessions.resolve('renderer:2', token)).toBeNull()
expect(sessions.issue('renderer:1', binding)).toBe(token)
expect(sessions.issue('renderer:1', { ...binding, rootDir: '/plugins/new' })).not.toBe(token)
@@ -36,6 +38,18 @@ describe('PluginPanelSessions', () => {
expect(sessions.resolve('connection:one', first)).toBeNull()
expect(sessions.resolve('connection:one', second)).toBeNull()
expect(sessions.resolve('connection:two', other)).toEqual(binding)
expect(sessions.resolve('connection:two', other)).toMatchObject(binding)
})
it('reports each closed session by its public id, never the token', () => {
const onClosed = vi.fn()
const sessions = new PluginPanelSessions(onClosed)
const token = sessions.issue('renderer:1', binding)
const { publicId } = sessions.resolve('renderer:1', token)!
sessions.revoke('renderer:1', token)
expect(onClosed).toHaveBeenCalledWith(publicId)
sessions.issue('renderer:1', binding)
sessions.clear()
expect(onClosed).toHaveBeenCalledTimes(2)
})
})
@@ -11,8 +11,12 @@ export type PluginPanelSessionBinding = {
type PluginPanelSession = PluginPanelSessionBinding & {
ownerKey: string
/** Names the open panel to its worker; never authorizes anything, unlike the token. */
publicId: string
}
export type ResolvedPluginPanelSession = PluginPanelSessionBinding & { publicId: string }
const MAX_PANEL_SESSIONS = 1_024
function bindingKey(ownerKey: string, binding: PluginPanelSessionBinding): string {
@@ -31,6 +35,9 @@ export class PluginPanelSessions {
private readonly sessions = new Map<string, PluginPanelSession>()
private readonly tokensByBinding = new Map<string, string>()
/** `onClosed` hears each session's public id once it can no longer be used. */
constructor(private readonly onClosed: (publicId: string) => void = () => undefined) {}
issue(ownerKey: string, binding: PluginPanelSessionBinding): string {
const key = bindingKey(ownerKey, binding)
const existing = this.tokensByBinding.get(key)
@@ -47,13 +54,13 @@ export class PluginPanelSessions {
this.delete(oldest[0], oldest[1])
}
const token = randomBytes(32).toString('base64url')
const session = { ownerKey, ...binding }
const session = { ownerKey, ...binding, publicId: randomBytes(9).toString('base64url') }
this.sessions.set(token, session)
this.tokensByBinding.set(key, token)
return token
}
resolve(ownerKey: string, token: string): PluginPanelSessionBinding | null {
resolve(ownerKey: string, token: string): ResolvedPluginPanelSession | null {
const session = this.sessions.get(token)
if (!session || session.ownerKey !== ownerKey) {
return null
@@ -63,7 +70,8 @@ export class PluginPanelSessions {
surface: session.surface,
contributionId: session.contributionId,
rootDir: session.rootDir,
manifestRevision: session.manifestRevision
manifestRevision: session.manifestRevision,
publicId: session.publicId
}
}
@@ -84,12 +92,17 @@ export class PluginPanelSessions {
}
clear(): void {
const closed = [...this.sessions.values()]
this.sessions.clear()
this.tokensByBinding.clear()
for (const session of closed) {
this.onClosed(session.publicId)
}
}
private delete(token: string, session: PluginPanelSession): void {
this.sessions.delete(token)
this.tokensByBinding.delete(bindingKey(session.ownerKey, session))
this.onClosed(session.publicId)
}
}
@@ -1,4 +1,5 @@
import type { ValidDiscoveredPlugin } from './plugin-discovery'
import type { PluginWorkerController } from './workers/plugin-worker-controller'
export function assertPluginWorkerCommand(plugin: ValidDiscoveredPlugin, commandId: string): void {
const command = plugin.manifest.contributes.commands.find((entry) => entry.id === commandId)
@@ -10,3 +11,22 @@ export function assertPluginWorkerCommand(plugin: ValidDiscoveredPlugin, command
throw new Error(`plugin ${plugin.pluginKey} command ${commandId} is a built-in action alias`)
}
}
/** `plugin` is the approved plugin (null when it is not): a palette or CLI run of its command. */
export async function invokePluginWorkerCommand(
pluginKey: string,
plugin: ValidDiscoveredPlugin | null,
workers: Pick<PluginWorkerController, 'ensure'>,
commandId: string,
args?: unknown
): Promise<unknown> {
if (!plugin) {
throw new Error(`plugin ${pluginKey} is not enabled`)
}
assertPluginWorkerCommand(plugin, commandId)
const handle = await workers.ensure(plugin)
if (!handle.commands.includes(commandId)) {
throw new Error(`plugin ${pluginKey} registered no handler for ${commandId}`)
}
return handle.invokeCommand(commandId, args)
}
@@ -0,0 +1,55 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { ValidDiscoveredPlugin } from './plugin-discovery'
import { readPluginDeclaredSettings, writePluginDeclaredSetting } from './plugin-declared-settings'
const settings = [
{ key: 'apiUrl', title: 'Server URL', type: 'string' as const, default: 'https://example.com' },
{ key: 'pageSize', title: 'Page size', type: 'integer' as const, minimum: 1 }
]
function host() {
const plugin = { manifest: { contributes: { settings } } }
return {
findValidPlugin: (key: string) =>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: settings code reads only manifest.contributes.settings.
key === 'acme.todos' ? (plugin as never as ValidDiscoveredPlugin) : null,
userDataPath: mkdtempSync(join(tmpdir(), 'orca-declared-settings-')),
dataChanged: vi.fn()
}
}
describe('declared plugin settings in Orca Settings', () => {
it('shows defaults until the person sets a value, then theirs, and tells the plugin', () => {
const settingsHost = host()
expect(readPluginDeclaredSettings(settingsHost, 'acme.todos')?.values).toEqual({
apiUrl: 'https://example.com'
})
expect(writePluginDeclaredSetting(settingsHost, 'acme.todos', 'pageSize', 50)).toEqual({
ok: true
})
expect(settingsHost.dataChanged).toHaveBeenCalledWith('acme.todos', 'pageSize')
expect(writePluginDeclaredSetting(settingsHost, 'acme.todos', 'apiUrl', null)).toEqual({
ok: true
})
expect(readPluginDeclaredSettings(settingsHost, 'acme.todos')?.values).toEqual({
apiUrl: 'https://example.com',
pageSize: 50
})
})
it('refuses undeclared keys and values that do not fit', () => {
const settingsHost = host()
expect(writePluginDeclaredSetting(settingsHost, 'acme.todos', 'other', 1)).toMatchObject({
ok: false
})
expect(writePluginDeclaredSetting(settingsHost, 'acme.todos', 'pageSize', 0)).toEqual({
ok: false,
error: 'Page size must be at least 1'
})
expect(settingsHost.dataChanged).not.toHaveBeenCalled()
expect(readPluginDeclaredSettings(settingsHost, 'acme.none')).toBeNull()
})
})
@@ -0,0 +1,68 @@
import type { PluginJsonValue } from '../../shared/plugins/workers/plugin-host-api'
import { pluginSettingValueIssue } from '../../shared/plugins/contributions/plugin-settings-contribution'
import {
pluginSettingDefaults,
type PluginDeclaredSettingsSnapshot
} from '../../shared/plugins/contributions/plugin-settings-values'
import { getPluginsDataDir, type ValidDiscoveredPlugin } from './plugin-discovery'
import { PluginKvStore } from './plugin-storage-store'
type SettingsHost = {
findValidPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null
userDataPath: string
/** Tells open panels and a running worker that `key` changed. */
dataChanged: (pluginKey: string, key: string) => void
}
function store(host: SettingsHost, pluginKey: string): PluginKvStore {
return new PluginKvStore(getPluginsDataDir(host.userDataPath), pluginKey, 'settings.json')
}
/** What Orca's Settings shows under a plugin; null when it declares none. */
export function readPluginDeclaredSettings(
host: SettingsHost,
pluginKey: string
): PluginDeclaredSettingsSnapshot | null {
const settings = host.findValidPlugin(pluginKey)?.manifest.contributes.settings ?? []
if (settings.length === 0) {
return null
}
const stored = store(host, pluginKey).getAll()
const values = pluginSettingDefaults(settings)
for (const setting of settings) {
if (Object.hasOwn(stored, setting.key)) {
values[setting.key] = stored[setting.key]
}
}
return { settings, values }
}
/** The person's edit in Settings; `null` goes back to the default. Only declared keys. */
export function writePluginDeclaredSetting(
host: SettingsHost,
pluginKey: string,
key: string,
value: PluginJsonValue
): { ok: true } | { ok: false; error: string } {
const setting = host
.findValidPlugin(pluginKey)
?.manifest.contributes.settings.find((entry) => entry.key === key)
if (!setting) {
return { ok: false, error: `the plugin declares no setting ${key}` }
}
const settings = store(host, pluginKey)
if (value === null) {
settings.delete(key)
} else {
const issue = pluginSettingValueIssue(setting, value)
if (issue) {
return { ok: false, error: `${setting.title} ${issue}` }
}
const written = settings.set(key, value)
if (!written.ok) {
return written
}
}
host.dataChanged(pluginKey, key)
return { ok: true }
}
+15
View File
@@ -9,6 +9,21 @@ import {
} from './plugin-discovery'
import type { PluginWorkerController } from './workers/plugin-worker-controller'
/** Whether an approved worker subscribes to `event` (open panels are asked separately). */
export function pluginWorkersWantEvent(
event: PluginEventName,
plugins: readonly DiscoveredPlugin[],
isRuntimeApproved: (plugin: ValidDiscoveredPlugin) => boolean
): boolean {
return plugins.some(
(plugin) =>
!isInvalidDiscoveredPlugin(plugin) &&
plugin.manifest.main !== undefined &&
plugin.manifest.contributes.events.some((subscription) => subscription.on === event) &&
isRuntimeApproved(plugin)
)
}
/** Delivers only to plugins whose manifest subscribes and whose grant covers the event's domain. */
export function deliverPluginEvent(options: {
event: PluginEventName
@@ -36,7 +36,7 @@ async function createSavingPlugin() {
const dialog = gate()
const writes: string[] = []
const desktop: PluginDesktopFrameServices = {
openExternal: async () => true,
openExternal: async () => ({ opened: true }),
async saveFile(pluginId, _input, scope) {
await dialog.opened
scope.assertActive()
@@ -171,6 +171,7 @@ describe('revocation checks inside executePluginHostCall', () => {
return [{ id: 't1' }]
},
sendTerminalText,
listAgentStatuses: () => [],
dispatchPluginNotification: vi.fn(),
storage: { get: vi.fn(), set: vi.fn(), delete: vi.fn(), keys: vi.fn() },
secrets: { get: vi.fn(), set: vi.fn(), delete: vi.fn(), protection: vi.fn() },
@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from 'vitest'
import {
createPluginAgentStatusForwarder,
pluginRuntimeDelegate,
watchPluginWorkspaceContext
} from './plugin-runtime-binding'
import { projectPluginWorkspaceAgents } from './workers/plugin-workspace-projection'
const LEAF = '11111111-1111-4111-8111-111111111111'
function runtime(terminals: { handle: string; title: string | null; paneTitle?: string }[]) {
return {
resolveActiveWorktreeContext: vi.fn(async () => ({
worktreeId: 'repo::/w',
path: '/w',
branch: 'refs/heads/main',
displayName: 'w'
})),
listTerminals: vi.fn(async () => ({
terminals: terminals.map((terminal, index) => ({
...terminal,
tabId: `tab${index}`,
leafId: LEAF
}))
})),
sendTerminal: vi.fn(),
dispatchPluginNotification: vi.fn()
}
}
describe('agent.status.changed for plugins', () => {
it('names the terminal and its title, in arrival order, only while someone listens', async () => {
const delegate = pluginRuntimeDelegate(
runtime([
{ handle: 'term_a', title: 'zsh' },
{ handle: 'term_b', title: 'codex', paneTitle: 'Reviewer' }
]),
() => []
)
const events = { wantsEvent: vi.fn(() => true), emitEvent: vi.fn() }
const forward = createPluginAgentStatusForwarder(delegate, events)
const base = { workspaceId: 'repo::/w', state: 'working' as const, receivedAt: 1 }
forward({ ...base, paneKey: `tab1:${LEAF}` })
forward({ ...base, paneKey: 'tab9:unknown', state: 'done' })
await vi.waitFor(() => expect(events.emitEvent).toHaveBeenCalledTimes(2))
expect(events.emitEvent.mock.calls).toEqual([
[
'agent.status.changed',
{ ...base, paneKey: `tab1:${LEAF}`, terminalId: 'term_b', title: 'Reviewer' }
],
['agent.status.changed', { ...base, paneKey: 'tab9:unknown', state: 'done' }]
])
events.wantsEvent.mockReturnValue(false)
forward({ ...base, paneKey: `tab0:${LEAF}` })
await Promise.resolve()
expect(events.emitEvent).toHaveBeenCalledTimes(2)
})
})
describe('agents.list projection', () => {
it("lists the focused workspace's agents with their terminal, newest first", () => {
const terminals = [{ id: 'term_a', title: 'codex', paneKey: `tab0:${LEAF}` }]
expect(
projectPluginWorkspaceAgents('w1', terminals, [
{
paneKey: `tab0:${LEAF}`,
worktreeId: 'w1',
state: 'working',
stateStartedAt: 5,
agentType: 'codex'
},
{ paneKey: 'gone', worktreeId: 'w1', state: 'done', stateStartedAt: 9 },
{ paneKey: 'x', worktreeId: 'w2', state: 'done', stateStartedAt: 7 },
{
paneKey: 'y',
worktreeId: 'w1',
state: 'done',
stateStartedAt: 8,
providerSessionOnly: true
}
])
).toEqual([
{ terminalId: null, title: null, agent: null, state: 'done', stateStartedAt: 9 },
{ terminalId: 'term_a', title: 'codex', agent: 'codex', state: 'working', stateStartedAt: 5 }
])
})
})
describe('workspace.changed', () => {
it('re-reads the context after session writes settle and emits only real changes', async () => {
vi.useFakeTimers()
try {
const source = runtime([{ handle: 'term_a', title: 'zsh' }])
const delegate = pluginRuntimeDelegate(source, () => [])
const events = { wantsEvent: vi.fn(() => true), emitEvent: vi.fn() }
let written: () => void = () => undefined
const stop = watchPluginWorkspaceContext({
delegate,
events,
onSessionWritten: (listener) => {
written = listener
return () => undefined
},
delayMs: 10
})
written()
written()
await vi.advanceTimersByTimeAsync(20)
expect(events.emitEvent).toHaveBeenCalledWith('workspace.changed', {
context: { branch: 'main', displayName: 'w', terminals: [{ id: 'term_a', title: 'zsh' }] }
})
written()
await vi.advanceTimersByTimeAsync(20)
expect(events.emitEvent).toHaveBeenCalledOnce()
source.listTerminals.mockResolvedValueOnce({ terminals: [] })
written()
await vi.advanceTimersByTimeAsync(20)
expect(events.emitEvent).toHaveBeenLastCalledWith('workspace.changed', {
context: { branch: 'main', displayName: 'w', terminals: [] }
})
stop()
} finally {
vi.useRealTimers()
}
})
})
+121
View File
@@ -0,0 +1,121 @@
import type { PluginEventName } from '../../shared/plugins/manifest/plugin-manifest'
import type { PluginAgentStatusChangedPayload } from '../../shared/plugins/events/plugin-events'
import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/workers/plugin-host-api'
import type { PluginRuntimeDelegate } from './workers/plugin-host-service-bindings'
import {
pluginTerminalTitle,
projectPluginWorkspaceContext,
terminalForPane,
type PluginAgentStatusRow,
type PluginWorkspaceTerminal
} from './workers/plugin-workspace-projection'
type PluginEvents = {
emitEvent(event: PluginEventName, payload: unknown): void
/** Whether any approved worker or open panel listens, so unheard events cost nothing. */
wantsEvent(event: PluginEventName): boolean
}
type RuntimeDelegateSource = Omit<PluginRuntimeDelegate, 'listAgentStatuses'>
const WORKSPACE_CHANGE_DEBOUNCE_MS = 150
/** Main's runtime, plus the hook server's agent rows, as the plugin host's delegate. */
export function pluginRuntimeDelegate(
runtime: RuntimeDelegateSource,
agentStatuses: () => readonly PluginAgentStatusRow[]
): PluginRuntimeDelegate {
return {
resolveActiveWorktreeContext: () => runtime.resolveActiveWorktreeContext(),
listTerminals: (...args) => runtime.listTerminals(...args),
sendTerminal: (...args) => runtime.sendTerminal(...args),
dispatchPluginNotification: (input) => runtime.dispatchPluginNotification(input),
listAgentStatuses: agentStatuses
}
}
async function listWorkspaceTerminals(
delegate: PluginRuntimeDelegate,
worktreeId: string
): Promise<PluginWorkspaceTerminal[]> {
const { terminals } = await delegate.listTerminals(
`id:${worktreeId}`,
PLUGIN_WORKSPACE_TERMINAL_LIMIT,
{ includeVisualLayouts: false }
)
return terminals.map((terminal) => ({
id: terminal.handle,
title: pluginTerminalTitle(terminal),
...(terminal.tabId && terminal.leafId
? { paneKey: `${terminal.tabId}:${terminal.leafId}` }
: {})
}))
}
/**
* `agent.status.changed` with the pane's `terminalId` and title, in arrival order. The lookup runs
* only while some plugin listens.
*/
export function createPluginAgentStatusForwarder(
delegate: PluginRuntimeDelegate,
events: PluginEvents
): (payload: PluginAgentStatusChangedPayload, terminalHandle?: string) => void {
let chain: Promise<void> = Promise.resolve()
return (payload, terminalHandle) => {
if (!events.wantsEvent('agent.status.changed')) {
return
}
chain = chain.then(async () => {
const { workspaceId } = payload
const terminals = workspaceId
? await listWorkspaceTerminals(delegate, workspaceId).catch(() => [])
: []
const terminal = terminalForPane(terminals, { paneKey: payload.paneKey, terminalHandle })
events.emitEvent('agent.status.changed', {
...payload,
...(terminal ? { terminalId: terminal.id } : {}),
...(terminal?.title ? { title: terminal.title } : {})
})
})
}
}
/**
* `workspace.changed`: after workspace session writes settle (focus moves, terminals open, close
* or are renamed), re-read the context and emit when it differs from the last one sent.
*/
export function watchPluginWorkspaceContext(input: {
delegate: PluginRuntimeDelegate
events: PluginEvents
onSessionWritten: (listener: () => void) => () => void
delayMs?: number
}): () => void {
let timer: ReturnType<typeof setTimeout> | null = null
let last: string | null = null
const check = async (): Promise<void> => {
timer = null
if (!input.events.wantsEvent('workspace.changed')) {
last = null
return
}
const context = await input.delegate.resolveActiveWorktreeContext().catch(() => null)
const terminals = context
? await listWorkspaceTerminals(input.delegate, context.worktreeId).catch(() => [])
: []
const projected = projectPluginWorkspaceContext(context, terminals)
const signature = JSON.stringify(projected)
if (signature !== last) {
last = signature
input.events.emitEvent('workspace.changed', { context: projected })
}
}
const stop = input.onSessionWritten(() => {
timer ??= setTimeout(() => void check(), input.delayMs ?? WORKSPACE_CHANGE_DEBOUNCE_MS)
})
return () => {
stop()
if (timer) {
clearTimeout(timer)
}
}
}
+56 -5
View File
@@ -8,9 +8,18 @@ import {
} from './workers/plugin-host-service-bindings'
import type { PluginDesktopFrameServices } from './workers/plugin-host-method-bindings'
import { pluginHostCallRevoked, type PluginHostCallScope } from './workers/plugin-host-call-scope'
import type { PluginPanelCaller } from '../../shared/plugins/workers/plugin-panel-bridge-protocol'
import type { PluginPanelBridgeServices } from './workers/plugin-host-panel-method-bindings'
import type { PluginPanelBridge } from './panels/plugin-panel-bridge'
import type {
PluginHostCallOutcome,
PluginSurface
} from '../../shared/plugins/frames/plugin-protocol'
import { executePluginHostCallRequest } from './workers/plugin-host-call-adapter'
/** Who made a call: the WebContents of the frame's host window, for desktop IPC calls only. */
export type PluginHostCaller = { webContentsId?: number }
/** Who made a call: the open panel (frames only), and its host window's WebContents for desktop
* IPC calls. */
export type PluginHostCaller = { webContentsId?: number; panel?: PluginPanelCaller }
type FindApprovedPlugin = (
pluginKey: string,
@@ -21,11 +30,14 @@ type FindApprovedPlugin = (
export function createPluginHostCallScope(
admitted: ValidDiscoveredPlugin,
findApprovedPlugin: FindApprovedPlugin,
caller: PluginHostCaller | undefined
caller: PluginHostCaller | undefined,
log?: (line: string) => void
): PluginHostCallScope {
const manifestRevision = JSON.stringify(admitted.manifest)
return {
callerWebContentsId: caller?.webContentsId,
log,
panel: caller?.panel,
pluginName: admitted.manifest.name,
assertActive() {
// A refresh in flight is not a revocation; disable, consent, kill list and removal are.
@@ -48,7 +60,9 @@ export function resolvePluginServiceHostCallPolicy(input: {
runtimeDelegate: PluginRuntimeDelegate | null
pluginsDataDir: string
desktop: PluginDesktopFrameServices | null | undefined
panels: PluginPanelBridgeServices | null
audit: PluginAuditLog
log?: (pluginKey: string) => (line: string) => void
}): PluginHostCallPolicy {
const plugin = input.findApprovedPlugin(input.pluginKey)
return {
@@ -57,12 +71,49 @@ export function resolvePluginServiceHostCallPolicy(input: {
? bindPluginHostServices({
delegate: input.runtimeDelegate,
pluginsDataDir: input.pluginsDataDir,
desktop: input.desktop
desktop: input.desktop,
panels: input.panels,
declaredSettings: plugin?.manifest.contributes.settings
})
: null,
audit: input.audit,
scope: plugin
? createPluginHostCallScope(plugin, input.findApprovedPlugin, input.caller)
? createPluginHostCallScope(
plugin,
input.findApprovedPlugin,
input.caller,
input.log?.(plugin.pluginKey)
)
: undefined
}
}
/** The bridge as host services: the request carries the calling panel. */
export function pluginPanelBridgeServices(bridge: PluginPanelBridge): PluginPanelBridgeServices {
return {
request: (pluginKey, request, caller) => bridge.request({ pluginKey, caller, ...request }),
subscribe: (pluginKey, panelId, event) => bridge.subscribe(pluginKey, panelId, event),
dataChanged: (pluginKey, area, key) => bridge.dataChanged(pluginKey, area, key)
}
}
export type PluginServiceHostCall = (
pluginKey: string,
method: string,
params: unknown,
surface: PluginSurface,
caller?: PluginHostCaller
) => Promise<PluginHostCallOutcome>
/** PluginService's one host-call entry: authenticate the request, then bind policy per call. */
export function createPluginServiceHostCall(
policy: (pluginKey: string, caller: PluginHostCaller | undefined) => PluginHostCallPolicy
): PluginServiceHostCall {
return (pluginKey, method, params, surface, caller) =>
executePluginHostCallRequest({
pluginKey,
request: { method, params },
surface,
resolvePolicy: (boundPluginKey) => policy(boundPluginKey, caller)
})
}
@@ -52,6 +52,8 @@ function testWorker(): PluginWorkerHandle & { dispose: ReturnType<typeof vi.fn>
commands: ['run'],
invokeCommand: vi.fn(async () => null),
deliverEvent: vi.fn(),
requestFromPanel: vi.fn(),
notifyDataChanged: vi.fn(),
lastActivityAt: () => Date.now(),
inFlightCount: () => 0,
dispose: vi.fn(async () => undefined),
+45 -43
View File
@@ -7,10 +7,6 @@ import type {
PluginActivationState,
PluginConsentLists
} from '../../shared/plugins/approval/plugin-consent-state'
import type {
PluginHostCallOutcome,
PluginSurface
} from '../../shared/plugins/frames/plugin-protocol'
import {
discoverPlugins,
getPluginsDataDir,
@@ -19,13 +15,15 @@ import {
type ValidDiscoveredPlugin
} from './plugin-discovery'
import { PluginAuditLog, type PluginAuditEntry } from './plugin-audit-log'
import { executePluginHostCallRequest } from './workers/plugin-host-call-adapter'
import { PluginContentVerifier } from './plugin-content-integrity'
import type { PluginRuntimeDelegate } from './workers/plugin-host-service-bindings'
import {
createPluginServiceHostCall,
pluginPanelBridgeServices,
resolvePluginServiceHostCallPolicy,
type PluginHostCaller
type PluginServiceHostCall
} from './plugin-service-host-call'
import { PluginPanelBridge } from './panels/plugin-panel-bridge'
import { PluginPanelController } from './panels/plugin-panel-controller'
import { PluginWorkerController } from './workers/plugin-worker-controller'
import { PluginServiceHousekeeping } from './plugin-service-housekeeping'
@@ -44,8 +42,8 @@ import { PluginContentPackRegistry } from './contributions/plugin-content-pack-r
import type { PluginServiceOptions } from './plugin-service-options'
import type { PluginChangeEvent } from '../../shared/plugins/events/plugin-change-event'
import { waitForPluginRefreshSettlement } from './plugin-refresh-settlement'
import { assertPluginWorkerCommand } from './plugin-command-invocation'
import { deliverPluginEvent } from './plugin-event-delivery'
import { invokePluginWorkerCommand } from './plugin-command-invocation'
import { deliverPluginEvent, pluginWorkersWantEvent } from './plugin-event-delivery'
import { PluginInstallationState } from './installation/plugin-installation-state'
export class PluginService {
@@ -55,6 +53,8 @@ export class PluginService {
private readonly contentVerifier = new PluginContentVerifier()
readonly contentPacks: PluginContentPackRegistry
readonly panels: PluginPanelController
readonly panelBridge: PluginPanelBridge
private readonly hostCall: PluginServiceHostCall
private readonly changeListeners = new Set<(event: PluginChangeEvent) => void>()
private readonly housekeeping = new PluginServiceHousekeeping()
private runtimeDelegate: PluginRuntimeDelegate | null = null
@@ -76,12 +76,33 @@ export class PluginService {
this.audit = new PluginAuditLog(getPluginsDataDir(options.userDataPath))
const resolveApproved = (pluginKey: string, allowRefreshPause = false) =>
this.findApprovedPlugin(pluginKey, allowRefreshPause)
this.panelBridge = new PluginPanelBridge({
findApprovedPlugin: (pluginKey) => resolveApproved(pluginKey),
grantedCapabilities: (pluginKey) => this.getGrantedCapabilities(pluginKey),
ensureWorker: (plugin) => this.workerController.ensure(plugin),
runningWorker: (pluginKey) => this.workerController.runningHandle(pluginKey),
log: (pluginKey) => this.installed.captureLog(pluginKey, 'warn')
})
this.hostCall = createPluginServiceHostCall((pluginKey, caller) =>
resolvePluginServiceHostCallPolicy({
pluginKey,
caller,
findApprovedPlugin: resolveApproved,
runtimeDelegate: this.runtimeDelegate,
pluginsDataDir: getPluginsDataDir(this.options.userDataPath),
desktop: this.options.getDesktopFrameServices?.(),
panels: pluginPanelBridgeServices(this.panelBridge),
audit: this.audit,
log: (pluginKey) => this.installed.captureLog(pluginKey, 'info')
})
)
this.panels = new PluginPanelController({
resolveApprovedPlugin: resolveApproved,
contentVerifier: this.contentVerifier,
executeHostCall: (pluginKey, method, params, surface, caller) =>
this.executeHostCall(pluginKey, method, params, surface, caller),
log: (pluginKey) => this.installed.captureLog(pluginKey, 'error')
log: (pluginKey) => this.installed.captureLog(pluginKey, 'error'),
onSessionClosed: (panelId) => this.panelBridge.close(panelId)
})
this.workerController = new PluginWorkerController({
entryPath: options.hostEntryPath ?? '',
@@ -94,6 +115,7 @@ export class PluginService {
isApprovalKept: (spec) => isApprovedWorkerSpec(spec, resolveApproved(spec.pluginKey, true)),
executeHostCall: (pluginKey, method, params) =>
this.executeHostCall(pluginKey, method, params, 'worker'),
onPanelPost: (pluginKey, post) => this.panelBridge.onWorkerPost(pluginKey, post),
log: (pluginKey) => this.installed.logs.capture(pluginKey),
onStateChanged: () => this.notifyChanged(false)
})
@@ -247,42 +269,20 @@ export class PluginService {
return plugin ? capabilityKinds(plugin.manifest.capabilities) : null
}
async executeHostCall(
pluginKey: string,
method: string,
params: unknown,
surface: PluginSurface,
caller?: PluginHostCaller
): Promise<PluginHostCallOutcome> {
return executePluginHostCallRequest({
pluginKey,
request: { method, params },
surface,
resolvePolicy: (boundPluginKey) =>
resolvePluginServiceHostCallPolicy({
pluginKey: boundPluginKey,
caller,
findApprovedPlugin: (key, allowRefreshPause) =>
this.findApprovedPlugin(key, allowRefreshPause),
runtimeDelegate: this.runtimeDelegate,
pluginsDataDir: getPluginsDataDir(this.options.userDataPath),
desktop: this.options.getDesktopFrameServices?.(),
audit: this.audit
})
})
executeHostCall(...args: Parameters<PluginServiceHostCall>): ReturnType<PluginServiceHostCall> {
return this.hostCall(...args)
}
async invokeCommand(pluginKey: string, commandId: string, args?: unknown): Promise<unknown> {
const plugin = this.findValidPlugin(pluginKey)
if (!plugin || !this.canStartPluginWork(plugin)) {
throw new Error(`plugin ${pluginKey} is not enabled`)
}
assertPluginWorkerCommand(plugin, commandId)
const handle = await this.workerController.ensure(plugin)
if (!handle.commands.includes(commandId)) {
throw new Error(`plugin ${pluginKey} registered no handler for ${commandId}`)
}
return handle.invokeCommand(commandId, args)
invokeCommand(pluginKey: string, commandId: string, args?: unknown): Promise<unknown> {
const plugin = this.findApprovedPlugin(pluginKey)
return invokePluginWorkerCommand(pluginKey, plugin, this.workerController, commandId, args)
}
wantsEvent(event: PluginEventName): boolean {
return (
this.panelBridge.hasSubscribers(event) ||
pluginWorkersWantEvent(event, this.installed.discovered, (p) => this.canStartPluginWork(p))
)
}
emitEvent(event: PluginEventName, payload: unknown): void {
@@ -298,6 +298,7 @@ export class PluginService {
grantedCapabilities: (pluginKey) => this.getGrantedCapabilities(pluginKey),
logWarning: (pluginKey) => this.installed.captureLog(pluginKey, 'warn')
})
this.panelBridge.publishEvent(event, payload)
}
removePlugin(pluginKey: string, remove: () => Promise<void>): Promise<void> {
@@ -333,6 +334,7 @@ export class PluginService {
this.disposed = true
this.housekeeping.dispose()
this.panels.dispose()
this.panelBridge.dispose()
// Why stop workers first: a refresh may be waiting on a worker slot only shutdown releases.
await Promise.all([this.workerController.dispose(), this.refreshChain.catch(() => undefined)])
await this.audit.flush()
@@ -1,4 +1,5 @@
import type { PluginErrorCode } from '../../../shared/plugins/frames/plugin-protocol'
import type { PluginPanelCaller } from '../../../shared/plugins/workers/plugin-panel-bridge-protocol'
/**
* What one admitted call carries to its side effect. `assertActive` throws once the plugin was
@@ -11,6 +12,10 @@ export type PluginHostCallScope = {
callerWebContentsId?: number
/** The plugin's display name, for prompts Orca shows on its behalf. */
pluginName?: string
/** The open panel or editor that made the call; absent for workers. */
panel?: PluginPanelCaller
/** Writes to the plugin's log (`orca plugin logs`). */
log?: (line: string) => void
}
export const UNSCOPED_PLUGIN_HOST_CALL: PluginHostCallScope = { assertActive: () => {} }
@@ -0,0 +1,49 @@
import type { z } from 'zod'
import type { PluginHostMethodSpec } from '../../../shared/plugins/workers/plugin-host-api'
import type { PluginHostCallScope } from './plugin-host-call-scope'
import type { PluginHostServices } from './plugin-host-method-bindings'
export type PluginHostMethodContext = {
pluginId: string
services: PluginHostServices
scope: PluginHostCallScope
}
export type PreparedPluginHostCall =
| { ok: false; issue: string }
| {
ok: true
params: unknown
/** Runs the handler; `value` is null when the result failed its schema. */
execute: (ctx: PluginHostMethodContext) => Promise<{ value: unknown } | null>
}
export type BoundPluginHostMethod = {
spec: PluginHostMethodSpec
prepare: (params: unknown) => PreparedPluginHostCall
}
export function bind<P extends z.ZodType, R extends z.ZodType>(
spec: PluginHostMethodSpec<P, R>,
handler: (params: z.output<P>, ctx: PluginHostMethodContext) => Promise<z.input<R>>
): BoundPluginHostMethod {
return {
spec,
prepare(raw) {
const parsed = spec.params.safeParse(raw)
if (!parsed.success) {
const issue = parsed.error.issues[0]
const path = issue?.path.join('.') || '(root)'
return { ok: false, issue: `${path}: ${issue?.message ?? 'invalid params'}` }
}
return {
ok: true,
params: parsed.data,
async execute(ctx) {
const result = spec.result.safeParse(await handler(parsed.data, ctx))
return result.success ? { value: result.data } : null
}
}
}
}
}
@@ -1,17 +1,23 @@
import type { z } from 'zod'
import {
isPluginHostMethodName,
PLUGIN_HOST_API,
PLUGIN_HOST_METHOD_NAMES,
PLUGIN_TERMINAL_ID_MAX_LENGTH,
PLUGIN_WORKSPACE_LABEL_MAX_LENGTH,
PLUGIN_WORKSPACE_TERMINAL_LIMIT,
type PluginHostMethodName,
type PluginHostMethodSpec,
type PluginJsonValue
} from '../../../shared/plugins/workers/plugin-host-api'
import { shortPluginBranchName } from '../../../shared/plugins/events/plugin-events'
import {
projectPluginWorkspaceAgents,
projectPluginWorkspaceContext,
type PluginAgentStatusRow,
type PluginWorkspaceTerminal
} from './plugin-workspace-projection'
import type { PluginHostCallScope } from './plugin-host-call-scope'
import type { PluginDataArea } from '../../../shared/plugins/workers/plugin-panel-bridge-protocol'
import {
PLUGIN_PANEL_BRIDGE_HANDLERS,
type PluginPanelBridgeServices
} from './plugin-host-panel-method-bindings'
import { bind, type BoundPluginHostMethod } from './plugin-host-method-bind'
export type { PluginHostCallScope } from './plugin-host-call-scope'
@@ -23,7 +29,11 @@ export type PluginWorktreeContext = {
/** Desktop-only sinks for frame methods; absent in headless serve. */
export type PluginDesktopFrameServices = {
openExternal(pluginId: string, url: string, scope: PluginHostCallScope): Promise<boolean>
openExternal(
pluginId: string,
url: string,
scope: PluginHostCallScope
): Promise<{ opened: boolean; reason?: 'cancelled' | 'consent-unavailable' }>
/** False when the person cancelled the dialog. */
saveFile(
pluginId: string,
@@ -34,7 +44,8 @@ export type PluginDesktopFrameServices = {
export type PluginHostServices = {
resolveActiveWorktreeContext(): Promise<PluginWorktreeContext | null>
listWorktreeTerminals(worktreeId: string): Promise<{ id: string }[]>
listWorktreeTerminals(worktreeId: string): Promise<PluginWorkspaceTerminal[]>
listAgentStatuses(): readonly PluginAgentStatusRow[]
sendTerminalText(
terminalId: string,
action: { text: string; enter: boolean }
@@ -64,6 +75,8 @@ export type PluginHostServices = {
protection(): 'os-keyring' | 'unavailable'
}
desktop?: PluginDesktopFrameServices | null
/** The plugin's open panels and its worker; absent where no panel host runs. */
panels?: PluginPanelBridgeServices | null
settings: {
getAll(pluginId: string): Record<string, PluginJsonValue>
set(
@@ -74,72 +87,33 @@ export type PluginHostServices = {
}
}
type PluginHostMethodContext = {
pluginId: string
services: PluginHostServices
scope: PluginHostCallScope
}
export type PreparedPluginHostCall =
| { ok: false; issue: string }
| {
ok: true
params: unknown
/** Runs the handler; `value` is null when the result failed its schema. */
execute: (ctx: PluginHostMethodContext) => Promise<{ value: unknown } | null>
}
export type BoundPluginHostMethod = {
spec: PluginHostMethodSpec
prepare: (params: unknown) => PreparedPluginHostCall
}
function bind<P extends z.ZodType, R extends z.ZodType>(
spec: PluginHostMethodSpec<P, R>,
handler: (params: z.output<P>, ctx: PluginHostMethodContext) => Promise<z.input<R>>
): BoundPluginHostMethod {
return {
spec,
prepare(raw) {
const parsed = spec.params.safeParse(raw)
if (!parsed.success) {
const issue = parsed.error.issues[0]
const path = issue?.path.join('.') || '(root)'
return { ok: false, issue: `${path}: ${issue?.message ?? 'invalid params'}` }
}
return {
ok: true,
params: parsed.data,
async execute(ctx) {
const result = spec.result.safeParse(await handler(parsed.data, ctx))
return result.success ? { value: result.data } : null
}
}
}
}
}
export type {
BoundPluginHostMethod,
PluginHostMethodContext,
PreparedPluginHostCall
} from './plugin-host-method-bind'
const api = PLUGIN_HOST_API
const OK = { ok: true } as const
const HANDLERS: Record<PluginHostMethodName, BoundPluginHostMethod> = {
'workspace.readContext': bind(api['workspace.readContext'], async (_params, { services }) => {
const context = await services.resolveActiveWorktreeContext()
const terminals = context ? await services.listWorktreeTerminals(context.worktreeId) : []
return projectPluginWorkspaceContext(context, terminals)
}),
'agents.list': bind(api['agents.list'], async (_params, { services }) => {
const context = await services.resolveActiveWorktreeContext()
if (!context) {
return null
return { agents: [] }
}
const terminals = await services.listWorktreeTerminals(context.worktreeId)
// Worktree IDs embed provider paths; explicitly project safe public fields.
return {
branch: shortPluginBranchName(context.branch).slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
displayName: context.displayName.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
terminals: terminals
.filter(
(terminal) =>
terminal.id.length > 0 && terminal.id.length <= PLUGIN_TERMINAL_ID_MAX_LENGTH
)
.slice(0, PLUGIN_WORKSPACE_TERMINAL_LIMIT)
.map((terminal) => ({ id: terminal.id }))
agents: projectPluginWorkspaceAgents(
context.worktreeId,
terminals,
services.listAgentStatuses()
)
}
}),
'terminal.sendText': bind(api['terminal.sendText'], async (params, { services, scope }) => {
@@ -164,11 +138,17 @@ const HANDLERS: Record<PluginHostMethodName, BoundPluginHostMethod> = {
value: services.storage.get(pluginId, key) ?? null
})),
'storage.set': bind(api['storage.set'], async ({ key, value }, { pluginId, services }) =>
okOrThrow(services.storage.set(pluginId, key, value))
changed(
services,
pluginId,
'storage',
key,
okOrThrow(services.storage.set(pluginId, key, value))
)
),
'storage.delete': bind(api['storage.delete'], async ({ key }, { pluginId, services }) => {
services.storage.delete(pluginId, key)
return OK
return changed(services, pluginId, 'storage', key, OK)
}),
'storage.keys': bind(api['storage.keys'], async (_params, { pluginId, services }) => ({
keys: services.storage.keys(pluginId)
@@ -194,18 +174,24 @@ const HANDLERS: Record<PluginHostMethodName, BoundPluginHostMethod> = {
settings: services.settings.getAll(pluginId)
})),
'settings.set': bind(api['settings.set'], async ({ key, value }, { pluginId, services }) =>
okOrThrow(services.settings.set(pluginId, key, value))
changed(
services,
pluginId,
'settings',
key,
okOrThrow(services.settings.set(pluginId, key, value))
)
),
'shell.openExternal': bind(
api['shell.openExternal'],
async ({ url }, { pluginId, services, scope }) => ({
opened: await requireDesktop(services).openExternal(pluginId, url, scope)
})
async ({ url }, { pluginId, services, scope }) =>
requireDesktop(services).openExternal(pluginId, url, scope)
),
'files.saveAs': bind(api['files.saveAs'], async (params, { pluginId, services, scope }) => {
const saved = await requireDesktop(services).saveFile(pluginId, params, scope)
return { status: saved ? ('saved' as const) : ('cancelled' as const) }
})
}),
...PLUGIN_PANEL_BRIDGE_HANDLERS
}
function requireDesktop(services: PluginHostServices): PluginDesktopFrameServices {
@@ -215,6 +201,18 @@ function requireDesktop(services: PluginHostServices): PluginDesktopFrameService
return services.desktop
}
/** Tells the plugin's panels and worker after a write lands. */
function changed<T>(
services: PluginHostServices,
pluginId: string,
area: PluginDataArea,
key: string,
result: T
): T {
services.panels?.dataChanged(pluginId, area, key)
return result
}
function okOrThrow(result: { ok: true } | { ok: false; error: string }): typeof OK {
if (!result.ok) {
throw new Error(result.error)
@@ -10,6 +10,7 @@ function createServices(storageSet: PluginHostServices['storage']['set']): Plugi
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(),
@@ -190,10 +191,17 @@ describe('host API surfaces and strict params', () => {
})
it('lets each surface call only the methods that list it', async () => {
await expect(call('storage.keys', {}, 'editor')).resolves.toMatchObject({
await expect(
call('terminal.sendText', { terminalId: 't', text: 'x' }, 'editor')
).resolves.toMatchObject({
ok: false,
code: 'not_permitted'
})
// Panels and editors share the plugin's own storage, behind the same capability.
await expect(call('storage.keys', {}, 'editor')).resolves.toEqual({
ok: true,
value: { keys: [] }
})
await expect(call('storage.keys', {}, 'worker')).resolves.toEqual({
ok: true,
value: { keys: [] }
@@ -321,7 +329,7 @@ describe('executePluginHostCall frame desktop methods', () => {
const audit = () => ({ record: vi.fn().mockResolvedValue(undefined) })
it('need no capability but still consent, and stay off workers', async () => {
const desktop = { openExternal: vi.fn().mockResolvedValue(true), saveFile: vi.fn() }
const desktop = { openExternal: vi.fn().mockResolvedValue({ opened: true }), saveFile: vi.fn() }
const services = { ...createServices(vi.fn()), desktop }
const call = (overrides: Partial<Parameters<typeof executePluginHostCall>[0]>) =>
executePluginHostCall({
@@ -0,0 +1,62 @@
import {
PLUGIN_HOST_API,
type PluginJsonValue
} from '../../../shared/plugins/workers/plugin-host-api'
import type { PluginEventName } from '../../../shared/plugins/manifest/plugin-manifest'
import type {
PluginDataArea,
PluginPanelCaller
} from '../../../shared/plugins/workers/plugin-panel-bridge-protocol'
import { bind, type BoundPluginHostMethod } from './plugin-host-method-bind'
import { PluginHostCallError } from './plugin-host-call-scope'
/** What `plugin.request`, `events.subscribe` and data-change notices reach (PluginPanelBridge). */
export type PluginPanelBridgeServices = {
request(
pluginId: string,
request: { name: string; input: unknown; timeoutMs?: number },
caller: PluginPanelCaller
): Promise<PluginJsonValue>
subscribe(pluginId: string, panelId: string, event: PluginEventName): void
dataChanged(pluginId: string, area: PluginDataArea, key: string): void
}
function requirePanel(caller: PluginPanelCaller | undefined): PluginPanelCaller {
if (!caller) {
throw new PluginHostCallError('not_permitted', 'only an open panel or editor can call this')
}
return caller
}
function requireBridge(
bridge: PluginPanelBridgeServices | null | undefined
): PluginPanelBridgeServices {
if (!bridge) {
throw new PluginHostCallError('unavailable', 'panels cannot reach their worker in this Orca')
}
return bridge
}
const api = PLUGIN_HOST_API
export const PLUGIN_PANEL_BRIDGE_HANDLERS = {
'plugin.request': bind(
api['plugin.request'],
async ({ name, input, timeoutMs }, { pluginId, services, scope }) => {
const caller = requirePanel(scope.panel)
const output = await requireBridge(services.panels).request(
pluginId,
{ name, input: input ?? null, timeoutMs },
caller
)
return { output }
}
),
'events.subscribe': bind(
api['events.subscribe'],
async ({ event }, { pluginId, services, scope }) => {
requireBridge(services.panels).subscribe(pluginId, requirePanel(scope.panel).id, event)
return { ok: true as const }
}
)
} satisfies Record<string, BoundPluginHostMethod>
@@ -0,0 +1,120 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { executePluginHostCall } from './plugin-host-methods'
import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings'
import type { PluginPanelBridgeServices } from './plugin-host-panel-method-bindings'
import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/plugin-capabilities'
const panel = { id: 'panel-1', surface: 'panel' as const, contributionId: 'todos' }
function services(panels: PluginPanelBridgeServices | null = null) {
return bindPluginHostServices({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these methods never reach the runtime delegate.
delegate: {} as PluginRuntimeDelegate,
pluginsDataDir: mkdtempSync(join(tmpdir(), 'orca-panel-methods-')),
panels,
declaredSettings: [
{ key: 'pageSize', title: 'Page size', type: 'integer', default: 20, minimum: 1 }
]
})
}
function bridge() {
return {
request: vi.fn(async (..._args: unknown[]) => ['todo']),
subscribe: vi.fn(),
dataChanged: vi.fn()
} satisfies PluginPanelBridgeServices
}
const call = (
method: string,
params: unknown,
options: {
surface?: 'panel' | 'worker'
panels?: PluginPanelBridgeServices
grants?: PluginCapabilityKind[]
withPanel?: boolean
}
) => {
const bound = services(options.panels ?? null)
return executePluginHostCall({
pluginId: 'acme.todos',
method,
params,
surface: options.surface ?? 'panel',
grantedCapabilities: options.grants ?? ['settings:own', 'storage:own'],
services: bound,
audit: { record: vi.fn().mockResolvedValue(undefined) },
scope: { assertActive: () => undefined, panel: options.withPanel === false ? undefined : panel }
})
}
describe('plugin.request', () => {
it("routes to the plugin's own worker with the calling panel", async () => {
const panels = bridge()
await expect(
call(
'plugin.request',
{ name: 'todos.list', input: { done: true }, timeoutMs: 500 },
{ panels }
)
).resolves.toEqual({ ok: true, value: { output: ['todo'] } })
expect(panels.request).toHaveBeenCalledWith(
'acme.todos',
{ name: 'todos.list', input: { done: true }, timeoutMs: 500 },
panel
)
})
it('is for open frames only', async () => {
await expect(
call('plugin.request', { name: 'x' }, { surface: 'worker', panels: bridge() })
).resolves.toMatchObject({
code: 'not_permitted'
})
await expect(
call('plugin.request', { name: 'x' }, { panels: bridge(), withPanel: false })
).resolves.toMatchObject({
code: 'not_permitted'
})
await expect(
call('plugin.request', { name: 'has space' }, { panels: bridge() })
).resolves.toMatchObject({
code: 'invalid_params'
})
})
})
describe('panel storage and settings', () => {
it('reads declared settings with their default and refuses values of the wrong type', async () => {
const panels = bridge()
await expect(call('settings.get', {}, { panels })).resolves.toEqual({
ok: true,
value: { settings: { pageSize: 20 } }
})
await expect(
call('settings.set', { key: 'pageSize', value: 0 }, { panels })
).resolves.toMatchObject({
ok: false,
error: 'pageSize must be at least 1'
})
expect(panels.dataChanged).not.toHaveBeenCalled()
})
it('tells panels and the worker after a write lands', async () => {
const panels = bridge()
await expect(
call('storage.set', { key: 'todos', value: [1] }, { panels })
).resolves.toMatchObject({ ok: true })
expect(panels.dataChanged).toHaveBeenCalledWith('acme.todos', 'storage', 'todos')
})
it('keeps the capability gate for panels', async () => {
await expect(call('storage.get', { key: 'todos' }, { grants: [] })).resolves.toMatchObject({
code: 'capability_denied'
})
})
})
@@ -0,0 +1,63 @@
import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/plugin-capabilities'
import type { PluginEventName } from '../../../shared/plugins/manifest/plugin-manifest'
import type {
PluginErrorCode,
PluginHostCallOutcome
} from '../../../shared/plugins/frames/plugin-protocol'
import type {
PluginDataChanged,
PluginPanelCaller,
PluginWorkerPanelPost
} from '../../../shared/plugins/workers/plugin-panel-bridge-protocol'
/** A worker answer that failed, with the code the worker (or the host) gave. */
export type PluginWorkerRequestError = Error & { code: PluginErrorCode }
export type PluginWorkerPanelRequest = {
name: string
input: unknown
panel: PluginPanelCaller
timeoutMs: number
signal?: AbortSignal
}
export type PluginWorkerLogSink = (level: 'info' | 'warn' | 'error', line: string) => void
export type PluginWorkerHostCallExecutor = (
method: string,
params: unknown
) => Promise<PluginHostCallOutcome>
export type PluginWorkerHandle = {
/** Command ids the worker registered on activate (⊆ manifest commands). */
commands: readonly string[]
invokeCommand(commandId: string, args?: unknown): Promise<unknown>
deliverEvent(event: PluginEventName, payload: unknown): void
/** A panel's `plugin.request`; rejects with a {@link PluginWorkerRequestError}. */
requestFromPanel(request: PluginWorkerPanelRequest): Promise<unknown>
/** Fire-and-forget: the worker's `storage`/`settings` `onDidChange` listeners. */
notifyDataChanged(change: PluginDataChanged): void
/** Last completed work timestamp, in milliseconds, for idle reaping. */
lastActivityAt(): number
inFlightCount(): number
dispose(): Promise<void>
kill(): void
onExit(callback: (code: number | null) => void): void
}
export type StartPluginWorkerOptions = {
pluginId: string
rootDir: string
mainEntry: string
/** Caller-resolved compiled entry path. */
entryPath: string
grantedCapabilities: readonly PluginCapabilityKind[]
executeHostCall: PluginWorkerHostCallExecutor
/** `orca.panels.post` from this worker, already shape-checked. */
onPanelPost?: (post: PluginWorkerPanelPost) => void
log: PluginWorkerLogSink
readyTimeoutMs?: number
invokeTimeoutMs?: number
eventTimeoutMs?: number
signal?: AbortSignal
}
+50 -60
View File
@@ -8,17 +8,21 @@ import {
WORKER_EVENTS,
WORKER_METHODS
} from '../../../shared/plugins/workers/plugin-host-protocol'
import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/plugin-capabilities'
import type { PluginEventName } from '../../../shared/plugins/manifest/plugin-manifest'
import {
pluginCancel,
pluginEvent,
pluginOutcomeResponse,
pluginRequest,
normalizePluginErrorCode,
readPluginEnvelope,
type PluginEnvelope,
type PluginHostCallOutcome
type PluginEnvelope
} from '../../../shared/plugins/frames/plugin-protocol'
import type { PluginWorkerHandle, StartPluginWorkerOptions } from './plugin-host-process-types'
import {
requestError,
sendPluginWorkerRequest,
type PendingCall,
type PluginWorkerRequestChannel
} from './plugin-worker-requests'
import { buildPluginWorkerEnv } from './plugin-worker-env'
import { pipePluginWorkerOutput } from './plugin-worker-output-buffer'
@@ -26,40 +30,12 @@ const PLUGIN_WORKER_SHUTDOWN_GRACE_MS = 2_000
const PLUGIN_WORKER_EVENT_TIMEOUT_MS = 5 * 60_000
const PLUGIN_WORKER_MAX_PENDING_EVENTS = 64
export type PluginWorkerLogSink = (level: 'info' | 'warn' | 'error', line: string) => void
export type PluginWorkerHostCallExecutor = (
method: string,
params: unknown
) => Promise<PluginHostCallOutcome>
export type PluginWorkerHandle = {
/** Command ids the worker registered on activate (⊆ manifest commands). */
commands: readonly string[]
invokeCommand(commandId: string, args?: unknown): Promise<unknown>
deliverEvent(event: PluginEventName, payload: unknown): void
/** Last completed work timestamp, in milliseconds, for idle reaping. */
lastActivityAt(): number
inFlightCount(): number
dispose(): Promise<void>
kill(): void
onExit(callback: (code: number | null) => void): void
}
export type StartPluginWorkerOptions = {
pluginId: string
rootDir: string
mainEntry: string
/** Caller-resolved compiled entry path. */
entryPath: string
grantedCapabilities: readonly PluginCapabilityKind[]
executeHostCall: PluginWorkerHostCallExecutor
log: PluginWorkerLogSink
readyTimeoutMs?: number
invokeTimeoutMs?: number
eventTimeoutMs?: number
signal?: AbortSignal
}
export type {
PluginWorkerHandle,
PluginWorkerHostCallExecutor,
PluginWorkerLogSink,
StartPluginWorkerOptions
} from './plugin-host-process-types'
/** Fork the unpacked entry because Node cannot execute inside app.asar. */
export function resolvePluginHostEntryPath(appPath: string, isPackaged: boolean): string {
@@ -71,12 +47,6 @@ export function resolvePluginHostEntryPath(appPath: string, isPackaged: boolean)
return join(basePath, 'out', 'main', 'plugin-host-entry.js')
}
type PendingCall = {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
export async function startPluginWorker(
options: StartPluginWorkerOptions
): Promise<PluginWorkerHandle> {
@@ -118,7 +88,7 @@ export async function startPluginWorker(
for (const [callId, entry] of pendingCommands) {
clearTimeout(entry.timer)
pendingCommands.delete(callId)
entry.reject(new Error(reason))
entry.reject(requestError('unavailable', reason))
}
for (const timer of pendingEvents.values()) {
clearTimeout(timer)
@@ -188,7 +158,8 @@ export async function startPluginWorker(
clearTimeout(entry.timer)
pendingCommands.delete(message.id)
if (message.error) {
entry.reject(new Error(message.error.message || 'plugin command failed'))
const code = normalizePluginErrorCode(message.error.code)
entry.reject(requestError(code, message.error.message || 'plugin command failed'))
} else {
entry.resolve(message.result)
}
@@ -218,6 +189,9 @@ export async function startPluginWorker(
clearTimeout(timer)
options.signal?.removeEventListener('abort', onAbort)
resolve(event.payload.commands)
} else if (event?.event === WORKER_EVENTS.panelPost) {
lastActivityAt = Date.now()
options.onPanelPost?.(event.payload)
} else if (event?.event === WORKER_EVENTS.log) {
log(event.payload.level, event.payload.message)
} else if (event?.event === WORKER_EVENTS.fatal) {
@@ -239,23 +213,39 @@ export async function startPluginWorker(
}
})
const requests: PluginWorkerRequestChannel = {
pending: pendingCommands,
send: sendToChild,
nextId: () => nextId++,
isRunning: () => !exited && !disposed,
tag
}
return {
commands,
invokeCommand(commandId, args) {
if (exited || disposed) {
return Promise.reject(new Error(`${tag} worker is not running`))
return sendPluginWorkerRequest(
requests,
WORKER_METHODS.invokeCommand,
{ commandId, args },
invokeTimeoutMs,
commandId
)
},
requestFromPanel({ name, input, panel, timeoutMs, signal }) {
return sendPluginWorkerRequest(
requests,
WORKER_METHODS.panelRequest,
{ name, input, panel },
timeoutMs,
name,
signal
)
},
notifyDataChanged(change) {
if (!exited && !disposed) {
sendToChild(pluginEvent(WORKER_EVENTS.dataChanged, change))
}
const callId = nextId++
return new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
pendingCommands.delete(callId)
// Tell the handler too, so it stops instead of running on unobserved.
sendToChild(pluginCancel(callId))
reject(new Error(`${tag} ${commandId} timed out after ${invokeTimeoutMs}ms`))
}, invokeTimeoutMs)
pendingCommands.set(callId, { resolve, reject, timer })
sendToChild(pluginRequest(callId, WORKER_METHODS.invokeCommand, { commandId, args }))
})
},
deliverEvent(event, payload) {
if (exited || disposed) {
+74 -90
View File
@@ -24,22 +24,17 @@ import {
type PluginRequest,
type PluginResponse
} from '../../../shared/plugins/frames/plugin-protocol'
import type { PluginEventName } from '../../../shared/plugins/manifest/plugin-manifest'
import { PLUGIN_V1_EVENT_ALIASES } from '../../../shared/plugins/contributions/plugin-event-contribution'
import {
PLUGIN_HOST_API_MINOR,
pluginHostMethodsFor
} from '../../../shared/plugins/workers/plugin-host-api'
import { PLUGIN_HOST_API_MAJOR } from '../../../shared/plugins/workers/plugin-host-api-major'
pluginDataChangedSchema,
pluginWorkerPanelRequestParamsSchema
} from '../../../shared/plugins/workers/plugin-panel-bridge-protocol'
import {
createPluginWorkerApiTables,
createPluginWorkerOrcaApi,
type PluginWorkerOrcaApi
} from './plugin-worker-orca-api'
/** A failed host call or command: the same `{ code, message, retryable }` frames get. */
// Why: format 1 handlers read `worktreeId`; newer payloads name it `workspaceId`.
function withV1WorktreeId(payload: unknown): unknown {
if (typeof payload !== 'object' || payload === null || !('workspaceId' in payload)) {
return payload
}
return 'worktreeId' in payload ? payload : { ...payload, worktreeId: payload.workspaceId }
}
export type { PluginDisposable, PluginWorkerOrcaApi } from './plugin-worker-orca-api'
export type PluginHostCallError = Error & {
code: PluginErrorCode
@@ -47,33 +42,6 @@ export type PluginHostCallError = Error & {
retryAfterMs?: number
}
export type PluginDisposable = { dispose(): void }
/** API surface handed to a plugin's `activate(orca)` export (host API 1). */
export type PluginWorkerOrcaApi = {
api: { readonly major: number; readonly minor: number }
commands: {
register(
commandId: string,
handler: (args: unknown, context: { signal: AbortSignal }) => unknown
): PluginDisposable
}
events: {
on(
event: PluginEventName,
handler: (payload: unknown) => void | Promise<void>
): PluginDisposable
}
host: {
call(method: string, params?: unknown, options?: { signal?: AbortSignal }): Promise<unknown>
/** Whether this host implements `method` for workers. */
has(method: string): boolean
}
/** Consented capability kinds (informational — the host re-gates). */
grantedCapabilities: readonly string[]
log(message: string): void
}
export type PluginWorkerRuntimeOptions = {
send: (message: PluginEnvelope) => void
importModule?: (specifier: string) => Promise<unknown>
@@ -100,7 +68,14 @@ function hostCallError(error: PluginResponse['error']): PluginHostCallError {
const CANCELLED = pluginError('cancelled', 'cancelled')
const WORKER_METHOD_NAMES: ReadonlySet<string> = new Set(pluginHostMethodsFor('worker'))
/** A handler's own `{ code }` (a known one) reaches the panel; anything else is `action_failed`. */
function handlerFailure(error: unknown) {
const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : null
return pluginError(
normalizePluginErrorCode(code),
error instanceof Error ? error.message : String(error)
)
}
export function createPluginWorkerRuntime(
options: PluginWorkerRuntimeOptions
@@ -108,11 +83,9 @@ export function createPluginWorkerRuntime(
const send = options.send
const importModule = options.importModule ?? ((specifier: string) => import(specifier))
const exit = options.exit ?? ((code: number) => process.exit(code))
const commandHandlers = new Map<
string,
(args: unknown, context: { signal: AbortSignal }) => unknown
>()
const eventHandlers = new Map<string, Set<(payload: unknown) => void | Promise<void>>>()
const tables = createPluginWorkerApiTables()
const commandHandlers = tables.commands
const eventHandlers = tables.events
const pendingHostCalls = new Map<number, (response: PluginResponse) => void>()
const runningCommands = new Map<number, AbortController>()
let nextHostCallId = 0
@@ -156,54 +129,24 @@ export function createPluginWorkerRuntime(
const module: unknown = await importModule(entryUrl)
const activate = readExport(module, 'default')
if (typeof activate !== 'function') {
throw new Error(`plugin entry ${input.mainEntry} has no default-exported activate function`)
// Why name it: `export function activate` is the usual slip, and Orca never calls it.
throw new Error(
typeof readExport(module, 'activate') === 'function'
? `plugin entry ${input.mainEntry} exports activate by name, but Orca calls the default export: write \`export default function activate(orca)\``
: `plugin entry ${input.mainEntry} has no default-exported activate function`
)
}
const deactivateExport = readExport(module, 'deactivate')
if (deactivateExport !== undefined && typeof deactivateExport !== 'function') {
throw new Error(`plugin entry ${input.mainEntry} has a non-function deactivate export`)
}
deactivate = deactivateExport === undefined ? null : () => deactivateExport()
const orca: PluginWorkerOrcaApi = Object.freeze({
api: Object.freeze({ major: PLUGIN_HOST_API_MAJOR, minor: PLUGIN_HOST_API_MINOR }),
commands: Object.freeze({
register(
commandId: string,
handler: Parameters<PluginWorkerOrcaApi['commands']['register']>[1]
) {
commandHandlers.set(commandId, handler)
return {
dispose: () => {
commandHandlers.delete(commandId)
}
}
}
}),
events: Object.freeze({
on(event: PluginEventName, handler: (payload: unknown) => void | Promise<void>) {
// Why: format 1 workers still listen under the old worktree.* names.
const name = PLUGIN_V1_EVENT_ALIASES.get(event) ?? event
const handlers = eventHandlers.get(name) ?? new Set()
eventHandlers.set(name, handlers)
const v1Payload = name !== event || event === 'agent.status.changed'
const entry = (payload: unknown): void | Promise<void> =>
handler(v1Payload ? withV1WorktreeId(payload) : payload)
handlers.add(entry)
return {
dispose: () => {
handlers.delete(entry)
}
}
}
}),
host: Object.freeze({
call: (method: string, params?: unknown, callOptions?: { signal?: AbortSignal }) =>
hostCall(method, params, callOptions?.signal),
has: (method: string) => WORKER_METHOD_NAMES.has(method)
}),
grantedCapabilities: Object.freeze([...input.grantedCapabilities]),
log(message: string) {
send(pluginWorkerLog('info', String(message)))
}
const orca: PluginWorkerOrcaApi = createPluginWorkerOrcaApi({
tables,
hostCall: (method, params, callOptions) => hostCall(method, params, callOptions?.signal),
postToPanels: (post) => send(pluginEvent(WORKER_EVENTS.panelPost, post)),
log: (message) => send(pluginWorkerLog('info', message)),
grantedCapabilities: input.grantedCapabilities
})
await activate(orca)
send(pluginEvent(WORKER_EVENTS.ready, { commands: [...commandHandlers.keys()] }))
@@ -235,6 +178,10 @@ export function createPluginWorkerRuntime(
}
return
}
if (request.method === WORKER_METHODS.panelRequest) {
await answerPanelRequest(request)
return
}
if (request.method === WORKER_METHODS.deliverEvent) {
const parsed = pluginWorkerDeliverParamsSchema.safeParse(request.params)
for (const handler of parsed.success ? (eventHandlers.get(parsed.data.event) ?? []) : []) {
@@ -252,6 +199,39 @@ export function createPluginWorkerRuntime(
)
}
async function answerPanelRequest(request: PluginRequest): Promise<void> {
const parsed = pluginWorkerPanelRequestParamsSchema.safeParse(request.params)
const handler = parsed.success ? tables.panelRequests.get(parsed.data.name) : undefined
if (!parsed.success || !handler) {
const name = parsed.success ? parsed.data.name : '(invalid)'
send(
pluginFailure(request.id, pluginError('unknown_method', `no panel handler named ${name}`))
)
return
}
const controller = new AbortController()
runningCommands.set(request.id, controller)
try {
const { input, panel } = parsed.data
send(pluginResult(request.id, await handler(input, { panel, signal: controller.signal })))
} catch (error) {
send(pluginFailure(request.id, handlerFailure(error)))
} finally {
runningCommands.delete(request.id)
}
}
function notifyDataChanged(payload: unknown): void {
const change = pluginDataChangedSchema.safeParse(payload)
for (const listener of change.success ? tables.dataListeners[change.data.area] : []) {
try {
listener({ key: change.success ? change.data.key : '' })
} catch (error) {
send(pluginWorkerLog('error', toErrorMessage(error)))
}
}
}
async function shutdown(): Promise<void> {
if (shuttingDown) {
return
@@ -279,6 +259,10 @@ export function createPluginWorkerRuntime(
await shutdown()
return
}
if (message.event === WORKER_EVENTS.dataChanged) {
notifyDataChanged(message.payload)
return
}
const init =
message.event === WORKER_EVENTS.init
? pluginWorkerInitSchema.safeParse(message.payload)
@@ -311,6 +295,6 @@ export function createPluginWorkerRuntime(
}
}
function readExport(module: unknown, name: 'default' | 'deactivate'): unknown {
function readExport(module: unknown, name: 'default' | 'activate' | 'deactivate'): unknown {
return typeof module === 'object' && module !== null && name in module ? module[name] : undefined
}
@@ -1,9 +1,16 @@
import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../../shared/plugins/workers/plugin-host-api'
import type { PluginHostServices } from './plugin-host-methods'
import type { PluginDesktopFrameServices } from './plugin-host-method-bindings'
import type { PluginPanelBridgeServices } from './plugin-host-panel-method-bindings'
import {
pluginSettingValueIssue,
type PluginSettingContribution
} from '../../../shared/plugins/contributions/plugin-settings-contribution'
import { pluginSettingDefaults } from '../../../shared/plugins/contributions/plugin-settings-values'
import { PluginSecretsStore, pluginSecretsProtection } from '../plugin-secrets-store'
import { PluginKvStore } from '../plugin-storage-store'
import type { TerminalInputKind } from '../../../shared/terminal-input-kind'
import { pluginTerminalTitle, type PluginAgentStatusRow } from './plugin-workspace-projection'
export type PluginRuntimeDelegate = {
resolveActiveWorktreeContext(): Promise<{
@@ -16,7 +23,15 @@ export type PluginRuntimeDelegate = {
worktreeSelector?: string,
limit?: number,
opts?: { includeVisualLayouts?: boolean }
): Promise<{ terminals: { handle: string; title: string | null }[] }>
): Promise<{
terminals: {
handle: string
title: string | null
paneTitle?: string
tabId?: string
leafId?: string
}[]
}>
sendTerminal(
handle: string,
action: { text?: string; enter?: boolean },
@@ -27,16 +42,23 @@ export type PluginRuntimeDelegate = {
title: string
body?: string
}): Promise<{ delivered: boolean }>
/** The hook server's agent rows; absent where no hook server runs. */
listAgentStatuses?(): readonly PluginAgentStatusRow[]
}
export function bindPluginHostServices(input: {
delegate: PluginRuntimeDelegate
pluginsDataDir: string
desktop?: PluginDesktopFrameServices | null
panels?: PluginPanelBridgeServices | null
/** The calling plugin's `contributes.settings`: defaults and value checks. */
declaredSettings?: readonly PluginSettingContribution[]
}): PluginHostServices {
const declared = new Map((input.declaredSettings ?? []).map((setting) => [setting.key, setting]))
const { delegate, pluginsDataDir } = input
return {
desktop: input.desktop ?? null,
panels: input.panels ?? null,
resolveActiveWorktreeContext: async () => {
const context = await delegate.resolveActiveWorktreeContext()
if (!context) {
@@ -55,10 +77,15 @@ export function bindPluginHostServices(input: {
PLUGIN_WORKSPACE_TERMINAL_LIMIT,
{ includeVisualLayouts: false }
)
return result.terminals
.slice(0, PLUGIN_WORKSPACE_TERMINAL_LIMIT)
.map((terminal) => ({ id: terminal.handle }))
return result.terminals.slice(0, PLUGIN_WORKSPACE_TERMINAL_LIMIT).map((terminal) => ({
id: terminal.handle,
title: pluginTerminalTitle(terminal),
...(terminal.tabId && terminal.leafId
? { paneKey: `${terminal.tabId}:${terminal.leafId}` }
: {})
}))
},
listAgentStatuses: () => delegate.listAgentStatuses?.() ?? [],
sendTerminalText: async (terminalId, action) => {
const result = await delegate.sendTerminal(terminalId, action, { inputKind: 'driving' })
return { accepted: result.accepted }
@@ -79,9 +106,17 @@ export function bindPluginHostServices(input: {
protection: () => (pluginSecretsProtection().level === 'os' ? 'os-keyring' : 'unavailable')
},
settings: {
getAll: (key) => new PluginKvStore(pluginsDataDir, key, 'settings.json').getAll(),
set: (key, itemKey, value) =>
new PluginKvStore(pluginsDataDir, key, 'settings.json').set(itemKey, value)
getAll: (key) => ({
...pluginSettingDefaults(declared.values()),
...new PluginKvStore(pluginsDataDir, key, 'settings.json').getAll()
}),
set: (key, itemKey, value) => {
const setting = declared.get(itemKey)
const issue = setting ? pluginSettingValueIssue(setting, value) : null
return issue
? { ok: false, error: `${itemKey} ${issue}` }
: new PluginKvStore(pluginsDataDir, key, 'settings.json').set(itemKey, value)
}
}
}
}
@@ -52,6 +52,8 @@ function worker(commands: string[]): PluginWorkerHandle & { dispose: ReturnType<
commands,
invokeCommand: vi.fn(async () => null),
deliverEvent: vi.fn(),
requestFromPanel: vi.fn(),
notifyDataChanged: vi.fn(),
lastActivityAt: () => Date.now(),
inFlightCount: () => 0,
dispose: vi.fn(async () => undefined),
@@ -11,6 +11,7 @@ import {
import { buildPluginWorkerSpawnSpec, pluginWorkerSpawnSpecsEqual } from './plugin-worker-spawn-spec'
import type { PluginWorkerHandle } from './plugin-host-process'
import type { PluginRunState } from './plugin-supervisor'
import type { PluginWorkerPanelPost } from '../../../shared/plugins/workers/plugin-panel-bridge-protocol'
export type PluginWorkerControllerOptions = {
entryPath: string
@@ -27,6 +28,7 @@ export type PluginWorkerControllerOptions = {
method: string,
params: unknown
) => Promise<PluginHostCallOutcome>
onPanelPost?: (pluginKey: string, post: PluginWorkerPanelPost) => void
log: (pluginKey: string) => (level: 'info' | 'warn' | 'error', line: string) => void
onStateChanged: (pluginKey: string) => void
}
@@ -44,11 +46,16 @@ export class PluginWorkerController {
idleReapMs: options.idleReapMs,
workerFactory: options.workerFactory,
executeHostCall: options.executeHostCall,
onPanelPost: options.onPanelPost,
log: options.log,
onWorkerStateChange: options.onStateChanged
})
}
runningHandle(pluginKey: string): PluginWorkerHandle | null {
return this.manager.runningHandle(pluginKey)
}
state(pluginKey: string): { state: PluginRunState; restarts: number } {
return {
state: this.manager.runState(pluginKey),
@@ -17,6 +17,8 @@ function worker(lastActivity = Date.now()): TestWorker {
commands: ['run'],
invokeCommand: vi.fn(async () => null),
deliverEvent: vi.fn(),
requestFromPanel: vi.fn(),
notifyDataChanged: vi.fn(),
lastActivityAt: () => lastActivity,
inFlightCount: () => 0,
dispose: vi.fn(async () => undefined),
@@ -8,6 +8,7 @@ import {
type PluginRunState
} from './plugin-supervisor'
import type { PluginWorkerHandle, PluginWorkerHostCallExecutor } from './plugin-host-process'
import type { PluginWorkerPanelPost } from '../../../shared/plugins/workers/plugin-panel-bridge-protocol'
import { PluginWorkerSlotPool } from './plugin-worker-slot-pool'
import {
startPluginWorkerAttempt,
@@ -36,6 +37,7 @@ export type PluginWorkerManagerOptions = {
method: string,
params: unknown
) => ReturnType<PluginWorkerHostCallExecutor>
onPanelPost?: (pluginKey: string, post: PluginWorkerPanelPost) => void
log: (pluginKey: string) => (level: 'info' | 'warn' | 'error', line: string) => void
onWorkerStateChange: (pluginKey: string) => void
}
@@ -63,6 +65,11 @@ export class PluginWorkerManager {
this.idleReapMs = options.idleReapMs ?? PLUGIN_WORKER_IDLE_REAP_MS
}
/** The live worker, without starting one. */
runningHandle(pluginKey: string): PluginWorkerHandle | null {
return this.workers.get(pluginKey)?.handle ?? null
}
runState(pluginKey: string): PluginRunState {
return this.supervisor.getState(pluginKey)
}
@@ -151,6 +158,7 @@ export class PluginWorkerManager {
factory: this.options.workerFactory,
executeHostCall: (method, params) =>
this.options.executeHostCall(spec.pluginKey, method, params),
onPanelPost: (post) => this.options.onPanelPost?.(spec.pluginKey, post),
log,
assertActive: () => this.throwIfCancelled(spec.pluginKey, generation, signal),
verifyBeforeSpawn: this.known.get(spec.pluginKey)?.verifyBeforeSpawn,
@@ -0,0 +1,204 @@
import type { PluginEventName } from '../../../shared/plugins/manifest/plugin-manifest'
import { PLUGIN_V1_EVENT_ALIASES } from '../../../shared/plugins/contributions/plugin-event-contribution'
import {
PLUGIN_HOST_API_MINOR,
pluginHostMethodsFor
} from '../../../shared/plugins/workers/plugin-host-api'
import { PLUGIN_HOST_API_MAJOR } from '../../../shared/plugins/workers/plugin-host-api-major'
import {
pluginBridgeNameSchema,
type PluginDataChanged,
type PluginPanelCaller,
type PluginWorkerPanelPost
} from '../../../shared/plugins/workers/plugin-panel-bridge-protocol'
export type PluginDisposable = { dispose(): void }
export type PluginPanelRequestHandler = (
input: unknown,
context: { panel: PluginPanelCaller; signal: AbortSignal }
) => unknown
type HostCall = (
method: string,
params?: unknown,
options?: { signal?: AbortSignal }
) => Promise<unknown>
type DataListener = (change: { key: string }) => void
/** API surface handed to a plugin's `activate(orca)` export (host API 1). */
export type PluginWorkerOrcaApi = {
api: { readonly major: number; readonly minor: number }
commands: {
register(
commandId: string,
handler: (args: unknown, context: { signal: AbortSignal }) => unknown
): PluginDisposable
}
events: {
on(
event: PluginEventName,
handler: (payload: unknown) => void | Promise<void>
): PluginDisposable
}
host: {
call: HostCall
/** Whether this host implements `method` for workers. */
has(method: string): boolean
}
/** The plugin's own panels and custom editors. */
panels: {
handle(name: string, handler: PluginPanelRequestHandler): PluginDisposable
post(event: string, payload?: unknown, options?: { panel?: string }): void
}
storage: {
get(key: string): Promise<unknown>
set(key: string, value: unknown): Promise<void>
delete(key: string): Promise<void>
keys(): Promise<string[]>
onDidChange(listener: DataListener): PluginDisposable
}
settings: {
/** Every setting, or one; declared settings read as their default until set. */
get(key?: string): Promise<unknown>
set(key: string, value: unknown): Promise<void>
onDidChange(listener: DataListener): PluginDisposable
}
secrets: {
get(key: string): Promise<string | null>
set(key: string, value: string): Promise<void>
delete(key: string): Promise<void>
}
/** Consented capability kinds (informational — the host re-gates). */
grantedCapabilities: readonly string[]
log(message: string): void
}
// Why: format 1 handlers read `worktreeId`; newer payloads name it `workspaceId`.
function withV1WorktreeId(payload: unknown): unknown {
if (typeof payload !== 'object' || payload === null || !('workspaceId' in payload)) {
return payload
}
return 'worktreeId' in payload ? payload : { ...payload, worktreeId: payload.workspaceId }
}
function field(result: unknown, name: string): unknown {
return typeof result === 'object' && result !== null && name in result
? Object.getOwnPropertyDescriptor(result, name)?.value
: undefined
}
function addTo<T>(set: Set<T>, entry: T): PluginDisposable {
set.add(entry)
return { dispose: () => void set.delete(entry) }
}
const WORKER_METHOD_NAMES: ReadonlySet<string> = new Set(pluginHostMethodsFor('worker'))
/** The tables the runtime dispatches from; this module only fills them. */
export type PluginWorkerApiTables = {
commands: Map<string, (args: unknown, context: { signal: AbortSignal }) => unknown>
events: Map<string, Set<(payload: unknown) => void | Promise<void>>>
panelRequests: Map<string, PluginPanelRequestHandler>
dataListeners: Record<PluginDataChanged['area'], Set<DataListener>>
}
export function createPluginWorkerApiTables(): PluginWorkerApiTables {
return {
commands: new Map(),
events: new Map(),
panelRequests: new Map(),
dataListeners: { storage: new Set(), settings: new Set() }
}
}
export function createPluginWorkerOrcaApi(input: {
tables: PluginWorkerApiTables
hostCall: HostCall
postToPanels: (post: PluginWorkerPanelPost) => void
log: (message: string) => void
grantedCapabilities: readonly string[]
}): PluginWorkerOrcaApi {
const { tables, hostCall } = input
const ok = async (method: string, params: unknown): Promise<void> => {
await hostCall(method, params)
}
return Object.freeze({
api: Object.freeze({ major: PLUGIN_HOST_API_MAJOR, minor: PLUGIN_HOST_API_MINOR }),
commands: Object.freeze({
register(
commandId: string,
handler: Parameters<PluginWorkerOrcaApi['commands']['register']>[1]
) {
tables.commands.set(commandId, handler)
return { dispose: () => void tables.commands.delete(commandId) }
}
}),
events: Object.freeze({
on(event: PluginEventName, handler: (payload: unknown) => void | Promise<void>) {
// Why: format 1 workers still listen under the old worktree.* names.
const name = PLUGIN_V1_EVENT_ALIASES.get(event) ?? event
const handlers = tables.events.get(name) ?? new Set()
tables.events.set(name, handlers)
const v1Payload = name !== event || event === 'agent.status.changed'
return addTo(handlers, (payload: unknown) =>
handler(v1Payload ? withV1WorktreeId(payload) : payload)
)
}
}),
host: Object.freeze({
call: (method: string, params?: unknown, options?: { signal?: AbortSignal }) =>
hostCall(method, params, options),
has: (method: string) => WORKER_METHOD_NAMES.has(method)
}),
panels: Object.freeze({
handle(name: string, handler: PluginPanelRequestHandler) {
pluginBridgeNameSchema.parse(name)
if (typeof handler !== 'function') {
throw new TypeError('orca.panels.handle needs a handler function')
}
tables.panelRequests.set(name, handler)
return {
dispose: () => {
if (tables.panelRequests.get(name) === handler) {
tables.panelRequests.delete(name)
}
}
}
},
post(event: string, payload?: unknown, options?: { panel?: string }) {
pluginBridgeNameSchema.parse(event)
input.postToPanels({ event, payload, ...(options?.panel ? { panel: options.panel } : {}) })
}
}),
storage: Object.freeze({
get: async (key: string) => field(await hostCall('storage.get', { key }), 'value') ?? null,
set: (key: string, value: unknown) => ok('storage.set', { key, value }),
delete: (key: string) => ok('storage.delete', { key }),
keys: async () => {
const keys = field(await hostCall('storage.keys'), 'keys')
return Array.isArray(keys) ? keys.filter((key) => typeof key === 'string') : []
},
onDidChange: (listener: DataListener) => addTo(tables.dataListeners.storage, listener)
}),
settings: Object.freeze({
get: async (key?: string) => {
const raw = field(await hostCall('settings.get'), 'settings')
const settings = typeof raw === 'object' && raw !== null ? { ...raw } : {}
return key === undefined ? settings : field(settings, key)
},
set: (key: string, value: unknown) => ok('settings.set', { key, value }),
onDidChange: (listener: DataListener) => addTo(tables.dataListeners.settings, listener)
}),
secrets: Object.freeze({
get: async (key: string) => {
const value = field(await hostCall('secrets.get', { key }), 'value')
return typeof value === 'string' ? value : null
},
set: (key: string, value: string) => ok('secrets.set', { key, value }),
delete: (key: string) => ok('secrets.delete', { key })
}),
grantedCapabilities: Object.freeze([...input.grantedCapabilities]),
log: (message: string) => input.log(String(message))
})
}
@@ -0,0 +1,162 @@
import { describe, expect, it, vi } from 'vitest'
import { createPluginWorkerRuntime, type PluginWorkerOrcaApi } from './plugin-host-runtime'
const panel = { id: 'panel-1', surface: 'panel', contributionId: 'todos' }
async function activate(setup: (orca: PluginWorkerOrcaApi) => void) {
const send = vi.fn()
const runtime = createPluginWorkerRuntime({
send,
exit: vi.fn(),
importModule: async () => ({ default: setup })
})
await runtime.handleMessage({
orca: 1,
kind: 'evt',
event: 'init',
payload: {
pluginId: 'samples.demo',
pluginRoot: '/p',
mainEntry: 'm.js',
grantedCapabilities: []
}
})
send.mockClear()
const request = (id: number, name: string, input: unknown) =>
runtime.handleMessage({
orca: 1,
kind: 'req',
id,
method: 'panel.request',
params: { name, input, panel }
})
return { send, runtime, request }
}
describe('worker panel requests', () => {
it('answers with the handler result and hands it the calling panel', async () => {
const handler = vi.fn(async (input: unknown) => ({ echoed: input }))
const { send, request } = await activate((orca) => orca.panels.handle('todos.list', handler))
await request(4, 'todos.list', { done: true })
expect(handler).toHaveBeenCalledWith({ done: true }, { panel, signal: expect.any(AbortSignal) })
expect(send).toHaveBeenCalledWith({
orca: 1,
kind: 'res',
id: 4,
result: { echoed: { done: true } }
})
})
it('keeps a known error code the handler threw, and refuses unknown names', async () => {
const { send, request } = await activate((orca) => {
orca.panels.handle('todos.add', () => {
throw Object.assign(new Error('title is taken'), { code: 'conflict' })
})
orca.panels.handle('todos.bad', () => {
throw Object.assign(new Error('nope'), { code: 'made_up' })
})
})
await request(1, 'todos.add', {})
await request(2, 'todos.bad', {})
await request(3, 'todos.missing', {})
const errors = send.mock.calls.map(([message]) => message.error?.code)
expect(errors).toEqual(['conflict', 'action_failed', 'unknown_method'])
})
it('aborts the handler signal when the host cancels the request', async () => {
let seen: AbortSignal | undefined
let release!: () => void
const { runtime, request } = await activate((orca) =>
orca.panels.handle('slow', (_input, { signal }) => {
seen = signal
return new Promise<null>((resolve) => (release = () => resolve(null)))
})
)
const pending = request(9, 'slow', null)
await vi.waitFor(() => expect(seen).toBeDefined())
await runtime.handleMessage({ orca: 1, kind: 'cancel', id: 9 })
expect(seen?.aborted).toBe(true)
release()
await pending
})
it('posts to panels, and validates names on both sides', async () => {
let orca!: PluginWorkerOrcaApi
const { send } = await activate((api) => (orca = api))
orca.panels.post('todos.changed', { count: 2 }, { panel: 'panel-1' })
expect(send).toHaveBeenCalledWith({
orca: 1,
kind: 'evt',
event: 'panel.post',
payload: { event: 'todos.changed', payload: { count: 2 }, panel: 'panel-1' }
})
expect(() => orca.panels.post('has spaces')).toThrow()
expect(() => orca.panels.handle('', () => null)).toThrow()
})
it('tells storage and settings listeners which key changed', async () => {
const storage = vi.fn()
const settings = vi.fn()
const { runtime } = await activate((orca) => {
orca.storage.onDidChange(storage)
orca.settings.onDidChange(settings)
})
await runtime.handleMessage({
orca: 1,
kind: 'evt',
event: 'data.changed',
payload: { area: 'settings', key: 'apiUrl' }
})
expect(settings).toHaveBeenCalledWith({ key: 'apiUrl' })
expect(storage).not.toHaveBeenCalled()
})
it('reads storage and settings through host calls', async () => {
let orca!: PluginWorkerOrcaApi
const { send, runtime } = await activate((api) => (orca = api))
const value = orca.storage.get('todos')
const setting = orca.settings.get('apiUrl')
const [get, settingsGet] = send.mock.calls.map(([message]) => message)
expect(get).toMatchObject({ method: 'storage.get', params: { key: 'todos' } })
expect(settingsGet).toMatchObject({ method: 'settings.get' })
await runtime.handleMessage({ orca: 1, kind: 'res', id: get.id, result: { value: [1] } })
await runtime.handleMessage({
orca: 1,
kind: 'res',
id: settingsGet.id,
result: { settings: { apiUrl: 'https://x' } }
})
await expect(value).resolves.toEqual([1])
await expect(setting).resolves.toBe('https://x')
})
})
describe('worker entry', () => {
it('names the fix when activate is exported by name instead of as the default', async () => {
const activate = vi.fn()
const send = vi.fn()
const runtime = createPluginWorkerRuntime({
send,
exit: vi.fn(),
importModule: async () => ({ activate })
})
await runtime.handleMessage({
orca: 1,
kind: 'evt',
event: 'init',
payload: {
pluginId: 'samples.demo',
pluginRoot: '/p',
mainEntry: 'm.js',
grantedCapabilities: []
}
})
expect(activate).not.toHaveBeenCalled()
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
event: 'fatal',
payload: { error: expect.stringContaining('export default function activate(orca)') }
})
)
})
})
@@ -25,6 +25,8 @@ function worker(): PluginWorkerHandle & { exit: () => void } {
commands: ['run'],
invokeCommand: vi.fn(async () => null),
deliverEvent: vi.fn(),
requestFromPanel: vi.fn(),
notifyDataChanged: vi.fn(),
// ast-grep-ignore: test-no-wall-clock -- idle checks compare against the real clock, so "now" keeps the worker active
lastActivityAt: () => Date.now(),
inFlightCount: () => 0,
@@ -0,0 +1,69 @@
import {
pluginCancel,
pluginRequest,
type PluginEnvelope,
type PluginErrorCode
} from '../../../shared/plugins/frames/plugin-protocol'
import type { PluginWorkerRequestError } from './plugin-host-process-types'
export type PendingCall = {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
export function requestError(code: PluginErrorCode, message: string): PluginWorkerRequestError {
return Object.assign(new Error(message), { code })
}
export type PluginWorkerRequestChannel = {
pending: Map<number, PendingCall>
send: (message: PluginEnvelope) => void
nextId: () => number
isRunning: () => boolean
tag: string
}
/** One host → worker request; a timeout or abort also cancels the worker's handler. */
export function sendPluginWorkerRequest(
channel: PluginWorkerRequestChannel,
method: string,
params: unknown,
timeoutMs: number,
label: string,
signal?: AbortSignal
): Promise<unknown> {
const { pending, send, tag } = channel
if (!channel.isRunning()) {
return Promise.reject(requestError('unavailable', `${tag} worker is not running`))
}
if (signal?.aborted) {
return Promise.reject(requestError('cancelled', `${tag} ${label} was cancelled`))
}
const callId = channel.nextId()
return new Promise<unknown>((resolve, reject) => {
const stop = (error: PluginWorkerRequestError): void => {
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
if (pending.delete(callId)) {
// Tell the handler too, so it stops instead of running on unobserved.
send(pluginCancel(callId))
reject(error)
}
}
const onAbort = (): void => stop(requestError('cancelled', `${tag} ${label} was cancelled`))
const timer = setTimeout(
() => stop(requestError('timeout', `${tag} ${label} timed out after ${timeoutMs}ms`)),
timeoutMs
)
signal?.addEventListener('abort', onAbort, { once: true })
const settle =
<T>(next: (value: T) => void) =>
(value: T): void => {
signal?.removeEventListener('abort', onAbort)
next(value)
}
pending.set(callId, { resolve: settle(resolve), reject: settle(reject), timer })
send(pluginRequest(callId, method, params))
})
}
@@ -18,6 +18,8 @@ function worker(): PluginWorkerHandle & { exit: (code: number) => void } {
commands: [],
invokeCommand: vi.fn(async () => null),
deliverEvent: vi.fn(),
requestFromPanel: vi.fn(),
notifyDataChanged: vi.fn(),
// ast-grep-ignore: test-no-wall-clock -- idle checks compare against the real clock, so "now" keeps the worker active
lastActivityAt: () => Date.now(),
inFlightCount: () => 0,
@@ -1,4 +1,5 @@
import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/plugin-capabilities'
import type { PluginWorkerPanelPost } from '../../../shared/plugins/workers/plugin-panel-bridge-protocol'
import {
startPluginWorker,
type PluginWorkerHandle,
@@ -33,6 +34,7 @@ export type PluginWorkerFactory = (options: {
entryPath: string
grantedCapabilities: readonly PluginCapabilityKind[]
executeHostCall: PluginWorkerHostCallExecutor
onPanelPost?: (post: PluginWorkerPanelPost) => void
log: PluginWorkerLogSink
signal: AbortSignal
}) => Promise<PluginWorkerHandle>
@@ -53,6 +55,7 @@ export async function startPluginWorkerAttempt(options: {
entryPath: string
factory?: PluginWorkerFactory
executeHostCall: PluginWorkerHostCallExecutor
onPanelPost?: (post: PluginWorkerPanelPost) => void
log: PluginWorkerLogSink
assertActive: () => void
verifyBeforeSpawn?: () => Promise<void>
@@ -75,6 +78,7 @@ export async function startPluginWorkerAttempt(options: {
entryPath: options.entryPath,
grantedCapabilities: options.spec.grantedCapabilities,
executeHostCall: options.executeHostCall,
onPanelPost: options.onPanelPost,
log: options.log,
signal: options.signal
})
@@ -0,0 +1,94 @@
import type { z } from 'zod'
import { shortPluginBranchName } from '../../../shared/plugins/events/plugin-events'
import {
PLUGIN_TERMINAL_ID_MAX_LENGTH,
PLUGIN_WORKSPACE_LABEL_MAX_LENGTH,
PLUGIN_WORKSPACE_TERMINAL_LIMIT,
type pluginWorkspaceContextSchema
} from '../../../shared/plugins/workers/plugin-host-api'
import { PLUGIN_AGENT_LIST_LIMIT } from '../../../shared/plugins/workers/plugin-host-api-panel-methods'
import type { AgentStatusState } from '../../../shared/agent-status-types'
/** One terminal of a workspace, as main's runtime lists it. */
export type PluginWorkspaceTerminal = {
/** The terminal handle plugins call `terminalId`. */
id: string
title?: string
/** `tabId:leafId`, which agent statuses carry. */
paneKey?: string
}
/** One agent row from the hook server's status store (the fields plugins may see). */
export type PluginAgentStatusRow = {
paneKey: string
worktreeId?: string
terminalHandle?: string
state: AgentStatusState
agentType?: string
stateStartedAt: number
providerSessionOnly?: boolean
}
export type PluginWorkspaceContext = z.output<typeof pluginWorkspaceContextSchema>
type Context = { worktreeId: string; branch: string; displayName: string }
/** A pane's own name wins over the title its program sets. */
export function pluginTerminalTitle(terminal: {
title: string | null
paneTitle?: string
}): string | undefined {
const title = (terminal.paneTitle ?? terminal.title ?? '').trim()
return title ? title.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH) : undefined
}
/** Worktree ids embed provider paths: only these public fields reach plugins. */
export function projectPluginWorkspaceContext(
context: Context | null,
terminals: readonly PluginWorkspaceTerminal[]
): PluginWorkspaceContext {
if (!context) {
return null
}
return {
branch: shortPluginBranchName(context.branch).slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
displayName: context.displayName.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
terminals: terminals
.filter(({ id }) => id.length > 0 && id.length <= PLUGIN_TERMINAL_ID_MAX_LENGTH)
.slice(0, PLUGIN_WORKSPACE_TERMINAL_LIMIT)
.map(({ id, title }) => (title ? { id, title } : { id }))
}
}
/** The terminal an agent's pane is: by the handle its status names, else by pane key. */
export function terminalForPane(
terminals: readonly PluginWorkspaceTerminal[],
row: Pick<PluginAgentStatusRow, 'paneKey' | 'terminalHandle'>
): PluginWorkspaceTerminal | undefined {
return (
terminals.find((terminal) => terminal.id === row.terminalHandle) ??
terminals.find((terminal) => terminal.paneKey === row.paneKey)
)
}
/** `agents.list`: the focused workspace's agents, newest state first. */
export function projectPluginWorkspaceAgents(
worktreeId: string,
terminals: readonly PluginWorkspaceTerminal[],
rows: readonly PluginAgentStatusRow[]
) {
return rows
.filter((row) => row.worktreeId === worktreeId && row.providerSessionOnly !== true)
.toSorted((a, b) => b.stateStartedAt - a.stateStartedAt)
.slice(0, PLUGIN_AGENT_LIST_LIMIT)
.map((row) => {
const terminal = terminalForPane(terminals, row)
return {
terminalId: terminal?.id ?? null,
title: terminal?.title ?? null,
agent: row.agentType?.slice(0, 128) ?? null,
state: row.state,
stateStartedAt: row.stateStartedAt
}
})
}
+2 -1
View File
@@ -19,7 +19,8 @@ vi.mock('electron', () => {
appendSwitch: vi.fn(),
getSwitchValue: vi.fn(() => '')
}
}
},
shell: { openExternal: vi.fn() }
}
})
+2
View File
@@ -1,4 +1,5 @@
import { app } from 'electron'
import { guardExternalOpensInE2E } from './e2e-external-open-guard'
import { quitProcess } from './process-quit-request'
import { existsSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
@@ -198,6 +199,7 @@ export function configureDevUserDataPath(isDev: boolean): void {
mkdirSync(e2eHomeDir, { recursive: true, mode: 0o700 })
app.setPath('home', e2eHomeDir)
app.setPath('userData', e2eConfig.userDataDir)
guardExternalOpensInE2E()
return
}
@@ -0,0 +1,15 @@
import { expect, it, vi } from 'vitest'
const openExternal = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({ shell: { openExternal } }))
const { shell } = await import('electron')
const { guardExternalOpensInE2E } = await import('./e2e-external-open-guard')
it("never opens the tester's browser or mail client in an e2e launch", async () => {
vi.spyOn(console, 'info').mockImplementation(() => undefined)
guardExternalOpensInE2E()
await shell.openExternal('mailto:a@example.com')
await shell.openExternal('https://example.com')
expect(openExternal).not.toHaveBeenCalled()
})
@@ -0,0 +1,11 @@
import { shell } from 'electron'
/**
* An e2e launch must never open the tester's browser or mail client: `shell.openExternal` only
* logs there. Specs that observe opens replace it themselves, and restore this guard after.
*/
export function guardExternalOpensInE2E(): void {
shell.openExternal = async (url: string): Promise<void> => {
console.info(`[e2e] shell.openExternal suppressed: ${url.split(':', 1)[0]}:`)
}
}
@@ -41,13 +41,14 @@ afterEach(async () => {
it('binds the runtime and exactly one settings→refresh listener without any window', async () => {
fixture.userData = await mkdtemp(join(tmpdir(), 'orca-main-plugins-'))
const settingsListeners: ((updates: Record<string, unknown>) => void)[] = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: plugin startup reads settings and subscribes to changes only.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: plugin startup reads settings and subscribes to settings changes and session writes only.
state.store = {
getSettings: () => ({ pluginSystemEnabled: false }),
onSettingsChanged: (listener: (updates: Record<string, unknown>) => void) => {
settingsListeners.push(listener)
return () => {}
}
},
onWorkspaceSessionWritten: () => () => {}
} as never
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: plugin startup reads only keybinding overrides.
state.keybindings = { getOverrides: () => ({}) } as never
@@ -60,7 +61,10 @@ it('binds the runtime and exactly one settings→refresh listener without any wi
await state.pluginService?.whenReady()
refresh.mockClear()
expect(setRuntimeDelegate).toHaveBeenCalledWith(runtime)
// The runtime, plus the hook server's agent rows for agents.list.
expect(setRuntimeDelegate).toHaveBeenCalledWith(
expect.objectContaining({ listAgentStatuses: expect.any(Function) })
)
for (const listener of settingsListeners) {
listener({ devPluginPaths: [] })
}
+14 -2
View File
@@ -37,6 +37,11 @@ import { rebuildAppMenu } from '../menu/register-app-menu'
import { rebuildSystemTrayMenu } from '../tray/system-tray'
import { logStartupMilestone } from './startup-diagnostics'
import { agentHookServer } from '../agent-hooks/server'
import {
createPluginAgentStatusForwarder,
pluginRuntimeDelegate,
watchPluginWorkspaceContext
} from '../plugins/plugin-runtime-binding'
import { emitPluginWorktreeLifecycle } from './main-process-pty-startup'
import { mainProcessState as state } from './main-process-state'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
@@ -229,7 +234,8 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService):
}
})
// Bound here, not in the IPC handlers: a headless serve never opens a window to register them.
pluginService.setRuntimeDelegate(runtime)
const pluginDelegate = pluginRuntimeDelegate(runtime, () => agentHookServer.getStatusSnapshot())
pluginService.setRuntimeDelegate(pluginDelegate)
// Headless RPC resolves this service too; consent shares the desktop hash-keyed write path.
setPluginServiceForRpc(state.pluginService, {
applyConsent: (request) =>
@@ -290,11 +296,17 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService):
}
}, PLUGIN_MARKETPLACE_SEED_STARTUP_DELAY_MS).unref?.()
// Filter hook/worktree events server-side by each plugin subscription.
const forwardAgentStatus = createPluginAgentStatusForwarder(pluginDelegate, pluginService)
agentHookServer.subscribeEnrichedStatus((enriched) => {
const payload = projectPluginAgentStatusChangedPayload(enriched)
if (payload) {
state.pluginService?.emitEvent('agent.status.changed', payload)
forwardAgentStatus(payload, enriched.terminalHandle)
}
})
watchPluginWorkspaceContext({
delegate: pluginDelegate,
events: pluginService,
onSessionWritten: (listener) => store.onWorkspaceSessionWritten(listener)
})
runtime.onWorktreeLifecycle(emitPluginWorktreeLifecycle)
}
+12
View File
@@ -42,6 +42,8 @@ export type {
PluginHostStatus
} from '../../shared/plugins/plugin-list-entry'
import type { PluginHostListEntry } from '../../shared/plugins/plugin-list-entry'
import type { PluginDeclaredSettingsSnapshot } from '../../shared/plugins/contributions/plugin-settings-values'
import type { PluginJsonValue } from '../../shared/plugins/workers/plugin-host-api'
export type PluginHostLogLine = {
ts: number
@@ -181,6 +183,16 @@ export type PluginsApi = {
}) => Promise<PluginHostCallOutcome>
/** A components panel's persisted atoms (desktop only); see plugin-panel-state.ts. */
panelState?: (args: PluginPanelStateRequest) => Promise<PluginPanelStateResult>
/** One open panel's pushes (`PluginPanelPush`, unvalidated); desktop only. Returns unsubscribe. */
subscribePanelPush?: (sessionToken: string, listener: (push: unknown) => void) => () => void
/** The settings a plugin declares and their current values; null when it declares none. */
getSettings?: (args: { pluginKey: string }) => Promise<PluginDeclaredSettingsSnapshot | null>
/** The person's edit; `null` restores the default. */
setSetting?: (args: {
pluginKey: string
key: string
value: PluginJsonValue
}) => Promise<{ ok: true } | { ok: false; error: string }>
/** Pins a Git install input to an exact commit without downloading it. */
resolveGitSource: (args: { url: string; ref: string }) => Promise<PluginGitSourceResolution>
install: (source: PluginHostInstallSource) => Promise<PluginHostInstallResult>
@@ -0,0 +1,48 @@
import { ipcRenderer } from 'electron'
type PanelPushListener = (push: unknown) => void
const PANEL_PUSH_CHANNEL = 'plugins:panelPush'
const listenersByToken = new Map<string, Set<PanelPushListener>>()
let routing = false
function route(): void {
if (routing) {
return
}
routing = true
ipcRenderer.on(PANEL_PUSH_CHANNEL, (_event, message: unknown) => {
const token =
typeof message === 'object' && message !== null && 'sessionToken' in message
? message.sessionToken
: null
const push =
typeof message === 'object' && message !== null && 'push' in message ? message.push : null
for (const listener of typeof token === 'string' ? (listenersByToken.get(token) ?? []) : []) {
listener(push)
}
})
}
/** Frames of one session share its channel; it opens with the first and closes with the last. */
export function subscribePluginPanelPush(
sessionToken: string,
listener: PanelPushListener
): () => void {
route()
let listeners = listenersByToken.get(sessionToken)
if (!listeners) {
listeners = new Set()
listenersByToken.set(sessionToken, listeners)
void ipcRenderer.invoke('plugins:openPanelPush', { sessionToken }).catch(() => undefined)
}
listeners.add(listener)
return () => {
const current = listenersByToken.get(sessionToken)
if (!current?.delete(listener) || current.size > 0) {
return
}
listenersByToken.delete(sessionToken)
void ipcRenderer.invoke('plugins:closePanelPush', { sessionToken }).catch(() => undefined)
}
}
+4
View File
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import { subscribePluginPanelPush } from './plugin-panel-push-bridge'
import type {
PluginFrameEntry,
PluginFrameTheme
@@ -48,6 +49,9 @@ export const pluginsApi = {
params?: unknown
}): Promise<PluginHostCallOutcome> => ipcRenderer.invoke('plugins:panelAction', args),
panelState: (args) => ipcRenderer.invoke('plugins:panelState', args),
subscribePanelPush: subscribePluginPanelPush,
getSettings: (args) => ipcRenderer.invoke('plugins:getSettings', args),
setSetting: (args) => ipcRenderer.invoke('plugins:setSetting', args),
resolveGitSource: (args) => ipcRenderer.invoke('plugins:resolveGitSource', args),
install: (source: PluginHostInstallSource): Promise<PluginHostInstallResult> =>
ipcRenderer.invoke('plugins:install', source),
@@ -107,7 +107,8 @@ const preview: PluginMarketplaceHostInstallPreview = {
iconThemes: [],
syntaxes: [],
customEditors: [],
newFiles: []
newFiles: [],
settings: []
},
capabilities: [{ kind: 'workspace:read' }]
}
@@ -29,6 +29,11 @@ function eventTitle(event: PluginEventName): string {
'auto.components.settings.PluginContributionActionEvents.agentStatusChanged',
'When an agent’s status changes'
)
case 'workspace.changed':
return translate(
'auto.components.settings.PluginContributionActionEvents.workspaceChanged',
'When you switch workspace or its terminals change'
)
}
}
@@ -13,6 +13,7 @@ import type { usePluginInstallAction } from '../state/use-plugin-install-action'
import { PluginDetailInfo, PluginReleaseNotes } from './PluginDetailInfo'
import { PluginAddsSection } from './PluginAddsSection'
import { PluginAccessSection, PluginLogsSection } from './PluginDetailSections'
import { PluginSettingsSection } from './PluginSettingsSection'
import { installedPluginControls, type InstalledRowHandlers } from '../list/PluginManagerRows'
import { PluginChips } from '../list/PluginChips'
import type { PluginPagePreview } from '../state/use-plugin-catalog'
@@ -297,6 +298,12 @@ export function PluginDetailPage({
{plugin && controls?.kind !== 'review' ? (
<PluginAccessSection capabilities={plugin.capabilities} trust={model.trust} />
) : null}
{plugin ? (
<PluginSettingsSection
pluginKey={plugin.pluginKey}
revision={`${plugin.version}:${plugin.consentFingerprint ?? ''}`}
/>
) : null}
<PluginReleaseNotes entry={entry} />
{plugin?.hasWorker ? <PluginLogsSection state={logsState} /> : null}
</div>
@@ -0,0 +1,55 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { PluginSettingsSection } from './PluginSettingsSection'
const values: Record<string, unknown> = { verbose: false }
const setSetting = vi.fn(async ({ key, value }: { key: string; value: unknown }) => {
values[key] = value
return { ok: true as const }
})
beforeEach(() => {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
plugins: {
getSettings: vi.fn(async () => ({
settings: [
{ key: 'verbose', title: 'Verbose logs', type: 'boolean', default: false },
{ key: 'apiUrl', title: 'Server URL', type: 'string', description: 'Where todos sync' }
],
values: { ...values }
})),
setSetting
}
}
})
})
afterEach(() => {
cleanup()
setSetting.mockClear()
})
it('shows declared settings and writes what the person changes', async () => {
render(<PluginSettingsSection pluginKey="acme.todos" revision="1" />)
expect(await screen.findByText('Verbose logs')).toBeTruthy()
expect(screen.getByText('Where todos sync')).toBeTruthy()
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(setSetting).toHaveBeenCalledWith({ pluginKey: 'acme.todos', key: 'verbose', value: true })
const url = screen.getByLabelText('Server URL')
fireEvent.change(url, { target: { value: 'https://todo.example' } })
// Typing alone writes nothing; leaving the field does.
expect(setSetting).toHaveBeenCalledTimes(1)
await act(async () => {
fireEvent.blur(url)
})
expect(setSetting).toHaveBeenLastCalledWith({
pluginKey: 'acme.todos',
key: 'apiUrl',
value: 'https://todo.example'
})
})
@@ -0,0 +1,170 @@
import { useId, useState } from 'react'
import { translate } from '@/i18n/i18n'
import { Button } from '../../ui/button'
import { Input } from '../../ui/input'
import { Label } from '../../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'
import { Switch } from '../../ui/switch'
import { Textarea } from '../../ui/textarea'
import type { PluginSettingContribution } from '../../../../../shared/plugins/contributions/plugin-settings-contribution'
import type { PluginJsonValue } from '../../../../../shared/plugins/workers/plugin-host-api'
import { DetailSection } from './PluginDetailSections'
import { usePluginDeclaredSettings } from '../state/use-plugin-declared-settings'
type SettingControlProps = {
id: string
setting: PluginSettingContribution
value: PluginJsonValue | undefined
onCommit: (value: PluginJsonValue) => void
}
/** Text and numbers commit on blur or Enter, so a half-typed value is never written. */
function TextSetting({ id, setting, value, onCommit }: SettingControlProps): React.JSX.Element {
const shown = value === undefined || value === null ? '' : String(value)
const [draft, setDraft] = useState<string | null>(null)
const numeric = setting.type === 'number' || setting.type === 'integer'
const commit = (): void => {
if (draft === null || draft === shown) {
setDraft(null)
return
}
// An emptied field goes back to the default.
onCommit(draft === '' ? null : numeric ? Number(draft) : draft)
setDraft(null)
}
const shared = {
id,
value: draft ?? shown,
placeholder: setting.placeholder,
onChange: (event: { target: { value: string } }) => setDraft(event.target.value),
onBlur: commit
}
return setting.multiline ? (
<Textarea {...shared} rows={3} />
) : (
<Input
{...shared}
type={numeric ? 'number' : 'text'}
min={setting.minimum}
max={setting.maximum}
step={setting.type === 'integer' ? 1 : undefined}
maxLength={setting.maxLength}
density="compact"
className="w-56"
onKeyDown={(event) => {
if (event.key === 'Enter') {
commit()
}
}}
/>
)
}
function SettingControl(props: SettingControlProps): React.JSX.Element {
const { id, setting, value, onCommit } = props
if (setting.type === 'boolean') {
return (
<Switch id={id} checked={value === true} onCheckedChange={(checked) => onCommit(checked)} />
)
}
if (setting.enum) {
const options = setting.enum.map((option, index) => ({
value: String(option),
raw: option,
label: setting.enumLabels?.[index] ?? String(option)
}))
return (
<Select
value={value === undefined || value === null ? undefined : String(value)}
onValueChange={(next) => {
const option = options.find((entry) => entry.value === next)
if (option) {
onCommit(option.raw)
}
}}
>
<SelectTrigger id={id} size="sm" className="w-56">
<SelectValue placeholder={setting.placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
return <TextSetting {...props} />
}
function SettingRow({
setting,
value,
error,
onWrite
}: {
setting: PluginSettingContribution
value: PluginJsonValue | undefined
error: string | undefined
onWrite: (value: PluginJsonValue) => void
}): React.JSX.Element {
const id = useId()
const changed = value !== undefined && value !== (setting.default ?? undefined)
const stacked = setting.multiline === true
return (
<div className="border-t border-border px-3.5 py-2.5 text-sm first:border-t-0">
<div
data-stacked={stacked}
className="flex items-center gap-3 data-[stacked=true]:flex-col data-[stacked=true]:items-stretch"
>
<div className="min-w-0 flex-1">
<Label htmlFor={id}>{setting.title}</Label>
{setting.description ? (
<p className="mt-0.5 text-xs text-muted-foreground">{setting.description}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<SettingControl id={id} setting={setting} value={value} onCommit={onWrite} />
{changed && setting.default !== undefined ? (
<Button variant="ghost" size="xs" onClick={() => onWrite(null)}>
{translate('auto.components.settings.PluginSettingsSection.reset', 'Reset')}
</Button>
) : null}
</div>
</div>
{error ? <p className="mt-1 text-xs text-destructive">{error}</p> : null}
</div>
)
}
/** `contributes.settings`, edited here by the person; the plugin reads them with `settings.get`. */
export function PluginSettingsSection({
pluginKey,
revision
}: {
pluginKey: string
/** Re-reads when the installed plugin changes. */
revision: string
}): React.JSX.Element | null {
const { snapshot, errors, write } = usePluginDeclaredSettings(pluginKey, revision)
if (!snapshot || snapshot.settings.length === 0) {
return null
}
return (
<DetailSection
title={translate('auto.components.settings.PluginSettingsSection.title', 'Settings')}
>
{snapshot.settings.map((setting) => (
<SettingRow
key={setting.key}
setting={setting}
value={snapshot.values[setting.key]}
error={errors[setting.key]}
onWrite={(value) => write(setting.key, value)}
/>
))}
</DetailSection>
)
}
@@ -10,10 +10,11 @@ import { getIntlLocale, translate } from '@/i18n/i18n'
import { customEditorTakeover } from './plugin-custom-editor-takeover'
import { fileList } from './plugin-contribution-file-list'
/** The manifest points shown as named rows; commands, shortcuts and events have their own lists. */
/** The manifest points shown as named rows; commands, shortcuts, events and settings have their
* own lists. */
export type PluginContributionKind = Exclude<
PluginContributionPoint,
'commands' | 'keybindings' | 'events'
'commands' | 'keybindings' | 'events' | 'settings'
>
/** One named thing a plugin adds, ready to show as a row. */
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useState } from 'react'
import type { PluginDeclaredSettingsSnapshot } from '../../../../../shared/plugins/contributions/plugin-settings-values'
import type { PluginJsonValue } from '../../../../../shared/plugins/workers/plugin-host-api'
export type PluginDeclaredSettingsState = {
snapshot: PluginDeclaredSettingsSnapshot | null
/** Why the last edit of each key was refused, until it next succeeds. */
errors: Readonly<Record<string, string>>
write: (key: string, value: PluginJsonValue) => void
}
/** A plugin's `contributes.settings` and their values, re-read when `revision` changes. */
export function usePluginDeclaredSettings(
pluginKey: string,
revision: string
): PluginDeclaredSettingsState {
const [snapshot, setSnapshot] = useState<PluginDeclaredSettingsSnapshot | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const load = useCallback(async (): Promise<PluginDeclaredSettingsSnapshot | null> => {
const read = window.api?.plugins?.getSettings
return read ? read({ pluginKey }).catch(() => null) : null
}, [pluginKey])
useEffect(() => {
let current = true
void load().then((next) => current && setSnapshot(next))
return () => {
current = false
}
}, [load, revision])
const write = useCallback(
(key: string, value: PluginJsonValue): void => {
const setSetting = window.api?.plugins?.setSetting
if (!setSetting) {
return
}
void setSetting({ pluginKey, key, value })
.then(async (result) => {
setErrors(({ [key]: _previous, ...rest }) =>
result.ok ? rest : { ...rest, [key]: result.error }
)
if (result.ok) {
setSnapshot(await load())
}
})
.catch((error: unknown) => setErrors((previous) => ({ ...previous, [key]: String(error) })))
},
[load, pluginKey]
)
return { snapshot, errors, write }
}
@@ -98,13 +98,16 @@ describe('createPluginFrameMessageHandler', () => {
payload: {
protocol: 1,
surface: 'editor',
// An iframe is offered no 1.1 methods, so it reports 1.0.
api: { major: 1, minor: 0 },
// An iframe is offered no user-gesture methods, but 1.2 gave every frame storage.
api: { major: 1, minor: 2 },
theme: { colorScheme: 'dark', tokens: '--orca-background:#000' }
}
})
expect(hello.payload.methods).toContain('notifications.show')
expect(hello.payload.methods).not.toContain('storage.get')
expect(hello.payload.methods).toContain('storage.get')
expect(hello.payload.methods).toContain('plugin.request')
expect(hello.payload.methods).not.toContain('terminal.sendText')
expect(hello.payload.methods).not.toContain('files.saveAs')
expect(onReady).toHaveBeenCalledOnce()
})
@@ -5,6 +5,7 @@ import {
PLUGIN_FRAME_KEY_CHANNEL
} from '../../../../shared/plugins/frames/plugin-frame-guest'
import { pluginEvent } from '../../../../shared/plugins/frames/plugin-protocol'
import { pluginPanelPushSchema } from '../../../../shared/plugins/workers/plugin-panel-bridge-protocol'
import {
createPluginFrameEnvelopeHandler,
createPluginFrameMessageHandler,
@@ -130,6 +131,20 @@ export function usePluginFrameHost(options: {
watchdog
])
// Worker posts, subscribed events and data changes for this panel, while its document lives.
useEffect(() => {
const subscribe = window.api?.plugins?.subscribePanelPush
if (!frameKey || !sessionToken || !subscribe) {
return
}
return subscribe(sessionToken, (raw) => {
const push = pluginPanelPushSchema.safeParse(raw)
if (push.success) {
getFrameWindow()?.postMessage(pluginEvent(FRAME_EVENTS.push, push.data), '*')
}
})
}, [frameKey, getFrameWindow, sessionToken])
useEffect(() => {
if (!frameKey || loadedFrameKey !== frameKey) {
return
+6 -1
View File
@@ -13400,7 +13400,8 @@
"worktreeCreated": "When a workspace is created",
"worktreeRemoved": "When a workspace is removed",
"agentStatusChanged": "When an agent’s status changes",
"detail": "Background code runs"
"detail": "Background code runs",
"workspaceChanged": "When you switch workspace or its terminals change"
},
"pluginInstallAction": {
"installed": "{{value0}} installed",
@@ -13532,6 +13533,10 @@
"smoothingKw": "smoothing",
"antialiasingKw": "antialiasing",
"thinKw": "thin text"
},
"PluginSettingsSection": {
"reset": "Reset",
"title": "Settings"
}
},
"right": {

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