Skip to content

feat(scripts): tmux-send-line.sh — one sender for lines typed into a core pane; the app uses it - #3899

Merged
sonichi merged 2 commits into
mainfrom
feat/shared-tmux-sender
Sep 4, 2026
Merged

sonichi merged 2 commits into
mainfrom
feat/shared-tmux-sender

Conversation

@sonichi

@sonichi sonichi commented Sep 4, 2026 •

Copy link
Copy Markdown
Owner

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) then Enter. Exit 0 sent · 3 no session · 4 no tmux · 5 pending text (quoted) · 6 the word is already queued · 7 inspection failed (capture or parser) — refused, never sent. Inspection and both send-keys run 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 from repoRoot with 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.

$ cd src/Sutando && swiftc -O -o Sutando main.swift SutandoConfig.swift RestartCoordinator.swift -framework Cocoa -framework Carbon -framework ApplicationServices -framework AVFoundation
swiftc rc=0
$ python3 tests/restart-coordinator-swift.test.py → OK ; tests/sutando-config-swift.test.py → OK

Script suite — shim leg (runs unconditionally through the PATH shim the resolver finds first) then the real-tmux leg (optional, ran here):

  ok   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: lit:alpha enter lit:beta enter
  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 exits 5 quoting it
  ok   R4 --skip-if-queued matches the queued word: exit 6, nothing typed
tmux-send-line: all checks pass

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: sending watcher into 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

…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
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested on exact head 220e6841d04171f32993037195a0dde227a5b16f.

  1. Blocking fail-open safety check — scripts/tmux-send-line.sh:19-29 treats capture or parser failure as an empty prompt and sends anyway. The script uses only set -u; it does not check the capture-pane | python pipeline or Python resolution before consulting PENDING. With a fake session where capture-pane failed and SUTANDO_PY=/bin/false, --refuse-if-pending still returned 0 and issued both send-keys calls. A failed prompt inspection must refuse the send, with focused controls for capture failure and parser/interpreter failure.

  2. Blocking concurrent-send race — scripts/tmux-send-line.sh:18-34 does not serialize capture plus the two send-keys operations. Two callers can both pass the prompt check and interleave their literal payloads before either Enter. A deterministic shim run produced literal: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.

  3. tests/tmux-send-line.test.sh:17-51 can pass while exercising none of the sender. Every assertion is inside a condition requiring tmux specifically at /opt/homebrew/bin/tmux or /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
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@sonichi

sonichi commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Addressed at 821e5db, all three:

  1. Fail closed — set -o pipefail; the interpreter is checked before use; capture and parse are checked separately; any failure refuses with exit 7. Controls S5 (capture-pane exits 1) and S6 (interpreter is /bin/false-shaped): nothing sent.
  2. Serialization — a flock on a per-socket+session lock file (fd 9) spans inspection and both send-keys. Control S7: two overlapping callers with a delayed capture serialize to lit:alpha enter lit:beta enter.
  3. Suite — 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 ran here.

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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

sonichi added a commit that referenced this pull request Sep 4, 2026
… 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
@sonichi

sonichi commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Live-path witness, script half — run this hour against the real core, not a throwaway pane:

$ bash scripts/tmux-send-line.sh sutando-core watcher --socket <core socket> --skip-if-queued watcher
sent 'watcher' to sutando-core
rc=0

The running core's pane then showed ❯ watcher queued, and the line was delivered to that core as its next input and processed (the core's watcher was already alive, so its handling was "nothing to start"). That is the exact argv the app's tmuxSendLine execs, with the real socket and the real session.

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

@sonichi

sonichi commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Live-path witness, app-driven, in an owner-granted window (2026-09-04):

  1. This PR's main.swift and scripts/tmux-send-line.sh were applied into the live checkout (git: M src/Sutando/main.swift, ?? scripts/tmux-send-line.sh — the control that the branch code, not main's, was built), the app rebuilt there with the README's swiftc line (rc=0), and src/restart.sh run — it relaunched the app from that binary (app start 18:43:45Z) and, as it does, killed the task watcher.
  2. The app's 300 s watcher timer then found no watcher and took the path this PR changed. App debug log:
2026-09-04T18:48:46Z notify: Sutando — Task watcher down — sent 'watcher' to sutando-core tmux
2026-09-04T18:48:46Z watcher dead; tmux send-keys to sutando-core

That second line is the rc == 0 branch of tmuxSendLine, i.e. the shared script returned 0 after has-session, prompt inspection and the literal send under its lock.
3. The running core's pane showed ❯ watcher, and the core received watcher as its next input at 18:48:5xZ and acted on it (re-armed its watcher). Post-restart, end to end, through the app.

The live checkout's Swift source is restored to main afterwards; the built binary and the untracked script stay in place so the running app keeps a sender to exec until this lands.

Stand: Echo Act IV Mini

@sonichi
sonichi requested a review from qingyun-wu September 4, 2026 18:49

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sonichi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@sonichi

sonichi commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

@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

@sonichi
sonichi requested a review from yixuan-ag2 September 4, 2026 19:27

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sonichi
sonichi merged commit f61a933 into main Sep 4, 2026
19 checks passed
@sonichi
sonichi deleted the feat/shared-tmux-sender branch September 4, 2026 20:57
sonichi added a commit that referenced this pull request Sep 4, 2026
…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
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.

3 participants