Skip to content

v0.86.0

Choose a tag to compare

@davidfarah2003 davidfarah2003 released this 19 Oct 15:08
cd08d04

Cotal 0.86.0 is out!

Grab it: npm i cotal-ai@0.86.0

Changes in this release

  • Every auth-callout success now records its broker-generated user key before the minted JWT leaves. startAuthCallout takes a required recordIssuance, and refuses to start without one. The authority plane's recorder writes one create-only natkey.<userNkey> row in the space's auth bucket with the bearer's own space, owner, actor, lifecycle, credential and view and the minted JWT's iat/exp, then re-checks the connect authority before the response. A failed write, an existing row for the key, or a revocation seen by that re-check is a signed deny. The plane removes rows only after their exp plus the clock tolerance, at boot and on a timer (nativeIssuancePruneMs, default 60 s), with the bucket's marker TTL on each purge marker; a corrupt row fails the open or fences the plane. Core adds nativeIssuanceKey. Connections admitted before this change, and static JWTs, have no row.

  • A Claude app conversation can join a local mesh. serveGatewayStdio(host, options) in @cotal-ai/connector-core serves the remote gateway's tools over stdio, so a local MCP server offers the same cotal_connect, cotal_wait and routed tools as the hosted connector without importing an MCP SDK. The hidden cotal app-identity plumbing command opens one conversation's own identity on the selected mesh: it answers taken for a live name, takes its channels and role from the mesh's default persona, provisions the identity's own delivery durables on a mesh with static credentials, and writes the credential to a 0600 file that never appears on stdout. cotal app-identity retire --dir removes the identity's broker footprint and then that file, and each provision retires what a host that exited without retiring left behind. A mesh that uses per-user sign-in is refused. The Claude Code plugin's server now serves those tools to a session Cotal did not launch, in place of cotal_how_to_join, and joins only when the conversation calls cotal_connect. cotal setup records the CLI's own argv beside the plugin, so that server runs the CLI by absolute path; a connector's install action receives it as ConnectorSetupInput from @cotal-ai/core. Started with --desktop, the same server suits Claude Desktop, whose working directory is no project. Without --space, provision sweeps the registry first, as cotal spawn does, so a recorded mesh whose broker is down no longer makes the choice ambiguous. A conversation in a Claude Code session is woken when a message arrives: the plugin declares a monitor, each local server writes the connection id of each wake to a private socket of its own, and the monitor turns each valid id into a fixed event line, so no socket text reaches the model. GatewayOptions.wake in @cotal-ai/connector-core is the hook for this: it signals a connection once until it is read, never while a cotal_wait holds it, and again when a client starts listening, and cotal_connect says whether the client will be woken. GATEWAY_INSTRUCTIONS now tells a conversation how to keep listening when asked, and is exported in two parts, GATEWAY_CONNECTING and GATEWAY_LISTENING. Claude Code runs the cotal plugin in place from the copy cotal setup materializes under ~/.cotal/claude-plugin, and before this change neither a repeat cotal setup nor cotal update rewrote that copy, so after an upgrade plain sessions kept starting the old server, hooks and monitor. A setup provider can now declare refresh (ConnectorSetupProvider.refresh in @cotal-ai/core), which brings what it installed to this release and installs nothing new; a repeat cotal setup and cotal update run it, and cotal update fails when the refresh fails. The copy's version is now the release plus a digest of its files, and cotal status reports the plugin as current or stale against it, in place of only whether it is installed. cotal setup always runs claude plugin marketplace update after adding the marketplace, since Claude Code 2.1.295 answers a repeat add with wording the old check did not match. On macOS and Windows, where Claude Desktop is installed, cotal setup adds a cotal server to Desktop's claude_desktop_config.json that runs the same server with --desktop, keeps every other entry, refuses a file that is not valid JSON, and the refresh keeps the entry current; cotal status reports it.

  • cotal setup now installs the cotal Claude Code plugin for your user account, so every Claude Code session on the machine gets the cotal_* tools in any project: the terminal, the IDE extensions and the Desktop app's local Code sessions. It used to install it only for the project setup ran in. Running cotal setup again moves the per-project installs an earlier release made under the one user-scope install. cotal update still only rewrites the plugin copy and does not change where it is installed. If Claude Code cannot change one project's settings, setup still removes the others and writes the Claude Desktop entry, then fails naming that project and the claude plugin uninstall command to run there. cotal status reads current (user scope). Setup and cotal status now read a plugin list longer than 1 MiB, which a machine with many old per-project installs reaches; before, the output was cut short and status showed unknown. A session Cotal launches still loads one cotal server, because its --plugin-dir copy replaces the installed one for that session.

    The plugin's hook, which Claude Code starts for every tool call, prompt and stop, no longer bundles the @cotal-ai/core package root. It shrinks from 945 KB to 14 KB and starts in about half the time. peerLabel is now also exported from the new @cotal-ai/core/peer-label subpath, and the root export is unchanged.

  • saveAgentFile now refuses a meta key that names a modeled frontmatter field (such as name, model, owner or capabilities) or a renamed one (channels, publish) before it creates a directory or writes the file. Metadata used to overwrite the fields it had just validated. Unknown scalar connector hints still round-trip unchanged.

  • @cotal-ai/core/browser-control describes and invokes any endpoint from a strict-CSP page, with no Node built-in and no runtime code generation. @cotal-ai/core/browser-validators-build generates its contract validators at build time. The envelope byte bounds (error code, traceparent, tracestate, baggage, event topic) now measure UTF-8 with TextEncoder instead of Node's Buffer, so an error reply no longer throws ReferenceError: Buffer is not defined in a browser. The bounds themselves are unchanged.

  • generateBrowserContracts no longer refuses a valid schema whose string data reads like code, such as a const of require(, eval( or new Function. It now parses the standalone validator with acorn and refuses only an actual reference to require, eval or Function, or a call of a member with one of those names. Ajv runtime helper reads are rewritten from the same parse, and output that does not parse refuses.

  • canonicalJson and contractDigest now serialize a plain copy built during the I-JSON check instead of reading the value a second time. A Proxy whose later reads differ from what the check saw, or that answers toJSON, could pass the check and come back as text holding a lone surrogate, null for a checked number, or unsorted keys, and contractDigest hashed that text. The text now always encodes the data the check inspected. An array's length is read once, so a Proxy whose length changes during the copy can no longer add or drop an element, and a length that is not a non-negative integer is refused. Values that are already plain JSON canonicalize as before.

  • The endpoint budget races in the action, work, checkpoint, receipt, guard and payment-proof paths now run through the shared withDeadline helper instead of six private copies. Each keeps its error code and message.

  • TURN_INPUT_SCHEMA and TURN_OUTPUT_SCHEMA are now deep-frozen at their declaration, like the other collections core exports. They were plain objects, so an importer could change the turn contract a manager serves and validates against. Their contents and contract digests are unchanged. The frozen-exports smoke now also admits the ./browser-control and ./browser-validators-build entries. Each declares the functions it adds beyond the barrel and exposes no other new export.

  • @cotal-ai/core now exports digestIdToken, the one recipe behind every id derived from a digest (the 43-character base64url sha256). Run notice and migration ids, checkpoint answer ids, the runtime's derived pause tokens and the manager's turn hold token all use it. Before this, each module kept its own copy, so a change to one copy could pass the typecheck and quietly stop matching the ids the others derive. The derived ids are unchanged.

  • Claude Code sessions report the directory their own SessionStart hook supplies on startup, resume or fork, not the connector process directory, so sessions hosted in one process no longer all show the host's directory. The directory is absent until the hook reports it and is not changed by clear or compact. CotalEndpoint.setCardCwd and MeshAgent.setCwd set it, and MeshAgent's new withholdProcessCwd option leaves it unset at construction.

  • The delivery daemon's lease watch now deletes its broker consumer when its stop handle runs, when the endpoint stops, when a manual reconnect retires a live connection, and when a newer lease watch replaces it mid-bind. Each of those retirements used to leave an idle consumer on the space's delivery bucket for five minutes. A lease-watch bind still in flight when the endpoint stops or reconnects now deletes its consumer instead of installing a watch for the retired connection, and that bind no longer delivers a lease event after stop() has begun.

  • The stop that watchMembership returns now calls finishMembershipWatchStop directly. It used to check the retained consumer identity itself first, which repeated part of the guard inside finishMembershipWatchStop. One guard now decides when a stopped membership watch resolves, on both the stop path and the reconnect rearm path. Behavior is unchanged.

  • peerLabel now prints every control character and Unicode line or paragraph separator in a peer's name or role as a space, as it already did for line breaks and brackets. Before, a peer that put ESC, BEL, a C1 control or a tab in its name, or in the role of the presence record it writes itself, had those bytes printed raw on every surface that labels it, including cotal endpoints, cotal status, cotal join and the console.

  • A run that names a placement instance now sends its placed seats' turns to that instance. Before, those turns went out on the class rail, where only the hosting manager maps the seat, so a live seat could be reported down once the re-issue budget ran out. The run host credential gains owner-pinned turn on that one instance only. Unplaced runs keep their class-rail reach, and their L4002 message no longer claims more than the members the call reached.

  • Readers now drop a presence row whose card.role, activity or attention is present and not a string, or whose condition is present and is not an object with a string code (null, a bare string and an object with no code included), and report it on the recoverable diagnostic path. A peer could previously put an object such as {"toString":null,"valueOf":null} in its own condition code or activity and make every other reader's cotal_roster fail with Cannot convert object to primitive value while that row was live.

  • resolveService no longer starts contract-store reads after it has failed. When one closure walk failed, the resolve rejected at once, but the walks queued behind the first window kept starting and each issued its own store reads for a call nobody could use. A failed walk now stops the pool from starting more; walks already in flight finish on their own.

  • cotal_roster, cotal ps, cotal status, cotal endpoints, cotal console and cotal join print each peer's condition code and activity on one line and at most 200 characters. A control character or Unicode line separator shows as a \uXXXX escape, so a peer can no longer split its row into what reads as a second roster entry, pass terminal control bytes through, or flood every row with a 4000-character activity. The rule is presenceText in @cotal-ai/core, and oneLine moved there from @cotal-ai/workspace, which still re-exports it.

  • Smoke comments and check labels in core, cli and manager name the function, branch or SPEC section they point at instead of a line number that drifts with every edit above it. No behavior changes.

  • The spawn-name smoke's delegation cells now parse agent-file.ts and look for a real assertValidOwnerToken call in spawnNameError, and for any regex literal there besides the traversal guard. They used to match stripped source text, so a string that spelled the call, or the token alphabet restated as \w+, kept them green. No behavior changes.

  • The spawn-name smoke's auth-mode cell now looks through parentheses before it checks the door's mode argument. Writing the call as (target.mode === "user") or parenthesizing either operand used to fail the cell although the CLI behaves the same. A !== comparison, a bare true and swapped arguments still fail it. No behavior changes.

  • cotal down -f on an authenticated mesh now removes the channels its run owns. The teardown credential it mints could not read the membership feed, so the check for other members always failed and every owned channel was kept with membership unknown (no feed), ending in ! partial teardown with exit 1, and down -f --run <id> could never finish the run. The credential now holds the feed's read verbs and still cannot write it. A channel another member still uses is kept as before.

  • validate() and transform() now refuse a write to a const binding through a destructuring assignment ([c] = [1], ({ c } = { c: 1 })) or a for...of with no declaration (for (c of xs)) with L2003. These used to validate clean and fail with L2003 only when the line ran, after any effects dispatched before it.

  • validate() now refuses with L2032 a waitUntil probe or terminal predicate that writes a binding declared, or a value built, outside it. Both engines run them one level deeper than the call and refuse the write at run time, so such a program used to validate clean and then fail on the first observation of every run.

  • A control command run without --space now probes the registered brokers once per resolve. Before this, the control resolver probed every non-catalog record for its mode check, and the connect helper probed them all again before it chose the target; cotal update's manager continuity check added a third round of probes. Each round could wait out the confirming timeout on a record that did not answer, and a broker that started or stopped between rounds could leave the mode check and the connect resolving different targets. ConnectFlags now carries the sweep's offline set, and resolveControlTarget, resolveTargetOrThrow, connectOrThrow, connectUserControlOrThrow and their exiting forms read it instead of sweeping again. A named --space still skips the sweep.

  • Callers can name a manager instance wherever a user-auth mesh with several live managers for one owner asks them to. cotal models and every hosted cotal run verb take --on <instance>, and MCP cotal_run and cotal_personas take an instance argument under the same rules as cotal_spawn's. A cotal command run from a managed seat's shell now selects the manager that launched the seat (COTAL_MANAGER_INSTANCE), as the seat's tools already did, unless --on names another. The ambiguity refusal still refuses, and now lists the owner's live manager instance ids and names the caller's own remedy: --on <instanceId> for the CLI, the instance argument for an agent's tools. Before, it named --manager-instance, which only cotal agent-bearer takes, so none of these callers could answer it.

  • The seed reconcile reads a built-in's package.json once per check. installedExtensionPackage returns the installed version and declared main from that one read, with the error policy installedExtensionVersion already had, and the entry-file check takes main from it. The check used to parse the file a second time and treat any failure of that read as a missing entry file, so a permission error there made cotal ext seed --repair silently reinstall the built-in, or report a torn install while the entry file existed. A read failure now surfaces as the error it is.

  • The comment on the space add second-tenant refusal and the P7/P1 design note now name ensureRootForSpace in the CLI's up command as the root-pin refusal. They used to cite it as up.ts:2233, and that line had drifted onto an unrelated interface field. No behavior changes.

  • A stray space.<key>, account.<key>.json, manager.<key>.pid or delivery.<key>.pid whose key decodes to . or .. is now treated like any other name the workspace did not write. spaceFromSegment returns undefined, accountInventory lists the file under corrupt, and recordedRuntimeSpaces skips it, where each used to throw "cannot name a space" and cotal status printed that error as a hint. The three decoders now share one exported spaceFromKey.

  • cotal status --components now names the HTTP status when the web dashboard's address answers /api/meta with a status outside 2xx, such as HTTP 401. It used to read the body first, so a plain-text or empty refusal printed http not answered. The status row and cotal web --detach now send one shared readiness request from @cotal-ai/workspace and read its answer the same way. That request no longer follows a redirect, so the readiness header goes only to the recorded address and a 3xx answer is reported as its status, such as HTTP 302, where both used to follow it and accept the identity the redirect target answered with.

  • The pre-hex record sweep that runs when a mesh is recorded or removed now throws any error listing the registry directory other than ENOENT. Before, the sweep read every listing failure as an absent registry, so a removal could return while a pre-hex record of the same space stayed behind and listed the mesh again. An absent registry directory still has nothing to sweep.

  • The AG-UI mapping smoke's comments now describe the run-opening rule the mapper uses, the ORIGIN_RULE and ABSENT_ORIGIN_RULE enumerations restated once as attributableTurn, in place of the retired promptSource-presence gate. The mutation ledger names the population cell by its current name, and the restatement's doc block now sits on attributableTurn. No behavior changes.

  • A forked Claude Code session no longer acts under the hosted Cotal connection of the session it was forked from. A fork inherits the parent's connection_id in its transcript, so before this it reused the parent's mesh name from a new session. The plugin now records which session opened each hosted connection, from a PostToolUse hook on cotal_connect, in the plugin's data directory, and its PreToolUse hook refuses that id in any other session with a reason that says to call cotal_connect. A resumed session and its subagents keep the session id and are unaffected, and cotal_disconnect releases the record. runHookRelay's local answer now runs in every session and is told whether the session carries a mesh identity.

  • A Claude Code session that Cotal launched now refuses the cotal_* tools of every MCP server other than its local Cotal server. Claude Code loads connected-account connectors in a spawned session by default, so a hosted Cotal connector beside the plugin let one session join the mesh twice under two names. The plugin's PreToolUse hook now denies those calls with the reason, and decides in the hook process, so the refusal holds when the control socket is down. A session Cotal did not launch keeps the hosted connector. runHookRelay takes an optional answer that settles an event in the hook process before the control socket is resolved.

  • The remote gateway gets cotal_wait, so a hosted conversation can listen for messages. A remote MCP client cannot start a turn, so a hosted conversation heard a message only when it happened to call a tool. cotal_wait returns at once when a wake-worthy message is buffered and otherwise as soon as one arrives, with the reply cotal_inbox gives, or with none after a bound. Wake-worthy follows the agent's attention, as a local seat's push does. The bound is GatewayOptions.waitSeconds on registerCotalGatewayTools, or waitSeconds on handleGatewayRequest, and defaults to GATEWAY_WAIT_SECONDS (50 seconds), under Claude Code's 60-second limit to a remote server's first byte, Cloudflare's origin timeout and claude.ai's tool-call timeout. A wait whose client closes the request stops listening, and a read whose client is gone clears nothing, so neither takes a message it cannot deliver. A wait ends when its connection closes, and a focus @mention that lands between two waits ends the next one. CotalToolSpec.run takes an optional ToolCall with the request's signal, which cotal_inbox honors. Every other routed tool except cotal_inbox now adds a line to its result when messages are waiting, and the default instructions tell the model to listen with cotal_wait. MeshAgent.inboxWake() is the inbox half of pendingWake(), without run turns. MeshAgent emits stopping once when stop() begins, whatever the transport's state.

  • cotal agent-bearer --manager-call is now told to name a manager with --manager-instance <instanceId> when an owner has more than one live manager. Before, it was told to use the instance argument, which only an agent's tools take. The exchange names --manager-instance for every actor-token caller, because cotal agent-bearer is that arm's one client, and the connector renames it to the instance argument when it runs the command for a tool. The refusal still refuses and still lists the owner's live manager instance ids.

  • Report the connector process working directory in session display metadata alongside its hostname.

  • The OpenCode 1.x plugin's teardown and session-swap comments now state the rule the code beside them keeps, in the present tense, in place of accounts of earlier revisions and of how a review caught them. The swap note no longer names a drivePending flag the code does not have. No behavior changes.

  • The Orca runtime's exit wait now settles within its bound when the Orca CLI survives SIGTERM. The async CLI calls behind terminal wait, list and show sent SIGTERM on timeout and then waited for the child to exit, so a child that ignored it held the wait open. Both the sync and async paths now share one set of exec options that kills a timed-out child with SIGKILL.

  • The tmux terminal layout now refuses a multi-pane tab with no split before it writes a launch script or opens a window. Before this, open() started the first pane in a new window and then threw, so the caller got an error with no ref while the window and its command kept running, and every retry left another one.

  • The first IdP pin is now written by exclusive create. Two processes enabling user auth on one state dir at once used to both succeed, each holding its own IdP while idp.json named only the last writer. Now one pin wins: the other caller adopts it when it names the same IdP and is refused with the usual re-key message when it does not.

  • When every remote manager an owner registered has exited, the manager-caller refusal now tells the owner to start one with cotal supervise. It used to suggest naming an instance with managerInstanceId, which cannot revive a dead manager. The call is still refused and is never routed to the local manager.

  • The auth service now records a manager-caller connection's credential expiry in the issued evidence it writes for that connection's generation. After the credential expires, the generation no longer resolves and is refused with generation <id> expired at <exp> (SPEC 13.15). A run admitted while the generation was live keeps its admission and keeps renewing.

  • A manager-authority answer whose body is not JSON now fails the request with an error that names the operation, the endpoint and the HTTP status. Before, the client replaced such a body with an empty object, so a 200 answer resolved as an empty result the endpoint never sent, and a refusal read the same as a refusal with an empty JSON body.

  • Keep the manager's delivery-aware marker, shutdown intent and spare capability when cotal down does not stop every selected component. The manager stop no longer removes them itself: down removes them after its all-stopped gate, and the cotal up teardowns and the delivery cutover preflight remove them after their own stop.

  • cotal backup create, restore and quarantine now fail after 15 seconds when their isolated bootstrap broker stays alive but never accepts connections. The reachability wait recomputed its bound on every pass, so only the 30-minute operation deadline ended it.

  • cotal up proves a surviving restore or ordinary-resume listener with one shared check. The two attempt kinds used to carry separate copies of the same ownership proof, so a check tightened in one could silently miss the other. Each kind now supplies only its own journal match and store path.

  • cotal deliver --space <s> --dev-mint now signs its dev cred with space <s>'s account. It used to pick the root's only space, so a root holding several spaces refused a correct --space, and a root holding one other space signed the cred with that space's account. A root with no account for <s> is now refused before any dial.

  • The manager's doc comment on its launch capability check now says spawn and resume both ask before a seat's launch is built. It used to say they ask before any reserve or mint, which resume does not do: its batch reserves the retained names before the check runs. No behavior changes.

  • The instance-pin smoke grades its two recovery-counter cells against the re-issues that left the caller. In a two-manager space a re-issue rides the same class queue and can be refused again, so one call may recover several splits, and the cells asserted exactly one and went red on correct runs. Each now requires the counter to move and to equal the number of re-issued requests. No behavior changes.

  • The instance-pin smoke's admin sweep now sends transcript-receive schema-valid nonsense arguments: a random digest, size 0, and session and host names that match nothing. It used to send {}, which the envelope refused on required before publishing, so the command never reached a responder and the cell that proves every admin command reaches the guard failed on every run. No behavior changes.

  • @cotal-ai/manager now exports managerBrowserContractSource(), the manager's registered cluster document with its closure digest and each distinct schema root. A host passes it to generateBrowserContracts from @cotal-ai/core/browser-validators-build to build the manager's browser validators. Before, the root exported only the document and digests, so no supported import carried the schema roots. Served commands, digests, wire, auth and runtime behavior are unchanged.

  • Remove the internal review attributions from the static lifecycle module's comments, which the published build carried. Each comment keeps the reason it gives.

  • The manager no longer keeps every spawn acceptance for the life of its process. An acceptance is dropped five minutes after its goal ends, and an in-flight goal's acceptance is never dropped. A resubmission of a goal that has ended is answered from the goal's terminal and is never accepted again: a succeeded goal returns the same acceptance, and any other outcome is refused failed-precondition with its terminal attached. Before, a resubmission the manager no longer held in memory, such as one after a restart, ran the accept again and was refused with an error telling the caller to reconcile the store.

  • cotal supervise --space <s> now starts on a root that hosts several spaces when <s> has no registry entry. The root's account record for <s> counted as the host proof only while <s> was the root's sole space, so a second space made the manager exit before readiness with the ambiguity error that asks for the space it was given.

  • cotal supervise now handles SIGTERM and SIGINT from the moment it starts. Before, its handlers were installed only after the manager finished starting, so a signal during startup killed the process with its liveness lease still held: no warning printed, and an immediate restart from the same workspace root was refused with already serves ... stop it first until the lease's 10 second TTL lapsed. A signal during startup now waits for the start to settle and runs the normal stop, which releases the lease. The process then exits 0, or, when the start itself failed, prints that error and exits 1.

  • Report an unanswered placed-host discovery as catchable unreachable L4002 before relay submission. Preserve accepted-goal uncertainty and permission refusals. Keep a migrated seat's original host in its durable binding and restored roster, and refuse incompatible placement requests without consuming its kept hand-over.

  • Attach the seat-address documentation to its type in a single JSDoc block.

  • The web events-filter smoke's call-site cell now counts every mention of chatOnly in the web server and expects three, the declaration and its two calls. It used to count only chatOnly(, so a third call written with a space, a line break, type arguments or ?.( left it green. No behavior changes.

  • The dashboard's static route now reads a page file before it writes the status line. A missing or unreadable file used to be answered with 200, cache-control: no-cache and an empty body, because the read failed after the status was already sent, so the browser ran an empty script and the page failed later with an unrelated message. It is now answered with the request handler's 500 naming the failed read. Successful responses keep their headers, and the session check still runs before the route.

What's Changed

Full Changelog: v0.85.0...v0.86.0