A replaced installed harness answers null for countsAccount until the bus restarts and reads the new module. A runtime test pins the startBroker wiring for claude-code (covered) and pi (refused). The plugin README says countsAccount also decides authority coverage and is read once at start.
599 lines
33 KiB
Markdown
599 lines
33 KiB
Markdown
# 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](#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/<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 `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`, `<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 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/<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:
|
|
|
|
```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/<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 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": "<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}]
|
|
```
|
|
|
|
- `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: <check> <path> limit <n> count <c>`;
|
|
`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: <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`:
|
|
|
|
```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 <name>
|
|
```
|
|
|
|
These commands read the selected system config's `dataRoot`, use
|
|
`<dataRoot>/plugins/<name>`, and publish `<dataRoot>/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 `<kind>/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
|
|
`<path byte length>:<path>:<file byte length>:` 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.
|