Adds token limits under launch.limits.tokens (instances, providers, keys, models.P.M). Core (packages/bus/src/limits.mjs) reads the organization file and the per-turn usage records row 124 writes, then asks every switched-on limit-check plug-in; the first refusal wins. A limit no switched-on check reports reads "not enforced". The limit-check kind is interface 2 (mayLaunch, mustStop, report); a v1 check is refused at load. The shipped token-limit check is pure and counts known end records only; unknown turns add nothing and are shown as their own count. The launcher refuses limit-reached through the host's mayLaunch op, or limit-check-failed naming the check. The broker asks mustStop after a usage end and stops the run at its next receive or state. Settings stamps since on a new or changed limit and runs reset; launch.mjs and business.mjs validate the shape. Readers: mosaic limits and mosaic limits reset, the bus limits view, the Console /api/bus/limits route and the Settings token table with Reset. Key limits stay "not enforced" until row 126 names accounts on records. Owner Dewey, design under lead decision 150. Reviewer Filbert: r1 changes (comment 28965), r2 approve (28981), r3 approve (28996) on candidate manifest 8b71c405...fe56 after the rebase onto row 164 (37 of 38 paths with identical +/- lines, TOOLS.md:621 bracket). Decision 123 go on the bus.js hunk from Mos (mos-0814). Sage gate onb6827d19e: 25 steps, all rc 0 (15 packages, 10 script suites), 2295 node tests and 422 script selftests, 0 failed, 20:03:25Z to 20:08:08Z, network sage-r125_default removed. Live stays as pinned. Not part of the8e7ea89a1window. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Plug-ins
The plug-in layer (PRD REQ-PLUG-1, build step 6b, Gitea #1626). Every part the core calls out to is a plug-in kind. A module of a kind is a directory in this package, and the organization file says which modules are switched on. No core module imports a plug-in module by name; calls go through the loader.
| Path | What it is |
|---|---|
src/kinds.mjs |
the kind registry and the check for the organization file's plugins key |
src/loader.mjs |
loads the switched-on modules from this tree |
<kind>/<name>/index.mjs |
one module of a kind |
Kinds
| Kind | Interface | Switch | Exports |
|---|---|---|---|
usage-source |
1 | list | interfaceVersion, readTurn |
limit-check |
2 | list | interfaceVersion, mayLaunch, mustStop, report |
search-source |
1 | one | interfaceVersion, search |
channel |
1 | list | interfaceVersion, dispatch |
Every module exports interfaceVersion, equal to its kind's interface
version, plus the kind's functions. PRD REQ-LIMIT-4 says what the two kinds
do: a usage source reads what a session used after each turn, and a limit
check answers whether a launch may start (mayLaunch), whether a running
session must stop at the end of its turn (mustStop), and what each limit
has counted (report). Row 124 set the usage source's arguments and ships
the transcript source. Row 125 set the limit check's at interface 2 (lead
decision 150) and ships token-limit; a module built for interface 1 is
refused with interface-version.
A search-source answers the broker's web.search (REQ-HARN-2, REQ-PLUG-2, decision 0b81b0c5): search(settings, query, deps, { signal }) resolves { query, total, results: [{ title, url, snippet }] } or throws a WebRefusal; signal is an AbortSignal the broker fires at its deadline, and a module must stop its request when it does. The kind switches on one module, and the module's name is the source: searxng-url queries the operator's SearXNG at the business var web.searxng; searxng-bundled queries the SearXNG the Stack installs, at a fixed loopback address (the install is REQ-INST-1's first component); harness-builtin is served by the harness itself: a granted claude-code role gets WebSearch and WebFetch, the broker serves no web verbs, and search only refuses. The choice and every change to it are the plugins setting, logged in changes.jsonl by the plug-in setter.
Adding a kind, or changing a kind's exports, interface version or switch,
is a core change: a reviewed edit to src/kinds.mjs, with the code that
calls the kind.
Adding a module
- Pick a name matching
^[a-z][a-z0-9-]*$. - Create
packages/plugins/<kind>/<name>/index.mjs. A real directory and a real file: the loader refuses a symlink anywhere frompackages/pluginsdown,index.mjsincluded. - Export
interfaceVersionand every function the kind lists. - Land it like any other change. A running stack sees it only after a
re-pin, because
scripts/stack-copy.shcopies the wholepackages/pluginsdirectory into the pinned copy. - Switch it on in the organization file's
pluginsobject, under its kind:mosaic pluginsshows the setting and its revision, andmosaic plugins set --file PATCH({ "revision": ..., "plugins": {...} }) changes it, one kind at a time: the patch names the kinds it changes, a kind withnullis removed, and a kind it doesn't name keeps its setting. The change goes through the broker's human-onlypluginsandplugins.setverbs. The setter refuses a bad shape, a name with no module in this tree (unknown-plugin), an unknown kind, an empty patch and a stale revision, and logs actor and old and new names inchanges.jsonl. A running broker reads the setting at bus start (REQ-WEB-3's reload will change that), so restart the bus after a switch.
Adding a module changes no core file.
The organization file's plugins key
One key per kind. A list kind takes a list of names; a kind that switches on one module takes one name. An absent key switches the kind off.
"plugins": { "usage-source": ["meter"], "limit-check": [] }
validateBusinessDocument checks the shape: known kinds, the name pattern,
no name twice. It doesn't check that a module exists; the loader does that
when it loads. The setter writes through the organization writer, which
logs actor and old and new names in changes.jsonl like a limit change. A
refused write throws OrganizationError (invalid-plugins or
invalid-request) and logs nothing.
The loader
import { createLoader } from "../packages/plugins/src/loader.mjs";
const loader = createLoader({ log: entry => record(entry) });
const { loaded, refused } = await loader.load(business.plugins);
loader.modules("limit-check"); // the switched-on modules, in order
loader.loaded(); // { kind: [names actually switched on] }
The loader finds this package from its own location and takes no path from config. Until Jason rules on Vikunja #286 it loads modules from this tree only (lead decision 143). For each name it checks, in order:
- the name matches
^[a-z][a-z0-9-]*$(bad-name); packages/plugins,<kind>,<name>andindex.mjsexist, are a directory or a regular file as expected, and are not symlinks (missing,symlink); ancestors ofpackages/pluginsare not checked;- just before import,
realpath(index.mjs)is still the expected path (symlink); - the module imports (
load-failed); - it has every export, and the kind's functions are functions
(
missing-export); interfaceVersionequals the kind's (interface-version).
At steps 2 and 3, a path that can't be read for another reason, such as a
permission error, refuses as unreadable.
A refusal refuses the whole kind for that load: the kind keeps the modules
it had, and the loader calls log with {event: "plugin-refused", kind, name, code, message, kept}. A kind refused on its first load has nothing
switched on. An unknown kind refuses only itself; a plugins value that
isn't an object refuses every kind. Loads run one at a time.
Limits of the checks:
- A module runs with the host's authority once imported. The checks keep the loader inside this tree; they don't sandbox the module.
- A module's top-level code runs at import, before the export and
interface-version checks. A module refused for
missing-exportorinterface-versionhas still run, and Node's cache keeps it. - Someone who can write this tree can swap a file between the realpath check and the import. The checks catch mistakes and stale links, not a writer racing the host as the same user.
- Node caches a module by URL, so a changed
index.mjsis not re-read until the process restarts. - The bus host wires the loader: row 124 for usage sources, row 125 for limit checks.
Transcript usage source (row 124)
usage-source/transcript now ships interface 1's readTurn({harness, root}).
The bus brackets each actual harness turn, reads all sources through the loader,
and appends baselines and outcomes. See that module's README for JSONL formats,
security/resource bounds and the best-reading limitation. Enable transcript
through the human plug-in settings writer; a restart/re-pin remains a
separate authorized window.
Limit checks (row 125)
Interface 2. Core calls each switched-on check through the loader only, in
the order the plugins setting lists them, with a copy of its arguments:
mayLaunch({business, limits, subject, records, now}) → {allow: true} | {allow: false, hit}
mustStop({business, limits, subject, records, now}) → {stop: false} | {stop: true, hit}
report({business, limits, records, now}) →
[{path, limit, since, count, kinds, unknownTurns} | {path, limit, since, notEnforced}]
limitsis the business'slaunch.limits.subjectis{instance, provider, model, account}.recordsare the per-turn usage records the usage sources wrote (usage.mjsturnRecords), eachknownwith its four kinds orunknown.nowis an ISO time.hitis{path, limit, count, unknownTurns}. Its path must name a limit inlimitswith that limit. A count is a safe integer or a digit string.- The launcher asks
mayLaunchbefore every launch, through the bus (mayLaunchlaunch op). A refusal islimit-reachedwith the hit as its message, orlimit-check-failed. The bus asksmustStopafter each turn's usage end. A stop lets the turn's result and reads land, then revokes the role claim (or releases the thread claim) with bylimit-checkand a reasonlimit: <check> <path> limit <n> count <c>;session.endedand the launcher's end record carry that reason. reportfeeds Settings andmosaic limits. Core composes one entry per limit:counted,not-enforced(no check reported a count; a check'snotEnforcedtext is the reason) orcheck-failed.
Fail closed (lead decision 150): a check that throws, answers in the wrong
shape, answers with a promise, or is switched on but not loaded refuses the
launch, stops the session at the end of its turn, and shows every limit as
check failed: <code>. The code is the check's own thrown code when it
matches the plug-in code shape, else check-threw or malformed-answer.
Both refusals and stops are logged with the check and the code or hit.
token-limit
limit-check/token-limit enforces launch.limits.tokens:
"tokens": {
"instances": {"coder": {"limit": 5000000, "since": "…"}},
"providers": {"anthropic": {"limit": 50000000, "since": "…"}},
"models": {"anthropic": {"claude-opus-5-5": {"limit": 20000000, "since": "…"}}},
"keys": {"team-key": {"limit": 10000000, "since": "…"}}
}
A count is the sum of all four kinds over the subject's known records since
since; unknown turns add nothing and are counted apart. The limits writer
sets since when a limit is added; a changed limit keeps it, a caller's
since that differs is refused, and only a reset update (a list of paths,
mosaic limits reset tokens providers anthropic, Settings, or the chief's
limits.set) moves it to now. There is no ceiling (Q150). A key matches a
record's account; while no record names one, a key limit reports as not
enforced. See limit-check/token-limit/README.md.
Human project/thread channels
channel modules carry project/thread operations between a human surface and
its existing core bus caller. The shipped cli and webui modules implement
dispatch({ verb, args }, { call }), returning the bus result or refusal
unchanged. Only the fixed project/thread operations are supported; CLI also
uses message.receive/read for thread talk/follow. Modules do not create a human
proof, choose a business/owner, spawn the shim or write organization settings.
The broker still sees the proved transport as via: 'cli' for both surfaces.
Discord is not connected and no Discord module ships here.
The setting is "plugins": { "channel": ["cli", "webui"] }. An absent key or
an empty list disables both converted chat surfaces. No silent default enables
them. CLI off refuses with exit 3; web chat off returns 503, both naming recovery.
Other CLI administration and Console views are independent. CLI project
init/check and non-thread talk/inbox are core operations, outside this conversion.
Each chat operation reads the existing plugins setting, loads only its channel
selection through the in-tree loader, captures a frozen snapshot('channel')
array of { name, module }, then dispatches without a lock. A slow send does
not block a list. A captured array never changes after a reload; loaded() and
modules() keep their existing interfaces. Invalid module settings retain the
loader's last good selection; the first refused load has nothing enabled.
The recovery commands never need a channel: from a human shell, run
mosaic plugins --business BUSINESS --json, make a patch with its revision and
{"channel":["cli","webui"]} as the plugins value, then run
mosaic plugins set --business BUSINESS --file PATCH. This uses the existing
proved human transport and organization writer, with revision checks and actor
receipts. The next chat operation reads the restored selection. Disabling a
channel cannot disable this write path. It is not a repair bypass for an invalid
organization file or unavailable bus.
For the re-pin introducing this kind, Jason enables both channels through plugins.set immediately after activate in Mos's window, then smokes CLI thread listing and Console chat. No development seat performs that live step.