Latest commit

History

History
310 lines (249 loc) · 14.5 KB

File metadata and controls

310 lines (249 loc) · 14.5 KB

The C2 Plugin Protocol

Status: normative protocol contract for external process extensions.

Version 1.0.0.

This document defines the process wire format. Bundle terminology, manifest namespacing, trust, policy, contribution support, and host capability rules are normative in the C2 Plugin Standard 1.2.0.

An external extension runtime is a process. C2 speaks JSON-RPC 2.0 to it over stdio. Its commands are declared statically in the Bundle Manifest; initialize confirms their implementation and may subscribe to events. The host registers both in the same scoped kernel registries used by built-in Rust runtime modules. Installed runtimes have stable managed names of the form bundle:<id>. Their commands appear in kernel.commands, are callable from a frontend in the matching command realm, and disappear the instant the runtime unloads.

Write one in any language that can read stdin and write stdout.

Why a protocol at all

docs/reference/plugins.md defines C2's internal runtime-module graph, but a Rust host can only load modules it was compiled with. This protocol is the external extension seam, using the transport the app already speaks twice over (ACP to provider CLIs, MCP to tool servers) rather than inventing a third.

Transport

  • Framing — one JSON object per line, UTF-8, \n-terminated. No Content-Length header.
  • stdout is the protocol channel. Anything on it that is not JSON is dropped with a warning, so a stray print() degrades to noise rather than a crash.
  • stderr is your log channel. C2 routes it into its own tracing output.
  • The process is killed when the plugin unloads, and it never outlives the app.

Handshake

On the first invocation of a declared command, the host starts the process, sends initialize first, and waits 10 seconds. Answer it promptly. A process that misses the window is killed and that scope generation's activation fails; its dormant command stubs remain fail-closed until the plugin is reloaded or disabled and re-enabled.

Host → plugin

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion": "1.0.0",
"host": { "name": "code2", "version": "0.0.0", "commands": ["git.status"] },
"config": { "…": "your entry from the plugin config, verbatim" },
"dataDir": "/home/me/.codetwo/plugins/.data/my-plugin/projects/09a7…",
"projectPath": "/home/me/work/my-project"
}}

Plugin → host

{"jsonrpc":"2.0","id":1,"result":{
"name": "my-plugin",
"version": "0.1.0",
"protocolVersion": "1.0.0",
"commands": [
{ "name": "my.greet", "description": "Say hello.", "schema": { "type": "object" } }
],
"events": ["engine/event"]
}}

For a C2 1.2 bundle, commands MUST contain exactly the IDs and schemas declared by extensions.dev.codetwo.commands. It is implementation confirmation, not a second contribution source: missing, extra, duplicate, or changed schemas are refused and the process is killed. The host uses Manifest titles and descriptions.

Wire compatibility is by major version. Declaring 2.x to a 1.x host, or omitting protocolVersion, is refused with a readable error.

dataDir is yours to write to and is created before you start. A user-scoped instance gets <plugins-dir>/.data/<id>. A project-scoped instance gets <plugins-dir>/.data/<id>/projects/<blake3(normalized-project-path)>, so two projects do not share runtime files. projectPath is the normalized project identity for that instance and is omitted for the user-scoped instance.

host.commands is the extension-public callable surface visible from this realm at initialization time. Internal Core and frontend commands are omitted. A user-scoped instance sees public global commands only. A project instance sees public global commands plus public commands registered for the same normalized project; it never receives another project's command list. command/call rechecks the public marker and uses that same realm's normal project fallback/blocking rules, so guessing an internal command name does not grant access.

Methods

Host → plugin

methodkindparamsresult
initializerequestsee abovesee above
command/invokerequest{ name, args }whatever your command returns
event/emitnotification{ name, payload }

command/invoke only ever names a command declared in the Manifest. Returning a JSON-RPC error turns into a readable failure at the caller — the frontend sees my.greet: <your message>. An extension command that collides with a global command owned by Core or another module is rejected during activation, so a project extension cannot shadow host dispatch. The same project-capable extension may register its own command name in both global and project instances.

When a user activates a manifest ui contribution, C2 first verifies bundle ownership, trust, enablement, the selected user/project realm, and that the contribution's command was registered by this process. The resulting command/invoke uses these args:

{
"context": { "cwd": "/repo", "projectPath": "/repo", "sessionId": "session-id" },
"input": { "mode": "working-tree" }
}

context is host state for this activation; input is the descriptor's static JSON value. Neither is a capability grant. Host commands remain accessible only through the allowlisted, realm-aware command/call seam.

Plugin → host

methodkindparamsresult
command/callrequest{ name, args }the extension-public command's result
event/emitnotification{ name, payload }
lognotification{ level, message }

command/call reaches an extension-public command visible in the process's realm, by name, through the same registry a Rust runtime module uses. Commands are internal by default and must be deliberately published by Core. git.status is the initial read-only public command; mutating Git, plugin management, credentials, and other Core commands stay internal. If a public command's owner is turned off or project fallback is blocked, the call fails through the normal command path.

level is one of error, warn, info, debug, trace.

Events

You receive only the events you name in events. The host publishes:

namepayload
engine/eventone agent-loop Event (agent text, tool call, permission request, turn ended, …)
skills/changednull — the skill library was rebuilt
scenes/changednull — the scene library was re-resolved

This list is the contract. Typed Rust events do not cross a pipe, so each entry is a deliberate decision to expose one — see publish_host_events in crates/plugins/src/app/plugins/extensions.rs. Because activation is command-driven, event subscriptions begin only after the first command has successfully initialized the process; events emitted while the runtime is dormant are not buffered or replayed.

Your own event/emit normally goes onto the host's JSON bus, where other plugins (in or out of process) can subscribe to it. The JSON bus is currently host-wide, not project-confidential: project process isolation does not filter events by realm. Do not put project secrets on an event merely because the sender or subscriber is a project-scoped runtime.

connector/event is reserved for a host-rendered connector's provider notifications. It does not enter that public JSON bus. The host wraps the payload in a typed internal event and adds the authenticated installed-bundle id; any plugin_id supplied by the process is ignored. Desktop adapters must match that owner and the active connector's bundle-local connectorId before using the event. A connector event payload uses this minimum envelope:

{
"connectorId": "workspace",
"eventId": "provider-event-or-message-id",
"kind": "message.created",
"createdAt": "1724900000000"
}

Provider-specific resource ids and bounded summaries may be added. The process must deduplicate at-least-once provider delivery before emitting; the host-rendered adapter repeats that guard before changing local activity state. Event payloads are not a way to inject UI or bypass connector command ownership.

Declaring a plugin

Every C2 bundle uses the Agent Plugins 1.0.0 root schema and the mandatory C2 extension. Add the runtime under C2's client-extension namespace; a top-level runtime field invalidates the bundle:

{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "my-plugin",
"version": "1.0.0",
"extensions": {
"dev.codetwo": {
"standardVersion": "1.2.0",
"commands": [{
"id": "my.greet",
"title": "Say hello",
"description": "Greet the current user.",
"argsSchema": { "type": "object", "additionalProperties": false }
}],
"runtime": {
"protocol": "1.0.0",
"command": "node",
"args": ["dist/plugin.js"],
"env": { "MY_PLUGIN_MODE": "release" },
"inject": ["store"],
"optionalInject": ["engine"],
"scopeSupport": ["user", "project"]
}
}
}
}

When activated, the process starts with the bundle directory as its working directory.

inject gets you the same reactive contract a Rust plugin has: command stubs are not made ready until those services exist, and an active process is stopped when one is replaced. It is declared in the Manifest because the host needs it before making the adapter eligible.

scopeSupport is an explicit capability declaration. If it is omitted, the runtime supports only ["user"]. Include "project" only when the process is prepared for one independently managed process, command realm, dataDir, and projectPath per active project. The bundle's skills and other data-only extension components are not made project-scoped by this field; they remain user-only and are managed through Bundle Tools.

A process runtime declares at least one sibling commands entry. UI action descriptors are declared beside both under extensions.dev.codetwo.ui; they do not alter this wire protocol or load third-party renderer code.

extensions.dev.codetwo.languageServers is a separate host-owned stdio LSP contribution. Language servers use standard Content-Length LSP framing, not this newline-delimited plugin protocol. They may exist without a C2 process runtime and still share bundle trust, enablement, and teardown.

Trust

Installing a bundle executes nothing. That property of the Plugin Hub is not weakened by this protocol — it is the reason the protocol is shaped this way.

An enabled and trusted bundle is eligible: its host adapter and dormant command stubs become ready, but the process does not start until the first declared command invocation. Trust is a separate, deliberate user action, and installing a bundle that ships a C2 runtime raises a diagnostic saying so. Until then the plugin is listed by extensions.list under untrusted.

Trust is a hard gate in every realm. No configuration setting can bypass it.

Lifecycle

enabled + trusted ──▶ register static stubs ──▶ ready, process dormant
│ first command
▼
spawn + initialize
│
exact command/schema confirmation ──▶ invoke
│
timeout / mismatch ──▶ process killed; activation terminal
caller cancellation ──▶ process killed; next invocation may retry
unload, dependency lost, bundle disabled/removed ──▶ process killed; stubs removed

Unloading is exact: the process is killed and every command it contributed is gone. Nothing has to remember to clean up, because the plugin's scope owns all of it.

Installed runtimes are ordinary dynamically registered factories named bundle:<id>. Installing, removing, enabling, trusting, or replacing a bundle reconciles that factory set and every live eligible realm immediately. Add and remove do not require an application restart; replacing a manifest or installed record rebuilds the same-named runtime because its factory revision changed. User and project state changes go through plugins.catalog, plugins.plan_change, and plugins.apply_change, including stale-plan protection and immediate unload.

A complete, runnable example lives in packs/hello-runtime/: it contributes a command, calls git.status back through the host from inside it, feature-detects the extension-public surface, and listens for a host event — in one file with no dependencies.

A minimal plugin

#!/usr/bin/env node
constreadline=require("readline");constrl=readline.createInterface({input: process.stdin});constsend=(m)=>process.stdout.write(JSON.stringify(m)+"\n");rl.on("line",async(line)=>{constmsg=JSON.parse(line);if(msg.method==="initialize"){send({jsonrpc: "2.0",id: msg.id,result: {name: "hello",version: "1.0.0",protocolVersion: "1.0.0",commands: [{name: "hello.status",description: "Repo status, via the host."}],events: ["skills/changed"],}});}elseif(msg.method==="command/invoke"){// Call a host command and answer with what it said.send({jsonrpc: "2.0",id: 1000,method: "command/call",params: {name: "git.status",args: {cwd: msg.params.args.cwd}}});// (a real plugin would correlate the response by id)}elseif(msg.method==="event/emit"){console.error(`host event: ${msg.params.name}`);// stderr is the log channel}});

Limits, stated plainly

  • No sandbox. A trusted plugin is a process with your user's permissions. Trust is the whole OS security boundary; project command/data/process isolation does not restrict filesystem, network, environment, or the host-wide JSON event bus. Treat it as such.
  • No timeout on command/invoke. A plugin that never answers a command hangs that caller — not the graph. The handshake is the only bounded wait.
  • One process per activated managed realm. A project-capable runtime may have one global process and separate processes for multiple live projects. Fan out inside one realm if you need more.
  • Rust plugins are still compile-time. This protocol is how a plugin gets added without rebuilding C2; it is not dynamic loading of native code, and deliberately so.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Latest commit

History

History
310 lines (249 loc) · 14.5 KB

File metadata and controls

310 lines (249 loc) · 14.5 KB

The C2 Plugin Protocol

Status: normative protocol contract for external process extensions.

Version 1.0.0.

This document defines the process wire format. Bundle terminology, manifest namespacing, trust, policy, contribution support, and host capability rules are normative in the C2 Plugin Standard 1.2.0.

An external extension runtime is a process. C2 speaks JSON-RPC 2.0 to it over stdio. Its commands are declared statically in the Bundle Manifest; initialize confirms their implementation and may subscribe to events. The host registers both in the same scoped kernel registries used by built-in Rust runtime modules. Installed runtimes have stable managed names of the form bundle:<id>. Their commands appear in kernel.commands, are callable from a frontend in the matching command realm, and disappear the instant the runtime unloads.

Write one in any language that can read stdin and write stdout.

Why a protocol at all

docs/reference/plugins.md defines C2's internal runtime-module graph, but a Rust host can only load modules it was compiled with. This protocol is the external extension seam, using the transport the app already speaks twice over (ACP to provider CLIs, MCP to tool servers) rather than inventing a third.

Transport

  • Framing — one JSON object per line, UTF-8, \n-terminated. No Content-Length header.
  • stdout is the protocol channel. Anything on it that is not JSON is dropped with a warning, so a stray print() degrades to noise rather than a crash.
  • stderr is your log channel. C2 routes it into its own tracing output.
  • The process is killed when the plugin unloads, and it never outlives the app.

Handshake

On the first invocation of a declared command, the host starts the process, sends initialize first, and waits 10 seconds. Answer it promptly. A process that misses the window is killed and that scope generation's activation fails; its dormant command stubs remain fail-closed until the plugin is reloaded or disabled and re-enabled.

Host → plugin

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion": "1.0.0",
"host": { "name": "code2", "version": "0.0.0", "commands": ["git.status"] },
"config": { "…": "your entry from the plugin config, verbatim" },
"dataDir": "/home/me/.codetwo/plugins/.data/my-plugin/projects/09a7…",
"projectPath": "/home/me/work/my-project"
}}

Plugin → host

{"jsonrpc":"2.0","id":1,"result":{
"name": "my-plugin",
"version": "0.1.0",
"protocolVersion": "1.0.0",
"commands": [
{ "name": "my.greet", "description": "Say hello.", "schema": { "type": "object" } }
],
"events": ["engine/event"]
}}

For a C2 1.2 bundle, commands MUST contain exactly the IDs and schemas declared by extensions.dev.codetwo.commands. It is implementation confirmation, not a second contribution source: missing, extra, duplicate, or changed schemas are refused and the process is killed. The host uses Manifest titles and descriptions.

Wire compatibility is by major version. Declaring 2.x to a 1.x host, or omitting protocolVersion, is refused with a readable error.

dataDir is yours to write to and is created before you start. A user-scoped instance gets <plugins-dir>/.data/<id>. A project-scoped instance gets <plugins-dir>/.data/<id>/projects/<blake3(normalized-project-path)>, so two projects do not share runtime files. projectPath is the normalized project identity for that instance and is omitted for the user-scoped instance.

host.commands is the extension-public callable surface visible from this realm at initialization time. Internal Core and frontend commands are omitted. A user-scoped instance sees public global commands only. A project instance sees public global commands plus public commands registered for the same normalized project; it never receives another project's command list. command/call rechecks the public marker and uses that same realm's normal project fallback/blocking rules, so guessing an internal command name does not grant access.

Methods

Host → plugin

methodkindparamsresult
initializerequestsee abovesee above
command/invokerequest{ name, args }whatever your command returns
event/emitnotification{ name, payload }

command/invoke only ever names a command declared in the Manifest. Returning a JSON-RPC error turns into a readable failure at the caller — the frontend sees my.greet: <your message>. An extension command that collides with a global command owned by Core or another module is rejected during activation, so a project extension cannot shadow host dispatch. The same project-capable extension may register its own command name in both global and project instances.

When a user activates a manifest ui contribution, C2 first verifies bundle ownership, trust, enablement, the selected user/project realm, and that the contribution's command was registered by this process. The resulting command/invoke uses these args:

{
"context": { "cwd": "/repo", "projectPath": "/repo", "sessionId": "session-id" },
"input": { "mode": "working-tree" }
}

context is host state for this activation; input is the descriptor's static JSON value. Neither is a capability grant. Host commands remain accessible only through the allowlisted, realm-aware command/call seam.

Plugin → host

methodkindparamsresult
command/callrequest{ name, args }the extension-public command's result
event/emitnotification{ name, payload }
lognotification{ level, message }

command/call reaches an extension-public command visible in the process's realm, by name, through the same registry a Rust runtime module uses. Commands are internal by default and must be deliberately published by Core. git.status is the initial read-only public command; mutating Git, plugin management, credentials, and other Core commands stay internal. If a public command's owner is turned off or project fallback is blocked, the call fails through the normal command path.

level is one of error, warn, info, debug, trace.

Events

You receive only the events you name in events. The host publishes:

namepayload
engine/eventone agent-loop Event (agent text, tool call, permission request, turn ended, …)
skills/changednull — the skill library was rebuilt
scenes/changednull — the scene library was re-resolved

This list is the contract. Typed Rust events do not cross a pipe, so each entry is a deliberate decision to expose one — see publish_host_events in crates/plugins/src/app/plugins/extensions.rs. Because activation is command-driven, event subscriptions begin only after the first command has successfully initialized the process; events emitted while the runtime is dormant are not buffered or replayed.

Your own event/emit normally goes onto the host's JSON bus, where other plugins (in or out of process) can subscribe to it. The JSON bus is currently host-wide, not project-confidential: project process isolation does not filter events by realm. Do not put project secrets on an event merely because the sender or subscriber is a project-scoped runtime.

connector/event is reserved for a host-rendered connector's provider notifications. It does not enter that public JSON bus. The host wraps the payload in a typed internal event and adds the authenticated installed-bundle id; any plugin_id supplied by the process is ignored. Desktop adapters must match that owner and the active connector's bundle-local connectorId before using the event. A connector event payload uses this minimum envelope:

{
"connectorId": "workspace",
"eventId": "provider-event-or-message-id",
"kind": "message.created",
"createdAt": "1724900000000"
}

Provider-specific resource ids and bounded summaries may be added. The process must deduplicate at-least-once provider delivery before emitting; the host-rendered adapter repeats that guard before changing local activity state. Event payloads are not a way to inject UI or bypass connector command ownership.

Declaring a plugin

Every C2 bundle uses the Agent Plugins 1.0.0 root schema and the mandatory C2 extension. Add the runtime under C2's client-extension namespace; a top-level runtime field invalidates the bundle:

{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "my-plugin",
"version": "1.0.0",
"extensions": {
"dev.codetwo": {
"standardVersion": "1.2.0",
"commands": [{
"id": "my.greet",
"title": "Say hello",
"description": "Greet the current user.",
"argsSchema": { "type": "object", "additionalProperties": false }
}],
"runtime": {
"protocol": "1.0.0",
"command": "node",
"args": ["dist/plugin.js"],
"env": { "MY_PLUGIN_MODE": "release" },
"inject": ["store"],
"optionalInject": ["engine"],
"scopeSupport": ["user", "project"]
}
}
}
}

When activated, the process starts with the bundle directory as its working directory.

inject gets you the same reactive contract a Rust plugin has: command stubs are not made ready until those services exist, and an active process is stopped when one is replaced. It is declared in the Manifest because the host needs it before making the adapter eligible.

scopeSupport is an explicit capability declaration. If it is omitted, the runtime supports only ["user"]. Include "project" only when the process is prepared for one independently managed process, command realm, dataDir, and projectPath per active project. The bundle's skills and other data-only extension components are not made project-scoped by this field; they remain user-only and are managed through Bundle Tools.

A process runtime declares at least one sibling commands entry. UI action descriptors are declared beside both under extensions.dev.codetwo.ui; they do not alter this wire protocol or load third-party renderer code.

extensions.dev.codetwo.languageServers is a separate host-owned stdio LSP contribution. Language servers use standard Content-Length LSP framing, not this newline-delimited plugin protocol. They may exist without a C2 process runtime and still share bundle trust, enablement, and teardown.

Trust

Installing a bundle executes nothing. That property of the Plugin Hub is not weakened by this protocol — it is the reason the protocol is shaped this way.

An enabled and trusted bundle is eligible: its host adapter and dormant command stubs become ready, but the process does not start until the first declared command invocation. Trust is a separate, deliberate user action, and installing a bundle that ships a C2 runtime raises a diagnostic saying so. Until then the plugin is listed by extensions.list under untrusted.

Trust is a hard gate in every realm. No configuration setting can bypass it.

Lifecycle

enabled + trusted ──▶ register static stubs ──▶ ready, process dormant
│ first command
▼
spawn + initialize
│
exact command/schema confirmation ──▶ invoke
│
timeout / mismatch ──▶ process killed; activation terminal
caller cancellation ──▶ process killed; next invocation may retry
unload, dependency lost, bundle disabled/removed ──▶ process killed; stubs removed

Unloading is exact: the process is killed and every command it contributed is gone. Nothing has to remember to clean up, because the plugin's scope owns all of it.

Installed runtimes are ordinary dynamically registered factories named bundle:<id>. Installing, removing, enabling, trusting, or replacing a bundle reconciles that factory set and every live eligible realm immediately. Add and remove do not require an application restart; replacing a manifest or installed record rebuilds the same-named runtime because its factory revision changed. User and project state changes go through plugins.catalog, plugins.plan_change, and plugins.apply_change, including stale-plan protection and immediate unload.

A complete, runnable example lives in packs/hello-runtime/: it contributes a command, calls git.status back through the host from inside it, feature-detects the extension-public surface, and listens for a host event — in one file with no dependencies.

A minimal plugin

#!/usr/bin/env node
constreadline=require("readline");constrl=readline.createInterface({input: process.stdin});constsend=(m)=>process.stdout.write(JSON.stringify(m)+"\n");rl.on("line",async(line)=>{constmsg=JSON.parse(line);if(msg.method==="initialize"){send({jsonrpc: "2.0",id: msg.id,result: {name: "hello",version: "1.0.0",protocolVersion: "1.0.0",commands: [{name: "hello.status",description: "Repo status, via the host."}],events: ["skills/changed"],}});}elseif(msg.method==="command/invoke"){// Call a host command and answer with what it said.send({jsonrpc: "2.0",id: 1000,method: "command/call",params: {name: "git.status",args: {cwd: msg.params.args.cwd}}});// (a real plugin would correlate the response by id)}elseif(msg.method==="event/emit"){console.error(`host event: ${msg.params.name}`);// stderr is the log channel}});

Limits, stated plainly

  • No sandbox. A trusted plugin is a process with your user's permissions. Trust is the whole OS security boundary; project command/data/process isolation does not restrict filesystem, network, environment, or the host-wide JSON event bus. Treat it as such.
  • No timeout on command/invoke. A plugin that never answers a command hangs that caller — not the graph. The handshake is the only bounded wait.
  • One process per activated managed realm. A project-capable runtime may have one global process and separate processes for multiple live projects. Fan out inside one realm if you need more.
  • Rust plugins are still compile-time. This protocol is how a plugin gets added without rebuilding C2; it is not dynamic loading of native code, and deliberately so.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

History
310 lines (249 loc) · 14.5 KB

File metadata and controls

310 lines (249 loc) · 14.5 KB

The C2 Plugin Protocol

Status: normative protocol contract for external process extensions.

Version 1.0.0.

This document defines the process wire format. Bundle terminology, manifest namespacing, trust, policy, contribution support, and host capability rules are normative in the C2 Plugin Standard 1.2.0.

An external extension runtime is a process. C2 speaks JSON-RPC 2.0 to it over stdio. Its commands are declared statically in the Bundle Manifest; initialize confirms their implementation and may subscribe to events. The host registers both in the same scoped kernel registries used by built-in Rust runtime modules. Installed runtimes have stable managed names of the form bundle:<id>. Their commands appear in kernel.commands, are callable from a frontend in the matching command realm, and disappear the instant the runtime unloads.

Write one in any language that can read stdin and write stdout.

Why a protocol at all

docs/reference/plugins.md defines C2's internal runtime-module graph, but a Rust host can only load modules it was compiled with. This protocol is the external extension seam, using the transport the app already speaks twice over (ACP to provider CLIs, MCP to tool servers) rather than inventing a third.

Transport

  • Framing — one JSON object per line, UTF-8, \n-terminated. No Content-Length header.
  • stdout is the protocol channel. Anything on it that is not JSON is dropped with a warning, so a stray print() degrades to noise rather than a crash.
  • stderr is your log channel. C2 routes it into its own tracing output.
  • The process is killed when the plugin unloads, and it never outlives the app.

Handshake

On the first invocation of a declared command, the host starts the process, sends initialize first, and waits 10 seconds. Answer it promptly. A process that misses the window is killed and that scope generation's activation fails; its dormant command stubs remain fail-closed until the plugin is reloaded or disabled and re-enabled.

Host → plugin

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion": "1.0.0",
"host": { "name": "code2", "version": "0.0.0", "commands": ["git.status"] },
"config": { "…": "your entry from the plugin config, verbatim" },
"dataDir": "/home/me/.codetwo/plugins/.data/my-plugin/projects/09a7…",
"projectPath": "/home/me/work/my-project"
}}

Plugin → host

{"jsonrpc":"2.0","id":1,"result":{
"name": "my-plugin",
"version": "0.1.0",
"protocolVersion": "1.0.0",
"commands": [
{ "name": "my.greet", "description": "Say hello.", "schema": { "type": "object" } }
],
"events": ["engine/event"]
}}

For a C2 1.2 bundle, commands MUST contain exactly the IDs and schemas declared by extensions.dev.codetwo.commands. It is implementation confirmation, not a second contribution source: missing, extra, duplicate, or changed schemas are refused and the process is killed. The host uses Manifest titles and descriptions.

Wire compatibility is by major version. Declaring 2.x to a 1.x host, or omitting protocolVersion, is refused with a readable error.

dataDir is yours to write to and is created before you start. A user-scoped instance gets <plugins-dir>/.data/<id>. A project-scoped instance gets <plugins-dir>/.data/<id>/projects/<blake3(normalized-project-path)>, so two projects do not share runtime files. projectPath is the normalized project identity for that instance and is omitted for the user-scoped instance.

host.commands is the extension-public callable surface visible from this realm at initialization time. Internal Core and frontend commands are omitted. A user-scoped instance sees public global commands only. A project instance sees public global commands plus public commands registered for the same normalized project; it never receives another project's command list. command/call rechecks the public marker and uses that same realm's normal project fallback/blocking rules, so guessing an internal command name does not grant access.

Methods

Host → plugin

methodkindparamsresult
initializerequestsee abovesee above
command/invokerequest{ name, args }whatever your command returns
event/emitnotification{ name, payload }

command/invoke only ever names a command declared in the Manifest. Returning a JSON-RPC error turns into a readable failure at the caller — the frontend sees my.greet: <your message>. An extension command that collides with a global command owned by Core or another module is rejected during activation, so a project extension cannot shadow host dispatch. The same project-capable extension may register its own command name in both global and project instances.

When a user activates a manifest ui contribution, C2 first verifies bundle ownership, trust, enablement, the selected user/project realm, and that the contribution's command was registered by this process. The resulting command/invoke uses these args:

{
"context": { "cwd": "/repo", "projectPath": "/repo", "sessionId": "session-id" },
"input": { "mode": "working-tree" }
}

context is host state for this activation; input is the descriptor's static JSON value. Neither is a capability grant. Host commands remain accessible only through the allowlisted, realm-aware command/call seam.

Plugin → host

methodkindparamsresult
command/callrequest{ name, args }the extension-public command's result
event/emitnotification{ name, payload }
lognotification{ level, message }

command/call reaches an extension-public command visible in the process's realm, by name, through the same registry a Rust runtime module uses. Commands are internal by default and must be deliberately published by Core. git.status is the initial read-only public command; mutating Git, plugin management, credentials, and other Core commands stay internal. If a public command's owner is turned off or project fallback is blocked, the call fails through the normal command path.

level is one of error, warn, info, debug, trace.

Events

You receive only the events you name in events. The host publishes:

namepayload
engine/eventone agent-loop Event (agent text, tool call, permission request, turn ended, …)
skills/changednull — the skill library was rebuilt
scenes/changednull — the scene library was re-resolved

This list is the contract. Typed Rust events do not cross a pipe, so each entry is a deliberate decision to expose one — see publish_host_events in crates/plugins/src/app/plugins/extensions.rs. Because activation is command-driven, event subscriptions begin only after the first command has successfully initialized the process; events emitted while the runtime is dormant are not buffered or replayed.

Your own event/emit normally goes onto the host's JSON bus, where other plugins (in or out of process) can subscribe to it. The JSON bus is currently host-wide, not project-confidential: project process isolation does not filter events by realm. Do not put project secrets on an event merely because the sender or subscriber is a project-scoped runtime.

connector/event is reserved for a host-rendered connector's provider notifications. It does not enter that public JSON bus. The host wraps the payload in a typed internal event and adds the authenticated installed-bundle id; any plugin_id supplied by the process is ignored. Desktop adapters must match that owner and the active connector's bundle-local connectorId before using the event. A connector event payload uses this minimum envelope:

{
"connectorId": "workspace",
"eventId": "provider-event-or-message-id",
"kind": "message.created",
"createdAt": "1724900000000"
}

Provider-specific resource ids and bounded summaries may be added. The process must deduplicate at-least-once provider delivery before emitting; the host-rendered adapter repeats that guard before changing local activity state. Event payloads are not a way to inject UI or bypass connector command ownership.

Declaring a plugin

Every C2 bundle uses the Agent Plugins 1.0.0 root schema and the mandatory C2 extension. Add the runtime under C2's client-extension namespace; a top-level runtime field invalidates the bundle:

{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "my-plugin",
"version": "1.0.0",
"extensions": {
"dev.codetwo": {
"standardVersion": "1.2.0",
"commands": [{
"id": "my.greet",
"title": "Say hello",
"description": "Greet the current user.",
"argsSchema": { "type": "object", "additionalProperties": false }
}],
"runtime": {
"protocol": "1.0.0",
"command": "node",
"args": ["dist/plugin.js"],
"env": { "MY_PLUGIN_MODE": "release" },
"inject": ["store"],
"optionalInject": ["engine"],
"scopeSupport": ["user", "project"]
}
}
}
}

When activated, the process starts with the bundle directory as its working directory.

inject gets you the same reactive contract a Rust plugin has: command stubs are not made ready until those services exist, and an active process is stopped when one is replaced. It is declared in the Manifest because the host needs it before making the adapter eligible.

scopeSupport is an explicit capability declaration. If it is omitted, the runtime supports only ["user"]. Include "project" only when the process is prepared for one independently managed process, command realm, dataDir, and projectPath per active project. The bundle's skills and other data-only extension components are not made project-scoped by this field; they remain user-only and are managed through Bundle Tools.

A process runtime declares at least one sibling commands entry. UI action descriptors are declared beside both under extensions.dev.codetwo.ui; they do not alter this wire protocol or load third-party renderer code.

extensions.dev.codetwo.languageServers is a separate host-owned stdio LSP contribution. Language servers use standard Content-Length LSP framing, not this newline-delimited plugin protocol. They may exist without a C2 process runtime and still share bundle trust, enablement, and teardown.

Trust

Installing a bundle executes nothing. That property of the Plugin Hub is not weakened by this protocol — it is the reason the protocol is shaped this way.

An enabled and trusted bundle is eligible: its host adapter and dormant command stubs become ready, but the process does not start until the first declared command invocation. Trust is a separate, deliberate user action, and installing a bundle that ships a C2 runtime raises a diagnostic saying so. Until then the plugin is listed by extensions.list under untrusted.

Trust is a hard gate in every realm. No configuration setting can bypass it.

Lifecycle

enabled + trusted ──▶ register static stubs ──▶ ready, process dormant
│ first command
▼
spawn + initialize
│
exact command/schema confirmation ──▶ invoke
│
timeout / mismatch ──▶ process killed; activation terminal
caller cancellation ──▶ process killed; next invocation may retry
unload, dependency lost, bundle disabled/removed ──▶ process killed; stubs removed

Unloading is exact: the process is killed and every command it contributed is gone. Nothing has to remember to clean up, because the plugin's scope owns all of it.

Installed runtimes are ordinary dynamically registered factories named bundle:<id>. Installing, removing, enabling, trusting, or replacing a bundle reconciles that factory set and every live eligible realm immediately. Add and remove do not require an application restart; replacing a manifest or installed record rebuilds the same-named runtime because its factory revision changed. User and project state changes go through plugins.catalog, plugins.plan_change, and plugins.apply_change, including stale-plan protection and immediate unload.

A complete, runnable example lives in packs/hello-runtime/: it contributes a command, calls git.status back through the host from inside it, feature-detects the extension-public surface, and listens for a host event — in one file with no dependencies.

A minimal plugin

#!/usr/bin/env node
constreadline=require("readline");constrl=readline.createInterface({input: process.stdin});constsend=(m)=>process.stdout.write(JSON.stringify(m)+"\n");rl.on("line",async(line)=>{constmsg=JSON.parse(line);if(msg.method==="initialize"){send({jsonrpc: "2.0",id: msg.id,result: {name: "hello",version: "1.0.0",protocolVersion: "1.0.0",commands: [{name: "hello.status",description: "Repo status, via the host."}],events: ["skills/changed"],}});}elseif(msg.method==="command/invoke"){// Call a host command and answer with what it said.send({jsonrpc: "2.0",id: 1000,method: "command/call",params: {name: "git.status",args: {cwd: msg.params.args.cwd}}});// (a real plugin would correlate the response by id)}elseif(msg.method==="event/emit"){console.error(`host event: ${msg.params.name}`);// stderr is the log channel}});

Limits, stated plainly

  • No sandbox. A trusted plugin is a process with your user's permissions. Trust is the whole OS security boundary; project command/data/process isolation does not restrict filesystem, network, environment, or the host-wide JSON event bus. Treat it as such.
  • No timeout on command/invoke. A plugin that never answers a command hangs that caller — not the graph. The handshake is the only bounded wait.
  • One process per activated managed realm. A project-capable runtime may have one global process and separate processes for multiple live projects. Fan out inside one realm if you need more.
  • Rust plugins are still compile-time. This protocol is how a plugin gets added without rebuilding C2; it is not dynamic loading of native code, and deliberately so.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

History
310 lines (249 loc) · 14.5 KB

File metadata and controls

310 lines (249 loc) · 14.5 KB

The C2 Plugin Protocol

Status: normative protocol contract for external process extensions.

Version 1.0.0.

This document defines the process wire format. Bundle terminology, manifest namespacing, trust, policy, contribution support, and host capability rules are normative in the C2 Plugin Standard 1.2.0.

An external extension runtime is a process. C2 speaks JSON-RPC 2.0 to it over stdio. Its commands are declared statically in the Bundle Manifest; initialize confirms their implementation and may subscribe to events. The host registers both in the same scoped kernel registries used by built-in Rust runtime modules. Installed runtimes have stable managed names of the form bundle:<id>. Their commands appear in kernel.commands, are callable from a frontend in the matching command realm, and disappear the instant the runtime unloads.

Write one in any language that can read stdin and write stdout.

Why a protocol at all

docs/reference/plugins.md defines C2's internal runtime-module graph, but a Rust host can only load modules it was compiled with. This protocol is the external extension seam, using the transport the app already speaks twice over (ACP to provider CLIs, MCP to tool servers) rather than inventing a third.

Transport

  • Framing — one JSON object per line, UTF-8, \n-terminated. No Content-Length header.
  • stdout is the protocol channel. Anything on it that is not JSON is dropped with a warning, so a stray print() degrades to noise rather than a crash.
  • stderr is your log channel. C2 routes it into its own tracing output.
  • The process is killed when the plugin unloads, and it never outlives the app.

Handshake

On the first invocation of a declared command, the host starts the process, sends initialize first, and waits 10 seconds. Answer it promptly. A process that misses the window is killed and that scope generation's activation fails; its dormant command stubs remain fail-closed until the plugin is reloaded or disabled and re-enabled.

Host → plugin

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion": "1.0.0",
"host": { "name": "code2", "version": "0.0.0", "commands": ["git.status"] },
"config": { "…": "your entry from the plugin config, verbatim" },
"dataDir": "/home/me/.codetwo/plugins/.data/my-plugin/projects/09a7…",
"projectPath": "/home/me/work/my-project"
}}

Plugin → host

{"jsonrpc":"2.0","id":1,"result":{
"name": "my-plugin",
"version": "0.1.0",
"protocolVersion": "1.0.0",
"commands": [
{ "name": "my.greet", "description": "Say hello.", "schema": { "type": "object" } }
],
"events": ["engine/event"]
}}

For a C2 1.2 bundle, commands MUST contain exactly the IDs and schemas declared by extensions.dev.codetwo.commands. It is implementation confirmation, not a second contribution source: missing, extra, duplicate, or changed schemas are refused and the process is killed. The host uses Manifest titles and descriptions.

Wire compatibility is by major version. Declaring 2.x to a 1.x host, or omitting protocolVersion, is refused with a readable error.

dataDir is yours to write to and is created before you start. A user-scoped instance gets <plugins-dir>/.data/<id>. A project-scoped instance gets <plugins-dir>/.data/<id>/projects/<blake3(normalized-project-path)>, so two projects do not share runtime files. projectPath is the normalized project identity for that instance and is omitted for the user-scoped instance.

host.commands is the extension-public callable surface visible from this realm at initialization time. Internal Core and frontend commands are omitted. A user-scoped instance sees public global commands only. A project instance sees public global commands plus public commands registered for the same normalized project; it never receives another project's command list. command/call rechecks the public marker and uses that same realm's normal project fallback/blocking rules, so guessing an internal command name does not grant access.

Methods

Host → plugin

methodkindparamsresult
initializerequestsee abovesee above
command/invokerequest{ name, args }whatever your command returns
event/emitnotification{ name, payload }

command/invoke only ever names a command declared in the Manifest. Returning a JSON-RPC error turns into a readable failure at the caller — the frontend sees my.greet: <your message>. An extension command that collides with a global command owned by Core or another module is rejected during activation, so a project extension cannot shadow host dispatch. The same project-capable extension may register its own command name in both global and project instances.

When a user activates a manifest ui contribution, C2 first verifies bundle ownership, trust, enablement, the selected user/project realm, and that the contribution's command was registered by this process. The resulting command/invoke uses these args:

{
"context": { "cwd": "/repo", "projectPath": "/repo", "sessionId": "session-id" },
"input": { "mode": "working-tree" }
}

context is host state for this activation; input is the descriptor's static JSON value. Neither is a capability grant. Host commands remain accessible only through the allowlisted, realm-aware command/call seam.

Plugin → host

methodkindparamsresult
command/callrequest{ name, args }the extension-public command's result
event/emitnotification{ name, payload }
lognotification{ level, message }

command/call reaches an extension-public command visible in the process's realm, by name, through the same registry a Rust runtime module uses. Commands are internal by default and must be deliberately published by Core. git.status is the initial read-only public command; mutating Git, plugin management, credentials, and other Core commands stay internal. If a public command's owner is turned off or project fallback is blocked, the call fails through the normal command path.

level is one of error, warn, info, debug, trace.

Events

You receive only the events you name in events. The host publishes:

namepayload
engine/eventone agent-loop Event (agent text, tool call, permission request, turn ended, …)
skills/changednull — the skill library was rebuilt
scenes/changednull — the scene library was re-resolved

This list is the contract. Typed Rust events do not cross a pipe, so each entry is a deliberate decision to expose one — see publish_host_events in crates/plugins/src/app/plugins/extensions.rs. Because activation is command-driven, event subscriptions begin only after the first command has successfully initialized the process; events emitted while the runtime is dormant are not buffered or replayed.

Your own event/emit normally goes onto the host's JSON bus, where other plugins (in or out of process) can subscribe to it. The JSON bus is currently host-wide, not project-confidential: project process isolation does not filter events by realm. Do not put project secrets on an event merely because the sender or subscriber is a project-scoped runtime.

connector/event is reserved for a host-rendered connector's provider notifications. It does not enter that public JSON bus. The host wraps the payload in a typed internal event and adds the authenticated installed-bundle id; any plugin_id supplied by the process is ignored. Desktop adapters must match that owner and the active connector's bundle-local connectorId before using the event. A connector event payload uses this minimum envelope:

{
"connectorId": "workspace",
"eventId": "provider-event-or-message-id",
"kind": "message.created",
"createdAt": "1724900000000"
}

Provider-specific resource ids and bounded summaries may be added. The process must deduplicate at-least-once provider delivery before emitting; the host-rendered adapter repeats that guard before changing local activity state. Event payloads are not a way to inject UI or bypass connector command ownership.

Declaring a plugin

Every C2 bundle uses the Agent Plugins 1.0.0 root schema and the mandatory C2 extension. Add the runtime under C2's client-extension namespace; a top-level runtime field invalidates the bundle:

{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "my-plugin",
"version": "1.0.0",
"extensions": {
"dev.codetwo": {
"standardVersion": "1.2.0",
"commands": [{
"id": "my.greet",
"title": "Say hello",
"description": "Greet the current user.",
"argsSchema": { "type": "object", "additionalProperties": false }
}],
"runtime": {
"protocol": "1.0.0",
"command": "node",
"args": ["dist/plugin.js"],
"env": { "MY_PLUGIN_MODE": "release" },
"inject": ["store"],
"optionalInject": ["engine"],
"scopeSupport": ["user", "project"]
}
}
}
}

When activated, the process starts with the bundle directory as its working directory.

inject gets you the same reactive contract a Rust plugin has: command stubs are not made ready until those services exist, and an active process is stopped when one is replaced. It is declared in the Manifest because the host needs it before making the adapter eligible.

scopeSupport is an explicit capability declaration. If it is omitted, the runtime supports only ["user"]. Include "project" only when the process is prepared for one independently managed process, command realm, dataDir, and projectPath per active project. The bundle's skills and other data-only extension components are not made project-scoped by this field; they remain user-only and are managed through Bundle Tools.

A process runtime declares at least one sibling commands entry. UI action descriptors are declared beside both under extensions.dev.codetwo.ui; they do not alter this wire protocol or load third-party renderer code.

extensions.dev.codetwo.languageServers is a separate host-owned stdio LSP contribution. Language servers use standard Content-Length LSP framing, not this newline-delimited plugin protocol. They may exist without a C2 process runtime and still share bundle trust, enablement, and teardown.

Trust

Installing a bundle executes nothing. That property of the Plugin Hub is not weakened by this protocol — it is the reason the protocol is shaped this way.

An enabled and trusted bundle is eligible: its host adapter and dormant command stubs become ready, but the process does not start until the first declared command invocation. Trust is a separate, deliberate user action, and installing a bundle that ships a C2 runtime raises a diagnostic saying so. Until then the plugin is listed by extensions.list under untrusted.

Trust is a hard gate in every realm. No configuration setting can bypass it.

Lifecycle

enabled + trusted ──▶ register static stubs ──▶ ready, process dormant
│ first command
▼
spawn + initialize
│
exact command/schema confirmation ──▶ invoke
│
timeout / mismatch ──▶ process killed; activation terminal
caller cancellation ──▶ process killed; next invocation may retry
unload, dependency lost, bundle disabled/removed ──▶ process killed; stubs removed

Unloading is exact: the process is killed and every command it contributed is gone. Nothing has to remember to clean up, because the plugin's scope owns all of it.

Installed runtimes are ordinary dynamically registered factories named bundle:<id>. Installing, removing, enabling, trusting, or replacing a bundle reconciles that factory set and every live eligible realm immediately. Add and remove do not require an application restart; replacing a manifest or installed record rebuilds the same-named runtime because its factory revision changed. User and project state changes go through plugins.catalog, plugins.plan_change, and plugins.apply_change, including stale-plan protection and immediate unload.

A complete, runnable example lives in packs/hello-runtime/: it contributes a command, calls git.status back through the host from inside it, feature-detects the extension-public surface, and listens for a host event — in one file with no dependencies.

A minimal plugin

#!/usr/bin/env node
constreadline=require("readline");constrl=readline.createInterface({input: process.stdin});constsend=(m)=>process.stdout.write(JSON.stringify(m)+"\n");rl.on("line",async(line)=>{constmsg=JSON.parse(line);if(msg.method==="initialize"){send({jsonrpc: "2.0",id: msg.id,result: {name: "hello",version: "1.0.0",protocolVersion: "1.0.0",commands: [{name: "hello.status",description: "Repo status, via the host."}],events: ["skills/changed"],}});}elseif(msg.method==="command/invoke"){// Call a host command and answer with what it said.send({jsonrpc: "2.0",id: 1000,method: "command/call",params: {name: "git.status",args: {cwd: msg.params.args.cwd}}});// (a real plugin would correlate the response by id)}elseif(msg.method==="event/emit"){console.error(`host event: ${msg.params.name}`);// stderr is the log channel}});

Limits, stated plainly

  • No sandbox. A trusted plugin is a process with your user's permissions. Trust is the whole OS security boundary; project command/data/process isolation does not restrict filesystem, network, environment, or the host-wide JSON event bus. Treat it as such.
  • No timeout on command/invoke. A plugin that never answers a command hangs that caller — not the graph. The handshake is the only bounded wait.
  • One process per activated managed realm. A project-capable runtime may have one global process and separate processes for multiple live projects. Fan out inside one realm if you need more.
  • Rust plugins are still compile-time. This protocol is how a plugin gets added without rebuilding C2; it is not dynamic loading of native code, and deliberately so.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Latest commit

History

History
310 lines (249 loc) · 14.5 KB

File metadata and controls

310 lines (249 loc) · 14.5 KB

The C2 Plugin Protocol

Status: normative protocol contract for external process extensions.

Version 1.0.0.

This document defines the process wire format. Bundle terminology, manifest namespacing, trust, policy, contribution support, and host capability rules are normative in the C2 Plugin Standard 1.2.0.

An external extension runtime is a process. C2 speaks JSON-RPC 2.0 to it over stdio. Its commands are declared statically in the Bundle Manifest; initialize confirms their implementation and may subscribe to events. The host registers both in the same scoped kernel registries used by built-in Rust runtime modules. Installed runtimes have stable managed names of the form bundle:<id>. Their commands appear in kernel.commands, are callable from a frontend in the matching command realm, and disappear the instant the runtime unloads.

Write one in any language that can read stdin and write stdout.

Why a protocol at all

docs/reference/plugins.md defines C2's internal runtime-module graph, but a Rust host can only load modules it was compiled with. This protocol is the external extension seam, using the transport the app already speaks twice over (ACP to provider CLIs, MCP to tool servers) rather than inventing a third.

Transport

  • Framing — one JSON object per line, UTF-8, \n-terminated. No Content-Length header.
  • stdout is the protocol channel. Anything on it that is not JSON is dropped with a warning, so a stray print() degrades to noise rather than a crash.
  • stderr is your log channel. C2 routes it into its own tracing output.
  • The process is killed when the plugin unloads, and it never outlives the app.

Handshake

On the first invocation of a declared command, the host starts the process, sends initialize first, and waits 10 seconds. Answer it promptly. A process that misses the window is killed and that scope generation's activation fails; its dormant command stubs remain fail-closed until the plugin is reloaded or disabled and re-enabled.

Host → plugin

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion": "1.0.0",
"host": { "name": "code2", "version": "0.0.0", "commands": ["git.status"] },
"config": { "…": "your entry from the plugin config, verbatim" },
"dataDir": "/home/me/.codetwo/plugins/.data/my-plugin/projects/09a7…",
"projectPath": "/home/me/work/my-project"
}}

Plugin → host

{"jsonrpc":"2.0","id":1,"result":{
"name": "my-plugin",
"version": "0.1.0",
"protocolVersion": "1.0.0",
"commands": [
{ "name": "my.greet", "description": "Say hello.", "schema": { "type": "object" } }
],
"events": ["engine/event"]
}}

For a C2 1.2 bundle, commands MUST contain exactly the IDs and schemas declared by extensions.dev.codetwo.commands. It is implementation confirmation, not a second contribution source: missing, extra, duplicate, or changed schemas are refused and the process is killed. The host uses Manifest titles and descriptions.

Wire compatibility is by major version. Declaring 2.x to a 1.x host, or omitting protocolVersion, is refused with a readable error.

dataDir is yours to write to and is created before you start. A user-scoped instance gets <plugins-dir>/.data/<id>. A project-scoped instance gets <plugins-dir>/.data/<id>/projects/<blake3(normalized-project-path)>, so two projects do not share runtime files. projectPath is the normalized project identity for that instance and is omitted for the user-scoped instance.

host.commands is the extension-public callable surface visible from this realm at initialization time. Internal Core and frontend commands are omitted. A user-scoped instance sees public global commands only. A project instance sees public global commands plus public commands registered for the same normalized project; it never receives another project's command list. command/call rechecks the public marker and uses that same realm's normal project fallback/blocking rules, so guessing an internal command name does not grant access.

Methods

Host → plugin

methodkindparamsresult
initializerequestsee abovesee above
command/invokerequest{ name, args }whatever your command returns
event/emitnotification{ name, payload }

command/invoke only ever names a command declared in the Manifest. Returning a JSON-RPC error turns into a readable failure at the caller — the frontend sees my.greet: <your message>. An extension command that collides with a global command owned by Core or another module is rejected during activation, so a project extension cannot shadow host dispatch. The same project-capable extension may register its own command name in both global and project instances.

When a user activates a manifest ui contribution, C2 first verifies bundle ownership, trust, enablement, the selected user/project realm, and that the contribution's command was registered by this process. The resulting command/invoke uses these args:

{
"context": { "cwd": "/repo", "projectPath": "/repo", "sessionId": "session-id" },
"input": { "mode": "working-tree" }
}

context is host state for this activation; input is the descriptor's static JSON value. Neither is a capability grant. Host commands remain accessible only through the allowlisted, realm-aware command/call seam.

Plugin → host

methodkindparamsresult
command/callrequest{ name, args }the extension-public command's result
event/emitnotification{ name, payload }
lognotification{ level, message }

command/call reaches an extension-public command visible in the process's realm, by name, through the same registry a Rust runtime module uses. Commands are internal by default and must be deliberately published by Core. git.status is the initial read-only public command; mutating Git, plugin management, credentials, and other Core commands stay internal. If a public command's owner is turned off or project fallback is blocked, the call fails through the normal command path.

level is one of error, warn, info, debug, trace.

Events

You receive only the events you name in events. The host publishes:

namepayload
engine/eventone agent-loop Event (agent text, tool call, permission request, turn ended, …)
skills/changednull — the skill library was rebuilt
scenes/changednull — the scene library was re-resolved

This list is the contract. Typed Rust events do not cross a pipe, so each entry is a deliberate decision to expose one — see publish_host_events in crates/plugins/src/app/plugins/extensions.rs. Because activation is command-driven, event subscriptions begin only after the first command has successfully initialized the process; events emitted while the runtime is dormant are not buffered or replayed.

Your own event/emit normally goes onto the host's JSON bus, where other plugins (in or out of process) can subscribe to it. The JSON bus is currently host-wide, not project-confidential: project process isolation does not filter events by realm. Do not put project secrets on an event merely because the sender or subscriber is a project-scoped runtime.

connector/event is reserved for a host-rendered connector's provider notifications. It does not enter that public JSON bus. The host wraps the payload in a typed internal event and adds the authenticated installed-bundle id; any plugin_id supplied by the process is ignored. Desktop adapters must match that owner and the active connector's bundle-local connectorId before using the event. A connector event payload uses this minimum envelope:

{
"connectorId": "workspace",
"eventId": "provider-event-or-message-id",
"kind": "message.created",
"createdAt": "1724900000000"
}

Provider-specific resource ids and bounded summaries may be added. The process must deduplicate at-least-once provider delivery before emitting; the host-rendered adapter repeats that guard before changing local activity state. Event payloads are not a way to inject UI or bypass connector command ownership.

Declaring a plugin

Every C2 bundle uses the Agent Plugins 1.0.0 root schema and the mandatory C2 extension. Add the runtime under C2's client-extension namespace; a top-level runtime field invalidates the bundle:

{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "my-plugin",
"version": "1.0.0",
"extensions": {
"dev.codetwo": {
"standardVersion": "1.2.0",
"commands": [{
"id": "my.greet",
"title": "Say hello",
"description": "Greet the current user.",
"argsSchema": { "type": "object", "additionalProperties": false }
}],
"runtime": {
"protocol": "1.0.0",
"command": "node",
"args": ["dist/plugin.js"],
"env": { "MY_PLUGIN_MODE": "release" },
"inject": ["store"],
"optionalInject": ["engine"],
"scopeSupport": ["user", "project"]
}
}
}
}

When activated, the process starts with the bundle directory as its working directory.

inject gets you the same reactive contract a Rust plugin has: command stubs are not made ready until those services exist, and an active process is stopped when one is replaced. It is declared in the Manifest because the host needs it before making the adapter eligible.

scopeSupport is an explicit capability declaration. If it is omitted, the runtime supports only ["user"]. Include "project" only when the process is prepared for one independently managed process, command realm, dataDir, and projectPath per active project. The bundle's skills and other data-only extension components are not made project-scoped by this field; they remain user-only and are managed through Bundle Tools.

A process runtime declares at least one sibling commands entry. UI action descriptors are declared beside both under extensions.dev.codetwo.ui; they do not alter this wire protocol or load third-party renderer code.

extensions.dev.codetwo.languageServers is a separate host-owned stdio LSP contribution. Language servers use standard Content-Length LSP framing, not this newline-delimited plugin protocol. They may exist without a C2 process runtime and still share bundle trust, enablement, and teardown.

Trust

Installing a bundle executes nothing. That property of the Plugin Hub is not weakened by this protocol — it is the reason the protocol is shaped this way.

An enabled and trusted bundle is eligible: its host adapter and dormant command stubs become ready, but the process does not start until the first declared command invocation. Trust is a separate, deliberate user action, and installing a bundle that ships a C2 runtime raises a diagnostic saying so. Until then the plugin is listed by extensions.list under untrusted.

Trust is a hard gate in every realm. No configuration setting can bypass it.

Lifecycle

enabled + trusted ──▶ register static stubs ──▶ ready, process dormant
│ first command
▼
spawn + initialize
│
exact command/schema confirmation ──▶ invoke
│
timeout / mismatch ──▶ process killed; activation terminal
caller cancellation ──▶ process killed; next invocation may retry
unload, dependency lost, bundle disabled/removed ──▶ process killed; stubs removed

Unloading is exact: the process is killed and every command it contributed is gone. Nothing has to remember to clean up, because the plugin's scope owns all of it.

Installed runtimes are ordinary dynamically registered factories named bundle:<id>. Installing, removing, enabling, trusting, or replacing a bundle reconciles that factory set and every live eligible realm immediately. Add and remove do not require an application restart; replacing a manifest or installed record rebuilds the same-named runtime because its factory revision changed. User and project state changes go through plugins.catalog, plugins.plan_change, and plugins.apply_change, including stale-plan protection and immediate unload.

A complete, runnable example lives in packs/hello-runtime/: it contributes a command, calls git.status back through the host from inside it, feature-detects the extension-public surface, and listens for a host event — in one file with no dependencies.

A minimal plugin

#!/usr/bin/env node
constreadline=require("readline");constrl=readline.createInterface({input: process.stdin});constsend=(m)=>process.stdout.write(JSON.stringify(m)+"\n");rl.on("line",async(line)=>{constmsg=JSON.parse(line);if(msg.method==="initialize"){send({jsonrpc: "2.0",id: msg.id,result: {name: "hello",version: "1.0.0",protocolVersion: "1.0.0",commands: [{name: "hello.status",description: "Repo status, via the host."}],events: ["skills/changed"],}});}elseif(msg.method==="command/invoke"){// Call a host command and answer with what it said.send({jsonrpc: "2.0",id: 1000,method: "command/call",params: {name: "git.status",args: {cwd: msg.params.args.cwd}}});// (a real plugin would correlate the response by id)}elseif(msg.method==="event/emit"){console.error(`host event: ${msg.params.name}`);// stderr is the log channel}});

Limits, stated plainly

  • No sandbox. A trusted plugin is a process with your user's permissions. Trust is the whole OS security boundary; project command/data/process isolation does not restrict filesystem, network, environment, or the host-wide JSON event bus. Treat it as such.
  • No timeout on command/invoke. A plugin that never answers a command hangs that caller — not the graph. The handshake is the only bounded wait.
  • One process per activated managed realm. A project-capable runtime may have one global process and separate processes for multiple live projects. Fan out inside one realm if you need more.
  • Rust plugins are still compile-time. This protocol is how a plugin gets added without rebuilding C2; it is not dynamic loading of native code, and deliberately so.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

History
310 lines (249 loc) · 14.5 KB

File metadata and controls

310 lines (249 loc) · 14.5 KB

The C2 Plugin Protocol

Status: normative protocol contract for external process extensions.

Version 1.0.0.

This document defines the process wire format. Bundle terminology, manifest namespacing, trust, policy, contribution support, and host capability rules are normative in the C2 Plugin Standard 1.2.0.

An external extension runtime is a process. C2 speaks JSON-RPC 2.0 to it over stdio. Its commands are declared statically in the Bundle Manifest; initialize confirms their implementation and may subscribe to events. The host registers both in the same scoped kernel registries used by built-in Rust runtime modules. Installed runtimes have stable managed names of the form bundle:<id>. Their commands appear in kernel.commands, are callable from a frontend in the matching command realm, and disappear the instant the runtime unloads.

Write one in any language that can read stdin and write stdout.

Why a protocol at all

docs/reference/plugins.md defines C2's internal runtime-module graph, but a Rust host can only load modules it was compiled with. This protocol is the external extension seam, using the transport the app already speaks twice over (ACP to provider CLIs, MCP to tool servers) rather than inventing a third.

Transport

  • Framing — one JSON object per line, UTF-8, \n-terminated. No Content-Length header.
  • stdout is the protocol channel. Anything on it that is not JSON is dropped with a warning, so a stray print() degrades to noise rather than a crash.
  • stderr is your log channel. C2 routes it into its own tracing output.
  • The process is killed when the plugin unloads, and it never outlives the app.

Handshake

On the first invocation of a declared command, the host starts the process, sends initialize first, and waits 10 seconds. Answer it promptly. A process that misses the window is killed and that scope generation's activation fails; its dormant command stubs remain fail-closed until the plugin is reloaded or disabled and re-enabled.

Host → plugin

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion": "1.0.0",
"host": { "name": "code2", "version": "0.0.0", "commands": ["git.status"] },
"config": { "…": "your entry from the plugin config, verbatim" },
"dataDir": "/home/me/.codetwo/plugins/.data/my-plugin/projects/09a7…",
"projectPath": "/home/me/work/my-project"
}}

Plugin → host

{"jsonrpc":"2.0","id":1,"result":{
"name": "my-plugin",
"version": "0.1.0",
"protocolVersion": "1.0.0",
"commands": [
{ "name": "my.greet", "description": "Say hello.", "schema": { "type": "object" } }
],
"events": ["engine/event"]
}}

For a C2 1.2 bundle, commands MUST contain exactly the IDs and schemas declared by extensions.dev.codetwo.commands. It is implementation confirmation, not a second contribution source: missing, extra, duplicate, or changed schemas are refused and the process is killed. The host uses Manifest titles and descriptions.

Wire compatibility is by major version. Declaring 2.x to a 1.x host, or omitting protocolVersion, is refused with a readable error.

dataDir is yours to write to and is created before you start. A user-scoped instance gets <plugins-dir>/.data/<id>. A project-scoped instance gets <plugins-dir>/.data/<id>/projects/<blake3(normalized-project-path)>, so two projects do not share runtime files. projectPath is the normalized project identity for that instance and is omitted for the user-scoped instance.

host.commands is the extension-public callable surface visible from this realm at initialization time. Internal Core and frontend commands are omitted. A user-scoped instance sees public global commands only. A project instance sees public global commands plus public commands registered for the same normalized project; it never receives another project's command list. command/call rechecks the public marker and uses that same realm's normal project fallback/blocking rules, so guessing an internal command name does not grant access.

Methods

Host → plugin

methodkindparamsresult
initializerequestsee abovesee above
command/invokerequest{ name, args }whatever your command returns
event/emitnotification{ name, payload }

command/invoke only ever names a command declared in the Manifest. Returning a JSON-RPC error turns into a readable failure at the caller — the frontend sees my.greet: <your message>. An extension command that collides with a global command owned by Core or another module is rejected during activation, so a project extension cannot shadow host dispatch. The same project-capable extension may register its own command name in both global and project instances.

When a user activates a manifest ui contribution, C2 first verifies bundle ownership, trust, enablement, the selected user/project realm, and that the contribution's command was registered by this process. The resulting command/invoke uses these args:

{
"context": { "cwd": "/repo", "projectPath": "/repo", "sessionId": "session-id" },
"input": { "mode": "working-tree" }
}

context is host state for this activation; input is the descriptor's static JSON value. Neither is a capability grant. Host commands remain accessible only through the allowlisted, realm-aware command/call seam.

Plugin → host

methodkindparamsresult
command/callrequest{ name, args }the extension-public command's result
event/emitnotification{ name, payload }
lognotification{ level, message }

command/call reaches an extension-public command visible in the process's realm, by name, through the same registry a Rust runtime module uses. Commands are internal by default and must be deliberately published by Core. git.status is the initial read-only public command; mutating Git, plugin management, credentials, and other Core commands stay internal. If a public command's owner is turned off or project fallback is blocked, the call fails through the normal command path.

level is one of error, warn, info, debug, trace.

Events

You receive only the events you name in events. The host publishes:

namepayload
engine/eventone agent-loop Event (agent text, tool call, permission request, turn ended, …)
skills/changednull — the skill library was rebuilt
scenes/changednull — the scene library was re-resolved

This list is the contract. Typed Rust events do not cross a pipe, so each entry is a deliberate decision to expose one — see publish_host_events in crates/plugins/src/app/plugins/extensions.rs. Because activation is command-driven, event subscriptions begin only after the first command has successfully initialized the process; events emitted while the runtime is dormant are not buffered or replayed.

Your own event/emit normally goes onto the host's JSON bus, where other plugins (in or out of process) can subscribe to it. The JSON bus is currently host-wide, not project-confidential: project process isolation does not filter events by realm. Do not put project secrets on an event merely because the sender or subscriber is a project-scoped runtime.

connector/event is reserved for a host-rendered connector's provider notifications. It does not enter that public JSON bus. The host wraps the payload in a typed internal event and adds the authenticated installed-bundle id; any plugin_id supplied by the process is ignored. Desktop adapters must match that owner and the active connector's bundle-local connectorId before using the event. A connector event payload uses this minimum envelope:

{
"connectorId": "workspace",
"eventId": "provider-event-or-message-id",
"kind": "message.created",
"createdAt": "1724900000000"
}

Provider-specific resource ids and bounded summaries may be added. The process must deduplicate at-least-once provider delivery before emitting; the host-rendered adapter repeats that guard before changing local activity state. Event payloads are not a way to inject UI or bypass connector command ownership.

Declaring a plugin

Every C2 bundle uses the Agent Plugins 1.0.0 root schema and the mandatory C2 extension. Add the runtime under C2's client-extension namespace; a top-level runtime field invalidates the bundle:

{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "my-plugin",
"version": "1.0.0",
"extensions": {
"dev.codetwo": {
"standardVersion": "1.2.0",
"commands": [{
"id": "my.greet",
"title": "Say hello",
"description": "Greet the current user.",
"argsSchema": { "type": "object", "additionalProperties": false }
}],
"runtime": {
"protocol": "1.0.0",
"command": "node",
"args": ["dist/plugin.js"],
"env": { "MY_PLUGIN_MODE": "release" },
"inject": ["store"],
"optionalInject": ["engine"],
"scopeSupport": ["user", "project"]
}
}
}
}

When activated, the process starts with the bundle directory as its working directory.

inject gets you the same reactive contract a Rust plugin has: command stubs are not made ready until those services exist, and an active process is stopped when one is replaced. It is declared in the Manifest because the host needs it before making the adapter eligible.

scopeSupport is an explicit capability declaration. If it is omitted, the runtime supports only ["user"]. Include "project" only when the process is prepared for one independently managed process, command realm, dataDir, and projectPath per active project. The bundle's skills and other data-only extension components are not made project-scoped by this field; they remain user-only and are managed through Bundle Tools.

A process runtime declares at least one sibling commands entry. UI action descriptors are declared beside both under extensions.dev.codetwo.ui; they do not alter this wire protocol or load third-party renderer code.

extensions.dev.codetwo.languageServers is a separate host-owned stdio LSP contribution. Language servers use standard Content-Length LSP framing, not this newline-delimited plugin protocol. They may exist without a C2 process runtime and still share bundle trust, enablement, and teardown.

Trust

Installing a bundle executes nothing. That property of the Plugin Hub is not weakened by this protocol — it is the reason the protocol is shaped this way.

An enabled and trusted bundle is eligible: its host adapter and dormant command stubs become ready, but the process does not start until the first declared command invocation. Trust is a separate, deliberate user action, and installing a bundle that ships a C2 runtime raises a diagnostic saying so. Until then the plugin is listed by extensions.list under untrusted.

Trust is a hard gate in every realm. No configuration setting can bypass it.

Lifecycle

enabled + trusted ──▶ register static stubs ──▶ ready, process dormant
│ first command
▼
spawn + initialize
│
exact command/schema confirmation ──▶ invoke
│
timeout / mismatch ──▶ process killed; activation terminal
caller cancellation ──▶ process killed; next invocation may retry
unload, dependency lost, bundle disabled/removed ──▶ process killed; stubs removed

Unloading is exact: the process is killed and every command it contributed is gone. Nothing has to remember to clean up, because the plugin's scope owns all of it.

Installed runtimes are ordinary dynamically registered factories named bundle:<id>. Installing, removing, enabling, trusting, or replacing a bundle reconciles that factory set and every live eligible realm immediately. Add and remove do not require an application restart; replacing a manifest or installed record rebuilds the same-named runtime because its factory revision changed. User and project state changes go through plugins.catalog, plugins.plan_change, and plugins.apply_change, including stale-plan protection and immediate unload.

A complete, runnable example lives in packs/hello-runtime/: it contributes a command, calls git.status back through the host from inside it, feature-detects the extension-public surface, and listens for a host event — in one file with no dependencies.

A minimal plugin

#!/usr/bin/env node
constreadline=require("readline");constrl=readline.createInterface({input: process.stdin});constsend=(m)=>process.stdout.write(JSON.stringify(m)+"\n");rl.on("line",async(line)=>{constmsg=JSON.parse(line);if(msg.method==="initialize"){send({jsonrpc: "2.0",id: msg.id,result: {name: "hello",version: "1.0.0",protocolVersion: "1.0.0",commands: [{name: "hello.status",description: "Repo status, via the host."}],events: ["skills/changed"],}});}elseif(msg.method==="command/invoke"){// Call a host command and answer with what it said.send({jsonrpc: "2.0",id: 1000,method: "command/call",params: {name: "git.status",args: {cwd: msg.params.args.cwd}}});// (a real plugin would correlate the response by id)}elseif(msg.method==="event/emit"){console.error(`host event: ${msg.params.name}`);// stderr is the log channel}});

Limits, stated plainly

  • No sandbox. A trusted plugin is a process with your user's permissions. Trust is the whole OS security boundary; project command/data/process isolation does not restrict filesystem, network, environment, or the host-wide JSON event bus. Treat it as such.
  • No timeout on command/invoke. A plugin that never answers a command hangs that caller — not the graph. The handshake is the only bounded wait.
  • One process per activated managed realm. A project-capable runtime may have one global process and separate processes for multiple live projects. Fan out inside one realm if you need more.
  • Rust plugins are still compile-time. This protocol is how a plugin gets added without rebuilding C2; it is not dynamic loading of native code, and deliberately so.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

History
310 lines (249 loc) · 14.5 KB

File metadata and controls

310 lines (249 loc) · 14.5 KB

The C2 Plugin Protocol

Status: normative protocol contract for external process extensions.

Version 1.0.0.

This document defines the process wire format. Bundle terminology, manifest namespacing, trust, policy, contribution support, and host capability rules are normative in the C2 Plugin Standard 1.2.0.

An external extension runtime is a process. C2 speaks JSON-RPC 2.0 to it over stdio. Its commands are declared statically in the Bundle Manifest; initialize confirms their implementation and may subscribe to events. The host registers both in the same scoped kernel registries used by built-in Rust runtime modules. Installed runtimes have stable managed names of the form bundle:<id>. Their commands appear in kernel.commands, are callable from a frontend in the matching command realm, and disappear the instant the runtime unloads.

Write one in any language that can read stdin and write stdout.

Why a protocol at all

docs/reference/plugins.md defines C2's internal runtime-module graph, but a Rust host can only load modules it was compiled with. This protocol is the external extension seam, using the transport the app already speaks twice over (ACP to provider CLIs, MCP to tool servers) rather than inventing a third.

Transport

  • Framing — one JSON object per line, UTF-8, \n-terminated. No Content-Length header.
  • stdout is the protocol channel. Anything on it that is not JSON is dropped with a warning, so a stray print() degrades to noise rather than a crash.
  • stderr is your log channel. C2 routes it into its own tracing output.
  • The process is killed when the plugin unloads, and it never outlives the app.

Handshake

On the first invocation of a declared command, the host starts the process, sends initialize first, and waits 10 seconds. Answer it promptly. A process that misses the window is killed and that scope generation's activation fails; its dormant command stubs remain fail-closed until the plugin is reloaded or disabled and re-enabled.

Host → plugin

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion": "1.0.0",
"host": { "name": "code2", "version": "0.0.0", "commands": ["git.status"] },
"config": { "…": "your entry from the plugin config, verbatim" },
"dataDir": "/home/me/.codetwo/plugins/.data/my-plugin/projects/09a7…",
"projectPath": "/home/me/work/my-project"
}}

Plugin → host

{"jsonrpc":"2.0","id":1,"result":{
"name": "my-plugin",
"version": "0.1.0",
"protocolVersion": "1.0.0",
"commands": [
{ "name": "my.greet", "description": "Say hello.", "schema": { "type": "object" } }
],
"events": ["engine/event"]
}}

For a C2 1.2 bundle, commands MUST contain exactly the IDs and schemas declared by extensions.dev.codetwo.commands. It is implementation confirmation, not a second contribution source: missing, extra, duplicate, or changed schemas are refused and the process is killed. The host uses Manifest titles and descriptions.

Wire compatibility is by major version. Declaring 2.x to a 1.x host, or omitting protocolVersion, is refused with a readable error.

dataDir is yours to write to and is created before you start. A user-scoped instance gets <plugins-dir>/.data/<id>. A project-scoped instance gets <plugins-dir>/.data/<id>/projects/<blake3(normalized-project-path)>, so two projects do not share runtime files. projectPath is the normalized project identity for that instance and is omitted for the user-scoped instance.

host.commands is the extension-public callable surface visible from this realm at initialization time. Internal Core and frontend commands are omitted. A user-scoped instance sees public global commands only. A project instance sees public global commands plus public commands registered for the same normalized project; it never receives another project's command list. command/call rechecks the public marker and uses that same realm's normal project fallback/blocking rules, so guessing an internal command name does not grant access.

Methods

Host → plugin

methodkindparamsresult
initializerequestsee abovesee above
command/invokerequest{ name, args }whatever your command returns
event/emitnotification{ name, payload }

command/invoke only ever names a command declared in the Manifest. Returning a JSON-RPC error turns into a readable failure at the caller — the frontend sees my.greet: <your message>. An extension command that collides with a global command owned by Core or another module is rejected during activation, so a project extension cannot shadow host dispatch. The same project-capable extension may register its own command name in both global and project instances.

When a user activates a manifest ui contribution, C2 first verifies bundle ownership, trust, enablement, the selected user/project realm, and that the contribution's command was registered by this process. The resulting command/invoke uses these args:

{
"context": { "cwd": "/repo", "projectPath": "/repo", "sessionId": "session-id" },
"input": { "mode": "working-tree" }
}

context is host state for this activation; input is the descriptor's static JSON value. Neither is a capability grant. Host commands remain accessible only through the allowlisted, realm-aware command/call seam.

Plugin → host

methodkindparamsresult
command/callrequest{ name, args }the extension-public command's result
event/emitnotification{ name, payload }
lognotification{ level, message }

command/call reaches an extension-public command visible in the process's realm, by name, through the same registry a Rust runtime module uses. Commands are internal by default and must be deliberately published by Core. git.status is the initial read-only public command; mutating Git, plugin management, credentials, and other Core commands stay internal. If a public command's owner is turned off or project fallback is blocked, the call fails through the normal command path.

level is one of error, warn, info, debug, trace.

Events

You receive only the events you name in events. The host publishes:

namepayload
engine/eventone agent-loop Event (agent text, tool call, permission request, turn ended, …)
skills/changednull — the skill library was rebuilt
scenes/changednull — the scene library was re-resolved

This list is the contract. Typed Rust events do not cross a pipe, so each entry is a deliberate decision to expose one — see publish_host_events in crates/plugins/src/app/plugins/extensions.rs. Because activation is command-driven, event subscriptions begin only after the first command has successfully initialized the process; events emitted while the runtime is dormant are not buffered or replayed.

Your own event/emit normally goes onto the host's JSON bus, where other plugins (in or out of process) can subscribe to it. The JSON bus is currently host-wide, not project-confidential: project process isolation does not filter events by realm. Do not put project secrets on an event merely because the sender or subscriber is a project-scoped runtime.

connector/event is reserved for a host-rendered connector's provider notifications. It does not enter that public JSON bus. The host wraps the payload in a typed internal event and adds the authenticated installed-bundle id; any plugin_id supplied by the process is ignored. Desktop adapters must match that owner and the active connector's bundle-local connectorId before using the event. A connector event payload uses this minimum envelope:

{
"connectorId": "workspace",
"eventId": "provider-event-or-message-id",
"kind": "message.created",
"createdAt": "1724900000000"
}

Provider-specific resource ids and bounded summaries may be added. The process must deduplicate at-least-once provider delivery before emitting; the host-rendered adapter repeats that guard before changing local activity state. Event payloads are not a way to inject UI or bypass connector command ownership.

Declaring a plugin

Every C2 bundle uses the Agent Plugins 1.0.0 root schema and the mandatory C2 extension. Add the runtime under C2's client-extension namespace; a top-level runtime field invalidates the bundle:

{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "my-plugin",
"version": "1.0.0",
"extensions": {
"dev.codetwo": {
"standardVersion": "1.2.0",
"commands": [{
"id": "my.greet",
"title": "Say hello",
"description": "Greet the current user.",
"argsSchema": { "type": "object", "additionalProperties": false }
}],
"runtime": {
"protocol": "1.0.0",
"command": "node",
"args": ["dist/plugin.js"],
"env": { "MY_PLUGIN_MODE": "release" },
"inject": ["store"],
"optionalInject": ["engine"],
"scopeSupport": ["user", "project"]
}
}
}
}

When activated, the process starts with the bundle directory as its working directory.

inject gets you the same reactive contract a Rust plugin has: command stubs are not made ready until those services exist, and an active process is stopped when one is replaced. It is declared in the Manifest because the host needs it before making the adapter eligible.

scopeSupport is an explicit capability declaration. If it is omitted, the runtime supports only ["user"]. Include "project" only when the process is prepared for one independently managed process, command realm, dataDir, and projectPath per active project. The bundle's skills and other data-only extension components are not made project-scoped by this field; they remain user-only and are managed through Bundle Tools.

A process runtime declares at least one sibling commands entry. UI action descriptors are declared beside both under extensions.dev.codetwo.ui; they do not alter this wire protocol or load third-party renderer code.

extensions.dev.codetwo.languageServers is a separate host-owned stdio LSP contribution. Language servers use standard Content-Length LSP framing, not this newline-delimited plugin protocol. They may exist without a C2 process runtime and still share bundle trust, enablement, and teardown.

Trust

Installing a bundle executes nothing. That property of the Plugin Hub is not weakened by this protocol — it is the reason the protocol is shaped this way.

An enabled and trusted bundle is eligible: its host adapter and dormant command stubs become ready, but the process does not start until the first declared command invocation. Trust is a separate, deliberate user action, and installing a bundle that ships a C2 runtime raises a diagnostic saying so. Until then the plugin is listed by extensions.list under untrusted.

Trust is a hard gate in every realm. No configuration setting can bypass it.

Lifecycle

enabled + trusted ──▶ register static stubs ──▶ ready, process dormant
│ first command
▼
spawn + initialize
│
exact command/schema confirmation ──▶ invoke
│
timeout / mismatch ──▶ process killed; activation terminal
caller cancellation ──▶ process killed; next invocation may retry
unload, dependency lost, bundle disabled/removed ──▶ process killed; stubs removed

Unloading is exact: the process is killed and every command it contributed is gone. Nothing has to remember to clean up, because the plugin's scope owns all of it.

Installed runtimes are ordinary dynamically registered factories named bundle:<id>. Installing, removing, enabling, trusting, or replacing a bundle reconciles that factory set and every live eligible realm immediately. Add and remove do not require an application restart; replacing a manifest or installed record rebuilds the same-named runtime because its factory revision changed. User and project state changes go through plugins.catalog, plugins.plan_change, and plugins.apply_change, including stale-plan protection and immediate unload.

A complete, runnable example lives in packs/hello-runtime/: it contributes a command, calls git.status back through the host from inside it, feature-detects the extension-public surface, and listens for a host event — in one file with no dependencies.

A minimal plugin

#!/usr/bin/env node
constreadline=require("readline");constrl=readline.createInterface({input: process.stdin});constsend=(m)=>process.stdout.write(JSON.stringify(m)+"\n");rl.on("line",async(line)=>{constmsg=JSON.parse(line);if(msg.method==="initialize"){send({jsonrpc: "2.0",id: msg.id,result: {name: "hello",version: "1.0.0",protocolVersion: "1.0.0",commands: [{name: "hello.status",description: "Repo status, via the host."}],events: ["skills/changed"],}});}elseif(msg.method==="command/invoke"){// Call a host command and answer with what it said.send({jsonrpc: "2.0",id: 1000,method: "command/call",params: {name: "git.status",args: {cwd: msg.params.args.cwd}}});// (a real plugin would correlate the response by id)}elseif(msg.method==="event/emit"){console.error(`host event: ${msg.params.name}`);// stderr is the log channel}});

Limits, stated plainly

  • No sandbox. A trusted plugin is a process with your user's permissions. Trust is the whole OS security boundary; project command/data/process isolation does not restrict filesystem, network, environment, or the host-wide JSON event bus. Treat it as such.
  • No timeout on command/invoke. A plugin that never answers a command hangs that caller — not the graph. The handshake is the only bounded wait.
  • One process per activated managed realm. A project-capable runtime may have one global process and separate processes for multiple live projects. Fan out inside one realm if you need more.
  • Rust plugins are still compile-time. This protocol is how a plugin gets added without rebuilding C2; it is not dynamic loading of native code, and deliberately so.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Latest commit

History

History
310 lines (249 loc) · 14.5 KB

File metadata and controls

310 lines (249 loc) · 14.5 KB

The C2 Plugin Protocol

Status: normative protocol contract for external process extensions.

Version 1.0.0.

This document defines the process wire format. Bundle terminology, manifest namespacing, trust, policy, contribution support, and host capability rules are normative in the C2 Plugin Standard 1.2.0.

An external extension runtime is a process. C2 speaks JSON-RPC 2.0 to it over stdio. Its commands are declared statically in the Bundle Manifest; initialize confirms their implementation and may subscribe to events. The host registers both in the same scoped kernel registries used by built-in Rust runtime modules. Installed runtimes have stable managed names of the form bundle:<id>. Their commands appear in kernel.commands, are callable from a frontend in the matching command realm, and disappear the instant the runtime unloads.

Write one in any language that can read stdin and write stdout.

Why a protocol at all

docs/reference/plugins.md defines C2's internal runtime-module graph, but a Rust host can only load modules it was compiled with. This protocol is the external extension seam, using the transport the app already speaks twice over (ACP to provider CLIs, MCP to tool servers) rather than inventing a third.

Transport

  • Framing — one JSON object per line, UTF-8, \n-terminated. No Content-Length header.
  • stdout is the protocol channel. Anything on it that is not JSON is dropped with a warning, so a stray print() degrades to noise rather than a crash.
  • stderr is your log channel. C2 routes it into its own tracing output.
  • The process is killed when the plugin unloads, and it never outlives the app.

Handshake

On the first invocation of a declared command, the host starts the process, sends initialize first, and waits 10 seconds. Answer it promptly. A process that misses the window is killed and that scope generation's activation fails; its dormant command stubs remain fail-closed until the plugin is reloaded or disabled and re-enabled.

Host → plugin

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion": "1.0.0",
"host": { "name": "code2", "version": "0.0.0", "commands": ["git.status"] },
"config": { "…": "your entry from the plugin config, verbatim" },
"dataDir": "/home/me/.codetwo/plugins/.data/my-plugin/projects/09a7…",
"projectPath": "/home/me/work/my-project"
}}

Plugin → host

{"jsonrpc":"2.0","id":1,"result":{
"name": "my-plugin",
"version": "0.1.0",
"protocolVersion": "1.0.0",
"commands": [
{ "name": "my.greet", "description": "Say hello.", "schema": { "type": "object" } }
],
"events": ["engine/event"]
}}

For a C2 1.2 bundle, commands MUST contain exactly the IDs and schemas declared by extensions.dev.codetwo.commands. It is implementation confirmation, not a second contribution source: missing, extra, duplicate, or changed schemas are refused and the process is killed. The host uses Manifest titles and descriptions.

Wire compatibility is by major version. Declaring 2.x to a 1.x host, or omitting protocolVersion, is refused with a readable error.

dataDir is yours to write to and is created before you start. A user-scoped instance gets <plugins-dir>/.data/<id>. A project-scoped instance gets <plugins-dir>/.data/<id>/projects/<blake3(normalized-project-path)>, so two projects do not share runtime files. projectPath is the normalized project identity for that instance and is omitted for the user-scoped instance.

host.commands is the extension-public callable surface visible from this realm at initialization time. Internal Core and frontend commands are omitted. A user-scoped instance sees public global commands only. A project instance sees public global commands plus public commands registered for the same normalized project; it never receives another project's command list. command/call rechecks the public marker and uses that same realm's normal project fallback/blocking rules, so guessing an internal command name does not grant access.

Methods

Host → plugin

methodkindparamsresult
initializerequestsee abovesee above
command/invokerequest{ name, args }whatever your command returns
event/emitnotification{ name, payload }

command/invoke only ever names a command declared in the Manifest. Returning a JSON-RPC error turns into a readable failure at the caller — the frontend sees my.greet: <your message>. An extension command that collides with a global command owned by Core or another module is rejected during activation, so a project extension cannot shadow host dispatch. The same project-capable extension may register its own command name in both global and project instances.

When a user activates a manifest ui contribution, C2 first verifies bundle ownership, trust, enablement, the selected user/project realm, and that the contribution's command was registered by this process. The resulting command/invoke uses these args:

{
"context": { "cwd": "/repo", "projectPath": "/repo", "sessionId": "session-id" },
"input": { "mode": "working-tree" }
}

context is host state for this activation; input is the descriptor's static JSON value. Neither is a capability grant. Host commands remain accessible only through the allowlisted, realm-aware command/call seam.

Plugin → host

methodkindparamsresult
command/callrequest{ name, args }the extension-public command's result
event/emitnotification{ name, payload }
lognotification{ level, message }

command/call reaches an extension-public command visible in the process's realm, by name, through the same registry a Rust runtime module uses. Commands are internal by default and must be deliberately published by Core. git.status is the initial read-only public command; mutating Git, plugin management, credentials, and other Core commands stay internal. If a public command's owner is turned off or project fallback is blocked, the call fails through the normal command path.

level is one of error, warn, info, debug, trace.

Events

You receive only the events you name in events. The host publishes:

namepayload
engine/eventone agent-loop Event (agent text, tool call, permission request, turn ended, …)
skills/changednull — the skill library was rebuilt
scenes/changednull — the scene library was re-resolved

This list is the contract. Typed Rust events do not cross a pipe, so each entry is a deliberate decision to expose one — see publish_host_events in crates/plugins/src/app/plugins/extensions.rs. Because activation is command-driven, event subscriptions begin only after the first command has successfully initialized the process; events emitted while the runtime is dormant are not buffered or replayed.

Your own event/emit normally goes onto the host's JSON bus, where other plugins (in or out of process) can subscribe to it. The JSON bus is currently host-wide, not project-confidential: project process isolation does not filter events by realm. Do not put project secrets on an event merely because the sender or subscriber is a project-scoped runtime.

connector/event is reserved for a host-rendered connector's provider notifications. It does not enter that public JSON bus. The host wraps the payload in a typed internal event and adds the authenticated installed-bundle id; any plugin_id supplied by the process is ignored. Desktop adapters must match that owner and the active connector's bundle-local connectorId before using the event. A connector event payload uses this minimum envelope:

{
"connectorId": "workspace",
"eventId": "provider-event-or-message-id",
"kind": "message.created",
"createdAt": "1724900000000"
}

Provider-specific resource ids and bounded summaries may be added. The process must deduplicate at-least-once provider delivery before emitting; the host-rendered adapter repeats that guard before changing local activity state. Event payloads are not a way to inject UI or bypass connector command ownership.

Declaring a plugin

Every C2 bundle uses the Agent Plugins 1.0.0 root schema and the mandatory C2 extension. Add the runtime under C2's client-extension namespace; a top-level runtime field invalidates the bundle:

{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "my-plugin",
"version": "1.0.0",
"extensions": {
"dev.codetwo": {
"standardVersion": "1.2.0",
"commands": [{
"id": "my.greet",
"title": "Say hello",
"description": "Greet the current user.",
"argsSchema": { "type": "object", "additionalProperties": false }
}],
"runtime": {
"protocol": "1.0.0",
"command": "node",
"args": ["dist/plugin.js"],
"env": { "MY_PLUGIN_MODE": "release" },
"inject": ["store"],
"optionalInject": ["engine"],
"scopeSupport": ["user", "project"]
}
}
}
}

When activated, the process starts with the bundle directory as its working directory.

inject gets you the same reactive contract a Rust plugin has: command stubs are not made ready until those services exist, and an active process is stopped when one is replaced. It is declared in the Manifest because the host needs it before making the adapter eligible.

scopeSupport is an explicit capability declaration. If it is omitted, the runtime supports only ["user"]. Include "project" only when the process is prepared for one independently managed process, command realm, dataDir, and projectPath per active project. The bundle's skills and other data-only extension components are not made project-scoped by this field; they remain user-only and are managed through Bundle Tools.

A process runtime declares at least one sibling commands entry. UI action descriptors are declared beside both under extensions.dev.codetwo.ui; they do not alter this wire protocol or load third-party renderer code.

extensions.dev.codetwo.languageServers is a separate host-owned stdio LSP contribution. Language servers use standard Content-Length LSP framing, not this newline-delimited plugin protocol. They may exist without a C2 process runtime and still share bundle trust, enablement, and teardown.

Trust

Installing a bundle executes nothing. That property of the Plugin Hub is not weakened by this protocol — it is the reason the protocol is shaped this way.

An enabled and trusted bundle is eligible: its host adapter and dormant command stubs become ready, but the process does not start until the first declared command invocation. Trust is a separate, deliberate user action, and installing a bundle that ships a C2 runtime raises a diagnostic saying so. Until then the plugin is listed by extensions.list under untrusted.

Trust is a hard gate in every realm. No configuration setting can bypass it.

Lifecycle

enabled + trusted ──▶ register static stubs ──▶ ready, process dormant
│ first command
▼
spawn + initialize
│
exact command/schema confirmation ──▶ invoke
│
timeout / mismatch ──▶ process killed; activation terminal
caller cancellation ──▶ process killed; next invocation may retry
unload, dependency lost, bundle disabled/removed ──▶ process killed; stubs removed

Unloading is exact: the process is killed and every command it contributed is gone. Nothing has to remember to clean up, because the plugin's scope owns all of it.

Installed runtimes are ordinary dynamically registered factories named bundle:<id>. Installing, removing, enabling, trusting, or replacing a bundle reconciles that factory set and every live eligible realm immediately. Add and remove do not require an application restart; replacing a manifest or installed record rebuilds the same-named runtime because its factory revision changed. User and project state changes go through plugins.catalog, plugins.plan_change, and plugins.apply_change, including stale-plan protection and immediate unload.

A complete, runnable example lives in packs/hello-runtime/: it contributes a command, calls git.status back through the host from inside it, feature-detects the extension-public surface, and listens for a host event — in one file with no dependencies.

A minimal plugin

#!/usr/bin/env node
constreadline=require("readline");constrl=readline.createInterface({input: process.stdin});constsend=(m)=>process.stdout.write(JSON.stringify(m)+"\n");rl.on("line",async(line)=>{constmsg=JSON.parse(line);if(msg.method==="initialize"){send({jsonrpc: "2.0",id: msg.id,result: {name: "hello",version: "1.0.0",protocolVersion: "1.0.0",commands: [{name: "hello.status",description: "Repo status, via the host."}],events: ["skills/changed"],}});}elseif(msg.method==="command/invoke"){// Call a host command and answer with what it said.send({jsonrpc: "2.0",id: 1000,method: "command/call",params: {name: "git.status",args: {cwd: msg.params.args.cwd}}});// (a real plugin would correlate the response by id)}elseif(msg.method==="event/emit"){console.error(`host event: ${msg.params.name}`);// stderr is the log channel}});

Limits, stated plainly

  • No sandbox. A trusted plugin is a process with your user's permissions. Trust is the whole OS security boundary; project command/data/process isolation does not restrict filesystem, network, environment, or the host-wide JSON event bus. Treat it as such.
  • No timeout on command/invoke. A plugin that never answers a command hangs that caller — not the graph. The handshake is the only bounded wait.
  • One process per activated managed realm. A project-capable runtime may have one global process and separate processes for multiple live projects. Fan out inside one realm if you need more.
  • Rust plugins are still compile-time. This protocol is how a plugin gets added without rebuilding C2; it is not dynamic loading of native code, and deliberately so.