mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
* 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.
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
import {
|
|
panelActionCallSchema,
|
|
type PluginFrameEntry
|
|
} from '../../../shared/plugins/frames/plugin-frame-bridge'
|
|
import type {
|
|
PluginHostCallOutcome,
|
|
PluginSurface
|
|
} from '../../../shared/plugins/frames/plugin-protocol'
|
|
import {
|
|
admitPluginPanelCall,
|
|
createPluginPanelCallAdmission,
|
|
type PluginPanelCallAdmission
|
|
} from '../../../shared/plugins/frames/plugin-panel-call-admission'
|
|
import {
|
|
buildPluginPanelShellHtml,
|
|
type PluginFrameSurface
|
|
} from '../../../shared/plugins/frames/plugin-panel-shell'
|
|
import type { ValidDiscoveredPlugin } from '../plugin-discovery'
|
|
import type { PluginHostCaller } from '../plugin-service-host-call'
|
|
import { getPluginHostMethodSpec } from '../../../shared/plugins/workers/plugin-host-api'
|
|
import type { PluginContentVerifier } from '../plugin-content-integrity'
|
|
import {
|
|
PLUGIN_PANEL_ENTRY_MAX_BYTES,
|
|
readContainedPluginArtifactText
|
|
} from '../plugin-artifact-validation'
|
|
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
|
|
contentVerifier: Pick<PluginContentVerifier, 'verify'>
|
|
executeHostCall: (
|
|
pluginKey: string,
|
|
method: string,
|
|
params: unknown,
|
|
surface: PluginSurface,
|
|
caller?: PluginHostCaller
|
|
) => 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 }
|
|
|
|
type LoadedPluginPanel = {
|
|
entry: { html: string; ui?: 'components' }
|
|
binding: PluginPanelSessionBinding
|
|
}
|
|
|
|
export class PluginPanelController {
|
|
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> {
|
|
return (await this.load(pluginKey, 'panel', panelId))?.entry ?? null
|
|
}
|
|
|
|
/** `shell: false` (desktop guests): the guest loads its own document, so no srcdoc HTML is
|
|
* read, verified or sent; the protocol verifies the tree when the guest navigates. */
|
|
open(
|
|
ownerKey: string,
|
|
pluginKey: string,
|
|
panelId: string,
|
|
isCurrent?: () => boolean,
|
|
options: FrameOpenOptions = {}
|
|
): Promise<PluginFrameEntry | null> {
|
|
return this.openFrame(ownerKey, pluginKey, 'panel', panelId, isCurrent, options)
|
|
}
|
|
|
|
openCustomEditor(
|
|
ownerKey: string,
|
|
pluginKey: string,
|
|
editorId: string,
|
|
isCurrent?: () => boolean,
|
|
options: FrameOpenOptions = {}
|
|
): Promise<PluginFrameEntry | null> {
|
|
return this.openFrame(ownerKey, pluginKey, 'editor', editorId, isCurrent, options)
|
|
}
|
|
|
|
/** The plugin behind this owner's live components-panel session, for its persisted state. */
|
|
componentsPanelPlugin(ownerKey: string, sessionToken: string): string | null {
|
|
const binding = this.sessions.resolve(ownerKey, sessionToken)
|
|
if (!binding || binding.surface !== 'panel') {
|
|
return null
|
|
}
|
|
const plugin = this.options.resolveApprovedPlugin(binding.pluginKey)
|
|
const panel = plugin ? findFrameContribution(plugin, 'panel', binding.contributionId) : null
|
|
return plugin && plugin.rootDir === binding.rootDir && panel?.ui === 'components'
|
|
? binding.pluginKey
|
|
: null
|
|
}
|
|
|
|
revokeSession(ownerKey: string, sessionToken: string): void {
|
|
this.sessions.revoke(ownerKey, sessionToken)
|
|
}
|
|
|
|
/**
|
|
* `desktop`: the call came from this machine's own window over IPC, never from a paired client;
|
|
* `callerWebContentsId` is that window, so its dialogs and budgets bind there.
|
|
*/
|
|
async execute(
|
|
ownerKey: string,
|
|
call: unknown,
|
|
origin: { desktop?: boolean; callerWebContentsId?: number } = {}
|
|
): Promise<PluginHostCallOutcome> {
|
|
const sessionToken = this.extractSessionToken(call)
|
|
if (!sessionToken) {
|
|
return { ok: false, code: 'invalid_request', error: 'invalid panel session' }
|
|
}
|
|
const binding = this.sessions.resolve(ownerKey, sessionToken)
|
|
if (!binding) {
|
|
return { ok: false, code: 'invalid_request', error: 'invalid panel session' }
|
|
}
|
|
// Why: a dialog or browser tab on this machine must never answer a remote client's click.
|
|
// Their own lane: a save carries a whole file, far past the 64 KiB message budget.
|
|
const userMethod = getPluginHostMethodSpec(this.readAction(call))?.userActivation === true
|
|
if (userMethod && !origin.desktop) {
|
|
return {
|
|
ok: false,
|
|
code: 'not_permitted',
|
|
error: 'this method is only available in the Orca desktop app'
|
|
}
|
|
}
|
|
const admissionRefusal = userMethod
|
|
? null
|
|
: admitPluginPanelCall(this.panelAdmission, binding.pluginKey, call)
|
|
if (admissionRefusal) {
|
|
return admissionRefusal
|
|
}
|
|
const parsed = panelActionCallSchema.safeParse(call)
|
|
if (!parsed.success) {
|
|
return { ok: false, code: 'invalid_request', error: 'malformed panel action call' }
|
|
}
|
|
if (!this.isCurrent(binding)) {
|
|
return { ok: false, code: 'unavailable', error: 'panel session is no longer available' }
|
|
}
|
|
return this.options.executeHostCall(
|
|
binding.pluginKey,
|
|
parsed.data.action,
|
|
parsed.data.params,
|
|
binding.surface,
|
|
{
|
|
...(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
|
|
)
|
|
}
|
|
|
|
revokeOwner(ownerKey: string): void {
|
|
this.sessions.revokeOwner(ownerKey)
|
|
}
|
|
|
|
bindOwnerSignal(ownerKey: string, signal: AbortSignal | undefined): void {
|
|
if (!signal || this.boundOwnerSignals.has(signal)) {
|
|
return
|
|
}
|
|
this.boundOwnerSignals.add(signal)
|
|
if (signal.aborted) {
|
|
this.revokeOwner(ownerKey)
|
|
return
|
|
}
|
|
signal.addEventListener('abort', () => this.revokeOwner(ownerKey), { once: true })
|
|
}
|
|
|
|
revokeAll(): void {
|
|
this.sessions.clear()
|
|
this.panelAdmission.clear()
|
|
}
|
|
|
|
dispose(): void {
|
|
this.revokeAll()
|
|
}
|
|
|
|
private readAction(call: unknown): string {
|
|
try {
|
|
const action =
|
|
typeof call === 'object' && call !== null && 'action' in call ? call.action : null
|
|
return typeof action === 'string' ? action : ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
private extractSessionToken(call: unknown): string | null {
|
|
if (typeof call !== 'object' || call === null) {
|
|
return null
|
|
}
|
|
try {
|
|
const token = (call as { sessionToken?: unknown }).sessionToken
|
|
return typeof token === 'string' && token.length >= 32 && token.length <= 128 ? token : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
private async openFrame(
|
|
ownerKey: string,
|
|
pluginKey: string,
|
|
surface: PluginPanelSessionBinding['surface'],
|
|
contributionId: string,
|
|
isCurrent: (() => boolean) | undefined,
|
|
options: FrameOpenOptions
|
|
): Promise<PluginFrameEntry | null> {
|
|
if (isCurrent?.() === false) {
|
|
return null
|
|
}
|
|
const loaded = await this.load(pluginKey, surface, contributionId, options.shell !== false)
|
|
if (!loaded || isCurrent?.() === false) {
|
|
return null
|
|
}
|
|
return { ...loaded.entry, sessionToken: this.sessions.issue(ownerKey, loaded.binding) }
|
|
}
|
|
|
|
private async load(
|
|
pluginKey: string,
|
|
surface: PluginPanelSessionBinding['surface'],
|
|
contributionId: string,
|
|
withShell = true
|
|
): Promise<LoadedPluginPanel | null> {
|
|
const plugin = this.options.resolveApprovedPlugin(pluginKey)
|
|
const contribution = plugin ? findFrameContribution(plugin, surface, contributionId) : null
|
|
if (!plugin || !contribution) {
|
|
return null
|
|
}
|
|
const declarative = contribution.ui === 'components'
|
|
// Why no entry text: a declarative entry is a module the desktop guest imports; browser
|
|
// clients get an empty shell and show that the panel needs the desktop app.
|
|
const html = withShell
|
|
? await this.readShell(plugin, declarative ? null : contribution.entry, surface)
|
|
: ''
|
|
if (html === null) {
|
|
return null
|
|
}
|
|
return {
|
|
entry: declarative ? { html, ui: 'components' } : { html },
|
|
binding: {
|
|
pluginKey,
|
|
surface,
|
|
contributionId,
|
|
rootDir: plugin.rootDir,
|
|
manifestRevision: JSON.stringify(plugin.manifest)
|
|
}
|
|
}
|
|
}
|
|
|
|
private async readShell(
|
|
plugin: ValidDiscoveredPlugin,
|
|
entryPath: string | null,
|
|
surface: PluginFrameSurface
|
|
): Promise<string | null> {
|
|
const { pluginKey } = plugin
|
|
const log = this.options.log(pluginKey)
|
|
try {
|
|
await this.options.contentVerifier.verify(plugin)
|
|
const html = buildPluginPanelShellHtml(
|
|
entryPath === null
|
|
? ''
|
|
: await readContainedPluginArtifactText(
|
|
plugin.rootDir,
|
|
entryPath,
|
|
PLUGIN_PANEL_ENTRY_MAX_BYTES
|
|
),
|
|
surface
|
|
)
|
|
const current = this.options.resolveApprovedPlugin(pluginKey)
|
|
if (current !== plugin || current.rootDir !== plugin.rootDir) {
|
|
return null
|
|
}
|
|
return html
|
|
} catch (error) {
|
|
log(
|
|
`${surface} entry ${entryPath} rejected: ${error instanceof Error ? error.message : String(error)}`
|
|
)
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
function panelCaller(binding: ResolvedPluginPanelSession): PluginPanelCaller {
|
|
return { id: binding.publicId, surface: binding.surface, contributionId: binding.contributionId }
|
|
}
|
|
|
|
function findFrameContribution(
|
|
plugin: ValidDiscoveredPlugin,
|
|
surface: PluginPanelSessionBinding['surface'],
|
|
id: string
|
|
): { entry: string; ui?: string } | null {
|
|
const list =
|
|
surface === 'panel'
|
|
? plugin.manifest.contributes.panels
|
|
: plugin.manifest.contributes.customEditors
|
|
return list.find((entry) => entry.id === id) ?? null
|
|
}
|