Releases: MatAtBread/matbot
Release list
v0.4.15: a runaway stopped, and one version per release
What's Changed
- A runaway
tool_functionno longer freezes the daemon — a body doing more than 10s of synchronous work before its firstawaitis stopped with an error naming the likely runaway loop, and the process log names the function that was stopped. Set the limit withfunction_timeout_msinmatbot.yaml(#73) FunctionRunner(new, optionalMatbotServicesmember) — a host-supplied compiler whose synchronous execution is bounded. The CLI registers anode:vmone; absent, bodies run directly as before- Cancelling a turn stops a looping
tool_function—invokeToolrefuses to start a tool on an aborted signal, andrunFunctionstops waiting when its call is aborted tool_functionpackages — one TypeScript module whose exported functions become<package>__<function>tools, with private helpers (#63)- Validation names the key a call should not have —
background({ action: 'cancel', id })reports.action: unexpected property, not.prompt: required property missing - The boot banner warns about duplicate singleton copies, not about version numbers that are allowed to differ
- frontend-web tells the model about
POST /tools/<name>(#72) - Release tooling: one release version, a web bundle that must match its sources, and
publish-allas the only way to publish (#71, #73)
Full Changelog: v0.4.14...v0.4.15
⚠️ Worth knowing before upgrading
No breaking change to the plugin contract. Three behaviour changes a caller could notice:
invokeToolthrows ifopts.signalhas already aborted, as it already did for an unknown tool name. A caller that deliberately invoked tools after cancelling its own signal now gets an error instead.function-tools' exportedPackageFncalling convention changed. A compiled package now calls the chosen export itself, sobuildPackageFn's result takes(tool, toolInContext, context, exportName, arg).exportFnhides this, and nothing outside function-tools is known to call either directly.- A
tool_functionbody in the CLI runs under a time limit. A legitimate body that computes for more than 10s without awaiting is now stopped. Raisefunction_timeout_ms, or yield occasionally withawait.
A runaway loop, and what stops it
On 15 September a model-authored lambda froze the daemon for 260 seconds — every session, the web UI, Telegram, schedules. Its coupon-date loop never advanced (d = addMonths(matISO, 0) === d ? d : … always chose d), and it ended only because the array it kept growing hit JavaScript's length limit. A loop that did not allocate would have run until someone restarted the service.
The cause is structural, not one bad lambda: a tool_function body runs on the one event loop everything shares, and nothing in-process can interrupt synchronous code — an abort signal included, since stopping needs the loop to yield.
What stops it now. The CLI registers a FunctionRunner over node:vm. Every tool_function call — a lambda, a defined function, a package export — enters through a timed script, so a body that computes for longer than the limit before it first awaits is stopped:
Stopped after 10s of synchronous work without an await — most likely a loop that never ends.
A function may compute between awaits, but not for this long.
The caller receives that as the tool's error, and the daemon's log records which function it was:
[function-tools] stopped tool_function lambda (session fd58ab72-…, call 928d6d9b-…): Stopped after 2s of synchronous work …
Definition: `(args: { outlay: number }): Promise<number> { let d = args.outlay; while (d > 0) { d = d; } return d; }`
Awaited work never counts. A body that spends minutes on slow tool calls or HTTP requests is untouched; the clock covers only the stretch from a call's start to its first await. A tool_function started from inside another's first stretch counts against both.
function_timeout_ms: 30000 # milliseconds; default 10000function_timeout_ms: 0 registers no runner at all — bodies run unbounded, exactly as before — and warns at boot. It exists for testing.
What it does not cover, and why. A loop that runs after an await is not caught. The design that catches it — a context per run in microtaskMode: 'afterEvaluate', with each continuation drained inside a timed evaluation — works, and was built and tested. It was withdrawn because a timeout landing during that drain aborts the whole process whenever async hooks are enabled (nodejs/node#38503, closed as stale without a fix). matbot enables none, but the test runner does and any instrumentation might, and a guard that can kill the daemon is worse than the freeze it prevents. The reasoning is recorded beside the runner so it is not rebuilt unknowingly.
Cancellation. Two gaps closed alongside: runFunction never watched its call's abort signal, so cancelling a turn waited on the body regardless; and invokeTool passed the signal on without checking it, so a body looping over await tool.x() kept calling tools after the abort. A cancelled call now ends at once, and its body's next tool call throws.
The seam. FunctionRunner is an optional MatbotServices member, like MediaStore: seeded by the host, replaceable by a plugin, reverting to the host's on unload. The browser registers none — it cannot interrupt synchronous code — and neither does an embedder that has not chosen to, so both keep today's behaviour. A run stopped at the limit rejects with code: FUNCTION_TIMEOUT, exported from plugin-api and core, so a consumer can tell a stopped runaway from an ordinary failure without matching message text.
tool_function packages
tool_function { action: 'package' } defines one TypeScript module whose exported functions become tools named <package>__<function>, while its helpers, types and constants stay private and are never registered. A package is stateless — its top level is evaluated afresh on every call — so a top-level let/var, class, enum, import, bare statement or top-level await is refused, pointing the author at a plugin.
That refusal was found bypassable in review: it looked for where a statement starts, which punctuation cannot tell once semicolons are optional. const LIMIT = 10\nlet count = 0 and function f() {}\n(async () => {…})() both got through. It now walks each allowed declaration to where it ends — a function or interface at its body's }, a const or type at a top-level ; or where ASI would end it — and requires the next thing to be another. export function f<T>(…) now reports that the export is generic, rather than that it isn't a function.
Validation that names the wrong key
Calling one tool with another's shape — background({ action: 'cancel', id }), where cancel belongs to every_action — reported .prompt: required property missing, which sent the model to fix its arguments instead of its choice of tool. The typed validator now reports a key that no arm of a discriminant-less union declares as unexpected property, before trying the arms; the schema validator lists undeclared keys first when it is already refusing a call (and still never refuses one for an undeclared key alone, the schema admitting them).
One release version
The harness — core, plugin-api and the apps — always carries the release version, and so does every package being released: anything whose current contents npm does not already have. Unchanged packages keep their number. changeset version cannot express this, so pnpm version-packages now runs it and then publish.mjs --align, which moves versions and rebuilds the web bundle.
publish.mjs enforces it on every PR and before every publish: VERSIONS (a harness or releasing package off the release version), WEB BUNDLE (a committed dist/ differing from a fresh assemble — the bundle bakes every package's source and version), STALE / BEHIND (contents compared with npm's tarballs, not just version numbers), and GUARD: every package now carries a prepublishOnly that refuses a bare pnpm publish or changeset publish, so pnpm publish-all is the only way to publish. A real publish always requires a clean tree, and the unchecked publish-app script is gone.
Other fixes
- The boot banner's "version skew" warning compared version numbers, so it fired on every release that bumped the CLI without core, while missing two physical copies that happened to share a version. It now compares the resolved directories of core and plugin-api, as reached from the CLI and each of its dependencies, and names the copies.
- frontend-web contributes system context describing the relative
POST /tools/<name>andPOST /stream/tools/<name>entry points, so a model writing a live dashboard knows it can call tools over HTTP. ts-validationdeclares its@matatbread/matbot-corepeer dependency on npm; the fix had landed in source after 0.4.12 was published.
Testing
488 tests pass, including the function runner (10: a loop is stopped and the event loop is free again, a slow await is untouched, a package export and a nested run are bounded, abort ends a call and its tool calls, the timeout is logged by name). plugin-api, core, function-tools and the CLI typecheck clean; publish-check is green; both web bundles are rebuilt.
Verified against running servers booted from scratch configs: the lambda from the incident is stopped at the limit ...
v0.4.14: who decides, and what was actually checked
What's Changed
PermissionGate— a privileged operation declares that acceptance is needed; a replaceable policy decides how it is obtained. 20 call sites, 13 gate ids, three copies ofconfirmActiongone (#62, #69)@matatbread/matbot-default-gate(new) — the default policy each host seeds: ask, offer standing answers, honour what was remembered. Carriesgate_actionfor inspecting and forgetting them- A one-off Allow no longer records a standing answer — the four options were matched by prefix, and Allow shares its first letter with Always allow …
- A store's
shapeis read as TypeScript, not matched against a pattern — seven silent-emit paths closed, each of which produced a confident wrong document type (#64) - A checker that checks nothing says so —
ToolCheckReport.checked,function-tools'definedUnchecked, and a shape that yields no document type refused rather than degraded PluginSettings.entries()— a plugin can enumerate its own settings, in exactly oneget- A
selectoption can carry a value distinct from its label —FormField.optionstakesstring | { value, label } .compiled-plugins/— the compiled-plugin build root is dot-prefixed like every other matbot-written root, and an installation can site it- docker-bash defaults to
node:24-bookworm, plus abash_config { action: 'pull' }that recreates the container and streams the pull - The CLI REPL is coloured by role, on a tty only (#66)
Full Changelog: v0.4.13...v0.4.14
⚠️ Breaking
Three, all of them named in CHANGELOG.md with the one-line fix:
default_settings.__matbot_core__.overwriteToolsOnCollisionis gone in both forms — a stored answer and a configured floor — and is not migrated. Re-author it asdefault_settings.'@matatbread/matbot-default-gate'.'tools.overwrite', taking the same list of tool names (ortrue). An install still carrying the old key is warned at boot, naming where it went. The standing answer is otherwise re-offered the first time that collision comes round again, so restoring it is one click — adopt-once machinery would have kept a reserved namespace alive to save a keystroke.ToolTypeIndex.checkreturns aToolCheckReport, notstring[].ToolContext.gateis required, andPluginSettings.entries()with it — additive for callers, breaking for anyone who implements either shape (an embedder standing up its own tool-invocation door, a test fixture, a settings facade).bindGate(machine.PermissionGate, toolName, ask)supplies the first in one line.
And one rename that is not an API break but does need an edit: compiled-plugins/ is now .compiled-plugins/, not migrated. An install with compiled plugins renames the directory and the matching ./compiled-plugins/<tool> entries in its config together, or pins the old name through compiledPluginsDir. A stale entry fails to load, naming itself, at boot.
The call site declares; the policy decides
Installing a plugin, adding a provider profile, connecting an MCP server, overwriting a tool another plugin owns — each of these did two jobs at the call site: decide that acceptance was needed, and implement how it was obtained. The second had grown a long way past a prompt. Core held a settings key, a cached memo, a per-tool allowlist and two "always" options; confirmAction was implemented three times over, in tool-plugin, mcp-http and browser.
The consequence is the thing this release fixes: an alternative installation could not supply a policy, only defeat one.
A call site's whole contribution is now a PermissionRequest:
if (!await ctx.gate({ gate: 'add', subject: specifier, label: `Install plugin **"${specifier}"**?`, fallback: false })) {
yield { type: 'result', value: { message: 'Cancelled.' } };
return;
}
// → services.PermissionGate.decide({ gate: 'plugin.add', subject, label, fallback }, ask)Four properties are worth knowing before you write a policy:
ask === undefinedIS "no human is reachable."decidereceives the prompt channel in scope for that call rather than holding one, because it is per-turn and frontend-owned. The runner passes the rawopts.prompt, never the stand-in that answers with a field's default — that substitute would make "nobody is here" indistinguishable from "a human answered with the default".ToolContext.promptkeeps the stand-in exactly as before.ctx.gatetakes the suffix; the host qualifies it with the tool's registered name. So one answer covers both runtimes' implementations of a tool — node'stool-pluginand the browser'splugin-toolboth registerplugin;mcpandmcp-httpboth registermcp_action— and a plugin cannot address a gate it does not own, tool-name collision onregisterclosing the impersonation route.subjectis a field, never folded intogate. A composite id makes the vocabulary unbounded, which collides with "an unknown gate id must ask"; subjects contain the separator anyway (@scope/pkg,./plugins/x,https://…/plugin.ts). It is also the identifier the call site has, not a canonical identity — atplugin.addthe plugin is not loaded, so an allow for@x/foodeliberately does not matchhttps://…/foo.ts.PermissionGateis a swap-member, not an optional service. Absence is not a sensible state, so the host captures a boot default andunregisterreverts to it: unload the policy ⇒ back to asking, with no revert rule of its own.
registry.ts sheds ~60 lines of policy for one decide({ gate: 'tools.overwrite', fallback: true }). That fallback: true is load-bearing: it is what still lets a deliberate override win at boot with nobody present, so the assertion recipe in docs/PER-USER-PLUGINS.md keeps working.
The 19 converted call sites are a new implementation with the same behaviour. Each still asks a human when there is one and still declines when there is not — every gate but tools.overwrite declares fallback: false, which is what the CONFIRM_NO default already resolved to. What changes is who answers, and that an installation can decide without editing a plugin.
mcp_action add is the one case to know about, being newly gated this release: connecting registers a remote party's tools for the rest of the session, and the local (stdio) variant spawns a child process on the host, yet only remove used to ask. A caller with no prompt channel (invokeTool, a trigger, a compiled skill, POST /tools/:name) now gets Cancelled. The supported fix is to name the gate, not fork the tool:
default_settings:
'@matatbread/matbot-default-gate':
'mcp_action.add': true # or a list of server names@matatbread/matbot-default-gate
A library each host seeds, in the tool-plugin mould rather than a configured plugin: createDefaultGate becomes the boot PermissionGate, and gate_action is registered beside plugin/provider. It has to be. A minimal install's first act is adding a plugin or a provider — which is gated — so neither the policy nor the means to inspect and undo an answer may depend on a plugins: line. Seeding it is also what stops gate_action colliding with itself at every boot.
It reproduces the old behaviour, keyed (gate, subject) in its own settings namespace:
default_settings:
'@matatbread/matbot-default-gate':
'tools.overwrite': [bash, plugin]gate_action has get (the standing answers in effect — a stored answer and a configured floor read identically, because the question is whether the prompt appears) and clear (forget a gate, or one subject within it; clearing means revert to what the installation configured). There is deliberately no set: the write path for a runtime actor is answering a prompt that names the specific act.
get reports answers, not a vocabulary. A gate nobody has answered is absent from the listing rather than shown as "will ask" — an absent key says only that no answer was recorded, and what happens then is the call site's fallback and whatever policy is registered, neither of which this tool can know. A fresh install therefore lists nothing: everything is default behaviour.
Honest statement of what this buys
Not "the LLM cannot change this", but "a privileged operation is decided somewhere replaceable, and by default that means a human is asked."
The default policy keeps its answers in .data/settings/, which any shell tool can write — immediately, since reads are not cached. A standing answer is also a decision, not a channel: it applies at every door, including the ones with no human behind them, so Always allow every plugin.add is a blanket grant to anything that can reach POST /tools/:name — the model itself, through its own http or bash tool. That is why the per-subject form is offered first. And plainly: a gate that auto-approves plugin.add has granted everything, a loaded plugin having full Node capability with no in-process sandbox.
A deployment that needs a real boundary ships a gate with its rules compiled in. The levers are named for a policy author rather than papered over: decide sees ask === undefined for any caller with no human behind it, and runs under the ambient security principal (tryCurrentPrincipal()), so a stricter deployment refuses there in four lines. Neither is a field on PermissionRequest — a request describes the act; the context of the call is already in scope.
Two more things a policy author should be told rather than dis...
v0.4.13: the dependencies a fetch cannot bring
What's Changed
provider update— changemodel,endpoint,parametersormaxRoundson an existing profile, with one patch policy shared by all three hosts that apply oneplugin addover http installs the dependencies a source-fetch cannot bring — into the plugin's own cache root, not your project- Three silent failures on that path — an unreachable remedy, a rethrow that discarded the error's identity, and a singleton link that depended on an unrelated dependency being declared
provider removeno longer swallows the config section that follows the last profile
Full Changelog: v0.4.12...v0.4.13
What it's for
A plugin fetched from a URL brings one package's own files and no dependency graph. That is by
design — the route crawls imports and mirrors them into .plugins/, with bare specifiers bridged to what
the host already has — but it meant any plugin with real dependencies could be fetched, verified, written
to config, and then fail to activate on its first unresolved import. The route worked for plugins that
needed nothing, which is not most of them.
plugin add now resolves what such a plugin declares, asks once with the full transitive list, installs,
and retries activation in process. Declining changes nothing.
Where those dependencies land is the substance of it. They go to the plugin's own cache root under
.plugins/, through the same plan/apply a local plugin already used. Not your project: pnpm add refuses
outright at a workspace root, and where it succeeds it writes a fetched plugin's dependencies into a
tracked manifest and lockfile that have nothing to do with them. Not the .plugins/node_modules link farm
either — that holds the host singletons and the plugin self-links, and an install there would prune them
as extraneous. The cache root sits earlier in the resolution walk-up than the farm, so what lands there
is reachable from that plugin and invisible to everything else. It is also removed with it: evicting a
plugin takes its dependencies, and rm -rf .plugins/ clears every plugin's without touching matbot's own
node_modules.
Three things that failed by saying nothing
Getting there meant fixing a chain in which each link failed silently — the output looked like a
considered answer rather than a missed branch, which is why none of it had surfaced.
The remedy that names a missing package matched Node's Cannot find package 'x'. A bare import from
inside .plugins/ never reaches Node's error: ts-hooks retries it against the host's graph first and
throws its own Cannot resolve "x". So the branch was dead for the one route it exists for, and the
generic fallback still printed the underlying text.
Behind that, loadPlugins with onLoadError: 'throw' built a bare new Error(message) — everything but
the text was lost, including the ERR_MODULE_NOT_FOUND code the remedy tests before it looks at the
wording. Fixing the wording alone could not have helped. The wrapper now carries the original as cause
and copies its code, and the reader walks the cause chain rather than trusting one layer to remember.
And applyProvision linked the host singletons only when it had something to npm ci. The simplest
plugin there is — one whose only dependency is the @matatbread/matbot-plugin-api peer — got no
node_modules and no link at all, and resolved the singleton by whatever happened to sit above it on
disk. Every plugin in this repo has dependencies, which is why nothing caught it. Whether an unrelated
third-party dependency is present cannot be what decides if the host's copy is reachable. Two things
behind that are fixed with it: the link target asked "what would a plugin here get" (an author's
devDependencies copy, if installed) rather than "what does the host have", and "a path already exists"
is now "a path leads to the host's copy", so a second physical copy of a singleton is replaced instead of
kept.
provider update
A provider renaming its models left no way to change model: on a profile, and remove + add could not
serve: provider list projects credentials down to hasCredentials, so an LLM re-adding a profile has
no ${NAME} reference to write back and must re-prompt for a key that was never lost — the vault kept it,
the reference did not survive the round trip.
update takes the fields a caller can read back and passes the credential map through verbatim. null
clears a field, absent leaves it alone. The patch semantics live in plugin-api beside
applyCreateSecret, because three hosts apply a patch — the node tool against matbot.yaml, the browser
bootstrap against localStorage, the Drive backend against its manifest — and what a patch means must not
be one of the things they differ on.
It also exposed a pre-existing provider remove bug: the block remover matched every following line that
did not begin <non-space>, which a top-level key does not. Deleting the last profile also deleted
the header of whatever came next and left that section's children indented under providers: — so
default_settings: ceased to exist and a settings namespace became a provider profile, in a file that
still parsed.
v0.4.12: the type is what's enforced, at every door
What's Changed
- Tool inputs are typechecked against their
ToolContractparams TYPE, at the executor — so the model's path,POST /tools/:nameandinvokeToolare all covered by one validator - #57 — a vault write refuses a name the backend cannot store (#57)
- #58 — settings changes are observable on the notification bus (#58)
- #59 — the Telegram frontend renders files as attachments (#59)
Full Changelog: v0.4.11...v0.4.12
What it's for
A tool declares its call contract as a ToolContract arm — a real TypeScript type — and until now
nothing enforced it. The model's arguments were checked against inputSchema, a lossy projection of
that type, and only on the model's path. 0.4.12 makes the type itself the thing enforced, at the one seam
every caller passes through.
Validation moved off the toolcall hook and onto the executor. The hook channel is unchanged and
still the place to reject a call — triggers still use it — but it is a runner channel, so a
validator installed there guarded the model while POST /tools/:name and invokeTool called
tool.executor.execute directly and fired no hooks at all. Core now consults an optional
ToolCallValidator service at the executor, wrapped once per tool at registration: one check instead
of one per door, and nothing to drift.
Core's own contract is additive — with no validator registered, nothing changes. What changes is the
behaviour of an install that loads one, in two deliberate ways:
- An unknown property is rejected, as TypeScript rejects one on a fresh object literal, which is
exactly what a model-authored params object is. This is the commonest tool-call hallucination made
invisible:sessionIdmis-sent assession_idwas silently dropped, and the tool then ran believing
no session was given rather than reporting a typo.enforce: 'warn'logs one release of "Would reject"
to find such callers first. - Internal calls are validated too. A statically-typed call site should already be sound, so this is
partly waste — butinvokeToolwith a dynamic name, a trigger'sinvoke.params(typedobject) and a
compiled skill's payload are all internal callers no compiler has checked.
For a multi-action tool this says what a JSON Schema cannot: session_action's schema requires only
action, so every per-arm requirement was lost in the projection. One validator now dispatches on the
discriminant, and a missing field is reported against the arm the caller actually selected.
Errors name the field as a caller would write it, and show what was sent:
Invalid input for tool "about_matbot": .x: never (no value is valid), actual value `{"x":8}`
Two new plugins carry it: tool-types emits a pure-JS validator per tool from the checker's
resolved type, inside the pass that already builds the dts, and registers no validator service — a code
generator loading it for dts() alone never silently starts having its calls rejected. ts-validation
consumes that supply, applies enforce: off | warn | reject (default reject, since loading the plugin
is the opt-in) and registers the service core consults. json-validation moves onto the same seam, so
schema checking covers every door too, and the two compose along the line the type system already draws:
typed contracts first, loose inputSchema behind.
The three issues
#57 — a vault key that was accepted and then lost. The default vault persists to .env, so a secret
stored as email:70de70:password was written, dropped by whatever read the file back (Ignoring invalid environment assignment), and the failure surfaced a boot later as a secret that had ceased to exist. The
caller is usually an LLM inventing a name, with no way to know the rule. VaultSpec gains an optional
unstorableKey(name) — what is storable is the backend's business — and every backend's writeSecret
now throws InvalidSecretNameError carrying the rejected key and the backend's rule, phrased as what IS
storable. Removal still skips the check, so a name predating the rule stays deletable.
#58 — settings changes announced nothing. Every reader had to choose between re-reading the store on
each use (a disk read, on the filesystem backend) and caching with unbounded staleness. ts-validation
made it concrete: validation at the executor read enforce on every tool call through every door, to
re-learn a value that changes approximately never — and there was no correct cache to write, event
invalidation being impossible and a TTL a guess. Settings writes now publish an ItemChange carrying the
writing principal, since an override is per-principal.
ItemChange also gained a key — the caller's key, where the medium derives id from it. A
settings document's id is its plugin package name slugged to fit the store's id rule, and another backend
could hash or truncate, so a consumer routing on identity had to reproduce a transformation it does not
own: a copy that keeps compiling after the rule changes and simply stops matching. Silent, and
indistinguishable from "nothing changed" — the opposite of what an invalidation is for.
#59 — Telegram swallowed files. The file pipeline event was dropped, so a file-producing tool looked
like it had done nothing: the model says "here is the chart" and the chat showed only that sentence. The
event is a durable handle rather than bytes on the wire, so the frontend now pulls it and uploads it — an
image inline, audio as a clip, anything else as a document — ahead of the turn's prose. telegram_send
takes a files list too. A method Telegram rejects retries as sendDocument, because a photo's
width+height sum and a container it will not transcode are only knowable server-side, and a file that
arrives beats one that renders inline.
v0.4.11: a scope the generator never entered
What's Changed
- #54 —
runAs()re-establishes the principal across deferred work (#53) by @matAtWork - A patch release: one security-relevant fix to the ambient principal, at the seam a host uses to invoke a tool itself. No API changed, and nothing in this repo behaved differently.
Full Changelog: v0.4.10...v0.4.11
What it's for
An async generator's body does not begin until the first pull. So an iterator returned out of a runAs
scope carried its whole extent outside it: the identity was established for the construction, and the
work then ran under whatever happened to be ambient where it was pulled.
The tool ABI returns exactly that shape — ToolExecutor.execute() is an AsyncIterable — so a host wiring
up its own tool invocation (an HTTP endpoint, a scheduler) reached for the broken form first:
// WRONG — typechecked, ran with the wrong identity
const events = runAs(principal, () => tool.executor.execute(input, ctx));
for await (const ev of events) { /* body runs HERE, outside the scope */ }And it failed quietly. A host that entered a boot principal at process entry — which the CLI does — read a
plausible identity rather than an error:
actual: [ 'matbot-boot' ]
expected: [ 'alice' ]
matbot's own call sites were never affected: each consumes inside the scope, both frontend-web tool routes
included. This is entirely about the seam handed to an embedder, where the identity is a security boundary.
The shape
Nothing to adopt — the form that was wrong is now correct as written:
const events = runAs(principal, () => tool.executor.execute(input, ctx));
await writeSse(events); // pulls re-enter the scope, wherever they happenrunAs re-establishes the identity around each pull of a returned async iterator, and unwraps a native
promise to find one behind an async () => execute(…). The value crossing back out is still what it was: the
wrapper is a Proxy over the pull points, so a class-based iterator keeps its own members, its prototype,
instanceof, and a private field still resolves.
Nesting needs no rule of its own — runAs(A, () => runAs(B, () => gen())) resolves every pull to B, the
same answer plain nesting already gives with no iterator in sight.
What is deliberately not covered
Each of these is a boundary with a reason, and each is pinned by a test:
- The caller's own continuations. A
catch/finallychained onto the result runs in the caller's flow.
Scoping those would extend a privilege rather than restore one —awaitcallsthenon a thenable, so a
patched one would leak the identity into the whole remainder of the awaiting function. - Exotic thenables.
PromiseLike.thenneed not return a promise, so adopting one would hand the caller
back a different object — a chainable query builder being the shape that breaks. Left intact, and so not
rescoped. ReadableStream. One can be proxied (measured:instanceof,pipeToand a platform
new Response(stream)all survive), but only[Symbol.asyncIterator]andgetReader().read()could be
re-entered —pipeTo/pipeThrough/teepull from platform internals no wrapper reaches. A conditional
guarantee is worse than none here: a host testing withfor awaitand shippingpipeTowould regress
silently, which is the exact failure mode this release fixes. The exposure is narrow anyway —start()is
eager and already inside the scope, so only a lazypull()that reads the principal is uncovered, and
matbot has none: core is pure native JS, andFileHandle.stream()isAsyncIterableby contract.- A returned function is not bound to the principal. An iterator has one thing that can be done to it,
and pulling it is the deferred half of the operation the scope was opened for — unspellable from outside,
which is why the repair belongs in the primitive. A function is a new operation the caller starts later
and N times, and the spelling is theirs: scope the call, not the construction
(() => runAs(p, () => fn(x))). Nothing distinguishes a deferred body from a factory result either —
typeof value === 'function'is equally true of a class, an unsubscribe function and a comparator. - An iterator nested inside a returned object (
{ events }) — no structural check can reach it. machineBusy/withUsageScope. Identical() => Tshape, identical footgun, but a hold and a
roll-up settle whenfndoes. An identity is a re-entrant label; a resource with a settle edge is not, so
contextSwitch(p, () => gen())gets the right identity and still releases the hold early. Their doc
warnings stand.
Deliberately not built
The issue proposed two alternatives, and neither shipped.
- A type-level guard — an overload resolving to
neverfor iterableT. Measured: it raises no
diagnostic at therunAscall, and for the motivating shape —writeSse(runAs(P, () => exec()))—tsc
exits 0, becauseneveris assignable to everything. It errors only on a directfor await, asTS2504
pointing at the consumer rather than the cause. It would have traded a silent runtime bug for a silent type
hole. - A separate
runAsIterableprimitive. FixingrunAsitself makes it redundant: no new export, no
second name to teach, and existing "wrong" code becomes correct without downstream changes.
Cost
~250ns per yield, on the previously-broken shape only. One extra microtask on a promise-returning runAs.
No in-repo behaviour change; 309 tests pass, and every new test was run against the unpatched build first.
Versioning
core, plugin-api, cli and web-bundle move together as a changesets fixed group; only plugin-api
changed, and the rest are bumped anyway because the boot banner reads any difference between the CLI and the
resolved core/plugin-api versions as two physical copies of a host singleton. Rebuilding
apps/web-bundle/dist is part of the release rather than cosmetic: it inlines core, so it is how the browser
bundle gets the fix at all.
Published: core, plugin-api, cli, web-bundle at 0.4.11. No plugin packages changed.
v0.4.10: settings an install can default, and a config that arrives whole
What's Changed
- #52 — install defaults for plugin settings, from the config (#51) by @matAtWork
- A patch release: one new config surface, plus a parser defect found while sizing it that could silently drop half a config file.
Full Changelog: v0.4.9...v0.4.10
What it's for
A plugin keeps its own runtime settings in matbot's store — a classifier provider, a list of tools to ignore, a tuning knob — and until now the only way to ship an install with those already set was to wrap the plugin in a package of your own that initialises them. That is worse than it looks. Plugin identity is loader-derived from the package name, and the settings namespace is that name, so a wrapper moves the namespace: whatever the install had already stored is orphaned, both copies collide on every tool name if both load, and the wrapper has to track upstream for ever. One wrapper per plugin you wanted an opinion about.
The shape
default_settings:
@matatbread/matbot-triggers:
classifierProvider: fast-haiku
@matatbread/matbot-cognition:
innerVoiceProvider: fast-haiku
dream:
maxItems: 5Keys are plugin package names — what plugin list reports, which need not be the specifier you wrote under plugins: (a plugin loaded as ./plugins/triggers is still named @matatbread/matbot-triggers). A key naming no loaded plugin is warned about at boot, because it would otherwise look like it had worked. Values are opaque: matbot does not interpret what a plugin stores. BrowserConfig.defaultSettings is the browser analogue, baked into the bundle.
Nothing in plugin-api changed and no plugin changed. Every consumer already spelled (await settings.get(k)) ?? codeDefault, so the new layer slots in below the store and above the code default — which is how every existing knob became config-defaultable at once, and the test for whether this belonged in matbot at all rather than in a wrapper.
The semantics
One rule: reads are layered, writes are not.
| Why not the alternative | ||
|---|---|---|
| Read only when the store has no value | yes | — |
| Seeded into the store on install | no | Once the document exists, editing the yaml does nothing, for ever, silently |
| Seeded when settings are next written | no | set('A') would freeze B's default: whether a yaml edit takes effect would depend on unrelated write history |
| Merge granularity | per key | A key is the only addressable unit PluginSettings has; deep merge has no bounded semantics |
get returns the stored key if present — in, so a stored null is an override a plugin gets to interpret — else the install's default, else undefined. The compare-and-swap write path reads the stored document only, so set persists exactly the key it was given: a probe reading three defaulted keys leaves data: {} on disk.
delete therefore means revert to the configured default, which is what every existing clear action already meant. Precedence reads store → config → the plugin's own code default.
Both seeding options were also wrong about ownership: they write a per-principal document for what is install-wide configuration, so under storage/profiles a boot-time seed lands under whichever principal booted and no other one ever gets it. A default is config rather than data, so it applies to everyone, cannot be destroyed by a plugin or provider update, and survives a StorageBackend swap.
Also: the config parser no longer returns half a file
Found while sizing the format options, and the more serious of the two changes.
An unparseable construct made the parser break its enclosing loop, which returned what had been read so far and left the rest of the document silently discarded. A stray - in plugins: — the dash alone on its line, a plausible hand-editing artifact — dropped every plugin after it and every top-level section below it, providers: included, with no error. An install that boots and behaves as though half its configuration had never been written. A config parser returning a subset of the file is worse than one that fails: the failure is one message, the subset is a running install.
It now throws, naming the line. Two constructs it could not read are now read:
- A bare
-takes the block indented beneath it as its item value. The branch for that existed but was unreachable — the dash test required a trailing space, so-alone matched nothing and then failed the mapping test too, which is precisely how the truncation arose. - A quoted mapping key is unquoted like any other scalar.
'@scope/pkg':addressed a key literally spelled with its quotes; nothing hit it before because provider names are written bare.
YAML's compact mapping in a sequence entry (- key: value) stays unsupported and is now rejected rather than mis-read. Telling it from a plugin specifier needs the spec's rule that a key separator is a colon followed by space or end-of-line, without which - https://host/p.ts parses as a mapping keyed https — so http specifiers make this permanent rather than incidental. Verified against every yaml in the repo: identical parses, bar the intended key unquoting.
Deliberately not built
- No tool to author a default. The runtime write path is the override — CAS'd, per-principal, notifiable — and for a single-principal install "set the override" is observationally identical. A tool for the floor would be a second way to do one thing, with restart semantics and no CAS.
- No
${NAME}resolution in these values. Secrets are the vault's; resolving atgettime would give the settings facade a Vault dependency it does not have. - No override (top-precedence) layer. Two layers is a lookup; three is a policy engine.
- No separate defaults file — but the seam is open at near-zero cost, because core takes a defaults map, never a path. Which file supplies it is a host detail, so a
matbot.defaults.yamlmerged before injection is a later ~10-line host change. Note thatextends:is not that mechanism today: the CLI chdirs to the base's directory and rewritesconfigPath, so a shared base becomes the project —.data,.envand every yaml write land beside the base rather than the install.
One consequence to know: nothing distinguishes a defaulted read from a stored one, so a *_config get action reports a configured default where it used to say pinned: null. That is the value in effect, which is what the model needs; the wording in those descriptions has yet to catch up.
Versioning
core, plugin-api, cli and web-bundle move together as a changesets fixed group. plugin-api is unchanged in this release and bumped anyway: the boot banner reads any difference between the CLI and the resolved core/plugin-api versions as two physical copies of a host singleton, and about_matbot reports the app's own version.
Published: core, plugin-api, cli, web-bundle at 0.4.10. No plugin packages changed.
v0.4.9: a bash call that ends, and an abort that aborts
What's Changed
- #49 — bash: kill the process group, complete on exit; bound an aborted turn in core by @matAtWork
- A patch release: two filed defects with one shared cause, a third found in the same function, two new bounds, and a core backstop. Harness packages now move in lockstep — see Versioning.
Full Changelog: v0.4.8...v0.4.9
The defect
bash -c forks each pipeline stage as its own process, and plugins/bash got both halves of that wrong.
- #47 — abort and
timeoutsignalled the direct child only, with nodetachedand therefore no process group to signal.find / … | head -5lost its shell to the SIGTERM and leftfindtraversing the filesystem, reparented to init. - #48 — completion hung off
'close', which needs the process to have exited and every stdio stream to have reached EOF. The orphan inherited stdout, so the event stream never terminated.
Individually each is a leak. Together the turn is unrecoverable: the session sits at "working" for ever while every abort reports success, because the abort worked and the tool call is simply unreachable. Measured before fixing — 'exit' at 14ms, 'close' at 5021ms behind a five-second orphan.
A third bug in the same function, unfiled: a signal-killed script gives code === null, which the success arm read as exit code 0, so a timeout kill and an abort both reported a clean run. docker-bash carried it too and is fixed with it.
What changed
- Own process group (POSIX; Windows keeps the direct-child kill), every stop signalling the negative pid, SIGTERM → SIGKILL after a grace.
'exit'is authoritative for completion.'close'still wins when it arrives, since it means the output is complete; otherwise an idle drain window ends the call and says so instderrrather than reading a pipe nothing waits for.- A kill is reported as a kill, naming the reason.
- Two bounds for an unattended host, which has no operator to restart it — both defaults rather than limits.
timeoutdefaults to ten minutes, and combined stdout+stderr to 1000000 bytes via a newmaxOutputBytesparam; either can be raised per call. A bound the caller cannot lift is a ceiling on what the tool can be used for rather than a safety net: the only party who knows whether 400KB is a verbose build or ayesloop is the one that wrote the command. The numbers are generous because the failure directions are not symmetric — overflowing output is output whose process was killed, so too low kills legitimate work, while runaway protection barely notices, since anything genuinely runaway trips either number in well under a second.docker-bashaccepts the same param, overriding itsbash_configsetting for that one command. - core — an aborted turn no longer depends on the tool's cooperation. The runner iterated executors with a bare
for await, so any tool that never returns held the turn open for ever. Once aborted, the read is bounded (30s): stop reading, warn, record the call as interrupted, keeping everytool_usepaired with atool_result. Armed only on abort, so a long tool on a healthy turn is never cut short.bashgot there through inherited file descriptors; a generator awaiting something that never settles does too.
function-tools also ships an unrelated pending change: the lambda guidance now states that wrapping a single tool call you are not reducing is the pathological case, not merely the expensive one.
Versioning
core, plugin-api, cli and web-bundle are now a changesets fixed group and move together; plugins version independently. Both halves of the harness assumed lockstep already — the boot banner reads any difference between the CLI and the resolved core/plugin-api versions as two physical copies of a host singleton, and about_matbot reports the app's own version — so a core-only release would have printed a false "run a clean reinstall" warning on every boot while the version it reported stayed put.
Published: core, plugin-api, cli, web-bundle at 0.4.9; function-tools at 0.4.9; tool-bash and tool-docker-bash at 0.4.9 (0.4.8 was cut first, then superseded within this release by the maxOutputBytes change above).
v0.4.8: media handling (core + plugins) & plugin fixes
What's Changed
- The spine is media. matbot could already let a tool show the model a file; now a person can attach one, and a tool holding a stored file can actually do the handing. One breaking change (
createAboutMatbotTooltakesservices), one new optional service (MediaStore, an alias ofFileStore), one new typed error, and no newMessageContentarm — the push path needed none.
Full Changelog: v0.4.7...v0.4.8
0.4.8 — a person can hand the model something to look at
Full detail is in CHANGELOG.md under 0.4.8; the reasoning lives in CLAUDE.md § Media and the new
docs/MEDIA.md.
Media, in three parts
User-supplied session media. Bytes arrive by value at the submission boundary and are gone from the
message before it is enqueued: open() writes each inline arm through the new MediaStore and replaces it
with a file-ref. What persists is always a reference, with no exception to police — store.set runs at
turn start and at every turn end, so a 5MB image inlined in the document would otherwise be ~6.7MB of
base64 riding two whole-document writes per turn for the rest of the session.
MediaStore is an alias of FileStore, so every existing implementation (filesystem, SQLite, OPFS,
Drive) is a candidate unchanged, and putting media on a different medium is a registration, not a port —
demonstrated live, with sessions on one backend and media on SQLite. UserContent is a deliberately narrow
subset of MessageContent, validated against a whitelist: a wire boundary must not accept a forged
tool-result, thinking block or marker into persisted history.
The pull path got its first producer. model-content was built, tested against a fake tool, and emitted
by nothing — so asked to examine a workspace PNG, a model would reason correctly that it needed the bytes
inline, find no tool that could do it, and fall back to bash, curl and PIL. workspace_action show is now
that producer. read structurally could not have been extended: a tool result is a value in the
transcript, so base64 there is 4/3 of the file persisted and re-sent every round, for something the
model cannot see.
Frontends. The web composer (paperclip, drag, paste, chips, restore-on-refusal, GET /media/<id>, media
inline in the bubble live and on reload) and Telegram (photos, documents, audio, voice, video, caption as
prose). single_turn gains attach?: string[] so a consulted model can be shown a stored file too.
Refusals happen at the boundary, naming the file
The alternative is a provider 400 part-way through a turn the user already believes was sent — and by then
the file-ref is in history, where it resolves into every subsequent outgoing copy and fails the session
for good. So MediaRejectedError carries a reason and the offending file, and nothing is enqueued on a
refusal. Three of the six reasons are about the bytes: bad base64, a magic-byte mismatch, and a type no
endpoint decodes — image/* being the one prefix that cannot be admitted by prefix alone, because a
provider tries to decode that arm where an unknown document or audio type degrades to a text note.
Two limits worth stating: the session total is derived by summing what the store holds rather than
counted, and the per-file cap is the 8MB residency budget rather than a second number. A file larger
than the outgoing-copy window can never be resident, so admitting one would only buy an attachment that is
stored, charged to the quota, and permanently invisible to the model with nothing having said so.
Also in this release
about_matbot reports the system prompt, attributed per plugin. SystemContextRegistry.parts(ctx)
returns each contribution with the name of the plugin that registered it, and build() now derives from it
— one traversal, so the text sent and the breakdown reported cannot drift. This is the breaking change:
createAboutMatbotTool needs the live machine to rebuild the prompt.
session_edit summarise — compact by meaning rather than by shape, rewriting a prefix as a two-part
hand-off document via one singleTurn. background at — run a prompt once, at a stated time, persisted
and cancellable like any schedule. ComposedCallContext.progress() — a tool_function body is a plain
async function and cannot yield, which left the progress event unreachable from the one place a long run
actually happens.
Notable fixes
- A mid-turn steer could interrupt a turn the user never saw. The decision tested
s.running, which is
true across the pump's whole queue — so it answers "something is running", never "still the one you
meant". The target turn'straceIdis now captured when the submission arrives and compared before
aborting. - Prior reasoning is replayed on a field, not as prose — it was leaking into visible answers.
parts()could pair a contribution with the wrong plugin, and drop one, corrupting the prompt
actually sent rather than merely the report.- The web client's media-URL cache never released a byte — and in the browser bundle those values are
blob:URLs, kept alive by being registered rather than referenced. It is now a byte-bounded LRU in the
transport that mints them. showrefused audio, which it advertises, becauseMIME_MAPhad no audio extension at all.background'ssleepleaked an abort listener per call, multiplied by the new chunked long-wait.- An attachment-only first turn gets a title — auto-titling read text blocks only.
Upgrading
createAboutMatbotTool(version) → createAboutMatbotTool(version, services). Nothing else in the public
surface changed incompatibly; MediaStore is optional and both hosts seed their own file area as the boot
default, so attachments work with no configuration. A deployment with no media store is completely
unaffected until someone attaches something, and is then told why rather than having it silently dropped.
v0.4.7: a stream that can end without telling either end
What's Changed
- #45 — 0.4.7: the web frontend's event stream survives its own connection dropping by @matAtWork
- A patch release. One defect with two faces, three follow-ons found while proving it, and a new doc for anyone writing their own UI. No plugin-API changes; one new frontend-web route and one new
WebServerDepsfield.
Full Changelog: v0.4.6...v0.4.7
0.4.7 — a stream that can end without telling either end
Two symptoms, reported separately, with one cause. The skills compiler stopped to ask for install
confirmation and the question never appeared, so the turn parked until it was cancelled. And separately,
some turns "never completed" — but had completed, and showed as complete the moment the page was refreshed.
The cause
ctx.prompt() in frontend-web was fire-and-forget over the per-session SSE stream, with no replay, no reach
check and no timeout. sendToSession returns silently when a session has no attached — or no live —
connection, so a prompt raised while no browser was on that conversation's stream went nowhere and the turn
blocked forever. Reproduced with no viewer attached: the turn parks, and POST /prompt answers 200 for a
question nobody could have seen.
Read the other way round, the same gap is the stuck turn. A stream replays the running turn and says
nothing about one that began and ended while it was gone, so the loading dots stayed up until committed
history was re-read by a refresh.
Underneath both: nothing was written to a quiet stream between turns, so neither end could tell quiet from
dead. The server kept a zombie connection in its viewer set and went on reporting successful writes into
it — which is how a prompt was lost — while the client's reader.read() stayed pending with no error, so
the reconnect loop it already had never ran at all. A long tool call is minutes of silence, and that is the
window a socket dies in.
Not a regression. The initial hypothesis was a deadlock in 0.4.5's machineBusy/quiescent-edge work, and
that was ruled out by test rather than by reading: a nested invokeTool → ctx.prompt is delivered and
answered over the real createWebServer while the pump holds the machine. Every wait in
context-switch.ts is bounded, and quiesced() is called by nothing but tests. git log -L on the
prompt-parking code stops well before 0.4.5. What made the skills compiler the place it showed is that its
install confirmation fires at the end of the longest, quietest tool call in the system.
The fix
A prompt is state, not an event. It stays true until answered, so it is kept and re-sent to every stream
that connects while it is outstanding — covering the absent viewer, the reloading tab and the zombie alike.
Both SSE endpoints heartbeat, and the client bounds how long it will sit in silence. This is the
enabling half: the beat is what lets the server reap a dead socket and the client notice one. Reconnect was
never the missing piece — detection was, and that is protocol-independent. It is why the WebSocket spec has
ping/pong frames, and browsers do not expose those to JS either.
A reconnect is announced rather than assumed continuous. The transport yields a synthetic
stream-resumed, and the UI re-reads committed history for any turn it still shows as running.
Becoming visible revives a stream that has gone quiet — visibilitychange and pageshow both, the
latter for the back/forward cache, which Safari leans on and where the page returns with its scripts
un-rerun and its streams gone. Deliberately not disconnect-on-hide: a hidden tab usually keeps its
connections, so forcing the gap would make the recovery re-read certain rather than rare.
A viewer going away is no longer treated as an answer. The old "no viewers left" release resolved the
prompt with '', which the prompt implementation turns into the field's default — an answer nobody gave,
to a question nobody saw. Harmless-looking on a confirm, where it declines, and destructive on
plugin store-key, whose default is '' and where a blank value removes the key. Abort and shutdown
cancel instead, which a tool already reports as an error.
The in-process build had the same prompt hole, with no socket in it. browser.js injected a prompt in
one pass over whatever streams were draining, and a session the user is not looking at has none — the same
bug in a build with no network, which is the clearest evidence that this half of the problem was never about
transport.
Found while proving it
-
The
/toolsboot grace expired on a clock, not an event. The endpoint holds a name that has not
registered yet, because the server starts listening insidesetup()— but the wait ended 30s after server
construction regardless of what the registry was doing. So a name that would never register (the UI
askingprofile_actionwhether a profiles backend exists) parked for the whole window before 404ing, long
after loading had finished. Nothing was slow at boot; the wait was for a deadline. It now ends when the
tool registry goes quiet, re-armed so a slow boot keeps its grace, with the 30s ceiling retained. -
A control built from a tool call now tracks the tool registry, in both directions. A plugin's
setup()may itself callloadPlugin()(google-drive, per-user bootstrap plugins), so a capability can
register arbitrarily late and beat any deadline; and a plugin can be unloaded from this UI's own panel, so
one can leave while the page is up. The old one-way latch got both wrong — a panel that never appeared
however long you waited, or sharing controls still offering operations that now 404. A 404 from/tools
means "not registered when you asked", never "absent". -
GET /ui-configserves the values the server and its UI must agree on —heartbeatMstoday — so that
agreement is data rather than a comment in each half stating what it assumes about the other. Deliberately
narrow, and deliberately not a feature-flag channel: whether a capability exists is answered by the tool
registry, which changes while the page is up. -
Smaller: the UI no longer serialises its whole bootstrap behind an optional-capability probe; a stale
"plugin not loaded" banner clears when its plugin arrives; and an abandoned read race no longer leaves a
65-second deadline pending after the stream it guarded has gone.
docs/SSE-CLIENTS.md
New, and the reason it exists is that anything embedding matbot with its own UI has to reimplement all of
the above from the outside. It is about the streams rather than the endpoints: what they guarantee, what
they do not, and the four mistakes that are invisible in testing and permanent in production — an
unanswerable prompt, a fabricated default, awaiting a prompt dialog inside the event loop (a deadlock, since
prompt-resolved arrives on the stream you stopped reading), and a turn that finished while nobody was
listening.
It is written against the transition rather than the browser event, because a soft-tabbed shell toggling
panels with display: none gets no lifecycle event at all when a conversation is hidden. The rules attach
to whatever that UI's own foreground/background signal is — a tab click, a route change — with
visibilitychange/pageshow as two sources among several. It also covers what a background transition
must not do, the ~6-socket-per-host budget, and two cases whose answers differ from the bundled UI's: a
soft-tabbed shell, and the serverless in-process build, where turn durability is the other way round — the
provider request is made from the page, so losing the network interrupts the turn itself and there is no
completed work to re-read.
A final section is for an embedder who owns the server half too, where five of these rules stop being ones
to obey and become ones to provide, since a client cannot work around their absence.
Upgrading
Nothing to do, and nothing to migrate. No plugin-API surface changed, no stored data is affected.
Two additions to frontend-web's own surface: GET /ui-config, and WebServerDeps.heartbeatMs (default 20s;
safe to change, since the client reads the interval rather than assuming it). Lower it behind an
intermediary that idles connections out aggressively.
If you have written a UI against the web frontend's HTTP+SSE API, read docs/SSE-CLIENTS.md — the server
half of these fixes is in this release, but the client half is yours.
Known gap
There is still no stream cursor: the server emits no id: lines and does not honour Last-Event-ID, so a
reconnect recovers by re-reading committed history rather than resuming. You get the finished answer, not
the tokens replaying. That is the fix if a re-render ever proves too expensive — most likely for a
soft-tabbed UI that opens and closes streams per conversation, where recovery is the common path rather than
the exception.
v0.4.6: the derived tool dts compiles, and a test says so
What's Changed
- #44 — 0.4.6: the derived tool dts compiles, and a test says so by @matAtWork
- A patch release. One fix, one gate; no API changes and nothing to migrate.
Full Changelog: v0.4.5...v0.4.6
0.4.6 — the generated types stop lying quietly
Reported downstream against v0.4.5, and it reproduced with this repo's own matbot.yaml: the derived tool
.d.ts did not compile.
buildMatbotToolsDts bundles every referenced workspace type into one flat scope, but it keyed that bundle
by declaration identity — file plus position — rather than by name. Two plugins each declaring a
file-local type of the same name therefore emitted both, side by side, and the artefact failed with
TS2300: Duplicate identifier.
The plugin sources were correct. plugins/background and plugins/edit-session each declare a local
SkipKind, which is legal TypeScript and here a deliberate decision that background records in a comment:
"two plugins agreeing on three words is not yet an abstraction worth a package." Renaming either one would
not have been a fix, only a deferral of the next collision. The generator was what could not represent them.
The damage was not the failed compile — it was that nothing compiled it. Both references resolved to an
error type, and an error type is assignable to anything. function-tools and the skills compiler are
graded against this dts, so generated code branching on kind was checked against an error type and the
diagnostic dropped. Both plugins' comments tell callers to "branch on kind, never on the prose" — exactly
the affordance that was silently gone. And it was never SkipKind-specific: any two plugins picking the
same local type name collided the same way, always quietly.
Why every gate was green
pnpm typecheckcompiles each plugin separately, so the two locals never meet.pnpm check:contractsverifies contracts against theirinputSchemas and never compiles the dts.- The checker compiles snippets with the dts as an ambient prefix and then drops every diagnostic inside
it. That is deliberate and still correct — a broken prefix is our bug, not the snippet's — and it is
precisely why our bug was inaudible.
Nothing in the repo compiled the artefact the generator produces. That is the real finding, and the reason
this arrived from a downstream consumer rather than from CI.
The fix
Alpha-rename on collision: the first symbol to claim a name keeps it, later ones become SkipKind$1, and
every reference is rewritten to match. Two details matter more than the rename itself:
- Keyed by symbol, never by declaration. A merged interface is several declarations of one symbol, and
renaming those apart would break the merge the source relies on. - Names already taken by a plugin-api import are reserved up front, because a bundled local named
Sessionbesideimport type { Session }is the same collision by another route, and which api types end
up imported is not known until the walk finishes.
Rewriting each reference also settles the local spelling of an import { X as Y }, which previously emitted
a name the bundle never declared.
Two alternatives were considered and rejected. Inlining the literal instead of renaming, on the grounds
that one of the pair is not exported — but edit-session's SkipKind is exported and nothing imports it;
export there means "visible to a sibling file", so exportedness does not identify which name is real.
Erroring early, matching the ContractConflict precedent — but that precedent covers a genuine source bug
where a human must pick a winner, whereas this is a legal construct the generator simply owed a
representation. Erroring would have taxed plugin authors with global uniqueness for file-local names.
The gate
A test now compiles the emitted dts as source, with no prefix filtering. One assertion, and it is what
separates "the contracts resolve" from "the contracts appear to resolve".
A fixture pair covers the collision directly, asserting each type keeps its own narrowing in both
directions — a duplicate identifier resolves to an error type assignable to anything, so only the negative
case fails without the fix. The fixtures are written to a temp directory symlinked to plugin-api, because
a declare module augmentation only merges if its specifier resolves from the declaring file, and under
pnpm's isolated layout it does not resolve from anywhere under apps/cli. A file where it fails declares a
fresh ambient module and silently contributes nothing — so the symlink is what makes the test test anything.
Both new tests fail against the pre-fix generator.
Known gap
A bundled type named after a TS lib global (Record, Event) shadows rather than duplicates. No
TS2300, so the new gate will not catch it. Zero occurrences today; not addressed in this release.
Upgrading
Nothing to do. No API surface changed, no config or stored data is affected, and the web bundle's only diff
is its version stamp. Consumers generating code against the tool dts — function-tools, compiled skills —
get narrowing back on any contract that referenced a collided type.