feat(plugins): workers can open files and run built-in actions (#37323)

* feat(plugins): add editor.open and commands.run for full-access workers

Both ride the existing plugin view request channel (the one plugin open and
plugin shot use) to the one main window's renderer; headless serve and a
missing window answer unavailable. editor.open takes a path that stays
inside the workspace (Git worktrees and folders) and opens it as a tab
without switching what the person sees. commands.run takes only the built-in
contributes.commands action list and dispatches Orca's own handler, never a
plugin replacement. Both need workspace:write.

workspace.list now also lists project-group folder workspaces.

* fix(plugins): open folder workspaces and host-naming ids through Orca's window

workspace.open of a project-group folder asks the window to show it
(setActiveFolderWorkspace), since the runtime activates Git worktrees only.
editor.open resolves the identity: ids workspace.list now returns. commands.run
documents that it must call the raw built-in, never a replacement wrapper.

* plugins: regenerate the testing bundle and skill guides on the rebased host actions

Generated files only (lint-staged's oxlint refuses a commit with no lintable files).

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(plugins): regenerate the testing bundle on main

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-11-02 05:47:35 +00:00
committed by GitHub
co-authored by Claude
parent 544577fe02
commit 2fb3a0975e
24 changed files with 576 additions and 41 deletions
+20 -2
View File
@@ -32,10 +32,12 @@ Methods whose capability is `none` need no capability but still need the plugin
| [`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. |
| [`statusBar.update`](#statusbarupdate) | none | worker | no | 1.2 | Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled. |
| [`workspace.list`](#workspacelist) | `workspace:read` | worker | no | 1.3 | Lists every workspace, Git worktrees and folders, on every host Orca reaches. |
| [`workspace.open`](#workspaceopen) | `workspace:write` | worker | yes | 1.3 | Shows a workspace in Orca, as clicking it in the sidebar does. |
| [`workspace.open`](#workspaceopen) | `workspace:write` | worker | yes | 1.3 | Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app. |
| [`terminal.create`](#terminalcreate) | `terminal:send` | worker | yes | 1.3 | Opens a terminal in a workspace, optionally running a command, in the background. |
| [`terminal.read`](#terminalread) | `terminal:read` | worker | no | 1.3 | Reads a terminal's newest output as plain text, at most 2000 lines. |
| [`agent.launch`](#agentlaunch) | `agent:launch` | worker | yes | 1.3 | Starts an agent's CLI in a new terminal of a workspace, in the background, and returns as soon as it starts; a `prompt` is sent once the agent is ready. |
| [`editor.open`](#editoropen) | `workspace:write` | worker | yes | 1.3 | Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace. |
| [`commands.run`](#commandsrun) | `workspace:write` | worker | yes | 1.3 | Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only. |
### `workspace.readContext`
@@ -199,7 +201,7 @@ Lists every workspace, Git worktrees and folders, on every host Orca reaches.
### `workspace.open`
Shows a workspace in Orca, as clicking it in the sidebar does.
Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app.
```ts
(params: { workspaceId: string }) => { opened: true }
@@ -229,6 +231,22 @@ Starts an agent's CLI in a new terminal of a workspace, in the background, and r
(params: { workspaceId: string; agent: string; prompt?: string }) => { terminalId: string }
```
### `editor.open`
Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace.
```ts
(params: { workspaceId: string; path: string }) => { opened: true }
```
### `commands.run`
Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only.
```ts
(params: { action: "worktree.history.back" | "worktree.history.forward" | "sidebar.left.toggle" | "sidebar.sleepingWorkspaces.toggle" | "floatingWorkspace.maximize" | "tab.rename" | "workspace.rename" | "workspace.openBoard" | "view.tasks" | "sidebar.right.toggle" | "sidebar.explorer.toggle" | "sidebar.search.toggle" | "sidebar.sourceControl.toggle" | "sidebar.checks.toggle" | "sidebar.ports.toggle" }) => { ran: true }
```
## 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`.
File diff suppressed because one or more lines are too long
+13 -1
View File
@@ -154,7 +154,7 @@ declare module 'orca/host' {
result: { workspaces: { id: string; kind: "worktree" | "folder"; name: string; path: string; branch: string | null; hostId: string | null }[]; truncated: boolean }
surfaces: "worker"
}
/** Shows a workspace in Orca, as clicking it in the sidebar does. Since host API 1.3. */
/** Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app. Since host API 1.3. */
"workspace.open": {
params: { workspaceId: string }
result: { opened: true }
@@ -178,6 +178,18 @@ declare module 'orca/host' {
result: { terminalId: string }
surfaces: "worker"
}
/** Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace. Since host API 1.3. */
"editor.open": {
params: { workspaceId: string; path: string }
result: { opened: true }
surfaces: "worker"
}
/** Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only. Since host API 1.3. */
"commands.run": {
params: { action: "worktree.history.back" | "worktree.history.forward" | "sidebar.left.toggle" | "sidebar.sleepingWorkspaces.toggle" | "floatingWorkspace.maximize" | "tab.rename" | "workspace.rename" | "workspace.openBoard" | "view.tasks" | "sidebar.right.toggle" | "sidebar.explorer.toggle" | "sidebar.search.toggle" | "sidebar.sourceControl.toggle" | "sidebar.checks.toggle" | "sidebar.ports.toggle" }
result: { ran: true }
surfaces: "worker"
}
}
export type HostMethodName = keyof HostMethods
/** Methods a surface may call; any other gets `not_permitted`. */
@@ -6,7 +6,7 @@ Host API 1.3 (`"pluginApi": 1`). Call `host.call(method, params, { signal })`; a
## Methods per surface
- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`, `statusBar.update`, `workspace.list`, `workspace.open`, `terminal.create`, `terminal.read`, `agent.launch`
- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`, `statusBar.update`, `workspace.list`, `workspace.open`, `terminal.create`, `terminal.read`, `agent.launch`, `editor.open`, `commands.run`
- 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`
@@ -109,7 +109,7 @@ HTML panels and custom editors get these CSS variables from Orca's active theme,
`(params: { id: string; text?: string; icon?: "activity" | "bar-chart-3" | "bell" | "blocks" | "book" | "bot" | "bug" | "calendar" | "cloud" | "code" | "database" | "file-text" | "flag" | "folder" | "gauge" | "globe" | "hammer" | "layers" | "lightbulb" | "package" | "plug" | "puzzle" | "rocket" | "star" | "terminal" | "wrench" | "zap" | "add" | "remove" | "delete" | "edit" | "refresh" | "search" | "settings" | "check" | "close" | "info" | "warning" | "error" | "open-external" | "copy" | "download" | "upload" | "play" | "pause" | "stop" | "more" | "expand" | "collapse" | "next" | "filter" | "branch" | "file" | "agent" | "link"; tone?: "neutral" | "accent" | "success" | "warning" | "danger"; tooltip?: string; visible?: boolean }) => { ok: true }`
- `workspace.list` (capability `workspace:read`; worker; since 1.3): Lists every workspace, Git worktrees and folders, on every host Orca reaches.
`(params?: {}) => { workspaces: { id: string; kind: "worktree" | "folder"; name: string; path: string; branch: string | null; hostId: string | null }[]; truncated: boolean }`
- `workspace.open` (capability `workspace:write`; worker; since 1.3): Shows a workspace in Orca, as clicking it in the sidebar does.
- `workspace.open` (capability `workspace:write`; worker; since 1.3): Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app.
`(params: { workspaceId: string }) => { opened: true }`
- `terminal.create` (capability `terminal:send`; worker; since 1.3): Opens a terminal in a workspace, optionally running a command, in the background.
`(params: { workspaceId: string; title?: string; command?: string }) => { terminalId: string }`
@@ -117,6 +117,10 @@ HTML panels and custom editors get these CSS variables from Orca's active theme,
`(params: { terminalId: string; maxLines?: number }) => { lines: string[]; truncated: boolean }`
- `agent.launch` (capability `agent:launch`; worker; since 1.3): Starts an agent's CLI in a new terminal of a workspace, in the background, and returns as soon as it starts; a `prompt` is sent once the agent is ready.
`(params: { workspaceId: string; agent: string; prompt?: string }) => { terminalId: string }`
- `editor.open` (capability `workspace:write`; worker; since 1.3): Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace.
`(params: { workspaceId: string; path: string }) => { opened: true }`
- `commands.run` (capability `workspace:write`; worker; since 1.3): Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only.
`(params: { action: "worktree.history.back" | "worktree.history.forward" | "sidebar.left.toggle" | "sidebar.sleepingWorkspaces.toggle" | "floatingWorkspace.maximize" | "tab.rename" | "workspace.rename" | "workspace.openBoard" | "view.tasks" | "sidebar.right.toggle" | "sidebar.explorer.toggle" | "sidebar.search.toggle" | "sidebar.sourceControl.toggle" | "sidebar.checks.toggle" | "sidebar.ports.toggle" }) => { ran: true }`
## Hooks
@@ -140,7 +144,7 @@ List each in `contributes.hooks` (`{ "event", "onError"? }`; `main` and `trusted
## Capabilities
- `workspace:read` (since 1.4.160): Read the name, branch, and terminal list of your focused workspace, and get notified when workspaces are created or removed
- `workspace:write` (since 1.4.248): Open your workspaces in Orca
- `workspace:write` (since 1.4.248): Open your workspaces and their files in Orca, and run its built-in commands
- `agent:read` (since 1.4.245): Get notified when agent status changes
- `agent:launch` (since 1.4.248): Start agents in your workspaces, local and SSH
- `terminal:send` (since 1.4.160): Type and run commands in your terminals, local and SSH
@@ -148,4 +148,6 @@ A `trusted-worker` can act as the `orca` CLI does, on whichever host owns the wo
`host.call('workspace.list')` (`workspace:read`) for ids, kinds, paths and branches;
`terminal.create({ workspaceId, command })` (`terminal:send`) then `terminal.read({ terminalId,
maxLines })` (`terminal:read`) for its output; `agent.launch({ workspaceId, agent: 'claude', prompt })`
(`agent:launch`); `workspace.open({ workspaceId })` (`workspace:write`) to show one. Panels get none.
(`agent:launch`); `workspace.open({ workspaceId })`, `editor.open({ workspaceId, path })` (a path
inside it) and `commands.run({ action })` (a built-in `contributes.commands[].action`, never a plugin's
replacement) need `workspace:write` and the desktop app. Panels get none.
+10 -4
View File
@@ -2885,7 +2885,7 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
"",
"## Methods per surface",
"",
"- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`, `statusBar.update`, `workspace.list`, `workspace.open`, `terminal.create`, `terminal.read`, `agent.launch`",
"- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`, `statusBar.update`, `workspace.list`, `workspace.open`, `terminal.create`, `terminal.read`, `agent.launch`, `editor.open`, `commands.run`",
"- 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`",
"",
@@ -2988,7 +2988,7 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
" `(params: { id: string; text?: string; icon?: \"activity\" | \"bar-chart-3\" | \"bell\" | \"blocks\" | \"book\" | \"bot\" | \"bug\" | \"calendar\" | \"cloud\" | \"code\" | \"database\" | \"file-text\" | \"flag\" | \"folder\" | \"gauge\" | \"globe\" | \"hammer\" | \"layers\" | \"lightbulb\" | \"package\" | \"plug\" | \"puzzle\" | \"rocket\" | \"star\" | \"terminal\" | \"wrench\" | \"zap\" | \"add\" | \"remove\" | \"delete\" | \"edit\" | \"refresh\" | \"search\" | \"settings\" | \"check\" | \"close\" | \"info\" | \"warning\" | \"error\" | \"open-external\" | \"copy\" | \"download\" | \"upload\" | \"play\" | \"pause\" | \"stop\" | \"more\" | \"expand\" | \"collapse\" | \"next\" | \"filter\" | \"branch\" | \"file\" | \"agent\" | \"link\"; tone?: \"neutral\" | \"accent\" | \"success\" | \"warning\" | \"danger\"; tooltip?: string; visible?: boolean }) => { ok: true }`",
"- `workspace.list` (capability `workspace:read`; worker; since 1.3): Lists every workspace, Git worktrees and folders, on every host Orca reaches.",
" `(params?: {}) => { workspaces: { id: string; kind: \"worktree\" | \"folder\"; name: string; path: string; branch: string | null; hostId: string | null }[]; truncated: boolean }`",
"- `workspace.open` (capability `workspace:write`; worker; since 1.3): Shows a workspace in Orca, as clicking it in the sidebar does.",
"- `workspace.open` (capability `workspace:write`; worker; since 1.3): Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app.",
" `(params: { workspaceId: string }) => { opened: true }`",
"- `terminal.create` (capability `terminal:send`; worker; since 1.3): Opens a terminal in a workspace, optionally running a command, in the background.",
" `(params: { workspaceId: string; title?: string; command?: string }) => { terminalId: string }`",
@@ -2996,6 +2996,10 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
" `(params: { terminalId: string; maxLines?: number }) => { lines: string[]; truncated: boolean }`",
"- `agent.launch` (capability `agent:launch`; worker; since 1.3): Starts an agent's CLI in a new terminal of a workspace, in the background, and returns as soon as it starts; a `prompt` is sent once the agent is ready.",
" `(params: { workspaceId: string; agent: string; prompt?: string }) => { terminalId: string }`",
"- `editor.open` (capability `workspace:write`; worker; since 1.3): Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace.",
" `(params: { workspaceId: string; path: string }) => { opened: true }`",
"- `commands.run` (capability `workspace:write`; worker; since 1.3): Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only.",
" `(params: { action: \"worktree.history.back\" | \"worktree.history.forward\" | \"sidebar.left.toggle\" | \"sidebar.sleepingWorkspaces.toggle\" | \"floatingWorkspace.maximize\" | \"tab.rename\" | \"workspace.rename\" | \"workspace.openBoard\" | \"view.tasks\" | \"sidebar.right.toggle\" | \"sidebar.explorer.toggle\" | \"sidebar.search.toggle\" | \"sidebar.sourceControl.toggle\" | \"sidebar.checks.toggle\" | \"sidebar.ports.toggle\" }) => { ran: true }`",
"",
"## Hooks",
"",
@@ -3019,7 +3023,7 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
"## Capabilities",
"",
"- `workspace:read` (since 1.4.160): Read the name, branch, and terminal list of your focused workspace, and get notified when workspaces are created or removed",
"- `workspace:write` (since 1.4.248): Open your workspaces in Orca",
"- `workspace:write` (since 1.4.248): Open your workspaces and their files in Orca, and run its built-in commands",
"- `agent:read` (since 1.4.245): Get notified when agent status changes",
"- `agent:launch` (since 1.4.248): Start agents in your workspaces, local and SSH",
"- `terminal:send` (since 1.4.160): Type and run commands in your terminals, local and SSH",
@@ -3399,7 +3403,9 @@ const ORCA_PLUGIN_AUTHORING_RECIPES_REFERENCE_MARKDOWN = [
"`host.call('workspace.list')` (`workspace:read`) for ids, kinds, paths and branches;",
"`terminal.create({ workspaceId, command })` (`terminal:send`) then `terminal.read({ terminalId,",
"maxLines })` (`terminal:read`) for its output; `agent.launch({ workspaceId, agent: 'claude', prompt })`",
"(`agent:launch`); `workspace.open({ workspaceId })` (`workspace:write`) to show one. Panels get none.",
"(`agent:launch`); `workspace.open({ workspaceId })`, `editor.open({ workspaceId, path })` (a path",
"inside it) and `commands.run({ action })` (a built-in `contributes.commands[].action`, never a plugin's",
"replacement) need `workspace:write` and the desktop app. Panels get none.",
"",
].join('\n')
@@ -5,6 +5,7 @@ import { ALL_RPC_METHODS } from '../runtime/rpc/methods'
const DISPATCHED = [
'worktree.list',
'repo.list',
'folderWorkspace.list',
'worktree.activate',
'terminal.create',
'terminal.read',
@@ -20,6 +20,7 @@ import type {
PluginSurface
} from '../../shared/plugins/frames/plugin-protocol'
import { executePluginHostCallRequest } from './workers/plugin-host-call-adapter'
import { getPluginHostRendererActions } from './view/plugin-host-renderer-actions'
/** Who made a call: the open panel (frames only), and its host window's WebContents for desktop
* IPC calls. */
@@ -84,6 +85,7 @@ export function resolvePluginServiceHostCallPolicy(input: {
pluginsDataDir: input.pluginsDataDir,
desktop: input.desktop,
panels: input.panels,
renderer: getPluginHostRendererActions(),
declaredSettings: plugin?.manifest.contributes.settings,
statusBar: input.statusBar,
declaredStatusBarItemIds: plugin?.manifest.contributes.statusBarItems.map(
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getPluginHostRendererActions,
setPluginHostRendererRequest
} from './plugin-host-renderer-actions'
afterEach(() => setPluginHostRendererRequest(null))
describe('plugin host renderer actions', () => {
it('are absent in headless serve, and unavailable with no window', async () => {
expect(getPluginHostRendererActions()).toBeNull()
setPluginHostRendererRequest(() => null)
await expect(getPluginHostRendererActions()?.runAction('view.tasks')).rejects.toMatchObject({
code: 'unavailable'
})
})
it('send open-file and run-action, and map the window refusals', async () => {
const request = vi
.fn()
.mockResolvedValueOnce({ requestId: 'a', ok: true })
.mockResolvedValueOnce({ requestId: 'b', ok: false, code: 'no_workspace', message: 'none' })
.mockResolvedValueOnce({ requestId: 'c', ok: false, code: 'renderer_error', message: 'boom' })
.mockRejectedValueOnce(new Error('renderer_unavailable'))
setPluginHostRendererRequest(request)
const actions = getPluginHostRendererActions()!
await expect(actions.openFile('app', 'src/a.ts')).resolves.toBeUndefined()
expect(request).toHaveBeenCalledWith({
kind: 'open-file',
worktreeId: 'app',
relativePath: 'src/a.ts'
})
await expect(actions.runAction('view.tasks')).rejects.toMatchObject({ code: 'unavailable' })
await expect(actions.runAction('view.tasks')).rejects.toMatchObject({ code: 'action_failed' })
await expect(actions.runAction('view.tasks')).rejects.toMatchObject({ code: 'unavailable' })
expect(request).toHaveBeenLastCalledWith({ kind: 'run-action', action: 'view.tasks' })
request.mockResolvedValueOnce({ requestId: 'd', ok: true })
await actions.openFolderWorkspace('f1')
expect(request).toHaveBeenLastCalledWith({
kind: 'open-folder-workspace',
folderWorkspaceId: 'f1'
})
})
})
@@ -0,0 +1,56 @@
import {
PLUGIN_VIEW_NO_WORKSPACE_CODE,
type PluginViewReply,
type PluginViewRequestBody
} from '../../../shared/plugins/view/plugin-view-protocol'
import type { PluginCommandAliasActionId } from '../../../shared/plugins/contributions/plugin-command-actions'
import { PluginHostCallError } from '../workers/plugin-host-call-scope'
/** What a worker's `editor.open` and `commands.run` ask of Orca's window. */
export type PluginHostRendererActions = {
openFile(worktreeId: string, relativePath: string): Promise<void>
runAction(action: PluginCommandAliasActionId): Promise<void>
openFolderWorkspace(folderWorkspaceId: string): Promise<void>
}
/** Sends one request to the main window's renderer; null when there is no window to ask. */
export type PluginHostRendererRequest = (
body: PluginViewRequestBody
) => Promise<PluginViewReply> | null
// Set by the desktop app's view binding (Electron stays out of this module); null in headless serve.
let request: PluginHostRendererRequest | null = null
export function setPluginHostRendererRequest(next: PluginHostRendererRequest | null): void {
request = next
}
async function ask(body: PluginViewRequestBody): Promise<void> {
const pending = request?.(body) ?? null
if (!pending) {
throw new PluginHostCallError('unavailable', 'Orca has no window open to do this in')
}
const reply = await pending.catch((error: unknown) => {
throw new PluginHostCallError(
'unavailable',
error instanceof Error ? error.message : String(error)
)
})
if (!reply.ok) {
const unavailable =
reply.code === PLUGIN_VIEW_NO_WORKSPACE_CODE || reply.code === 'action_unavailable'
throw new PluginHostCallError(unavailable ? 'unavailable' : 'action_failed', reply.message)
}
}
export function getPluginHostRendererActions(): PluginHostRendererActions | null {
return request
? {
openFile: (worktreeId, relativePath) =>
ask({ kind: 'open-file', worktreeId, relativePath }),
runAction: (action) => ask({ kind: 'run-action', action }),
openFolderWorkspace: (folderWorkspaceId) =>
ask({ kind: 'open-folder-workspace', folderWorkspaceId })
}
: null
}
@@ -8,6 +8,10 @@ import {
} from '../panels/plugin-frame-guest-diagnostics'
import { createPluginViewDesktop } from './plugin-view-desktop'
import { isPluginShotWindowShown } from './plugin-shot-policy'
import { setPluginHostRendererRequest } from './plugin-host-renderer-actions'
import { requestPluginViewFromRenderer } from './plugin-view-renderer-relay'
const HOST_ACTION_TIMEOUT_MS = 10_000
/** Guest console lines into plugin logs, and `plugins.open` / `plugins.shot` for the desktop
* app; a headless serve leaves open and shot refusing. */
@@ -19,8 +23,16 @@ export function bindPluginDesktopViews(
setPluginFrameGuestLogSink((pluginKey) => service.captureLog(pluginKey))
if (serveMode) {
setPluginViewForRpc(null)
setPluginHostRendererRequest(null)
return
}
// The one main window, so a worker's editor.open and commands.run always reach the same one.
setPluginHostRendererRequest((body) => {
const window = getMainWindow()
return window && !window.isDestroyed()
? requestPluginViewFromRenderer(window.webContents, body, HOST_ACTION_TIMEOUT_MS)
: null
})
setPluginViewForRpc(
createPluginViewDesktop({
rendererContents: () => {
@@ -18,6 +18,7 @@ import {
type PluginPanelBridgeServices
} from './plugin-host-panel-method-bindings'
import { bind, type BoundPluginHostMethod } from './plugin-host-method-bind'
import type { PluginHostRendererActions } from '../view/plugin-host-renderer-actions'
import {
PLUGIN_STATUS_BAR_HANDLERS,
type PluginStatusBarServices
@@ -89,6 +90,8 @@ export type PluginHostServices = {
statusBar?: PluginStatusBarServices | null
/** The runtime methods the `orca` CLI calls, for workspace actions; absent without a runtime. */
runtimeRpc?: PluginRuntimeRpc | null
/** Orca's window, for actions only it can take; absent in headless serve. */
renderer?: PluginHostRendererActions | null
settings: {
getAll(pluginId: string): Record<string, PluginJsonValue>
set(
@@ -4,6 +4,7 @@ import type { PluginDesktopFrameServices } from './plugin-host-method-bindings'
import type { PluginPanelBridgeServices } from './plugin-host-panel-method-bindings'
import type { PluginStatusBarState } from '../status-bar/plugin-status-bar-state'
import type { PluginRuntimeRpc } from './plugin-host-workspace-method-bindings'
import type { PluginHostRendererActions } from '../view/plugin-host-renderer-actions'
import {
pluginSettingValueIssue,
type PluginSettingContribution
@@ -55,6 +56,7 @@ export function bindPluginHostServices(input: {
pluginsDataDir: string
desktop?: PluginDesktopFrameServices | null
panels?: PluginPanelBridgeServices | null
renderer?: PluginHostRendererActions | null
/** The calling plugin's `contributes.settings`: defaults and value checks. */
declaredSettings?: readonly PluginSettingContribution[]
statusBar?: PluginStatusBarState | null
@@ -71,6 +73,7 @@ export function bindPluginHostServices(input: {
? statusBarServices(input.statusBar, input.declaredStatusBarItemIds, input.statusBarRevision)
: null,
runtimeRpc: delegate.callRuntime ?? null,
renderer: input.renderer ?? null,
resolveActiveWorktreeContext: async () => {
const context = await delegate.resolveActiveWorktreeContext()
if (!context) {
@@ -11,6 +11,7 @@ import {
} from './plugin-host-method-bind'
import type { PluginHostServices } from './plugin-host-method-bindings'
import { PluginHostCallError } from './plugin-host-call-scope'
import type { PluginHostRendererActions } from '../view/plugin-host-renderer-actions'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope'
/** The runtime RPC the `orca` CLI calls, run in process; rejects with the runtime's error.
@@ -65,6 +66,13 @@ const agentLaunchAnswer = z.object({
outcome: z.object({ kind: z.enum(['structured', 'terminal']), handle: z.string() })
})
function renderer(services: PluginHostServices): PluginHostRendererActions {
if (!services.renderer) {
throw new PluginHostCallError('unavailable', 'only the Orca desktop app can do this')
}
return services.renderer
}
function rpc(services: PluginHostServices): PluginRuntimeRpc {
if (!services.runtimeRpc) {
throw new PluginHostCallError('unavailable', 'this Orca host cannot run workspace actions')
@@ -141,9 +149,12 @@ export const PLUGIN_WORKSPACE_ACTION_HANDLERS = {
}
}),
'workspace.open': bind(api['workspace.open'], async ({ workspaceId }, ctx) => {
if (parseWorkspaceKey(workspaceId)?.type === 'folder') {
// The runtime activates Git worktrees only; a folder is shown by Orca's window.
throw new PluginHostCallError('unavailable', 'this Orca cannot open folder workspaces yet')
const scope = parseWorkspaceKey(workspaceId)
if (scope?.type === 'folder') {
// The runtime activates Git worktrees only; Orca's window shows a folder workspace.
ctx.scope.assertActive()
await renderer(ctx.services).openFolderWorkspace(scope.folderWorkspaceId)
return { opened: true as const }
}
await effect(ctx, 'worktree.activate', { worktree: workspaceSelector(workspaceId) })
return { opened: true as const }
@@ -200,5 +211,17 @@ export const PLUGIN_WORKSPACE_ACTION_HANDLERS = {
})
}
return { terminalId }
}),
'editor.open': bind(api['editor.open'], async ({ workspaceId, path }, { services, scope }) => {
scope.assertActive()
await renderer(services).openFile(workspaceId, path)
return { opened: true as const }
}),
'commands.run': bind(api['commands.run'], async ({ action }, { services, scope }) => {
scope.assertActive()
// Always Orca's own action, never a plugin command that replaces it: a replacement that
// calls commands.run for the original would otherwise call itself.
await renderer(services).runAction(action)
return { ran: true as const }
})
} satisfies Record<string, BoundPluginHostMethod>
@@ -3,8 +3,12 @@ import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/
import { executePluginHostCall, type PluginHostServices } from './plugin-host-methods'
import { pluginHostCallRevoked, type PluginHostCallScope } from './plugin-host-call-scope'
import type { PluginRuntimeRpc } from './plugin-host-workspace-method-bindings'
import type { PluginHostRendererActions } from '../view/plugin-host-renderer-actions'
function services(runtimeRpc: PluginRuntimeRpc | null): PluginHostServices {
function services(
runtimeRpc: PluginRuntimeRpc | null,
renderer: PluginHostRendererActions | null = null
): PluginHostServices {
return {
resolveActiveWorktreeContext: vi.fn().mockResolvedValue(null),
listWorktreeTerminals: vi.fn().mockResolvedValue([]),
@@ -19,7 +23,8 @@ function services(runtimeRpc: PluginRuntimeRpc | null): PluginHostServices {
protection: vi.fn().mockReturnValue('unavailable')
},
settings: { getAll: vi.fn().mockReturnValue({}), set: vi.fn() },
runtimeRpc
runtimeRpc,
renderer
}
}
@@ -56,6 +61,7 @@ function call(
capabilities?: PluginCapabilityKind[]
surface?: 'worker' | 'panel'
scope?: PluginHostCallScope
renderer?: PluginHostRendererActions | null
} = {}
) {
return executePluginHostCall({
@@ -64,7 +70,7 @@ function call(
params,
surface: options.surface ?? 'worker',
grantedCapabilities: options.capabilities ?? ALL,
services: services(rpc),
services: services(rpc, options.renderer ?? null),
audit: { record: vi.fn().mockResolvedValue(undefined) },
...(options.scope ? { scope: options.scope } : {})
})
@@ -248,4 +254,54 @@ describe('workspace host actions', () => {
await flush()
await expect(call('workspace.list', undefined, null)).resolves.toMatchObject({ ok: false })
})
it('editor.open asks the window to open a file inside the workspace', async () => {
const renderer = {
openFile: vi.fn().mockResolvedValue(undefined),
runAction: vi.fn(),
openFolderWorkspace: vi.fn()
}
await expect(
call('editor.open', { workspaceId: 'app', path: 'src/main.ts' }, null, { renderer })
).resolves.toEqual({ ok: true, value: { opened: true } })
expect(renderer.openFile).toHaveBeenCalledWith('app', 'src/main.ts')
for (const path of ['../secret', '/etc/passwd', 'C:\\x', 'a/../../b', '.']) {
await expect(
call('editor.open', { workspaceId: 'app', path }, null, { renderer })
).resolves.toMatchObject({ ok: false, code: 'invalid_params' })
}
expect(renderer.openFile).toHaveBeenCalledTimes(1)
})
it("commands.run runs only built-in actions, and needs Orca's window", async () => {
const renderer = {
openFile: vi.fn(),
runAction: vi.fn().mockResolvedValue(undefined),
openFolderWorkspace: vi.fn()
}
await expect(
call('commands.run', { action: 'sidebar.left.toggle' }, null, { renderer })
).resolves.toEqual({ ok: true, value: { ran: true } })
expect(renderer.runAction).toHaveBeenCalledWith('sidebar.left.toggle')
await expect(
call('commands.run', { action: 'editor.save' }, null, { renderer })
).resolves.toMatchObject({ ok: false, code: 'invalid_params' })
await expect(
call('commands.run', { action: 'sidebar.left.toggle' }, null)
).resolves.toMatchObject({ ok: false, code: 'unavailable' })
})
it("workspace.open shows a folder workspace through Orca's window", async () => {
const renderer = {
openFile: vi.fn(),
runAction: vi.fn(),
openFolderWorkspace: vi.fn().mockResolvedValue(undefined)
}
const { rpc, calls } = runtime({})
await expect(
call('workspace.open', { workspaceId: 'folder:f1' }, rpc, { renderer })
).resolves.toEqual({ ok: true, value: { opened: true } })
expect(renderer.openFolderWorkspace).toHaveBeenCalledWith('f1')
expect(calls).toEqual([])
})
})
@@ -37,7 +37,7 @@ export function pluginCapabilityDescription(kind: string, fallback: string): str
case 'workspace:write':
return translate(
'auto.components.settings.PluginConsentDialog.capability.workspaceWrite',
'Open your workspaces'
'Open your workspaces and files, and run built-in commands'
)
case 'agent:launch':
return translate(
@@ -0,0 +1,130 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { registerAppCommandDispatcher } from '@/lib/app-command-dispatch'
import { handlePluginViewRequest } from './plugin-view-request-handler'
const initialState = useAppStore.getState()
afterEach(() => {
useAppStore.setState(initialState, true)
})
function seedWorkspaces(
openFile: ReturnType<typeof vi.fn>,
setActiveFolderWorkspace: ReturnType<typeof vi.fn> = vi.fn()
): void {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only id and path of worktrees and id and folderPath of folders.
useAppStore.setState({
openFile,
worktreesByRepo: {
r1: [{ id: 'r1::/repo', path: '/repo', identity: { key: 'wt2:local:i1' } }]
},
setActiveFolderWorkspace,
folderWorkspaces: [{ id: 'f1', folderPath: 'C:\\notes' }]
} as unknown as Partial<ReturnType<typeof useAppStore.getState>>)
}
describe('open-file', () => {
it('opens a workspace file by its path inside the workspace, folders included', async () => {
const openFile = vi.fn().mockReturnValue('file-1')
seedWorkspaces(openFile)
await expect(
handlePluginViewRequest({
requestId: 'r',
kind: 'open-file',
worktreeId: 'r1::/repo',
relativePath: 'src/main.ts'
})
).resolves.toEqual({ requestId: 'r', ok: true })
expect(openFile).toHaveBeenLastCalledWith(
expect.objectContaining({
filePath: '/repo/src/main.ts',
relativePath: 'src/main.ts',
worktreeId: 'r1::/repo',
mode: 'edit'
}),
{ preview: false }
)
await handlePluginViewRequest({
requestId: 'f',
kind: 'open-file',
worktreeId: 'folder:f1',
relativePath: 'todo/today.md'
})
expect(openFile).toHaveBeenLastCalledWith(
expect.objectContaining({ filePath: 'C:\\notes\\todo\\today.md', worktreeId: 'folder:f1' }),
{ preview: false }
)
})
it('takes the host-naming identity id workspace.list returns', async () => {
const openFile = vi.fn().mockReturnValue('file-1')
seedWorkspaces(openFile)
await handlePluginViewRequest({
requestId: 'i',
kind: 'open-file',
worktreeId: 'identity:wt2:local:i1',
relativePath: 'a.ts'
})
expect(openFile).toHaveBeenLastCalledWith(
expect.objectContaining({ filePath: '/repo/a.ts', worktreeId: 'r1::/repo' }),
{ preview: false }
)
})
it('shows a folder workspace for workspace.open', async () => {
const setActiveFolderWorkspace = vi.fn()
seedWorkspaces(vi.fn(), setActiveFolderWorkspace)
await expect(
handlePluginViewRequest({
requestId: 'o',
kind: 'open-folder-workspace',
folderWorkspaceId: 'f1'
})
).resolves.toEqual({ requestId: 'o', ok: true })
expect(setActiveFolderWorkspace).toHaveBeenCalledWith('f1')
await expect(
handlePluginViewRequest({
requestId: 'p',
kind: 'open-folder-workspace',
folderWorkspaceId: 'zz'
})
).resolves.toMatchObject({ ok: false, code: 'workspace_not_found' })
})
it('refuses a workspace this window does not list', async () => {
seedWorkspaces(vi.fn())
await expect(
handlePluginViewRequest({
requestId: 'x',
kind: 'open-file',
worktreeId: 'gone',
relativePath: 'a.ts'
})
).resolves.toMatchObject({ ok: false, code: 'workspace_not_found' })
})
})
describe('run-action', () => {
it("runs the window's own handler for the built-in action", async () => {
const dispatcher = vi.fn().mockReturnValue(true)
const unregister = registerAppCommandDispatcher(dispatcher)
try {
await expect(
handlePluginViewRequest({
requestId: 'a',
kind: 'run-action',
action: 'sidebar.left.toggle'
})
).resolves.toEqual({ requestId: 'a', ok: true })
expect(dispatcher).toHaveBeenCalledWith('sidebar.left.toggle', 'plugin-host')
dispatcher.mockReturnValue(false)
await expect(
handlePluginViewRequest({ requestId: 'b', kind: 'run-action', action: 'view.tasks' })
).resolves.toMatchObject({ ok: false, code: 'action_unavailable' })
} finally {
unregister()
}
})
})
@@ -0,0 +1,92 @@
import type {
PluginViewReply,
PluginViewRequest
} from '../../../../../shared/plugins/view/plugin-view-protocol'
import { detectLanguage } from '@/lib/language-detect'
import { dispatchAppCommand } from '@/lib/app-command-dispatch'
import { useAppStore } from '@/store'
import { getIndexedWorktreeMap } from '@/store/worktree-repo-index'
import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors'
import { parseWorkspaceKey } from '../../../../../shared/workspace-scope'
/** A plugin worker's `editor.open` and `commands.run`, run in this window. */
const fail = (requestId: string, code: string, message: string): PluginViewReply => ({
requestId,
ok: false,
code,
message
})
/** The workspace path and a relative one joined with the workspace's own separator. */
function workspaceFilePath(worktreePath: string, relativePath: string): string {
const separator = worktreePath.includes('\\') && !worktreePath.includes('/') ? '\\' : '/'
const parts = relativePath.split(/[\\/]/).filter((part) => part !== '' && part !== '.')
return [worktreePath.replace(/[\\/]+$/, ''), ...parts].join(separator)
}
/** A `workspace.list` id (a worktree id, its host-naming `identity:` key, or a folder key) as
* this window's workspace id and folder path. */
function resolveWorkspace(workspaceId: string): { id: string; root: string } | null {
const state = useAppStore.getState()
const scope = parseWorkspaceKey(workspaceId)
if (scope?.type === 'folder') {
const folder = selectFolderWorkspaces(state)?.find(
(entry) => entry.id === scope.folderWorkspaceId
)
return folder ? { id: workspaceId, root: folder.folderPath } : null
}
const worktrees = selectWorktreesByRepo(state)
const identity = workspaceId.startsWith('identity:') ? workspaceId.slice(9) : null
const worktree = identity
? Object.values(worktrees)
.flat()
.find((entry) => entry.identity?.key === identity)
: getIndexedWorktreeMap(worktrees).get(workspaceId)
return worktree ? { id: worktree.id, root: worktree.path } : null
}
export function openWorkspaceFile(
request: Extract<PluginViewRequest, { kind: 'open-file' }>
): PluginViewReply {
const { requestId, worktreeId, relativePath } = request
const workspace = resolveWorkspace(worktreeId)
if (!workspace) {
return fail(requestId, 'workspace_not_found', `This window does not list ${worktreeId}.`)
}
const filePath = workspaceFilePath(workspace.root, relativePath)
useAppStore.getState().openFile(
{
filePath,
relativePath: relativePath.split(/[\\/]/).filter(Boolean).join('/'),
worktreeId: workspace.id,
language: detectLanguage(filePath),
mode: 'edit'
},
{ preview: false }
)
return { requestId, ok: true }
}
/** `workspace.open` of a folder workspace: the runtime activates Git worktrees only. */
export function openFolderWorkspace(
request: Extract<PluginViewRequest, { kind: 'open-folder-workspace' }>
): PluginViewReply {
const { requestId, folderWorkspaceId } = request
const state = useAppStore.getState()
if (!selectFolderWorkspaces(state)?.some((folder) => folder.id === folderWorkspaceId)) {
return fail(requestId, 'workspace_not_found', `This window does not list that folder.`)
}
state.setActiveFolderWorkspace(folderWorkspaceId)
return { requestId, ok: true }
}
// Rule: the raw built-in handler, never a wrapper that runs a plugin's replacement first, or a
// replacement that calls commands.run for Orca's own version would call itself.
export function runBuiltInAction(
request: Extract<PluginViewRequest, { kind: 'run-action' }>
): PluginViewReply {
return dispatchAppCommand(request.action, 'plugin-host')
? { requestId: request.requestId, ok: true }
: fail(request.requestId, 'action_unavailable', `${request.action} cannot run in this view.`)
}
@@ -33,6 +33,11 @@ import { captureOpenEditor } from './plugin-view-editor-capture'
import { planEditorShotCleanup, runEditorShotCleanup } from './plugin-view-editor-shot-cleanup'
import { captureChrome } from './plugin-view-chrome-capture'
import { pickWorkspaceToShow } from './plugin-view-workspace-pick'
import {
openFolderWorkspace,
openWorkspaceFile,
runBuiltInAction
} from './plugin-view-host-actions'
type Failure = Extract<PluginViewReply, { ok: false }>
@@ -226,6 +231,12 @@ export async function handlePluginViewRequest(
return await replayPluginPanel(request)
case 'capture-chrome':
return await captureChrome(request)
case 'open-file':
return openWorkspaceFile(request)
case 'run-action':
return runBuiltInAction(request)
case 'open-folder-workspace':
return openFolderWorkspace(request)
case 'end-capture': {
endPluginShotReplay()
usePluginViewCaptureStore.getState().end(request.captureId)
+1 -1
View File
@@ -12030,7 +12030,7 @@
"agentsRead": "Get notified when agent status changes",
"terminalRun": "Run commands in your terminals, including SSH",
"terminalRead": "Read the output of your terminals, including SSH",
"workspaceWrite": "Open your workspaces",
"workspaceWrite": "Open your workspaces and files, and run built-in commands",
"agentLaunch": "Start agents in your workspaces, including SSH"
},
"decisionFailed": "Could not save the permission decision. Try again.",
+2 -1
View File
@@ -1,6 +1,6 @@
import { isKeybindingActionId, type KeybindingActionId } from '../../../shared/keybindings'
export type AppCommandSource = 'plugin-keybinding' | 'plugin-palette'
export type AppCommandSource = 'plugin-keybinding' | 'plugin-palette' | 'plugin-host'
export type AppCommandDispatcher = (
actionId: KeybindingActionId,
source: AppCommandSource
@@ -17,6 +17,7 @@ export function registerAppCommandDispatcher(dispatcher: AppCommandDispatcher):
}
}
/** Runs Orca's own handler for the action; plugin commands that replace it never run here. */
export function dispatchAppCommand(actionId: string, source: AppCommandSource): boolean {
return isKeybindingActionId(actionId) && currentDispatcher !== null
? currentDispatcher(actionId, source)
@@ -65,7 +65,7 @@ export type PluginCapability = z.infer<typeof pluginCapabilitySchema>
export const PLUGIN_CAPABILITY_DESCRIPTIONS: Record<PluginCapabilityKind, string> = {
'workspace:read':
'Read the name, branch, and terminal list of your focused workspace, and get notified when workspaces are created or removed',
'workspace:write': 'Open your workspaces in Orca',
'workspace:write': 'Open your workspaces and their files in Orca, and run its built-in commands',
'agent:read': 'Get notified when agent status changes',
'agent:launch': 'Start agents in your workspaces, local and SSH',
'terminal:send': 'Type and run commands in your terminals, local and SSH',
@@ -6,6 +6,7 @@
import type { PluginDeclarativeNode } from '../declarative/plugin-declarative-catalog'
import type { PluginShotAct, PluginShotActResult } from './plugin-shot-acts'
import type { PluginCommandAliasActionId } from '../contributions/plugin-command-actions'
export const PLUGIN_VIEW_REQUEST_CHANNEL = 'plugins:viewRequest'
export const PLUGIN_VIEW_REPLY_CHANNEL = 'plugins:viewReply'
@@ -70,6 +71,12 @@ export type PluginViewRequestBody =
}
/** Puts back the width and theme a capture changed, and undoes editor capture `captureId`. */
| { kind: 'end-capture'; captureId?: string }
/** A plugin worker's `editor.open`: a workspace file, by path inside the workspace. */
| { kind: 'open-file'; worktreeId: string; relativePath: string }
/** A plugin worker's `commands.run`: always the built-in action, never a plugin's override. */
| { kind: 'run-action'; action: PluginCommandAliasActionId }
/** A plugin worker's `workspace.open` of a folder workspace, which the runtime cannot activate. */
| { kind: 'open-folder-workspace'; folderWorkspaceId: string }
export type PluginViewReplay = {
tree: PluginDeclarativeNode | null
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { isTuiAgent } from '../../tui-agent-config'
import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../contributions/plugin-command-actions'
import { spec, WORKER_ONLY } from './plugin-host-method-spec'
/**
@@ -74,6 +75,34 @@ const agentLaunchResult = z.object({
terminalId
})
/** A `/`-separated path inside the workspace: not absolute, no `..`, no drive letter. */
export function isPluginWorkspaceRelativePath(path: string): boolean {
const segments = path.split(/[\\/]/)
return (
!/^[a-z]:/i.test(path) &&
segments[0] !== '' &&
segments.every((segment) => segment !== '..') &&
segments.some((segment) => segment !== '' && segment !== '.')
)
}
const editorOpenParams = z.strictObject({
workspaceId,
/** Relative to the workspace, `/`-separated; it must stay inside it. */
path: z
.string()
.min(1)
.max(ID_MAX)
.refine(isPluginWorkspaceRelativePath, 'path must be relative and inside the workspace')
})
const editorOpenResult = z.object({ opened: z.literal(true) })
const commandsRunParams = z.strictObject({
/** A built-in action, the same list `contributes.commands[].action` takes. */
action: z.enum(PLUGIN_COMMAND_ALIAS_ACTION_IDS)
})
const commandsRunResult = z.object({ ran: z.literal(true) })
export const PLUGIN_WORKSPACE_ACTIONS_API = {
'workspace.list': spec({
summary: 'Lists every workspace, Git worktrees and folders, on every host Orca reaches.',
@@ -86,7 +115,8 @@ export const PLUGIN_WORKSPACE_ACTIONS_API = {
result: workspaceListResult
}),
'workspace.open': spec({
summary: 'Shows a workspace in Orca, as clicking it in the sidebar does.',
summary:
'Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app.',
since: 3,
scope: 'any-workspace',
capability: 'workspace:write',
@@ -125,5 +155,27 @@ export const PLUGIN_WORKSPACE_ACTIONS_API = {
surfaces: WORKER_ONLY,
params: agentLaunchParams,
result: agentLaunchResult
}),
'editor.open': spec({
summary:
"Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace.",
since: 3,
scope: 'any-workspace',
capability: 'workspace:write',
mutation: true,
surfaces: WORKER_ONLY,
params: editorOpenParams,
result: editorOpenResult
}),
'commands.run': spec({
summary:
"Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only.",
since: 3,
scope: 'desktop',
capability: 'workspace:write',
mutation: true,
surfaces: WORKER_ONLY,
params: commandsRunParams,
result: commandsRunResult
})
}