Files
orca/src/main/plugins/workers/plugin-worker-orca-api.ts
T
Neil ec0d3cedd1 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.
2026-11-01 04:40:17 -08:00

205 lines
7.7 KiB
TypeScript

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))
})
}