Skip to content

Latest commit

 

History

History
289 lines (232 loc) · 13.7 KB

File metadata and controls

289 lines (232 loc) · 13.7 KB

The C2 Plugin Protocol

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.1.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/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 1.1 static 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. Legacy 1.0 Bundles retain eager initialization for compatibility, with the same bounded wait.

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 1.1 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

method kind params result
initialize request see above see above
command/invoke request { name, args } whatever your command returns
event/emit notification { 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

method kind params result
command/call request { name, args } the extension-public command's result
event/emit notification { name, payload }
log notification { 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:

name payload
engine/event one agent-loop Event (agent text, tool call, permission request, turn ended, …)
skills/changed null — the skill library was rebuilt
scenes/changed null — 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 1.1 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 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.

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.1.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 1.1 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. A legacy 1.0 runtime without static commands remains valid and eager.

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 1.1 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
const readline = require("readline");
const rl = readline.createInterface({ input: process.stdin });
const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");

rl.on("line", async (line) => {
  const msg = 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"],
    }});
  } else if (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)
  } else if (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.