Skip to content

The /model rows come from a documented setting, and say which computer they mean (#1185, #1177) - #1241

Merged
gen16k merged 1 commit into
mainfrom
feat/1185-model-picker-rows
Sep 5, 2026
Merged

gen16k merged 1 commit into
mainfrom
feat/1185-model-picker-rows

Conversation

@gen16k

@gen16k gen16k commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

The Waired rows of Claude Code's /model picker move from Claude Code's private discovery cache to the documented modelPicker setting, the reserved ids are re-spelled, every row whose side declares a 1M window gets a [1m] twin, and the local row is dropped where local inference is turned off.

Decision docs/decisions/20260903/0333 §5 (publication) and docs/decisions/20260906/0330 (this PR's own ruling). Lane L97 of waired-ai/waired#1312.

What changed

Publication. internal/integration/claudecode/modelpicker.go writes a modelPicker lineup into the user's own ~/.claude/settings.json, classified absent / ours / foreign / unreadable before it is touched — the same shape modelsetting.go uses for a default model. Foreign is left alone and reported: Claude Code takes the whole lineup from the highest source that sets the key and never merges two, so writing over one deletes it. User scope rather than managed for the same reason and one more — it needs no elevation, so it can follow the mesh at session start, and an organisation's managed lineup outranks it whole.

Gone: gatewaycache.go, cmd/waired/claude_models_cache.go, scripts/ci/canary-cache-schema.py, anthropic_models_cache_sync_test.go, and CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY from managed settings (scrubbed on write, and only the value waired itself wrote). The SessionStart hook stays; its command is _picker write --from-managed now and the marker is a prefix, so older forms are replaced automatically. The gateway keeps serving /v1/models for the OpenCode surface.

Ids. waired / waired/local / waired/peer / waired/peer-<node> / waired/public, and <id>[1m]. Every previous spelling still routes — TestEveryPreviousSpellingStillRoutes names each one, and per-peer ids map onto the current prefix by slug so one resolver covers both.

1M twins. Offered only where a node declares a 1M window; none for public share, because this host learns someone else's window only when it answers. RequiredWindowForRequest honours the tier on node-naming rows now, which it deliberately did not before — the comment said naming a node must not also make demands of it, and that is still true of the bare row, which still returns 0. A twin is a different row, only ever offered where the node declares the window, so picking it IS the demand.

#1177. localServes reads SubsystemStateDisabled as well as "no engine at all". rc5 found the row offered on a machine whose operator had turned local inference off; every turn on it failed.

Measured against Claude Code 2.1.261, not assumed

docs/knowledges/20260906/0340-the-model-picker-measured-again.md has the full record. Three things corrected an earlier on-device measurement:

  1. A modelPicker row has a description and the picker renders it as the second line; a row without one reads Custom model (<id>). The note in peerdirectives.go saying there was no description field and every row read "From gateway" was about the private cache. The per-peer rows stop folding the model into the label.

  2. CLAUDE_CODE_MAX_CONTEXT_TOKENS is honoured only for ids that do not start with claude- (!id.toLowerCase().startsWith("claude-")). That is what the old heads were really deciding, and why the local id alone was spelled anthropic-. It went uncosted until 2.1.26x began enforcing an assumed window on catalog-unknown ids: with a claude- head the variable is ignored, the session runs at an assumed 200k, and the picker puts "claude-waired-peer" isn't described by this version's model catalog… on screen. With no head, the variable applies and the notice is gone.

  3. A SessionStart hook's write to settings.json does not reach the session it starts. Settings are read at startup and the watch is armed after; the cache was read after hooks ran. Measured: synchronous, 1 s, 2 s and 3 s all missed; 6 s and 15 s land. A race, not a contract, so the hook does not try to win it — the rows a session sees are the ones written before it started, and the docs say so. Only the per-peer rows and the presence of the public and 1M rows move with the mesh.

Also confirmed and not used: behavesAs on a picker row suppresses the catalog notice without changing the id on the wire, but appears in no public doc (settings-reference, managed-settings, sub-agents, llm-gateway-protocol, model-config all return zero). Depending on it would cut against the point of this lane. Reasoning in the decision record.

The canary now measures something that can still break

It stopped testing a file waired no longer writes. Two legs against the real binary instead: (a) the lineup waired writes parses — a row Claude Code cannot read is dropped silently, so its settings warning is the only signal; (b) CLAUDE_CODE_MAX_CONTEXT_TOKENS still applies to a non-claude- id. Asymmetric: a notice on our spelling fails, no notice on the claude- control only warns, since upstream dropping the enforcement costs waired nothing but leaves the predicate unexercised.

Run locally against 2.1.261 — every check including both new legs passes.

Also fixed, found on the way

  • internal/integration/adapter.go claimed ~/.claude/settings.json is NEVER touched. Untrue since the status line shipped; it now names the three keys waired owns and the ownership rule.
  • The canary watched CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC because docs-site documented it as a cause of missing entries. That guidance was about the cache read and is gone from the page, so the check goes with it.
  • docs/decisions/20260820/0400's supersede note recorded the write target moving; it now also records that its own measurement (the cache is read after the hook) inverted.

Checks

go build ./..., go test ./..., gofmt -l, golangci-lint run --max-same-issues=0, bash -n + shellcheck -x on the canary, npm run i18n:check (32 pairs in sync), npm run build on docs-site. Rebased on main after #1234.

Not yet verified on a real waired host — the picker measurements above were taken against the real claude with a stub gateway, but waired claude enable/model on a machine running the daemon is the after-check, and it is the next thing I do.

Fixes #1185
Fixes #1177
Refs waired-ai/waired#1313

🤖 Generated with Claude Code

https://claude.ai/code/session_01HJUUSmAfbRdjER1seDdjXm

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

📘 Docs preview — the preview channel for this PR has been deleted now that it is closed.

gen16k added a commit that referenced this pull request Sep 5, 2026
…the page (#1215)

Every ja page carried a sourceHash in its frontmatter — a digest of the English
page it was translated from — and i18n:check compared it. That is a derived
value stored in a versioned file: two PRs touching one English page always
rewrote it to two different values and always conflicted, on that line and
nothing else, while the prose merged cleanly.

Measured on main over the 60 days before this: 219 of 622 commits touch a ja
page, 90% of them rewrite the hash, and 35 of 144 overlapping PR pairs share a
ja file. At one or two docs lanes that is a cost per collision. At today's lane
count it is a condition on landing — #1198 held 12 of the 32 ja pages for 2 h
19 m and went CONFLICTING four times, each within nine minutes of a competing
PR merging, and the answer of the day was to hold everyone else's merges. It is
not only wide docs PRs: #1237, a gateway change that touches three docs pages
incidentally, spent two of its four force-pushes on the same line. This PR
itself went CONFLICTING 2 m 40 s after it was opened, before its own CI
finished, on one line of ja/troubleshooting.md.

Removing the stored value removes the class. The freshness question moves to
the diff, where nothing is stored and nothing conflicts: a PR that changes an
English page changes its Japanese one too, enforced by
scripts/ci/i18n-pair-guard.sh in docs-guard.yml, with a
`translation-not-needed: <reason>` line in the PR body for the English edit
that genuinely needs no translation. Replayed over every commit since the
mirror was enforced (#147), that line would have been needed six times across
405 page pairs — three terminology sweeps where an English phrase changed and
the Japanese sentence was already right — and nothing else in the repository's
history would have failed the rule.

A merge driver was considered and does not work: a custom driver only runs
where git config sets it, and GitHub computes mergeability server-side, so the
PR still goes DIRTY and still has to be rebased.

i18n:check keeps the two questions that are about the tree and cannot be
answered from a diff: is there a ja page at all, and do the two sides still
have the same shape (#678, #1011). That comparison now runs on every pair,
always, instead of only on pairs whose hashes already agreed — and that gate
was worth less than it looked, because on the path a stale pair actually takes,
--accept wrote the hash before it ever reached the refusal, recording a lost
heading as intentional and reporting it only on the next run.

--accept and i18n:accept are gone with the value they wrote. So is the "may be
out of date" notice in PageTitle.astro and the second copy of the digest it
carried: deploy-docs.yml runs i18n:check before the build in the same job, so a
page the notice would fire on never reaches the site, and it has never rendered
on docs.waired.ai since it was added alongside the check in #147.

The rule and its opt-out are written where a contributor already reads about
the sibling one — CONTRIBUTING.md next to docs-not-needed:, and CLAUDE.md
§Documentation, whose "npm run i18n:check gates it" was true of freshness
before this change and is not any more.

Sequenced directly with the lanes that had docs PRs open (#1235, #1237 first;
#1241 after), because this touches all 32 ja pages at once.

Fixes #1215

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgrcRiYj339QxzD3b6KijQ
…r they mean (#1185, #1177)

The Waired rows were written straight into Claude Code's own discovery
cache. Not by choice: discovery is credential-gated and waired supplies
none, so the picker's fallback read of that file was the only way the
rows appeared at all. The price was a canary that re-measured somebody
else's private file shape every release.

Claude Code v2.1.242 documents `modelPicker`, which says the same thing
out loud. The rows go there, in the user's own settings.json — not
managed settings: it needs no elevation, and because Claude Code takes
the whole lineup from the highest source that sets the key and never
merges two, an organisation's managed lineup outranks waired's whole
instead of being silently merged into. The same rule is why a lineup
that is not ours is left alone rather than replaced, classified the way
modelsetting.go already classifies a default model.

The rows gained a second line, which is the part people will notice: a
`modelPicker` row has a description and the picker renders it. The
per-peer rows no longer fold the model name into the label — the
computer names the row, the model describes it.

The ids lost their `claude-` and `anthropic-` heads. Those existed to
pass discovery's id filter, and `modelPicker` filters nothing. The one
other thing the heads decided was that CLAUDE_CODE_MAX_CONTEXT_TOKENS is
honoured only for ids that do NOT start with `claude-`, which is why the
local id alone was spelled `anthropic-`. That arrangement started
costing something once 2.1.26x began enforcing an assumed window on
catalog-unknown ids: every `claude-`-headed row ran in a 200k session
and put a notice on screen saying the id is not in the model catalog.
With no head at all the variable applies to every row and the notice is
gone. It is a compaction hint either way; what actually refuses a too
long prompt is this gateway's own 400.

Every row whose side declares a 1M window now has a `[1m]` twin, and a
twin is offered only where a node declares it — a tier is a promise
about the serving node, and a twin with nothing behind it is a menu
entry whose selection fails. There has been no 1M row at all since the
crossing to Anthropic was removed; this is how it comes back, fail
closed. RequiredWindowForRequest honours the tier on node-naming rows
now, which it deliberately did not before: picking "Waired local (1M
context)" IS the demand, and serving it at 200k would be the surprise.

inference off, not only where there is no engine. rc5 found it offered
on a machine that answers nothing, and picking it failed every turn.
SubsystemStateDisabled is that intent on the wire.

Every previous spelling still routes, per-peer ids included, and the
new test says so by name — a session keeps the id it was started with.

The canary stops measuring a file waired no longer writes. It measures
the two things that would break this quietly instead: that the lineup
waired writes parses, and that the window variable still applies to a
non-`claude-` id. Asymmetric on purpose — a notice on our spelling
fails, no notice on the `claude-` control only warns, because upstream
dropping the enforcement costs waired nothing but leaves the predicate
unexercised.

One thing is worse than before and the docs say so. The cache was read
after SessionStart hooks ran, so a hook could refresh it for the session
about to start. Settings are read before the watch that would notice a
hook's write is armed, so a row written by the hook first appears in the
next session. Measured, not assumed: a synchronous hook write and writes
at 1s, 2s and 3s are all missed; 6s and 15s land. That is a race, not a
contract, so the hook does not try to win it. Only the per-peer rows and
the presence of the public and 1M rows move with the mesh.

Fixes #1185
Fixes #1177
Refs waired-ai/waired#1313

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJUUSmAfbRdjER1seDdjXm
Signed-off-by: gen16k <gen16k@users.noreply.github.com>
@gen16k
gen16k force-pushed the feat/1185-model-picker-rows branch from 460e42e to 11c86f8 Compare September 5, 2026 18:37
@gen16k
gen16k merged commit 6e7452f into main Sep 5, 2026
32 checks passed
@gen16k
gen16k deleted the feat/1185-model-picker-rows branch September 5, 2026 19:03
gen16k added a commit that referenced this pull request Sep 12, 2026
…he same thing (#1308, #1310, #1272, #1274) (#1325)

* claude: remember every hook command waired has written

`waired claude _models-cache write --from-managed` was renamed to
`waired claude _picker write --from-managed` in #1185/#1241, and nothing
was left that recognised the old spelling. A marker identifies OUR entry,
so a rename with no forwarding address stranded every SessionStart entry
written before it: `waired claude disable` — and so `uninstall.sh
--clean`, which runs it — left the entry on disk, and a re-enable
appended a second entry beside it instead of replacing it. The stale
command names a subcommand that no longer exists, so it fails at every
Claude Code session start (silently on the Unixes, one line of stderr per
session on Windows).

Measured on three hosts running 0.0.3-rc6; the fixture is the byte-exact
string left on an Ubuntu host by `uninstall.sh --clean`.

Removal and replacement now read a marker LIST per hook — today's plus
every retired one — the shape the retired Stop hook (fallbackHookMarker)
already had for exactly this reason.

Refs #1308

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VB9qaNLWCAdpnoMNW9ZY1H
Signed-off-by: gen16k <gen16k@users.noreply.github.com>

* uninstall: tear the Claude integration down while the agent is still up

`waired claude disable` tells its own CLAUDE_CODE_MAX_CONTEXT_TOKENS from
an operator's by resolving this host's context window, and it resolves it
by asking the running agent. macOS stopped the LaunchDaemon first and ran
the teardown third, so the window was always 0 there, the key was always
kept, and an uninstalled machine went on steering every Claude Code
session that started on it. Linux and Windows already had the right
order; this moves macOS onto it.

waired-agent#1174 anticipated exactly this and added a warning for it --
which all three branches then discarded, macOS and Linux with
`2>/dev/null` and Windows with `2>$null | Out-Null`. The warning was
written for the uninstall transcript, so let it reach the transcript. The
per-user Windows call keeps its quiet form: unelevated, it can only
report a permission it was never going to have.

Refs #1308, #1174

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VB9qaNLWCAdpnoMNW9ZY1H
Signed-off-by: gen16k <gen16k@users.noreply.github.com>

* sign-out: take local inference off the Claude gateway

The :9472 listener is built at boot -- it has to answer before enrollment
-- and the local-inference handler is published once, at activation.
Sign-out had no way to unpublish it: SetLocalInference(nil) returned
early and nothing else wrote the pointer. So a signed-out daemon kept
serving out of a torn-down session, and the gateway's per-request lazy
start brought the inference engine back up, while `sudo waired doctor`
said local inference was offline and :9473 refused connections. Three
surfaces, three answers, all measured on one macOS host after
`sudo waired logout`.

The engine itself already stops on sign-out (the session context runs
ollama.Stop/vllm.Stop and teardown waits on it) -- measured, not read off
the types, which is what waired-agent#1274 asked for. What was missing
was anything stopping it from being started again.

intercept gains Deps.LocalServing, a probe rather than a second Set call
because the answer belongs to the switchboard and changes without this
package hearing about it. A Waired id then fails with the sign-out reason
in the existing waired_cannot_serve shape -- 400, because Claude Code
retries 5xx ten times before showing anything -- and /v1/models stops
splicing Waired ids into the picker, so /model offers nothing that cannot
run.

`waired doctor` stops telling a signed-out computer its turns will go to
another of its computers. It has no others; it is off the mesh.

Fixes #1274
Refs #1310

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VB9qaNLWCAdpnoMNW9ZY1H
Signed-off-by: gen16k <gen16k@users.noreply.github.com>

* status: stop reading "I cannot look" as "this computer is signed in"

The system state dir is 0700 root (SYSTEM+Administrators DACL'd on
Windows) whether or not anything is enrolled inside it, so an unelevated
caller gets fs.ErrPermission either way. `waired status` turned that one
error into "This computer is signed in system-wide" -- measured on macOS
in the same minute `sudo waired status` said "Not signed in." on the same
machine, right after `sudo waired logout`.

Absent, unreadable and enrolled are three states, which is what #1005
established for doctor and #1269 for the app. The answer for an
unreadable directory comes from something that actually observed the
enrollment: the background service, over the management socket, which is
bound 0666 precisely so an unprivileged caller can reach it -- and which
`waired doctor` already asks. Three outcomes now: it says signed in
(today's wording), it says signed out (the plain "Not signed in" message
the caller already had), or it does not answer (say that, rather than
pick one).

`waired auth status` and doctor's state-directory row take the same
argument; doctor's only consumer of that classification is its
daemon-is-enrolled branch, so its wording is unchanged.

Refs #1272, #1310

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VB9qaNLWCAdpnoMNW9ZY1H
Signed-off-by: gen16k <gen16k@users.noreply.github.com>

* sign-out: take the Waired rows out of /model, and say what stays

Owner ruling 2026-09-12: close the local side of the Claude gateway on
sign-out, and stop offering Waired models in /model. The rows live in the
user's own ~/.claude/settings.json since #1185, so removing them needs no
elevation -- which is what makes this compatible with sign-out asking for
none (docs/decisions/20260907/0230). `waired logout` drops them for the
invoking user; the SessionStart hook, which would otherwise rewrite them
at the next Claude Code launch from a base URL that is still in managed
settings, now asks the daemon first and clears instead. Only an explicit
"signed out" clears: a daemon that did not answer is what every machine
looks like for a moment at boot.

Sign-out then says what it deliberately left behind -- the Claude Code
integration itself, because changing it needs administrator rights -- and
that this computer stays in the device list as signed out. That last line
is the `-1` on the next sign-in, told before it happens instead of found
afterwards.

Also corrects runLogout's own doc comment, which claimed the preserved
row is recoverable via `waired init`. It is not: the control plane
matches an enrolling device on its machine key, and sign-out deletes
secrets/machine.key. Coming back as the same device is filed as #1323.

Refs #1310, #1308

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VB9qaNLWCAdpnoMNW9ZY1H
Signed-off-by: gen16k <gen16k@users.noreply.github.com>

* logs: bound the service-log child in both directions

`waired logs` shelled out to journalctl / log / Get-WinEvent with no
process group and no WaitDelay. CommandContext's kill then reached only
the direct child, and CombinedOutput went on waiting for every inherited
pipe writer to close -- so one surviving grandchild held the command open
for as long as it lived. The command a person runs when something is
already wrong is the worst one to hang.

Own process group plus a Cancel that signals the group on the Unixes
(the treatment the engine spawner and the HF downloader already give
their children), and a 2s WaitDelay on all three. The unix test spawns a
grandchild that outlives the shell: without the fix it hung for the full
30s bound, with it the read returns at cancellation.

This is the in-tree half of waired-agent#1308's macOS "log stream"
orphan. The orphan itself is NOT one of ours -- nothing in this repo runs
`log stream`, `waired logs` runs `log show`, and its predicate is
`process == "waired-agent"` rather than the substring match the reported
process carried. Noted on the issue.

Refs #1308

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VB9qaNLWCAdpnoMNW9ZY1H
Signed-off-by: gen16k <gen16k@users.noreply.github.com>

* installtest: make the uninstall legs subjects of the Claude file

None of the three uninstall legs asserted anything about
managed-settings.json except Windows, and Windows only checked that the
file was absent -- against a host this same build had enrolled, so its
SessionStart hook carried today's command and the leftover the issue is
about could not appear. That is why macOS shipped an uninstall that ran
`claude disable` after stopping the daemon, and why a pre-rename hook
survived --clean on every OS.

All three legs now plant the pre-rename shape (`_models-cache`, byte for
byte as measured on a real host) before the uninstall, the way this
harness already plants a staging directory and the Vulkan machine env
vars for the same reason: an assert over a victim nothing created is
vacuous. macOS additionally asserts the ORDER -- the Claude teardown
before the service teardown -- which is the half the file contents cannot
show on a runner with no engine to resolve a context window from.

Refs #1308

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VB9qaNLWCAdpnoMNW9ZY1H
Signed-off-by: gen16k <gen16k@users.noreply.github.com>

* intercept: fold the one-line wrapper into WriteCannotServe

Its only call site now passes a reason, so the no-argument form was
unused. The contract it documented (400, the error type, the escape
hatch) moves onto the function that still exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VB9qaNLWCAdpnoMNW9ZY1H
Signed-off-by: gen16k <gen16k@users.noreply.github.com>

* docs: say what a sign-out stops, and what it deliberately leaves

The CLI reference told people `waired status` "reports that the computer
is signed in system-wide and stops there", and the troubleshooting page
said it "rather than guess, tells you the device is enrolled" -- which is
the guess waired-agent#1272 is about. Both now describe what it actually
does: ask the background service and say what the service says, including
"cannot tell" when neither source is available.

The logout section gains what sign-out stops (local inference, the
/model rows) and the two things it deliberately does not touch: the
Claude Code integration, because changing it needs administrator rights
and signing out does not ask for any, and the device-list entry, which is
where the "-1" on the next sign-in comes from.

The decision record is the answer to waired-agent#1274, with the
measurement it asked for: the engine already stops on sign-out; what was
missing was anything stopping it being started again.

Refs #1274, #1310, #1272

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VB9qaNLWCAdpnoMNW9ZY1H
Signed-off-by: gen16k <gen16k@users.noreply.github.com>

* statehint: name the Windows half of the channel too

The comment described the unix socket's 0666 mode as if it were the
whole story; the Windows named pipe grants Interactive Users, which is
what makes the same unelevated read work there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VB9qaNLWCAdpnoMNW9ZY1H
Signed-off-by: gen16k <gen16k@users.noreply.github.com>

---------

Signed-off-by: gen16k <gen16k@users.noreply.github.com>
Co-authored-by: gen16k <gen16k@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant