# 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 0cdff8739051 (0235fa9fd, queue rev 1950; round 1 was blob f3b06a165586). Issue #1558. Reviewers: Darkwing and Dewey. Built in the detached worktree `/mnt/storage/scratch/sage-r65`. Round 2 candidate **8d7c9e2f1** (8d7c9e2f142822046f20eb1e5acad760a674fab8) on base 89ddb0225: 64 files, 2292 insertions, 336 deletions. `candidate.diff` is `git diff 89ddb0225 8d7c9e2f1`; `candidate-manifest.sha256` has the sha256 of each changed file at the candidate. Round 1 was ccdbc2232 on d8336daef; d8336daef..89ddb0225 touches no manifest path (it adds this packet, the brief re-pin, SESSIONS and queue revs 1945-1950). `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. Any refusal of the fallback (preflight, `harness-refused`, `harness-failed`, `thinking-unsupported`) ends with `(primary : )`, so the reason it was tried stays in the launch log. The manifest records `route` (`primary` or `fallback`), `routeReason` (the primary's preflight code, or null) and `harnessVersion`. **Thinking.** pi's adapter passes `--thinking `; Claude Code's passes `--effort `. 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 ` | off, minimal, low, medium, high, xhigh, max | | claude-code | 2.1.302 (`MOSAIC_CLAUDE_BIN` or PATH `claude`) | `--effort ` | 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`, 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. `setRoutes` checks the primary and fallback models against `launch.models` before the file's own validation, which would refuse an unknown primary model with no code, so `routes.set` answers `unknown-model` for either (round 2, Dewey N1). **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 send a `routes` key. The writer logs the human as actor. The bus passes `harness-off`, `harness-refused` and `thinking-unsupported` through as their own codes, and `unknown-model` for `routes.set` only (the older verbs keep `invalid-request`). The chief of staff still reaches one route. Under Q169 (Jason: "The COS and the user should have the ability to (re)configure the assistant role and seat name"), `organization.set` takes `assistants..vars`, which replaces the assistant's whole vars, harness, thinking and `fallback.*` included. Round 2 keeps that and names it in the brief (ruling (b), sage-1006, blob 0cdff8739051): the write goes through `limits.set`, runs the same `checkRoutes`, and logs `chief-of-staff.` as actor. Every other instance's route is human-only. **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. ## Round 2 Round 1 drew changes from both reviewers: Dewey, comment 29136 (rev 1947), and Darkwing, comment 29137 (rev 1948). Each finding and what round 2 does about it: | Finding | Round 2 | |---|---| | Dewey B1, Darkwing F1: the chief can change the assistant's harness, thinking and fallback through `assistants..vars` | Ruling (b), sage-1006: Q169 stands and the brief names the assistant as the one exception (0235fa9fd). The write already runs `checkRoutes`; the new chief-of-staff case proves it through the real bus, refusals and a passing save. Comments corrected in broker.mjs, launch-settings.mjs, routes-verb.test.mjs, the bus and business READMEs and this file. serve.mjs's comment is about the `routes.set` verb, which the chief's tool still can't reach, so it stays. | | Dewey B2, Darkwing F3: no `thinking-unsupported` or dropped-fallback-columns mutant | m8a, m8b and m9 below. | | Dewey B3, Darkwing F2: no test reads a thinking flag from a run | harness.test runs both real adapters against recording stand-in binaries (Tests table). | | Dewey N1: `unknown-model` can't reach the Console | The broker passes `unknown-model` through for `routes.set`, and `setRoutes` checks the primary model before the file's uncoded refusal. routes-verb asserts both. The webui `ROUTE_REASONS` entry is now reachable. | | Dewey N2: a fallback refused at `harnessOf` drops the primary's reason | launcher.mjs appends `(primary : )` to that refusal; launch-routes asserts it for `thinking-unsupported` and `harness-refused`. | REQ-LIMIT-4: both reviewers accept the narrowed launch-limit-checks assertion. Darkwing's optional tightening, pinning the single `createLoader` import, isn't in this round. Darkwing's round 1 runs on ccdbc2232, by path only: `~/darkwing-scratch/r65/singles/` (33 changed test files singly, 311 pass, 0 fail), `mut/` (their m8-m11, all killed; m11 only because the table never renders) and `probe/` (the F1 business-layer probe). Follow-ups, no change in this row (Darkwing's notes): 1. `runner.mjs:276` spreads `session.harnessEnv` last, so a module's env can shadow a core variable. The brief puts env in the module. 2. `launcher.mjs` wraps `levels()` in `call()` but not the `.includes` and `.join` after it, so a module returning a non-array throws a raw TypeError; a preflight result with no `code` gives `Refusal(undefined)`. 3. `adapter()` and `mounts()` run after the workspace and session dirs exist; a throw there leaves them behind. 4. `mosaic-bus.service.in:16-17` still names `claudeVersion`. Already on the row 166 list above. 5. Darkwing and Dewey, round 2: when the chief of staff sends a partial fallback through `assistants`, the write is refused, but with `invalid-request` instead of `organization-partial-fallback`. The refusal holds; only the code is less exact. ## 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; a fallback refused at `thinking-unsupported` or `harness-refused` names the primary's failure (round 2) | | `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 grep test, with the brief's exceptions listed and each still naming a harness; round 2: the real `adapters/pi/adapter.sh` and `adapters/claude/adapter.sh` run against stand-in `pi` and `claude` binaries that record their argv, with each module's `env()` for thinking `xhigh` and `low`, and the recorded argv holds `--thinking ` or `--effort ` exactly once | | `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; round 2: `unknown-model` for a primary or fallback model the table lacks | | `packages/bus/tests/chief-of-staff.test.mjs` (round 2 case) | the chief's assistant write through the real bus: `harness-off` for an off harness, primary or full fallback; `thinking-unsupported` for claude-code at off; a partial fallback refused (`invalid-request`, the file layer's uncoded refusal); each with the bytes and the log unchanged. A valid route with a whole fallback saves, the vars equal the route, and the log's actor is `chief-of-staff.chief` (Dewey's suggested passing case) | | `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 a `routes` key 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`, checked out at 8d7c9e2f1 and the same files as the gated tree by `diff -rq`, before and after the run), 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. Round 2 ran after the gate, in the slot: the control run of the union of the tests passed 87/87 first (round 1: 71/71, 8 mutants). | 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 | | m8a-thinking-write | `checkRoutes` skips `thinking-unsupported` | launch-settings `setRoutes`; routes-verb runtime; the chief-of-staff Q169 route case | | m8b-thinking-launch | launcher skips `thinking-unsupported` | launch-routes thinking test | | m9-fallback-cells | the routing table drops its three fallback cells (`public/bus.js`, mutant tree only) | routes-browser, on the cells' deep-equal | | m10a-pi-thinking-flag | pi's adapter never sets `--thinking` | harness.test adapter argv test | | m10b-claude-effort | Claude Code's adapter drops `--effort` | harness.test adapter argv test | | m11a-chief-skips-checks | `limits.set` skips `checkRoutes` when the actor is a role (`.`) | the chief-of-staff Q169 route case, on a missing refusal | | m11b-chief-refused-route | the chief's assistant write is refused whenever it sets `harness` | the chief-of-staff Q169 route case, on the passing save | | m12-unknown-model-masked | the broker drops the `routes.set` `unknown-model` pass-through | routes-verb runtime | | m13-unknown-primary-uncoded | `setRoutes` checks only the fallback model | launch-settings `setRoutes`; routes-verb runtime | | m14-fallback-refusal-unnamed | a fallback refused at `harnessOf` no longer names the primary's failure | launch-routes thinking test | 18 of 18 killed, each by an assertion failure in the named test, not a load error. Round 2 logs are in `/mnt/storage/scratch/sage-r65-out/mutants/` (console `mutants-r2.console.txt`), round 1's in `mutants-r1/`. Darkwing's m11 shape (dropping the fallback `ROUTE_CELLS`, killed only because the table never renders) is left out on purpose; m9 is the column check. ## Gate Round 2: `/mnt/storage/scratch/sage-r65-out/gate2.sh` (gate.sh with the output dir `g2/`) on the committed candidate 8d7c9e2f1, `dirty 0`. GATE_PASSED, 25 steps, each rc=0. 2514 node tests (2358 package, 156 in test-queue) and 422 script selftests, 0 failed, summed with awk over `g2.console.txt`. The same one webui skip. pkg-bus is 420, one more than round 1 (the new chief-of-staff case); the other brief suites match round 1: pkg-plugins 111, 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. Round 1: `/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`. Landed 2026-10-19 as c216f47db, 8d7c9e2f1 cherry-picked onto 866f3c5a8 with the round 2 message. Approvals: Darkwing 29144 (rev 1953) and Dewey 29145 (rev 1954), both on manifest 8df15e39. Between 89ddb0225 and 866f3c5a8 only this packet, the queue files and SESSIONS changed, none of them a candidate path. The landed commit's path list has the same sha256 as the candidate's, all 64 manifest hashes check against the landed tree, and the public/ +/- digest is a91cf866, the one Mos cleared (mos-0841 condition 3). Gate g3 (`/mnt/storage/scratch/sage-r65-out/gate3.sh`) on c216f47db, `dirty 0`: GATE_PASSED, 25 steps, each rc=0. 2514 node tests (2358 package, 156 in test-queue) and 422 script selftests, 0 failed, summed with awk over `g3.console.txt`, the same counts as g2. The same one webui skip. Network `sage-r65_default` removed, no container left. The mos-0840 view is the Routing section of the Console's Settings page (`

Routing

` in `packages/webui/src/public/bus.js`), which replaces the Role models fieldset. The patch for Jason, sent through Mos: read the revision with `mosaic plugins --business mosaic-stack --json`, then run `mosaic plugins set --business mosaic-stack --file PATCH` with `{"revision": "", "plugins": {"harness": ["claude-code"]}}`. A kind the patch doesn't name keeps its setting, so this adds harness to whatever `plugins` holds. It works only on a stack pinned past c216f47db: an older setter refuses the harness kind as unknown. A new bind reads `plugins.harness` from the file as it stands, so no bus restart is needed for it. After that Sage runs one managed coder session and attaches the manifest (module, route, counted model, thinking).