sage: row 65 packet, candidate ccdbc2232 manifest, diff, public/ diff, build notes, gate and mutants (#1558)

This commit is contained in:
jason.woltje committed 2026-10-19 10:36:42 -05:00
1 parent d8336daef2
commit 1d5897024d
4 files changed
+4566

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
# Row 65: harness, model and thinking from the instance vars, a fallback route, and the harness plug-in kind
Sage, 2026-10-19. Brief: `docs/plans/2026-10-11_managed-session-config.md`, section "Model routing
table", blob f3b06a165586 (841e0b0a2, queue rev 1944; lead decision 151 and mos-0840 folded in). Issue #1558.
Reviewers: Darkwing and Dewey. Base d8336daef. Built in the detached worktree
`/mnt/storage/scratch/sage-r65`.
Candidate **ccdbc2232** (ccdbc2232ddd6f879356bbbff9c2805797c9be3e) on d8336daef: 64 files,
2189 insertions, 336 deletions. `candidate.diff` is `git diff d8336daef ccdbc2232`;
`candidate-manifest.sha256` has the sha256 of each changed file at the candidate.
`public.diff` in this directory is the `packages/webui/src/public/` diff for Mos (decision 123).
## What changed
**Harness plug-in kind.** `kinds.mjs` gains `harness` (interface 1, switch list). A module
exports `adapter`, `levels`, `preflight`, `version`, `countsAccount`, `mounts`, `builtinWeb`,
`builtins`, `typedName`, `reliesOn`, `gateTimeout`, `bundle`, `env`, `turnEnd` and `transcript`.
`packages/plugins/harness/pi/` and `claude-code/` hold what core used to branch on: the adapter
path, the thinking levels, the binary and credential preflight, the version probe, the sandbox
mounts, the builtins prompt line, the typed tool names, the gate timeout, the bundle files (the
pi extension or the Claude settings and MCP files), the adapter environment, how a turn ends and
where the session files live. Core reaches a module only through the loader.
**Route.** `sessionModel(resolved, models, which)` (bundle.mjs) reads `harness`, `model` and
`thinking`, or the three `fallback.` vars, from the instance. A missing one throws `no-route`
naming the var. The system file's `execution.adapter` and `execution.model` are never read for a
session; they stay for `run-task.sh`.
**Launcher check order** (launcher.mjs). Primary route, then the fallback route when
`fallback.harness` is set; `no-role-contract`; `harness-off` for either route not in
`plugins.harness`, so an off primary never falls back; the primary module through a fresh
loader (`harness-refused` names the module and the loader's code); its levels
(`thinking-unsupported`); its preflight. A failed preflight with no fallback refuses with the
preflight's code. With a fallback, the fallback module goes through the same steps, and a second
failure names both reasons. The account checks (only when the route module's `countsAccount()`
is true), the sandbox check, the capacity limits and `mayLaunch` all run on the route taken.
The manifest records `route` (`primary` or `fallback`), `routeReason` (the primary's preflight
code, or null) and `harnessVersion`.
**Thinking.** pi's adapter passes `--thinking <level>`; Claude Code's passes `--effort <level>`.
Both read `MOSAIC_THINKING`. The levels each module applies:
| Harness | Binary the session runs | Flag | Levels |
|---|---|---|---|
| pi | 0.85.1, the repository's `node_modules/.bin/pi` (the sandbox mounts the repo's node_modules on the session's PATH; `pi` on the host PATH is 1.2.0 and isn't used) | `--thinking <level>` | off, minimal, low, medium, high, xhigh, max |
| claude-code | 2.1.302 (`MOSAIC_CLAUDE_BIN` or PATH `claude`) | `--effort <level>` | low, medium, high, xhigh, max |
The `thinking` var takes off, minimal, low, medium, high, xhigh and max. Nothing is remapped: a
level the route's harness doesn't apply refuses `thinking-unsupported`, at a write and at launch.
The live file uses high on five instances and medium on the coder, all claude-code, so nothing
live changes. `adapters/README.md` has the `MOSAIC_THINKING` row.
**Business vars and writes.** `harness` and `fallback.harness` take a module name;
`fallback.model` is a string; `thinking` and `fallback.thinking` take a level. The file layer
refuses a fallback with one or two of its three vars. `checkRoutes` (launch-settings.mjs) is shared
by `routes.set`, `instances.set` and `limits.set`: `unknown-model` for a fallback model outside
`launch.models` (the file's own check refuses a bad primary model first, as `invalid-request` at
the bus), then, only for a route whose harness or thinking changed, `harness-off`,
`harness-refused` (the module didn't load, so its levels are unknown) and `thinking-unsupported`.
A route the write doesn't touch isn't re-checked, so an earlier unswitched harness doesn't block
an unrelated save.
**Routes verbs** (mos-0840). `routes` reads `{revision, harnesses, levels, models, instances}`;
`routes.set` takes exactly `{revision, routes}`, each route `{harness, model, thinking,
fallback}` with `fallback` null or all three. Both are human-only broker actions outside
`ORGANIZATION_KEYS`, so `organization.set` and the chief of staff's `change_organization` can't
reach them. The writer logs the human as actor. The bus passes `harness-off`, `harness-refused`
and `thinking-unsupported` through as their own codes.
**Bus binds.** A new run binds only on a harness module name (`HARNESS_NAME`, the loader's
pattern) that `plugins.harness` switches on now (`harnessOn`, read from the file at bind time);
otherwise `invalid-harness`. A run the store already holds binds again unchanged, so a switched
off harness doesn't strand a running session. The bind record takes `transcript`, the module's
session-files path, which the usage reader now uses instead of a harness-name branch.
**Console** (mos-0840). Settings gets a "Routing" section and loses the "Role models" fieldset
from Agent limits. One row per instance, six selects: harness, model, thinking, and the
fallback's three. Each select offers the switched-on harnesses, the model table's keys or the
levels the row's harness applies. A value the file holds that isn't offered stays selected with
its reason in the label ("not switched on", "not in the model table", "not applied by
claude-code"), so a save sends it and the bus names the refusal. A fallback with all three set to
none is cleared; a partial one is sent as it is and refused. Save posts only the changed rows to
`POST /api/routes` with the read's revision. Every refusal keeps the draft; nothing retries.
`GET /api/bus/routes` reads the table. `packages/webui/src/bus.mjs` is unchanged, so the
channel-proof sha256 pin holds.
## Decisions taken in the build
- Write-time checks run on changed routes only; the model check runs on every model set. A
missing route var is a launch refusal (`no-route`), not a write refusal, so the file can hold
an instance that isn't launchable yet.
- The bus runtime gains `harnessLevels()` (each module in the tree, loaded once at start, on or
off, so a write can check a level before the harness is switched on) and `harnessOn` for binds.
- `RUNNER_FILES` drops `bundle.mjs` (the runner no longer imports it) and adds `kinds.mjs`.
- The runner's turn check is by `turnEnd()`: pi's "marker" needs the marker file, and the message
reads "(no turn marker)". Claude Code's is "exit".
- A usage end after a begin with no transcript reads `baseline-unknown`.
- `bus start --pm` refuses `harness-off` while `plugins.harness` is unset. At the re-pin the bus
starts without `--pm`, Jason runs the plugins patch, then the PM launches.
## Changes outside the owned list
- `claudeVersion` moved out of the launcher into the claude-code module's `version()`, with
row 68's precedence (`MOSAIC_CLAUDE_BIN`, else PATH `claude`) and its tests, now in
`harness.test.mjs`. The manifest's `piVersion` and `claudeVersion` fields are replaced by one
`harnessVersion` from the module the route took. Nothing in the tree reads either old field
(grep over `packages`, `scripts` and the READMEs); old run manifests keep theirs.
- Not changed, follow-up: `packages/cli/systemd/mosaic-bus.service.in:16-17` still says the
pinned binary serves "the manifest's claudeVersion". It's a comment, but row 166 changes this
unit and Mos rechecks its quoting before that lands, so I left the file alone. One-word fix
after row 166 lands.
- `packages/cli/tests/launch-limit-checks.test.mjs` (row 125's file), the REQ-LIMIT-4 test.
It asserted the launcher never imports the plug-in loader. The launcher now loads its harness
through the loader, so the assertion is narrowed: the launcher's only `.load(` call is
`{ harness: [m.harness] }`, and no `modules(` call names a kind other than harness. A limit
check still can't load in the launcher. This is the one guarantee in another row's test that
got weaker, and it's the reviewers' call whether the narrowed form holds.
- `packages/bus/tests/project-runtime.test.mjs`, the broker import-closure test: the broker gains
two imports, `isAbsolute` from `node:path` (the `transcript` check) and `NAME` from
`packages/plugins/src/kinds.mjs` (the harness name pattern). kinds.mjs imports nothing and is
in `RUNNER_FILES`, so the sandboxed runner can load the broker.
- `packages/plugins/tests/loader.test.mjs`: each `loaded` map gains `harness: []`.
- `packages/webui/tests/settings-form.test.mjs`: the Agent limits form has no selects now, and
the save no longer sets an instance model. `shell.test.mjs`: the write list gains the
`/api/routes` POST, and the `method:` count goes from 6 to 7.
- Bus test fixtures (`chief-of-staff`, `credential-status`, `daily-quota-runtime`, `end-launch`,
`limits-runtime`, `process`, `tasks`, `tester`, `usage-runtime`): each business fixture sets
`plugins.harness` so its runtime launch binds. `usage` and `usage-runtime` bind a
`transcript`, and `createUsage` no longer takes `dataRoot`.
- READMEs beyond the brief's list: bus, cli, seat and webui, for the same changes.
## Tests
| Test file | Covers |
|---|---|
| `packages/cli/tests/launch-routes.test.mjs` (new, 9) | primary taken when it passes; fallback on a failed preflight with limits on the fallback; no fallback refuses with the preflight code; both failing names both; account on a counting fallback; `no-route` per var with execution keys never used; `harness-off` primary or fallback; `harness-refused` and `harness-failed`; `thinking-unsupported` on primary and on a fallback taken |
| `packages/cli/tests/launcher.test.mjs` (row 65 case) | a real sandboxed launch on a fixture harness module no core file names, switched on in a test organization file: the primary fails preflight, the fallback runs, the manifest records route and reason |
| `packages/plugins/tests/harness.test.mjs` (new) | both modules load with every export; per-module answers; claude-code preflight codes; version probes; env and bundle files; the adapters' `--thinking` and `--effort`; the grep test, with the brief's exceptions listed and each still naming a harness |
| `packages/bus/tests/routes-verb.test.mjs` (new, 4) | human-only for every non-human cap including the chief; `routes` not in `ORGANIZATION_KEYS`; `harnessOn` binds; `harnessLevels`; the runtime write path and each refusal leaving the bytes and the log unchanged |
| `packages/business/tests/launch-settings.test.mjs`, `instance-settings.test.mjs`, `vars.test.mjs` | `checkRoutes` codes through each writer; partial fallback at the file layer |
| `packages/webui/tests/routes.test.mjs` (new, 5) | GET and POST routes, status codes, receipts, the identity log line |
| `packages/webui/tests/routes-browser.test.mjs` (new, 1) | the table lists every instance with six values; read-only without send; a full save read back from the file; partial fallback, thinking-unsupported, revision-conflict and harness-off each refused with the draft kept; "Nothing changed." posts nothing |
The chief's tool refusing routes is covered at the bus (`organization.set` with the chief's
cap); there's no separate chief-tool test.
## Mutants
`/mnt/storage/scratch/sage-r65-out/mutants.py`, run in a second worktree
(`/mnt/storage/scratch/sage-r65m`, the same files as the gated tree by `diff -rq`), so the gated
tree was never edited. Each mutant replaces one anchor that appears exactly once, runs the tests
named for it, and restores the file from a checked backup. The control run of the union of those
tests passed 71/71 first.
| Mutant | Edit | Killed by |
|---|---|---|
| m1-no-route | `sessionModel` returns the system file's `execution.adapter` instead of throwing `no-route` | launch-routes `no-route` test; bundle.test `sessionModel` |
| m2-harness-off-launch | launcher skips the `harness-off` check | launch-routes `harness-off` primary or fallback |
| m3-harness-off-write | `checkRoutes` skips `harness-off` | 5 tests: launch-settings (3, Q169 assistants among them), routes-verb runtime, webui routes |
| m4a-partial-fallback-file | vars.mjs skips the partial-fallback check | vars.test `types` (the partial cases at lines 73-74) |
| m4b-partial-fallback-routes | `setRoutes` skips the partial-fallback check | launch-settings `setRoutes`; routes-verb runtime |
| m5-fallback-on-pass | a passing primary preflight is treated as failed when a fallback is set | launch-routes "a passing primary is the route" |
| m6-harness-name-in-core | `sandbox.mjs` gains `DEFAULT_HARNESS = "pi"` | harness.test grep test |
| m7-routes-in-org-keys | `routes` added to `ORGANIZATION_KEYS` | routes-verb human-only test |
8 of 8 killed, each by an assertion failure in the named test, not a load error. Logs are in
`/mnt/storage/scratch/sage-r65-out/mutants/`.
## Gate
`/mnt/storage/scratch/sage-r65-out/gate.sh`, from the 2026-10-14 template, on d8336daef plus
the 64 changed files, the tree committed as ccdbc2232 with nothing else changed (`git diff HEAD
--quiet` after the commit): every package suite except queue, then every `scripts/test-*.sh`.
GATE_PASSED. 25 steps, each rc=0. 2513 node tests (2357 package, 156 in test-queue) and 422
script selftests, 0 failed, summed with awk over `g1.console.txt`. One skip, the existing webui
esbuild rebuild test (pinned esbuild not installed), which this row doesn't touch. The brief's
suites: pkg-plugins 111, pkg-bus 419, pkg-business 148, pkg-harness 123, pkg-seat 151, pkg-cli
344, pkg-webui 237, and every test-*.sh. Compose project `sage-r65`; its network was removed on
exit and no container was left.
## Landing
At landing Sage sends Jason, through Mos, the patch for the live business file:
`mosaic plugins set` adding `"harness": ["claude-code"]`. Nothing goes live before a re-pin on
Mos's go. Until that patch is in, a new launch refuses `harness-off`.
@@ -0,0 +1,64 @@
fc5425680bfe5ce48bb41ba5aabe817c5a20ce3ebd4b74c5c03b902ed1b27f26 adapters/README.md
40df61ad93d8109aa4348e32676649e94eca7e2ebc140dfb60b66379c93eb478 adapters/claude/adapter.sh
1eda0066a2dee2ffdcde9db5c9a38774f8c452c73383b5f1995e0b97977c62f7 adapters/pi/adapter.sh
f1e2ddfaa376b968ffce02fc77e3a902a8ab094c216dec6b7f76ae9efb1ff5ff packages/bus/README.md
5888dc3c02f53a1cee9ade1ded0092985e8ca725bc4f11b99fb8ae2b2b9d9375 packages/bus/src/broker.mjs
fe70f4b6568fbfec85e58d34f3cb8c3624daba65487d73ad24e981f2121cb27a packages/bus/src/runtime.mjs
0accd20566096e06ee2f354cfee70da6e86b2f4091076eda6847253cdb9e036b packages/bus/src/usage.mjs
7620eda0f16f4a126dad4e46887716afccd5638dc0028e8d989f0b5d3e4a6b00 packages/bus/tests/chief-of-staff.test.mjs
d4a26f810298ee2a9222855318ec119353745eb8fbdbfe67e99ec591705b89c5 packages/bus/tests/credential-status.test.mjs
dbbfa80b4082fd44e2f08539b5455653fac062321b8f2106f638e2d8161fa3eb packages/bus/tests/daily-quota-runtime.test.mjs
2e649657ec73aeb2d8d7e3f1ff3015f8c71027e21815feca299330e311c81ff1 packages/bus/tests/end-launch.test.mjs
a2e553025edbe6e930c9d03b48289c53b51b153ac9ae49bb21bf721b725f1913 packages/bus/tests/limits-runtime.test.mjs
2942ee6e77ff99187bb31bbbb1d1df4f723a18fb799bf3b784dd18abe03b1ac3 packages/bus/tests/process.test.mjs
6c5ad1a2b003abc469440e292a094b930246bfbb234c1076b969da5a53e05c7c packages/bus/tests/project-runtime.test.mjs
409807e8ee248ce1a2ae4e887410871349b85d0d144d2c81acad01bb509b5a03 packages/bus/tests/routes-verb.test.mjs
25bc6448389f095a22c3bf659b5accd01d78150c2e8af80c211951a680b8c061 packages/bus/tests/tasks.test.mjs
404b7f33db494aa288a9d8e29358580a81e7db28819cf0f8e943e04f845ce0a7 packages/bus/tests/tester.test.mjs
5abe93fca8442f06ff68760623e91262d863e1191fa35a3d0c87d7b0e6c0a4c6 packages/bus/tests/usage-runtime.test.mjs
471a0b1bd84df8812281e47c915dfae86859fdf63fb257bd7aa4ceda7562362b packages/bus/tests/usage.test.mjs
e3a447f3b3a57befe2b4d8ce34425c385c8f22cefea0bff19d16fa410088246e packages/business/README.md
2862f7a409757ce930a34c6332e588da5f6ed5e4e25be90f89a07c163a9027ad packages/business/src/index.mjs
c5f847de26356c97f09aee428e35f256885e8825860fa1b49448a1ab8bf2973b packages/business/src/instance-settings.mjs
ee8d940631541dffb4f78af5c0ac95707d9c24d47be43b0d9e7573bab57a6015 packages/business/src/launch-settings.mjs
7cac2ba23d333e447b5545c030c8d9686122f2fd31cc62c220bcf3703f8e9c7e packages/business/src/vars.mjs
9be0521258a3fc7d044b494ec92e066ddeab9a3a33172d7eb54f24b0b97420a6 packages/business/tests/instance-settings.test.mjs
77e560fd67f3390404bedb0fbd90b4e30f58255d1aa194598d141ac02e2aa4ad packages/business/tests/launch-settings.test.mjs
140d7da07cfb0794be6438d8fb2fd504d3ec1b2fade244a396b802ff650ed009 packages/business/tests/vars.test.mjs
fd0cc0a27035e10c253653ccc77997b4d0c3736e00fba6b9191a1e81c31043ba packages/cli/README.md
6b2813071b857c58e7aae67a64b6f29d15fdca431e0dae8c0041988aeb699c23 packages/cli/src/launcher.mjs
8a73002152c88c2850d466e5ee2c61fe6bcd44f03cea390f1ce8f282ca394b7a packages/cli/tests/helpers.mjs
06c20167a843064e8df13d9ea3aaffd2a689a2326e313497be69677b4af64b7f packages/cli/tests/instances.test.mjs
b132e102df8bfb4a6409ccd19971d06c201ddd290b6869c52bb3099c26101d82 packages/cli/tests/launch-limit-checks.test.mjs
e7255685d7de5013f9d10450c863143b000616fd42d444eaa4b2c1fa17ece2a2 packages/cli/tests/launch-routes.test.mjs
3e57437f3aa96ef491fe4f5146241bcacbc57cce5feadd544313c17eff055b2e packages/cli/tests/launcher.test.mjs
aa1ef8517c3560d619b77d96d01bce00e50b8b6b7ac54013b632fcde874f49b2 packages/cli/tests/thread-launch.test.mjs
c1ad84c544b6b1ea8a4897a77b3899c19c48705834ca19d963dadbe15fe73d50 packages/harness/README.md
9ce5b05b20c9eb85c3c2acbabaee2f0157e8eeb13d91cb2c13c15834d6031585 packages/harness/src/bundle.mjs
dd8df37ecee238f51e89e8e3c8ad0e71b1bacb046f8fafcd5b9c6a853e3a659f packages/harness/src/runner.mjs
4ed9477ac21784d8e6fe1cd77c42fad90357854eb73bac2770454dcb7eda6aa1 packages/harness/tests/bundle.test.mjs
d3769a4052eb66be0cc34eb4e012c7e6b0294b21cb732d0f79d077f3edf5e3af packages/harness/tests/claude-session.test.mjs
f3a8ea7b81395b22e1a5612f46579e4248ef23c037483aa602a8a4adece617be packages/harness/tests/fixtures/fake-adapter.mjs
7fb487227a90cf9bbb02b44084372cfece89bdeed33deed38afc441842ef2cb3 packages/harness/tests/helpers.mjs
94dd7f4fef41870a769def394c7469d65f90a6e400534fefe62364b2a2d05619 packages/harness/tests/pi-session.test.mjs
956f42cb77b571625adf6d0bdadf0ee5052b5c2f7e6160aa91a102d2d9bd81c3 packages/harness/tests/runner.test.mjs
77fd7f2460f4f58abd78b4cb145391f5a08bef5ae20978ec77c55eceac97db2c packages/plugins/README.md
fc65ec7e54f45617d7aab43f370bca5d6d88f9e27902188c3e8497255e7e541e packages/plugins/harness/claude-code/index.mjs
1b7bef037f24b9eca2180630bb1d91dcfc47009eb5339b869ab469681b0d9a7b packages/plugins/harness/pi/index.mjs
215bfcb058660239fc298b1e5459d5d44943543c2499af16f9726fd8cf9492da packages/plugins/src/kinds.mjs
f9e8dbd79c4276a88759da112be0853d274918d8a5bce78ddd0a0b35c4cb47e7 packages/plugins/tests/harness.test.mjs
ef3b670787bf8f75abf1a3113c46eadf17a3e2e984106926bfb18660efa247e1 packages/plugins/tests/kinds.test.mjs
2f4f0e41baaf8ece16113674d7cca0d915e2fd34848845bd5776bc3d5f7f37b7 packages/plugins/tests/loader.test.mjs
a862bbf8ace77b7fd143addb8c29bd89e08e1d22e2a24af6c54ca66dea186aa0 packages/seat/README.md
8c1b67d791540c2bdca36c0f8048b00835b4333d9440f70a87ed71beb0d28299 packages/seat/src/sandbox.mjs
2dd222d6519e365ae86e64768113b8cc2535a7a862368ad02496aa7dc6d0d050 packages/seat/src/session.mjs
8c57fb0610f2f55f3b9491b9435b5ad26b45322a331f5e0bd5f1e928a024420d packages/seat/tests/sandbox.test.mjs
62d5a75d4d84791bd99f1f38ba3fbd13a95b01c5f3e3917017288adc48e2dc0e packages/seat/tests/session.test.mjs
fe1d1787df9d685871ec450125f889d9f80f32d726490f633065603515a394cf packages/webui/README.md
0bdebc781dc865a1eb2bfcaa70dbbd12e649b2f6338b40a81b394f388d2fd63f packages/webui/src/cli.mjs
acb46ebf39a88cead437c8c310d08a12c6f1f2fa1b0ab0af2de21fc308ffdd8b packages/webui/src/public/bus.js
555ca97616b6616f2b3e3070304ad7c97d21b7f609b8807de82caaf5b0013c84 packages/webui/src/serve.mjs
9a157959622653f358b248bf01eff7a80ca0af3d355fe40f7e2de5134ba73e17 packages/webui/tests/routes-browser.test.mjs
642fd426432fbcd63fd5f6236a7715582f289f283257ca062ae1b4838aacf1b3 packages/webui/tests/routes.test.mjs
624749a0345addc28abff6976ec0c89618d95d948a718eac8b4298af19f8b264 packages/webui/tests/settings-form.test.mjs
ff0b4fce9af8ed8b779a6c4c96932d4039ba0f2f9a1b83d96acddc2d82cca92c packages/webui/tests/shell.test.mjs
File diff suppressed because it is too large. Load diff
+99
View File
@@ -0,0 +1,99 @@
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index 24d36d654..b0ffec0ce 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -574,14 +574,14 @@
const providerRows=[...providers].map(p=>field(['providers',p],p,limits.providers?.[p],usage.providers?.[p])).join('');
const modelRows=[...pairs.values()].map(([p,m])=>field(['models',p,m],`${p} / ${m}`,limits.models?.[p]?.[m],usage.models?.[p]?.[m])).join('');
const table=Object.entries(modelTable).map(([key,m])=>`<div class="filters"><strong>${txt(key)}</strong><label>Provider <input data-setting="${esc(inputKey(['table',key,'provider']))}" value="${esc(val(['table',key,'provider'],m.provider))}" maxlength="200"${disabled}></label><label>Model <input data-setting="${esc(inputKey(['table',key,'model']))}" value="${esc(val(['table',key,'model'],m.model))}" maxlength="200"${disabled}></label></div>`).join('');
- const selections=Object.entries(v.instances||{}).map(([instance,entry])=>`<label>${txt(instance)} <select data-setting="${esc(inputKey(['instances',instance]))}"${disabled}>${Object.keys(modelTable).map(key=>`<option value="${esc(key)}"${val(['instances',instance],entry.modelKey)===key?' selected':''}>${txt(key)}</option>`).join('')}</select></label>`).join('');
- return `<section class="panel" aria-labelledby="launch-settings-title"><h2 id="launch-settings-title">Agent limits</h2><p>Leave a limit blank for no limit. Zero stops new launches. Running sessions keep running when limits or models change.</p><form id="launch-settings-form"><fieldset><legend>All sessions</legend>${field(['total'],'Total',limits.total,usage.total)}</fieldset><fieldset><legend>By provider</legend><div class="filters">${providerRows}</div></fieldset><fieldset><legend>By provider and model</legend><div class="filters">${modelRows}</div></fieldset><fieldset><legend>Model table</legend>${table}</fieldset><fieldset><legend>Role models</legend><div class="filters">${selections}</div></fieldset><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${settingsSaving?'Saving…':'Save launch settings'}</button> <button type="button" id="launch-settings-reload">Read current settings and discard draft</button><p role="status" id="launch-settings-receipt">${txt(settingsReceipt)}</p></form></section>`;
+ // Row 65 (mos-0840): each instance's model moved to the routing table below.
+ return `<section class="panel" aria-labelledby="launch-settings-title"><h2 id="launch-settings-title">Agent limits</h2><p>Leave a limit blank for no limit. Zero stops new launches. Running sessions keep running when limits or models change.</p><form id="launch-settings-form"><fieldset><legend>All sessions</legend>${field(['total'],'Total',limits.total,usage.total)}</fieldset><fieldset><legend>By provider</legend><div class="filters">${providerRows}</div></fieldset><fieldset><legend>By provider and model</legend><div class="filters">${modelRows}</div></fieldset><fieldset><legend>Model table</legend>${table}</fieldset><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${settingsSaving?'Saving…':'Save launch settings'}</button> <button type="button" id="launch-settings-reload">Read current settings and discard draft</button><p role="status" id="launch-settings-receipt">${txt(settingsReceipt)}</p></form></section>`;
}
function settingDraft(form){const values={};for(const el of form.querySelectorAll('[data-setting]'))values[el.dataset.setting]=el.value;return values;}
- function settingPayload(){const v=settingsSnapshot, limits={providers:Object.create(null),models:Object.create(null)},models=structuredClone(v.models),instances={};for(const [encoded,text]of Object.entries(settingsInputs)){const [kind,p,m]=JSON.parse(encoded);if(kind==='table'){models[p][m]=text;continue;}if(kind==='instances'){instances[p]=text;continue;}if(text==='')continue;if(!/^\d+$/.test(text)||!Number.isSafeInteger(Number(text)))throw Error('Limits need whole numbers of zero or more.');const n=Number(text);if(kind==='total')limits.total=n;else if(kind==='providers')limits.providers[p]=n;else {limits.models[p]??=Object.create(null);limits.models[p][m]=n;}}
+ function settingPayload(){const v=settingsSnapshot, limits={providers:Object.create(null),models:Object.create(null)},models=structuredClone(v.models);for(const [encoded,text]of Object.entries(settingsInputs)){const [kind,p,m]=JSON.parse(encoded);if(kind==='table'){models[p][m]=text;continue;}if(text==='')continue;if(!/^\d+$/.test(text)||!Number.isSafeInteger(Number(text)))throw Error('Limits need whole numbers of zero or more.');const n=Number(text);if(kind==='total')limits.total=n;else if(kind==='providers')limits.providers[p]=n;else {limits.models[p]??=Object.create(null);limits.models[p][m]=n;}}
// Row 125 (#1619): the form doesn't edit token limits; a save keeps them as read.
if(v.limits?.tokens)limits.tokens=structuredClone(v.limits.tokens);
- return {revision:v.revision,limits,models,instances};
+ return {revision:v.revision,limits,models};
}
document.addEventListener('input',e=>{const form=e.target.closest?.('#launch-settings-form');if(form)settingsInputs=settingDraft(form);});
document.addEventListener('change',e=>{const form=e.target.closest?.('#launch-settings-form');if(form)settingsInputs=settingDraft(form);});
@@ -727,6 +727,59 @@
else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
catch{instanceReceipt=unknownInstance;}finally{instanceBusy=false;if(!out())render(false);}
});
+ // Row 65 (mos-0840): the routing table. One row per instance: harness, model and thinking, and
+ // the fallback's three. Each cell offers the switched-on harnesses, the model table's keys and the
+ // levels that harness applies; a value the file holds that isn't offered stays, marked, so a save
+ // sends it and the bus says why it refuses. A fallback is set by choosing all three or cleared by
+ // choosing none for all three. Save sends the changed rows through routes.set. Drafts survive
+ // polling and refusals; nothing retries.
+ let routesView=null, routesDraft=null, routesBusy=false, routesReceipt='';
+ const ROUTE_CELLS=[['harness','Harness'],['model','Model'],['thinking','Thinking'],['fallback.harness','Fallback harness'],['fallback.model','Fallback model'],['fallback.thinking','Fallback thinking']];
+ const routeCells=r=>({harness:r.harness??'',model:r.model??'',thinking:r.thinking??'','fallback.harness':r.fallback?.harness??'','fallback.model':r.fallback?.model??'','fallback.thinking':r.fallback?.thinking??''});
+ const sameCells=(a,b)=>ROUTE_CELLS.every(([k])=>a[k]===b[k]);
+ function routesSection(view){
+ const title='<h2 id="routes-title">Routing</h2>';
+ if(!view)return `<section class="panel" aria-labelledby="routes-title">${title}<p class="small warn">The routes could not be read, so there is nothing to change. Read again to retry; they come from the bus host with your identity.</p></section>`;
+ if(!routesDraft)routesView=view;
+ const v=routesView, disabled=!canSend||out()||routesBusy?' disabled':'';
+ const cells=name=>routesDraft?.[name]??routeCells(v.instances[name]);
+ const select=(name,key,list,current,why)=>{const offered=current===''||list.includes(current)?list:[...list,current];
+ return `<select data-route="${esc(inputKey([name,key]))}" aria-label="${esc(`${name} ${ROUTE_CELLS.find(([k])=>k===key)[1].toLowerCase()}`)}"${disabled}>${key.startsWith('fallback.')||current===''?`<option value=""${current===''?' selected':''}>${key.startsWith('fallback.')?'none':'unset'}</option>`:''}${offered.map(x=>`<option value="${esc(x)}"${x===current?' selected':''}>${txt(x)}${list.includes(x)?'':` (${why})`}</option>`).join('')}</select>`;};
+ const levelsOf=h=>Array.isArray(v.levels[h])?v.levels[h]:[];
+ const rows=Object.keys(v.instances).map(name=>{const c=cells(name);
+ const harness=(key)=>select(name,key,v.harnesses,c[key],'not switched on');
+ const model=(key)=>select(name,key,v.models,c[key],'not in the model table');
+ const thinking=(key,h)=>select(name,key,levelsOf(c[h]),c[key],c[h]?`not applied by ${c[h]}`:'no harness');
+ return `<tr><th scope="row">${txt(name)}</th><td>${harness('harness')}</td><td>${model('model')}</td><td>${thinking('thinking','harness')}</td><td>${harness('fallback.harness')}</td><td>${model('fallback.model')}</td><td>${thinking('fallback.thinking','fallback.harness')}</td></tr>`;}).join('');
+ const off=v.harnesses.length?'':'<p class="small warn">No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. <code>mosaic plugins set</code> switches one on.</p>';
+ return `<section class="panel" aria-labelledby="routes-title">${title}<p>Each instance's harness, model and thinking, and the fallback a launch takes when the primary harness fails its preflight. A fallback sets all three or none. Running sessions keep their route; the next launch reads the change.</p>${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col">Instance</th>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${routesBusy?'Saving…':'Save routes'}</button> <button type="button" id="routes-reload">Read current routes and discard draft</button><p role="status" id="routes-receipt">${txt(routesReceipt)}</p></form></section>`;
+ }
+ function routesFields(form){const draft=Object.fromEntries(Object.keys(routesView.instances).map(name=>[name,routeCells(routesView.instances[name])]));for(const el of form.querySelectorAll('[data-route]')){const [name,key]=JSON.parse(el.dataset.route);if(draft[name])draft[name][key]=el.value;}return draft;}
+ // The changed rows only. A fallback with all three cells empty is cleared; one with some set goes
+ // as it is, and the bus refuses it as partial.
+ function routesPayload(draft){
+ const routes={};
+ for(const [name,c]of Object.entries(draft)){if(sameCells(c,routeCells(routesView.instances[name])))continue;
+ const set=['harness','model','thinking'].filter(k=>c[`fallback.${k}`]!=='');
+ routes[name]={harness:c.harness,model:c.model,thinking:c.thinking,fallback:set.length===0?null:Object.fromEntries(set.map(k=>[k,c[`fallback.${k}`]]))};}
+ if(!Object.keys(routes).length)throw Error('Nothing changed.');
+ return {revision:routesView.revision,routes};
+ }
+ const unknownRoutes='Outcome unknown. Read the routes again before trying. Draft kept.';
+ const routesAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&Array.isArray(r.harnesses)&&Array.isArray(r.models)&&[r.levels,r.instances].every(o=>o&&typeof o==='object'&&!Array.isArray(o));
+ for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('#routes-form');if(!form||!routesView)return;routesDraft=routesFields(form);
+ // A harness change redraws the row so its thinking cell offers that harness's levels.
+ if(kind==='change'&&/harness"\]$/.test(e.target.dataset?.route??''))render(false);});
+ document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';last.delete('routes');render(true);});
+ document.addEventListener('submit',async e=>{
+ if(e.target.id!=='routes-form')return;e.preventDefault();if(!canSend||out()||routesBusy||!routesView)return;
+ routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
+ routesBusy=true;routesReceipt='';render(false);
+ try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
+ if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;}
+ else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
+ catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
+ });
async function settingsView(get) {
const s = await get.api('settings'), n = s.notifier || {};
let usage, names = null;
@@ -734,12 +787,14 @@
try { names = await get('names'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; /* the section says the name could not be read */ }
let instances = null;
try { [instances] = await get('instances'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
+ let routes = null;
+ try { [routes] = await get('routes'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
let limitRows = [], limitsRefused = false;
try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; }
const notify = typeof n.binding === 'string'
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
- return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${assistantSection(s.launch, names)}${instancesSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
+ return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${routesSection(routes)}${assistantSection(s.launch, names)}${instancesSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
}