Headless permission policy and output formats.
⛰️ Features
- (acp) Headless permission policy and output formats (#866)
- (acp) Read harness authentication from the protocol (#860)
- (acp) Harden v1 controller routing (#853)
- (acp) Add controller identity and session routing (#852)
- (acp) Add multi-session controller (#849)
- (acp) Rebuild the ACP agent surface and add the chat TUI (#816)
- (actions) [breaking] One table for status, list_models, route (#869)
- (chat) Resolve slash commands against the actions table (#880)
- (cli) Operate trajectory history
- (eval) Publish trajectory operations
- (eval) Add generic route evaluation workflow (#763)
- (eval) Make policy locks the routing source (#762)
- (launch) Route every catalog harness (#781)
- (mcp) [breaking] Drop the complete tool from the origin server (#875)
- (mcp) Upgrade to stable rmcp 3.1 for MCP 2026-07-28 (#760)
- (observability) [breaking] Live router view and hosted launch (#803)
- (optimize) Add agentic workflow loop (#777)
- (policy) Route models by reasoning effort (#804)
- (policy) Route agent workflows by predicted role (#790)
- (policy) Add trajectory progress guard
- (policy) Harden cost-aware workflow routing (#768)
- (policy) Generalize agent trace routing (#757)
- (registry) Add agents and runtimes as a primitive pair (#879)
- (router) Add shadow classifier bakeoff (#865)
- (router) Freeze routing measurement (#862)
- (skills) [breaking] Unify discovery and conform to SEP-2640 (#870)
- (skills) [breaking] Serve over MCP (SEP-2640), cut installer (#770)
- (substrate) Detach/reattach session robustness (#743)
- (trajectory) Reduce replayable health
- (trajectory) Add durable event ledger
- Optimize routes from observed history (#833)
- Publish bitrouter/auto as the router slug (#788)
- Guide @auto workflow optimization (#779)
- Add MCP registry discovery (mcp search/list/add) (#759)
- Add cache-aware routing, Terminus 2 identity, and Claude bridge (#717)
🐛 Bug Fixes
- (auth) Preserve fallback semantics
- (auth) Redact extension failures
- (chat) Show the session log when a launch fails (#857)
- (cli) Onboard into the default ACP TUI (#886)
- (cli) Harden trajectory operations
- (config) Make server.log_level set the log filter (#806)
- (daemon) Authenticate restart kill target
- (daemon) Await restart process exit
- (db) Normalize anchored SQLite URLs on Windows
- (eval) Preserve unguarded policy subjects
- (eval) Scope trajectory decision credit
- (launch) Say
launch:notspawn:in console output (#798) - (pipeline) Settle routing failures
- (policy) Bind progress guard evidence
- (policy) Make runtime mode authoritative (#761)
- (protocol) Preserve Anthropic tool extension fields (#774)
- (responses) Budget shutdown drain per batch
- (responses) Drain after slow reconciler shutdown
- (responses) Close continuation safety gaps
- (responses) Require graceful reconciliation
- (responses) Fence continuation reconciliation
- (responses) Bound continuation ownership state
- (responses) Close continuation publication races
- (responses) Harden continuation publication
- (responses) Authorize continuation on delivery
- (responses) Harden continuation delivery
- (responses) Bind completed continuation
- (responses) Persist gateway continuation
- (responses) Preserve native continuation
- (responses) Stabilize stream identity
- (responses) Include response creation time
- (router) Correct research measurement reports (#874)
- (sdk) Normalize routed stream usage (#887)
- (tests) Escape Windows database URLs in YAML
- (tests) Drain settlements before restart assertions
- (trajectory) Hold continuation starts at episode head (#829)
- (trajectory) Align start and settlement clocks
- (trajectory) Count recovery transitions
- (trajectory) Preserve truncated ancestry honesty
- (trajectory) Correct outbox delivery audit
- (trajectory) Bind reused evaluation
- (trajectory) Stabilize failed retries
- (trajectory) Expose ancestry conflicts
- (trajectory) Separate hold from tier selection
- (trajectory) Validate initial episode start
- (trajectory) Enforce replay lifecycle
- (trajectory) Bind native evidence key
- (trajectory) Close correlation edge cases
- (trajectory) Harden correlation runtime
- (trajectory) Harden ledger persistence
- Point stale surfaces at commands that exist (#799)
- Harden provider fallback errors (#755)
🚜 Refactor
- (acp) [breaking] One ACP stack, from the engine to a flat chat loop (#848)
- (acp) Move the ACP proxy engine into bitrouter-sdk (#793)
- (docs) Consolidate internal specs under docs/ (#750)
- (policy) Make selection trajectory-aware
- (sdk) [breaking] Move the OTLP renderer out of the SDK (#851)
- (substrate) Drop detach/reattach + fleet machinery (#751)
- (tui) [breaking] Move all drawing into bitrouter-tui (#830)
- Consolidate BitRouter Cloud SDK ownership (#846)
- Unify task-aware routing on agent_route/v1 (#828)
📚 Documentation
- Sync status and launch docs (#840)
- Harden Harbor EC2 network preflight (#838)
- Align docs with current CLI surfaces (#802)
- Correct routing surface docs (#741)
⚡ Performance
- (trajectory) Bound prefix lookup cardinality
- (trajectory) Bound prefix correlation
🧪 Testing
-
(trajectory) Prove progress control
-
Headless permissions and formats for
acp prompt/spawn -p. A headless run used to deny everysession/request_permission; now the caller states the rule:--deny-all(still the default),--approve-reads(the ACP tool kind isreadorsearch),--approve-all, and a per-tool--permission-policy '{"autoApprove":[…],"autoDeny":[…],"defaultAction":…}'(or@path). Each answer is a new NDJSON line,{"type":"permission","decision":"approved"|"denied","title":…,"kind":…}, and the process exits 5 when at least one request was denied and none approved.--format textprints the transcript exactly asbitrouter chatprints it to a pipe;--format quietprints the assistant's text only;json(the default) is the unchanged NDJSON. The decision is made bybitrouter_tui::permission::Policyand reaches the agent through one shared interpreter (chat/effects.rs) that the interactive TUI, the pipedchat, andacp promptall run — the first session-verb parity between the terminal and the headless CLI. -
Breaking (MCP): the origin MCP server's
completetool is removed. MCP is BitRouter's control and introspection surface; running a completion is what the daemon's HTTP inference API is for. Use it instead — it is the transport built for inference, and it always had the surface the tool did not: streaming, the full parameter set, and the metering path.// before — MCP tools/call { "name": "complete", "arguments": { "model": "openai/gpt-4o", "messages": [{ "role": "user", "content": "hi" }] } }# after — the daemon's HTTP API (OpenAI-shaped; /v1/messages is the # Anthropic-shaped twin) curl http://127.0.0.1:4356/v1/chat/completions \ -H 'content-type: application/json' \ -d '{"model":"openai/gpt-4o","messages":[{"role":"user","content":"hi"}]}'What remains on
bitrouter mcp serveislist_models,status,route_previewand the skills pair: what is routable, where it would go, and what it has cost. The stdio profile is nowlist_models+status+route_preview+skills_search+skills_get; the HTTP profile islist_models, plusstatuswhere the backend can answer it.The one-line spend footer went with it. It only ever rode successful
completeresults;statusreports the same figure as typed structured content underspend, from the same metering database, withunpricedand the remaining cap a footer had no room for.Breaking (Rust API):
bitrouter_mcp::backend::Backend::completeand theCompleteRequest/CompleteResponse/Usagetypes are gone;Backendis now a pure port-handover trait (status_port+models_port). Also removed:server::Builder::completion,server::CostFooter,BitrouterMcp::with_cost_footer,server::CompleteArgs, andServeOptions::cost_footer.server::serve_stdioloses itscost_footerparameter and now takes the handler alone.serve_http_on,CloudBackendandCloudAuthare unchanged. -
Breaking (MCP wire, SEP-2640): the skills extension's
resourcesfield now matches the specification the MCP Core Maintainers accepted on 2026-09-03. BitRouter previously implemented an earlier draft and emitted entries a conforming host must refuse. Three changes:// before — a skill entry BitRouter published { "uri": "skill://pdf-processing/SKILL.md", "frontmatter": { "name": "pdf-processing", "description": "…" }, "resources": [ { "uri": "skill://pdf-processing/SKILL.md", "digest": "sha256:…" } ] } // after { "uri": "skill://pdf-processing/SKILL.md", "frontmatter": { "name": "pdf-processing", "description": "…" }, "resources": [ { "uri": "skill://pdf-processing/SKILL.md", "digest": "sha256:…", "size": 5120 } // new, REQUIRED on every entry ] }sizeis required on everyresourcesentry — the file's raw byte length. It lets a host budget a skill from the listing alone, and a read whose length differs fromsizeis now a verification failure equivalent to a digest mismatch, whether or not the digest is computed.resourcesis required, and "dynamic" is a string marker. The draft let the key be omitted to mean "generated dynamically"; the accepted specification requires the key and takes either the complete array or the literal string"dynamic". An entry with neither "is invalid, and hosts MUST NOT load it". The gateway therefore no longer republishes an upstream entry that omitsresources; it skips it, as it already skipped other malformed entries.- Per-skill limits are fixed: 512
resourcesentries and 16 MiB (16,777,216 bytes) summed oversize. BitRouter will not serve or re-publish a skill exceeding either.
Breaking (Rust API):
bitrouter_sdk::mcp::skills::SkillResourcegainssize: u64, andSkillEntry::resourceschanges fromOption<Vec<SkillResource>>to the newSkillResourcesenum (Enumerated(Vec<SkillResource>)|Dynamic). -
Breaking (CLI):
bitrouter skills list --jsonchanges shape, and the command sees more skills than it used to.bitrouter skills listand the MCPskills_searchtool are now one action over one report type.// before { "skills": [{ "name": "alpha", "path": "/p/.claude/skills/alpha" }] } // after { "skills": [{ "name": "alpha", "description": "What alpha does", // new "dir": "/p/.claude/skills/alpha", // was `path` "skill_md": "/p/.claude/skills/alpha/SKILL.md", // new "valid": true, // new "problem": "…" // new, omitted when valid }] }pathmeant the skill directory to the CLI and theSKILL.mdfile to the MCP tool. Neither was wrong; sharing one key for both was, so both surfaces now carrydirandskill_md.Three behaviour fixes ride along, all from collapsing three discovery rules over two roots into one:
- All three conventional layouts are listed.
bitrouter skills listread only<root>/.claude/skills, so a./skills/fooskill was invisible to it while the agent could see it. - A skill that cannot be loaded is listed and explained, with
valid: falseand aproblem, instead of being listed unmarked by the CLI and dropped silently by the agent's surface. SEP-2640'sskills/liststill publishes only the loadable ones, which the specification requires. - The MCP surfaces read the user-global root, which only
-gused to reach. An agent no longer misses a skill because of where it was installed.
- All three conventional layouts are listed.
-
Fixed (MCP): a
bitrouter mcp install-ed client now sees the skills tools.mcp installwrites["mcp", "serve"], but the skills and SEP-2640 surfaces were wired only undermcp serve --backend skills, so an installed client never saw a skill. Every stdio profile now carries them — the identity argument that makes--backend skillsstdio-only (the server is a subprocess of the caller whose machine it is) holds identically for stdiomcp serve. The multi-tenant HTTP profile is unchanged and still carries neither. -
Breaking (CLI):
bitrouter route --jsonchanges shape.bitrouter routeand the MCProute_previewtool are now one action over one report type, and the report keepsroute_preview's richer vocabulary — it was the superset, and it is the one an agent reads. Duplicated keys for one fact are exactly the drift the unification exists to remove, so the old names are gone rather than deprecated.// before — `bitrouter route gpt-5 --json` { "model": "gpt-5", "resolved_via": "config", "chain": [{ "provider": "openai", "service_id": "gpt-5", "protocol": "openai" }] } // after { "requested_model": "gpt-5", // was `model` "effective_model": "gpt-5-codex", // new: what the policy table selects "effective_effort": "high", // new, omitted when policy selects none "resolved_via": "config", // now live | config | zero_config // (was live daemon | config | zero-config) "policy_decision": { … }, // new, omitted on the live-daemon path "provider_chain": [ // was `chain` { "provider": "openai", "service_id": "gpt-5-codex", "api_protocol": "openai" } // `protocol` → `api_protocol` ], "estimated_cost": { … } // new, omitted when the registry prices nothing }Migration is mechanical:
.model→.requested_model(read.effective_modelif you want what would actually run),.chain→.provider_chain,.chain[].protocol→.provider_chain[].api_protocol, and.resolved_via == "live daemon"→"live"/"zero-config"→"zero_config". Theresolved_viavalues are now the same wordsbitrouter models --jsonuses for the same fact (live/config), instead of two spellings that almost matched.Two behaviour fixes ride along.
bitrouter routenow runs the policy table in its config fallback, asroute_previewalways did — it could previously name a model the daemon would never pick, which is whyeffective_modelis a separate field fromrequested_model. (The live-daemon path is unchanged on both surfaces: the daemon'srouteverb resolves the model as given, so aliveanswer reportseffective_model == requested_modeland nopolicy_decision.) Androute_previewnow resolves config per call instead of snapshotting it atbitrouter mcp servestart, so an editedbitrouter.yamlis visible to a long-lived MCP server, as it always was to the CLI. -
Breaking (Rust API):
bitrouter_mcp::capabilities::routingis gone. The routing port moved tobitrouter_mcp::actions::routeand is now typed:RoutingQuery::preview(RoutePreviewArgs) -> serde_json::ValuebecomesRouteQuery::route(RouteInput) -> RouteReport.ServeOptions::routingtakes the new trait object. -
Breaking (MCP tools): the origin server's
statusandlist_modelstools return the same report types asbitrouter statusandbitrouter models, and advertise them asoutput_schema.list_modelswas a bare[{ id, provider }]that kept only the first provider of each model. It is now{ models: [{ id, providers: [...] }], resolved_via: "live" | "config" }— the whole fallback chain per model — with an optionalproviderargument (the same filter asbitrouter models --provider). On stdio + local it reads the daemon's live routing table over the control socket and falls back to a config parse, so it answers with no daemon running;resolved_viasays which view it is.statuswas aGET /v1/modelsin disguise ({ listen, models, providers }locally, the raw balance on cloud). It is now{ running, pid?, listen?, models?, providers, socket?, spend? }: a stopped daemon isrunning: false, not a tool error, andspendcarries two independent halves —spent(today's locally metered estimate, withunpricedsaying how partial it is) andlimit(a metered account's remaining credit). It is no longer served on HTTP + local — nothing on that transport can read the daemon's control socket — and no longer carries the free-text spend footer, which is now the typedspendblock (the footer is gone from the server entirely — see thecompleteremoval above).
-
bitrouter status --jsongainsproviders[](the distinct providers behind the routable models) andspend;bitrouter models --jsongainsresolved_via. Both are additive — every pre-existing key is unchanged.bitrouter modelsnow prefers the running daemon's catalog, likebitrouter route, and falls back to the config parse it always used; standalone answers (models,route,spawn's Codex preflight) now resolve a config the way the daemon does, so a subscription-backed provider (claude-code,google-ai) is no longer missing from them. -
Breaking (Rust API):
bitrouter_mcp::backend::{ModelInfo, StatusInfo, ProviderStatus}andBackend::{list_models, status}are gone;Backendgainsstatus_port/models_port(Option<Arc<dyn StatusQuery>>/Option<Arc<dyn ModelsQuery>>),Builder::completion_localis gone (itsmodels(...)half is what survives — see thecompleteremoval above), andServeOptionsgainsstatusandmodels.bitrouter_sdk::language_model::routing::ModelInfois the element type of the shared report and now derivesJsonSchema,PartialEqandEq. -
Breaking (CLI):
bitrouter skills add,remove,find, andupdateare removed, along with thebitrouter-skillscrate that backed them. Installing skills is the ecosystem's job —npx skills add, or the Claude Code / Codex plugin marketplaces (this repo ships as one). BitRouter reads the installed-skills directory and serves it over MCP; it does not populate it. That is the same line as "server, not host" applied to content lifecycle: BitRouter handles transport, not distribution.bitrouter skills listandbitrouter skills initremain.SKILL.mdformat support moved into the binary (apps/bitrouter/src/skills/), where its only consumers live; the git-clone, source-resolution, install-to-disk, and registry-client code is gone. The--registry/--namespaceflags and theapi.bitrouter.aiskills-hub client went with them. -
Skills over MCP (SEP-2640). BitRouter now serves Agent Skills as an MCP server and proxies them as a gateway, over stdio and Streamable HTTP alike.
bitrouter mcp serve --backend skillsanswersskills/list,skills/get,resources/list, andresources/readover the installed-skills root, with completesha256:digests per file; the existingskills_search/skills_gettools are unchanged and still served. The daemon's aggregatePOST /mcpmerges upstream skill catalogs, namespacing each under its configured server name (skill://<server>/<skill-path>/SKILL.md) so two upstreams publishing the same URI cannot shadow one another. BitRouter is a skills server and gateway, never a host: no daemon path installs skills, and gateway-sourced content never touches a filesystem skill-discovery path. Caveats are documented inskills/bitrouter/references/mcp-server.md— the gateway is not a security boundary, and remote catalogs are daemon-scoped rather than caller-scoped. -
Breaking (behaviour): aggregate
resources/read(POST /mcp) no longer tries each member and returns the first success. It resolves exactly one owning member — by skill-URI label, else by which member enumerates the URI — and errors when zero or several match, naming the candidates. First-success scanning let configuration order silently decide which upstream answered a URI two members both served, which is a cross-origin misroute (and the impersonation surface SEP-2640 names for skills). A URI that no member enumerates is now an error on the aggregate endpoint; read it from that server's direct route (POST /mcp/{server}) instead. -
The gateway's
initializenow advertises theresourcescapability and declares theio.modelcontextprotocol/skillsextension. Skills are read throughresources/read, so a compliant client that saw noresourcescapability would never issue one. The extension is declared optimistically — upstream capabilities are discovered lazily, so the gateway cannot know at handshake time whether any member serves skills. -
Breaking (Rust API):
BenchmarkOutcomeRecordhas a newrequest_idfield and strict reward feedback joins it to the persistedCapturedIngressTrace.id. Migrate Rust struct literals toBenchmarkOutcomeRecord::new(session_key, task_id, reward)followed by.with_request_id(trace_id)when producing reward-feedback artifacts. Older outcome JSONL remains serde-compatible (request_iddefaults to absent), but it is analytical-only and strict feedback rejects it. -
Breaking (policy routes): active
policy_tablerouting now uses one predictive route contract:agent_route/v1|<task-family>|<role>|<risk>. Exact task-family routes fall back to the correspondingunknown-family role/risk baseline, then to the policy default. Observedagent_trace/v2keys remain telemetry only. Staticagent_traceroutes, three-segment predictive v1 routes, and all v2 predictive routes are rejected during config and lock validation. Regenerate policy locks and certificates with the current predictor contract.key_strategy: agent_traceselects this deterministic predictor; the retiredworkflow_stateandlegacy_fingerprintspellings are rejected.adequacy.max_downgraded_requests_per_sessionis rejected: session identity is diagnostic-only and no longer affects routing.adequacy.explore_openingis honored for source-neutral opening projections. -
Rust API:
PolicyKeyStrategynow exposes only the canonicalAgentTracevariant.PolicyDecisionkeepsworkflow_state_kindandworkflow_identity;PolicyDecisionRecordkeepsworkflow_stateandworkflow_identity; andPolicyDecisionSummarykeepsby_workflow_state. Their JSON output uses canonicaltrace_state,trace_identity, andby_trace_statenames while accepting the old JSON spellings on input. The matchingtrace_*accessors are available for new Rust callers.