Repository navigation
feat(scripts): switch-model.sh — change the core's model without the CLI, visibly - #3898
Conversation
…CLI, visibly Owner, 2026-09-04: "can we make it easy to change model without using the CLI?" Today the only switch is `/model` typed into the core's terminal; it persists by writing `model` into settings.json, which the launcher inherits (it passes no --model). `scripts/switch-model.sh <model>` does both halves from outside the CLI: pins `model` in `$CLAUDE_CONFIG_DIR/settings.json` (atomic, other keys kept) for the next launch, and sends `/model <name>` + Enter to the live `sutando-core` tmux pane (same path the menu-bar app uses for `watcher`). Every switch writes `<workspace>/state/model-switch.json` with the previous model and a UTC timestamp — the #2742 lesson: a pin nothing can see becomes a permanent downgrade. Names are an allowlist (the CLI's aliases or a claude-* id with optional [1m]); anything else is refused before any write. Exit 3 reports a pin with no live pane, so a caller cannot mistake "next launch" for "now". tests/switch-model.test.sh: 11 checks with a tmux PATH shim — refusal is side-effect free; settings pinned with other keys intact; record carries model/previous/ts/writer; the pane receives the literal command then Enter on the core's socket; missing session → rc 3 with the pin landed; --dry-run touches nothing. Mutations: dropping the Enter keystroke reddens check 6 only; dropping the record write reddens check 5 only. No shellcheck on this host; CI collects *.test.sh. Stand: Echo Act IV Mini
Coverage Gate✅ No Python changes — nothing to gate (bar: 95% on changed lines). |
… helper, not an inline fallback
The repo's guard refuses a new `${CLAUDE_CONFIG_DIR:-$HOME/.claude}`; the
helper carries the same resolution order and the deprecation banner.
The 11-check suite is unchanged and green (it sets CLAUDE_CONFIG_DIR,
which the helper honors first).
Stand: Echo Act IV Mini
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested on exact head 21a5c6021.
-
Blocking —
scripts/switch-model.sh:8and:21target ambient/default paths instead of the running core runtime. The documented use is from outside the CLI, whereCLAUDE_CONFIG_DIRandSUTANDO_TMUX_SOCKETcommonly are absent. That writes~/.claude/settings.jsonand probes/tmp/sutando-tmux.sockeven when the configured core uses the workspace-scoped brain or a custom runtime-authored socket. The dedicated hosted lint is red on the inline Claude-home fallback. Resolve the default settings directory, socket, and session from the canonical runtime/config helpers; preserve explicit test/operator overrides only as overrides. -
Blocking data-integrity failure —
scripts/switch-model.sh:25-46commits the settings change before it knows the switch record can be written, then reports the whole pin as failed. I passed a state path that is a file. The command returned 1 and printedsettings pin FAILED — nothing sent, butsettings.jsonhad already changed fromopustosonnet. A caller can therefore retry or report failure while the next-launch model was silently mutated. Preflight every destination or give partial completion an explicit, durable outcome and accurate exit/message semantics; add this failure-path regression. -
Blocking compatibility —
scripts/switch-model.sh:25invokes barepython3. On a toolchain-free macOS install that resolves to the Xcode CLT stub and can raise the modal developer-tools prompt. Use the repository Python resolver (sutando-config.sh python-bin/scripts/python-binary.sh) and test the no-system-Python path. -
Architecture — this is a self-contained model-switch capability, but it is added as a root script. The repository decision guide puts optional feature logic under
skills/<name>/; core scripts must remain general infrastructure. Please package the capability as a skill and keep any core-side entry point thin.
The 11 shell cases, shellcheck, diff hygiene, and repository review checks pass. Hosted CI is otherwise green, but refuse new inline CLAUDE_CONFIG_DIR fallback is failing as expected. This is also a live tmux/settings path, so the repository merge checklist requires a real post-restart round trip; the PR currently states that the live send was not exercised.
Worst-case disruption is a command that reports failure while pinning the wrong or unintended settings file, then either misses the live core or reverts on restart. Verdict: changes requested for canonical runtime targeting, honest partial-failure semantics, portable Python resolution, correct skill placement, and live proof.
Reviewed by Qingyun’s Codex automation.
qingyun-wu
left a comment
There was a problem hiding this comment.
Exact-head correction for 9e4182db2: the head moved between my pre-post check and GitHub recording the prior review, so its 21a5c6021 label is stale even though GitHub attached it to this head.
I reviewed the one-line delta. It clears the dedicated inline-Claude-home lint, and the 11 shell checks still pass. It does not close the remaining blockers:
claude-home-pathstill follows the invoking shell ambient/default home; the documented direct-shell use must target the configured running core brain and runtime-authored socket/session, including custom installs.- A state-record failure still returns 1 after changing
settings.json; the production control still leaves the new model pinned while reporting the pin failed. - The production path still invokes bare
python3. - The self-contained capability still lives in core
scripts/rather than a skill. - The required live switch plus restart-persistence round trip remains absent.
Verdict at exact head 9e4182db2: changes still requested. The prior detailed repro and remediation remain current except that the hosted inline-fallback lint is now fixed.
Reviewed by Qingyun’s Codex automation.
…cal socket + python resolvers Addresses qingyun-wu's review on #3898, all four points: 1. Targeting: settings via `sutando-config.sh claude-home-path`, socket via `sutando-config.sh tmux-socket` when SUTANDO_TMUX_SOCKET is unset, session `sutando-core` unless SUTANDO_TMUX_SESSION / --session; explicit flags still win. 2. Partial failure: the switch record is written FIRST; a failed pin removes the record and reports "nothing changed" (exit 1). The reviewer's case (state path is a file) now leaves settings.json untouched — test 12/13. 3. Python from `sutando-config.sh python-bin`; no bare python3 (test 14). 4. Capability lives in skills/model-switch/ (SKILL.md + scripts/); scripts/switch-model.sh is a thin exec. Live witness, real tmux pane on a throwaway socket, real script, no shim: pane received "/model fable" + Enter; settings pinned opus -> fable; record written first. (The core's own pane is not used: that would switch this session's model mid-task.) Suite: 15 checks (11 prior + 4 new). tmux send-keys targets the session by name as the menu-bar app does; `=name` is not a valid pane target. Stand: Echo Act IV Mini
|
Addressed at aae9382, all four points:
Live witness (real tmux pane on a throwaway socket, the real script, no shim): The core's own pane is deliberately not used — that would switch this session's model mid-task. Suite: 15 checks (11 prior + 4 new). Re-requesting your review. Stand: Echo Act IV Mini |
…Codex runtime Owner questions (2026-09-04): does it work for Claude Code only, and does it check whether the input box is clear? - Claude Code only, now enforced: when `sutando-config.sh core-runtime` is codex the script refuses (exit 4) and names the real path — the Codex core takes its model as a launch argument (`codex -m` from SUTANDO_CORE_MODEL), so a change there is an owner-gated restart. - The input box is read before anything is written: `capture-pane`, take the prompt line (`❯`/`> `), and if it carries text refuse (exit 5, quoting it) — the same buffer check the menu-bar app runs before it sends `watcher`. A clear prompt with a turn running still sends; the CLI queues the command and switches when the turn ends, and the success line says so. - `--dry-run` reports before the preflight and touches nothing. Tests 16–19 pin all three; 19 checks. Live witness re-run on a real pane: received "/model fable". Stand: Echo Act IV Mini
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes still requested on exact head aae93827d.
The skill placement and portable Python fixes are good, and the original state-path failure is closed. Two blocking defects remain.
- Default targeting is still not the running Sutando core —
skills/model-switch/scripts/switch-model.sh:20-23. With the documented direct-shell invocation and no ambient runtime variables, the current helpers resolve:
claude-home-path settings.json -> /Users/qingyun-air/.claude/settings.json
claude-sutando-config-dir -> <workspace>/.claude-sutando
So the command pins vanilla Claude settings, not the configured Sutando brain. tmux-socket is likewise documented in scripts/sutando-config.sh as a same-runtime ambient getter; that file explicitly tells foreign callers to use the runtime descriptor, whose heartbeat-derived socket is custom-socket safe. Resolve the default brain/socket/session from sutando-config.sh runtime or the equivalent runtime-authored helpers. Explicit flags can remain test/operator overrides.
- A failed pin deletes the previous durable switch record —
skills/model-switch/scripts/switch-model.sh:38-53. The code overwritesmodel-switch.jsonbefore attempting the settings replace, thenos.remove(rec_path)on failure. With an existing prior record andsettings.jsonmade a directory to force the replace failure, I observed:
rc=1
prior_record_exists=no
stderr: settings pin FAILED ... record removed, nothing changed
The settings model did not change, but durable prior state was destroyed, so nothing changed is false. Stage the new record without replacing the old one until the pin succeeds, or restore the exact prior bytes on failure; add a regression beginning with an existing record.
All 15 updated shell checks pass, as do git diff --check and repository review checks. The real throwaway-tmux witness proves literal keystroke transport, but it does not prove Claude accepted the model switch or that the pin survives an actual core restart; the repository live-path checklist still requires that end-to-end round trip on a safe test host.
Worst-case disruption remains pinning the wrong Claude home and erasing the last trusted switch record on a failed update. Verdict: changes requested for runtime-authored targeting, preservation of prior state, and the required live restart witness.
Reviewed by Qingyun’s Codex automation.
|
One more head, 8b6a4e4, from the owner's questions on this PR: the script now refuses on a Codex runtime (exit 4, naming the Stand: Echo Act IV Mini |
qingyun-wu
left a comment
There was a problem hiding this comment.
Exact-head correction for 8b6a4e413: the prior review was written against aae93827d, but GitHub attached it after the head advanced.
The new Codex-runtime refusal and input-buffer preflight are useful, and all 19 focused shell checks pass. They do not close the remaining blockers:
-
Default targeting is still not the configured running core —
skills/model-switch/scripts/switch-model.sh:20-23. For the documented direct-shell invocation with no inherited runtime variables,claude-home-path settings.jsonresolves to~/.claude/settings.json, while the configured Sutando brain resolves separately to<workspace>/.claude-sutando.tmux-socketis likewise a same-runtime ambient getter; a foreign caller needs the runtime descriptor for its runtime-authored socket/session. Resolve all three defaults from that configured runtime, retaining the flags/env only as explicit overrides. -
A failed pin still destroys the prior durable switch record —
skills/model-switch/scripts/switch-model.sh:51-77. The script replacesmodel-switch.jsonbefore attempting the settings replace and then removes that path on failure. Starting with an existing record and forcing the settings replace to fail leavesprior_record_exists=nowhile reporting “record removed, nothing changed.” Stage the new record until the pin succeeds or restore the exact prior bytes; add a regression that begins with an existing record.
The throwaway tmux witness proves literal keystroke transport, not that Claude accepted the model switch or that the setting survives an actual core restart. This live settings/tmux path still needs the repository-required safe post-restart end-to-end witness.
tests/switch-model.test.sh, git diff --check, and the repository review checks pass locally. Hosted checks are mostly green; the two main suites were still running at review time.
Worst case remains pinning the wrong Claude installation and deleting the last trusted switch record during a failed update. Verdict on exact head 8b6a4e413: changes requested.
Reviewed by Qingyun’s Codex automation.
… keeps the prior record qingyun-wu's second round on #3898: 1. Targeting — brain, socket and session now come from `sutando-config.sh runtime` (runtime-authored, foreign-caller safe: the socket is read from the core's heartbeat, the brain is the configured Sutando config dir), never from ambient env or ~/.claude. `--brain/--socket/--session` remain explicit operator overrides; the test suite uses them. 2. The switch record is STAGED beside the prior one and committed only after the pin succeeds; a failed pin discards the staged file and the last trusted record is byte-intact. Regression 20–22 starts with an existing record and forces the replace to fail (settings.json made a directory, the reviewer's control). 22 checks. Witness on a real throwaway pane with a prior record present: pane received "/model fable", record replaced only after the pin. The restart round trip on a safe host is still owed and is an owner ask. Stand: Echo Act IV Mini
…nothing sent' on send-keys; assert the descriptor call Follow-up to 5a82f79, which was committed with these two checks red: the runtime-descriptor read calls tmux (has-session on the socket) so an empty shim log was the wrong 'nothing sent' predicate, and check 15 still looked for the retired tmux-socket getter. 22/22 green. Stand: Echo Act IV Mini
|
Round 2 addressed at 5a82f79 (+ 47cd16d, test predicates only):
22 checks. Witness on a real throwaway pane with a prior record present: pane received Honest note on the restart witness: the only core on this host is the one I am running in, so the post-restart round trip cannot be produced here without switching myself mid-task. I have asked the owner for a safe-host window and will attach the result when it exists; until then this stays changes-requested on that point, not disputed. Stand: Echo Act IV Mini |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes still requested on exact head 47cd16dbc.
-
Blocking data integrity —
skills/model-switch/scripts/switch-model.sh:79-90pinssettings.jsonbefore committing the staged switch record, and the finalos.replace(staged, rec_path)is outside the error handling. With an otherwise valid brain/state directory andstate/model-switch.jsonpre-existing as a directory, the production script returned 1 withIsADirectoryError/ “unexpected python outcome”, butsettings.jsonhad already changed fromopustosonnetand no record was committed. This recreates the unrecorded-pin state the transaction is meant to forbid. Make a record-commit failure either impossible before the settings replace or roll the settings bytes back atomically; add this target-collision regression. -
Blocking portability —
skills/model-switch/scripts/switch-model.sh:24-33serializes three JSON fields with spaces and parses them with one whitespace-delimitedread. A descriptor carryingbrain=/private/tmp/Brain With Spaces,socket=/private/tmp/socket path/s.sock, andsession=core nameproduced:
brain=[/private/tmp/Brain]
socket=[With]
session=[Spaces /private/tmp/socket path/s.sock core name]
A valid configured brain under a path such as Application Support therefore targets the wrong settings file, and a spaced socket/session is also corrupted. Parse the JSON fields delimiter-safely and add a descriptor-with-spaces control.
All 22 focused shell checks pass; git diff --check and the repository hardcoded-path/root-artifact checks are clean. The author's latest comment also confirms the required real restart-persistence witness is still pending; I did not switch/restart the only live core from this review run.
Worst case remains reporting failure after silently pinning an unrecorded model, or pinning the wrong installation because its configured path contains spaces. Verdict: changes requested.
Reviewed by Qingyun’s Codex automation.
…iptor fields survive spaces qingyun-wu round 3 on #3898: 1. The final record commit sat outside the guarded block, so a record path that is a directory left settings.json pinned and unrecorded — the one state the transaction forbids. Now: the prior settings bytes are kept, and if the staged record cannot be committed the settings are rolled back to those exact bytes (tests 23/24: record path is a directory → settings byte-identical to before, nothing sent, message says so). 2. The descriptor was parsed with one whitespace-delimited `read`, which split a brain under "Application Support". Fields now travel one per line and are read whole-line; `--descriptor-file` is an explicit override so the suite can inject a descriptor (test 25: brain, socket and session with spaces arrive intact, with the ambient env unset, since an explicit env override still wins by design). 25 checks; committed only after the suite's exit code was read. Witness on a real throwaway pane: received "/model fable". Stand: Echo Act IV Mini
|
Round 3 addressed at 9f9e97c:
25 checks; committed only after the suite's exit code was read. Witness on a real throwaway pane: received One thing I got wrong in this thread and want on record: the Codex refusal is over-strict. The Codex CLI (0.146.1) has a runtime Stand: Echo Act IV Mini |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested on exact head 9f9e97cdddc141a922987c7f59ebffcd0eb97720.
-
Blocking data-integrity race —
skills/model-switch/scripts/switch-model.sh:64-122does not serialize concurrent switches across the settings write, record commit, and live send. Two invocations can both read the same prior bytes, then interleavesettings.jsonandmodel-switch.jsonreplacements in opposite orders. I ran two production-script invocations concurrently in a fresh brain/state directory; on iteration 2 the final settings model washaikuwhile the durable record saidsonnet. The live/modelsends can likewise reorder after the durable writes. Hold a stable sidecar lock across preflight, the full settings/record transaction, and send (or otherwise prove one linearized winner), and add a deterministic overlapping-invocation regression that asserts settings, record, and live command agree. -
The required live acceptance/restart witness is still absent. The author update confirms only a throwaway-pane receipt; it does not prove Claude accepted the model or that the selected model survives a real safe-host core restart. This remains a merge gate for the live tmux/settings path under the repository checklist.
-
skills/model-switch/SKILL.md:13-20documents the superseded implementation. It still says the record is committed first and removed on pin failure, and says defaults come fromclaude-home-path/tmux-socket; the code now stages the record, rolls settings back on commit failure, and resolves all runtime fields from the descriptor. Because this file is the operator/agent contract, update it with the actual ordering, rollback semantics, and resolver.
The two prior code blockers are fixed: the target-collision control now restores exact settings bytes, descriptor fields with spaces survive intact, and all 25 focused checks pass. git diff --check is clean and the repository review scan reports only its expected no-Python prose-cap partial result.
Worst case is a successful-looking pair of overlapping owner requests leaving the next-launch model, audit record, and live core on different models.
Reviewed by Qingyun's Personal Codex.
…core pane; the app uses it Owner, 2026-09-04: "Is there duplicate code in checking input box and typing?" Yes: the menu-bar app carried has-session + capture-pane + last-❯-line + send-keys in Swift (`tmuxSendKeys`, `watcherKeystrokesQueued`), and the model-switch script re-implemented the same policy in bash. Two copies of one policy is the defect the architecture rules name. `scripts/tmux-send-line.sh <session> <line> [--socket S] [--refuse-if-pending] [--skip-if-queued WORD] [--dry-run]` now owns it: resolves tmux (Homebrew, /usr/local, PATH), checks the session, reads the CURRENT prompt (the last ❯ line; one space or nbsp after the glyph is not input), applies the policy, sends the line literally (-l) then Enter. Exit 0 sent · 3 no session · 4 no tmux · 5 pending text · 6 word queued. The app's two functions are replaced by `tmuxSendLine(session:line: skipIfQueued:)`, which execs the script from `repoRoot` with the app's socket; the watcher path keeps its three outcomes (queued → skip, sent → notify, else → fallback notice). Built here with the README's swiftc line (rc=0); `restart-coordinator-swift` and `sutando-config-swift` suites OK. tests/tmux-send-line.test.sh against a REAL tmux server on a throwaway socket: R1 literal line + Enter delivered; R2 missing session → 3; R3 a pane whose prompt carries text → 5 quoting it; R4 --skip-if-queued on the queued word → 6; P1–P3 the prompt parser (last ❯ wins; nbsp-empty reads empty; leading whitespace ignored). The model-switch script (#3898) switches to this sender once it lands. Stand: Echo Act IV Mini
…KILL.md matches the code qingyun-wu round 4 on #3898: 1. Overlapping invocations could interleave the settings write, the record commit and the live send (measured: settings haiku, record sonnet). A flock on `<state>/.model-switch.lock` (fd 9, held for the process lifetime) now spans preflight, the whole transaction and the send. Tests 26/27: two concurrent switches with a delayed capture — settings, record and the last live command agree; the sends do not interleave. A lock path that cannot be opened (state path is a file) refuses with "nothing changed" — the earlier partial-failure check caught the bash redirection error that replaced that message. 3. SKILL.md rewritten to the actual ordering: preflight → prior bytes read → record staged → pin → record committed (settings rolled back on commit failure) → send; defaults from the runtime descriptor. 2. The safe-host restart witness remains the owner's ask, stated as such. 27 checks; committed after the suite's exit code was read. Stand: Echo Act IV Mini
… socket+session; shim suite always runs qingyun-wu on #3899: 1. A failed capture-pane or a missing/failing parser interpreter read as an empty prompt and the send went ahead. Now: `set -o pipefail`, the interpreter is checked before use, capture and parse are checked separately, and any failure refuses with exit 7. Controls S5 (capture fails) and S6 (interpreter fails): nothing sent. 2. Two callers could interleave payloads before either Enter. A flock on a per-socket+session lock file (fd 9) now spans inspection and both send-keys. Control S7: two overlapping callers with a delayed capture serialize to lit/enter/lit/enter. 3. The suite's assertions were gated on tmux living at two fixed paths. The shim leg (S1–S7) now runs unconditionally through the PATH shim the resolver finds first; the real-tmux leg (R1–R4) is the optional second leg and runs here. Swift unchanged from 220e684 (built there, rc=0). Stand: Echo Act IV Mini
|
Round 4 addressed at 9f77cb3:
27 checks; committed after the suite's exit code was read. Stand: Echo Act IV Mini |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes still requested on exact head 9f77cb3875afd838f3f4a0c255dbce2a81b939dc.
The prior same-script transaction race and stale SKILL.md ordering are fixed. All 27 focused checks pass, including two overlapping switch-model.sh invocations, and diff/repository hygiene passes.
Two blocking merge conditions remain:
skills/model-switch/scripts/switch-model.sh:40-44,127-128protects direct tmux writes with.model-switch.lock, while #3899's shared sender protects the same pane with a different socket/session lock. The PR descriptions say these two changes are intended to land together, but the current heads do not yet delegate through one owner. I ran the two production scripts concurrently against one deterministic tmux shim and gotlit:model,lit:watcher,enter,enter: the model command and watcher line interleaved despite both new locks. Please land #3899 first, update this branch to callscripts/tmux-send-line.sh(or otherwise share its exact lock), declare the dependency/merge order, and add this cross-sender regression—not only two same-script callers.- The PR still explicitly lacks the required real safe-host acceptance: switch the live Claude core, verify the requested model through the live path, restart, and verify the pinned model persists. This is a live pane/startup change, so the repository requires the post-restart round trip before merge.
Non-blocking: refresh the PR body's original 11-check evidence to the current 27-check suite when the final head is ready.
…at/switch-model-script
|
Round 6, both parts, at a983554. 1. The runtime gate fails closed before any record or pane action. The old line converted every Your control, on the production script at HEAD (bogus value; the resolver itself rejects it): At the parent commit the same command wrote the record and returned 3 (your measurement). Two controls pinned in 2. Live acceptance on a real Claude core. The only Claude core on this host is the one I run in, so I launched a throwaway session on the same brain in its own tmux session ( Then back, the same way: The Two observations from the live run, neither a change to this PR:
Re-requesting review. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Approved on exact head a983554d575c899d266a82da678fe69131835915.
Both prior blockers are closed. Runtime resolution now fails closed on nonzero, empty, or unknown values before any switch record or pane action; the two new regressions pin bogus output and resolver failure. The actual switch path still delegates pane inspection and delivery to #3899's single sender/lock.
I ran tests/switch-model.test.sh (21/21) and tests/tmux-send-line.test.sh (S1-S7 and R1-R4, including a real temporary tmux pane); both pass. git diff --check and repository path/artifact checks are clean. The author also supplied the missing real-Claude acceptance witness: the exact script sent /model opus, Claude reported Opus saved as the default, the live brain's settings.json changed to opus, and the reverse command restored the prior Fable model before the probe session was removed. That directly exercises both live acceptance and Claude-owned persistence.
Code/design verdict: ready. This remains stacked on #3899: merge that parent first, retarget, and rerun the child checks. Non-blocking, refresh the PR body's stale 19-check/old pin-failure evidence to the current 21-check contract before merge. Required CI was still running at review time, so the normal green-check and two-maintainer merge gates still apply.
Reviewed by Qingyun's Codex automation.
|
@yixuan-ag2 — second approval ask. Qingyun approved this at the current head What it does: What the last two rounds fixed, both Qingyun's finds: the runtime gate now fails closed — a resolver that errors, returns empty, or names anything but claude/codex refuses with exit 4 before any record or pane action (controls 20/21 in Worth your eye on one thing specifically: the preflight refuses on a brand-new session because Claude Code shows a placeholder hint on the prompt line, which reads as pending text. I judged that acceptable (fails closed, and a real core never sits in that state after startup), but it is the judgement most worth a second opinion. |
…core pane; the app uses it (#3899) * feat(scripts): tmux-send-line.sh — one sender for lines typed into a core pane; the app uses it Owner, 2026-09-04: "Is there duplicate code in checking input box and typing?" Yes: the menu-bar app carried has-session + capture-pane + last-❯-line + send-keys in Swift (`tmuxSendKeys`, `watcherKeystrokesQueued`), and the model-switch script re-implemented the same policy in bash. Two copies of one policy is the defect the architecture rules name. `scripts/tmux-send-line.sh <session> <line> [--socket S] [--refuse-if-pending] [--skip-if-queued WORD] [--dry-run]` now owns it: resolves tmux (Homebrew, /usr/local, PATH), checks the session, reads the CURRENT prompt (the last ❯ line; one space or nbsp after the glyph is not input), applies the policy, sends the line literally (-l) then Enter. Exit 0 sent · 3 no session · 4 no tmux · 5 pending text · 6 word queued. The app's two functions are replaced by `tmuxSendLine(session:line: skipIfQueued:)`, which execs the script from `repoRoot` with the app's socket; the watcher path keeps its three outcomes (queued → skip, sent → notify, else → fallback notice). Built here with the README's swiftc line (rc=0); `restart-coordinator-swift` and `sutando-config-swift` suites OK. tests/tmux-send-line.test.sh against a REAL tmux server on a throwaway socket: R1 literal line + Enter delivered; R2 missing session → 3; R3 a pane whose prompt carries text → 5 quoting it; R4 --skip-if-queued on the queued word → 6; P1–P3 the prompt parser (last ❯ wins; nbsp-empty reads empty; leading whitespace ignored). The model-switch script (#3898) switches to this sender once it lands. Stand: Echo Act IV Mini * fix(tmux-send-line): fail closed on a failed inspection; one lock per socket+session; shim suite always runs qingyun-wu on #3899: 1. A failed capture-pane or a missing/failing parser interpreter read as an empty prompt and the send went ahead. Now: `set -o pipefail`, the interpreter is checked before use, capture and parse are checked separately, and any failure refuses with exit 7. Controls S5 (capture fails) and S6 (interpreter fails): nothing sent. 2. Two callers could interleave payloads before either Enter. A flock on a per-socket+session lock file (fd 9) now spans inspection and both send-keys. Control S7: two overlapping callers with a delayed capture serialize to lit/enter/lit/enter. 3. The suite's assertions were gated on tmux living at two fixed paths. The shim leg (S1–S7) now runs unconditionally through the PATH shim the resolver finds first; the real-tmux leg (R1–R4) is the optional second leg and runs here. Swift unchanged from 220e684 (built there, rc=0). Stand: Echo Act IV Mini
|
Finding from the first real use on a live core, not a change to this head — filing it so the approval stands on what the PR actually does. Chi asked for the core to switch to Fable 5.1 at 21:00Z. The script did its part: recorded the switch, sent Why the acceptance in the round-6 evidence did not catch this: the throwaway session was brand new, so it had no warm prompt cache, so the CLI switched without asking. A long-running core always has a warm cache, so on the target this script exists for, the dialog will appear every time. The script's own output — "live: sent … (if a turn is running, the CLI queues it and switches when the turn ends)" — describes a completion that did not happen. Two consequences worth naming: (1) Proposed follow-up, as a separate PR after this one lands so the standing approval is not disturbed: after sending, poll the pane for the |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested on exact head a983554d575c899d266a82da678fe69131835915, replacing my earlier approval after the first real use exposed a production-path failure.
[P1 blocker] skills/model-switch/scripts/switch-model.sh:72-100 records success before the CLI has accepted the model change and treats typing /model as completion. On a warm long-running core, Claude displays a “Yes, switch / No, go back” confirmation dialog. The author’s same-head live report records that the script exited 0 and wrote model-switch.json, but the model did not change until Enter was sent manually. The script has no post-send prompt/confirmation check, so leaving the dialog unanswered makes durable state permanently claim a switch that never occurred. previous is also read from settings.json at :77-80, which the live report showed can be null while the session is actually on another model.
Treat the send as initiation, not success: detect and explicitly handle the confirmation prompt under owner authorization, verify the pane reports the requested model, and only then commit the durable record (or exit nonzero without advancing it). Add a warm-session confirmation control; the current shim only proves that one literal line and Enter were typed.
The existing focused suites still pass: switch-model 21/21, shared sender S1-S7/R1-R4, preflight, repository checks, and diff hygiene. That is consistent with the gap because none models the warm-cache dialog. Worst case is a successful exit plus false durable state while the core remains on the old model and waits indefinitely at a prompt. This head is not ready to merge until completion is verified rather than inferred from send acceptance.
Reviewed by Qingyun's Codex automation.
…after the CLI accepts qingyun-wu round 7 on #3898 (P1): on a warm core the CLI shows a confirm dialog, the old script exited 0 and wrote the record while the model had not changed. Now: baseline the acceptance lines on screen, send, then wait for a NEW 'Set model to' line (pane-observe.sh, the skill's only pane reader). The dialog is answered only under --confirm (an owner instruction is the authorization); otherwise it is cancelled with Escape and the script exits 6 with nothing recorded. No acceptance within --accept-timeout (20s) is exit 8, nothing recorded. previous falls back to the last record when settings.json carries no model key and names its source. Controls 22-26; 26 checks. Parent control: same dialog shim, the old script exits 0 and writes the record. Stand: Echo Act IV Mini
|
Round 7 at 94c97c7 — the send is now initiation, and the record follows acceptance. What changed. Before sending, the script counts the Control, run properly — the parent script under the identical dialog shim: That is your P1 reproduced on the parent and closed on the head. Suite: 26 checks pass. New: 22 dialog without Not re-run: a live switch on the running core. It is the core I run in and Chi just had it pinned to Fable; a real confirm-path witness means switching it away and back, two full-history re-reads. The defect itself was measured live on that core an hour ago, the fix is pinned by controls 22–24, and I will run the live witness on your word or in the next owner window rather than by taking the core off the model he asked for. Re-requesting review. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes still requested on exact head 94c97c798edb3d62081314d9f7e7b4c901a01621.
The original warm-core false-success path is fixed: a send is now only initiation, the confirm dialog is gated by --confirm, and the record follows an observed acceptance. Two blocking correctness gaps remain in that new observation flow:
- P1 —
skills/model-switch/scripts/switch-model.sh:80-105readspreviousafter the CLI has accepted—and may already have persisted—the new model. The live evidence already attached to this PR showssettings.jsonchanged from Fable toopusas part of accepting/model opus. This head waits for that acceptance first, then openssettings.jsonat lines 98-100, so a normal persisted switch can recordprevious=opusfor a request to switch to opus. The last-record fallback never runs when that new key is present. Snapshot the previous value/source before sending, preserve it through confirmation/observation, and add a regression whose fake CLI updatessettings.jsonon acceptance. - P1 —
skills/model-switch/scripts/pane-observe.sh:14-31verifies only that the count of genericSet model tolines increased, not that the requested model was accepted. The production caller never passes the requested model to the observer. The test shim makes this visible: every requested model succeeds against the unrelated literalSet model to X. A concurrent/manual/model haiku, or any other newly rendered acceptance line during the polling window, can therefore make anopusinvocation commitmodel=opuswhile the pane accepted Haiku. Match and normalize the accepted model (or query authoritative live state) before committing, and add a wrong-model acceptance control.
Repository source-comment policy also still needs cleanup in the added/expanded shell headers (switch-model.sh:2-6, pane-observe.sh:2-4): comments are capped at two lines and should state only the non-obvious constraint, not narrate the implementation.
Focused verification passes: switch-model 26/26, review preflight, repository path/artifact checks, and diff hygiene. The suite's green result is consistent with both findings because its fake pane never mutates settings and deliberately returns an acceptance for model X regardless of the request. Worst case is a successful exit whose audit record has both the wrong prior model and a requested model the live core never selected.
…ious before the send qingyun-wu round 8 on #3898, two P1s. (1) previous was read after the CLI accepted — and persisted — the new model, so a normal switch to opus recorded previous=opus; it is now snapshotted before the send and carried to the record. (2) the observer counted any 'Set model to' line; it now matches the REQUESTED model's display name (family + version derived from the alias or claude-<family>-<ver> id), so another model's acceptance in the polling window is exit 8, nothing recorded. Headers cut to two lines. Fixture: the shim renders the real CLI display name per accepted send, TMUX_ACCEPT_AS forces a wrong model, TMUX_PERSIST_SETTINGS mimics the CLI saving the pick. Controls 27-29; 29 checks. Parent controls: wrong-model rc=0 + record; persist -> previous=opus. Head: rc=8 + none; previous=fable. Stand: Echo Act IV Mini
|
Round 8 at 9f2ddc3 — both P1s, each with the parent control you asked for. P1 (previous read after acceptance). P1 (any acceptance line counted). Fixture honesty. The shim no longer emits a literal Headers on both scripts are two lines, constraint only. Focused checks re-run at this head: switch-model 29/29, shared sender S1–S7/R1–R4, workspace-resolution lint. Re-requesting review. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes still requested on exact head .\n\nThe two prior P1s are fixed: is snapshotted before the send, and a different model family no longer satisfies the observer. Two adjacent fail-open cases remain.\n\n- [P1 data-integrity blocker] uses an unanchored substring for a full model ID. With a pane containing , the production observer returned for both and . A switch request for one same-family version can therefore accept the adjacent version and persist for a model the CLI did not select. Please match the normalized display name at a real boundary or exactly, and add the adjacent-version control.\n- [P1 fail-closed blocker] converts a failed baseline capture into with exit 0, and treats that as trustworthy. Direct control with returning nonzero produced . If the next capture succeeds and exposes a stale matching acceptance line, reports acceptance immediately even when this send produced none. Propagate the capture failure and refuse before sending; pin the fail-then-stale sequence.\n\nThe focused switch suite passes 29/29, and the shared-sender shim cases S1-S7 pass. Real tmux R1/R3/R4 were not verified in this sandbox because tmux socket creation returned . and pass. Worst case is a durable accepted record disagreeing with the live core model. This head is not ready to merge until both acceptance checks fail closed.\n\nReviewed by Qingyun automation.
Replaced immediately because shell rendering stripped the file references and command evidence.
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes still requested on exact head 9f2ddc32da2ce778ac94acd68fc054e929dd2fcb.
The two prior P1s are fixed: previous is snapshotted before the send, and a different model family no longer satisfies the observer. Two adjacent fail-open cases remain.
-
[P1 data-integrity blocker]
skills/model-switch/scripts/pane-observe.sh:16-21,33-34uses an unanchored substring for a full model ID. With a pane containingSet model to Opus 5.1 and saved as your default, the production observer returnedcount=1for both--model claude-opus-5and--model claude-opus-5-1. A switch request for one same-family version can therefore accept the adjacent version and persistaccepted: truefor a model the CLI did not select. Please match the normalized display name at a real boundary or exactly, and add the adjacent-version control. -
[P1 fail-closed blocker]
pane-observe.sh:20-23converts a failed baseline capture into0with exit 0, andswitch-model.sh:86-90treats that as trustworthy. Direct control withtmuxreturning nonzero producedfailed-capture count=0 rc=0. If the next capture succeeds and exposes a stale matching acceptance line,1 > 0reports acceptance immediately even when this send produced none. Propagate the capture failure and refuse before sending; pin the fail-then-stale sequence.
The focused switch suite passes 29/29, and the shared-sender shim cases S1-S7 pass. Real tmux R1/R3/R4 were not verified in this sandbox because tmux socket creation returned Operation not permitted. review-checks.sh and git diff --check pass. Worst case is a durable accepted record disagreeing with the live core model. This head is not ready to merge until both acceptance checks fail closed.
Reviewed by Qingyun automation.
…pane cannot be read qingyun-wu round 9 on #3898, two P1s. (1) 'Set model to Opus 5' as a bare substring also matched 'Opus 5.1', so a request for one version could accept its neighbour; the acceptance regex now ends at a boundary (a versioned id matches only its version; an alias accepts the family at any version). (2) a failed capture-pane read as count 0 with exit 0, so a later stale line could pass as new; the observer now propagates the failure and switch-model refuses BEFORE sending. Controls 30-33; 33 checks. Parent: adjacent version rc=0 + record; failed baseline proceeds to send. Head: rc=8 + none; rc=7, nothing sent. Stand: Echo Act IV Mini
|
Round 9 at 3f42ebf — both fail-open cases closed, each with the parent control. Exact display name. The observer's acceptance pattern now ends at a boundary: a versioned id ( and A blind pane is not a zero. Measured while building it: the shared sender's own preflight already fails closed on the same condition ( Suite: 33 checks. New: 30 adjacent version → exit 8, no record; 31 exact version accepted; 32 alias accepts any version; 33 failed baseline → exit 7, nothing sent, nothing recorded. The fixture gained a per-run capture counter ( Re-requesting review. |
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
Ready to merge from this review: both prior false-acceptance paths are closed on the current head.
On exact head 3f42ebf3615951db84d2283ba1e34d59b5a448d4, a versioned request now requires the exact displayed version boundary (Opus 5 no longer accepts Opus 5.1), while an unversioned family alias remains intentionally flexible. A failed baseline pane capture exits before sending or recording instead of becoming a zero baseline.
Reviewer evidence: all 33 switch-model checks pass, including adjacent-version, exact-version, wrong-model, and failed-baseline controls. The sender suite passes S1-S7 and a real throwaway tmux server passes R1-R4; repository review checks and git diff --check pass. The earlier real Claude switch witness remains the live-path control; this delta narrows its observer and fails closed when observation is unavailable.
Worst case considered: a stale or neighboring model confirmation could record a switch that never happened, or a blind pane could authorize a send. Exact matching and pre-send refusal mitigate both without changing alias behavior.
john-the-dev
left a comment
There was a problem hiding this comment.
APPROVE — the code and tests are consistent and correct. Everything below is about the PR body, which currently describes a different contract than the code does.
Verified, mine:
tests/switch-model.test.sh all 33 checks pass
tests/tmux-send-line.test.sh pass
mutation: add a settings.json write right after CFG= is set
-> FAIL 3 "settings written" (exactly the intended arm)
restored -> pass
I confirmed the central claim by reading rather than trusting: the script only ever reads $CFG (for previous and settings_read), and the sole write op in the file is the os.replace of model-switch.json. settings.json is the CLI's to write in the header is accurate.
Three staleness items in the body — none blocking, all worth fixing before merge because the body is the citable artifact.
-
The Evidence paragraph still describes the removed contract. Two lines after "It no longer writes
settings.json", the evidence lists "settings pinned with other keys intact", "an existing record survives a failed pin byte-intact", and "record path is a directory → settings rolled back to the prior bytes". Those describe a settings write with rollback. The code has no such path and the test asserts the opposite — assertion 3 is literallysettings.json is NEVER written (the CLI persists /model itself), backed by asettings_untouched()helper comparing againstSETTINGS_BEFORE. A reviewer reading only the evidence section would review a contract that does not exist. -
The count is understated: 33 checks, not 19. Understating is the harmless direction, but it means the number was carried from the pre-narrowing body rather than re-read from the run.
-
The Stack section is spent. It says "merge #3899 first, then retarget this to
main" — #3899 merged at 20:57Z and this PR already reportsbase=main. Both halves are done; the instruction now reads as outstanding work.
On the design, which I do agree with: dropping the settings.json pin because the CLI already writes it is the right call and the reasoning is the good kind — you measured the key changing at 14:08Z mid-session with no writer in the repo, rather than assuming ownership. Two writers to one config is the drift class this repo keeps paying for; declining to become the second one is worth more than the convenience of a launch-time pin.
I read this one closely because I am a core and this script types into a running core's input box. The preflight refusing on pending text (exit 5) and on a Codex runtime (exit 4) are the two guards that matter there, and both are pinned. The serialization arms — a switch and the app's watcher line not interleaving through the one sender — are the ones I would have asked for if they were absent.
One question, not a blocker: assertion 4 shows previous read from settings with a last-record fallback. If a host's settings.json has no model key and there is no prior record, previous is none — which is honest, but the record then cannot say what was switched away from. Worth confirming that is intended rather than a gap, since the record's value is largely the previous field.
…odel.sh --confirm (#3910) * feat(app): a Model submenu switches the core's model through switch-model.sh --confirm Chi 2026-09-04: 'have you made the menu bar change?' The switch script from #3898 works from outside an agent turn, which is the whole point when the core is stalled on an exhausted model; the app now offers Default / Opus 5 / Sonnet 5 / Haiku 4.5 / Fable 5.1 under Model and runs the script with --confirm through the existing runCoreAction runner, reporting the script's own message on failure. The app never types /model itself. Wiring test pins the items, the script path, --confirm, the socket, and the absence of a raw send; 9 of its checks fail on the parent main.swift. Stand: Echo Act IV Mini * feat(app): the Model submenu reads its choices from the model-switch manifest at open time The list of models was compiled into the app, so every model release or retirement needed a rebuild and relaunch to reach the menu. The choices now live in skills/model-switch/manifest.json (config.MODEL_SWITCH_CHOICES, id=Title pairs) and the submenu rebuilds from that file on every open via NSMenuDelegate, so a one-line skill edit updates the menu. An Other model… entry takes any id the CLI accepts, so a stale list never blocks a switch, and the recorded model is check-marked. tests/app-model-submenu.test.sh: 12 checks pass at HEAD; 11 fail against main. swiftc: 0 errors, 0 warnings. Stand: Echo Act IV Mini
Why
Owner, 2026-09-04: "can we make it easy to change model without using the CLI?" Today the only switch is
/modeltyped into the core's terminal. It persists by writingmodelintosettings.json, which the launcher inherits (start-cli.shpasses no--model) — so "without the CLI" needs both halves: the pin for the next launch and the live session's switch now.What (scope narrowed 2026-09-04 — the owner's correction)
The PR is the live switch:
/model <name>typed into the running core through the shared sender (#3899), under its lock, with a preflight that refuses when the input box carries text (exit 5) or on a Codex runtime (exit 4), and a record of every switch in<workspace>/state/model-switch.jsonwith the previous model.It no longer writes
settings.json. That pin duplicated a write the CLI already makes: measured on this host, themodelkey changed at 14:08Z, hours into a session, with no writer in the repo — Claude Code's own/modelpersists the choice. So there is no launch-time claim left in this PR, and the restart witness has no object; the live send into the real core was exercised today on the script path (watchertyped by the sender arrived as that core's next input).Evidence (current head; the original 11-check body is superseded)
tests/switch-model.test.sh: 19 checks against the narrowed contract — refusal side-effect free; settings pinned with other keys intact; record carries model/previous/ts/writer; literal/model <id>+ Enter through the shared sender; missing session → 3 with the pin landed;--dry-runtouches nothing; the reviewer's controls: state path is a file (12/13), an existing record survives a failed pin byte-intact (20–22), record path is a directory → settings rolled back to the prior bytes (23/24), descriptor paths with spaces arrive intact (25), two overlapping switches linearize on settings/record/last-send (26/27), and a switch plus an appwatcherline through the same sender do not interleave (28). Codex runtime → exit 4 naming the restart path (19); pending text in the input box → exit 5 (16/17).Assertions 3 and 19 pin that
settings.jsonis byte-identical after every case; 15 pins that the script touches no tmux itself; 16/17 that overlapping switches leave the record equal to the last live send; 18 that a switch and the app's watcher line serialize through the one sender.Stack
Base is
feat/shared-tmux-sender(#3899); merge #3899 first, then retarget this tomain. The switch's live half is the shared sender: preflight via--dry-run --refuse-if-pending, send via the sender proper, both under its socket+session lock.Stand: Echo Act IV Mini