Skip to content

feat(desktop): composer model/mode pills, and the three switches that never worked (R6) - #573

Merged
physercoe merged 2 commits into
mainfrom
r6-composer-pills
Aug 17, 2026
Merged

feat(desktop): composer model/mode pills, and the three switches that never worked (R6)#573
physercoe merged 2 commits into
mainfrom
r6-composer-pills

Conversation

@physercoe

Copy link
Copy Markdown
Owner

Closes the R6 wedge of docs/plans/desktop-companion-vision-parity.md. W4 now has only R5 left.

What the director gets

Two pills beside the context ring, in the composer's own row — these are questions asked while typing ("which model is about to answer this?"), not facts looked up in a details panel.

  • An agent that advertises its choices (the ACP engines) gets a menu.
  • claude, which advertises none, gets a text entry taking an alias or a full model name.
  • A switch on the respawn route restarts the agent. The pill previews that and asks first (IAA) instead of doing it and letting you find out.
  • A pill that cannot change anything is shown greyed with the reason, not hidden. Mobile's picker hides whenever nothing is advertised, which on a claude session means no model indicator at all — "cannot be changed" and "unknown" are different facts.

Building the consumer measured the producer

Three of the four (family, field) pairs the Companion drives could never have switched anything:

family field flag the table wants reality
claude-code model --model in every claude template's cmd — works
claude-code mode --permission-mode real flag (2.1.220: acceptEdits|auto|bypassPermissions|manual|dontAsk|plan), in no template
codex model --model codex app-server, the M2 argv, accepts none
codex mode --approval-policy not a codex flag — 0.147.0: "unexpected argument … a similar argument exists: --approve-for-me"; the real one is -a, --ask-for-approval

respawn rewrites a flag that must already be in backend.cmd, so the last three answered a 422 reading "pick a fresh template that exposes it" — advice pointing at a template that cannot exist.

Root cause: one routing token answered for two independent capabilities. Families now declare runtime_switch_fields: {mode, model} alongside the route; the hub checks it before routing; GET /agent-families publishes it so the client hides a control rather than discovering the refusal from an error.

This is not the agents.kind defect fixed in df24415c — that gate matched no family at all. This one matched the right family and asked it a question the data could not express.

Two deliberate non-fixes

  • Claude's mode pill is read-only on purpose. Shipping a template with --permission-mode is necessary but not sufficient: L3a measured that --permission-mode plan does not stop Bash under --print (claudewire.test.ts:56), so an M2 pill offering plan would name a boundary the engine does not enforce.
  • Codex has a real path that is not respawnthread/start.config takes a map of config overrides (measured in L4c), as does -c model=…. Driver work, filed as i18n: localize team / profile switcher menu (#236) #243. Flipping the registry bit lights the pill up with no UI change.

Verification

  • 10 new state tests, both mutation-checked: same-event list capture (the pre-W7c bug mobile hit) and a route-only gate each fail them.
  • The Go case set separates the mask from the route — every claude/codex row routes respawn, so a mask that mirrored the route would have passed; (claude-code, model)=true vs (claude-code, mode)=false is the separating input. Mutation-verified.
  • go test ./internal/server ./internal/hostrunner ./internal/agentfamilies ✅ (the schema-coverage guard caught the undeclared field; schema updated)
  • desktop frontend node --test: 886 pass, 0 fail · electron shell: 715 pass, 0 fail, 6 skipped
  • tsc --noEmit ✅ · full vite build ✅ · token ratchet clean, no phantom tokens (five of my first-draft tokens did not exist)
  • CI's whole lint set ✅ including lint-openapi.sh (openapi.yaml updated for the new field + the second gate)

Known gap — #242

A respawn mints a new agent id. SessionsPanel follows it (it keys the transcript on session:agent from the digest's current_agent_id, and this PR invalidates those caches). FocusRegion and ProjectBoard mount a fixed id and stay pointed at the terminated agent — the same exposure the pause/stop lifecycle actions already have. Filed rather than half-built here.

🤖 Generated with Claude Code

… never worked (R6)

Two pills beside the context ring, in the composer's own row: which model
is about to answer, and which permission mode it runs under. An agent that
advertises its choices gets a menu; claude, which advertises none, gets a
text entry. A switch on the respawn route is previewed before it fires —
it restarts the agent, and the pill says so rather than letting the
director find out.

Building the consumer measured the producer, and three of the four
(family, field) pairs the Companion drives could never have switched
anything:

  claude-code/model  --model            in every claude template  → works
  claude-code/mode   --permission-mode  real flag, in NO template → 422
  codex/model        --model            `codex app-server` has none
  codex/mode         --approval-policy  not a codex flag at all

codex-cli 0.147.0: "error: unexpected argument '--approval-policy' found;
tip: a similar argument exists: '--approve-for-me'" — the real one is
`-a, --ask-for-approval`. `respawn` rewrites a flag that must already be
in backend.cmd, so the last three answered a 422 reading "pick a fresh
template that exposes it" — advice for a template that cannot exist.

The routing table could not express this: one token answered for two
independent capabilities. Families now declare `runtime_switch_fields:
{mode, model}` alongside the route, the hub checks it before routing, and
`GET /agent-families` publishes it so the client hides a control instead
of discovering the refusal from an error. This is NOT the `agents.kind`
defect fixed in df24415 — that gate matched no family at all; this one
matched the right family and asked it a question it could not express.

Claude's mode pill is read-only on purpose, not deferred: L3a measured
that `--permission-mode plan` does not stop Bash under `--print`, so an M2
pill offering it would name a boundary the engine does not enforce.
Codex's real path is `thread/start.config` (measured in L4c), which is
driver work — task #243; flipping the registry bit lights the pill up with
no UI change.

Porting mobile's picker exactly would have hidden the pill whenever the
agent advertises nothing, leaving a claude session with no model indicator
at all. The desktop adds a read-only state mobile has no equivalent for,
so "cannot be changed" and "unknown" stop looking identical.

Tests: 10 new state tests, both mutation-checked — same-event list capture
and a route-only gate each fail them. The Go case set separates the mask
from the route (one family, one route, two different answers).

Known gap, filed as #242: a respawn mints a new agent id. SessionsPanel
follows it (keyed on session:agent from the digest); FocusRegion and
ProjectBoard mount a fixed id and stay pointed at the terminated agent —
the same exposure the pause/stop actions already have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…solver

The resolver test pins the mask's VALUES — (claude-code, mode)=false and
both codex fields false — but nothing exercised the handler actually
consulting them: deleting the `!switchFields[field]` gate left the whole
server suite green while turning every masked switch back into the doomed
respawn attempt (a 500 "no live session" in the test rig; the misleading
422-with-template-advice in production) that R6 exists to remove.

Three cases, one per wrong (family, field) pair, each asserting the typed
422 lands BEFORE the route and that a refusal writes no input event row.
Verified by mutation: neutralizing the gate fails all three with
`status = 500 want 422`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@physercoe
physercoe merged commit df76194 into main Aug 17, 2026
8 checks passed
physercoe pushed a commit that referenced this pull request Aug 17, 2026
…atus docs

Both PRs rewrote the plan's W4 status paragraph and appended under the
changelog's Unreleased headings — the collision called out at review time.

- plan: united to "W4 complete (2026-08-17)" naming all of F4 / L4a–L4c /
  R5 / R6, remaining = L3c only (deferrable).
- changelog: union — both Added entries kept (R5 above R6), both Fixed
  entries under one heading.

Everything else auto-merged; verified on the merged tree: agentfamilies +
hostrunner drift/parity tests (the auto-merged generated artifact matches
a regeneration of the merged YAML), electron 720/0, frontend 908/0, docs
lint 323.

Co-Authored-By: Claude Fable 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

Development

Successfully merging this pull request may close these issues.

2 participants