Skip to content

Latest commit

 

History

History
1069 lines (872 loc) · 73.7 KB

File metadata and controls

1069 lines (872 loc) · 73.7 KB

Writing Shofer Plugins

A plugin extends Shofer's behavior, not just its data. Where the custom-tool registry adds tools, a plugin is a self-contained bundle that can contribute modes, skills, slash commands, MCP servers, and rules declaratively, and — if it ships code — register tools, transform the system prompt, hook the task/tool lifecycle, run a background service, call the host LLM, and render UI into the app.

Plugins are host-agnostic: the same plugin runs in the VS Code extension and the CLI (and any future host). Everything a plugin can touch goes through a permission-checked, restricted surface — a plugin only gets the capabilities its manifest declares and the user grants.

This is the practical guide to authoring one. For the internal design rationale see todos/plugin_system.md.


1. What a plugin is

A plugin is a directory containing a plugin.json manifest and, optionally, a code entry point and asset directories (skills/, commands/, rules markdown, etc.):

my-plugin/
  plugin.json        # the manifest — required, at the root
  index.ts           # optional code entry ("main")
  skills/            # optional SKILL.md files
  commands/          # optional slash-command .md files
  rules/             # optional rules markdown

Discovery directories

Shofer scans two locations and loads a <name>/plugin.json from each subdirectory:

Scope Directory Meaning
global ~/.shofer/plugins/<name>/ available in every workspace
project <project>/.shofer/plugins/<name>/ scoped to one workspace (the current cwd)

If a plugin of the same name exists in both, the project one (scanned later) wins. A plugin is disabled by default after install — the user enables it per-plugin (that toggle is the consent gate; see §7).

A third, lowest-precedence scope is bundled — the first-party plugins shipped inside the extension build itself (<extension>/dist/plugins/). Hosts that supply their entire plugin set out-of-band can build an extension without any of them: SHOFER_NO_BUNDLED_PLUGINS=1 at bundle time skips the bundled-plugins packaging entirely, and the runtime treats the absent directory as "no built-in tier" (see the build-flavor section of docs/plugin_system.md).


2. Quickstart

A minimal code plugin that contributes one tool. Create ~/.shofer/plugins/hello/:

plugin.json

{
	"name": "hello",
	"version": "1.0.0",
	"shoferPluginApiVersion": "1.0.0",
	"description": "Adds a greeting tool.",
	"main": "index.ts",
	"permissions": {
		"tools": true
	}
}

index.ts

import { defineCustomTool, parametersSchema as z } from "@shofer/types"
import type { PluginContext, ShoferPlugin } from "@shofer/types"

const plugin: ShoferPlugin = {
	name: "hello", // MUST equal manifest `name`

	registerTools(_ctx: PluginContext) {
		return [
			defineCustomTool({
				name: "greet",
				description: "Greet someone by name.",
				parameters: z.object({
					who: z.string().describe("Who to greet"),
				}),
				async execute({ who }) {
					return `Hello, ${who}!`
				},
			}),
		]
	},
}

export default plugin

Then, from the CLI:

shofer plugin install ~/.shofer/plugins/hello --enable
shofer plugin list

The plugin's default export must be a ShoferPlugin object whose name matches the manifest. The entry may be .ts/.tsx/.js/.mjs; TypeScript is transpiled with the same esbuild path custom tools use (Worker built-ins external, deps bundled).

A purely declarative plugin needs no main and no code — just a manifest with a contributes block. See §4.


3. Manifest reference (plugin.json)

The manifest is validated fail-closed: every level is .strict(), so an unknown key is a hard error and the plugin is skipped with a warning. Fields:

Field Type Notes
name string required Unique id. Must match ^[a-zA-Z0-9][a-zA-Z0-9._-]*$. Used for ordering, dedupe, namespacing.
version string required Free-form version string.
shoferPluginApiVersion string The plugin-API semver you target (current host API: 1.0.0). Incompatible ⇒ refused at load.
description string Shown in the Plugins UI and plugin list.
author string
homepage string
license string
shoferVersion string Minimum Shofer version (semver range). Not yet enforced.
main string | null Code entry, relative to the plugin dir. Absent/null ⇒ purely declarative.
permissions object The security contract. See below.
contributes object Declarative modes/skills/commands/mcpServers/rules, plus ui bundles. See §4, §6.
dependencies string[] Other plugins that must be installed. (Discovery records unmet deps; not fully enforced yet.)
config object JSON-Schema-ish description of user settings. See below.
defaultEnabled boolean Bundled (first-party) scope only. Ship enabled instead of waiting to be opted into — for a plugin that IS a Shofer feature. Ignored for global/project plugins. If you also declare permissions.ai, gate every hook on ctx.ai.hasConsent(): enabling is not consent to bill, so an unconsented plugin must contribute nothing rather than a tool that only fails (see plugins/live-memory/main.ts).
hookTimeoutMs number Override the 500 ms per-hook budget for this plugin's lifecycle hooks (max 60000). Only for a hook the agent must genuinely wait for.
unqualifiedContributions boolean Bundled (first-party) scope only. Register contributes.modes and contributes.commands under their authored names instead of <plugin>:<name>, at the built-in precedence tier. Exists for a plugin shipping the platform's own defaults, whose names are a contract (plugins/builtin-config/ — the built-in modes must stay code, architect, …; plugins/basics//merge-worktree). Ignored for global/project plugins: an unqualified third-party name could shadow a built-in.

permissions

Every capability defaults to denied. A contribution is only surfaced, and a code capability only reachable, when its permission is present. All keys are optional:

Key Type Gates
tools boolean registerTools contributions.
systemPrompt boolean transformSystemPrompt.
modes boolean contributes.modes.
skills boolean contributes.skills.
commands boolean contributes.commands.
rules boolean contributes.rules.
mcpServers boolean contributes.mcpServers.
mcpInvoke boolean Invoking tools on connected MCP servers via ctx.mcp. Separate from mcpServers: invoking reaches every server the host has, not just yours.
ui region[] UI regions the plugin may render into. See §6.
lifecycle boolean The lifecycle hooks. Without it, none of them ever fire.
events boolean onEvent observation.
network string[] Allowed network origins/prefixes for ctx.host.fetch.
filesystem string[] Allowed paths for ctx.host.fs and ctx.host.watch (relative entries resolve to plugin root and workspace).
ai boolean Host LLM/embeddings via ctx.ai. Necessary but not sufficient — also needs the AI-billing consent (§7).
agent boolean Deliver messages into a task's mailbox (ctx.agent.deliver), register a mailbox transport, and start/cancel tasks (spawn). Billed/behavioral.
task boolean Task control via ctx.task: timeline markers, rewind, setCwd, openTask. Each changes what a task IS (rewind destroys history), so it is its own grant.
telemetry boolean Report product events via ctx.host.telemetry. Telemetry LEAVES the machine, so it is a grant — the user's global telemetry opt-in still gates it underneath.
editor boolean The host's multi-file diff viewer via ctx.host.editor.

config

A JSON-Schema-ish object ({ type, properties: { <key>: { default, ... } } }). Shofer does a shallow default-merge: any properties.<key>.default seeds ctx.config[key] unless the user has stored a value (stored values always win). Full type/enum validation of stored values is not yet enforced — treat ctx.config as best-effort typed.

Credentials: "secret": true. A property marked secret is stored in the host's secret store (the OS keychain), never in plain state, and its value is never sent to the settings webview — the panel renders it as a password field showing only whether one is stored. Your plugin reads it from ctx.config[key] like any other property; the split is the host's job:

"config": {
	"type": "object",
	"properties": {
		"qdrantUrl": { "type": "string", "default": "http://localhost:6333" },
		"qdrantApiKey": { "type": "string", "secret": true, "description": "Vector-store token" }
	}
}

Saving an empty secret field deletes the stored value; leaving it untouched keeps it (the panel cannot round-trip a value it is never shown). "Reset to defaults" clears the plain config and leaves credentials alone — losing a key to a button labelled "reset defaults" would be a surprise.

A real example

{
	"name": "acme-ci",
	"version": "2.1.0",
	"shoferPluginApiVersion": "1.0.0",
	"description": "ACME CI helpers: a status tool, a guardrail, and a CI mode.",
	"author": "ACME",
	"main": "index.ts",
	"permissions": {
		"tools": true,
		"lifecycle": true,
		"modes": true,
		"network": ["https://ci.acme.example"],
		"filesystem": ["./ci-config"]
	},
	"contributes": {
		"modes": [
			{
				"slug": "ci",
				"name": "CI",
				"roleDefinition": "You help diagnose and fix CI failures.",
				"groups": ["read", "command"]
			}
		]
	},
	"config": {
		"type": "object",
		"properties": {
			"baseUrl": { "type": "string", "default": "https://ci.acme.example" },
			"blockForcePush": { "type": "boolean", "default": true }
		}
	}
}

4. Extension points

Declarative (no code)

Declared under contributes and gated by the matching permission. The physical assets live in the plugin directory; the manifest entry is the declaration.

  • modes — array of mode configs (same shape as a ModeConfig, minus source/pluginName, which Shofer assigns). Each must provide tools or tools_allowed.
  • skills{ name, description }. The SKILL.md lives under the plugin's skills/ dir.
  • commands{ name, description?, argumentHint? }. The .md lives under commands/.
  • mcpServers — a map of name → server config (validated by McpHub's own schema before connecting; kept loose in the manifest).
  • rules{ path, modes? }, where path is a rules-markdown file relative to the plugin root, optionally scoped to specific modes.

Namespacing

Plugin-contributed modes, commands, and skills are addressed under a <plugin-name>:<name> identifier, so a plugin item can never shadow a built-in/user item or another plugin's item — collisions are impossible by construction (there is no "last-installed-wins" tie-break between plugins). How the qualification surfaces differs slightly:

Contribution Addressed as On-disk / authored name
modes <plugin-name>:<slug> The emitted mode slug is the qualified form; the authored slug in your manifest stays natural (no :). Attribution is carried on source: "plugin" + pluginName.
commands <plugin-name>:<command> The command is registered and invoked under the namespaced name; the bare name is never resolvable on its own.
skills <plugin-name>:<name> Namespaced purely at the resolution/addressing layer — the on-disk directory name and the SKILL.md frontmatter name stay spec-compliant (no :). The model lists and invokes the skill by its qualified name.

The only residual collision is a single plugin declaring the same slug/name twice — a manifest bug, surfaced with a defensive warning (later entry wins, deterministically).

Private contributions

A mode, command, or skill can be marked private: true. A private contribution is fully registered and agent-invocable by its qualified name (<plugin-name>:<name>), but is hidden from every user-facing surface — the mode selector/picker, the slash-command menu, the skills list, and the Plugins panel. Use it for an item the agent drives but the user never picks directly — e.g. a plugin's verifier mode that a task switches into programmatically. Absent/false ⇒ a normal, user-visible contribution. (private is a field on the mode, command, and skill contribution schemas; a private mode still governs its subtask's tools once switched into.)

Code (requires main)

Implement any subset of the ShoferPlugin hooks. All are optional.

  • initialize(ctx) — run once when the plugin is registered.
  • registerTools(ctx) — return CustomToolDefinition[] added to the tool set. Requires permissions.tools.
  • transformSystemPrompt(prompt, ctx) — return a new prompt string. Plugins run in registration order, each receiving the previous output. Requires permissions.systemPrompt.
  • onEvent(event, ctx) — observe lifecycle/telemetry events. Read-only; must not throw.
  • onUiMessage(message, ctx) — receive a message from this plugin's UI (see §6).
  • lifecycle — the task/tool lifecycle hooks below. Only fires with permissions.lifecycle.

Lifecycle hooks & reducer semantics

Plugins run in registration order. Each hook is bounded by a 500 ms per-hook timeout with per-plugin error isolation: a hook that throws or exceeds the budget is skipped with a shown+logged warning — it can never stall or crash the agent loop, and its would-be mutation is not applied.

Hook Return Effect
beforeToolCall { allow, modifiedArgs?, reason? } Allow / modify / block. modifiedArgs threads into later hooks and the tool; the first allow:false short-circuits the tool (surfaced like a denied tool, with reason).
afterToolCall string | void Observe / transform the result string. A returned string replaces it for later hooks and the model.
beforeAsk { decision?, text? } | void Modify / auto-answer an ask. text edits the surfaced ask; decision of "approve"/"deny" auto-answers (short-circuit); "ask"/absent lets it proceed to the user.
afterAsk ignored Observer. How that ask ended: outcome (answered/superseded/aborted), and on answered the response, decidedBy (user/auto-approval/plugin) and autoApproved. The only way to see the host's own auto-approval verdict — beforeAsk runs before it.
beforeTaskStart ignored Observer. Fire-and-forget (off the latency-critical path). ctx.prompt carries the initial prompt.
afterTaskComplete ignored Observer. ctx.reason is "completed" or "aborted".
onUserMessage ignored Observer. The user sent a message into a task — the step boundary the tool hooks cannot see. Fires for a message into a RUNNING task and for the one that resumes a task from history, so a conversation's every turn is visible. info.trace carries the W3C trace context the delivering request stated, when it stated one.
onAssistantMessage ignored Observer. The agent completed a narration text block — its prose between tool calls, which no other hook carries. Never fired for streaming partials.
onApiRequestStart ignored Observer. An LLM request is about to be issued: { taskId, requestIndex, model, apiProtocol, retryAttempt }. The real wall-clock start of a model call.
onApiRequestFinish ignored Observer. That request finished. Carries the host's own record — time-to-first-byte, the offset at which generation began (end of the reasoning phase), retries, tokens, cost, tool spans.
onTimelineRewind ignored (awaited) The chat is about to be rewound to info.ts. Runs BEFORE the messages go, so state anchored to them can be rolled back. info.restoreState: false ⇒ chat-only, don't touch the workspace.
onTaskDeleted ignored Observer. A task was deleted — drop per-task state kept outside its task dir.

ctx.turn (a per-task turn counter) lets a hook that fires per tool call act once per turn. A hook that legitimately needs longer than 500 ms declares hookTimeoutMs in its manifest — see §3.


5. The ctx (PluginContext) API

Every hook receives a PluginContext. Which fields are populated depends on the host and the plugin's grants:

Field Availability
workspacePath Active workspace path, if any.
mode Current mode slug.
taskId Id of the task the hook runs for, if applicable.
parentTaskId The spawning parent's task id, when the task is a subtask — how an observer attributes a child's events.
rootTaskId The delegation tree's root task id, when the task is a subtask.
cwd Current working directory.
config This plugin's validated, default-merged settings.
host The restricted host surface (below). Present when the host wired its bridge.
ai Host LLM/embeddings. Present only with permissions.ai + a wired AI seam.
agent Proactive agent-steering. Present when the host wired its agent seam; denying stub without permissions.agent.
mcp Invoke tools on connected MCP servers. Present when the host wired its MCP seam; denying stub without permissions.mcpInvoke.
task Timeline markers + rewind. Present when the host wired its task seam; denying stub without permissions.task.
turn The task's current turn index (one per assistant turn), for once-per-turn hook behaviour.
toolCallId The provider's own id for the tool call being observed. Present on beforeToolCall / afterToolCall — a turn issues several calls, so taskId + turn do not identify one.
askId The host's id for the ask being observed. Present on beforeAsk / afterAsk; the join key to whatever surface decided it.
trace (beforeTaskStart only) W3C trace context of the request that created the task, when its creator supplied one. Its per-message twin is UserMessageInfo.trace on onUserMessage, which is where a conversation's later turns carry theirs.
storage Per-plugin persistent dir. Present when the host wired a storage base dir.
registerService(svc) Register a background service. Present when the host wired the supervisor.

ctx.host — the restricted host surface

Permission-scoped; the shape is always the same, but an out-of-scope call is denied at runtime (throw + shown/logged warning), not hidden from the type. So you always get a clear error, never a missing API.

  • host.fsreadFile/writeFile/exists/mkdir/delete/findFiles, scoped to permissions.filesystem. Relative allowlist entries resolve against both the plugin root and the workspace.

  • host.fetch(input, init?) — HTTP, scoped to permissions.network origins/prefixes.

  • host.notifierinfo/warn/error. Always available (surfacing messages is safe). Pops a user-facing toast; use it sparingly for things the user must see.

  • host.logdebug/info/warn/error. Always available (logging is safe). Writes to the plugin's own Log category Plugin:<name> (Settings → Logging), not a user toast. Each loaded plugin automatically gets its category, so a user can view and filter one plugin's output independently. Prefer this over console.* for anything diagnostic.

    ctx.host?.log.info("reindexed", { files: 12 })
    // shows in Settings → Logging under the "Plugin:my-plugin" category
  • host.metricsincrement/gauge/observe. Always available (a number in a local registry is as harmless as a log line), and a no-op on a host with no metrics pipeline. Metric names are yours: a plugin that owns a subsystem publishes the numbers an operator watches.

  • host.telemetry.capture(event, properties?) — report a product event, gated on permissions.telemetry. Three things the host does for you, and none of them is optional: your event name is namespaced under the single Plugin Event catalog entry with plugin/event properties (a plugin cannot name a top-level event, or shadow a core one); properties are scrubbed to primitives with strings truncated (you see workspace content, and this leaves the machine — an Error.stack or a file's text must not become an analytics payload); and the user's telemetry opt-in still applies, so a granted plugin on a machine with telemetry off reports nothing. Ungranted ⇒ warns and drops. It never throws: reporting a failure must not fail differently because reporting was refused.

    ctx.host?.telemetry.capture("indexing_error", { subsystem: "OpenAiEmbedder", attempts: 3 })
  • host.env — read-only host/environment metadata. Always available.

  • host.watch(pattern, onChange) — watch a glob for create/change/delete, scoped to permissions.filesystem (watches pattern under each granted root). The callback receives the event { path, type }path is the absolute path of the changed file (always inside a granted root) and type is "create" | "change" | "delete" — so you can act on which file changed, not just that something did. Ungranted ⇒ deny + no-op disposable. Dispose to stop (the manager also disposes it on plugin disable). Present only when the host wired a watcher.

    ctx.host.watch("**/*.json", (event) => {
    	// event.path e.g. "/abs/ws/ci-config/status.json", event.type "change"
    	reindex(event.path)
    })

Phase-6 host capabilities

ctx.ai (PluginAi) — requires permissions.ai and the user's "uses AI (billed)" consent.

  • buildHandler(profileRef?)Promise<Handler> — build the same ApiHandler the main agent uses (the host's default profile when profileRef is omitted). The plugin never sees raw API keys.
  • embed(texts, profileRef?)Promise<number[][]> — one embedding vector per input text.
  • hasConsent()booleanread-only consent check: true when calls will actually run, false on the denying stub. Because ctx.ai is present in both the live and unconsented cases, use this to word prompt/UI copy for the consent state without making a billed call to find out. It cannot grant consent — only the user can.

Granted but not consented ⇒ ctx.ai is present but a denying stub (buildHandler/embed throw + warn, hasConsent() returns false), so the user is never silently billed. Ungranted ⇒ ctx.ai is absent entirely.

ctx.agent (PluginAgent) — proactive agent-steering, requires permissions.agent. Lets a plugin put a message in front of a task (rather than only reacting to it) — from a background service, a ctx.host.watch callback, or a lifecycle hook.

There is ONE delivery door, and no delivery mode to choose:

  • deliver(envelope)Promise<Envelope>. The message becomes an envelope in the target task's mailbox (docs/task_messaging.md): persisted, deadline-bounded, listed in the environment_details digest on the task's next request, and read by the agent with wait. kind says whether an answer is expected (notification / request / reply), wake says whether a task whose loop has STOPPED is resumed for it, and deadline (absolute epoch ms) says how long it is worth delivering at all. The host fills to and sent_at and mints id when you supply none — pass an upstream idempotency key as id if you own one, because a mailbox already holding that id acknowledges without appending, which is what makes a retry safe. taskId targets a specific task; otherwise the host's current task.

    It resolves once the box has persisted the envelope, so an upstream ack sent afterwards means "in the box". It rejects when the box refuses (full, expired) and — deliberately — when there is no target task: a delivery never silently becomes a billed spawn. Use spawn for that.

  • registerMailboxTransport({ canRoute, send }) → an unregister function. The mirror of deliver: how an envelope addressed to a task this node does not hold LEAVES the node. The core resolves an outbound to locally first; an address that resolves to nothing is offered to each registered transport in turn, and the first whose canRoute(to) accepts it owns the delivery — which is what lets the agent's one send_message tool address a remote peer without knowing a mesh exists. canRoute is synchronous (it runs on the send path; no I/O), and a plugin whose service stops must call the returned unregister function.

  • spawn(prompt, opts?) / cancel(taskId) — job control (§14): a task that does not exist yet, with an awaitable, cancellable handle.

// e.g. inside a registered service watching a deploy log
await ctx.agent?.deliver({
	from: "deploy-watcher",
	kind: "notification",
	subject: "deploy failed",
	body: "The deploy just failed — see /var/log/deploy.log for the trace.",
	deadline: Date.now() + 600_000,
	wake: false, // it will be read on the agent's next turn
	plane: "local",
})

Ungranted (host wired the seam) ⇒ ctx.agent is a denying stub (every call throws + warns). No agent seam (headless/pure-core) ⇒ ctx.agent is absent entirely.

ctx.mcp (PluginMcp) — invoke a tool on any MCP server the host has connected, requires permissions.mcpInvoke. This is the counterpart of permissions.mcpServers, which only lets you CONTRIBUTE a server; 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 separate, larger grant that mcpServers never implies.

  • callTool(serverName, toolName, args?, opts?)Promise<McpToolCallResponse> — the server's raw result (content array + isError), unshaped: no truncation, no image extraction, no chat row, because your plugin is a program and not a transcript. opts.taskId attributes the call to a run (it travels in _meta and is what a per-call header resolver keys a run credential off) — pass it when you are acting on behalf of a task; it is never guessed for you, because the wrong run's credential is worse than none. opts.signal cancels the call.
const res = await ctx.mcp?.callTool("memory", "search", { query: "deploy" }, { taskId: ctx.taskId })

The call rides the host's own MCP hub, so it reaches the same server processes the agent's use_mcp_tool does. It does not raise an approval ask: your code is trusted (the user installed and granted it) and may run headless with nobody to ask, so the manifest grant is the gate. Ungranted (host wired the seam) ⇒ ctx.mcp is a denying stub whose callTool throws and warns. No MCP seam ⇒ ctx.mcp is absent entirely.

ctx.task (PluginTaskControl) — task control, requires permissions.task. For a plugin whose feature belongs in the conversation rather than in a side panel, and for one that decides where a task runs:

  • marker({ kind, text, data?, restorable?, suppress?, taskId? }) — append a row the plugin's own chat-message-addon component renders (the host never interprets kind/data). suppress persists it without rendering it (an anchor the user doesn't need to see); restorable makes the delete/edit dialog offer to roll your state back.
  • listMarkers(taskId?) — your markers, oldest first: how you recover anchors after a restart without a second, drift-prone copy in ctx.storage. Scoped to your plugin.
  • rewind(ts, { includeTargetMessage? }) — truncate the conversation to ts and restart the task. Roll back anything outside the conversation yourself, first.
  • setCwd(cwd, taskId?) — re-point a task at another working directory. The host throws when there is no task to re-point, rather than silently doing nothing.
  • openTask({ cwd?, name?, text?, images?, mode? }) — open and focus a NEW task, resolving with its id. With no text it lands idle, waiting for the user: this is for a plugin that has prepared a place to work (the basics plugin's worktrees feature opens a task in a worktree it just created), not a prompt. Use ctx.agent.spawn instead when you want an agent RUN — that one takes a prompt, returns an awaitable handle, and is gated on permissions.agent because it bills.
await ctx.task?.marker({ kind: "snapshot", text: commitHash, restorable: true })

Ungranted (seam wired) ⇒ a denying stub; no task seam ⇒ absent.

ctx.host.editor (permissions.editor) — showMultiFileDiff(title, changes) opens the host's native multi-file diff view for a set of before/after file contents.

ctx.ui.openSettings() — reveal Settings → Plugins, where this plugin's toggle, config form and (with permissions.ai) its billed-AI consent live. For the case where your UI has to say "I need your approval before I can do anything": send the user to the approval rather than describing where it is. Fire-and-forget; a warned no-op on a host with no settings surface.

ctx.storage (PluginStorage) — the plugin's own persistent dir at <globalStorage>/plugins/<name>/, independent of permissions.filesystem. readFile/writeFile/ exists/delete/list, all resolved under dir and traversal-blocked (a .. escape is denied). Created lazily, survives restart, removed on uninstall.

ctx.registerService(service) — register a supervised background service { name, start, stop? }. start() runs when the plugin is enabled+active; stop() on disable/uninstall/deactivate. Each start/stop is bounded by a 5 s timeout and error-isolated, so a hanging or throwing service can never crash the host. Returns a disposable that stops + removes the service.

Answering requests (handleRequest)

onUiMessage is fire-and-forget; when your UI needs an answer, implement handleRequest(method, params, ctx) and call it with api.request(method, params?, opts?) from the component. Errors propagate to the caller (they are not swallowed like observer hooks), so a failure surfaces instead of looking like an empty result.

The host answers the request on the machine that OWNS the focused task — a remote executor for a task running there — so a plugin-owned feature works the same locally and remotely. Three conventions:

  • prefix a method local: to force it onto the host the UI runs on (opening an editor/viewer, which a headless executor cannot do);
  • pass { mutates: true } for a state-changing request: the host refuses to route it to an executor while a local task shares the workspace;
  • return { rewound: true } if you rewound the task's conversation, so the controller resyncs its view of a remote task.
// ui/row.tsx
const result = await api.request("diff", { id }) // answered where the task runs
await api.request("local:show-diff", result, { mutates: false }) // rendered here

The host asks too. handleRequest also receives Shofer's own broadcast questions — core needs a fact that a feature owns, and asks every plugin rather than knowing which one provides it. Throw for a method you do not recognise; that is what marks you silent. The questions and their answer shapes are tabulated in docs/plugin_system.md (§ "Broadcast requests"); one worth knowing about here is "resolve-mcp-call-headers", asked before every MCP tool call:

async handleRequest(method, params) {
	if (method !== "resolve-mcp-call-headers") throw new Error("not mine")
	const q = params as { serverName: string; source: "global" | "project"; taskId?: string }
	// Answer only for a server you actually recognise — a header is a credential
	// handed to whatever URL that server names. "Nothing to add" is `{ headers: {} }`;
	// there is no error channel, because the call must go out either way.
	return isMine(q) ? { headers: { Authorization: `Bearer ${tokenFor(q.taskId)}` } } : { headers: {} }
}

This is the seam for anything that belongs to the RUN rather than to the host: a transport's own headers are fixed when it connects, and one connection serves every task.

"resolve-model-call-headers" is the same question one seam over, asked before every MODEL request (createMessage and completePrompt alike), with { operation, provider, model, taskId, parentTaskId, rootTaskId } and the same { headers } answer. A provider's SDK client is built once from the API configuration and shared by every task, so the same argument applies — but with one difference worth knowing before you answer it: the host refuses authorization, x-api-key and the other credential names outright, and never lets an answered header displace one the provider already set. An answer there annotates a model call and cannot re-authenticate it, which is why the question names no endpoint.


6. UI contributions

A plugin can render React components into named webview regions. Declare the regions in permissions.ui — this is the grant. A granted region renders either a first-party/co-bundled component (default) or your own compiled UI bundle when you point contributes.ui at a built module (see Shipping your own UI bundle). Regions:

  • chat-input-toolbar
  • task-header
  • settings-tab
  • chat-message-addon
  • chat-footer
  • sidebar-panel

The component is loaded into the webview by dynamic import (not a sandboxed iframe), so it shares the host's React and theme. It receives only a PluginUIApi:

  • postMessage(message) — send to this plugin's extension-side code. Every message is tagged with the plugin name, so it's routed only to that plugin.
  • 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? }.

The extension side receives your UI's messages via the onUiMessage(message, ctx) hook and pushes back with the host-side sender. No vscode API and no parent-DOM access are exposed.

Shipping your own UI bundle

A third-party plugin ships its own compiled UI bundle. Point a granted region at a built module with contributes.ui:

{
	"permissions": { "ui": ["chat-input-toolbar"] },
	"contributes": { "ui": [{ "region": "chat-input-toolbar", "entry": "ui/toolbar.js" }] }
}
  • permissions.ui is still the grant — a region must be listed there or the contribution is refused (fail-closed). contributes.ui[].region must be one of those granted regions.
  • entry is the built ESM module, relative to your plugin root (e.g. ui/toolbar.js). The extension serves it as a local vscode-webview:// resource (its dir is added to the webview's localResourceRoots) and the webview dynamic-imports it. A granted region without a contributes.ui entry falls back to a first-party/co-bundled component (unchanged behavior).

The build contract — externalize React and the kit. Your bundle must not bundle its own React: the host injects an import map so that react, react-dom, react/jsx-runtime (and react/jsx-dev-runtime, react-dom/client) resolve to the host's running React instance. Sharing one instance is what keeps hooks and context working — a second copy silently breaks them. The same map resolves @shofer/plugin-ui (next section) to the host's component kit. So build the entry as an ES module and mark those packages external. With esbuild:

esbuild ui/toolbar.jsx --bundle --format=esm --jsx=automatic \
  --external:react --external:react-dom --external:react/jsx-runtime \
  --external:@shofer/plugin-ui \
  --outfile=ui/toolbar.js

(or, with Vite/Rollup, build.lib + rollupOptions.external: ["react", "react-dom", "react/jsx-runtime", "@shofer/plugin-ui"]).

The module must default-export a React component taking a single { api: PluginUIApi } prop:

import { useEffect, useState } from "react"
export default function Toolbar({ api }) {
	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>
}

The bundle loads under the webview CSP without weakening it: script-src uses strict-dynamic + a nonce, so the nonced host script may dynamic-import your same-origin (vscode-webview://) module. Arbitrary external hosts remain blocked — only files under the plugin dirs are served. If your component throws while rendering, it is caught by an error boundary and unmounted; the host UI keeps working.

@shofer/plugin-ui — the host's components and your translations

Your UI shares more than React. Import the host's kit and render the same components the product does, instead of look-alikes that drift from it and behave differently under keyboard and focus:

import {
	Button,
	Popover,
	PopoverContent,
	PopoverTrigger,
	StandardTooltip,
	cn,
	usePluginTranslation,
} from "@shofer/plugin-ui"

export default function Toolbar({ api }) {
	const t = usePluginTranslation()
	return (
		<StandardTooltip content={t("toolbar.deployTooltip")}>
			<Button variant="ghost" size="icon" onClick={() => api.postMessage({ deploy: true })}>
				<span className="codicon codicon-rocket" />
			</Button>
		</StandardTooltip>
	)
}

Available: Button, Badge, Checkbox, Input, Textarea, ToggleSwitch, Progress, Separator, StandardTooltip, Popover*, Dialog*, Collapsible*, SearchableSelect, useShoferPortal, cn, usePluginTranslation. The full signature list is plugins/plugin-ui.d.ts — map it in your plugin's tsconfig paths to typecheck your UI:

{ "compilerOptions": { "jsx": "react-jsx", "paths": { "@shofer/plugin-ui": ["../plugin-ui.d.ts"] } } }

Styling needs nothing: your component renders inside the host document, so its CSS and VS Code theme variables already apply (text-vscode-descriptionForeground, codicon codicon-*, …). Icons are not re-exported — bundle lucide-react yourself if you want them.

Translations. Ship locales/<lang>.json in your plugin root and usePluginTranslation() reads them, with the host's interpolation, plural rules and language switching:

my-plugin/
├── plugin.json
├── locales/en.json      { "toolbar": { "deployTooltip": "Deploy this task" } }
├── locales/de.json
└── ui/toolbar.js

They are registered as the i18next namespace plugin:<your-plugin-name>, so your keys can never collide with the host's or another plugin's. A key with no translation renders as the key itself — visible, rather than silently blank. A plugin with no locales/ directory simply gets no translations.


7. Permissions & consent

Two independent gates decide what a plugin can do:

  1. Enable = per-plugin consent. A plugin is disabled by default. Enabling it (Plugins tab, or --enable on install) is the user's consent to run it at all. Disabling unregisters it: its tools/transforms/observers stop firing, its watchers and services are torn down.

  2. Manifest permissions gate each capability. A declarative contribution is only surfaced, and a code capability only reachable, when the matching permissions key is granted. fs/network/ filesystem calls are checked at runtime against the allowlists; lifecycle hooks don't fire without permissions.lifecycle; UI regions must be in permissions.ui.

A deployment can decide both, by env. SHOFER_DISABLED_PLUGINS suppresses plugins (a suppressed one never loads and the panel toggle refuses); SHOFER_ENABLED_PLUGINS activates them (on without a user enabling, for a host that was provisioned with the plugin — a headless pod whose job is to run it). Suppression wins if a name is in both. Activation is not a way around anything else: permissions, AI consent and dependency resolution are unchanged, and there is no way for a plugin to put its own name in either list.

And it can decide where the code comes from. SHOFER_PLUGIN_DIRS (a PATH-style list of absolute directories) names extra roots to scan, read-only and last — after the user and project roots, so nothing in either can shadow a name the deployment provisioned. It exists because the three standard roots are all writable by the person the plugin may be there to constrain: a plugin under ~/.shofer/plugins can be edited, replaced, or moved aside by anyone holding a shell in that home directory, and a plugin its subject can rewrite enforces nothing. A host that mounts the code somewhere the user cannot write, and names that path here, closes all three. Plugins found this way cannot be uninstalled (the directory is read-only, and the code is not the user's to delete); the enable toggle still applies, and SHOFER_DISABLED_PLUGINS still wins over everything.

AI billing is a third, separate consent. permissions.ai alone is not enough: ctx.ai only becomes live after the user grants the "uses AI (billed)" consent in the Plugins panel (a distinct toggle from enable). Granted-but-unconsented gives a denying ctx.ai stub; consent can be revoked at any time. Plugins never receive raw API keys. One exception to the default: a bundled plugin declaring defaultEnabled: true is consented by default — a plugin the product ships on should not sit inert behind a second approval — and the user's explicit revocation in the Plugins panel still wins, exactly like the explicit-off that beats defaultEnabled itself.

The plugin-API version is also enforced: a plugin whose shoferPluginApiVersion is incompatible with the host (major mismatch, or the host is older than the minor/patch it needs) is refused at load, before any of its code runs.


8. Packaging & install

The archive

A distributable plugin is a .shofer-plugin file — a gzip tarball of the plugin directory's contents (the plugin.json, code, and asset dirs sit at the archive root; there is no wrapping directory). The archive must contain a valid plugin.json at its root. Unpacking is hardened against zip-slip (absolute paths, .. segments, symlink/hardlink entries are all rejected).

CLI

# Install from a .shofer-plugin archive, an unpacked plugin directory, OR a direct http(s) URL
shofer plugin install <source> [--enable] [--overwrite] [--allow-insecure-http]

# List installed plugins and their state (enabled / disabled / inactive)
shofer plugin list [--json]

# Remove a plugin (deletes its dir, drops it from the enabled list)
shofer plugin remove <name>

<source> may be a local .shofer-plugin archive, an unpacked plugin directory, or a direct http(s) URL to a .shofer-plugin archive. A URL source is downloaded and unpacked through the same manifest-validation / zip-slip-hardened path as a local archive; https is required unless the host is loopback or you pass --allow-insecure-http (see the https/size policy below).

The CLI installs into the global dir (~/.shofer/plugins/<name>) and shares the enabled allow-list with the running app, so a plugin installed/enabled here is picked up by the extension and the Plugins settings tab unchanged. Without --enable, a freshly installed plugin stays disabled (the per-plugin consent gate). --overwrite replaces an already-installed plugin of the same name (the upgrade path).

Install surfaces

Install/uninstall are CLI verbs and declarations only — there is no webview install surface. The Settings → Plugins tab shows the discovered-plugin list with enable/disable toggles, schema-driven config editing, the settings-tab UI region, and the "uses AI (billed)" badge + consent affordance ({ action: "setAiConsent" }). Freshly installed plugins stay disabled (the per-plugin consent gate), so you enable them there afterward.

Direct-URL install is supported; a remote registry is not. There is no shofer plugin search / install name@version / hosted directory — install is a local archive, a local directory, or a direct archive URL. Registry lookup + a trust/signing chain stay deferred.

The https / size policy (URL install)

The CLI (shofer plugin install <URL>) goes through the host-agnostic core helper:

  • https only by default. A plain http:// URL is refused unless the host is loopback (localhost/127.0.0.1) or, on the CLI, --allow-insecure-http is passed.
  • Size-capped — the download is bounded (default 64 MiB) to prevent an oversized-response DoS.
  • Validated + hardened — the fetched bytes are unpacked through the same .shofer-plugin pipeline: a valid plugin.json at the archive root is required, and unpacking rejects absolute paths, .. segments, and symlink/hardlink entries (zip-slip hardened).

Plugin declarations (.shofer/plugins.json)

The discovery directories in §1 and the CLI installs in §8 cover a plugin whose code is already present as an installed directory. A .shofer/plugins.json declaration covers the complementary need — stating which plugins a scope wants, from where, and at which version — without committing the plugin bytes. "Declare, don't vendor": only the declaration lives in .shofer/, so the tree stays text-only, reproducible, and zip/overlay-able. Plugin config already flows through .shofer/settings.json (pluginConfigs); the declaration adds the missing source/version/enablement lockfile.

The declaration file

.shofer/plugins.json is validated fail-closed (pluginDeclarationSchema in plugin-declaration.ts):

{
	"version": 1,
	"plugins": {
		"git-guard": { "source": "./plugins/git-guard", "version": "1.0.0", "enabled": true },
		"acme-ci": {
			"source": "/opt/plugins/acme-ci.shofer-plugin",
			"version": "2.1.0",
			"config": { "baseUrl": "https://ci.acme.example" }
		}
	}
}

Each entry is { source, version, config?, enabled? }:

  • source — a local directory path or a local .shofer-plugin archive path, or an http(s) URL to such an archive resolve. A URL is downloaded under the same policy as shofer plugin install <URL> (https-only unless the host is loopback, size-capped) and unpacked through the same hardened path, so manifest validation and zip-slip protection apply wherever the bytes came from. A content-addressed URL — one whose filename is sha256-<hex>.shofer-plugin — additionally pins the bytes: the resolver verifies the digest and refuses a mismatch, so a URL that starts serving different code fails the load instead of silently swapping it. A pinned URL is also the one case where plain http is accepted: the digest IS the integrity proof, so tampering in transit fails the check and installs nothing. An unpinned http URL is still refused — there, nothing would detect a swap. The pin rides in the filename because this schema is .strict() and parsing fails closed — an unknown digest key would discard every declaration in the file, not just be ignored. A source that cannot be materialized at all (missing path, unreachable URL, digest mismatch) raises PluginResolveError, isolated per-declaration so it never blocks discovery of the physically-present plugins.
  • version — the version the resolver materializes under.
  • config — the user's config overrides, merged with the manifest's config defaults (see §3).
  • enabled — defaults to true.

Merge across scopes and governance

The three scopes' declarations are cross-merged per plugin name by mergePluginDeclarations, under the same locked.json engine as the rest of the layered config (see configuration.md):

  • Unlocked (or global does not declare it) → more-specific wins: project ?? user ?? global. A user/project may always add plugins the global scope did not declare.
  • Locked (plugins/<name> in the global scope's locked.json) → the global scope's entry wins and is final; user/project entries for that name are dropped, and the plugin is force-enabled with its declared config authoritative per key. This is the governance payoff: a read-only org-global .shofer/ can mandate "these plugins, these versions, this config — non-negotiable", while users still add their own unlocked plugins.

Resolution and wiring

resolvePluginDeclaration materializes each declared source@version into a content-addressed cache dir <globalStorage>/plugins-cache/<name>@<version>/ — a directory source is copied, a .shofer-plugin archive is unpacked, and an already-materialized dir is reused idempotently. The materialized plugin.json is validated against the manifest schema and its name checked against the declaration key; a mismatch skips that one plugin with a warning.

The host loader (pluginDeclarationLoader.ts) then folds the resolved plugins into PluginManager discovery: each cache dir is appended to the scan list (alongside the bundled, global ~/.shofer/plugins/, and project <ws>/.shofer/plugins/ dirs), its declared config seeds pluginConfigs (the user's stored values win per key for an unlocked plugin; the declaration wins per key for a locked one), and an enabled !== false (or locked) plugin is enabled. The path is purely additive — with no plugins.json anywhere it resolves nothing and discovery is unchanged. A declared plugin still passes through the same enable/permission/AI-consent gates as any other (§7).


9. Worked examples

A guardrail plugin

A guardrail plugin: a status tool, a beforeToolCall hook that blocks force-pushes, and a config flag. It uses permissions.tools + permissions.lifecycle + permissions.network.

plugin.json

{
	"name": "git-guard",
	"version": "1.0.0",
	"shoferPluginApiVersion": "1.0.0",
	"description": "Blocks risky git commands and reports CI status.",
	"main": "index.ts",
	"permissions": {
		"tools": true,
		"lifecycle": true,
		"network": ["https://ci.acme.example"]
	},
	"config": {
		"type": "object",
		"properties": {
			"blockForcePush": { "type": "boolean", "default": true }
		}
	}
}

index.ts

import { defineCustomTool, parametersSchema as z } from "@shofer/types"
import type { PluginContext, ShoferPlugin } from "@shofer/types"

const plugin: ShoferPlugin = {
	name: "git-guard",

	async initialize(ctx: PluginContext) {
		ctx.host?.notifier.info("git-guard active")
	},

	registerTools(ctx: PluginContext) {
		return [
			defineCustomTool({
				name: "ci_status",
				description: "Fetch the latest CI status for a branch.",
				parameters: z.object({ branch: z.string().describe("Branch name") }),
				async execute({ branch }) {
					// host.fetch is scoped to permissions.network
					const res = await ctx.host!.fetch(`https://ci.acme.example/status?branch=${branch}`)
					return await res.text()
				},
			}),
		]
	},

	lifecycle: {
		// Allow / modify / block — here we block `git push --force`.
		async beforeToolCall(toolName, args, ctx) {
			const blocking = ctx.config?.blockForcePush !== false
			const cmd = typeof args.command === "string" ? args.command : ""
			if (blocking && toolName === "execute_command" && /\bpush\b.*--force\b/.test(cmd)) {
				return { allow: false, reason: "git-guard: force-push is blocked by policy." }
			}
			return { allow: true }
		},
	},
}

export default plugin

Install and enable it:

shofer plugin install ./git-guard --enable

Now the model sees a ci_status tool (network-scoped), and any attempt to run git push --force via execute_command is short-circuited with the guard's reason — unless the user sets blockForcePush: false in the plugin's config.

A feature as a plugin: Checkpoints (first-party, bundled)

plugins/basics/ (its checkpoints feature) is per-task undo history — shadow-git snapshots, the timeline row with diff/restore, cleanup on task deletion — implemented entirely on the public surface. It is the reference for a plugin that owns a feature rather than adding a tool:

What it does Extension point
Snapshot before a file-mutating tool lifecycle.beforeToolCall, awaited, once per ctx.turn, under a manifest hookTimeoutMs
An anchor per user message lifecycle.onUserMessage
The checkpoint row in the chat ctx.task.marker + a chat-message-addon bundle
Diff / restore from that row handleRequest + api.request (incl. the local: and mutates conventions)
Rewinding the conversation ctx.task.rewind
Rolling the workspace back on a message delete/edit lifecycle.onTimelineRewind
Dropping a deleted task's shadow repo lifecycle.onTaskDeleted
Rendering a computed diff ctx.host.editor.showMultiFileDiff
Being on out of the box manifest defaultEnabled (bundled scope only)

It also shows the packaging end: build-ui.mjs bundles both the UI and main.mjs (with simple-git inlined), so the plugin ships with no node_modules and packs to a single .shofer-plugin archive.

A capability-rich plugin: Live Memory (first-party dogfood)

The repo ships plugins/live-memory/ — a real, first-party plugin that re-implements the core of Shofer's built-in Live Memory using only the public plugin surface (no reach into @shofer/core internals). It is the reference for the Phase-6/7 capabilities, exercising most of them in one plugin. Read its source; it is the canonical worked example.

Its plugin.json grants tools, systemPrompt, lifecycle, events, ai, and filesystem: ["."], and each capability maps to a public extension point:

What it does Extension point
The ask_live_memory tool registerTools
A memory section appended to the prompt transformSystemPrompt (gated on ctx.ai?.hasConsent(), not a bare !!ctx.ai)
Answering / summarizing from memory ctx.ai.buildHandler (never sees keys) + the billed-calls consent
Persisting observations + Q&A across restarts ctx.storage (its own traversal-blocked dir)
Observing external edits ctx.host.watch(glob, cb) — uses the path-carrying cb(event: { path, type }) to record which file changed
Observing Shofer's own file activity lifecycle.afterToolCall (+ beforeTaskStart / afterTaskComplete, onEvent)
Periodic background compaction of the memory log ctx.registerService (a supervised { name, start, stop } service)

Key files: plugins/live-memory/main.ts (the ShoferPlugin), memory-store.ts (the ctx.storage wrapper), memory-llm.ts (the ctx.ai calls), system-section.ts (the prompt section), and plugin.json (the manifest + config schema). plugins/live-memory/DOGFOOD.md documents the full built-in → plugin-API mapping, the reduced-fidelity notes, and the one genuine gap (the external-edit watch granularity) that Phase 7's path-carrying watch closed.


Reference

  • Manifest schema, ShoferPlugin, PluginContext, PluginHost, PluginAi, PluginStorage, PluginService, PluginUIApi: packages/types/src/plugin.ts
  • Runtime (manager/loader/registry/sandbox/ai/storage/services/pack): packages/core/src/plugins/
  • CLI: apps/cli/src/commands/plugin/
  • UI: webview-ui/src/components/settings/PluginsSettings.tsx, webview-ui/src/components/plugins/ (PluginSlot, component resolver)
  • Worked examples: plugins/live-memory/ (+ plugins/live-memory/DOGFOOD.md) and plugins/basics/ (+ its DESIGN.md) — the latter is a whole feature set (per-task undo history) living outside core on beforeToolCall + ctx.task + onTimelineRewind + handleRequest.