Conversation
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 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>
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
toolsfeature 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 ephemeralper-completion container, with the daemon routing to a host. The CLI
toolsfeature is gone — plugins only.The daemon IS the MCP server. The standalone
objectiveai-mcpcrate is deleted;
/mcpaccepts its signature via the standardAuthorizationheader.A framework and a scaffold for writing plugins in Rust.
objectiveai-mcp-plugin-frameworkgives a plugin its identity,arguments, database, CLI command executor, and a tool set it can swap
while serving.
objectiveai-mcp-plugin-scaffold-rsis the copyablestarting point, with a
plugin-developmentskill 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 -vmounts. Registered pluginspin 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 floorabove the pinned node. Both 2.2.11 and 2.2.12 failed there with
byte-identical
EBADENGINEerrors while every other job succeeded, sonpm 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 — thetwo 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.