Skip to content

2.2.13 - #291

Merged
WiggidyW merged 257 commits into
mainfrom
2-2-13
Jul 28, 2026
Merged

2.2.13#291
WiggidyW merged 257 commits into
mainfrom
2-2-13

Conversation

@WiggidyW

Copy link
Copy Markdown
Collaborator

257 commits. The through-line is that a plugin is now a container the
laboratory builds and runs, and everything else follows from that: the
MCP server moved into the daemon, the CLI's tools feature went away,
and the viewer learned to host what plugins contribute.

What's in it

Plugins are containers. The laboratory builds a plugin's image from
its own objectiveai.json + Containerfile and runs it as an ephemeral
per-completion container, with the daemon routing to a host. The CLI
tools feature is gone — plugins only.

The daemon IS the MCP server. The standalone objectiveai-mcp
crate is deleted; /mcp accepts its signature via the standard
Authorization header.

A framework and a scaffold for writing plugins in Rust.
objectiveai-mcp-plugin-framework gives a plugin its identity,
arguments, database, CLI command executor, and a tool set it can swap
while serving. objectiveai-mcp-plugin-scaffold-rs is the copyable
starting point, with a plugin-development skill that travels with it.

Development mode. development plugins mcp {create,list,delete,reset}
points a plugin's coordinates at a local directory, so the host builds
from the working tree instead of a git tag — no push, no tag. Build
caches are declared in the manifest as container paths (language
agnostic) and bound in as podman build -v mounts. Registered plugins
pin to the local host.

Viewer. One child webview per tab with lossless pop-out/pop-in;
plugin tabs as runtime-imported React components sharing the host's
React through an import map; real Chromium browser tabs with injected
scripts, a mailbox key and persistent profiles; viewer builds moved
into the plugin's own Containerfile, so the host needs no node or pnpm.

Duplex channels between a daemon plugin and a viewer plugin, and a
tasks system for running commands on a schedule.

Closes

Closes #269
Closes #270
Closes #271
Closes #272
Closes #273
Closes #276
Closes #277
Closes #282
Closes #283
Closes #284
Closes #285
Closes #286

Release note

The javascript SDK job has been broken since 2.2.11: it pinned node
exactly and floated npm@latest, and npm 12 raised its engine floor
above the pinned node. Both 2.2.11 and 2.2.12 failed there with
byte-identical EBADENGINE errors while every other job succeeded, so
npm is on 2.2.10 while crates.io and PyPI are on 2.2.12. Node and
npm are now pinned together (85ee2bd87). This is forward-only — the
two missing versions are not being backfilled, so npm will jump
2.2.10 → 2.2.13.

Not verified

The plugin scaffold has never completed a container build, because its
only dependency — the framework — is not on crates.io yet. This release
publishes it, which is what unblocks that test.

WiggidyW and others added 30 commits July 18, 2026 19:08
Hand-written /channels SSE consumer + accept client (fetch mode): folds
connection/offer/owner_secret/closed events, captures S_conn, and
accept(channelId) POSTs the accept then awaits the daemon-pushed
S_owner. Swaps the cli index export userListener -> channelListener and
deletes userListener.ts (its generated types are removed on regen).
Generated channel_listener types arrive with the SDK regen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d Go fix

channels logs list/open no longer nest the full AgentArguments bag on
each entry. Matching the agents-logs convention, identity is now INLINE
and sender-only: sender_agent_instance_hierarchy plus the originating
plugin trio (owner/repository/version, omitted when not a plugin). The
daemon projects the stored full identity down at read time; the rest of
the bag stays in the DB, unshown.

Also drops the doc comments from MessageKind's variants: a fieldless
enum with variant docs renders as a oneOf, and the Go generator panics
on oneOf variants without titles (JS/Py don't). Flat enum = Go-happy,
matching ClientNotificationPartType.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…emoved)

Follow-up to the identity flatten: open's Response::Entry no longer has
an `identity` field and list::to_identity is gone — inline the sender +
plugin fields directly, matching list::to_entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds cli.command.channels.* + cli.channel_listener.* schemas; drops
cli.command.user.* + cli.user_listener.*. Generated from the Rust SDK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
channel_listener + channels command-tier types; drops user_listener +
user tier. Build artifacts (dist/, src/wasm/) left at HEAD — regenerated
before PR. sdk-js build: SUCCESS (release).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
channels + channel_listener types; drops user. go build: SUCCESS
(the MessageKind flat-enum fix cleared the earlier oneOf-title panic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
channels + channel_listener types + tests; drops user. sdk-py build:
SUCCESS (release). Native ext (_pyo3.pyd) gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drops the tests/tools/{hello,error}-tool fixture crates (+ workspace
members), tool_dispatch_e2e.rs + its 2 snapshots, the daemon
assets/tools INSTRUCTIONS, and the tests/tools references in build.sh /
test-integration.sh. Part of removing the tools feature (#272).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tools was a thin binary-wrapper (run a script/binary through our
program); it's fully subsumed by plugins, which are being containerized
(#271). Removed entirely; plugins are the one extension mechanism.

Shared types relocated to the plugins side (they were only 'tools' types
because tools and plugins were parallel): the SDK per-OS Exec -> plugins
get tier; the daemon CliZip/CliZipArch + platform_exec -> filesystem/
plugins. The generic install engine stays but is de-abstracted: the
now-single-variant InstallKind enum and its KIND-threading are removed
(plugins-only; future kinds like containerized plugins re-add as needed).

Deleted: SDK cli/command/tools + all root Tools arms + dispatch + json_
schema regs; daemon command/tools + filesystem/tools + Request::Tools +
the plugin-run tools guard + the dead ToolRunByPlugin error + WIPE_KEEP
'tools'. objectiveai-mcp: dropped CLI-tools registration/dispatch, kept
plugins + the client_objectiveai_mcp header filter (its CLI-tools
membership check dropped — no tools to validate against).

Green: sdk (+coverage), daemon, cli, mcp, integration. Generated
js/py/go/json-schema tools bindings are orphaned until the pre-PR regen
sweeps them. Out of scope (survives): agents mcp tools, tool_response,
generic MCP tool machinery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The script + workspace-member half of the tools fixture removal (split
off from ca4a610 by a pathspec race). Removes the tests/tools/* members
and Cargo.lock entries, and the tests/tools loop/find refs in build.sh
and test-integration.sh. #272

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fulfilling side of the plugin command protocol: an MCP connection
established with the (upcoming) command-execution extension hands
incoming command requests to a CommandExecution implementor and pumps
the resulting stream back to the server. Wire-level serde_json::Value
in/out because mcp cannot see the cli feature's types (cli depends on
mcp). UnimplementedCommandExecution is the statically-dead impl for
connections established without the extension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…andExecutor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… requires cli

- McpCommandExecutor -> McpClientCommandExecutor (named from the MCP
  client's perspective: the client executes commands the server
  requests); file renamed to match
- execute() now takes the typed cli::command::Request — the connection
  deserializes off the wire before dispatching
- feature edge flipped: mcp = ["cli", ...] (cli no longer implies
  mcp), so the mcp module can name cli types without cfg gates

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cli = ["mcp", ...] restored alongside mcp = ["cli", ...] — cargo
feature resolution is a fixed-point union, so the cycle is legal and
simply pins the two features together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
json_rpc.rs becomes the json_rpc/ module: derive-only, method-tagged
frames modeled on rmcp's untagged pattern, no manual serde, no
serde_json::Value at the envelope/method level.

- Single-variant method marker enums pin each variant to its exact
  method string; #[serde(untagged)] trying variants in order IS the
  dispatch. Every variant carries the full frame (jsonrpc/id/method/
  params) — no envelope type.
- Each frame enum ends in a total Fallback variant whose method is a
  union of all known markers + String: a known marker there means
  known-method-bad-params (-32602 WITH the id), Other means unknown
  method (-32601). Ids are never lost to parse failures.
- JsonRpcRequest (Initialize/Ping/ListTools/CallTool/ListResources/
  ReadResource/Fallback) with constructors; typed
  InitializeRequestParams + ClientCapabilities; byte-identical
  initialize frame (golden test).
- JsonRpcClientNotification (Initialized/Cancelled/Fallback),
  JsonRpcServerNotification (ToolsListChanged/ResourcesListChanged/
  CliRequest/Fallback — the command-execution extension rides the
  typed layer), JsonRpcClientMessage (Request|Notification union —
  the proxy's id-sniff moved into one typed parse).
- RequestId (untagged Number|String, Eq+Hash) replaces Value ids
  everywhere; JsonRpcResponse Success.id: RequestId, Error.id:
  Option<RequestId> (None = explicit null); EmptyObject for ping.
- connection.rs: rpc() takes a typed JsonRpcRequest; SSE listener
  parses JsonRpcServerNotification (CliRequest arm is a documented
  no-op until executor wiring). client.rs: typed initialize +
  initialized handshake.
- proxy: handle_post parses one JsonRpcClientMessage and matches;
  per-handler from_value dances and the raw protocolVersion dig are
  gone; error builders take RequestId/Option<RequestId>; in-flight
  map keys on RequestId directly (request_id_key deleted).
- Old JsonRpcRequest struct, JsonRpcNotification, and the interim
  Notification/ClientNotification files are deleted/absorbed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- McpClientCommandExecutor stream re-typed: items are
  cli::command::ResponseItem (no serde_json::Value)
- UnimplementedMcpClientCommandExecutor -> NotSupportedMcpClientCommandExecutor,
  returns CommandExecutionNotSupported instead of unreachable!()
- CliResponse POST body (Item/Error/Done, tagged; Error non-terminal,
  Done ALWAYS terminal) + {mcp_url}/objectiveai/command endpoint suffix
- objectiveai capability = "objectiveai" key in
  ServerCapabilities.experimental (has_objectiveai(); zero schema change);
  Client::connect opens the GET SSE stream when present
- Client<E>/Connection<E>/ConnectionInner<E> with
  NotSupported default (external crates compile unchanged); manual
  Debug impls so E needs no Debug; Client::with_executor opt-in;
  executor cloned per connection
- SSE listener CliRequest arm fulfills via fulfill_cli_request:
  execute -> POST each frame as it arrives -> unconditional Done;
  POST failure aborts the pump (drops stream = cancels run) but still
  attempts Done

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every handler is spawned off the listener loop — list_changed
refreshes, the reconnect catch-up, and command fulfillments all run in
parallel; a long command run can no longer delay other notifications.
Spawned tasks hold a strong Arc for their duration and race the
connection's cancel token (explicit delete() aborts them; plain drop
lets in-flight handlers finish). Frame order stays guaranteed per
command run, not across runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Track SSE id: lines and send Last-Event-ID on every reconnect GET, so
a spec-conformant server (rmcp caches server->client frames in a ring
buffer) resumes precisely after the last processed event instead of
replaying its whole retained cache — a bare GET is resume-from-0 in
rmcp, and a replayed cli_request would re-execute a command. A
definite HTTP rejection of the resume drops the stored id (evicted
index / restarted session) so the next attempt reconnects plain;
transport errors keep it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An exchange is now Ack (Item|Error)* Done: the connection POSTs Ack
the moment it picks a request up — before execute(), which may be
slow to produce its first frame — so the server knows a response is
coming. An undeliverable Ack skips the run entirely (the server is
unreachable, output would be undeliverable) and still attempts the
terminal Done.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Server-side-plugin groundwork across four crates:

sdk: server_request::Payload::Command {agent_arguments, plugin,
request} — ALL fields required, typed, no header bags; multi-frame
reply server_response::Payload::Command wrapping CommandFrame
(Ack (Item|Error)* Done, items are typed cli ResponseItems, Error
non-terminal, Done always last — the wire's ONE multi-frame-per-id
exchange); AgentArguments::from_transient_headers (plugin trio never
read from headers); Notifier::send_server_response (unsolicited
server_response frames — the pump's outbound seam); RejectHandler
answers Command with a first-frame Error.

proxy: X-MCP-Plugins typed marker ({url: mcp.server.Plugin}) parsed
like X-MCP-Laboratories and AUTHORITATIVE — the ws:/// plugin URL
parse is deleted (unmarked non-objectiveai ws:// now fails initialize
with a typed error); ReverseChannel::command() mints its OWN uuid
(never a plugin-supplied id), parks an mpsc across N frames
(first-frame leniency for the Ack race), deliver_response routes
Command frames to it (Done/dead-consumer evicts);
ReverseChannelCommandExecutor implements McpClientCommandExecutor by
forwarding to the daemon and streaming items back — wired ONLY for
plugin-marked http(s) upstreams via the new Upstream::HttpPlugin
variant (dormant until server-side plugins exist; ws plugins
unchanged); Session::transient_headers is now Arc-shared with the
executors so identity refreshes reach in-flight connections.

api: emits X-MCP-Plugins alongside the synthetic plugin URLs (raw
coordinates, URL-keyed); recv_loop routes Command frames proxy-bound
(multi-frame pass-through).

daemon: conduit answers Command with Ack from dispatch and spawns the
pump: apply_agent_arguments + with_plugin (the authenticated-conduit
exception to never-trust-wire-plugin-identity — the API asserts the
trio from its typed marker; plugin run-gates apply), crate::run
--request re-entry, one Item frame per ResponseItem AS IT ARRIVES
(transform values ride as ResponseItem::Python), non-terminal Error
frames, Done always; WS send failure drops the run stream (cancels).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
client://objectiveai, client:///owner/name/version/mcp,
client://laboratory/{id} — ws:// reads as a remote websocket URL,
which these never were: they name CLIENT-side upstreams reached over
the reverse channel. The two functional parse points
(parse_ws_mcp_kind strip_prefix + connect_upstream starts_with) and
every construction site in the API move together; the actual API WS
dial (http->ws swap in sdk http/client.rs) is untouched. Wire-compat:
API constructs and proxy parses in the same release, and the daemon
only ever sees typed McpKinds, never these URLs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n = one MCP server)

- sdk: delete cli/command/plugins entirely (get/install/list/logs/run —
  commands, wire requests, dispatch, schema registrations); delete the
  plugin stdout protocol Mcp/McpType (no stdout protocol going forward)
- sdk: new agent/plugin.rs — agent.Plugin {owner, name, version,
  arguments} with validate/prepare canonicalization; agents gain raw
  objectiveai_mcp: Option<bool> + plugins: Vec<Plugin> at all levels
  (client_objectiveai_mcp kept as reference, deletion pending)
- daemon: command/plugins handlers deleted (run included); drop
  plugin_messages table + db/logs/plugin_messages.rs
- objectiveai-mcp: single root-gated ObjectiveAI tool; dynamic plugin
  routes, plugins_list validation, per-session tool/plugin state removed
- tests: delete plugin e2e suites driving the removed surface

KNOWN BROKEN (intentional, next iteration rewires them):
objectiveai-daemon (http/conduit.rs dial_plugin_upstream,
command/daemon/spawn.rs, filesystem/plugins/{manifest,client}.rs) and
objectiveai-viewer src-tauri plugins.rs — dangling references to the
removed sdk surface. All other crates cargo-check green incl. tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Laboratory hosts can now execute objectiveai commands over their own
websocket connection, identically to the API<->daemon reverse channel:

- sdk laboratories/daemon: HostCommandRequest {id, agent_arguments,
  plugin, request} (host-minted id space) answered by multi-frame
  HostCommandResponse {id, flattened CommandFrame} — grammar
  Ack (Item|Error)* Done. Local vocabulary twins, per the channels'
  never-import-each-other rule; only the genuine data types
  (cli::command::*, mcp::server::Plugin) are shared. Demux-safety +
  wire-shape tests included.
- daemon websocket_laboratory: writer queue generalized to
  OutboundFrame {Request, Command}; inbound demux tries
  ChannelResponse, then HostCommandRequest, then HostNotification;
  dispatch_host_command mirrors the conduit's dispatch_command
  (Ack on pickup — undeliverable Ack skips the run,
  apply_agent_arguments + with_plugin stamping, crate::run --request
  re-entry, items streamed as they arrive, Done always terminal).

The laboratory host does NOT send these yet — daemon-side handling
only. Daemon still has exactly the 15 sanctioned plugin-removal
errors (conduit dial, resident spawn, filesystem/plugins); this
change adds none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dormant)

One plugin = one MCP server = one container image, built from the
plugin's GitHub repo on the laboratory host.

SDK:
- laboratories: PLUGIN_LABORATORY_ID_PREFIX (oai-plugin-)
- agent::plugin::prepare lowercases owner/name (version keeps its
  case — git tags are case-sensitive)
- wire: RequestPayload::PluginCreate {owner,name,version} ->
  ResponsePayload::PluginCreate(JsonRpcResult<Identify>); additive
  Identify.plugin: Option<IdentifyPlugin>
- cli laboratories list ResponseItem gains plugin: Option<Plugin>
  (local trio twin; laboratory-daemon module is feature-gated),
  registered in json_schemas(); daemon list fills it

Host (objectiveai-laboratory):
- git2 (vendored) shallow single-tag fetch into bin/temp/<uuid>
  (gitrepo.rs): refs/tags/{v-version} only, missing tag = hard error,
  Windows-safe readonly-clearing delete, boot sweep of bin/temp
- plugin_manifest.rs: objectiveai.json {containerfile, port} at the
  checkout root, traversal-safe validation
- plugin_image.rs: canonical coords (owner/name lowercased, version
  v-prefixed case-preserved), image
  localhost/objectiveai-plugin:{owner}-{name}-{version}; ensure() =
  exists -> label fast-path (port/sha stamped at build), else bin
  lock -> re-check -> fetch+build+tag -> delete checkout -> release
- podman: image_exists/pull/tag/build/label helpers; create_plugin
  (image entrypoint verbatim, manifest port published, no injection,
  no env); host_port takes the internal port; Label/LaboratoryInfo
  carry the plugin record
- host.rs: PluginCreate handler (idempotent ensure, created-not-
  started, LaboratoryCreated broadcast), oai-plugin- create squat
  rejection, plugin-aware lab_server (manifest port, no filetree
  pump, PluginSeed)
- host_command.rs: CommandBridge (outbound moved off HostServer +
  command_streams) speaking HostCommandRequest/Response;
  HostCommandExecutor per session (transient header bag full-replaced
  every op, read at execute time); channel.rs demux routes
  HostCommandResponse; detach fails in-flight exchanges
- agent-lab image caching: stable localhost/objectiveai-agent:{id}
  tag; exists -> reuse, else bin lock -> re-check -> inline build or
  pull+tag (external images tagged too) -> release; user labs
  byte-identical

Nothing sends PluginCreate yet — the daemon phase wires it (and must
classify it timeout-free; a cold build can exceed the 600s forward
timeout). Daemon still has exactly the 15 sanctioned plugin-removal
errors; sdk(+tests)/laboratory/proxy cargo-check green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pend anywhere

- agent::plugin::validate requires version to start with lowercase 'v'
  (the version IS the repo's git tag, byte-for-byte; V1.2.3 rejected)
- PluginCoords::canonicalize no longer prepends or checks — version
  passes through verbatim
- docs updated across the wire types; tests updated + v-prefix cases

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…next)

Agent and plugin laboratories are now EPHEMERAL: one per
agent-completion response id, created and MCP-connected in ONE atomic
op, exactly one MCP connection ever, lifetime == that connection's,
podman rm -f with zero grace when it ends. Regular laboratories keep
the split create/connect model and 30s stop-grace unchanged.

SDK:
- RequestPayload::{AgentEphemeralCreate, PluginEphemeralCreate}
  (host-level; envelope headers seed the MCP connect; timeout-free-
  family note for the daemon phase) -> JsonRpcResult<EphemeralCreated
  {identify, reply: InitializeReply}> — pass only when BOTH container
  and connection exist; a connect failure removes the container and
  fails the op (the proxy's upstream connect then fails too).
  PluginCreate vocabulary deleted (never wired).
- Identify.response_id + cli laboratories list ResponseItem.response_id
  (Some for ephemerals); agent::laboratories::ephemeral_id helper.

Host (objectiveai-laboratory):
- ids embed the response id: {derived_id}-{response_id} (content hash
  keeps keying the cached agent image) and
  oai-plugin-{owner}-{name}-{version}-{response_id}.
- NEW ephemeral.rs EphemeralLab: single create-time connection,
  response-id-gated MCP ops, Initialize rejected, transfers while
  alive; NEW transfer.rs Transfers registry + pipe_export (shared by
  both lab kinds); NEW upstream.rs (raw_call/sanitize/lab_mcp_client
  shared plumbing); server.rs slimmed to regular labs.
- host.rs: ephemerals registry; atomic create flow (validate response
  id, ensure image under the per-id lifecycle lock, stale dupe
  evaporate+recreate, create+start+port+connect with the request's
  full headers, any post-create failure rm -f — never a half-made
  ephemeral; agent kind spawns the filetree pump); evaporate() funnel
  (SessionTerminate/Drop owner-checked, channel death, host shutdown,
  defensive Delete); Create now rejects agent provenance and both
  reserved prefixes flat.
- podman: create() regular-only (agent-image ensure extracted to
  ensure_agent_image; shared create_injected_container core);
  create_agent_ephemeral; create_plugin gains response_id; Label/
  LaboratoryInfo/Identify/list carry response_id; list_running
  deleted — cleaner sweep now partitions: ephemeral leftovers REMOVED
  (any state), regular running containers stopped.

Daemon: two sanctioned compile-fix touches only (from_host_payload
group arm, list fill) — still exactly the 15 known errors.
sdk(+tests)/laboratory/proxy cargo-check green. Daemon-phase wiring
(conduit collapse of Create+Initialize into the ephemeral op) comes
next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ompiles fully

The final stage of plugins->containers. The daemon holds exactly ONE
kind of MCP connection (the in-process objectiveai-mcp); plugin and
agent-embedded laboratory upstreams are EPHEMERAL containers on
laboratory hosts, load-balanced by pure RNG.

Daemon (0 errors, from the 15 sanctioned):
- conduit: dial_plugin_upstream + plugin_drain + the two-level
  (response_id -> McpKind) connection cache REPLACED. connections is
  now response_id -> the one objectiveai connection. NEW HostRoutes
  ((response_id, RouteKey{Client|Agent|Plugin}) -> {lab_id, host pair})
  serves routing AND the death sweep, replacing the laboratory_sessions
  ledger. Ephemeral Initialize = ONE atomic forward_to_host
  ({Agent,Plugin}EphemeralCreate) with the FULL header set on a
  UNIFORMLY RANDOM connected host (ensure_local_host only at zero
  hosts); reply maps EphemeralCreated straight onto InitializeReply;
  route recorded from the host-authoritative identify.id. Later ops /
  SessionTerminate resolve+forward via the route (miss -> -32001);
  Drop + the reverse-channel death sweep fan host Drops concurrently.
  Agent-seed Create block + derived-id reuse deleted.
- registry: random_host() (rand 0.9 uniform pick); ephemeral creates
  join the timeout-free forward family (image builds > 600s).
- deleted: filesystem/plugins + filesystem/install (whole trees),
  the resident daemon-plugin launcher in daemon/spawn.rs (daemon now
  stays resident via pending()), WIPE_KEEP plugins entry, newly-dead
  spawn::resolve_program, stale PLUGINS.md docs + assets. KEPT:
  db/compartment.rs (plugin DB over websocket returns later),
  channels plugin columns, ScopedContext::with_plugin.

SDK:
- McpKind::Plugin + mcp::server::Plugin drop the vestigial mcp
  coordinate (one plugin IS one MCP server; trio everywhere).
- InitializeRequest drops args: plugin arguments are HEADERS-ONLY
  (X-OBJECTIVEAI-ARGUMENTS flows API -> proxy -> daemon -> host ->
  plugin container on every call).
- LEGACY agent surface DELETED: agent/client_objectiveai_mcp.rs, the
  client_objectiveai_mcp field + glue on all 5 agent types, the
  accessors, the 5 schema registrations.

Proxy: trio-only kind; the X-OBJECTIVEAI-ARGUMENTS lift deleted
(header passes through untouched). Parallelism verified already in
place (try_join_all upstream fan-out).

API: plugin upstreams now from agent.plugins (one URL per plugin,
client:///{owner}/{name}/{version}); needs_objectiveai =
objectiveai_mcp flag; X-OBJECTIVEAI-MCP-* stamping block deleted
(absent ROOT defaults true mcp-side); error message updated.

Laboratory host: mcp: String::new() vestiges dropped.

Tests: agents_duplicate_{server,tool}_names_e2e deleted (legacy
concept); laboratory_transfer_e2e legacy JSON stripped; api-tests
literals updated for the current agent shape.

Workspace --all-targets: every crate green EXCEPT objectiveai-viewer
(5 errors, deliberately untouched). Deferred to test authorization:
API snapshot regen (agent serialization changed), json-schema-10x-
tools mock assets + laboratory_transfer_e2e runtime rework, SDK regen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- AgentArguments::identity_env(): the nine (env name, value) pairs —
  six agent-identity values + the plugin trio — Some-valued only
  (wire-parsed bags always null the trio; only an in-process
  authority sets it).
- Laboratory host stamps them at ephemeral create: agent containers
  get the six from the create request's headers (appended after user
  env so they win); plugin containers get the six PLUS
  OBJECTIVEAI_PLUGIN_{OWNER,REPOSITORY,VERSION} from the canonical
  coordinates. Regular laboratories unchanged (multi-identity;
  identity rides per-request headers).
- agent::RESERVED_LABORATORY_ENV (all nine names) +
  laboratories::validate rejects them from agent-laboratory env
  declarations — the host's stamping is authoritative, a user value
  could only be overridden or a spoof.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…IN_NAME

The plugin coordinate is (owner, name, version) — the env name now
matches. All 8 sites: reserved-env const, identity_env,
apply_to_command, daemon child-env stamping + scrub lists, cli scrub
list. (The AgentArguments.plugin_repository FIELD keeps its serde name
— wire compat; only the env-var name changes.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WiggidyW and others added 29 commits July 27, 2026 17:36
`serve.rs` is down to `pub async fn serve` and nothing else. Config
moves to its own module; Handler is private, since a plugin has no
reason to hold one; serve_handler is gone.

`bind` and `stateful` are gone as options because neither had a second
correct value. The bind is 0.0.0.0 — podman publishes the CONTAINER's
port, so loopback inside it is unreachable — and the session mode is
stateful because the ObjectiveAI client carries an mcp-session-id.
Offering a knob for a constant is just a way to set it wrong.

The error type went with them: both failures are a listener that would
not bind or would not run, which are `io::Error`s already. That leaves
nothing of ours for a caller to learn, and is what let the public
surface reach one item.

What a plugin actually decides is now all in `config.rs`: the manifest
port, and the SSE keep-alive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A plugin can now change what it exposes at runtime without
implementing ServerHandler. rmcp has no route to that — every
ToolRouter mutator is &mut self, every ServerHandler method is &self,
and the transport builds a fresh handler per session from an `Fn`
factory — so the framework owns the handler and reads routes from
something shared.

`Tools` is that something: a route list and one callback, immutable
but for `replace`, which swaps the whole list and tells whoever is
serving. `serve` installs the callback to rebuild its live router and
send tools/list_changed. The plugin keeps a clone of the Arc and calls
`replace`; nothing else.

No lock anywhere. The list is an ArcSwap — reads are per-request and
wait-free, swaps are rare and a single atomic store — and the notifier
is a OnceLock, which is exactly "set once by serve, read from many
threads".

Two orderings are load-bearing and commented as such. `replace` stores
BEFORE firing the callback, because the callback exists to make the
world agree with the stored value. `serve` rebuilds and publishes the
router BEFORE notifying, because a client that re-lists the instant it
is told must not race the swap.

The notifier RECEIVES the new list rather than reading it back off
`Tools`. A closure holding Arc<Tools> would close a cycle through the
notifier field and leak the structure; taking the list as an argument
makes that unrepresentable. A callback that must look back takes a
Weak, which is what the test demonstrates.

Serving one `Tools` twice is refused rather than allowed to silently
leave one server stale.

Replaces both earlier attempts: the Arc<RwLock<ToolRouter>> wrapper
(nine of eleven methods were passthrough renames of rmcp's API) and
the Tools trait (rmcp has no router trait to mirror).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ilure

A plugin has one database for its whole life, so a second pool would
only spend connections duplicating the first. `connect` now memoizes:

- success is TERMINAL — later calls return the same pool without
  touching a lock at all;
- overlapping callers share ONE attempt and all receive its outcome,
  success or failure alike, so only one connection is ever in flight;
- failure is NOT cached — once a failed attempt finishes the slot is
  cleared and the next call starts fresh, so a plugin that raced the
  tunnel coming up can recover.

`tokio::sync::OnceCell::get_or_try_init` looks like the answer and is
not: on failure it lets each waiter retry in turn rather than handing
them all the same error. This is a shared future in a slot that resets.

Errors are `Arc<Error>` because `Shared` clones its output to every
waiter and `sqlx::Error` is not `Clone`.

A generation counter identifies each attempt, so a slow failure clears
the slot only if the slot still holds ITSELF — otherwise it could
clear a newer attempt that had already replaced it.

The memoization is split from what it memoizes (`coalesce` takes the
attempt as a closure). That is not decoration: with no database the
real attempt fails WITHOUT ever yielding, so callers can never overlap
it, and a coalescing test driving the real thing would pass while
proving nothing. The test drives `coalesce` with an attempt that
actually sleeps.

Two test-only notes learned the hard way: the memoization is
process-global, so tests that count attempts serialize on a mutex or
they count each other; and a single `yield_now` is not enough of a
window — it guarantees one further poll, which let some callers arrive
after the attempt had already resolved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a window between an attempt RESOLVING and a waiter clearing
the slot in which the slot still held a finished attempt. A caller
arriving then found it occupied, joined it, and received its cached
error — the stale failure that is supposed to be unreachable, since
anything arriving after a failure is meant to start fresh. If every
waiter was dropped inside that window nobody ever cleared, so the next
caller was GUARANTEED the dead attempt before it self-healed.

The cause was structural: the decision to join was made against a slot
whose occupant might already be finished, and "finished" was
discovered by a separate step that raced it. So make it one decision
under one lock — `Shared::peek` reads completion atomically, and the
slot is now retired in the same critical section that hands out joins.
"Arrived before it finished" and "arrived after" are separated by that
section rather than by a clear that races it.

A finished SUCCESS is retired there too, and handed straight back: a
caller landing between resolution and `POOL.set` gets the pool that
just connected rather than opening a second one.

Success now releases the slot as well. It never did, so a completed
future and a duplicate pool handle were retained for the life of the
process beside `POOL`, which is the real answer from then on.

The natural window is too narrow to hit on purpose, so the new test
builds the state it produces: a slot holding an already-resolved
failure, with a fresh attempt whose error differs, asserting the fresh
one answers. The equivalent success path needs a real pool and is
reasoned rather than tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Result<Pool, Arc<Error>>` says what it is; a name in front of it only
meant a reader had to go look the name up. Written out at all six
sites instead.

The one thing the alias documented — why the error is behind an Arc —
moves onto `Error`, where someone wondering about it would actually
look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`IndexMap<String, serde_json::Value>` says what it is, and the alias
only meant a reader had to look the name up to find out whether the
values were strings.

Its doc comment moves onto `arguments()`, which is where a reader
meets the type: that a `null` is a key PRESENT without a value, and
that the `IndexMap` is there because the order is canonical — the API
key-sorts these so equivalent declarations serialize byte-identically.

The module now exports exactly two things, both functions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A plugin has no CLI binary and no daemon it may dial. What it has is
the MCP connection the host opened, and an extension that runs
backwards along it: declare the `objectiveai` capability, push a
`notifications/objectiveai/cli_request` frame naming the command, and
the HOST runs it and POSTs each result frame back to
`{mcp_url}/objectiveai/command`.

`command_executor()` wraps that exchange in the SDK's own
`CommandExecutor` trait, so a plugin issues commands the same way
everything else in ObjectiveAI does. `serve` mounts the response
endpoint beside the MCP root, declares the capability, and publishes
the peer.

An executor may be taken BEFORE serve. Its state is entirely
process-global, so the value carries nothing that has to exist yet,
and `execute` WAITS for a client rather than failing — a plugin that
built its executor during startup should not be punished for it. The
peer rides a `watch`, not a slot plus a notify, so a waiter arriving
after the peer was published does not block on a signal that already
fired.

Maximum parallelism by construction: one correlation id and one
channel per run, routed through a DashMap the response endpoint
touches per frame. Two runs answering at once never serialize, and
nothing is shared between runs but the map. Frame order is guaranteed
per run, never across runs.

Registration happens BEFORE the notification is sent — the host may
answer the instant it lands, and a frame arriving before the slot
exists would be dropped as unknown. A drop guard deregisters however
the stream ends, so an abandoned run cannot leak its slot or leave a
live id delivering into a channel nobody reads.

`identity` is accepted and ignored, and cannot be otherwise: the host
stamps the plugin trio from the canonical image coordinates and
refuses any claim off the wire, so a plugin asserting its own identity
would be asserting nothing.

Needs the SDK's `cli-executor` feature — `CommandExecutor` is gated
behind it, not `cli`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`get_info` was returning `ServerInfo::default()`, whose
`Implementation::from_build_env()` resolves at the FRAMEWORK's compile
time — so every plugin built on this introduced itself to ObjectiveAI
as `objectiveai_mcp_plugin_framework` 2.2.13.

That is not cosmetic. The proxy derives each upstream's ROUTING PREFIX
from name and version and prepends `<prefix>_` to every tool name the
agent sees, escalating name → name-version → name-version-index on
collision. Two framework plugins in one agent matched at both of the
first two tiers and fell through to a positional index, so the agent
saw `objectiveai-mcp-plugin-framework-2-2-13-0_greet`. The name is
also the key the agent-SDK runners file the server under in their
`mcpServers` config.

So `Config` now carries the whole `initialize` reply, which ObjectiveAI
keeps verbatim and reports through `agents mcp servers list`: name and
version REQUIRED as constructor arguments, since defaulting them is
what caused this; title, description, website_url, icons and
instructions optional, with builder setters.

The reply is built once at startup rather than per session — nothing
in it varies, so recomputing would only be a chance to disagree with
itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the first half of #286: manifest, Containerfile, README, a
working `main.rs` with a typed tool and one that reports the ambient
identity, and `rename.sh`.

A workspace member so it is compiled on every build and cannot rot
against a framework change, but `publish = false` — plugins ship as
container images built from a git tag, never as crates. Not in the
releaser.

`rename.sh` does more than substitute a string: it also cuts the two
ties to the monorepo that would otherwise stop a copied-out plugin
building at all — the framework dependency's `path`, which resolves
nowhere else, and the missing `[workspace]` table, without which cargo
absorbs the plugin into whatever parent workspace it lands in. awk
rather than `sed -i`, which GNU and BSD disagree about; a scaffold
that only renames itself on Linux is half a scaffold.

`rmcp` is deliberately NOT a direct dependency. The framework
re-exports the exact one it was built against, so the two cannot skew
into a `ToolRouter<S>` that will not fit `serve`; only the
`server_handler` macro variant needs absolute `::rmcp::` paths, and
this does not use it.

The README is written for someone starting a plugin rather than
auditing one — the reference material lives in the framework's docs.
The gotchas kept are the ones that fail at RUNTIME: the name becoming
a routing prefix, and the port having to agree in three places.

Also drops an unused `mut` the scaffold's build surfaced in the
framework.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A scaffold is read before it is understood. `oai::serve::serve` asks
the reader to have already met the alias; the full path says which
crate every one of these comes from.

The `use` of `rmcp` stays — that one is not shorthand. The
`#[tool_router]`/`#[tool]` macros expand to a bare `rmcp::` path, so
the name has to be in scope, and it is re-exported by the framework
precisely so a plugin cannot end up with a second `rmcp` whose
`ToolRouter` will not fit `serve`.

Also drops the four-item copy checklist from the module docs.
`rename.sh` now does three of the four, and a list telling you to hand
edit what a script already handled is worse than no list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scaffold gets COPIED OUT, so its Cargo.toml has to be exactly what
a standalone plugin needs and nothing else. It carried two things that
only made sense inside this repo: a `path` on the framework dependency
pointing at a sibling directory that will not be there, and a
`rename.sh` that performed surgery to remove it. Both gone — the
dependency is now a plain `objectiveai-mcp-plugin-framework = "2.2.13"`
and the script only renames.

The monorepo's problem now stays in the monorepo. Building the scaffold
here would otherwise want the framework from crates.io at the version
this branch is developing, which by definition is not published; a
root `[patch.crates-io]` points that name at the local checkout, so the
scaffold still compiles on every workspace build and cannot rot against
a framework change. Nothing in the root travels with a copied folder.

Also drops the `[workspace]` table `rename.sh` used to insert. It only
mattered if the plugin were nested inside another cargo workspace,
which is not the assumption — the folder stands alone, and cargo's
error for the nested case is self-explanatory anyway.

version.sh had a matching gap: `set_cargo_objectiveai_deps` only
rewrites the inline-table form, so the bare string would have been
skipped and the scaffold left pinned at 2.2.13 forever. Widened the
bare-string bumper from `objectiveai-sdk` to any `objectiveai-*` crate
and ran it over Cargo.tomls as well as READMEs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`greet` and `whoami` are plausible names. A plugin that ships with
them looks like a plugin, and the first sign of trouble is an agent
calling `whoami` on something that was supposed to do real work.

They are now `scaffold_greet_deleteme` and `scaffold_whoami_deleteme`.
rmcp derives the wire name from the fn name — `rmcp-macros/src/tool.rs`
uses the ident whenever `#[tool]` has no explicit `name` — so the fn
rename is what an agent actually sees, and the description carries the
same warning for anything reading that instead. Both are loud in a
tool list, and neither is renamed by `rename.sh`: the point is that a
half-finished scaffold announces itself.

Instructions lose the reference to `greet` and now say what the field
is for, since a plugin that never edits it should not be telling an
agent to call a tool that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`scaffold_whoami_deleteme` was reading two environment values and
formatting a string. It now does the thing a plugin author actually
needs to see: it takes the agent instance hierarchy the host stamped
on the container, addresses that exact agent, and runs `agents
instances get` back through the host via `command_executor()`.

Uses the leaf's own `get::execute`, not the root delegate. That command
is genuinely streaming — `targets` is a `Vec` and a `tag=` target can
resolve to several agents — so it returns `Stream<ResponseItem>` and
the tool takes the first row. The SDK does draw the distinction where
it exists: `agents get` is generated against `execute_one`, this is
not.

Returns `Json<WhoAmI>` rather than a string, so the row lands in the
result's `structured_content` with a published schema and an agent
reads fields instead of parsing prose. `WhoAmI` embeds the SDK's
`ResponseItem` verbatim rather than copying its fields across, so it
cannot drift. Failure cases are `ErrorData`, including "no agent
instance in the environment" — outside a laboratory container the
question has no meaning, which is not the same as an agent having
nothing to report.

The identity argument to `execute` is `None` on purpose and commented
as such: the host stamps the trio from the image coordinates and
refuses any claim off the wire, so a plugin passing its own asserts
nothing.

Two supporting changes:

The framework re-exports `objectiveai_sdk` alongside `rmcp` and
`sqlx`, for the reason those two are already re-exported and which
binds hardest here — every `cli::command::*::execute` is generic over
the `CommandExecutor` TRAIT, so a separately-resolved SDK would make
`command_executor()`'s return type implement a different trait of the
same name and the call would not compile.

The scaffold leaves the workspace, and the `[patch.crates-io]` that
was keeping it compiling is gone. The patch bought build coverage by
making the in-repo copy resolve differently from the copy a user takes
away, which defeats the point of the folder being naked. It is
excluded rather than left to break the workspace, since an
unresolvable member fails resolution for every other crate too. Cost,
stated plainly: nothing now compiles the scaffold on a normal build,
so it can rot against a framework change until the framework
publishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the two example tools that show a tool set is not fixed — neither
across copies of a plugin, nor across the life of one process.

`scaffold_switch_deleteme` is served only when the agent declared a
`switch` argument. `scaffold_switched_deleteme` starts unserved and
appears when the first is called, via `Tools::replace`, which swaps the
set and sends `notifications/tools/list_changed`.

The two gates are deliberately different in kind, and the code says so:
the argument gate cannot move, because the host stamps arguments at
container create and nothing rewrites them, so a plugin the agent did
not ask to have a switch can never serve one. The switch gate is the
runtime one.

Both tools are declared with `#[tool]` like any other. Conditional is
not a property of a tool but of the route list, so `served_routes`
filters a full `Plugin::tool_router()` by name rather than assembling
routes by hand — the macros stay the single declaration of what a tool
is, and this only decides what is currently served.

The served list is also the only state. `scaffold_switch_deleteme` asks
`tools.routes()` whether the second tool is present rather than keeping
a bool, because a separate flag is a second source of truth that can
disagree with what is actually routed.

Gating on PRESENCE of the argument, not truthiness: a bare `--switch`
arrives as a `null` value, and a key present without a value is still
the agent asking for it.

`Plugin` now carries the `Arc<Tools<Plugin>>` so a call can swap the
set from inside itself. Not a cycle — `Tools` holds route handlers,
never a `Plugin`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Presence was the wrong test. The gate now reads JSON `true` and
nothing else: absent, `null`, `0`, and the string `"true"` are all off.

`Value::as_bool` is `Some` only for a JSON boolean, so every other
shape falls through to `unwrap_or(false)` without a match arm per case
— and it does not need `serde_json` named in the scaffold, which is
not a dependency and should not become one.

Deliberately not lenient. Argument values are free-form JSON that a
human typed into an agent definition, and a plugin that guesses what
someone meant by `"true"` is a plugin that will one day guess a
feature ON that should have stayed off. Failing closed is the only
reading that is safe in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds `scaffold_note_write_deleteme` and `scaffold_note_read_deleteme`,
a round trip through `db::connect` — upsert on one side, lookup on the
other, both returning the stored row as structured JSON.

The database is the DAEMON's, tunnelled in, not a private one. So the
example owns a distinctly named table rather than writing into
someone else's, and scopes every row by
`identity().agent_instance_hierarchy` — the next container over is a
different agent reading the same table, and unscoped rows would leak
one agent's notes to another.

`sqlx::query`, deliberately not the `sqlx::query!` macro: the macro
validates SQL against a live database at COMPILE time, and the
database a plugin talks to does not exist until a host creates its
container. A scaffold that could not build without a running Postgres
would be a scaffold nobody can start from.

`written_at` comes back through `written_at::text`. Decoding a
`TIMESTAMPTZ` as a real time type needs sqlx's `chrono` or `time`
feature and the framework enables neither, so the cast is what makes
this work with the sqlx a plugin actually has.

Parameters are bound, never formatted in — a note whose value is
`'; DROP TABLE ...` is just an odd note.

The read uses `fetch_optional`. No note under a key is an ordinary
answer, and `fetch_one` would report it as `RowNotFound` dressed up as
a database failure; it returns invalid_params instead.

Errors walk the `source` chain, because sqlx's top-level Display is
often just "error returned from database server" and an agent gets one
string to work from.

Table created on first use behind a `OnceCell` — idempotent, but the
round trip is not free, and a plugin container is ephemeral with
nowhere to run a migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds `scaffold_credential_call_deleteme`: call a URL with a credential,
obtaining one through a viewer channel when the database has none for
this agent. The stored credential is what makes it conditional — the
first call may wait on a human, no later one does.

The channel exchange is not obvious from the command list, so the code
says what it learned:

`channels publish` BLOCKS until a viewer accepts and is uncapped
without a timeout, so a plugin that omits one waits for a person
forever. Accept and reply get separate caps because they are different
waits — "is anyone there" versus "has the person finished typing".

`channels logs subscribe` wakes ONCE per call: immediately if entries
are already unread, otherwise when one arrives. So this loops with a
deadline rather than holding a single long stream, and the remaining
time is recomputed per round so the total stays bounded.

The channel's FIRST entry is the offer itself, seeded at accept, so
the loop skips forward to the first `reply` rather than reading its
own publish back as an answer. Entries are envelopes, which is why
finding the reply and reading it are two round trips — `channels logs
open` reveals the content.

The channel is closed on both paths. A close failure does not
overwrite the real outcome, and a channel left open is a user surface
left waiting on a plugin that stopped caring.

Failures return as a tool RESULT with `is_error` and a `step`, never
as a protocol error. A protocol error means the call was malformed;
every failure here is the call working correctly and the world not
cooperating — nobody accepted, the person cancelled, the endpoint
refused — which an agent can reason about and retry. The HTTP status
is reported rather than enforced for the same reason: a 401 is a real
answer, and erroring on it would hide the one result that says the
credential is stale.

The credential is written only once it is in hand, so a half-finished
exchange leaves nothing behind for the next call to trust.

reqwest pinned to the version and rustls backend the rest of
ObjectiveAI uses, so a plugin image needs no system OpenSSL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The credential wait kept its own deadline and re-subscribed in a loop.
Both halves were solving problems that do not exist.

The daemon already caps every command: `timeout_seconds` becomes a
whole-stream deadline, anchored at first poll, that yields a `Timeout`
error item and ends the stream (`executor.rs`, `TimeoutStream`). The
`Err` arm already reported it, so the `Instant` arithmetic was a second
clock racing a better one.

And the loop existed to skip past the offer's seed entry. It cannot
arrive: `role_read_params` scopes a PUBLISHER's reads to `reply`
entries alone, while the seed is `publish` and belongs to the owner
side. A publisher never reads its own offer back, so one subscribe is
enough and there is no cursor to carry.

The non-reply arm stays, skipping rather than failing, so that widening
a role's directions could not turn this into a hard error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The host half of development mode. `plugin_image::ensure` takes an
optional directory that stands in for the git checkout: with one, it
fetches nothing and — the part that matters — DELETES nothing, since
the tree belongs to the developer and that was the only reason
`remove_checkout` could be unconditional. `fetch_at_tag` and
`remove_checkout` both move inside a production arm; the two modes now
share one `build` back half.

Caches are declared in the manifest as CONTAINER paths, under a new
`mcp.development`. Container paths rather than anything language-shaped
is the whole point: the same field serves `/build/target` and the cargo
registry, `node_modules`, `GOCACHE`, the pip cache. Only the plugin
author knows their own image's layout. Validated lexically in the SDK
so the CLI's manifest lint covers it, and inert outside development
mode — a released plugin's build never binds a host directory.

They become `podman build -v` mounts, which are RUN-scoped and never
committed. Verified both halves against podman 5.8.4 before writing
this: a Windows host path mounts and the files land on the host owned
by the host user, and in the finished image the mount point does not
exist at all while a file copied out within the same RUN survives. That
is the constraint on a plugin author, and it is stated where the field
is declared.

`objectiveai.plugin.development` labels the image with its source
directory, and the fast path compares it. This is load-bearing, not
bookkeeping: one image tag serves both sources, so without it,
registering a directory, building, then unregistering would leave
locally-built bits under the tag that the production path fast-paths
into forever — shipping uncommitted code under a git-tag identity. A
mismatch reads as absent and rebuilds, so it self-heals with no
cooperation from the daemon and no reset that might never arrive.

No `sha` label in development mode. Both `EnsuredPluginImage.sha` and
`PluginLabel.sha` are already `Option`, so absence flows end to end,
where a fabricated "development" would eventually be read or displayed
AS a commit.

`PluginImageReset` drops the tag so the next create rebuilds, under the
same lock the build takes. It uses `podman rmi --ignore`, NOT `-f`:
forcing also removes every container using the image, which for a
plugin tag means killing live ephemeral laboratories mid-completion
behind this host's own registry. A tag in use should fail loudly.
Removing nothing is a success — for a never-built plugin, "the next run
rebuilds" is already true. Caches survive by default, since reset is
the per-edit verb and the cache is the only reason that rebuild is
fast.

Cache directories are `<bin>/plugin-cache/{image_tag}/{slug}`.
`image_tag()` as ONE segment on purpose: `canonicalize` permits `.`
freely, so an `owner/name/version` layout would turn `version = ".."`
into a live traversal out of `<bin>`. Not under `<bin>/temp`, which
`cleaner::sweep` empties at every host boot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`development plugins mcp {create,list,delete,reset}` — the surface for
pointing a plugin at a local directory. A new top-level tier because
these commands change how this machine BUILDS things rather than how
anything runs, and none of it is durable: the registrations live in the
resident daemon's memory, so a stale override cannot outlive the work
it was for.

`list` is streaming and the other three are unary. That is not
cosmetic: the JS codegen parses these bodies with a bounded grammar
that hard-errors when the return shape and the executor call disagree,
so `-> Result<E::Stream<T>>` must pair with `executor.execute` and
`-> Result<T>` with `execute_one`.

Twelve arms added to the listener dispatch, three per leaf. Nothing
tests that file — a leaf missing from it is silently skipped at
runtime — so they are generated from one template rather than
hand-copied, as are the eight request_schema/response_schema
submodules, which are byte-identical to `channels/close`'s but for the
rename and the path.

`delete` deliberately does NOT remove the built image, and says so.
The image carries its source directory as a label, so the next create
sees the mismatch and rebuilds from git by itself; `reset` is only for
reclaiming the disk. Removing nothing is a success on both `delete`
and `reset` — in each case the state the caller asked for already
holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The daemon half. `create`/`list`/`delete` touch an in-process registry
and nothing else — no table, no file. A registration is session state,
and one that survived a restart would be a stale override silently
outliving the work it was for; losing them is the feature.

The registry key canonicalizes owner and name to lowercase and leaves
the version verbatim, because that is exactly what `agent::plugin::
prepare` does to a declaration. Get that wrong and a registration is
simply never found — the failure would be silence, not an error.

`run_ephemeral_create` now looks the trio up before picking a host. A
registered plugin builds from a directory only this machine can see, so
it pins to the local host instead of `random_host`, and says so when
none is running rather than reporting a generic "no host connected".
Unregistered plugins take the unchanged path.

`GlobalContext::local_host` memoizes this daemon's own (machine id,
state). Not just to save a Windows registry read: `machine_id` falls
back to a FRESH UUID per call when neither the OS id nor the persisted
file is readable, so recomputing it could have two calls disagree about
which host is local.

`reset` is the one command that leaves the daemon, forwarding
`PluginImageReset` to that same local host.

The four handlers live in `development/` beside the registry they all
touch, with `plugins/mcp/` holding only dispatch — the tier nesting
exists to mirror the CLI path, not to spread three files across three
directories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo run` was the first thing the README told you to do, and it is
the least informative thing you can do with a plugin: nothing dials it,
and identity, the database and the command executor are all empty
outside a container. Getting Started now spawns a host and registers
the directory, so the first run is the real thing.

The manifest declares `/build/target` and the cargo registry and git
directories. These match the Containerfile as written — `WORKDIR
/build`, and `rust:1-slim` sets `CARGO_HOME=/usr/local/cargo`. It also
already satisfies the constraint build mounts impose, copying the
binary out to `/plugin` in the SAME `RUN` that produces it, so nothing
about the build had to change to gain the cache.

The cargo subdirectories are named individually and deliberately:
caching `/usr/local/cargo` would shadow `/usr/local/cargo/bin/cargo`
behind an empty mount and the first build would die with "cargo: not
found".

Shipping-it now says to drop the registration on release, and why not
doing so is survivable: the image records the directory it was built
from, so it rebuilds from the tag on its own rather than serving
uncommitted work under a released version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A SKILL.md in the scaffold rather than the monorepo, because the
audience never opens the monorepo — plugin authors copy this directory
out and work in it. Committed here, it is present in their repo from
their first commit with nothing to install.

Placed in `.agents/skills/`, the vendor-neutral location, rather than
`.claude/skills/`. Worth knowing what that buys and costs: OpenCode
reads `.agents/skills/*/SKILL.md`, walking up to the git worktree root,
but Claude Code, Codex and Gemini CLI each still scan only their own
`.claude/`, `.codex/` and `.gemini/` directories. The file is portable
byte-for-byte under the Agent Skills standard, so adding those is
copying, not adapting — say the word and they go in.

The body leads with the failure that will actually cost time: an edit
does nothing until `reset`, because a registered plugin still takes the
image-exists fast path, so a call after an edit runs the old image and
looks like the change did not work. Then the rest of the runtime-only
failures — registrations dying with the daemon, `v0.1.0` not matching
`0.1.0` (silent, and the most likely reason a registration "does not
work"), the absolute-path rule, and the two cache rules whose symptoms
name nothing like their cause.

No plugin name appears in it, so `rename.sh` has nothing to rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The json-schema builder refused the regen:

  schema title changed during normalization:
  "cli.command.development.plugins.Request" became "Mcp"

`development::Request` and `development::plugins::Request` each have
exactly ONE variant. schemars collapses a single-variant enum to that
variant's schema and hoists the variant title over the type's `rename`,
so the emitted title was the variant name rather than the dotted path
the builder keys files by. The 12-variant `mcp::Request` is unaffected.

Same trap as `agent.script.Script` and `ClientLaboratoryType`, both of
which carry a comment saying so; these two now do as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
32 new schemas for `development plugins mcp {create,list,delete,reset}`
plus `cli.plugins.Development`, and the `cli.plugins.Mcp` /
`cli.command.Request` / `agent.Plugin` files that reference them.

Generated by `bash build.sh --release --no-zip`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New `src/cli/command/development/` tree and `src/cli/plugins/development.ts`,
with the command generatedIndex, request/listenerExecution unions and the
dist bundle rebuilt. Release profile, so the wasm artifacts stay well
under GitHub's file limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
44 new files across the four leaves' request/response/path/execute
shapes, plus `cli_plugins_development.go` and the refreshed cffi wasm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New `cli/command/development/` package and `cli/plugins/development.py`,
with the generated roundtrip tests that come with them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The javascript job pinned node exactly and floated npm:

    node-version: '22.14.0'
    run: npm install -g npm@latest

npm 12 raised its floor to `^22.22.2 || ^24.15.0 || >=26.0.0`, so
`npm@latest` stopped being installable on the pinned node. EBADENGINE,
exit 1, and the publish step never ran.

This already cost TWO releases. 2.2.11 and 2.2.12 both failed here with
byte-identical errors — same npm@12.0.1, same node v22.14.0 — while
every other job in both runs succeeded. crates.io and PyPI are on
2.2.12; npm is still on 2.2.10. It would have taken 2.2.13 as well:
`npm view npm@latest` still returns 12.0.1 with the same engines today,
so this was deterministic, not flaky.

node moves to 24 (always >= 24.15, so it satisfies npm 12) and npm is
pinned to 12.0.1. They are now a PAIR and the comment says to bump them
in one commit or not at all.

The general defect is the floating `@latest` in a release pipeline: it
lets the pipeline's behaviour change with no commit and no review, and
the only signal is a red run after the version is already tagged.

Note this fixes releases GOING FORWARD only. 2.2.11 and 2.2.12 are
still absent from npm and cannot be backfilled by this workflow, which
publishes whatever version is in the checked-out package.json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@WiggidyW
WiggidyW merged commit e5fbe23 into main Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment