Repository navigation
Gate unsafe optional tmux features independently - #37
Merged
Merged
Conversation
tony
force-pushed
the
bump-min-tmux-000
branch
from
October 17, 2026 10:44
f80ca37 to
92d22e2
Compare
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 17, 2026 10:44
a9ccb73 to
29d712d
Compare
tony
force-pushed
the
bump-min-tmux-000
branch
from
October 17, 2026 11:53
92d22e2 to
9de038c
Compare
tony
force-pushed
the
bump-min-tmux-001
branch
2 times, most recently
from
October 17, 2026 13:39
8e5cdeb to
78ae2a3
Compare
why: Short-lived control attaches crash 3.2a and 3.6b servers, and tmux fixed the initialization faults in 3.7. A fallback after the attach fails cannot protect acquisition, so the decision has to come first. Subprocess operations, snapshots, polling and the workspace builder do not need control and stay on the 3.3a floor. what: - Refuse control acquisition below 3.7 with UnsupportedFeatureError (delivery :not_sent, phase :admission) in ControlConnection, which covers Server#open_control and reconnect, and in the Async scope - Let a server record its checked release on its socket route, so a control connection reads the release instead of probing again; a connection built on a route no server checked probes the daemon with the subprocess probe before it spawns the client - Wait for MCP tmux_wait screen text by polling the pane screen every 50 ms when the daemon takes no control client, with no control attach and no extra option; 3.7 and newer keep the control event path. The capability report and the tool description name both paths, and process_exit is unchanged - Join wrapped rows in the wait's capture (capture-pane -J) on both paths, so text that the pane width split across rows still matches - End a wait whose pane, or whose tmux server, ends first as the new pane_died result with delivery observed and the last rows read in error.capture, the same on the poll and the control path. It no longer comes back as stale_target, the bad-reference code. After a stream end or a failed read the wait asks the daemon one hook-free run-shell -C question, so a killed server that a stopped second client keeps alive still answers pane_died. Only a daemon that reports it is gone ends the wait: a probe that cannot run, such as a failed spawn, leaves a lost stream as observation_lost - Add TmuxVersion.identity_argv and parse_identity for that question: the fields expand into one comma-joined word, because tmux reports only the first unparseable word, and a report that is multi-line, spaced, miscounted or successful is refused - Report observation_lost only when the control stream ends while the pane and server live. Async ControlSubscription#next now ends the stream with StopIteration, which Kernel#loop used to swallow so next returned nil, and the observer reads it the same way - Describe the new result in the tool description and its error schema - Test each wait case on a poll lane and a control lane: a wrapped match, a pane that dies, a killed server (also with a stopped second client), a detached client on a live pane, a probe that cannot run and a hook-free probe, and unit-test the identity parser - Make tests that need control assert the refusal on older daemons instead of skipping, because skipped tests mark a CI cell incomplete. Where control only witnessed output, poll the pane or observe a terminal client instead - Let the examples demonstrate the refusal below 3.7, rewrite the capture example to poll the pane, and make the benchmark refuse early because its lanes compare against control transport - Regenerate the public method reference, and the README capture excerpt that follows the polling example - Expect the installed control examples to fail with the refusal below 3.7 instead of attaching a client Verified on real daemons 3.3a, 3.6b and 3.7d: acquisition is refused with no client attached on the first two and works on the last; the control, Async, server control, MCP wait/capture, snapshot and capture semantics suites pass on all three. Removing the guards fails the feature, control and Async tests (see the PR for the recorded breaks).
why: Some tmux releases mishandle commands the library or its callers can send as literal argv. Deleting or renaming a named buffer that does not exist acts on an unrelated buffer before 3.4. tmux 3.3a faults on a malformed layout header. 3.4 breaks split-window -p. Exact 3.7 crashes on break-pane without a name, ignores a given one, and 3.3a through 3.5 print no window ID when the broken pane is alone. Typed methods and raw Server#run reach the same dispatch, so the guard belongs there. what: - Read the argv of every dispatch, including ';' batches and tmux's command abbreviations, and refuse before any effect: delete-buffer -b and set-buffer -b -n below 3.4; break-pane on exact 3.7; split-window -p on 3.4; malformed select-layout below 3.4 - Leave opaque programs and command-alias contents unread; the table of commands is a superset across releases, so an ambiguous abbreviation stays ambiguous - Route typed operations through a private dispatch so that Async and blocking servers share the guard and Pane#break_out can mark its repaired call - Make Pane#break_out reapply the name after the move on exact 3.7 and read the window back from the pane when break-pane prints no ID - Keep named paste-and-delete available: its lookup is exact on 3.3a - Ask the release whether it is exactly X.Y instead of comparing its key layout, and list native_guard.rb in the gemspec so installed gems load it - Delete the byte test's buffer without a name below 3.4, where exact named deletion is refused Verified on real daemons 3.3a, 3.4, 3.5, 3.6b, 3.7, 3.7d, 3.8 and master (next-3.9): each refusal leaves a sentinel buffer, window or layout untouched, and the positive control runs where the feature is safe. Unit tests cover separators, abbreviations, flag clusters and escaped semicolons in both directions.
why: Exact 3.7 rejects session and window names containing ':' or '.' after the command has been accepted, which surfaced as a late command failure. Earlier releases replace the characters and 3.7a lifts the restriction, so only that one release needs the check. what: - Check names in the shared literal-name step, which new_session, new_window, rename and break_out already use, and refuse before dispatch on exact 3.7 Verified on exact 3.7: new_session and rename raise UnsupportedFeatureError (delivery :not_sent) and the session list is unchanged. 3.3a through 3.6b, 3.7d, 3.8 and master accept the same name.
why: Snapshot capture read the server's version, PID and start time with display-message, and the Darwin process-identity check read the PID the same way. display-message fires a user's after-display-message hook on every release, so each snapshot ran that hook. The break-out read-back had the same dependency. what: - Read the snapshot server identity and the Darwin freshness PID with the run-shell -C identity probe the wait already uses - Read a sole pane's window back with list-panes instead - Check the daemon's version with the subprocess probe before any control client attaches and carry the release on the route, because the probe's error line arrives outside any %begin/%end block over control mode Verified: a test installs after-display-message and checks that a snapshot and a break-out fire nothing while display-message itself still fires it, on 3.3a, 3.5, 3.7d, 3.8 and master. The Darwin freshness path is covered by the unit parser tests only; this host cannot run it.
why: The 3.3a floor does not apply to every feature. Readers need each feature's own requirement, what an older daemon gets, and how far the evidence reaches. what: - Add an Optional tmux features table to the behavior reference with the daemon requirement, the older-daemon outcome and a support state per feature, and say what the literal-argv guard does not read - Note the 3.7 control requirement in the libtmux, Async and MCP guides and in the control and layout recipes - Record the one unverified area: concurrent control acquisition beyond an idle attachment on 3.7 and newer
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 17, 2026 15:46
78ae2a3 to
094d142
Compare
tony
force-pushed
the
bump-min-tmux-000
branch
from
October 17, 2026 17:16
f9b6bd4 to
25c396e
Compare
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 17, 2026 17:17
094d142 to
d17f2b2
Compare
tony
force-pushed
the
bump-min-tmux-000
branch
2 times, most recently
from
October 17, 2026 18:59
b2a63a6 to
355a492
Compare
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 17, 2026 18:59
d17f2b2 to
546b180
Compare
tony
force-pushed
the
bump-min-tmux-000
branch
from
October 18, 2026 02:26
355a492 to
0481a88
Compare
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 18, 2026 02:26
546b180 to
e041fe6
Compare
tony
force-pushed
the
bump-min-tmux-000
branch
from
October 18, 2026 04:51
0481a88 to
75af984
Compare
why: The examples, the benchmark and the signature consumer decided whether to use control by calling a private method and an internal module. Callers need a public answer to choose a polling path first. what: - Add Server#supports_control?, true from tmux 3.7, with its RBS, contract mapping and behavior text - Use it in the examples, the benchmark, the type consumer and the control test support instead of the private release - Regenerate the method inventory Verified with scripts/types --check and the unit tests.
why: next-3.7 and 3.7-rc builds rank below 3.7 and must not attach a control client, while 3.8-rc2 and next-3.8 rank above it. what: - Add the accept and refuse vectors for the 3.7 control gate Verified: comparing only major and minor makes the test fail.
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 18, 2026 04:54
e041fe6 to
173bf02
Compare
tony
force-pushed
the
bump-min-tmux-000
branch
from
October 18, 2026 07:19
75af984 to
4fec2ca
Compare
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 18, 2026 07:19
173bf02 to
8ad3c7e
Compare
tony
force-pushed
the
bump-min-tmux-000
branch
from
October 18, 2026 07:21
4fec2ca to
1463e64
Compare
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 18, 2026 07:21
8ad3c7e to
2a054d7
Compare
why: Each release gate carried its own copy of a floor or an affected release, so the docs could only repeat them by hand. One table that the gates read also feeds the capability pages the docs site builds, and a drift test keeps the two equal. what: - Add Internal::Capabilities, a typed table of capability and workaround records beside Internal::TmuxVersion - Read the control, named-buffer, layout, split, break-pane and name gates from the table instead of literals - Raise UnsupportedFeatureError for a JSON layout below tmux 3.8-rc2, so the refusal matches the error the manifest names - Add scripts/emit-capabilities, which writes the root capabilities.json and checks it with --check from scripts/check - Add tests: the committed manifest equals the registry, every API name is a public symbol, and availability follows the release order - List capabilities.rb in libtmux.gemspec
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 20, 2026 04:12
2a054d7 to
957d12e
Compare
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 20, 2026 08:36
957d12e to
3d9ccd7
Compare
why: ControlConnection#exchange sent any command line to the daemon.
Exact tmux 3.7 is the one release where control opens and a nameless
break-pane kills the server, so exchange("break-pane") took the daemon
down while the same command through Server#run was refused.
what:
- Read each exchange line the way tmux parses a command string (quotes,
backslashes, unquoted ";" separators, brace blocks) and run it through
the same literal-command rules as Server#run, so aliases, unique
prefixes and ";" lists resolve as tmux resolves them. The Async
facade shares the check
- Keep the line parsing beside the argv rules in NativeGuard
- Name the control path among the break-pane gate's APIs, regenerate the
manifest, and describe the line reading in the behavior reference
- Test unit vectors for refusals and for text that only mentions a
command, and real-daemon tests that send break-pane over the sync and
Async connections on exact 3.7, expect UnsupportedFeatureError with
delivery :not_sent, and check the daemon answers afterwards
- Regenerate docs/reference/api.md, whose source links move with the
edited files
Verified on 3.7 (refused, daemon survives), 3.7a (runs) and 3.3a. Removing
the check fails both exchange tests on 3.7.
why: Typed calls refuse ':' and '.' in session and window names on exact tmux 3.7, but raw new-session -s, new-window -n and rename-* reached tmux, which answered with its own error. A raw caller got a failed CommandResult where a typed caller got UnsupportedFeatureError. The registry summary also said tmux partly runs the command, which a probe of every spelling did not show. what: - Give new-session, new-window, rename-session and rename-window option specs in NativeGuard and refuse a name with ':' or '.' on exact 3.7, through abbreviations and ";" lists, with the typed call's error - Reword the registry summary, add Server#run to the APIs, regenerate the manifest, and describe the raw forms in the behavior table - Test the raw forms on the unit vectors and on a real 3.7 daemon with no session or window created - Regenerate docs/reference/api.md, whose source links move with the edited files Verified on 3.7, 3.4 and 3.3a. Making the name check a no-op fails the unit test and the 3.7 integration test.
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 20, 2026 09:10
3d9ccd7 to
e1076f9
Compare
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 20, 2026 09:18
e1076f9 to
971a630
Compare
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 20, 2026 22:01
971a630 to
8bb8dda
Compare
why: tmux 3.3a stops when refresh-client -l asks an attached xterm-256color or tmux-256color terminal for its clipboard, and on 3.4 through 3.6 the command returns but the server stops when that client leaves, killed or detached, before its terminal answers; 3.6a and later survive. The native guard refused the other commands that crash a supported release, but not this one, so one raw command or control line could take down a supported daemon. what: - Refuse refresh-client, under any name tmux resolves to it, carrying -l below 3.6a in Server#run and ControlConnection#exchange lines, before anything is sent - Record the gate in the registry as clipboard-query and regenerate capabilities.json - Add unit tests for the spellings, flag positions and a control line on 3.3a through 3.6 and from 3.6a, and an integration test whose list of affected releases is written out, so moving the floor fails it - Add the row to the optional features table
why: Upgraders on tmux 3.3a through 3.6 lose control connections and need to know the alternative. The other gates refuse commands that misbehave on one release. what: - Record the 3.7 control requirement as a breaking change, with the support check and the polling alternative - Record the release-specific command refusals and the break-out repair - Record the hook-free snapshot identity read - Record the polling screen wait in libtmux-mcp, and the wait contracts: wrapped rows, the pane_died result and the lost-stream result - Record the control-line refusals, the raw name refusals and the Async subscription end-of-stream fix - Record the capability registry and the capabilities.json manifest for the docs capability pages
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 24, 2026 01:13
8bb8dda to
e9bcac8
Compare
tony
force-pushed
the
bump-min-tmux-001
branch
from
October 24, 2026 08:14
e9bcac8 to
bff3b1f
Compare
tony
marked this pull request as ready for review
October 24, 2026 08:36
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Keep ordinary operations, snapshots, the workspace builder and MCP startup on tmux 3.3a while refusing unsafe optional operations on the daemons that mishandle them, before their effects. This builds on the core minimum change and targets master.
open_control, Async control, reconnect)tmux_waitscreen_containscapture-panebefore it, no refusal or opt-in; wrapped rows join, and a pane or server that ends first fails aspane_diedbreak-pane, including lines sent over a control connectionPane#break_outrenames after the move there:or., typed or in rawnew-session -s,new-window -nandrename-*split-window -p-l N%refresh-client -l, including control linesxterm-256colorortmux-256colorterminal for its clipboard stops the serverControl acquisition is decided from the checked release before any control client attaches, because a fallback after the attach fails cannot protect it; a route no server checked probes the daemon with the subprocess probe first. Over control mode the probe's error line arrives outside any
%begin/%endblock, so the subprocess probe is the only version-check read. The Ruby port already holds a retained short private socket route, so the private alias requirement is met by the existing route. The MCPscreen_containswait polls the pane screen every 50 ms below 3.7 and attaches no control client. On both paths it joins wrapped rows, so text split across rows matches. After a stream end or a failed read the wait asks the daemon one hook-freerun-shell -Cquestion; a pane that is gone, or a daemon that reports it is gone (no server running,server exited unexpectedly, or a missing or refused socket), ends the wait. A probe that cannot run, such as a failed spawn, decides nothing, so a lost stream on a live pane staysobservation_lost. A pane, or a tmux server, that ends before the text appears fails the wait with the newpane_diedcode,delivery: observedand the last rows read inerror.capture; a control stream that ends while the pane lives, such as after a detach, returnsobservation_lost. AsyncControlSubscription#nextnow ends the stream withStopIterationinstead of returningnil.process_exit, captures and every other tool are unchanged. Tests that need control assert the refusal on older daemons instead of skipping, and examples demonstrate it.Literal argv guards cover typed operations and raw
Server#run, including;batches, flag clusters and tmux's command abbreviations.ControlConnection#exchangeand its Async twin apply the same rules to their command line, which they read as tmux does (quotes, backslashes, unquoted;), because a namelessbreak-panesent over a control connection killed an exact 3.7 daemon. Tmux command strings nested inside another command (the command given toif-shellorrun-shell -C, brace blocks,$VARIABLEexpansions) andcommand-aliascontents are not read, so callers must not nest a command a gate refuses. Named paste-and-delete (paste-buffer -d -b) stays available on 3.3a because its lookup is exact. The library promises no capturedrun-shellstdout, so the 3.3 to 3.4 view-mode routing needs no refusal. Single-panebreak-paneon 3.3a to 3.5 prints no ID, soPane#break_outreads the window back from the pane.Identity reads no longer use
display-message, which firesafter-display-messageon every release: the snapshot's server identity and the Darwin freshness check read through the probe's error text with the fields joined by commas into one word (tmux reports only the first unparseable word, and a multi-line or spaced report is refused), and the break-out read-back useslist-panes.Every release gate lives in one internal capability registry, emitted as the root
capabilities.jsonmanifest that the docs site reads for its capability pages;scripts/emit-capabilities --checkfails when the manifest is stale.Window#select_layoutraisesUnsupportedFeatureError, notArgumentError, for a JSON layout below 3.8-rc2.Server#supports_control?reports whether control is available so callers and the examples can choose a polling path first; they no longer reach into private methods. The control gate follows the shared vectors:next-3.7and3.7-rc*rank below 3.7 and are refused,3.8-rc2andnext-3.8are admitted.Not applicable here:
split-window -pencoding (already-l N%), the layout header check (already present for every release), temporary-buffer cleanup callers (the library has none), and workspace colon or period names (the workspace normalizer already refuses them).Validation, all against real daemons: 3.3a, 3.4, 3.5, 3.6b, 3.7, 3.7a, 3.7d, 3.8 and master (
next-3.9). At the tip, the feature, wait, control, Async, server-control, MCP capture and application, snapshot and operations suites pass again on 3.3a, 3.4, 3.7, 3.7a and 3.8, andscripts/check outerpasses.-prefused only on 3.4; malformed layouts refused before 3.4 with the daemon alive. Unit tests cover separators, abbreviations, flag clusters and escaped semicolons both ways.scripts/check midpasses after the rebase onto current master, including the manifest check.-pguard (3.4); removing the layout guard (3.3a); restoring the refusal in the MCP screen wait (the wait tests error on 3.3a); and restoringdisplay-messagefor the snapshot identity and the read-back (the hook test fails on 3.7d, 3.3a and 3.5); comparing the control gate by major and minor only (the vector test fails). Further breaks, each failing its test: removing the guard fromexchange(both exchange tests fail on exact 3.7); making the raw name check a no-op (unit and 3.7 tests fail); dropping-Jfrom the wait capture (the wrapped-row test fails on 3.3a and 3.7a); mappingpane_diedback tostale_target(the dead-pane and killed-server tests fail on both); raising a tmux process exit asobservation_lostagain (killed-server test fails on 3.3a); and lettingKernel#loopswallowStopIterationin the Async subscription or the observer (detach test errors on 3.7a). Treating any failed probe as a dead daemon fails the failed-probe test on 3.3a and 3.7a and the lost-stream test on 3.7a. The killed-server case with a stopped second client, which keeps the server alive while it shuts down, is decided by a hook-free daemon probe, not a timer: 0 failures in 25 runs on each of 3.3a and 3.7a, against 25 of 25 failing with the earlier 0.25 s timer.Not qualified: concurrent control acquisition beyond an idle attachment on 3.7 and newer is unverified. The Darwin freshness read is covered by the parser unit tests only because the host cannot run it. The full matrix and macOS are for hosted CI.
Closes #33.