Files
orca/src/main/plugins/workers/host-api/plugin-workspace-projection.ts
T
Neil 7b41b92062 feat(plugins): host API 1.7 — workspace ids, project/terminal list, terminal focus/close
Plugins that track workspaces and terminals across Orca had to read the private runtime
token or shell out to the CLI. Host API 1.7 gives them the same through consented methods:

- workspace.readContext (and workspace.changed) carry `workspaceId`, the id workspace.list
  gives; workspace.created/removed and agent.status.changed add `workspaceListId` beside
  their existing raw `workspaceId`, which keeps its meaning.
- workspace.list rows name their `projectId`; opt-in `includeStatus` joins each row's agent
  state, unread flag, live terminal count and last activity from worktree.ps.
- project.list (workspace:read), terminal.list (workspace:read), terminal.focus
  (workspace:write, desktop only, steers only this window) and terminal.close
  (terminal:send, reports whether the process is known to have stopped).
- Runtime refusals (unknown or ambiguous workspace, stale or exited terminal) reach plugins
  as fixed messages instead of runtime wording.
- Consent copy for workspace:read, workspace:write and terminal:send says what they now
  allow; terminal.focus and terminal.close are audited by terminal id.
- PLUGIN_SCHEMA_NEWEST_SINCE covers the editor typography and theme video floors it missed,
  and the provisional 1.7 floor, so development builds accept plugins requiring them.
2026-11-12 02:56:40 -08:00

113 lines
4.0 KiB
TypeScript

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 {
PLUGIN_HOST_ID_MAX_LENGTH,
pluginWorkspaceId
} from '../../../../shared/plugins/workers/plugin-host-api-workspace-methods'
import type { AgentStatusState } from '../../../../shared/agent-status/contracts/agent-status-types'
/** One terminal of a workspace, as main's runtime lists it. */
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; identityKey?: 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
}
/** The id `workspace.list` gives, or undefined when it exceeds the public bound. */
export function pluginListedWorkspaceId(row: {
id: string
identityKey?: string | null
}): string | undefined {
const id = pluginWorkspaceId(row)
return id.length <= PLUGIN_HOST_ID_MAX_LENGTH ? id : undefined
}
/** Only these public fields reach plugins; the id is the one `workspace.list` gives. */
export function projectPluginWorkspaceContext(
context: Context | null,
terminals: readonly PluginWorkspaceTerminal[]
): PluginWorkspaceContext {
if (!context) {
return null
}
const workspaceId = pluginListedWorkspaceId({
id: context.worktreeId,
identityKey: context.identityKey
})
return {
...(workspaceId ? { workspaceId } : {}),
branch: shortPluginBranchName(context.branch).slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
displayName: context.displayName.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
terminals: terminals
.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
}
})
}