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.
- Motivation
- Design Goals
- Architecture Overview
- Plugin Manifest
- Extension Points
- Plugin Lifecycle
- Security Model
- Distribution & Discovery
- Relationship to MCP
- Relationship to Existing Subsystems
- UI Integration
- Comparison with OpenCode and Claude Code
- Deferred
- Agent-Control API for Workflow/Runner Plugins
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).
-
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).
-
Declarative manifest. A
plugin.jsondeclares what the plugin contributes. Shofer reads it and wires the extension points without the plugin needing to know Shofer internals. -
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. Novscodeimports in plugin code. -
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.
-
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.
-
Distributable. Plugins are packaged as
.shofer-pluginarchives; installation is one CLI command (or a.shofer/plugins.jsondeclaration). -
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.
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 exposescollectTools(),applySystemPromptTransforms(),applyLifecycleHook(),dispatchEvent(), and arevisioncounter (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
<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)
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: 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 sufficient —
ctx.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.
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
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.
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.)
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.
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.)
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).
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).
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.
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.
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 |
A plugin ships rules markdown, optionally scoped to specific modes. These are
injected into the system prompt via addCustomInstructions().
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 theonUiMessage(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.)
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
Every telemetry event is forwarded to plugin onEvent observers. The
PluginEvent carries a typed name, optional properties, taskId, and
timestamp.
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.logis present for every plugin. It is aPluginLogger(@shofer/types) writing to aPlugin:<name>Log category (Settings → Logging), backed byplugin-log.ts(getPluginLogger(name),PLUGIN_LOG_CTX_PREFIX = "Plugin:"). Thectxtag is the single source of truth for the category name; unlikectx.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/observeinto 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_EVENTentry withplugin/eventproperties, 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.stackor 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 theTELEMETRY_ENABLEDbuild flag and the user'sTelemetrySetting.
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-indexingplugin for indexing errors and segment reuse. - The catalog stays core's. Every plugin event arrives as the single
-
ctx.ai— provider access.ctx.ai.buildHandler(profileRef?)(async — profile resolution viaProviderSettingsManager.getProfileis async) resolves a host-configured provider profile (the default whenprofileRefis omitted) and returns the sameApiHandlerbuildApiHandlerreturns.ctx.ai.embed(texts, profileRef?)returnsnumber[][]from the embedder the bundledrag-indexingplugin has configured (the host forwards to it — the thinnest seam that gives a plugin real vectors;profileRefis 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 onpermissions.aiand the billed-calls consent (§7): ungranted ⇒ctx.aiabsent; granted-but-unconsented ⇒ a denying stub (throw + warn). Construction is host-side (it needs the extension'sProviderSettingsManager), injected intoPluginManagervia aPluginAiProviderseam (plugin-ai.ts) so@shofer/corestays host-agnostic. -
ctx.storage— per-plugin persistent dir.ctx.storage.diris<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 ofpermissions.filesystem— it is the plugin's own sandbox. The base path is host-provided viaPluginManagerOptions.storageBaseDir(plugin-storage.ts). -
ctx.host.watch(pattern, cb)— scoped file watch. Gated by the plugin'spermissions.filesystemscope — it only ever watches inside granted paths (one hostFileSystemWatcherper granted root). Without afilesystemgrant it is a deny + warn (no-op disposable). The callback is path-carrying:cb(event: { path, type: "create" | "change" | "delete" }). Host-backed via theHostWatcher/HostFileWatcherseam; 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.PluginManagerowns the registry (viaPluginServiceSupervisor,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 dedicatedpermissions.mcpInvokegrant (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 aPluginMcpProviderseam (plugin-mcp.ts) wired inShoferProvider.getPluginManageragainstgetMcpHub(), so the call rides the same hub — and the same per-call header machinery — the agent's ownuse_mcp_tooldoes. 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, actx.host.watchcallback, 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 theenvironment_detailsdigest on the task's next request, and read withwait.There is no delivery MODE to choose. What four modes used to express is carried by fields the caller fills in:
Field Decides kindnotification(no answer expected) orrequest(areplyis), orreplywakewhether a task whose loop has STOPPED is resumed for this message — a stopped task is rehydrated from history if need be deadlineabsolute epoch ms; past it the envelope expires out of the box unread planeinformational — local/bus/a2a/temporal, rendered in the digestThe host fills
to(the resolved target task) andsent_at, and mintsidwhen the caller supplied none. A caller owning an upstream idempotency key — an A2Amessage_id, a Temporal message id — MUST pass it asid, because a mailbox already holding that id acknowledges the delivery without appending it again; that is what makes a retry safe.taskIdnames 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.agentgrant (steering the agent has billed/behavioral impact): ungranted-but-seam-wired ⇒ denying stub; no seam ⇒ absent. Host-side behind aPluginAgentProviderseam (plugin-agent.ts) mirroringPluginAiProvider, wired inShoferProvider.getPluginManageragainst the provider's task stack anddeliverToTask. -
ctx.agent.registerMailboxTransport(transport)— how a message LEAVES the node. The mirror ofdeliver. The core mailbox resolves an outboundtolocally (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 whosecanRoute(to)accepts it owns the delivery. That is what lets the agent's onesend_messagetool address a peer across the A2A mesh without the tool knowing a mesh exists.canRouteis 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 answerstrueand fails insend. 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. Samepermissions.agentgrant: 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
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 aspostMessage, 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.
stdioservers are never asked (a pipe has no headers; such a server reads the task id from_metainstead).
"resolve-model-call-headers" is its twin on the LLM side, asked once per model request
— createMessage 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-keyand the other credential and transport names outright, and the sharedfetchonly 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 customfetch— 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.resolvePluginUiRequest → pluginRegistry.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. |
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>
}markerappends asay: "plugin_marker"message, persisted with the task and rendered by that plugin'schat-message-addoncomponent (the host renders no chrome and never interpretskind/data).suppresskeeps an anchor out of the rendered timeline;restorableis what makes the delete/edit dialog offer to roll back plugin-held state.listMarkersreads them back in order — how a plugin recovers its anchors after a restart without keeping a second, drift-prone copy inctx.storage. Scoped to the calling plugin: one plugin can neither see nor rewind another's.rewindtruncates the conversation tots, 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.setCwdre-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.openTaskopens and focuses a NEW task, optionally in another directory, and resolves with its id. Withouttextthe task is idle and waits for the user — the distinction fromctx.agent.spawn, which starts an agent run (prompt in, awaitable result out,permissions.agentbecause it bills). Thebasicsplugin'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.
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
PluginManager scans three roots (see §8 for scope
semantics):
<extension>/dist/plugins/— bundled first-party plugins.~/.shofer/plugins/— global plugins.<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.
pluginManifestSchema.safeParse(manifest) — invalid manifests are skipped with a
warning logged to the output channel.
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.
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.
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.
- 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).
- Reload —
PluginManager.reloadPlugin(name)re-reads and re-registers a single plugin; used when its config or AI-consent changes soctx.config/ctx.aireflect 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.
| 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. |
- Code plugins (with
main) run in the host process but with a restrictedPluginContextwrapping the host surface with permission checks (plugin-sandbox.ts). - UI plugins load via dynamic
import()with a restrictedPluginUIApi— no directvscodeAPI, no parent-DOM access, only a plugin-scoped message channel. External bundles are served local-only (vscode-webview://under the plugin dir) under astrict-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.
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.aiis constructed only when both (a) the manifest grantedpermissions.ai, and (b) the user AI-consented this specific plugin. Consent is persisted separately (shofer.plugins.aiConsentedPlugins). - Fail-closed states.
permissions.aiabsent ⇒ctx.aiabsent. Present but not consented ⇒ a denying stub: everybuildHandler/embedcall 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.PluginViewcarriesusesAiandaiConsented; toggling consent issues a{ action: "setAiConsent" }PluginRequestand reloads the plugin soctx.aiflips live/denied. - No key exposure. Even fully consented, the plugin receives only the
ApiHandlerthe host built — never provider settings or API keys.
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).
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 setsdefaultEnabled(a plugin that IS a Shofer feature does). The esbuild build (src/esbuild.mjs) copiesplugins/**→dist/plugins, auto-builds each plugin's UI bundles (running itsbuild-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/typesSDK (dist/plugin-sdk/node_modules/@shofer/types, so a bare@shofer/typesimport 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/typesruntime footprint); seePLUGINS.md. A bundled plugin may ship vendored dependencies (basics vendorssimple-gitassrc/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).
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.
A plugin is distributed as a .shofer-plugin archive (gzip tarball) containing
plugin.json, the entry point, and the contribution directories.
# 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 (isPluginUrl → installPluginFromUrl) 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.
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.
CustomToolRegistry(packages/core/src/custom-tools/) is the implementation behindregisterTools. Plugin tools carrysource: "plugin"+pluginName;.shofer/tools/file loading continues to work as another source. Plugin-source tools are dispatchable regardless of thecustomToolsexperiment (§5.1).SkillsManagergains plugin-contributed skills fromcontributes.skills(dirs supplied byPluginManager). Plugin skills are namespaced (qualifiedSkillName), not merged by precedence, so they can't shadow file skills;privateskills are excluded from user-facing enumeration.CustomModesManager—getAllModes()includes plugin modes fromcontributes.modes, emitted under the namespaced slug<plugin>:<authoredSlug>withsource: "plugin";privatemodes are agent-switchable but hidden from the picker.McpHubreads MCP configs from plugin manifests (contributes.mcpServers), merged with.shofer/mcp.jsonandmcp_settings.json.- Checkpoints are no longer a core subsystem: per-task undo history is the bundled
basicsplugin's checkpoints feature, built onbeforeToolCall+ctx.task+onTimelineRewind+handleRequest(plugins/basics/docs/checkpoints.md). Core keeps only those generic seams — no shadow-git, noenableCheckpointssetting, no checkpoint-specific wire methods.
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.
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.
Plugin components in the task-header region render as badges/rows in the expanded
TaskHeader — e.g. CI status, coverage deltas.
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 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'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. |
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).
Status:
ctx.agent.spawn/canceland the wholePluginTaskHandle/PluginTaskResultsurface below are SHIPPED (packages/types/src/plugin.ts, host seam inShoferProvider.buildPluginAgentProvider), includingmode,completionSchemaandsessionIdon the spawn options. What is not shipped is §14.2'sunattended/approvalPolicypair and §14.3's non-HTTPpermissions.networkgeneralization; 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 rawShoferExtensionApi.
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.
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
}-
modeis 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 makesspawnreject (with an error whosenameisPLUGIN_UNKNOWN_MODE_ERROR) and start no task; substituting the default mode would hand the caller a different agent, on a different model, reporting success. -
outputis the task's answer, not a status line. It is theattempt_completionresult as the agent rendered it — read from the task's lastcompletion_resultmessage when it settles. A caller awaitingresult()has no chat to read, so a host that declined to set this would leave the whole point ofspawn(as opposed todeliver) unreachable. It is absent, not empty, when the task ended without declaring an answer. -
spawnis bounded by the host's global parallel-task limit (maxParallelTasks,parallelism.md) — the same limit and the same defaultnew_taskenforces, 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 whosenameisPLUGIN_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'sspawnsubscriptions 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. -
taskIdis also the SESSION handle. Passing it back asopts.sessionIdcontinues 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_completionleaves the instance in place withabortset, 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
modeon 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.aihands out a scopedApiHandler(never raw keys): the plugin gets task control, not the task stack orShoferExtensionApi. 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 existingPluginAgentProviderseam so@shofer/corestays host-agnostic (the host binds the concrete task stack). Ungranted ⇒ denying stub, per §7. -
Completion + result.
afterTaskComplete(§5.9) andTaskHandle.result()carry the structuredTaskResult(today the lifecycle context carries only areason— this adds the result payload). -
ctx.agent.deliverstays as the message path (inbound delivery into an existing task's mailbox);spawn/cancelare 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 onask(). Inunattendedmode any approval not granted by theapprovalPolicyresolves 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, socheckAutoApproval()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.
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.networkto 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 tofetch. - Host-mediated client seam (heavier, likely unnecessary): a
ctx.hoststreaming/socket client analogous toctx.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.
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.
| 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. |
{ "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" }, }, }, }