Skip to content

Latest commit

 

History

History
1728 lines (1458 loc) · 109 KB

File metadata and controls

1728 lines (1458 loc) · 109 KB

Plugin System — Design Reference

This is the developer-facing architecture reference for Shofer's plugin system: a single package format that is a strict superset of MCP — one plugin can affect the UI, system prompt, tools, modes, hooks, background services, and lifecycle, not just expose callable functions.

Everything described here is implemented and shipped, except two scoped remainders inside §14 — the unattended / approvalPolicy pair (§14.2) and the non-HTTP permissions.network generalization (§14.3), both marked in place; the §14 ctx.agent.spawn / PluginTaskHandle surface itself is shipped — the two deferred MCP modes in §5.6 (a plugin as an in-process server, and Shofer as an aggregating server host), also marked in place, and the deferred hosted remote plugin registry (§13 Deferred).

For authoring a plugin (manifest fields, build invocations, step-by-step walkthroughs) see the author-facing guide ../PLUGINS.md. This document covers the substrate — how the pieces fit inside Shofer — and does not duplicate the how-to.

Table of Contents

  1. Motivation
  2. Design Goals
  3. Architecture Overview
  4. Plugin Manifest
  5. Extension Points
  6. Plugin Lifecycle
  7. Security Model
  8. Distribution & Discovery
  9. Relationship to MCP
  10. Relationship to Existing Subsystems
  11. UI Integration
  12. Comparison with OpenCode and Claude Code
  13. Deferred
  14. Agent-Control API for Workflow/Runner Plugins

1. Motivation

Shofer has several extension mechanisms that predate the plugin system, each limited to one surface:

Mechanism What it extends Limitation
MCP servers Tools + resources Only callable functions and readable resources. No system prompt, UI, mode, or lifecycle reach.
Custom tools (.shofer/tools/) Tools only File-based TypeScript tools; no lifecycle hooks, no prompt access.
Private tool providers (shofer.privateToolProviders) Tools only VS Code extension-registered tools via command channel. No behavioral extension.
Skills (.shofer/skills/) System prompt (lazy) Markdown instructions loaded on demand. No code execution, no hooks.

A plugin unifies all of these into one package format with one manifest — a package that bundles tools, prompt modifications, UI contributions, mode definitions, hooks, background services, and lifecycle handlers, all declaratively described and safely sandboxed — while remaining a strict superset of MCP (an MCP server is one kind of plugin contribution).


2. Design Goals

  1. Superset of MCP. A plugin does everything an MCP server can (expose tools/resources) plus everything Shofer-internal (modify the system prompt, contribute UI, register modes, hook into lifecycle, run background services, affect auto-approval).

  2. Declarative manifest. A plugin.json declares what the plugin contributes. Shofer reads it and wires the extension points without the plugin needing to know Shofer internals.

  3. Host-agnostic. Plugins use the same HostBridge (getHost()) seam as the core. A plugin runs identically in the VS Code extension, the CLI, and a headless server. No vscode imports in plugin code.

  4. Sandboxed. Plugins load with restricted permissions. The manifest declares what resources the plugin needs (filesystem paths, network endpoints, UI regions); Shofer enforces these at runtime.

  5. Composable. Multiple plugins coexist. Extension points have defined composition semantics — system-prompt transforms chain in order, tool contributions merge, UI contributions slot into named regions, and modes/skills/commands are namespaced so they cannot collide.

  6. Distributable. Plugins are packaged as .shofer-plugin archives; installation is one CLI command (or a .shofer/plugins.json declaration).

  7. Safe failure. A crashing plugin is isolated. Its tools disappear, its prompt transforms are skipped, its UI unmounts, its services stop — the rest of Shofer keeps working.


3. Architecture Overview

The substrate

The plugin system is built on two host-agnostic core types plus a host-side manager:

  • ShoferPlugin (packages/types/src/plugin.ts) — the plugin interface: initialize, registerTools, transformSystemPrompt, lifecycle hooks, onEvent, and UI message handlers.
  • PluginRegistry (packages/core/src/plugins/plugin-registry.ts) — the in-process registry the core reads from. It exposes collectTools(), applySystemPromptTransforms(), applyLifecycleHook(), dispatchEvent(), and a revision counter (bumped on every register/unregister).
  • PluginManager (packages/core/src/plugins/plugin-manager.ts) — the host-side driver: discovery, manifest validation, permission/consent gating, code loading, dependency resolution, and register/unregister into the registry.

The core calls into plugins at fixed seams:

Call site File What it does
pluginRegistry.collectTools() build-tools.ts Plugin tools are registered into customToolRegistry and assembled.
pluginRegistry.applySystemPromptTransforms() system.ts The system prompt is threaded through all plugin transforms in order.
pluginRegistry.applyLifecycleHook() presentAssistantMessage.ts, Task.ts Tool-call / ask / task-lifecycle hooks fire.
pluginRegistry.dispatchEvent() extension.ts Every telemetry event is forwarded to plugin onEvent observers.

Because plugins load asynchronously (fire-and-forget, off the task-start hot path), the registry's revision is folded into Task._buildToolsCacheKey (Task.ts) so the per-task tool catalog rebuilds when an async-loaded plugin registers — otherwise a late-registering plugin tool would be absent from the catalog the model sees.

flowchart TD
    subgraph HOST["Shofer host — extension / CLI / server"]
        direction TB
        PM["PluginManager<br/>discovers bundled + global + project<br/>validates plugin.json<br/>enforces permissions, consent, dependencies<br/>loads code — esbuild transpile for .ts<br/>enable / disable / reload / uninstall"]
        PR["PluginRegistry<br/>collectTools<br/>applySystemPromptTransforms<br/>applyLifecycleHook, dispatchEvent<br/>revision"]
        UIR["UI registry — ui-registry.ts<br/>webview regions, PluginPanelManager"]
        CON["Mode / skill / command / rule<br/>and MCP-config contributions"]
        CORE["Shofer core — Task, tools, prompts"]
        PM --> PR
        PM --> UIR
        PM --> CON
        PR --> CORE
        UIR --> CORE
        CON --> CORE
    end
Loading

Directory layout

<extension>/dist/plugins/          ← bundled first-party plugins (shipped in the build)
~/.shofer/plugins/                 ← global plugins (all workspaces)
<workspace>/.shofer/plugins/       ← project plugins

  my-plugin/
    ├── plugin.json          ← manifest
    ├── index.ts             ← entry point (or index.js); optional (declarative-only plugins omit it)
    ├── tools/               ← tool definitions
    ├── modes/               ← mode YAML files
    ├── skills/              ← SKILL.md files
    ├── commands/            ← slash command .md files
    └── ui/                  ← built UI bundles (optional)

4. Plugin Manifest

plugin.json

{
	"name": "my-org-ci",
	"version": "1.0.0",
	"description": "CI/CD integration — Jenkins/GitLab tools, deploy modes, pipeline UI.",
	"author": "DevOps Team",
	"homepage": "https://github.com/my-org/shofer-ci-plugin",
	"license": "MIT",

	// Minimum Shofer version required, and the plugin-API version this targets.
	"shoferVersion": ">=1.0.0",
	"shoferPluginApiVersion": "1",

	// Entry point (relative to plugin dir). If omitted, the plugin is purely
	// declarative (modes, skills, commands, MCP configs — no code hooks).
	"main": "index.ts",

	// Permissions the plugin requests (all default to denied).
	"permissions": {
		"tools": true, // Register tools
		"systemPrompt": true, // Transform the system prompt
		"modes": true, // Contribute mode definitions
		"skills": true, // Contribute skills
		"commands": true, // Contribute slash commands
		"rules": true, // Contribute rules markdown
		"mcpServers": true, // Declare MCP server configs
		"mcpInvoke": true, // Invoke tools on connected MCP servers via ctx.mcp
		"ui": ["chat-input-toolbar", "task-header"], // UI regions to contribute to
		"lifecycle": true, // Hook into task lifecycle
		"events": true, // Observe telemetry/lifecycle events
		"network": ["https://jenkins.my-org.com"], // Fetch allowlist
		"filesystem": ["./ci-config/"], // Host-path fs allowlist
		"ai": true, // Host LLM/embeddings (billed; consented separately)
		"agent": true, // Deliver into a task's mailbox (ctx.agent.deliver) + job control
		"task": true, // Task control via ctx.task: markers, rewind, setCwd, openTask
		"editor": true, // Multi-file diff viewer via ctx.host.editor
	},

	// Bundled (first-party) only: ship enabled rather than waiting to be opted into.
	"defaultEnabled": false,
	// Override the 500ms per-hook budget for THIS plugin's lifecycle hooks (max 60000).
	"hookTimeoutMs": 500,

	// Declarative contributions (no code needed for these).
	"contributes": {
		"modes": [
			{ "slug": "deploy", "name": "🚀 Deploy", "roleDefinition": "...", "tools": ["read", "execute", "mcp"] },
		],
		"skills": [{ "name": "deploy-to-staging", "description": "Deploy the current branch to staging" }],
		"commands": [
			{ "name": "deploy", "description": "Deploy the current project", "argumentHint": "<environment>" },
		],
		"mcpServers": {
			"jenkins": { "type": "streamable-http", "url": "https://jenkins.my-org.com/mcp" },
		},
		"rules": [{ "path": "rules/deploy-rules.md", "modes": ["deploy", "code"] }],
		"ui": [{ "region": "chat-input-toolbar", "entry": "ui/badge.js" }],
	},

	// Other plugins that must be installed + enabled for this one to activate.
	"dependencies": ["git-integration"],

	// User-configurable settings for this plugin (schema drives the Settings form).
	"config": {
		"type": "object",
		"properties": {
			"jenkinsUrl": { "type": "string", "description": "Jenkins base URL" },
			"defaultEnvironment": { "type": "string", "enum": ["staging", "production"], "default": "staging" },
		},
	},
}

Manifest validation

The manifest is validated against a Zod schema (pluginManifestSchema) in @shofer/types. Unknown fields are rejected (fail-closed). The permissions block is the security contract — every plugin API call is checked against the declared permissions. Plugin modes, commands, and skills are namespaced <plugin>:<name> (see §5), so contributor collisions are impossible by construction — there is no last-installed-wins tie-break.

permissions.ai

permissions.ai: true requests host LLM/embeddings access — the ctx.ai surface (§5.11). It is unlike the other permission flags: it costs the user money (billed model calls on their configured provider account). The manifest grant is necessary but not sufficientctx.ai goes live only after a separate, explicit consent (§7, "Billed AI calls consent"), distinct from the plain enable toggle. A plugin that declares permissions.ai but has not been AI-consented gets a denying ctx.ai (every call throws + warns); a plugin that never declared it gets no ctx.ai at all. The plugin never receives raw API keys — only an opaque ApiHandler the host constructs.


5. Extension Points

5.1 Tools (registerTools)

A plugin returns CustomToolDefinition[]; they are registered into the shared customToolRegistry (custom-tool-registry.ts) with source: "plugin" and plugin attribution (pluginName) for the UI and auto-approval.

Plugin tools reach and execute through the model regardless of the customTools experiment flag. That experiment gates file-based custom tools, but plugin-source tools are a first-class capability: customToolRegistry.isDispatchable(id, experimentOn) and getDispatchable(id, experimentOn) return true/the definition for any source: "plugin" tool unconditionally (t.source === "plugin" || experimentOn). The model-facing wiring uses these — validateToolUse (via isValidToolName plus the mode allow-list) and the presentAssistantMessage dispatch — so a plugin tool is callable even with the experiment off.

Because plugins register asynchronously, the registry's revision (§3) is part of Task._buildToolsCacheKey, forcing the tool catalog to rebuild when a late plugin registers (else a plugin tool such as ask_live_memory would be missing from the catalog).

The path from a plugin's registerTools to a model-issued call:

flowchart LR
    P["plugin.registerTools(ctx)<br/>CustomToolDefinition[]"]
    CT["pluginRegistry.collectTools()"]
    REG["customToolRegistry.register(def, 'plugin')<br/>source: plugin, plus pluginName"]
    BT["build-tools.ts<br/>getAllSerialized → the catalog the model sees"]
    CK["Task._buildToolsCacheKey<br/>folds in pluginRegistry.revision"]
    V["validateToolUse<br/>isDispatchable(id, experimentOn)"]
    D["presentAssistantMessage<br/>getDispatchable(id, experimentOn) → execute"]

    P --> CT --> REG --> BT
    CK -->|"a late register bumps revision — rebuild"| BT
    BT --> V --> D
Loading

5.2 System Prompt Transform (transformSystemPrompt)

Plugins chain in registration order (or manifest priority). Each receives the prompt-so-far and the PluginContext and returns a new prompt; a throwing plugin is skipped. PluginContext carries:

export interface PluginContext {
	readonly workspacePath?: string
	readonly mode?: string
	readonly taskId?: string
	readonly parentTaskId?: string // spawning parent's id, when the task is a subtask
	readonly rootTaskId?: string // the delegation tree's root, when the task is a subtask
	readonly cwd?: string
	readonly config?: Record<string, unknown> // validated, default-merged plugin settings
	readonly host?: PluginHost // RESTRICTED, permission-checked host surface (NOT the full getHost())
	readonly ai?: PluginAi // host LLM/embeddings (only with permissions.ai + consent)
	readonly storage?: PluginStorage // per-plugin persistent dir
	readonly agent?: PluginAgent // proactive agent-steering (only with permissions.agent)
	readonly ui?: PluginUiSender // push to the plugin's own UI / open a panel
}

host is the restricted PluginHost (fs/fetch/notifier/env/watch/log), scoped to the plugin's permissions and checked at runtime by the sandbox — not the full getHost() HostBridge.

5.3 Modes (contributes.modes)

A plugin ships mode definitions in its manifest; they merge into the mode resolution chain alongside .shofer/shofermodes — which is now the only source of modes, since Shofer's own six are themselves a plugin's contribution (the bundled builtin-config plugin, plugins/builtin-config/docs/modes.md). effectiveModes() in packages/core/src/plugins/plugin-modes.ts performs that merge.

Each plugin mode is emitted with a qualified slug of <plugin>:<authoredSlug> and tagged source: "plugin" + pluginName. The authored slug in the manifest stays natural (no :); the qualified form is how the mode is addressed/switched-to. Namespacing makes plugin↔plugin slug collisions impossible — there is no precedence tie-break. ModeConfig.source is z.enum(["global", "project", "plugin"]) with a sibling pluginName?.

The one exemption: a bundled plugin may set unqualifiedContributions: true in its manifest, and its modes — and its slash commands — then keep their authored names, registered at the built-in precedence tier. It exists solely for a plugin shipping the platform's own defaults, whose names are a public contract — the built-ins must stay code/architect/…, not builtin-config:code, and /merge-worktree must not become /worktrees:merge-worktree. Scope is enforced: a global or project plugin declaring it is ignored, because an unqualified third-party name could silently shadow a built-in. A user or project mode of the same slug replaces the contributed one in place, so overriding a built-in neither duplicates it nor reorders the mode picker.

A plugin mode may set private: true: it is switch-able by its qualified slug (the agent can enter it) but hidden from every user-facing surface (mode selector, Plugins panel) — e.g. a browser plugin's verifier mode the agent runs but the user never picks. A private mode still governs its subtask's tools once entered. (Implemented in plugin-manager.ts getContributedModes; mode.ts source/pluginName/private.)

5.4 Skills (contributes.skills)

A plugin ships SKILL.md files under skills/, each declared in the manifest ({ name, description, private? }). They are discovered alongside .shofer/skills/ and ~/.shofer/skills/. A plugin skill is qualified <plugin>:<name> purely at the resolution/addressing layer (qualifiedSkillName() in @shofer/types): the on-disk directory name and the SKILL.md frontmatter name stay spec-compliant (no :), while the model lists and invokes the skill by its qualified name — so a plugin skill can never shadow a file skill. A private: true skill is invocable by its qualified name but excluded from user-facing enumeration (the skills UI, the slash-command menu); the manager reports private names to the scanner via getContributedSkillDirs().privateNames.

5.5 Slash Commands (contributes.commands)

A plugin ships .md command files under commands/, each declared ({ name, description?, argumentHint?, private? }) and discovered alongside .shofer/commands/. A plugin command is registered and invoked as <plugin>:<command> — the bare name never resolves, so it cannot collide with a built-in/user command or another plugin's. A private: true command is invocable by its qualified name but filtered out of the command palette. (Implemented in services/command/commands.ts.)

5.6 MCP Integration

A plugin meets MCP from two directions, and they are separate grants: it can contribute a server (permissions.mcpServers, Mode A) and it can invoke one (permissions.mcpInvoke, Mode B). Two further modes — a plugin that IS an in-process server, and Shofer as an aggregating server host — are designed and deferred (Mode C, Mode D below).

Mode A — Plugin bundles an MCP server (declarative)

The plugin declares MCP server configs in contributes.mcpServers; McpHub connects to them alongside .shofer/mcp.json. The server runs as a separate process managed by McpHub — standard MCP protocol, no Shofer-specific code.

{
	"contributes": {
		"mcpServers": {
			"my-database": {
				"type": "stdio",
				"command": "worker",
				"args": ["${SHOFER_PLUGIN_ROOT}/server.js"],
				"env": { "DB_PATH": "${SHOFER_PLUGIN_DATA}/db.sqlite" },
			},
		},
	},
}

Use case: wrap an existing MCP server and add Shofer-specific content (a mode, a skill, a status badge).

Mode B — Plugin invokes a connected server (ctx.mcp)

A plugin granted permissions.mcpInvoke gets ctx.mcp.callTool(serverName, toolName, args?, opts?) — a call against any server the host has connected, resolving with the server's raw McpToolCallResponse (its content array plus isError). The result is returned unshaped: no truncation, no image extraction, no chat row, because a plugin is a program and not a chat transcript.

const result = await ctx.mcp?.callTool("memory", "search", { query: "deploy" }, { taskId: ctx.taskId })

The call rides the host's own McpHub.callTool — the same entry point use_mcp_tool uses — so the plugin reaches the same server processes, the same connections, and the same per-call header machinery (mcp.md, the "resolve-mcp-call-headers" broadcast). opts.taskId is what attributes the call to a run: it travels in the request's _meta and is what the per-call header seam resolves a run's credential from. It is deliberately not inferred from "the host's current task" — guessing would attach another run's credential to this call, and no credential is a better failure than the wrong one. opts.signal gives the call cooperative cancellation.

It bypasses the ask/approval pipeline by design. The agent's use_mcp_tool is a model's request and is gated per tool group by checkAutoApproval; a plugin's call is trusted host-side code the user installed and granted, and it may run from a background service with no task, on a headless node, with nobody to ask. So the manifest grant IS the gate, enforced where the context is assembled (PluginManager.buildPluginMcp) exactly like ctx.agent and ctx.task:

State ctx.mcp
granted + host wired its MCP seam live surface (createPluginMcp)
ungranted, host wired the seam denying stub — callTool throws + warns
no seam wired (pure-core embedding) absent entirely

permissions.mcpInvoke is separate from permissions.mcpServers on purpose: contributing adds ONE server the plugin ships, invoking reaches EVERY server the host is configured with — the user's, the project's, the org's, and other plugins' — so it is a strictly larger grant and cannot ride along on the smaller one.

The seam is PluginMcpProvider (plugin-mcp.ts), supplied by the host so @shofer/core never reaches for a hub; ShoferProvider wires it from getMcpHub(), resolving the hub per call because the hub is built asynchronously while the plugin manager is built once. shofer serve runs the same provider, so a headless node's plugins get the same live surface.

Use case: an integrator's headless worker plugin — one that runs agent tasks as steps of a durable job — invoking MCP tools directly, where there is no chat to render into and no human standing by to approve each call.

Mode C — Plugin IS an in-process MCP server (programmatic) — deferred

Deferred, not built. A plugin would register itself as an MCP server in-process — no separate process, no stdio/SSE transport — with its tools exposed through the MCP tool protocol but executing in the host with full PluginContext access. Advantages over Mode A: no process management, full context access, lower latency, and the same tools could also participate directly via registerTools; McpHub would treat in-process servers identically to stdio/SSE ones (tools in the catalog, resources in access_mcp_resource, the same group-based auto-approval). Nothing of this exists today — a plugin that wants its own tools in the catalog uses registerTools (§5.1), and a plugin that wants them reachable over MCP ships a server via Mode A.

Mode D — Shofer as MCP server host (external) — deferred

Deferred, not built. Shofer would expose all registered tools — native, plugin-contributed, and MCP-aggregated — as a single MCP server endpoint external clients (Claude Desktop, other editors) connect to, via an adapter over the transport layer (packages/core/src/transport/), turning Shofer into a tool aggregator. No such adapter exists today.

Mode What Process model Plugin code? Status
A Plugin bundles an MCP server Separate process (stdio/SSE) Server code in the package Shipped
B Plugin invokes a server Host's hub connections ctx.mcp.callTool() Shipped
C Plugin IS an MCP server In-process (host) Deferred, not built
D Shofer as MCP server host Shofer process No (infrastructure) Deferred, not built

5.7 Rules (contributes.rules)

A plugin ships rules markdown, optionally scoped to specific modes. These are injected into the system prompt via addCustomInstructions().

5.8 UI Components (permissions.ui)

The key differentiator from MCP. A plugin contributes React components that render in designated Shofer UI regions:

Region ID Location What plugins render
chat-input-toolbar ChatTextArea toolbar Buttons, chips, status badges/popovers
task-header TaskHeader (expanded) Status badges, info rows, action buttons
settings-tab SettingsView (per-plugin panel) Full plugin settings panel
chat-message-addon A plugin_marker row in the chat The plugin's own timeline row (see §5.13)
chat-footer Between the chat and its input A per-task summary the user acts on (the file-changes panel)
sidebar-panel New panel in the Shofer sidebar Custom dashboard/view

The kit — @shofer/plugin-ui. The same import map that resolves a bundle's react also resolves @shofer/plugin-ui to the host's component kit (Button, Dialog*, Popover*, SearchableSelect, cn, …) and to usePluginTranslation, which reads the plugin's own locales/<lang>.json as the i18next namespace plugin:<name>. A plugin's UI is therefore built from the real components and follows the user's language, instead of hand-rolled look-alikes that drift from the product and behave differently under keyboard and focus. Surface: webview-ui/src/plugin-ui/index.ts (runtime), webview-ui/public/plugin-host/plugin-ui.js (the served shim), plugins/plugin-ui.d.ts (what plugins typecheck against) — a spec fails when the three stop agreeing.

Loading model — dynamic import(), not iframe. A plugin UI component loads into the webview by dynamic import() with a restricted API surface (PluginUIApi). This is deliberate: sharing the host's React instance + theme is what keeps hooks/context working. A component that throws while rendering is caught by an error boundary and unmounted; the host UI keeps working. The PluginUIApi a component receives is scoped to its own plugin:

  • postMessage(msg) — send to this plugin's extension-side code (tagged with the plugin name; routes only there). Received via the onUiMessage(message, ctx) hook.
  • onMessage(listener) — subscribe to messages addressed only to this plugin (namespaced — a plugin can neither observe nor spoof another's channel). Returns an unsubscribe fn.
  • context — read-only { region, pluginName, task?, config?, theme? } (theme = VS Code CSS vars).

External UI bundles. A third-party plugin ships its own compiled UI module by pointing a granted region at a built entry with contributes.ui: [{ region, entry }] (the region must also be in permissions.ui — that is the grant; fail-closed). entry is an ESM file relative to the plugin root (e.g. ui/toolbar.js). The extension adds the plugin dir to the webview's localResourceRoots and resolves the entry with asWebviewUri to a local vscode-webview:// URL (surfaced as PluginUiContribution.source); the webview dynamic-imports it (pluginComponentResolver.ts). Arbitrary external hosts stay blocked — only files under the plugin dirs are served, and the webview CSP uses strict-dynamic + a nonce, so the nonced host script may import the same-origin plugin module without weakening the policy. A granted region without a contributes.ui entry falls back to a co-bundled/first-party component.

The build contract — externalize React. The bundle must not bundle its own React: the host injects an import map (src/core/webview/pluginHostImportMap.ts) so react, react-dom, react/jsx-runtime (+ react/jsx-dev-runtime, react-dom/client) resolve to the host's running instance (a second copy silently breaks hooks). The import map targets shared-React shims shipped at webview-ui/build/plugin-host/* (served as vscode-webview:// resources). Build the entry as an ES module marking those packages external (esbuild --format=esm --external:react …), default-exporting a component that takes a single { api: PluginUIApi } prop. See PLUGINS.md for the exact build invocation.

// my-plugin/ui/toolbar.jsx → built to ui/toolbar.js
import { useEffect, useState } from "react" // resolves to the host's React via the import map
export default function Toolbar({ api }: { api: PluginUIApi }) {
	const [reply, setReply] = useState("")
	useEffect(() => api.onMessage((m) => setReply(String(m))), [api])
	return <button onClick={() => api.postMessage({ deploy: api.context.task?.taskId })}>Deploy {reply}</button>
}

Sending the user to your own controls — ctx.ui.openSettings(). Reveals Settings → Plugins, where the plugin's enable toggle, config form and billed-AI consent live. It exists for the state a defaultEnabled + permissions.ai plugin starts in: enabled but unable to act, where the UI's job is to say so and offer the fix. Live Memory's badge renders a NeedsApproval state whose only action calls this.

Standalone panels — ctx.ui.showPanel({ title, region }). Beyond in-region mounts, a plugin can open its UI bundle in a standalone editor panel (a WebviewPanel tab beside the editor) via ctx.ui.showPanel(...) (PluginPanelManager.ts): the panel opens with ViewColumn.Beside + preserveFocus, reveals-if-already-open, and is disposed on close. It hosts the bundle for the requested region (default sidebar-panel) through a standalone plugin-panel webview entry (webview-ui/src/plugin-panel/main.tsx), which injects window.__shoferPluginPanel = { bundleUri, pluginName, region, task } plus the same shared-React import map. The panel is wired to the same scoped, name-tagged channel as the sidebar mount — postMessage pushes and onUiMessage reach it too, and ShoferProvider.postPluginUiMessage fans a plugin's ctx.ui state out to every open panel. (This standalone panel replaced an earlier in-sidebar drawer.)

(Implemented in ui-registry.ts, pluginComponentResolver.ts, PluginSlot, PluginPanelManager, and ShoferProvider's localResourceRoots/asWebviewUri

  • import-map wiring.)

5.9 Lifecycle Hooks (permissions.lifecycle)

A plugin granted permissions.lifecycle can hook into task lifecycle and tool execution:

export interface LifecycleHooks {
	/** Observe a task starting (ctx.prompt = the initial prompt). Fire-and-forget observer. */
	beforeTaskStart?(context: TaskLifecycleContext): void | Promise<void>
	/** Observe a task completing/aborting (ctx.reason = "completed" | "aborted"). Observer. */
	afterTaskComplete?(context: TaskLifecycleContext): void | Promise<void>
	/** Before a tool executes. Can block or modify the call. */
	beforeToolCall?(
		toolName: string,
		args: Record<string, unknown>,
		context: PluginContext,
	): Promise<{ allow: boolean; modifiedArgs?: Record<string, unknown>; reason?: string }>
	/** After a tool executes. Can modify the result. */
	afterToolCall?(
		toolName: string,
		args: Record<string, unknown>,
		result: string,
		context: PluginContext,
	): Promise<string | void>
	/** Before an ask is shown. Can auto-approve/deny/edit. */
	beforeAsk?(
		askType: string,
		payload: unknown,
		context: PluginContext,
	): Promise<{ decision?: "approve" | "deny" | "ask"; text?: string } | void>
	/**
	 * How that ask ended. The observer half `beforeAsk` cannot be: `beforeAsk` runs BEFORE
	 * the host's own auto-approval decision (so a plugin may pre-empt it), which means a
	 * `beforeAsk` observer can watch an ask be raised and never learn whether a human
	 * decided it. Only `outcome: "answered"` means a decision was made — `"superseded"`
	 * and `"aborted"` mean the wait ended, never that anything was refused. Observer.
	 */
	afterAsk?(
		info: {
			taskId: string
			askId: string
			askType: string
			outcome: "answered" | "superseded" | "aborted"
			response?: string
			decidedBy?: "user" | "auto-approval" | "plugin"
			autoApproved?: boolean
		},
		context: PluginContext,
	): void | Promise<void>
	/** An LLM request is about to be issued — the real wall-clock start of a model call. Observer. */
	onApiRequestStart?(
		info: {
			taskId: string
			requestIndex: number
			model: string
			apiProtocol: "anthropic" | "openai"
			retryAttempt: number
		},
		context: PluginContext,
	): void | Promise<void>
	/**
	 * That request finished (normally, cancelled, or in error), with the host's OWN
	 * per-request record — time-to-first-byte, the offset at which output generation
	 * began (the end of the model's reasoning phase), the retry attempt, tokens, cost and
	 * the tool calls it produced. Handed over rather than reassembled, so an observer does
	 * not build a second, worse copy of a record that already exists. Observer.
	 */
	onApiRequestFinish?(info: ApiRequestFinishedPayload, context: PluginContext): void | Promise<void>
	/**
	 * The user sent a message into a task (a step the tool hooks cannot see) — including
	 * the message that RESUMES a task from history, which is how a conversation's second
	 * and later turns arrive. `trace` is the W3C trace context of the request that
	 * delivered it (`ShoferApi.sendMessage`'s `trace`), and it is the twin of
	 * `TaskLifecycleContext.trace`: `beforeTaskStart` fires when a task is CREATED, so
	 * without this every turn after the first would lose the caller's context. Observer.
	 */
	onUserMessage?(
		info: { taskId: string; text?: string; imageCount?: number; trace?: TraceContext },
		context: PluginContext,
	): void | Promise<void>
	/** The agent completed a narration text block (its prose between tool calls). Observer. */
	onAssistantMessage?(
		info: { taskId: string; text: string; turn?: number },
		context: PluginContext,
	): void | Promise<void>
	/**
	 * The task's chat timeline is about to be rewound to `info.ts` (a message delete/edit,
	 * or a restore). **Awaited before the messages disappear**, so a plugin holding state
	 * anchored to them rolls it back while its anchor still exists. `info.restoreState`
	 * says whether the user asked for that out-of-band state at all — `false` is a
	 * chat-only rewind and must not touch the workspace.
	 */
	onTimelineRewind?(
		info: { ts: number; taskId: string; operation: "delete" | "edit" | "restore"; restoreState: boolean },
		context: PluginContext,
	): void | Promise<void>
	/** A task was deleted from history — drop per-task state kept OUTSIDE its task dir. Observer. */
	onTaskDeleted?(info: { taskId: string; workspacePath?: string }, context: PluginContext): void | Promise<void>
	/**
	 * A tool is about to mutate a workspace file, with the file's content as it is right
	 * now (`before === undefined` ⇒ it does not exist yet). **Awaited** — a "before"
	 * snapshot taken after the write is worthless.
	 *
	 * It exists because only the tool knows what it is about to touch: a path may be
	 * embedded in a patch body, resolved by the language server (a rename hitting N
	 * files), or be a move's destination. Deriving that from `beforeToolCall`'s arguments
	 * would mean re-implementing every tool's semantics — and silently missing files when
	 * one changes.
	 */
	beforeFileEdit?(edit: { path: string; before?: string }, context: PluginContext): void | Promise<void>
	/** A tool finished mutating a workspace file; the new content is on disk. Observer. */
	afterFileEdit?(edit: { path: string }, context: PluginContext): void | Promise<void>
}

The file-edit pair is what the bundled file-changes plugin (plugins/basics/docs/file-changes.md) is built on: core publishes the edit, the plugin keeps the two copies that make a diff and a revert possible.

Per-plugin hook budget. Hooks run under a 500 ms default budget ({@link PLUGINHOOK_TIMEOUT_MS}); a plugin whose hook does work the agent must genuinely _wait for declares a manifest hookTimeoutMs (capped at 60 s) that overrides it for that plugin only. The bundled basics plugin (its checkpoints feature) is the motivating case: snapshotting a large workspace inside beforeToolCall takes seconds, and finishing late is useless because the file has already been written.

This enables policy plugins (block rm -rf, require review before attempt_completion), integration plugins (auto-approve known-safe commands, log to a SIEM), and workflow plugins (observe task start, post results externally).

Reducer semantics + isolation. Plugins run in registration order, threading state: beforeToolCall returns { allow, modifiedArgs?, reason? } (a modifiedArgs threads into later hooks and the tool; the first allow: false short-circuits the tool, surfaced like a denied tool with reason); afterToolCall returns string | void (a returned string replaces the result for later hooks + the model); beforeAsk returns { decision?, text? } | void (text edits the surfaced ask, decision of "approve"/"deny" auto-answers, "ask"/absent proceeds); beforeTaskStart/afterTaskComplete are fire-and-forget observers. Every hook is bounded by a 500 ms per-hook timeout with per-plugin error isolation — a hook that throws or exceeds its budget is skipped with a shown+logged warning and its would-be mutation dropped, so it can never stall or crash the agent loop.

Where each hook point sits in one turn — the reducer wrappers on PluginRegistry are the only entry points, and the two task-lifecycle observers are invoked without awaiting so a plugin never delays task start or completion:

sequenceDiagram
    autonumber
    participant T as Task
    participant PAM as presentAssistantMessage
    participant PR as PluginRegistry
    participant PL as Plugins, in registration order
    participant TL as Tool handler

    T-)PR: notifyBeforeTaskStart(context)
    PR->>PL: beforeTaskStart — observer only
    PAM->>PR: applyBeforeToolCall(name, args, ctx)
    PR->>PL: beforeToolCall — modifiedArgs thread through
    alt a plugin returns allow false
        PR-->>PAM: allow false plus reason — the tool is short-circuited
    else allowed
        PR-->>PAM: allow true, modifiedArgs when something changed
        PAM->>TL: execute with the threaded args
        TL->>T: askToolApproval — Task.ask
        T->>PR: applyBeforeAsk(askType, text, ctx)
        PR->>PL: beforeAsk — approve / deny / ask, and an optional text edit
        TL-->>PAM: result string
        PAM->>PR: applyAfterToolCall(name, args, result, ctx)
        PR->>PL: afterToolCall — a returned string replaces the result
    end
    T-)PR: notifyAfterTaskComplete(context)
    PR->>PL: afterTaskComplete — observer only
    Note over PR,PL: every hook runs under a 500 ms per-hook timeout<br/>with per-plugin error isolation
Loading

5.10 Events (onEvent)

Every telemetry event is forwarded to plugin onEvent observers. The PluginEvent carries a typed name, optional properties, taskId, and timestamp.

5.11 Host Capabilities (ctx.*)

Beyond the restricted ctx.host (fs/fetch/notifier/env/watch/log), a plugin context carries optional host capabilities, all off by default — a plugin that uses none is byte-for-byte identical to a no-capability plugin, and with no plugins the host is unchanged.

  • ctx.host.log — always-available per-plugin logger. Unlike the gated capabilities below, ctx.host.log is present for every plugin. It is a PluginLogger (@shofer/types) writing to a Plugin:<name> Log category (Settings → Logging), backed by plugin-log.ts (getPluginLogger(name), PLUGIN_LOG_CTX_PREFIX = "Plugin:"). The ctx tag is the single source of truth for the category name; unlike ctx.host.notifier (user-facing toasts) this goes only to the log/output channel. Lets a user isolate one plugin's logs from the rest.

  • ctx.host.metrics — always-available instruments. increment/gauge/observe into the host's in-process registry (metrics/registry.ts). Ungated for the same reason as the logger: a number that never leaves the machine is as harmless as a log line, and a plugin that owns a subsystem has to be able to publish what an operator watches. A no-op on a host with no metrics pipeline.

  • ctx.host.telemetry — product events (permissions.telemetry). Data leaves the machine, which is what separates it from the logger and the metrics, so it is a grant. Three host-side rules make it safe to expose:

    • The catalog stays core's. Every plugin event arrives as the single TelemetryEventName.PLUGIN_EVENT entry with plugin/event properties, so a plugin can neither mint a top-level event nor shadow one of core's — and those two keys are stripped from its own properties, so it cannot misattribute events either.
    • Properties are scrubbed to primitives (strings truncated at 256 chars, at most 20 keys). A plugin sees paths, code and prompts; an Error.stack or a spread object is dropped at the boundary rather than trusted to each plugin author.
    • The user's opt-in still applies — the seam routes through TelemetryService, behind the TELEMETRY_ENABLED build flag and the user's TelemetrySetting.

    Ungranted it warns and returns rather than throwing (every other denied capability throws): reporting an error must not fail differently because reporting was refused. Emitted by the bundled rag-indexing plugin for indexing errors and segment reuse.

  • ctx.ai — provider access. ctx.ai.buildHandler(profileRef?) (async — profile resolution via ProviderSettingsManager.getProfile is async) resolves a host-configured provider profile (the default when profileRef is omitted) and returns the same ApiHandler buildApiHandler returns. ctx.ai.embed(texts, profileRef?) returns number[][] from the embedder the bundled rag-indexing plugin has configured (the host forwards to it — the thinnest seam that gives a plugin real vectors; profileRef is accepted for symmetry but embeddings follow the Code Index config). ctx.ai.hasConsent() is a read-only accessor for whether calls will actually run. The plugin never sees raw API keys — only the handler. Access is gated on permissions.ai and the billed-calls consent (§7): ungranted ⇒ ctx.ai absent; granted-but-unconsented ⇒ a denying stub (throw + warn). Construction is host-side (it needs the extension's ProviderSettingsManager), injected into PluginManager via a PluginAiProvider seam (plugin-ai.ts) so @shofer/core stays host-agnostic.

  • ctx.storage — per-plugin persistent dir. ctx.storage.dir is <globalStorage>/plugins/<name>/; the scoped fs (readFile/writeFile/list/exists/delete) is confined to it (traversal-blocked). Created lazily, survives restart, removed on uninstall. Works regardless of permissions.filesystem — it is the plugin's own sandbox. The base path is host-provided via PluginManagerOptions.storageBaseDir (plugin-storage.ts).

  • ctx.host.watch(pattern, cb) — scoped file watch. Gated by the plugin's permissions.filesystem scope — it only ever watches inside granted paths (one host FileSystemWatcher per granted root). Without a filesystem grant it is a deny + warn (no-op disposable). The callback is path-carrying: cb(event: { path, type: "create" | "change" | "delete" }). Host-backed via the HostWatcher/HostFileWatcher seam; disposed on plugin disable.

  • ctx.registerService({ name, start, stop }) — supervised background service. A long-lived service tied to plugin lifecycle: start() runs when the plugin is enabled+active, stop() on disable/uninstall/deactivate. PluginManager owns the registry (via PluginServiceSupervisor, plugin-services.ts), starts after load, stops on unregister, and isolates a throwing/hanging service (per-service start/stop timeout + shown/logged warning, never crash).

  • ctx.mcp.callTool(server, tool, args?, opts?) — invoke a connected MCP server. Gated on a dedicated permissions.mcpInvoke grant (invoking spans every server the host is configured with, not just the plugin's own): ungranted-but-seam-wired ⇒ denying stub; no seam ⇒ absent. Host-side behind a PluginMcpProvider seam (plugin-mcp.ts) wired in ShoferProvider.getPluginManager against getMcpHub(), so the call rides the same hub — and the same per-call header machinery — the agent's own use_mcp_tool does. The call does not enter the ask/approval pipeline; the grant is the gate. Full contract in §5.6.

  • ctx.agent.deliver(envelope) — the ONE delivery door. A plugin (from a background service, a ctx.host.watch callback, or a lifecycle hook) can PROACTIVELY put a message in front of a task — a bus event, an A2A frame, a Temporal owner's instruction, or its own observation ("the deploy just failed, here's the log"). The message becomes an envelope in that task's mailbox (task_messaging.md): persisted, deadline-bounded, listed in the environment_details digest on the task's next request, and read with wait.

    There is no delivery MODE to choose. What four modes used to express is carried by fields the caller fills in:

    Field Decides
    kind notification (no answer expected) or request (a reply is), or reply
    wake whether a task whose loop has STOPPED is resumed for this message — a stopped task is rehydrated from history if need be
    deadline absolute epoch ms; past it the envelope expires out of the box unread
    plane informational — local / bus / a2a / temporal, rendered in the digest

    The host fills to (the resolved target task) and sent_at, and mints id when the caller supplied none. A caller owning an upstream idempotency key — an A2A message_id, a Temporal message id — MUST pass it as id, because a mailbox already holding that id acknowledges the delivery without appending it again; that is what makes a retry safe. taskId names the target (default: the host's current task).

    It resolves with the envelope as ACCEPTED, once the box has validated and persisted it — so a caller acknowledging an upstream delivery should do so only after this promise resolves, and the receipt then means "in the box" rather than "seen by a plugin". It rejects when the box refuses the envelope (full, expired, addressed elsewhere) and — deliberately — when there is no target task at all: a delivery must never silently become a billed spawn nobody asked for. A plugin that wants a new task says so with spawn.

    Gated on a dedicated permissions.agent grant (steering the agent has billed/behavioral impact): ungranted-but-seam-wired ⇒ denying stub; no seam ⇒ absent. Host-side behind a PluginAgentProvider seam (plugin-agent.ts) mirroring PluginAiProvider, wired in ShoferProvider.getPluginManager against the provider's task stack and deliverToTask.

  • ctx.agent.registerMailboxTransport(transport) — how a message LEAVES the node. The mirror of deliver. The core mailbox resolves an outbound to locally (a live task, or one it can rehydrate from history); an address that resolves to neither is offered to each registered transport in turn, and the first whose canRoute(to) accepts it owns the delivery. That is what lets the agent's one send_message tool address a peer across the A2A mesh without the tool knowing a mesh exists.

    canRoute is a synchronous predicate over the address alone, because it runs on the agent's send path and must not turn a validation into a network round trip; a transport that cannot tell from the id whether the peer is reachable answers true and fails in send. Registration returns an unregister function — a plugin whose service stops must call it, or the host keeps offering it envelopes it can no longer send. Same permissions.agent grant: a transport decides where an agent's messages go.

The one door, and where an envelope goes from it:

flowchart TD
    N["ctx.agent.deliver(envelope)"]
    G{"permissions.agent granted?"}
    STUB["denying stub — seam wired but ungranted<br/>absent entirely when there is no seam"]
    T{"target task"}
    X["reject — nothing to deliver to.<br/>Use ctx.agent.spawn for a new task"]
    L{"live instance?"}
    MB["the task's mailbox —<br/>validate, persist, emit delivered"]
    H{"resumable history?"}
    RH["persist first, then rehydrate dormant,<br/>register, queue the wake turn, start"]
    W{"envelope.wake"}
    DG["nothing more — the digest carries it<br/>on the task's next request"]
    WK["resume the stopped loop"]

    N --> G
    G -->|no| STUB
    G -->|yes| T
    T -->|"none — no taskId, no current task"| X
    T -->|resolved| L
    L -->|yes| MB
    L -->|no| H
    H -->|no| X
    H -->|yes| RH --> MB
    MB --> W
    W -->|false| DG
    W -->|"true, and the loop has stopped"| WK
Loading

5.12 Plugin Requests (handleRequest)

onUiMessage is fire-and-forget, which is the wrong shape for the common case where a plugin's UI needs an answer ("give me this diff", "list my markers"). A plugin implements handleRequest(method, params, ctx) and the caller awaits its result:

  • from its own UI, via api.request(method, params?, { mutates? }) (PluginUIApi) — the same scoped, name-tagged channel as postMessage, with correlation handled by the transport;
  • from the controller to a plugin running on a remote executor, via ShoferApi.pluginRequest(taskId, plugin, method, params) (shofer-api.md).

Unlike the observer hooks, a request is not timeout-guarded or error-isolated: a caller is waiting on the answer, so a throw (or an unknown plugin / missing handleRequest) propagates to it rather than becoming a silent undefined.

Broadcast requests. Core sometimes needs a fact that a feature owns without knowing which plugin — if any — provides it. pluginRegistry.requestAll(method, params) asks every plugin the same question and returns the answers; a plugin that does not recognise the method throws, which counts as "no answer" rather than an error. Five conventions are in use:

Question Answer Nobody answers
"task-stats" { insertions, deletions } A completed task gets no +/ badge.
"resolve-task-cwd" { cwd } or { error } The task runs in the workspace.
"resolve-task-placement" { dispatched: { taskId, address?, token? } } or { error } The task runs in-process, exactly as before.
"resolve-mcp-call-headers" { headers } The MCP call carries only its connection's own headers.
"resolve-model-call-headers" { headers } The model request carries only its provider's own headers.

"resolve-mcp-call-headers" is asked once per MCP tool call, with the server's name, scope, transport, URL and the task id — enough for a resolver to decide whether this is a server it should hand anything to at all. It exists because a transport's headers are bound once, at connect, from static config, while the connection is shared by every task the host runs: a value belonging to the RUN has no other way onto the wire. The resolved headers reach the transport's fetch through an AsyncLocalStorage, which is what keeps concurrent calls on one connection from seeing each other's (call-headers.ts). Two rules follow from what a header IS here:

  • No { error } channel, deliberately. A header is additive; a resolver that cannot produce one must degrade to the call it would have made anyway, so failing the tool call would invent an outage the seam exists to avoid. "Nothing to add" is { headers: {} }.
  • Headers the transport owns are refused — content negotiation, the MCP session and protocol identifiers, the SSE resumption cursor — because overriding one breaks the protocol rather than annotating the call. Two plugins claiming the same header name: the first answer wins and the rest are warned about, since letting registration order pick which credential goes out is the worse failure. stdio servers are never asked (a pipe has no headers; such a server reads the task id from _meta instead).

"resolve-model-call-headers" is its twin on the LLM side, asked once per model requestcreateMessage and completePrompt alike — from buildApiHandler, which is the one layer every provider and every caller passes through (api/call-headers.ts). It exists for the same reason: a provider's SDK client is built once from the API configuration and shared by every task, so its headers are the HOST's and can carry nothing that belongs to a run. Same AsyncLocalStorage mechanism, same "no error channel" rule, same first-answer-wins merge — and two rules of its own, because a model request carries a credential the host paid for:

  • The question names no URL. Each provider owns its own base-url setting and builds its client privately, so there is no endpoint core can state here. A resolver decides from the PROFILE — provider, model, operation — plus the task ids.
  • An answer can never authorize. The merge refuses authorization, x-api-key and the other credential and transport names outright, and the shared fetch only sets a header the request does not already carry, so the provider's own headers always win. That is what makes answering without a URL safe: a plugin annotates a model call and can neither re-point nor re-authenticate it. (A provider whose SDK takes no custom fetch — AWS Bedrock, Google GenAI — simply never sees the headers, which is the no-plugin behaviour.)

"resolve-task-cwd" and "resolve-task-placement" are the placement seam, and both share the opposite rule: an { error } answer aborts task creation, because a plugin that recognised the question and failed is not the same as one that stayed silent — running the task locally anyway would put the agent somewhere the user did not choose. A claimed task is created on another host; core does not create a local one and instead attaches to the returned reference (host-boundary.md).

Routing. A UI request is answered by the plugin instance on this host (ShoferProvider.resolvePluginUiRequestpluginRegistry.request), against the focused task. Reaching a plugin on a DIFFERENT host — the one running a task this view is attached to — is the separate, explicit ShoferApi.pluginRequest call above; nothing routes there implicitly. Two request-shape conventions survive as plugin-side conventions, declared in plugin.ts and interpreted by the plugin itself (see basics' main.ts):

Convention Meaning
method prefixed local: The UI is stating this must be answered where the UI runs — opening an editor/viewer, which a headless executor would silently no-op.
{ mutates: true } on api.request The request changes state rather than reading it.

5.13 Task control (ctx.task)

A plugin granted permissions.task can write to the task's chat timeline, rewind it, move it, and open a new one — what lets a plugin own a feature whose UX belongs in the conversation rather than in a side panel, and one that decides where work happens:

interface PluginTaskControl {
	marker(input: {
		kind: string
		text: string
		taskId?: string
		data?: Record<string, unknown>
		restorable?: boolean
		suppress?: boolean
	}): Promise<void>
	listMarkers(taskId?: string): Promise<PluginMarker[]>
	rewind(ts: number, opts?: { includeTargetMessage?: boolean }): Promise<void>
	setCwd(cwd: string, taskId?: string): Promise<void>
	openTask(opts?: { name?: string; text?: string; images?: string[]; cwd?: string; mode?: string }): Promise<string>
}
  • marker appends a say: "plugin_marker" message, persisted with the task and rendered by that plugin's chat-message-addon component (the host renders no chrome and never interprets kind/data). suppress keeps an anchor out of the rendered timeline; restorable is what makes the delete/edit dialog offer to roll back plugin-held state.
  • listMarkers reads them back in order — how a plugin recovers its anchors after a restart without keeping a second, drift-prone copy in ctx.storage. Scoped to the calling plugin: one plugin can neither see nor rewind another's.
  • rewind truncates the conversation to ts, reports the discarded API cost, and restarts the task. Rolling back anything outside the conversation is the plugin's own job, done before it calls this.
  • setCwd re-points an existing task (and every agent it starts afterwards) at another directory — the seam a plugin that manages directories needs, since only the host can move a live task. It throws when there is no task to re-point, rather than silently doing nothing.
  • openTask opens and focuses a NEW task, optionally in another directory, and resolves with its id. Without text the task is idle and waits for the user — the distinction from ctx.agent.spawn, which starts an agent run (prompt in, awaitable result out, permissions.agent because it bills). The basics plugin's worktrees feature uses it to put the user inside a checkout it just created.

Gated like the other capabilities: ungranted ⇒ a denying stub, no host seam ⇒ absent. The complementary direction is lifecycle.onTimelineRewind (§5.9), where the host is rewinding and the plugin follows.

ctx.host.editor (permissions.editor) rounds this out with the host's multi-file diff viewer, for a plugin that computes a set of before/after contents and needs it rendered rather than reinvented.

The bundled basics plugin's checkpoints feature (plugins/basics/docs/checkpoints.md) is built entirely from §5.9 + §5.12 + §5.13 — it is the worked example of a feature, not just a tool, living outside core.


6. Plugin Lifecycle

stateDiagram-v2
    [*] --> discovered
    discovered --> validated: pluginManifestSchema.safeParse
    discovered --> [*]: invalid manifest, skipped with a logged warning
    validated --> gated: enable toggle is the consent to run at all
    gated --> blocked: resolveDependencies — closure not all enabled and present
    blocked --> gated: the unmet dependency is enabled
    gated --> loaded: main transpiled or imported
    gated --> registered: declarative-only plugin, no main
    loaded --> registered: pluginRegistry.register(plugin, context)
    registered --> active: initialize, then services start
    active --> gated: reloadPlugin — config or AI consent changed
    active --> disabled: disable
    disabled --> gated: enable
    active --> [*]: uninstall — the plugin dir is deleted

    note right of blocked
        the toggle stays on but nothing registers;
        disabledReason names the unmet dependency
    end note

    note right of disabled
        tools, modes, skills and commands disappear,
        UI unmounts, MCP servers disconnect, services stop
    end note
Loading

Discovery

PluginManager scans three roots (see §8 for scope semantics):

  1. <extension>/dist/plugins/bundled first-party plugins.
  2. ~/.shofer/plugins/global plugins.
  3. <workspace>/.shofer/plugins/project plugins.

Each subdirectory with a plugin.json is a candidate. discover() fully rebuilds state and is idempotent, so a directory watcher can re-run it for hot reload.

Manifest validation

pluginManifestSchema.safeParse(manifest) — invalid manifests are skipped with a warning logged to the output channel.

Permission & consent check

Consent is per-plugin, via the enable toggle, not a per-permission dialog. A discovered plugin is disabled by default; enabling it in the Plugins panel (or --enable on CLI install) is the user's consent to run it at all. The single exception is a bundled (first-party) plugin whose manifest declares defaultEnabled — a shipped Shofer feature packaged as a plugin rather than an opt-in add-on (both bundled plugins declare it). It is on until the user says otherwise, and that "otherwise" is recorded explicitly (shofer.plugins.disabledPlugins) rather than inferred from absence, so it is never resurrected by the next discovery. defaultEnabled is ignored for non-bundled scopes — a third party can never enable itself.

defaultEnabled never implies the AI consent. The two gates stay independent: a default-enabled plugin declaring permissions.ai loads, but its ctx.ai is a denying stub until the user consents — so it must stay inert rather than contribute things that cannot work. Live Memory returns [] from registerTools, leaves the system prompt untouched, and starts no watcher or service until ctx.ai.hasConsent(); registering a tool that can only fail would cost every task's catalog its schema and burn a turn when the model tried it. Consent triggers reloadPlugin, so the plugin comes alive through the ordinary enable path. The manifest permissions then gate each capability at runtime — a contribution is only surfaced, and a code capability only reachable, when its permission is granted; fs/network/filesystem calls are checked against their allowlists. Enabling unregisters/re-registers the whole plugin. permissions.ai carries a second, independent consent (billed AI calls — see §7).

Organization suppression (forceDisabledPlugins). A deployment can suppress plugins outright — PluginManager.forceDisabledPlugins, fed from env by governanceDisabledPlugins() (SHOFER_DISABLED_PLUGINS, comma-separated — builtin-config is the entry that removes the built-in modes; a basics:<feature> entry is ignored here and read by the basics plugin itself as a feature switch). It is not a preference: a suppressed plugin never loads, setEnabled refuses to switch it on, and the user's recorded intent only takes effect if the org lifts it. This is what lets an org define the entire mode set from a config bundle.

Deployment activation (forceEnabledPlugins). The mirror image, fed from env by governanceEnabledPlugins() (SHOFER_ENABLED_PLUGINS, comma-separated). A host that was provisioned with a plugin — a pod whose reason to exist is running it, e.g. a headless Shofer that must register as a Temporal runner before any human attaches — comes up with it on. defaultEnabled cannot serve this case by design (bundled scope only: a third-party plugin must never enable itself), and seeding the host's persisted enable list would put deployment policy in per-host state where it drifts. Activation answers exactly one question — is this plugin on — and bypasses nothing else: manifest permissions, billed-AI consent, and fail-closed dependencies all still apply. Suppression wins when a name appears in both lists; setEnabled(name, false) records the intent but cannot switch it off, and says so.

Provisioned code (PluginDir.readOnly). Activation says whether a plugin runs; this says where its code lives, and it is a separate lever because the standard answer is unsafe for org policy. All three standard roots are writable by the person using the host: ~/.shofer/plugins and <cwd>/.shofer/plugins obviously so, and the plugin-declaration resolver materializes a declared source into <globalStorage>/plugins-cache/ — also under the user's home on a typical host. So a plugin an organization mandates can be edited, replaced with a stub, or moved aside by its own subject, and the digest a declaration pinned does not help: it is verified once at download, never against the resting copy.

SHOFER_PLUGIN_DIRS (governancePluginDirs(), a path.delimiter-separated list of absolute directories; relative entries are dropped rather than resolved against the process cwd) names roots the host provisioned. They are appended last, and discovery is last-wins per plugin name, so a same-named plugin in a user-writable root cannot shadow one the deployment supplied. Each is marked readOnly, which makes uninstall() refuse it — the directory is a read-only mount, so the removal would fail anyway, and the code is not the user's to delete. Nothing else changes: enable state, permissions, AI consent and dependency resolution behave exactly as for any other plugin, and SHOFER_DISABLED_PLUGINS still wins over everything.

Dependencies fail-closed. An enabled plugin whose declared dependencies (plugin names) are not all enabled+present is itself treated as disabled — none of its contributions register. PluginManager.resolveDependencies() (run after discovery and on every enable/disable) computes each enabled plugin's dependency closure; a plugin is effectiveEnabled only when that closure is entirely enabled+present. Transitive failures cascade, and dependency cycles fail every plugin in the cycle closed. Each blocked plugin surfaces a warning (both shown and logged) naming the unmet dependency, and its disabledReason is pushed to the Plugins panel so the user sees why the toggle is on yet nothing registered.

Code loading

If main is specified, .ts entries are transpiled via the runtime esbuild loader and .js entries loaded via dynamic import(); the default export must be a ShoferPlugin. In the packaged extension the loader transpiles TypeScript with a shipped esbuild-wasm CLI at <extension>/dist/bin/esbuild and resolves a self-contained @shofer/types SDK at <extension>/dist/plugin-sdk (see §8).

Transpiled bundles are cached content-addressed (an OS-temp dir by default), keyed on the entry path plus a stamp of the plugin's whole source tree (every file's mtime and size, node_modules excluded — installed deps are externalized, not bundle inputs). The tree-wide key matters: the bundle inlines the full import graph, so keying on the entry file alone once served a weeks-stale bundle after a vendored dependency changed without the entry's mtime moving.

Registration

pluginRegistry.register(plugin, context)context includes the plugin's validated, default-merged config values (manifest config schema + user settings).

Config vs credentials. A manifest config property may declare "secret": true. Its value is stored in the host's secret store (pluginSecrets in GLOBAL_SECRET_KEYS, one JSON blob keyed by plugin — SecretState is a fixed typed key set and a plugin must not be able to mint entries in it), never in the plain pluginConfigs state, and never sent to the webview: PluginView.config is redacted and PluginView.configSecretsSet reports only which credentials exist. The plugin reads the value from ctx.config[key] like any other property — resolvePluginConfig(manifest, stored, secrets) merges it in. The split rules (empty string deletes, an absent key keeps, a non-string is refused) live in plugin-config-secrets.ts so the write path, the read path and the manager cannot disagree about them.

Enable / Disable / Reload / Uninstall

  • Disable — the plugin is removed from the registry; tools, modes, skills, commands disappear, UI unmounts, MCP servers disconnect, services stop.
  • Enable — the plugin is re-registered (code re-loaded if needed).
  • ReloadPluginManager.reloadPlugin(name) re-reads and re-registers a single plugin; used when its config or AI-consent changes so ctx.config/ctx.ai reflect the new state live.
  • Uninstall — the plugin directory is deleted and all contributions removed (bundled plugins are non-uninstallable).

Enabled state is persisted in globalState under shofer.plugins.enabledPlugins: string[]; AI consent under shofer.plugins.aiConsentedPlugins.


7. Security Model

Permission boundaries

Permission What it allows Risk
tools Register model-callable tools Tool code runs in the host with getHost() access; gated by auto-approval.
systemPrompt Modify the system prompt Can inject behavior-changing instructions. User sees the diff in Settings.
modes Contribute mode definitions Can restrict or expand per-mode tool access. Subject to user mode-override.
ui Render React components Components run with a restricted PluginUIApi (no direct vscode access); error-boundary isolated.
lifecycle Hook into task lifecycle beforeToolCall can block/modify calls; beforeAsk can auto-approve/deny. High trust. 500 ms per-hook cap.
network HTTP to listed domains fetch() to listed domains only; others blocked. Non-HTTP (gRPC/socket) egress is a proposed generalization — §14.3.
filesystem Read/write listed paths ctx.host.fs + ctx.host.watch scoped to listed paths only.
ai Host LLM/embeddings via ctx.ai Billed model calls on the user's account. Requires a separate consent (below). Only an ApiHandler, never keys.
agent Proactive steering via ctx.agent Injects messages into the running agent (queue/spawn/interrupt) — billed/behavioral. Dedicated grant; ungranted ⇒ denying stub.
mcpInvoke Invoke MCP tools via ctx.mcp Calls tools on every server the host has connected, not just the plugin's own — and the call bypasses the ask/approval pipeline, so this grant is the only gate. Dedicated grant, never implied by mcpServers; ungranted ⇒ denying stub.
task Task control via ctx.task Writes rows into the conversation, can destroy conversation history (rewind restarts the task), move a task to another directory, and open new tasks. Dedicated grant; ungranted ⇒ denying stub.
telemetry Product events via ctx.host.telemetry Data leaves the machine, unlike the always-on logger and metrics. Host-namespaced (Plugin Event + plugin/event properties) and scrubbed to primitives; the user's telemetry opt-in still gates it. Ungranted ⇒ warns and drops (never throws).
editor ctx.host.editor (multi-file diff viewer) Opens editors — focus-stealing, so granted explicitly rather than always-on like notifier.

Sandboxing

  • Code plugins (with main) run in the host process but with a restricted PluginContext wrapping the host surface with permission checks (plugin-sandbox.ts).
  • UI plugins load via dynamic import() with a restricted PluginUIApi — no direct vscode API, no parent-DOM access, only a plugin-scoped message channel. External bundles are served local-only (vscode-webview:// under the plugin dir) under a strict-dynamic + nonce CSP; an error boundary unmounts a throwing component.
  • Declarative-only plugins (no main) contribute only static files (modes, skills, commands, MCP configs) and execute no code.

Billed AI calls consent (permissions.ai)

ctx.ai is the one capability that spends the user's money. Enabling a plugin is therefore not consent to bill them. A plugin declaring permissions.ai requires a second, explicit confirmation before ctx.ai is live:

  • Two independent gates. ctx.ai is constructed only when both (a) the manifest granted permissions.ai, and (b) the user AI-consented this specific plugin. Consent is persisted separately (shofer.plugins.aiConsentedPlugins).
  • Fail-closed states. permissions.ai absent ⇒ ctx.ai absent. Present but not consented ⇒ a denying stub: every buildHandler/embed call throws + emits a shown/logged warning (never silently bills).
  • Surfaced in the UI. The Plugins panel shows a "uses AI (billed)" badge for any plugin declaring permissions.ai, and a distinct consent toggle separate from enable. PluginView carries usesAi and aiConsented; toggling consent issues a { action: "setAiConsent" } PluginRequest and reloads the plugin so ctx.ai flips live/denied.
  • No key exposure. Even fully consented, the plugin receives only the ApiHandler the host built — never provider settings or API keys.

Audit log

Plugin hook invocations are logged to the Shofer output channel, and each plugin's own ctx.host.log output goes to its Plugin:<name> category (Settings → Logging).


8. Distribution & Discovery

Plugin scopes

Discovery classifies each plugin by where it was found (PluginScope):

  • bundled — first-party plugins shipped inside the extension build. Non-uninstallable, and opt-in unless the manifest sets defaultEnabled (a plugin that IS a Shofer feature does). The esbuild build (src/esbuild.mjs) copies plugins/**dist/plugins, auto-builds each plugin's UI bundles (running its build-ui.mjs), and ships the runtime deps external bundles need: the esbuild-wasm CLI (dist/bin/esbuild), the shared-React shims (webview-ui/build/plugin-host/*.js), and a self-contained @shofer/types SDK (dist/plugin-sdk/node_modules/@shofer/types, so a bare @shofer/types import resolves at runtime). The bundled set is basics (checkpoints, file-changes, worktrees), builtin-config (the built-in modes), live-memory, rag-indexing and second-brain (plugins/<name>/) — each self-contained, with its domain types living in the plugin (zero @shofer/types runtime footprint); see PLUGINS.md. A bundled plugin may ship vendored dependencies (basics vendors simple-git as src/vendor/simple-git.mjs), which is what keeps it dependency-free at runtime and packable to a single archive.
  • global — installed under ~/.shofer/plugins/ (all workspaces).
  • project — installed under <workspace>/.shofer/plugins/ (checked into VCS or gitignored per team choice).

The "no bundled plugins" build flavor

Setting SHOFER_NO_BUNDLED_PLUGINS=1 (or true) when bundling (src/esbuild.mjs, so SHOFER_NO_BUNDLED_PLUGINS=1 pnpm bundle / pnpm vsix) packages the extension with no bundled plugins at all: the plugins/**dist/plugins copy and every bundled plugin's UI build are skipped. It exists for hosts/embedders that supply their entire plugin set out-of-band — the global scope (~/.shofer/plugins) or .shofer/plugins.json declarations — and want none of the first-party set on disk. The headless CLI/serve runtime consumes the same src/dist output, so the flag governs that artifact too. The plugin SDK (dist/plugin-sdk) still ships: global/project code plugins resolve @shofer/types through it regardless of flavor.

At runtime the absent dist/plugins degrades to no built-in tier: a missing scan root discovers nothing (and is tolerated, not an error), and effectiveModes merges only what the remaining scopes and the user's/project's own mode files supply. Because the built-in modes ship as the bundled builtin-config plugin, this flavor has no code/architect/… modes of its own — the host's tiers must define the mode set; an entirely empty effective mode list surfaces as an error when a prompt is built (resolveModeConfig). Pinned by no-bundled-plugins.spec.ts. The default build is unchanged and ships all bundled plugins.

The flag is declared as task env for both the bundle and vsix tasks (src/turbo.json), and both declarations are load-bearing. On bundle it keys the turbo cache, so the two flavors never replay each other's cached dist/**. On vsix it is what actually delivers the variable to the packaging step: turbo runs tasks in strict env mode, and vsce package re-runs vscode:prepublish (a fresh node esbuild.mjs outside turbo), so without the declaration that rebuild silently reverts to the full-fat default and packages it — while the lean bundle output sits cached, looking correct.

Package format

A plugin is distributed as a .shofer-plugin archive (gzip tarball) containing plugin.json, the entry point, and the contribution directories.

Installation

# CLI — a local archive, an unpacked directory, or a direct http(s) archive URL (all implemented)
shofer plugin install /path/to/my-org-ci-1.0.0.shofer-plugin
shofer plugin install ./my-org-ci                                    # unpacked plugin directory
shofer plugin install https://example.com/my-org-ci.shofer-plugin    # direct-URL install
shofer plugin install <source> [--enable] [--overwrite] [--allow-insecure-http]

# Or extract manually
tar xzf my-org-ci-1.0.0.shofer-plugin -C .shofer/plugins/

Pack/unpack/install live in host-agnostic core (plugin-pack.ts): packPlugin/unpackPlugin/installPlugin, validated against pluginManifestSchema, zip-slip- and symlink-hardened, name-collision-gated. The CLI commands (shofer plugin install|list|remove) are thin wrappers over this plus PluginManager; the enabled allow-list is the same shofer.plugins.enabledPlugins the running agent reads.

Install-from-URL. The CLI (isPluginUrlinstallPluginFromUrl) and the declaration resolver (materializeSource, via fetchPluginArchive) both download a direct http(s) .shofer-plugin and unpacks it through the same validation/zip-slip pipeline as a local archive: https required (unless the host is loopback or --allow-insecure-http is passed), size-capped (DEFAULT_MAX_PLUGIN_DOWNLOAD_BYTES = 64 MiB), fail-closed on a bad manifest. This is a direct download, not a registry lookup.

A content-addressed URL — filename sha256-<hex>.shofer-plugin — also pins the bytes: fetchPluginArchive verifies the digest and throws on a mismatch, so a URL that begins serving different code fails the load rather than silently swapping what runs. The pin lives in the filename because pluginDeclarationEntrySchema is .strict() and parsePluginDeclaration fails closed to an empty declaration — an unknown digest key would discard every plugin declaration in that scope, not merely be ignored.


9. Relationship to MCP

MCP is a subset of plugins. Every MCP server can be wrapped as a declarative plugin:

{
	"name": "filesystem-mcp",
	"version": "1.0.0",
	"permissions": { "mcpServers": true },
	"contributes": {
		"mcpServers": {
			"filesystem": {
				"type": "stdio",
				"command": "npx",
				"args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
			},
		},
	},
}

But a plugin does more than an MCP server:

Capability MCP Plugin
Expose tools
Expose resources ✅ (via tools or HostFileSystem)
Modify system prompt
Contribute modes / skills / commands
Contribute UI components
Hook into lifecycle
Observe events
Run in-process (no separate process)
Access Shofer state (task, mode, cwd) ✅ (via PluginContext)
Cross-platform (CLI + extension) ❌ (separate process) ✅ (host-agnostic)

Existing MCP servers continue to work unchanged — .shofer/mcp.json and mcp_settings.json are not deprecated. A plugin's contributes.mcpServers is just another source of MCP configs, merged into McpHub alongside the existing sources. An MCP author who wants prompt/UI access wraps their server in a plugin: declare contributes.mcpServers, optionally add a main with transformSystemPrompt, and optionally add a UI status badge — no MCP protocol changes needed.


10. Relationship to Existing Subsystems

  • CustomToolRegistry (packages/core/src/custom-tools/) is the implementation behind registerTools. Plugin tools carry source: "plugin" + pluginName; .shofer/tools/ file loading continues to work as another source. Plugin-source tools are dispatchable regardless of the customTools experiment (§5.1).
  • SkillsManager gains plugin-contributed skills from contributes.skills (dirs supplied by PluginManager). Plugin skills are namespaced (qualifiedSkillName), not merged by precedence, so they can't shadow file skills; private skills are excluded from user-facing enumeration.
  • CustomModesManagergetAllModes() includes plugin modes from contributes.modes, emitted under the namespaced slug <plugin>:<authoredSlug> with source: "plugin"; private modes are agent-switchable but hidden from the picker.
  • McpHub reads MCP configs from plugin manifests (contributes.mcpServers), merged with .shofer/mcp.json and mcp_settings.json.
  • Checkpoints are no longer a core subsystem: per-task undo history is the bundled basics plugin's checkpoints feature, built on beforeToolCall + ctx.task + onTimelineRewind + handleRequest (plugins/basics/docs/checkpoints.md). Core keeps only those generic seams — no shadow-git, no enableCheckpoints setting, no checkpoint-specific wire methods.

11. UI Integration

Settings → Plugins

One panel owns the surface: Settings → Plugins (PluginsSettings.tsx) — the discovered-plugin list with enable/disable toggles, the settings-tab UI region slot, schema-driven config editing (when a plugin manifest declares config, PluginView.configSchema/config drive a form; edits issue a { action: "setConfig" } PluginRequest and are committed via the shared Settings Save button, which calls PluginManager.reloadPlugin(name) so ctx.config reflects the change live), and — for a plugin declaring permissions.ai — the "uses AI (billed)" badge plus the AI-consent allow/revoke control ({ action: "setAiConsent" }, which reloads the plugin so ctx.ai flips live/denied). Install/uninstall have no webview surface: they are CLI verbs (shofer plugin install|remove) and .shofer/plugins.json declarations.

Each plugin row shows name, version, scope badge, a summary of contributions (N modes · N skills · N commands · N mcpServers · N rules), and — when dependencies are unmet — a disabledReason.

Chat-input status badge

A plugin with a chat-input-toolbar UI contribution renders as a button/chip in ChatTextArea, after the existing built-in chips. A common pattern (used by Live Memory) is a clickable status badge with a popover — a compact indicator whose popover surfaces live state pushed from the plugin's extension side via ctx.ui.postMessage.

Task header contributions

Plugin components in the task-header region render as badges/rows in the expanded TaskHeader — e.g. CI status, coverage deltas.


12. Comparison with OpenCode and Claude Code

Shofer's plugin system draws on prior art from OpenCode and Claude Code. This section records where the designs align and where Shofer deliberately differs.

OpenCode

OpenCode has a two-generation plugin architecture: a V1 Hooks-object API and a V2 imperative-registration API where plugins call ctx.domain.transform() / ctx.domain.hook().

OpenCode pattern Shofer
Runtime before/after hooks (tool.execute.before, later hooks see earlier mutations) Matched by Shofer's reducer-semantics lifecycle hooks (§5.9).
Scope-owned registration (closing a scope removes all registrations) Matched by Shofer's enable/disable/reload model — disabling a plugin removes all its contributions.
Config in opencode.jsonc, options as ctx.options Matched by Shofer's manifest config schema surfaced as ctx.config (schema-driven Settings form).
Domain transform model (replayable mutations on a stateful domain editor) Not adopted — Shofer merges contributions directly and uses namespacing for collision safety rather than a transform pipeline.
Auth / provider hooks (models() callback) Not adopted — Shofer's provider settings and llm-router already handle dynamic model discovery.

Claude Code

Claude Code's plugin system is manifest-driven and declarative-first: a plugin is a directory with a .claude-plugin/plugin.json, components discovered by convention.

Claude Code pattern Shofer
Declarative-first (a plugin with no code works from directories of skills/agents/hooks/MCP) Adopted — declarative-only plugins (no main) contribute modes/skills/commands/MCP/rules with no code execution.
Namespacing (/plugin-name:skill-name) Adopted — plugin modes, commands, and skills are addressed <plugin>:<name>, so cross-contributor collisions are impossible by construction (§5.3§5.5).
${CLAUDE_PLUGIN_ROOT} / ${CLAUDE_PLUGIN_DATA} substitution Adopted as ${SHOFER_PLUGIN_ROOT} / ${SHOFER_PLUGIN_DATA} in MCP/hook configs, plus ctx.storage for the persistent data dir.
Plugin scopes (user/project/local/managed) Adopted as bundled/global/project scopes (§8).
User configuration prompted at enable time Matched by the manifest config schema + the Settings → Plugins form.
Background monitors (shell commands feeding stdout to the agent) Achieved differently — Shofer plugins run in-process supervised services (ctx.registerService) that steer the agent via ctx.agent.deliver, rather than declarative shell monitors.
Hooks as external commands / HTTP / MCP tool calls Not adopted — Shofer's hooks are in-process (ShoferPlugin lifecycle hooks), which are more powerful but require code loading.
LSP server configs, agent-markdown definitions, themes, output styles, token-cost estimation Not adopted — Shofer covers language intelligence via its own LSP tools and personas via modes.

13. Deferred

Remote plugin registry. There is no hosted registry (no shofer plugin search, no install name@version, no update --all), because it needs code signing and a trust chain. What ships today is install from a local archive, an unpacked directory, or a direct http(s) archive URL — the URL path is a direct download, not a registry lookup. A future registry would layer search / versioned install / update --all on top of the existing pack/unpack + PluginManager + direct-URL substrate.

Other prior-art features considered but not built are noted inline in §12 (external command/HTTP hook types, declarative background monitors, LSP configs, agent-markdown definitions, themes, output styles, token-cost estimation, and OpenCode's domain-transform model).


14. Agent-Control API for Workflow / Runner Plugins

Status: ctx.agent.spawn / cancel and the whole PluginTaskHandle / PluginTaskResult surface below are SHIPPED (packages/types/src/plugin.ts, host seam in ShoferProvider.buildPluginAgentProvider), including mode, completionSchema and sessionId on the spawn options. What is not shipped is §14.2's unattended / approvalPolicy pair and §14.3's non-HTTP permissions.network generalization; both are marked in place. This section lets a plugin drive the agent as a durable unit of work — workflow, integration, and runner plugins (e.g. a Temporal activity worker). It rides the seams §5.10/§7 already define and exposes a scoped capability, never the raw ShoferExtensionApi.

14.1 Motivation

ctx.agent.deliver (§5.11) lets a plugin put a message in a task's mailbox, but it addresses a task that ALREADY EXISTS and yields no handle: no completion, no result, no cancel. A runner/workflow plugin needs to treat an agent run as a job: start it, await its structured result, and cancel it (e.g. when an external orchestrator cancels, or a kill switch fires). The full ShoferExtensionApi (shofer-api.md) already has exactly this (startNewTask → taskId, cancelCurrentTask, the event stream) — but it is a companion-extension surface, not available to sandboxed plugins, and dumping it into ctx would break the restricted-context model (§7). So the additions expose a scoped, gated slice of that surface through ctx.

14.2 Scoped agent-control on ctx.agent

Extend the existing permissions.agent capability (host-side PluginAgentProvider seam — the same recipe as ctx.ai / ctx.agent.deliver) with an awaitable, cancellable task surface:

interface PluginAgentControl {
	// Start a task and get a HANDLE (unlike deliver, which needs a task already).
	spawn(
		prompt: string,
		opts?: {
			images?: string[]
			// Shipped. Picks the tool set AND the provider profile (see below);
			// a slug the host does not define is refused, not substituted.
			mode?: string
			metadata?: Record<string, unknown>
			// Shipped: a JSON Schema the answer must conform to — threaded into the
			// task's `attempt_completion` tool, not sent as a provider response_format.
			completionSchema?: Record<string, unknown>
			// Shipped: continue an existing session (a prior task's id) instead of
			// starting cold, which is what makes a contract re-prompt a continuation.
			sessionId?: string
			// NOT SHIPPED. Headless execution: the task has no interactive approver, so an un-granted
			// approval must NOT park on ask() — it resolves as Deny (tool fails, task continues).
			unattended?: boolean
			// NOT SHIPPED. What the task is pre-authorized to do (granted ⇒ auto-approve; miss ⇒ auto-deny).
			// Expressed in Shofer's existing auto-approval vocabulary so checkAutoApproval()
			// consumes it unchanged. See the arkware.ai SaaS design doc, §5.6
			// "Runner-task approval" — it lives in that integrator's repo, not here.
			approvalPolicy?: ApprovalPolicy
		},
	): Promise<TaskHandle>
	// Cancel by id — exposes Shofer's EXISTING structured cancellation (v3 §5, terminateProcessTree/abortStream).
	cancel(taskId: string): Promise<void>
}

interface TaskHandle {
	readonly taskId: string
	result(): Promise<TaskResult> // resolves on completion/abort
	onEvent(cb: (e: PluginEvent) => void): () => void // scoped to THIS task
	cancel(): Promise<void>
}

interface TaskResult {
	taskId: string
	status: "completed" | "aborted" | "error"
	output?: string // the attempt_completion result, verbatim
	metadata?: Record<string, unknown> // the `metadata` passed to spawn, echoed back
}
  • mode is load-bearing, and an unknown one is refused. The slug picks the task's TOOL SET and, through the host's per-mode API-configuration association (modeApiConfigs), the provider profile its LLM calls go to — the host only consults that association for a task created WITH a mode, so a runner that omits it gets the node's default model whatever the configuration says. A slug the host does not define makes spawn reject (with an error whose name is PLUGIN_UNKNOWN_MODE_ERROR) and start no task; substituting the default mode would hand the caller a different agent, on a different model, reporting success.

  • output is the task's answer, not a status line. It is the attempt_completion result as the agent rendered it — read from the task's last completion_result message when it settles. A caller awaiting result() has no chat to read, so a host that declined to set this would leave the whole point of spawn (as opposed to deliver) unreachable. It is absent, not empty, when the task ended without declaring an answer.

  • spawn is bounded by the host's global parallel-task limit (maxParallelTasks, parallelism.md) — the same limit and the same default new_task enforces, because a plugin spawning per inbound event is the one caller that can produce unbounded concurrency without an agent ever deciding to. Over the limit it rejects with an error whose name is PLUGIN_TASK_LIMIT_ERROR, a well-known constant rather than a message, because the caller's correct reaction is to do the work another way (shofer-mesh's spawn subscriptions fall back to delivering the event as an ordinary notification) and it can only choose that if it can tell this refusal from a real failure.

  • taskId is also the SESSION handle. Passing it back as opts.sessionId continues that conversation instead of starting a cold one, which is what makes an output-contract re-prompt cheap and correct. Resuming a session whose loop has ENDED — the normal case for a re-prompt, since the task completed before its answer was judged — rehydrates the conversation from history and delivers the new prompt as its next user message. Being on the host's task stack is not evidence that a loop is running: attempt_completion leaves the instance in place with abort set, so a resume that only checked for the instance would hand a message to a task that had already stopped, and the caller would wait forever.

  • Send mode on a resume as well as on a cold spawn. Rehydration restores the mode's TOOL SET from history, but a headless host deliberately does not restore provider settings from history (a stale persisted profile must not override the node's runtime flags) — so a resume that carries no mode continues on the node's DEFAULT model: the right conversation, the right tools, the wrong model. With the mode present the host re-applies that mode's profile to the resumed task, per task, exactly as it seeds a fresh one.

  • Scoped, not raw. Mirrors how ctx.ai hands out a scoped ApiHandler (never raw keys): the plugin gets task control, not the task stack or ShoferExtensionApi. This is what keeps it a proper sandboxed capability rather than a hole.

  • Gated + host-agnostic. Same gate as steering (permissions.agent), wired via the existing PluginAgentProvider seam so @shofer/core stays host-agnostic (the host binds the concrete task stack). Ungranted ⇒ denying stub, per §7.

  • Completion + result. afterTaskComplete (§5.9) and TaskHandle.result() carry the structured TaskResult (today the lifecycle context carries only a reason — this adds the result payload).

  • ctx.agent.deliver stays as the message path (inbound delivery into an existing task's mailbox); spawn/cancel are the job-oriented path. This completes the "workflow plugin" use-case §5.9 already names ("observe task start, post results externally").

  • Unattended approval (unattended + approvalPolicy). A spawned runner task has no interactive approver, so it must never hang on ask(). In unattended mode any approval not granted by the approvalPolicy resolves as Deny (the tool fails with the standard denial result and the task continues) — never an interactive wait. The policy reuses Shofer's existing auto-approval vocabulary, so checkAutoApproval() consumes it unchanged:

    interface ApprovalPolicy {
    	read?: boolean
    	write?: { paths?: string[] } // allowedWritePaths
    	execute?: { allow?: string[]; deny?: string[] } // command allow/deny lists
    	mcp?: { servers?: string[] }
    	browser?: boolean
    	subtasks?: boolean
    	onMiss?: "deny" | "escalate" // default "deny"; "escalate" ⇒ agent opens a ticket / signals a workflow
    	neverAllow?: string[] // hard-gated even if otherwise matched (e.g. provisioning)
    }

    Hard-gated actions (neverAllow, e.g. provisioning/prod-deploy) are never auto-approvable unattended and route to escalation — an async human path (ticket / mesh event) or a Temporal Workflow Update/signal the agent issues via a plugin tool (the runner already holds a Temporal client), which blocks a coordination workflow on a human decision without parking the runner. Full model + escalation flows: the arkware.ai SaaS design doc, §5.6 "Runner-task approval" — that doc lives in the integrator's repo, not here, since Shofer core assumes nothing about a specific deployment.

14.3 Non-HTTP network egress (permissions.network)

A runner plugin connects to a Temporal server (gRPC) and a NATS bus (its own TCP protocol). permissions.network today is a fetch/HTTP allowlist governing ctx.host.fetch; it does not model gRPC/socket egress — and a code plugin can already open raw Worker sockets in-process (the sandbox is a restricted context, not a hard VM — §7). Two modest generalizations, both extending the existing allowlist concept rather than adding a trust boundary:

  • (preferred) Declared socket egress. Extend permissions.network to accept non-HTTP endpoints ("grpc://temporal:7233", "nats://nats:4222") so connection targets are declared, surfaced, and audited — the same contract as HTTP domains, just not limited to fetch.
  • Host-mediated client seam (heavier, likely unnecessary): a ctx.host streaming/socket client analogous to ctx.host.fetch.

The goal is honesty of declaration, not a new trust class — task control and network egress are already "high trust," on par with permissions.lifecycle / permissions.ai.

14.4 What does NOT change

The rest of the runner/workflow surface is already shipped: ctx.registerService (§5.11) hosts the long-lived worker (Live-Memory-precedented); ctx.agent.deliver already does inbound delivery into a task's mailbox; onEvent + lifecycle hooks observe; ctx.config / ctx.storage back config + idempotency state. With spawn/cancel and the task handle now shipped too, the remaining delta is the unattended-approval pair and the socket-egress declaration. The first consumer and worked example is a Temporal worker plugin.


Related documents

Document Relationship
../PLUGINS.md Author-facing how-to: manifest fields, build invocations, walkthroughs.
shofer-api.md The programmatic agent API surface plugins run alongside.
shofer-api.md Agent Client Protocol — an external control surface complementary to plugins.
host-boundary.md The host-agnostic architecture the plugin substrate is built on.
mcp.md MCP servers are one kind of plugin contribution (contributes.mcpServers).
adding-new-tools.md Plugin tools follow the CustomToolDefinition contract.
skills.md Plugin skills are discovered alongside .shofer/skills/.
plugins/builtin-config/docs/modes.md Plugin modes merge into the mode resolution chain.
host-boundary.md Plugins use getHost() — host-agnostic by construction.
packages/types/src/plugin.ts The ShoferPlugin interface and all plugin types.
packages/core/src/plugins/ PluginManager, PluginRegistry, sandbox, and host-capability implementations.