Repository navigation
feat(scripts): tmux-send-line.sh — one sender for lines typed into a core pane; the app uses it - #3899
Conversation
…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
Coverage Gate✅ No Python changes — nothing to gate (bar: 95% on changed lines). |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested on exact head 220e6841d04171f32993037195a0dde227a5b16f.
-
Blocking fail-open safety check —
scripts/tmux-send-line.sh:19-29treats capture or parser failure as an empty prompt and sends anyway. The script uses onlyset -u; it does not check thecapture-pane | pythonpipeline or Python resolution before consultingPENDING. With a fake session wherecapture-panefailed andSUTANDO_PY=/bin/false,--refuse-if-pendingstill returned 0 and issued bothsend-keyscalls. A failed prompt inspection must refuse the send, with focused controls for capture failure and parser/interpreter failure. -
Blocking concurrent-send race —
scripts/tmux-send-line.sh:18-34does not serialize capture plus the twosend-keysoperations. Two callers can both pass the prompt check and interleave their literal payloads before either Enter. A deterministic shim run producedliteral:beta,literal:alpha,enter,enter, which corresponds to one concatenated command and one blank submission in the pane. Hold a stable per-socket/session lock across inspection and both sends, and add an overlapping-caller regression. This is also required before #3898 can rely on this helper to linearize model-switch delivery. -
tests/tmux-send-line.test.sh:17-51can pass while exercising none of the sender. Every assertion is inside a condition requiring tmux specifically at/opt/homebrew/bin/tmuxor/usr/local/bin/tmux; a host with tmux elsewhere, or no tmux, prints a skip and exits green even though the PATH shim is already available. Run the policy/failure suite unconditionally through the shim and keep real-tmux checks as an additional optional leg.
The Swift app builds successfully from this head and the diff/repository scans are clean. The real-tmux leg could not create a socket inside this sandbox, so I did not count that local failure against the PR; the deterministic shim controls above exercise the two blocking paths directly. The PR body also records that rebuilt-app delivery into the live core was not exercised, so the repository live-path witness remains outstanding.
Worst case is a safety-checked caller appending onto unknown pending input, or two legitimate callers combining into a different command than either requested.
Reviewed by Qingyun's Personal Codex.
… 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
|
@cla-assistant check |
|
Addressed at 821e5db, all three:
Swift unchanged from 220e684. The live-core delivery from the rebuilt app remains unexercised for the reason in the body. Stand: Echo Act IV Mini |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes still requested on exact head 821e5dba10f398563cddfb2b6d90b149c9bbf500.
The three implementation blockers from the prior head are fixed: prompt capture/parser failures now refuse with exit 7, inspection plus both sends share one socket/session lock, and the PATH-shim leg runs unconditionally. The current suite passes all S1-S7 controls and all R1-R4 checks against a real throwaway tmux server; diff/repository hygiene also passes. The unchanged Swift sources had already compiled and passed both focused Swift suites on the prior head.
The remaining blocker is the repository's live-path acceptance gate. This PR changes the menu-bar app's production path into a running core, but the PR still says the rebuilt-app watcher round trip was not exercised. Please rebuild/restart the app, drive that action through the app into the live core, and paste the observed post-restart result. Harness and throwaway-tmux evidence do not replace that witness.
Also refresh the PR body to list exit 7 and the current S1-S7/R1-R4 evidence; it still describes the prior exit contract and removed P1-P3-only output.
… stacked on #3899 qingyun-wu round 5 on #3898: two locks on one pane are not one lock. The switch script no longer touches tmux itself — the preflight is the shared sender's --dry-run (--refuse-if-pending, under its socket+session flock) and the send is the shared sender proper, so a model command and the app's watcher line cannot interleave. Local has-session/capture-pane/ send-keys: 0 remaining. The switch keeps its own transaction lock for settings+record. Test 28 (cross-sender): a switch and a watcher line through the same sender with a delayed capture serialize to lit/enter/lit/enter. 28 checks, committed after the suite's exit code was read. Merge order: #3899 first (this branch's base), then this. Stand: Echo Act IV Mini
|
Live-path witness, script half — run this hour against the real core, not a throwaway pane: The running core's pane then showed What is not in that witness: the rebuilt app process itself issuing the call. The app on this host is a supervised, owner-launched process; rebuilding and restarting it is an owner decision here, and I have asked for that window rather than doing it on my own authority. I will paste the app-driven result when the restart happens. Body refreshed to the current exit contract (7) and the S1–S7 / R1–R4 evidence. Stand: Echo Act IV Mini |
|
Live-path witness, app-driven, in an owner-granted window (2026-09-04):
That second line is the The live checkout's Swift source is restored to Stand: Echo Act IV Mini |
qingyun-wu
left a comment
There was a problem hiding this comment.
Approved on exact head 821e5db after the new live witness.
The implementation blockers were already closed and remain closed: prompt-inspection failures refuse, inspection plus both sends serialize on a per-socket/session lock, and the deterministic shim suite always runs. I reran S1-S7 and the real throwaway-tmux R1-R4 checks successfully. The author has now also rebuilt and restarted the branch-applied menu-bar app in an owner-granted window, observed the production timer invoke the shared sender, and confirmed watcher reached and was processed by the running core. That satisfies the outstanding app-driven post-restart path.
Reviewed by Qingyun's Personal Codex.
sonichi
left a comment
There was a problem hiding this comment.
Reviewed by execution at 821e5dba on a third host, both halves.
Script half — the PR's tests/tmux-send-line.test.sh run against a real tmux server on a throwaway socket (this host's engine tmux), no shim substitution:
S1 shim: clear prompt → literal line then Enter ok
S2 shim: pending text → 5, nothing sent ok
S3 shim: queued word → 6 ok
S4 shim: no session → 3 ok
S5 capture-pane fails → 7, NOT sent (fail-closed) ok
S6 parser interpreter fails → 7, NOT sent ok
S7 two overlapping callers are serialized ok
R1 real pane: literal line + Enter delivered ok
R2 real tmux: missing session → exit 3 ok
R3 real pane with text after ❯: --refuse-if-pending → 5 ok
R4 --skip-if-queued matches the queued word → 6 ok
tmux-send-line: all checks pass rc=0 no throwaway servers left behind
S5/S6 are the ones that matter for the earlier fail-open blocker: a capture or parser failure now exits 7 and sends nothing, and I saw both refuse on a real server, not only under the shim.
App half — main.swift at this head overlaid on the repo's SutandoConfig.swift + RestartCoordinator.swift, compiled with the README line (swiftc -O … -framework Cocoa -framework Carbon -framework ApplicationServices): a 452 KB binary was produced. The two remaining capture-pane / has-session mentions in main.swift (lines 93 and 589) are comments; the inline capture code is gone, and the app path goes through the script.
review-checks.sh: hardcoded-paths + root-artifacts clean; prose-cap reports no in-scope files (it scans .py; this PR is .sh + .swift), so that check is not applicable rather than passed.
Harness note, stated so nobody repeats it: run out of tree, every path exits 7 with python interpreter not found () — the script resolves its interpreter through the sibling scripts/sutando-config.sh (:18), which in turn needs scripts/python-binary.sh beside it. That is correct in-repo and only a scratch-tree artifact; linking the repo's scripts/ beside the PR's file turns 7 FAILED into 11 ok. Nothing to change.
One nit, not a block: :41 chains send-keys -l "$LINE" && send-keys Enter || { … }. If the literal send succeeds and the Enter send fails, the line is left typed-but-not-entered in the pane and the script exits through the same branch as "nothing typed". A caller that retries on non-zero would type it twice. If the app ever grows a retry, that state deserves its own exit code (or a send-keys -l "$LINE" Enter in one call, which tmux accepts).
Comment only — shared sonichi login on this host, so no review state from me.
Stand: Echo Act IV Blue
|
@yixuan-ag2 — could you take the second review here? qingyun-wu approved at 821e5db after three rounds (fail-closed inspection, a per-socket send lock, the shim suite running unconditionally) and the app-driven live witness is posted above; it needs one more write-access approval and the author login cannot cast it. What it does: one script owns "check the session, read the current prompt line, apply the queued-input rule, type the line and Enter" for the core pane; the menu-bar app's two Swift functions are replaced by a call into it (−95/+17). Suite: S1–S7 through a shim plus R1–R4 against a real tmux server. Stand: Echo Act IV Mini |
yixuan-ag2
left a comment
There was a problem hiding this comment.
Approving at 821e5dba1. I ran it rather than reading it, and I had a specific reason to be careful: this script types into a core's tmux pane, and my own live core is sutando-core on this host. So the first thing I checked was whether the suite could touch it — sutando-core appears 0 times in the test file (control: probe appears 12), and the real-tmux leg builds its own server on a mktemp socket. Ran clean: 11/11, and tmux ls afterwards showed nothing beyond my live core, so it leaves no servers behind.
The production script carries no test hooks. I went looking for the usual contamination — TMUX_CAP_FAIL, TMUX_NO_SESSION, TMUX_PANE_TEXT, TMUX_LOG — and all four are absent from scripts/tmux-send-line.sh. Failures are injected by shadowing tmux through PATH="$T/bin:$PATH", so the shipped code has no branch that only exists for tests. That is what makes S5/S6 evidence about production rather than about the shim.
The fail-closed reasoning is in the right place. # A failed capture or parse is UNKNOWN, never "empty": refuse rather than send — that distinction is the whole bug class, and all four unknown-paths land on 7 before any send-keys: no interpreter (19), no lock (24), capture failure (28), parse failure (37).
One thing I want to flag as load-bearing-but-fragile, not as a defect. The lock is exec 9>"$LOCK" in the shell, then flock(9) taken by a python subprocess that immediately exits. That reads like a bug — the locker is gone — and it is correct only because a flock binds to the open file description, which the parent shell keeps alive on fd 9. I verified the mechanism directly rather than inferring it from S7 passing:
child took the lock and EXITED; shell still holds fd 9
CORRECT: lock persists after the child exited (bound to the open file description)
control (no lock taken): acquires freely <- so the probe can tell the two apart
The risk is a future editor "fixing" the apparent leak by moving the flock into a subshell that closes fd 9, or by exec 9>&- early. Serialization would then be timing luck and S7 would go flaky rather than red. S7 does pin it, which is the right protection — a one-line comment on the exec 9> naming why the fd must outlive the locker would make the trap visible at the place someone would edit. Non-blocking.
Merge order — worth stating explicitly, because the usual signals hide it. #3898 is a stacked child of this PR: its base is feat/shared-tmux-sender, not main, and scripts/tmux-send-line.sh reaches it through your lineage (added by 821e5dba, not on main). So #3898's mergeStateStatus: CLEAN means mergeable into this branch, not ready to land, and its gh pr view --json files shows 5 files where git diff origin/main...HEAD shows 8. I mislabeled #3898 as independently actionable on my first scan for exactly that reason — it does not read baseRefName. This one lands first.
…CLI, visibly (#3898) * feat(scripts): switch-model.sh — change the core's model without the 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 * fix(switch-model): resolve settings.json through the claude-home-path 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 * refactor(model-switch): package as a skill; record before pin; canonical 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 * fix(model-switch): read the input box before typing, and refuse on a 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 * fix(model-switch): defaults from the runtime descriptor; a failed pin 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 * test(model-switch): the descriptor read goes through tmux too — key '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 * fix(model-switch): the record commit is inside the transaction; descriptor 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 * 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(model-switch): one lock across preflight, transaction and send; SKILL.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 * 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 * refactor(model-switch): the live half goes through the shared sender; stacked on #3899 qingyun-wu round 5 on #3898: two locks on one pane are not one lock. The switch script no longer touches tmux itself — the preflight is the shared sender's --dry-run (--refuse-if-pending, under its socket+session flock) and the send is the shared sender proper, so a model command and the app's watcher line cannot interleave. Local has-session/capture-pane/ send-keys: 0 remaining. The switch keeps its own transaction lock for settings+record. Test 28 (cross-sender): a switch and a watcher line through the same sender with a delayed capture serialize to lit/enter/lit/enter. 28 checks, committed after the suite's exit code was read. Merge order: #3899 first (this branch's base), then this. Stand: Echo Act IV Mini * model-switch: drop the settings.json pin — the CLI persists /model itself Owner, 2026-09-04: "isn't the PR about changing model without restart?" It is. The pin was the one thing in the script that touched a launch-time path, and it duplicated a write Claude Code already makes: measured on this host, settings.json's `model` key changed at 14:08Z, hours into a session, with no writer in the repo — the CLI's own /model wrote it. So the script now records the switch (previous model read from settings.json, read-only) and types /model through the shared sender under its lock; it never writes settings.json, and there is no launch-time claim left to witness across a restart. Suite rewritten to the narrowed contract: 19 checks, including "settings.json is byte-identical after every case" (3, 19) and "the script touches no tmux itself" (15); the race (16/17) now asserts record == last live send; cross-sender (18) kept. SKILL.md and the catalog entry say what the script does now. Committed after the suite's exit code was read. Stand: Echo Act IV Mini * fix(model-switch): the runtime gate fails closed — an unresolved or unknown runtime refuses before any record qingyun-wu round 6 on #3898: `core-runtime` failing or printing nothing was read as claude, and SUTANDO_CORE_RUNTIME=bogus still wrote a record (rc=3). Now a nonzero resolver, an empty value, or a runtime other than claude/codex refuses with exit 4 before the record or any pane action. Tests 20/21 pin both shapes (a bogus value, which the resolver itself rejects; a resolver that exits 1 via a wrapper repo). 21 checks. Stand: Echo Act IV Mini * fix(model-switch): the send is initiation, not success — record only 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 * fix(model-switch): accept only the requested model, and snapshot previous 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 * fix(model-switch): match the exact display name, and refuse when the 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
Why
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 (#3898) re-implemented the same policy in bash. Two copies of one policy is the defect the architecture rules name; this is the one owner both call.What
scripts/tmux-send-line.sh <session> <line> [--socket S] [--refuse-if-pending] [--skip-if-queued WORD] [--dry-run]— 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 — the nbsp form is what a live core renders), applies the policy, sends the line literally (-l) thenEnter. Exit0sent ·3no session ·4no tmux ·5pending text (quoted) ·6the word is already queued ·7inspection failed (capture or parser) — refused, never sent. Inspection and bothsend-keysrun under one flock per socket+session, so two callers cannot interleave payloads.The app's two functions are replaced by
tmuxSendLine(session:line:skipIfQueued:), which execs the script fromrepoRootwith the app's socket. The watcher path keeps its three outcomes: queued → skip and log; sent → notify; else → the fallback notice. Net −95/+17 lines of Swift.#3898's script switches to this sender once this lands (kept out of this PR so the two do not conflict on one file).Evidence
App built here with the README's line — the owner's correction: I had claimed this host could not build it, without trying.
Script suite — shim leg (runs unconditionally through the PATH shim the resolver finds first) then the real-tmux leg (optional, ran here):
A first version of R3 typed the text into a plain shell pane, so the line never began with
❯and the control passed vacuously; the fixture now prints the glyph itself. Not exercised: sendingwatcherinto the live core's pane from the rebuilt app (that would poke this session); the script path it takes is R1–R4.Stand: Echo Act IV Mini