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, and lists what is installed |
src/theme.mjs |
checks a theme module's answer and lists the installed, valid themes |
<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 |
model-provider |
1 | list | interfaceVersion, catalogue, preflight, route |
harness |
1 | list | interfaceVersion, adapter, levels, preflight, version, countsAccount, mounts, builtinWeb, builtins, typedName, reliesOn, gateTimeout, bundle, env, turnEnd, outputPolicy, transcript |
theme |
1 | list (see Themes: found by listing, not switched on) | interfaceVersion, theme |
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.
model-provider modules return bounded plain data. catalogue receives the
host's narrow account/model/group reads and returns a status, observation time,
models and notices; each model declares its module, upstream, accounting
provider and model, availability, label and compatible harnesses. preflight
checks a selection against that fresh catalogue. route maps a selection,
including supported legacy entries, to the same accounting identity and a
transport descriptor (routeId, endpoint, protocol api, piProvider, and
optional compat). Compatibility is null or a bounded object with at most 16
identifier keys: thinkingFormat holds a bounded identifier and other fields
hold booleans. The harness receives it as data. Modules return named refusals for unsupported selections;
they never return credentials, commands or endpoints. The host rejects an
unknown or ambiguous legacy mapping and validates transport data before use.
Organization presets
A harness or model-provider module may export orgPresets(), returning an
array of preset descriptions. Each has a globally unique stable name, a
label, a one-line description, thinking: { levels, default }, an
available({ env }) function returning { ok: true } or { ok: false, reason }, and overlay({ thinking }) returning { plugins, launch, assistantVars, vars? }. The loader discovers installed modules; the
organization service rejects duplicate names, invalid metadata, unknown
presets and unsupported thinking levels. Availability reasons are displayed
in the Console and must never contain credential values or private paths.
The fixture provider supplies the legacy --fixture local alias; the
Claude Code harness checks its preflight using the Console's environment.
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'spluginsandplugins.setverbs, the human's and, since row 175 (#1673), the tester's (--tester, actortester). 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, row 65 for harness levels. The launcher loads a launch's harness itself.
Listing what is installed (stream S4)
load(plugins) loads the names a setting gives it. Two functions added for
the theme kind find modules without any name list, and leave createLoader
and loadModule as they were:
import { discoverModules, loadInstalled } from "../packages/plugins/src/loader.mjs";
discoverModules("theme"); // { names: [...], invalid: [...] }, both sorted
await loadInstalled("theme", { barrier }); // { modules: [{ name, module }], refused: [entry] }
discoverModules reads the directory packages/plugins/<kind>/. A directory
or symlink whose name matches ^[a-z][a-z0-9-]*$ is a candidate name; any
other name is invalid; a plain file is ignored; a missing kind directory is
an empty list; a symlinked kind directory is refused. loadInstalled runs
loadModule (every check above, unchanged) on each candidate and returns the
ones that load. A refusal, including each invalid name (bad-name), becomes
a {event: "plugin-refused", kind, name, code, message} entry in refused,
and one refusal never hides the others. It changes no createLoader state, so
it has no "kept" modules: every call lists the tree as it is, subject to the
Node import cache above (an edited module is re-read after a restart; a
removed or added directory is seen at once).
Themes (stream S4)
A theme module is data. The Console's colours, which docs/design/tokens.json
defines as ten palettes, are ten modules under packages/plugins/theme/<name>/
(harbor, carmine, atlantic, terracotta, aubergine, mineral,
cobalt, rosewood, graphite, grove). A module:
export const interfaceVersion = 1;
export function theme() {
return { displayName: "Harbor", tokens: { light: { "--canvas": "#f6f8fa", /* … */ }, dim: { … }, dark: { … } } };
}
theme() takes nothing and returns exactly displayName and tokens; tokens
has exactly light, dim and dark; each mode has exactly the fourteen
tokens src/theme.mjs lists as TOKEN_NAMES (--canvas, --surface,
--raised, --line, --border, --text, --muted, --action,
--onAction, --accent, --focus, --success, --warning, --danger),
each a #rrggbb string. checkTheme refuses everything else: an extra or
missing key, a token that isn't one of the fourteen, a colour that isn't six-digit
hex (so no url(), var(), expression(), named colour or CSS text), a
display name outside 1 to 40 letters, digits, spaces and . _ ' ( ) & -, a
getter, a symbol key, a class instance, a promise. It also holds a contrast
floor on the pairs the Console reads text and controls on (CONTRAST: 4.5:1
for text, link and status colours on the canvas and surface, 3:1 for the focus
ring and control border), so an installed theme can't make the Console
unreadable. All ten shipped palettes clear it.
A refused module is never listed and never rendered: installedThemes()
returns { themes: [{ id, displayName, tokens }], refused } and logs each
refusal through its log argument. The Console serves the list at
GET /api/themes (packages/webui/src/themes-http.mjs), which lists the
directory on every request and logs a given refusal once per process.
Themes are found by listing, not switched on. The organization file picks one
with appearance.theme (a name; see packages/business/src/appearance.mjs),
never with plugins. The kind is in the registry, so the plugins key
accepts a theme entry without refusing it, but nothing reads that entry:
list or empty, every installed theme stays available.
To add a theme: create packages/plugins/theme/<name>/index.mjs as above, run
node --test packages/plugins/tests/theme.test.mjs, land it, and re-pin
(scripts/stack-copy.sh copies this directory). To remove one, delete its
directory. An organization whose appearance.theme names a theme that is no
longer installed keeps the saved name and shows the built-in default with a
visible warning until the module is back.
Harness (row 65)
A managed session's harness is a module under harness/: pi and
claude-code ship. Core reaches a harness only through the loader, so the
launcher, the bundle, the runner and the usage reader name no harness. The
host calls the module and writes its answers into session.json as data;
the sandboxed runner never imports it. src/kinds.mjs lists what each
export returns:
adapter()is the absolute path of the adapter script the runner runs.levels()lists the thinking levels the harness applies: pi takesoff,minimal,low,medium,high,xhighandmaxas--thinking, Claude Code takeslow,medium,high,xhighandmaxas--effort.preflight({env})returnsnullor{code, message}: pi checks its binary innode_modules(harness-binary-missing), Claude Code its session credential (no-session-credential) andMOSAIC_CLAUDE_BIN.version({env})is what the manifest records asharnessVersion.countsAccount()says whether its launches spendlaunch.accountand get the row 126 account checks.mounts,builtinWeb,builtins,typedName,reliesOn,gateTimeout,bundle,env,turnEnd,outputPolicyandtranscriptgive its sandbox binds, web, tool names, S0 lines, bundle files, adapter variables, how a turn ends and where its transcripts land.compactions({store, cursors, budget, streams, first})reads a thread session's native store for row 270's compaction count (row 307). Core keeps the ledger (packages/seat/src/compactions.mjs) and passesstoreas{session, projects}, the ledger'scursorsby stream, the readbudgetand stream capstreamsfrom the system config, andfirstwhen the store has no ledger yet. The answer is{found, cursors, known, reason, version}:foundlists{entry, completedAt, stream}past the cursors,cursorsmaps each stream read to{bytes, tail}(tailis the sha256 of up to 4096 bytes ending there),reasonis null,store-incomplete,store-unreadable,not-collectedorharness-unsupported,knownis true exactly whenreasonis null, andversionnames the store format read, or null. Core refuses any other answer and writes nothing. pi reads its streams within the budget and the cap, and answersharness-unsupportedon a first collection that finds Claude history. Claude Code answersharness-unsupportedwith version null (Vikunja 466).- Row 66: both modules wire the unslop check from
tools/unslop-hook.mountsadds its three files read-only andbundlenames them in the manifest's sources. Pi'senvloadsextension.tsas the second entry ofMOSAIC_EXTENSIONS; Claude Code'sbundleadds Stop and UserPromptSubmit hooks that runclaude-hook.jsand never block.
plugins.harness switches harnesses on, and an instance's harness and
fallback.harness vars each name one. A launch on a harness that isn't
switched on refuses harness-off, primary or fallback, and an off primary
never falls back. The launcher then loads the route's one module through a
fresh loader, so an earlier load's module is never reused. A module the
loader refuses fails the launch with harness-refused, naming the module
and the loader's code. The switch gates launches and writes, not loads: the
bus host loads every harness module in the tree to read its thinking levels,
on or off. The broker binds a new run only on a switched-on harness; a run
an earlier broker bound binds again.
Writes check it too. routes.set, instances.set and limits.set refuse a
changed route whose harness is off (harness-off), whose module didn't load
(harness-refused), or whose module doesn't apply its thinking level
(thinking-unsupported). A route the write doesn't change isn't checked,
so a model change still saves while plugins.harness is unset.
A new harness is a new module directory and a plugins set patch: no core
file changes. tests/harness.test.mjs launches a fixture module that way,
and greps every packages/*/src outside this package for a quoted harness
name. The exceptions are the tool gates (harness/src/gate.mjs,
claude-gate.mjs, pi-extension.mjs), the human-proof check
(bus/src/human.mjs), the engines that start pi themselves
(conversation/src/, discord/src/) and the process scan
(control-board/src/scan.mjs). The test fails if an exception stops naming
a harness, so the list stays true.
Switching harnesses on is a mosaic plugins set patch. The list replaces
the kind's whole setting, so it names every harness that stays on:
{ "revision": "<from mosaic plugins>", "plugins": { "harness": ["pi", "claude-code"] } }
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, lineages?}) → {allow: true} | {allow: false, hit}
mustStop({business, limits, subject, records, now, lineages?}) → {stop: false} | {stop: true, hit}
report({business, limits, records, now, lineages?}) →
[{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.- Row 333: while
limits.tokens.keysholds a daily model-key quota,subjectalso carriesmodelKeyIdand core passeslineages, one array of key ids per hired agent with a model key (current, retiring, then earlier ids). Core refuses before asking any check when the subject has no model key (key-quota-no-model-key) or its key or the lineages can't be resolved (proxy-key-refused). 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.
daily-quota
limit-check/daily-quota (row 126) enforces launch.limits.tokens.daily,
one quota per account, each with the reset time of day and the IANA
timeZone its day starts in:
"daily": {"jason-max": {"limit": 40000000, "since": "…", "reset": "05:00", "timeZone": "America/Chicago"}}
A count is the sum of all four kinds over the account's known records since
the later of the day's start and since. Only a Claude Code launch carries
an account; a pi launch's is null, so no quota covers it. The count is this
stack's own use of the account, not its use anywhere else, and mosaic limits and Settings say so. See limit-check/daily-quota/README.md.
Row 333 adds daily quotas on model keys, launch.limits.tokens.keys.<12-hex key id> with the same shape. A key quota follows its agent's key lineage across
rotation and re-issue, matching and counting every id in it. After a fire and
re-hire the old id is in no lineage: the quota limits nothing and the Console
usage view lists it as matching no agent. The limits writer gives a new key
quota the earliest since among the old file's key quotas in its key's lineage
(row 333 R4), so moving a quota to the agent's new key id keeps its count; a
reset restarts it, and a quota removed and added back in a later write is new
and gets now. See the usage module contract.
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.
Per-session proxy context (P3)
The launcher's injected proxy is either a { baseUrl, key } context supplied by
P2, or a stand-in factory receiving { business, instance }. With system.proxy
enabled, the real launch uses only the trusted host's modelPreflight result;
the stand-in option cannot override it. Metadata is checked before acquisition,
then the single admitted context reaches final harness preflight, bundle and
spawn. Its key id is retained for P2 ownership, binding and stall monitoring.
P3 does not issue or revoke keys. No context means existing nonproxy routing
and environment behavior; a selected proxy model without context refuses.
The harness preflight, bundle and env calls receive an optional proxy
argument; bundle and env also receive the declared model table. In proxy
mode the launcher removes instance provider variables, then separates the
harness's credential variables from harnessEnv before writing session.json.
Only the spawned runner's environment receives those variables.
A proxy launch.models entry requires route: anthropic|glm|codex. Omission
refuses proxy-route-missing; unknown routes refuse proxy-route-unknown. Other providers
retain provider/model-only entries. launch.account.credential: proxy names the
proxy credential source. Pi needs its own provider key or a supplied proxy key;
Claude OAuth alone cannot satisfy pi preflight.
Inside an actual proxy context, existing provider anthropic selects messages
and zai selects GLM completions. This uses provider identity only, preserving
the declared provider for accounting. Unsupported provider identities refuse
proxy-provider-unsupported before launch rather than trying a fallback or an
upstream credential. A proxy entry still needs its explicit route.
Pi writes private, fresh run-local home/.pi/agent/models.json, with
$ANTHROPIC_API_KEY references. Anthropic uses messages at the proxy root; GLM
uses completions at /v1 with z.ai compatibility fields. GPT uses the stack-owned
mosaic-proxy-codex provider, never pi's Codex OAuth provider. Its build-time API
value is openai-responses per Sage's sage-4331 ruling: both shapes passed the
pinned offline probe, so this is a reasoning-based tie-break rather than an
evidence-decided pick. Row209 phase A step7 verifies the first real Codex turn.
There is no runtime API selection or fallback.
Proxy usage telemetry (row333 / P5)
The optional usage kind is interface 1, a single module with
read({ readUsageQueue }); the bus hands it that one function and nothing else.
cliproxy-queue is off by default and enabled through human plugins.set.
It supplies proxy-labelled per-account numbers alongside the native per-key
counts; it has no quota authority. See
the module contract for poll settings, ID mapping,
record separation, bounded reads and per-key quota settings.
A harness declares outputPolicy({ proxy }) as adapter, non-newline-stdout, or stdout. The launcher validates and serializes that declaration; old sessions retain their recorded activity behavior.