# 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 | | `//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](#themes-stream-s4): 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 1. Pick a name matching `^[a-z][a-z0-9-]*$`. 2. Create `packages/plugins///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 `plugins` and `plugins.set` verbs, the human's and, since row 175 (#1673), the tester's (`--tester`, actor `tester`). 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. ```json "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 ```js 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`, ``, `` 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 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: ```js 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//`. 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//` (`harbor`, `carmine`, `atlantic`, `terracotta`, `aubergine`, `mineral`, `cobalt`, `rosewood`, `graphite`, `grove`). A module: ```js 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/organization/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//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 takes `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max` as `--thinking`, Claude Code takes `low`, `medium`, `high`, `xhigh` and `max` as `--effort`. - `preflight({env})` returns `null` or `{code, message}`: pi checks its binary in `node_modules` (`harness-binary-missing`), Claude Code its session credential (`no-session-credential`) and `MOSAIC_CLAUDE_BIN`. - `version({env})` is what the manifest records as `harnessVersion`. - `countsAccount()` says whether its launches spend `launch.account` and get the row 126 account checks. The same answer decides whether an account quota covers authority hires (#1873); the bus reads it once at start, so return a constant. - `mounts`, `builtinWeb`, `builtins`, `typedName`, `reliesOn`, `gateTimeout`, `bundle`, `env`, `turnEnd`, `outputPolicy` and `transcript` give 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 passes `store` as `{session, projects}`, the ledger's `cursors` by stream, the read `budget` and stream cap `streams` from the system config, and `first` when the store has no ledger yet. The answer is `{found, cursors, known, reason, version}`: `found` lists `{entry, completedAt, stream}` past the cursors, `cursors` maps each stream read to `{bytes, tail}` (`tail` is the sha256 of up to 4096 bytes ending there), `reason` is null, `store-incomplete`, `store-unreadable`, `not-collected` or `harness-unsupported`, `known` is true exactly when `reason` is null, and `version` names 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 answers `harness-unsupported` on a first collection that finds Claude history. Claude Code answers `harness-unsupported` with version null (Vikunja 466). - Row 66: both modules wire the unslop check from `tools/unslop-hook`. `mounts` adds its three files read-only and `bundle` names them in the manifest's sources. Pi's `env` loads `extension.ts` as the second entry of `MOSAIC_EXTENSIONS`; Claude Code's `bundle` adds Stop and UserPromptSubmit hooks that run `claude-hook.js` and 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: ```json { "revision": "", "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}] ``` - `limits` is the business's `launch.limits`. `subject` is `{instance, provider, model, account}`. `records` are the per-turn usage records the usage sources wrote (`usage.mjs` `turnRecords`), each `known` with its four kinds or `unknown`. `now` is an ISO time. - Row 333: while `limits.tokens.keys` holds a daily model-key quota, `subject` also carries `modelKeyId` and core passes `lineages`, 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`). - `hit` is `{path, limit, count, unknownTurns}`. Its path must name a limit in `limits` with that limit. A count is a safe integer or a digit string. - The launcher asks `mayLaunch` before every launch, through the bus (`mayLaunch` launch op). A refusal is `limit-reached` with the hit as its message, or `limit-check-failed`. The bus asks `mustStop` after 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 by `limit-check` and a reason `limit: limit count `; `session.ended` and the launcher's end record carry that reason. - `report` feeds Settings and `mosaic limits`. Core composes one entry per limit: `counted`, `not-enforced` (no check reported a count; a check's `notEnforced` text is the reason) or `check-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: `. 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`: ```json "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: ```json "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](usage/cliproxy-queue/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. ## 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](usage/cliproxy-queue/README.md) 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. ### System types `system-type` modules export `interfaceVersion = 1` and `systemType()`, returning `{ id, label, description, toolsets }`. The id is the module directory name; `toolsets` is a distinct list of toolset ids. The installed list is discovered from module directories, and malformed modules refuse discovery. An organization selects modules with `plugins.system-type`; an omitted list enables none. The initial five modules declare no toolsets yet. ### Instance-installed packages (PL1, #1873) The instance owner may install trusted plugin code from the host shell: ``` mosaic plugins install ./package --sha256 <64 lowercase hex> mosaic plugins install ./package.zip --sha256 <64 lowercase hex> mosaic plugins install https://example.invalid/plugin.git --commit <40 lowercase hex> --sha256 <64 lowercase hex> mosaic plugins list mosaic plugins remove ``` These commands read the selected system config's `dataRoot`, use `/plugins/`, and publish `/plugins.lock.json`. They have no bus, Console, organization, tester, or agent installation path. Removal holds the organization registry lock and refuses if any organization file still enables the package. Disable it through the existing owner-only `plugins.set` before removal. The installer prints the names of all host tools before its first instance write. Plugin code runs as the instance uid; installing it is a trust decision, and a hash pin does not make code safe. Every package has this strict `plugin.json` shape (all fields required): ```json { "name": "example", "version": "1.0.0", "kinds": ["usage-source"], "interfaceVersions": {"usage-source": 1}, "sha256": "<64 lowercase hex>", "source": {"path": "./source", "version": "1.0.0"}, "signature": null, "tools": [{ "name": "example.lookup", "description": "Look up a local fixture", "arguments": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}, "runs": "sandbox", "entry": "tools/lookup.mjs" }], "actions": ["example.publish"], "services": [{"name": "example.account", "scopes": ["read"]}], "roleTemplates": [{"name": "example.writer", "file": "templates/writer.json"}] } ``` `source` takes exactly one of `path`/`url` and one of `version`/`commit`. Git installs record their actual full commit and URL in the lock. `signature` is reserved and must be null. Kinds and interface versions remain core policy; each kind has `/index.mjs` with the usual exports. Every declaration has the plugin namespace, and no declaration may reuse a core name or hard wait. Templates are recorded as data; installation imports no role and hires nobody. `pluginHash(directory)` in `src/pinned.mjs` computes the expected digest. Sort relative regular-file paths lexically and hash the UTF-8 frame `:::` followed by each file's bytes. For `plugin.json`, replace only the top-level `sha256` value with 64 zeroes. All other bytes, including whitespace and descriptions, are pinned. Symlinks, hard links, special files, unsafe archive paths and duplicate archive entries refuse. Files become 0444, directories 0555. Empty directories carry no code and are omitted from the digest. A declared tool exports `execute(arguments)` from its `entry` module. Argument schemas use the bounded, reference-free subset checked in `src/manifest.mjs`; unknown keywords refuse. Tool presence follows the resolved role, and the broker rechecks the current organization setting and hash on every call. Host tools run in the host process. Sandbox tools run in a fresh bubblewrap namespace with the pinned package and Node runtime, no home, config, workspace or network. They receive JSON on stdin and return JSON on stdout. The loader checks the complete package against the lock before every installed module import. A refusal logs static codes and the validated package name; core modules continue. A bad pin evicts previously loaded installed modules. Once a package path has been imported, the process latches its verified pin before the first import attempt (including failed attempts). Removing and reinstalling a different pin at that path refuses with `plugin-restart-required` until the host restarts. The latch is shared by loaders and host tools, so cached helper modules cannot run under a replacement pin. A fresh sandbox tool process has no old cache. Executable dependencies must be bundled in the package and imported by relative path (or an in-package file URL). A shared resolver guard refuses bare third-party lookups, executable data URLs and paths outside that package; Node built-ins remain available. This applies to static imports, dynamic imports and `createRequire`, including sandbox tools. It constrains module resolution, not R10 host authority: trusted host code can still use filesystem and other host APIs. A role referencing a missing or disabled plugin refuses at launch with `plugin-disabled` and the package name. Disabling never fires or rewrites an agent; its next plugin tool call refuses with that same code.