Files
stack/packages/plugins
jason.woltjeandClaude Opus 5.5 70504d23a4 bus, cli, webui: token accounting per run, schema 3h usage_records (row 124, #1618)
Rocko's round 2 candidate e09313aba4d155a1c5af7bfb227ec397e96c0d57444782cd0b48593102bcc684
(38 paths, base 23533625d), approved by Dewey in #1618 comment 28923 (queue rev 1757).
Packet: agents/rocko/work/queue-124/r2/.

- Schema 3g to 3h: packages/bus/src/migrations/3g-3h.sql adds an append-only
  usage_records table; nothing earlier changes. usage_records is in timedTables.
- The transcript usage-source module reads token counts from Pi and Claude
  Code session transcripts and follows no symlink.
- `mosaic usage` in the CLI and a read-only usage section in the Console's
  Settings (one GET route; 400 on a query string).

Decision 123 go: Mos, mos-0737. Activation (3g to 3h) is a migrating run
with Jason present, in Mos's combined re-pin; landing changes nothing live.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 11:08:17 -05:00
..

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 1 list interfaceVersion, mayLaunch, mustStop
search-source 1 one interfaceVersion, search

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) and whether a running session must stop at the end of its turn (mustStop). The arguments and return values of these functions are set by the rows that call them (row 124 converts the first usage source). The transcript usage source ships with row 124; no limit-check module ships yet.

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

  1. Pick a name matching ^[a-z][a-z0-9-]*$.
  2. Create packages/plugins/<kind>/<name>/index.mjs. A real directory and a real file: the loader refuses a symlink anywhere from packages/plugins down, index.mjs included.
  3. Export interfaceVersion and every function the kind lists.
  4. Land it like any other change. A running stack sees it only after a re-pin, because scripts/stack-copy.sh copies the whole packages/plugins directory into the pinned copy.
  5. Switch it on in the organization file's plugins object, under its kind: mosaic plugins shows the setting and its revision, and mosaic plugins set --file PATCH ({ "revision": ..., "plugins": {...} }) changes it, one kind at a time: the patch names the kinds it changes, a kind with null is removed, and a kind it doesn't name keeps its setting. The change goes through the broker's human-only plugins and plugins.set verbs. 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 in changes.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:

  1. the name matches ^[a-z][a-z0-9-]*$ (bad-name);
  2. packages/plugins, <kind>, <name> and index.mjs exist, are a directory or a regular file as expected, and are not symlinks (missing, symlink); ancestors of packages/plugins are not checked;
  3. just before import, realpath(index.mjs) is still the expected path (symlink);
  4. the module imports (load-failed);
  5. it has every export, and the kind's functions are functions (missing-export);
  6. interfaceVersion equals 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-export or interface-version has 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.mjs is not re-read until the process restarts.
  • The loader is not wired into any host process yet. The rows that call the first two kinds (124, 125, 126) wire it.

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. No limit check ships with this row. Enable transcript through the human plug-in settings writer; a restart/re-pin remains a separate authorized window.