Skip to content

Send caller text literally, surface every failure - #28

Merged
tony merged 117 commits into
masterfrom
api-improvements
Sep 20, 2026
Merged

tony merged 117 commits into
masterfrom
api-improvements

Conversation

@tony

@tony tony commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Treat caller text as text. Names, titles, start directories, shell commands, messages, key bindings and access rules reach tmux as literal values. Text beginning with - is a value rather than a flag, and #{...} is no longer expanded by accident. TmuxArg::format opts a caller into a deliberate template.
  • Surface failures that were being dropped. ControlEvents reports stream errors during iteration, the scoped helpers return a ScopeError carrying both the operation's failure and the cleanup's, and PaneOutput ends when its pane or window closes instead of hanging on next_chunk().
  • One option reader, and checked writes. get_option and its global variants give way to typed_option; set_typed_option validates a value against the recorded choices and ranges before writing it.
  • Remove the lenient listing twins. The *_or_empty helpers are gone — handle the listing error, or apply unwrap_or_default at the call site for the old behavior.
  • Reuse one control connection. Server::over_control_mode routes typed operations through an existing control connection, and Server::owns_control_client / Client::is_own tell this process's control clients from a person's terminal so attachment reports can exclude them.
  • Harden tmux-mcp's contract. Refusals return ToolError with structured reasons a client can act on, wait_for_text separates output already on screen from output that arrived during the wait and ignores a command's own echo, tool metadata stops repeating each tool's schema, and environment values are withheld unless named in LIBTMUX_ENVIRONMENT_VALUES.
  • Bring tmux-workspace to tmuxp's behavior for inherited and relative start directories, pane and command shorthands, per-command Enter and delay, shell-history suppression, and configuration errors that name the line and column to fix.

Breaking changes

This is a prerelease, so breaking changes are expected; they are listed here because there are enough of them to plan around. The changelog's Unreleased section is the complete list.

Covered by the migration guide

crates/libtmux/docs/migration.md is packaged with the crate and rendered into its documentation. It carries a worked before/after for each of: SystemTime timestamps, TmuxText buffer names, Respawn and MenuItem in place of bare literals, ID-carrying filter handles, the single typed_option reader, the _or_empty removal, literal names and titles, fallible control events, scoped errors, and owned queries.

Not in the guide

Crate Change What a caller does
libtmux AccessRule::name replaces user; principal distinguishes a user from a group Read principal where the distinction matters
libtmux Pane::pid is Option<u32> Use is_dead to check liveness; a dead pane reports no pid
tmux-mcp Tool methods return ToolError for a refusal Match the error rather than parsing a message
tmux-mcp wait_for_text reports present_at_entry, pending input, and output received during the wait as distinct outcomes Branch on the outcome instead of treating any match alike
tmux-mcp Tool metadata omits repeated descriptions and schemas Read them from the tool or from tmux://capabilities
tmux-mcp set_history_limit requires the teardown toolset Enable teardown where the tool is needed
tmux-mcp Environment tools withhold values by default Name the variables in LIBTMUX_ENVIRONMENT_VALUES
tmux-workspace ConfigError carries line and column Update matches on the error
tmux-workspace Shell history is suppressed by default Set suppress_history: false to record commands
tmux-workspace PaneConfig::shell_commands takes ShellCommand values Use the struct form for per-command Enter and delay

Changes by area

crates/libtmux

  • Argument handling: caller text is literal everywhere it reaches tmux — creation commands, plans, shell commands, messages, key bindings, access rules and environment values, including values carrying shell punctuation or newlines.
  • Control mode: streams end when their pane or window closes, including a window that was never active and a pane that was its window's last; resume_pane and unmute_pane each fully undo mute_pane; watch_only no longer mutes panes in other sessions; the actor orders its stop signal ahead of a pending EOF; a chained command reads one block per command. A failure while opening a connection or a pane stream drains its owned events and reports the terminal cause, so a frame-budget violation during attach is no longer reported as an ordinary close.
  • Channels: Server::with_channel_lock releases on failure and cancellation, lock_channel and wait_for_channel keep their lock or signal when a waiting call is cancelled or times out, and a wait a connection cannot carry is refused rather than silently dropped.
  • Options: set_typed_option checks writes against a recorded schema of choices and ranges; replacing set_hooks clears and writes in one invocation, so a cancelled call cannot leave a hook cleared but unwritten.
  • New surface: Pane::wait_until for a predicate over captured lines, Pane::stream_output_with_limits to bound streamed output, Server::typed_key_bindings on tmux 3.7+, Server::load_buffer / save_buffer for buffers too large for command arguments, NewSessionOptions::environment, per-object get for any field a listing already fetched, plan::Pause, and TmuxVersion::has_behavior.
  • Layouts: Window::select_layout and every layout path in plans, workspaces and MCP validate before dispatch, accepting a unique preset prefix and refusing an ambiguous one.
  • Formats: the row codec accepts the escapes newer tmux builds emit; Pane::capture_with trims unwritten wrap padding on 3.2a.

crates/tmux-mcp

Tool annotations are derived from the toolset rows rather than hand-maintained, every input property is described and every optional one states its default, inspect tools are classed read-only and set_history_limit destructive, refusals are typed, session IDs from its own listings are accepted, an empty TMUX or TMUX_PANE reads as detached, interrupts reach a pane while run_shell_command owns it, a shared daemon stays alive while another instance uses it, and streamed captures keep reading past an unterminated terminal escape string.

crates/tmux-workspace

tmuxp parity for start-directory resolution and environment expansion, pane and command shorthands, blank-pane forms, per-command Enter and delay, and waiting out tmuxp's sleeps. freeze records an idle shell as an empty pane, so rebuilding a frozen workspace does not start a nested shell.

crates/libtmux-macros

Every reachable derive error site has a compile-fail fixture, pinned on one compiler. The derive names the crate ::libtmux correctly when used inside the libtmux package itself.

CI and fuzzing

Fuzz targets cover control framing, control lines, the environment listing, the filter expression and format rows, with seed corpora; the weekly run keeps its corpus between passes. rust-cache is pinned by SHA.

Design decisions

  • Literal by default, templates by request. Format expansion in a name or a start directory is a footgun that reads as working code, so expansion is now opt-in through TmuxArg::format rather than opt-out through manual escaping. tmux expands more than names — a -c start directory is format-expanded too — so the escape had to move to the argument layer rather than each call site.
  • has_behavior, not meets, for a capability question. TmuxVersion::meets clamps a development identifier to the crate's minimum supported release regardless of that build's own next-release number, which is correct for a floor and wrong for a capability. Three production call sites were asking the wrong question: the most consequential refused a pane-scoped pane-border-* write through Pane::set_option on any development build, including one that genuinely supports it. has_behavior is the boolean form of the rule Server::require already uses, and is now public API rather than test-only.
  • Buffer transfer through files. load_buffer and save_buffer let tmux read and write the file itself, which is the only path that works for a buffer larger than a command argument can carry.
  • Refusals as ToolError, not JSON-RPC errors. A JSON-RPC error tells a client the call failed; it does not tell it whether retrying could succeed. Tool failures are reported as isError content with a structured reason so a client can distinguish a refusal it can fix from one it cannot.
  • Why not trybuild for the matching borrow bound. Converting that compile_fail,E0271 doctest to a pinned trybuild case was tried and reverted. The .stderr generated under the pinned development toolchain does not match what the crate's own MSRV compiler prints for the same rejection: the "required by a bound in ..." note names the return-position-impl Trait's synthesized associated type differently per compiler version, and trybuild has no normalization for it. just msrv runs that older compiler in CI, so a pinned fixture would fail there. The doctest stays — illustrative rather than enforced, since rustdoc checks only that compilation fails, not the error code.

Verification

Hand-tested end to end against real tmux, not only the fixtures.

tmux Why this one
3.2a The oldest supported release: ignores a new session's requested size, and pads joined capture lines
3.4 One of the releases whose command output wraps in a second codec dialect
3.7c Muting also stops tmux reading the pane's pty from 3.7; typed key bindings start here
next-3.9 (locally built) Widened the #{q:} escape set to cover {, } and a raw newline, and added group ACLs

CI's compatibility matrix builds 3.2a, 3.4, 3.5a, 3.6b and 3.7b. The development-build findings therefore have no gate behind them — they were found by hand and are held by tests that assert the decode claim rather than exact wire bytes, so a later development build does not re-break them.

Two findings came only from the development build and are worth stating in full:

  • Group ACL markers. A tmux release carrying group ACLs (upstream commit 4d1ab1ba) marks every server-access -l row U or G beside R/W, including the owner's. The old decoder matched only the bare (R)/(W) form and silently dropped every row. An unrecognized marker now returns Error::UnreadableAccessRule naming it, rather than vanishing.
  • Widened #{q:} escapes. Accepting {, } and a raw newline is a strict superset, so no released tmux changes behavior, and escapes tmux never emits are still rejected. This reached most listings rather than an edge case, because window_layout is requested on nearly every window listing and its value is JSON on that build.

Each fix ships a negative test that was shown to fail for its intended reason before the fix landed.

Test plan

  • just check — formatting, Clippy, workspace tests, doctests, public examples, documentation, feature combinations, dependency policy, the supported Rust floors, and package validation
  • just api-check — the regenerated public-api.txt matches the exported surface, and ships as the review artifact for the API changes above
  • just example-coverage-check — every new public type has a runnable example
  • CI's tmux compatibility job — the workspace suite against each tmux the matrix builds
  • Hand-tested against real tmux 3.2a, 3.4, 3.7c and a locally built next-3.9
  • Each fix paired with a deliberate break proving its test bites

why: Accessor names should feed byte-based lookups without an explicit
conversion or any change to invalid UTF-8.

what:
- Implement AsRef<[u8]> as an allocation-preserving borrow
- Exercise accessor-to-lookup composition and exact invalid-byte matching
- Regenerate the public API index
why: Selecting owned values should not require cloning them. A generic
Borrow<T> replacement makes generic matchers ambiguous on references.

what:
- Keep matching for borrowed candidates and add matching_owned
- Preserve each iterator's item type in cardinality results
- Cover non-Clone values, adapters, matcher inference and lifetimes
- Record the public trait-bound change and owned consumer examples
why: Cleanup used to replace the caller's error when both failed, losing
its type and value at the point where both diagnostics matter.

what:
- Return ScopeError<E> from session, window and pane scopes
- Retain both errors and mark cleanup as an AfterEffect
- Keep generic operation values recoverable without formatting them
- Document source chaining for creation and cleanup; generic operation
  values remain in their variants, including boxed and non-Error values
- Verify real double failures, ownership, redaction and boxed consumers
@tony
tony force-pushed the api-improvements branch 2 times, most recently from 6b608b9 to b54de22 Compare September 13, 2026 14:57
tony added 11 commits September 19, 2026 08:08
why: A fallible connection must report its terminal diagnostic during
ordinary iteration, without requiring a separate shutdown to discover it.
poll_next and shutdown also mapped every join failure to
control_mode_closed, including a panic in the connection task; nothing
else aborts that task, so a join failure can only be a panic, and a
supervisor that reconnects on control_mode_closed would retry into the
same panic with no trace of it reaching a log.

what:
- Yield Result events and consume one terminal error after buffered events
- Wait for cleanup before EOF and retain explicit early shutdown
- Distinguish tmux's normal exit from an unexpected EOF
- Resume a connection task's panic in the caller's task in both
  poll_next and shutdown, matching the existing pattern in
  internal/scoped.rs for the same join-failure-is-always-a-panic case
- Preserve cancellation and document forced-shutdown delivery limits
- Exercise real consumers and prove terminal-error suppression fails
- Publish compiled migration examples and include them in packages
why: The design and parity notes still limited queries to borrowed items.

what:
- Describe borrowed and owned matching and cardinality.
- Align the iterator plan signatures with the public trait.
why: ci.yml and release.yml floated Swatinem/rust-cache@v2, so a tag
move changes what both workflows run without a diff here to review.
release.yml is the part that matters: a publish should not rest on a
moving reference. v2.9.2 is 6323deb1, which is what the floating @v2
already resolves to, so pinning it runs the same bundle master runs
today while making the reference immutable.

Nested cache cleanup returning before nested target directories
finish, and emitting unhandled missing-directory errors during
restore and save, is a real defect that reaches this crate:
cleanProfileTarget takes the affected branch only when the profile
directory is named "tests", which exists here because
crates/libtmux-macros dev-depends on trybuild; inside it,
cleanTargetDir(target/tests/target) is called without await, and this
crate uses no kaos or macrotest, so that directory is absent and the
ENOENT escapes the enclosing try as an unhandled rejection. It is not
worth a supply-chain dependency on an unreviewed branch to fix now:
the fix lives only on Swatinem/rust-cache PR 387, still open on the
maintainer's personal fork branch, and save.ts installs a
process-level uncaughtException handler that logs and returns, so the
rejection surfaces as two error annotations and the save proceeds and
exits zero. Reproduced against that handler shape to confirm the save
continues rather than aborting.

what:
- Pin CI and release caches to Swatinem/rust-cache@6323deb1 (v2.9.2),
  not the floating @v2 tag.
- Preserve compiled dependency reuse and workspace invalidation.

Revisit when PR 387 lands in a release.
why: The serve loop's select was unbiased when EOF and Ok(()) meant the
same thing, so the race never mattered. It now returns Err on EOF, so
whichever branch the executor happened to poll first decided whether a
caller-requested shutdown raced against a pending EOF saw Ok(()) or
Err(Closed), contradicting the documented "caller-requested closure
succeeds" contract.

what:
- Mark the select biased and check the stop and executor-shutdown
  signals before the read, so either one wins deterministically over
  a simultaneous EOF
why: libtmux now reports EOF without %exit as Error::ControlMode with
ControlModeErrorKind::Closed rather than Ok(()), a runtime-only change
this crate never opted into. wait_for_text's closing shutdown().await?
turned that into a hard error, discarding the WaitView already built
for a pane that simply closed (WaitOutcome::PaneClosed) or that this
loop had already stopped reading after a match.

what:
- Tolerate only ControlModeErrorKind::Closed from the closing shutdown
  and still propagate every other terminal error (frame budget,
  timeout, executor shutdown), matching Error::ControlMode's own
  documented Closed idiom
why: Debug and Display hand-wrote unconditional impls for any E to keep
ScopeError<E> working for operation values with no trait bounds at
all, and paid for that by never printing the operation value: Debug
said Operation(<redacted>), Display said "scoped operation failed",
and source() returned None even from OperationAndCleanup's own
operation field. A caller propagating ScopeError with `?` into
Box<dyn Error>, as both examples do, lost the actual cause from stdout
and from the error chain -- the opposite of what a PR titled
"preserve failures" should do.

what:
- Bound Debug and Display each on the one trait they need from E, and
  Error on both, since it requires them as supertraits; each now shows
  the operation value when E provides it, and still compiles for an E
  that provides neither
- Leave source() returning None for a lone Operation: E is still not
  required to implement std::error::Error, so there is no
  &(dyn Error + 'static) to hand back even when Display can show it
- Update the doc comment, the migration guide, and the test that
  asserted redaction to match

Verified scratch.rs (E = Box<dyn Error>, not itself Error) and
orchestrate.rs (E = libtmux::Error) both still compile: the bound this
drops is Error, not Debug or Display, so neither example needed it.
why: ScopeError::Cleanup(Error) discarded T on the one path where the
caller had already paid for it: the operation succeeded, cleanup
failed afterward, and the computed value was thrown away with no way
back to it. The PR that made cleanup and operation errors mutually
retrievable still lost a result the caller already has in hand.

what:
- Add a T parameter to ScopeError, unreleased so this is free: Cleanup
  becomes a struct variant carrying both the operation's value and the
  cleanup Error, alongside OperationAndCleanup's existing shape
- Thread T through with_session, with_window, with_pane, and the
  internal scoped::run that builds ScopeError
- Bound Debug on T: Debug alongside E: Debug, matching Cleanup's new
  field; Display and Error need no T bound, since neither ever prints
  a successful result
- Update the crate doc, design and migration docs, and the test that
  matched Cleanup's old tuple shape; add a value-carrying assertion
  cleanup_failure_after_success_retains_the_computed_value replaces
why: next_event() now yields a terminal error instead of ending the
stream silently, so the watcher's event? can end the task with Err.
main propagated that with tokio::time::timeout(...).await???, which
returned before server.kill(), server.shutdown(), and
remove_file(&socket) ran -- leaking a tmux server and a socket file
under /tmp/libtmux-rs-dev per failed run.

what:
- Collect the watcher's outcome in an inner block instead of
  propagating it immediately, so kill/shutdown/remove_file always run
  before the error (if any) is returned
- Keep events.shutdown() ahead of server.kill() on the success path,
  unchanged from before

Verified by temporarily forcing the watcher's first iteration to
return Err: before this change the run left both the socket file and
the tmux server behind; after, neither remains.
why: The two compile_fail blocks added for matching_owned's exactly_one
and one_or_none examples name no error code, so an unrelated typo would
make either pass for the wrong reason. Replacing the old
into_iter().matching(...) example with matching_owned's working one
also dropped the only test that a borrowed-only matching() still
refuses an owned iterator, with nothing added back in its place.

what:
- Pin E0597 (borrow does not live long enough) and E0382 (use of moved
  value) on the two existing compile_fail blocks
- Restore an into_iter().matching(...) compile_fail example, pinned to
  E0271 (the associated-type mismatch it now produces)

Verified each E-code against the actual diagnostic by compiling the
three snippets directly. Also verified, by pinning one to a wrong code
and rerunning `cargo test --doc`, that stable rustdoc's compile_fail
does not check the code against the pin: the mismatched run still
passed. The pins document the intended failure for a reader; they are
not an enforced gate on this toolchain.
why: A review finding claimed PaneOutput collapses every terminal
error into a generic closed/None, same as its own Stream and
next_chunk do by design. It does not: shutdown() never touches
ControlEvents::poll_next, so the connection's JoinHandle is still
intact when shutdown awaits it, and the specific error comes back
whichever one it was. Nothing exercised that path.

what:
- Add pane_output_shutdown_reports_the_specific_terminal_error: a mock
  connection resolves to ControlModeFrameTooLarge, next_chunk still
  ends quietly (the documented infallible-stream contract), and
  shutdown returns the frame error rather than a generic one

Verified the test bites: temporarily made shutdown's Ok(outcome) arm
discard outcome (`Ok(_outcome) => Ok(())`), reran, watched it fail on
the expect_err, then reverted.
why: The unreleased tree reporting itself as next-3.9 has tmux
escaping `{`, `}` and a raw newline under `#{q:}`, which no release
through 3.7c does. QUOTE_SHELL_SPECIALS is a closed allow-list, so
decode_escape rejected every one of the three with InvalidEscape.
window_layout is the field almost every listing carries, and on that
tree its value is JSON -- built from braces -- so the failure reached
nearly every Window listing: new-session, new-window, and any query
that lists or resolves a window. Verified directly against real
next-3.9 output for `#{q:session_name}` (`##{version}` now decodes as
`\#\{version\}`, versus `\#{version}` through 3.7b) and for a raw
newline in a set option's value.

Accepting the three bytes is a strict superset: no supported release
emits them, so nothing already decoding correctly can start decoding
differently. No `since::` gate is needed, matching how this crate
already treats other tmux-emitted-bytes sets.

Kept the allow-list rather than switching decode_escape to "a
backslash always makes the next byte literal". decode_escape already
uses QUOTE_SHELL_SPECIALS to catch a dialect mismatch loudly -- Vis
output read as RawQ, or the reverse -- instead of quietly decoding into
different bytes (see its own doc comment). A generic rule would also
misread a Vis three-digit octal escape as three separate one-byte
escapes under RawQ. The pre-existing
format_codec_rejects_escapes_tmux_never_emits test (probes `\:`, `\]`,
`\z`, none newly accepted) still passes unmodified, so strict rejection
of a genuinely unknown escape is preserved.

what:
- Add `{`, `}` and `\n` to QUOTE_SHELL_SPECIALS
- Extend the escape-set-equality test with the three additions and add
  a dedicated round-trip test for them; verified both fail with
  InvalidEscape on the prior set and pass after, then reverted the
  fixture to confirm the pre-existing rejection test needed no change
- Confirmed against live next-3.9: a previously-InvalidEscape listing
  (crates/libtmux/tests/commands.rs
  a_global_window_option_is_read_globally) now passes, and the
  adversarial-transport compat test's decode-and-round-trip step now
  succeeds (its separate raw-wire-byte fixture still needs its own
  fix, tracked apart from this one)
- CHANGELOG entry under Unreleased
tony added 13 commits September 19, 2026 08:19
why: tmux's group-ACL change (upstream commit 4d1ab1ba, "Allow ACLs
to use groups as well as users") marks every `server-access -l` row
`(U,R)`/`(G,W)` instead of the bare `(R)`/`(W)` this crate expected --
owner row included. The old decoder matched only the exact strings
"(R)" and "(W)", so it silently dropped every row on a release with
this change, and `access_rules()` came back empty even though the
server was reachable and owned. Verified against
a tmux built from master-e880cf63 (next-3.9) and confirmed the grammar
against tmux's cmd-server-access.c, server-acl.c and the tmux.1 diff
in commit 4d1ab1ba.

what:
- Add `Principal::{User, Group}` and `AccessRule::principal`. Rename
  `AccessRule::user` to `AccessRule::name`, since a row can now name
  either.
- Decode the marker from the line itself, not from the detected tmux
  version: a legacy line with no comma is always a user, a compound
  line reads its `U`/`G` letter. This is a capability probe on the
  text tmux actually printed, not a version predicate.
- Add `Error::UnreadableAccessRule`, naming the unrecognized marker,
  for a line that matches neither grammar -- replacing the silent
  drop.
- Unit tests for both grammars and for the new error, plus updating
  the real-tmux integration test to assert `Principal::User` for the
  owner; ran it against both the default tmux 3.7d and the next-3.9
  probe binary.
- CHANGELOG entries under Unreleased; regenerated public-api.txt.
why: `just example-coverage-check` (CI's "public API surface" job)
requires every crate-root type to carry a runnable example, and
Principal shipped in the previous commit with prose only. CI on
"Server(fix[access]): Decode server-access group ACL markers" caught
it: `just example-coverage-check` failed while `just api-check` passed.

what:
- Add a doctest to `Principal` matching both variants through
  `AccessRule::principal`.
- Verified locally: crate-root types with a runnable example: 77/77
  (100%), up from 76/77.
why: Four tests gate on the same capabilities Server::require refuses
with, but predicted the branch using TmuxVersion::meets instead --
a rule meets's own doc comment says clamps every development
identifier ("master" and "next-X.Y" alike) to "no more than the
crate's minimum supported release", refusing any requirement above
that floor regardless of the build's own next-release number.
require, through the private behavior_release, reads next-X.Y as
the real release and takes a bare master at its word. Verified the
divergence is not hypothetical: reverting one test to meets and
running it against this workspace's own next-3.9 tmux-matrix probe
binary fails it outright, panicking on the "older tmux" branch
while the real dispatch had already succeeded.

what:
- Add TmuxVersion::has_behavior, gated behind test-support: the
  boolean form of the rule require refuses with, factored into a
  shared behavior_satisfies so require and has_behavior cannot drift
  apart the way two independently written rules could.
- Switch dispatch_only_commands_are_accepted,
  a_suspended_client_is_not_reported_gone,
  trimming_blank_cells_is_refused_below_the_release_that_has_it, and
  real_tmux_compat_capture_line_flags_mark_prompts_when_the_shell_emits_them
  from meets to has_behavior. The fourth was not named in the
  original finding but has the identical shape (gated by
  Server::require through CAPTURE_LINE_FLAGS) and would have
  reappeared as "a fourth" had it been left.
- Add has_behavior_and_meets_disagree_above_the_supported_floor,
  pinning the general rule (any development identifier, any
  requirement above the floor) rather than only the master case, and
  naming next-3.9 -- the probe binary's actual version string --
  directly.
- Ran the full set against both the default tmux 3.7d and the
  next-3.9 probe; regenerated public-api.txt.
why: real_tmux_compat_format_q_matches_versioned_adversarial_option_transport
pins the exact stdout bytes tmux prints for the RawQ and Vis
dialects. next-3.9 widened #{q:}'s escape set (fixed for decode in
"Formats(fix[codec]): Accept next-3.9's widened #{q:} escape set") to
also cover a raw newline, and OPTION_BYTES contains one, so next-3.9
now escapes it as `\` + literal newline where every release through
3.7c left it bare. Both are "the RawQ dialect" --
TransportDialect::for_version does not change -- so this is not a
dialect switch, it is the escape set moving under a frozen pin.
Reproduced: reran the test against the tmux-matrix's master-e880cf63
build before this fix and it failed with exactly that byte difference
([...,0x5c,0x0a,...] vs the pinned [...,0x0a,...]); the same tmux
still decodes the value correctly (the assertion this fix keeps
unconditional).

what:
- Pin EXPECTED_RAW_STDOUT/EXPECTED_VIS_STDOUT only when
  version.release().is_some() -- a numbered release's wire is frozen
  forever, so a mismatch there is this crate's own regression, not
  tmux's. A development identifier's wire is not frozen, so only the
  decode_text(slot) == OPTION_BYTES claim -- the one made to callers
  -- is asserted for it.
- Negative-test proof: reran against 3.7c with one byte of
  EXPECTED_RAW_STDOUT deliberately wrong -- fails, naming the
  mismatch, confirming the frozen-release pin still bites. The same
  corruption against the next-3.9 probe still passes, confirming the
  pin is skipped rather than vacuous there. Reverted before
  committing.
- Verified against every locally available matrix binary: 3.4 and
  3.7c (frozen, pinned) and next-3.9 (development, decode-only).
why: tmux 3.8 reports #{pane_pid} as an empty string once a
remain-on-exit pane's process has exited. PANE_PID was cataloged
Required, so an empty value there was RequiredFieldEmpty: the whole
pane listing failed rather than reporting the pane with no pid.
Measured on 3.2a, 3.4, 3.6 and 3.7c: all four report the exited
process's own (by then possibly reused) pid, never an empty value.
tmux 3.8 is not breaking a contract, it is declining to keep
reporting a pid that may no longer name that process, so a populated
pid was never proof of liveness on any release -- a caller must
check Pane::is_dead rather than infer it from pid. tmux-mcp hit this
through respawn(Some("exit 0"), true): two tests failed at the
identical byte offset, deterministically. libtmux-java fixed the
same tmux change at its one call site rather than loosening its
shared numeric parser; this does the same, since RequiredFieldEmpty
must stay strict for every other numeric field.

what:
- Change PANE_PID's catalog empty policy from Required to Absent --
  the same policy pane_pipe_pid and pane_dead_status already use for
  a numeric field tmux may not report. Its floor stays V3_2A, so it
  is never Unsupported or Unproven on any supported or development
  build, only Available or Absent.
- Change Pane::pid from `u32` to `Option<u32>`; update its two
  `> 0` callers in hierarchy.rs and mutations.rs, and document that a
  populated value is not evidence the process is alive on any
  release.
- Move pane_pid into the u32 evidence group in the stored-field-type
  compile check, and into parity.md's checked catalog table
  (required -> absent), updating the partition counts it feeds.
- Negative test: snapshot_catalog_empty_policy_distinguishes_all_three_states
  now covers an empty pane_pid decoding to Absent, a populated one
  still decoding to Available, and (since the floor is V3_2A) that
  it is never Unsupported on an old numbered release or Unproven on
  a development build -- while pane_width in the same test still
  proves RequiredFieldEmpty is unchanged for a field that is
  genuinely required.
- Added a_dead_panes_pid_is_absent_rather_than_a_decode_failure
  (mutations.rs): a real respawn(Some("exit 0"), true) fixture,
  matching tmux-mcp's repro. Branches on whichever pid shape the
  running tmux actually reports (every release through 3.7c retains
  the exited process's own pid; 3.8+ clears it) rather than assuming
  one, so it passes on the default tmux and was verified separately
  against the next-3.9 tmux-matrix probe binary, where reaching the
  assertions at all is the regression proof.
- Reran tmux-mcp's two originally-failing tests
  (send_keys_refuses_modal_and_dead_configured_members_before_input,
  paste_text_is_target_only_and_guards_before_buffer_creation): both
  pass now without any tmux-mcp change, confirming the fix belongs
  in libtmux as the finding said.
- Regenerated public-api.txt; format-coverage-check confirmed
  unaffected (no field added or removed).
- Audited every production (non-test) read of pane_pid/Pane::pid
  across libtmux, tmux-mcp and tmux-workspace: none exists, so
  nothing in this workspace assumed liveness from a populated pid.
why: wait_for_text tolerates only ControlModeErrorKind::Closed from
output.shutdown(); every other shutdown error is real and discards
the WaitView being built. No test reached that branch, because
Pane::stream_output() always attaches with ControlLimits::default(),
and tmux-mcp had no way to inject a smaller budget. Exposing the
budget as an MCP tool argument would leak a protocol-tuning detail
into the tool's surface, so this makes limits injectable as real
library API instead -- ControlMode::attach_with_limits already
exists for the same reason -- rather than a test-only seam that
cannot cross the crate boundary.

The first version of the test sent one adversarial line a fixed
200ms after attaching, which flaked under load: dropping the sleep
entirely produced the ordinary Closed outcome in 2 of 8 runs instead
of the frame-budget error, a root cause not established at the time.
A sleep that hides an unexplained failure is a flake vector, not a
fix for one, so the test instead attaches synchronously first
(pane.stream_output_with_limits), sends the adversarial line only
once that call has returned, then calls the read loop directly -- no
tokio::spawn, no sleep, no race. Attach is provably complete before
the flood starts, so the only reachable path is the already-verified
one: a post-attach oversized %output line -> ControlModeFrameTooLarge.
The Closed-instead-of-FrameTooLarge sharp edge itself is reported,
not chased further here.

what:
- Add Pane::stream_output_with_limits, the same default/explicit
  pair ControlMode::attach/attach_with_limits already is;
  stream_output now delegates to it with ControlLimits::default().
- Add exec::wait_for_text_with_limits in tmux-mcp, pub(crate); the
  one MCP tool call site keeps calling the unchanged wait_for_text.
- Split wait_for_text_with_limits at the attach point: it now
  attaches and delegates to a new wait_on_output, which runs the
  read loop, present-at-entry check, and shutdown. wait_for_text is
  unaffected.
- Add wait_for_text_surfaces_a_frame_budget_error_instead_of_tolerating_it:
  attach synchronously, send the adversarial line only once attach
  has returned, then call wait_on_output directly. Asserts the
  resulting ControlModeFrameTooLarge propagates rather than being
  read as an ordinary close.
- Negative-test proof: widened the budget to a value the adversarial
  line cannot exceed and reran -- the wait matches the pattern
  instead of erroring, confirming the assertion exercises the budget
  rather than any error. Reverted before committing. Verified: 20 of
  20 isolated runs and 3 of 3 full tmux-mcp --lib suite runs (143
  tests) pass with no sleep at all. Also reran the existing
  wait_and_cursor_tools_observe_live_output integration test, which
  exercises wait_for_text (unsplit path) through the real MCP tool,
  to confirm the split changed nothing observable there.
- Regenerated public-api.txt.
why: no counts in code comments -- they go in the commit message,
where they are timestamped against the exact diff instead of
drifting from it. Two comments violated this, one of them also
wrong: version.rs said "three tests" reproduced the meets/
has_behavior divergence, but "Tests(fix[version]): Predict
require-gated branches with require's rule" fixed four. Separately,
exec/tests.rs's frame-budget test doc kept the "2 of 8 runs"
measurement and the deliberation that led to the current design,
both of which belong in "MCP(add[wait]): Cover wait_for_text's
non-Closed shutdown branch"'s message, which already has them.

what:
- version.rs: name what the rule was reproducing (the commands.rs
  tests) instead of counting them.
- exec/tests.rs: cut the measurement and the design deliberation,
  keeping only the constraint the comment needs: attach happens
  before the flood is sent.
- Reran both edited tests; unaffected. just doc-blocks, parity-claims,
  fixture-root and example-tables all report clean.
…ites

why: the coordinator authorized this after review of "Tests(fix[version]):
Predict require-gated branches with require's rule"'s four test fixups:
has_behavior was gated behind test-support, reachable only from tests,
while the mistake it fixes -- gating a capability above the crate's
minimum supported release with meets, which clamps every development
identifier to that floor regardless of its own next-release number --
lives in production code too. Seven call sites across this workspace
made the same mistake (four tests, fixed in "Tests(fix[version]):
Predict require-gated branches with require's rule", three production,
fixed here), which is one API offering a plausible-looking wrong
answer, not seven unrelated bugs.

Severity ranked: internal/options.rs:303 (LATE_SCOPES) is a functional
regression on any development build -- it actively refused a pane-
scoped pane-border-format/pane-active-border-style/pane-border-style
write through Pane::set_option even when the running server genuinely
has the capability. tmux-mcp's tools/mod.rs:94 degraded
capture_last_command to a plain capture on a development build that
has per-line capture flags. control.rs:477 fails safe (costs a pane's
back-pressure, not correctness) -- lowest severity, fixed for
consistency.

what:
- Remove has_behavior's test-support gate; it is real, always-on
  library API now. Added a doctest contrasting it with meets
  (next-3.9 against CAPTURE_LINE_FLAGS) since it is no longer a
  test-only helper.
- Document the trap at its source: TmuxVersion::meets's doc comment
  now says explicitly that it does not read a next-X.Y build's own
  release number even at or below the required version, and points
  to has_behavior for a capability question above the floor -- so the
  wrong choice is harder to make by accident at the definition, not
  only after this fix.
- Switch all three production sites (internal/options.rs:303,
  tools/mod.rs:94, control.rs:477) from meets to has_behavior.
- Switch five illustrative doctests (pane/observe.rs, server.rs x3,
  pane.rs) that taught the same wrong pattern -- shipping the fix
  while leaving the docs teaching readers to reintroduce it would not
  have closed anything.
- Negative test for the most severe site:
  a_late_pane_scope_is_granted_to_whichever_tmux_actually_has_it
  (options.rs). Branches on whichever tmux is actually running rather
  than assuming one: ran against 3.2a (below the real floor, still
  refused, error names the capability), the default tmux 3.7d (has
  it, accepted), and the next-3.9 probe (has it via its own
  next-release number, accepted). Negative-test proof: reverted the
  fix to meets and reran against next-3.9 -- fails, panicking with
  UnsupportedCapability on a build that genuinely has the capability.
  Reverted the revert.
- Regenerated public-api.txt (unchanged: has_behavior's signature was
  already recorded identically under --all-features, which is how the
  generator always builds); just api-check, example-coverage-check,
  doc-blocks, docs (-D warnings) and parity-claims all clean.
Killing the pane a Pane::stream_output stream watched left next_chunk
waiting forever: tmux names no notification for a pane leaving, and the
stream only re-listed panes to repair its mute set. The re-listing now
also reports whether the watched pane is still there, and a completed
listing without it ends the stream, including when the pane was its
window's last and the death arrives as WindowClosed.

resume_pane sent only continue, so after mute_pane took a pane off on
tmux 3.7+ it left the pane muted, with tmux no longer reading its pty for
any client. unmute_pane and resume_pane now send on and continue in one
refresh-client; each is a no-op for a flag that is not set.

attach sends refresh-client -f new-layouts so LayoutChanged carries the
JSON Window::layout reports on 3.8+; the flag is ignored below 3.8.

PaneOutput::sender exposes the stream's own connection so a caller can
mute or resume its pane.
tmux 3.3 and 3.3a exit on a layout select-layout cannot parse,
destroying every session on the socket. select_layout("-o") passed
behind -- reached tmux as exactly that value; without the refusal the
new test's server is gone on 3.3a (ServerGone on "-o").

A saved value must now be a preset name, a classic layout (four hex
digits and a comma, as layout_parse reads it), or JSON. JSON below 3.8
is refused with UnsupportedCapability naming 3.8; anything else with
Error::UnrecognizedLayout, which retains no input.

LayoutSpec::Saved no longer claims every saved string restores the exact
arrangement: only JSON carries pane ids. Pane::left and Pane::top make
that checkable, and meets documents that it clamps a development build.
send_keys then wait_for_text for text the typed line contains could
match the shell's echo. A pattern on screen once the stream is attached
now returns outcome present_at_entry immediately rather than a flag on a
result that waited out its deadline. Attaching still comes first, so a
pattern landing between the attach and the capture is reported rather
than lost. A deadline whose buffer already holds a match reports
matched.

The echo test types without Enter and waits for the echo before calling
wait_for_text; without the early return it reports deadline.
…elog

NewSessionOptions::size has no effect on a detached session's first
window on tmux 3.2a, and a capture right after wait_for_text can miss the
redraw that satisfied it; both are tmux behaviour, now documented.
…us one

layout_set_lookup is a prefix match, so raw tmux applies "tile" and
"even-h" on every supported release; SavedLayout::classify only matched
the full preset name, so both were refused as "not a layout".
Prefix candidates are filtered to the presets the running release
actually has, so "main-h" is unique before 3.5 and ambiguous from it
(Error::AmbiguousLayout, naming the candidates), and an empty string
never reads as "ambiguous among all seven".

Factored the guard into Window::validate_saved_layout so a caller other
than Window::select_layout can reach the same refusal:
plan::ops::SelectLayout bypassed it entirely, and any
tmux-workspace file with a bad layout: value took down the whole
server building one session, not just that session. Plan::run now
validates every recorded SelectLayout before its first command, the
same way it already validates option scopes.
Plan::run_over_control_mode does not get this check: that connection
carries no server to probe a version against.

tmux 3.3 and 3.3a exit on a select-layout value they cannot parse,
taking every session on the socket with them; verified against the
pinned 3.3a binary that the fixed paths never dispatch such a value.
why: From tmux 3.7 a lowered history-limit trims every existing pane
it covers: on 3.7d a pane holding 1,477 lines kept 100 after a global
limit of 100, and 50 after a session limit of 50. That is deleting
tmux state, which is what teardown means, and teardown is withheld by
default on a socket the operator chose so that an agent cannot destroy
the operator's state unasked. set_history_limit sat in manage, so the
default surface on the operator's own server could discard scrollback
across every pane, while clearing one pane's scrollback needed
teardown.

what:
- Put set_history_limit's capability row in the teardown toolset
- Move it between the manage and teardown lists the permutation test
  pins; an operator-selected socket now offers 40 tools, not 41
- Regenerate TOOLS.md; update the README's toolset table and say why
  the tool is there
- Narrow the README's caller-identity sentence to the tools that run
  the check: pane input and the three kill tools. clear_pane_scrollback
  never did, and set_history_limit does not
- Record the move and LIBTMUX_TOOLS=set_history_limit, which restores
  the tool alone, in the changelog entry that declared its delete effect
- Say the _meta size figures were measured with 41 tools
why: wait_for_text and capture_since both attach an observer client
that only reads, capture_since for longer: its tail stays until evicted
or the server stops, where wait_for_text's ends with the wait. Yet
capture_since was observe-only, as 4e07853 ruled an attached observer
belongs to the inspect effect model, while wait_for_text declared a
change and so told clients it was not read-only. Two tools, one
mechanism, opposite hints.

what:
- Declare wait_for_text's row observe-only, as capture_since's is
- Say in its description that waiting owns an observer client until
  the wait ends, where it said the attach changed the session's
  attached-client state
- Pin every tool in schema.rs's hand-kept inspect list as read-only;
  it failed on wait_for_text before the row changed
- Regenerate TOOLS.md; say the rule in the README and the changelog
`proc-macro-crate` reports `FoundCrate::Itself` whenever the package
being compiled is `libtmux` and `CARGO_TARGET_TMPDIR` is unset: the
library, its doctests and its examples. The derive emitted `crate` for
it, which is `libtmux` only in the library; a doctest failed with
"cannot find `query` in `crate`", so every one carried
`#[filterable(crate = "libtmux")]`. Integration tests never needed it --
proc-macro-crate 3.5 answers `Name` there -- and carried it anyway.

The derive now emits `::libtmux`, and the library aliases itself with
`extern crate self as libtmux` so the same path resolves inside it. The
overrides go from the doctests, design.md and the integration tests.

Proof: a unit test derives inside the library with no override. With
the old `quote!(crate)` restored, `cargo test --doc Filterable` fails
at `src/lib.rs - Filterable` with E0433.
Three changes to what the derive's UI tests prove.

The README listed `#[filterable(many = …)]` and `one = …`; the parser
refuses any value ("is a bare flag"). The table now shows the bare
flags, and `relation_flag_value` pins the refusal.

Three reachable error sites had no compile-fail case: an option path
with more than one segment, on a container and on a field, and an empty
`#[filterable()]`. Each has a fixture now. With each guard removed,
trybuild reports "Expected test case to fail to compile, but it
succeeded" for `container_option_path`, `field_option_path` and
`empty_helper_attribute`. Two sites could not fire and are gone: a
named field without an identifier, and a missing target after
`finish()` had already returned the error that explains it -- the
target is now held as the `Result` whose error is that explanation.
The missing-package error stays, with a comment: trybuild builds each
case with this crate's dev-dependencies, which include `libtmux`.

The compile-fail `.stderr` files quote rustc's own diagnostics, which
reword between releases, and `just msrv` ran them on 1.85.0 too. They
now run only when `rustc -V` matches `rust-toolchain.toml`; on 1.85.0
the test prints "compile-fail cases skipped" and checks the passing
cases. `just macros-ui-bless` rewrites them after a toolchain bump.
The `-s` listing parser A1.5 added reads names and values the server
inherited or a client set, and had no fuzz target, which CONTRIBUTING
asks of every parser of tmux output. `environment_listing` feeds it
arbitrary bytes, then renders NUL-separated variables the way
`cmd_show_environment_print` does and asserts they parse back to
themselves. Seeds are hand-written: the unit tests' listings and a
round-trip input. The weekly CI matrix runs it.

It failed within seconds: an exported variable named `unset t X;\nY`
-- tmux accepts any name without `=` -- made the whole listing fail
with `Err((2, 58))`, because `unset NAME;` was tried first and took
the start of the name for a removal of `t X`. An exported entry is now
tried first: its name is printed twice, so the second copy confirms
where the first ended.

A removed name holding `;` and a newline stays ambiguous: `unset A;\n
unset B;` is one removal or two, byte for byte. The oracle leaves that
case out and `Session::environment_all` says so.

Proof: `an_exported_name_shaped_like_a_removal_stays_one_variable`
fails with "listing parses: (1, 9)" under the old order. After the
fix, 563,999 runs in 61 s, no crash, cov 672.
`buffer_names` returned `Vec<String>` from an unframed `list-buffers
-F '#{buffer_name}'` split on newlines, in a crate whose rule is that
text from tmux is bytes. Whoever makes a buffer names it, and tmux
before 3.7 stores any bytes (3.6a's paste.c has no `clean_name`): a name
holding a newline listed as two names, and one that was not UTF-8 came
back altered and could not be passed back.

It now lists `#{q:buffer_name}=` through `split_quoted_rows` and returns
`Vec<TmuxText>`. That splitter accepted only raw `#{q:}` escapes, and
`list-buffers` has no version floor, so 3.4 and 3.5 -- the `vis`
releases -- are in range: the plan codec's escape decoder is now shared
and takes the dialect, and `list-keys` passes `RawQ` because its `-F`
arrived in 3.7. `buffer` and `delete_buffer` take `impl AsRef<[u8]>`,
the shape name lookups already use, so a listed name passes back; a
`&str` still does, and no caller in the workspace changed.

`option_names` stays `Vec<String>`, with the reason in its rustdoc.
tmux's own names are ASCII and every option method takes `&str`, but a
user option's name is caller-chosen: `@a b`, `@c\xffd` and `@n\nl` are
all accepted, and `show-options` prints names raw with no `-F` on any
release through 3.7b, so `@a b` lists as `@a`.

Proof: `a_listed_buffer_name_passes_back_unchanged` lists `a=b c`, and
`two\nlines` where tmux accepts it (3.2a, 3.5 and 3.6 run; 3.7d
refuses the name). With the template unquoted it fails with
`DecodeListing { .. UnexpectedRowTerminator .. }`.
`quoted_rows_outside_the_catalog_decode_on_every_dialect` fails with
`InvalidEscape` for `Vis` when the dialect is not threaded through.
The `processes` column was assigned: `result.dispatches()` for a
subprocess row and `1` for control mode. The README publishes that
column and `just example-tables` gates it, so the gate compared a
constant with itself -- a dispatch that spawned two processes, or a
control-mode run that fell back to subprocesses, printed the same
table.

Each row now runs its fixture with `tmux_executable` pointed at a
wrapper that appends a line to a log and execs the real tmux
(`LIBTMUX_TEST_TMUX`, else `PATH`), and reports the lines its run
added: from just before `plan.run`, or just before the attach for the
control-mode rows so the connection's own process counts. The numbers
did not move -- 6, 6, 3, 3, 1 -- and are now measured.

A new `control-mode/routed` row runs the same plan with `Plan::run` on a
`Server::over_control_mode` handle: six dispatches, one process, the
route the whole typed API can take. `waits` and tmux-mcp still have no
such count (the rest of A4.2).

Proof: with the streaming row sent through `plan.run(server, ..)`,
`just example-tables` fails with "control-mode/streaming ... 6 7" where
the block shows "6 1", and "processes ranged 1..7". Three clean runs
matched.
tmuxp's `sleep_before`/`sleep_after` had nowhere to go: `Plan::run`
dispatches start to finish, and a plan cannot be split and resumed
because a later half cannot address what the first half created (N10).

tmux already has the pause. `run-shell -d SECONDS` with no command
waits and runs nothing, from 3.2a on (`bd:Ct:`), and returns
CMD_RETURN_WAIT, so it holds the client. Measured on 3.2a, 3.3a, 3.4,
3.5, 3.6, 3.7, 3.7c and 3.8-rc: 0.3 s alone, and `display a ;
run-shell -d 0.3 ; display b` printed both lines after 0.3 s -- the
wait holds inside a chain. So `Pause` is an ordinary operation:
chainable, read-only, and a folded plan pauses where an unfolded one
does. On the wire it is `{"Pause": {"seconds": 1.5}}`, tmuxp's unit;
a negative or oversized number fails to deserialize.

A control-mode connection cannot carry it. Over `tmux -C`, the pause's
`%end` arrived at 1 ms and the next command's at 600 ms: the same
early guard as `wait-for` (N19). Both control-mode routes refuse a plan
holding a pause with `BlockingCommand` before sending anything.

Proof: `a_pause_holds_the_plan_on_every_planner` fails with
"Sequential returned after 7.492133ms" when the delay flag is not
rendered; `a_pause_is_refused_over_control_mode` fails when the
refusal returns `Ok`.
`sleep_before` and `sleep_after` were read, written back by `to_yaml`,
and ignored, because a plan could not pause (N10). With `plan::Pause`
they lower to pauses around the command they belong to, and carry
forward to the pane's later commands as tmuxp's builder carries them
(`sleep_before = cmd.get("sleep_before", sleep_before)` in
`builder/classic.py`), pane-level values first. The pause happens in
tmux, so the `Marked` fold `build` uses waits too.

Decision on N10: add the step rather than decline. The pause needs no
split plan and no new transport -- tmux's own `run-shell -d` is one
command -- and a caller of `WorkspaceBuilder::plan` sees each wait as a
step.

Proof: `sleeps_pause_the_build_between_commands` pins the plan's shape
and a build of at least 400 ms. With the `sleep_before` pause removed
it fails with `left: [.., "new-window", "send-keys", "send-keys", ..]`.
@tony
tony force-pushed the api-improvements branch 2 times, most recently from 144431d to 34952d7 Compare September 19, 2026 14:09
why: `capture-pane -J` joins a wrapped line back into one row and is
documented to preserve trailing spaces, right for what a program
printed. On tmux 3.2a the join also carries the pane's unwritten
cells past the printed text: joining a 300-byte line's wrap in an
80-column pane returns 320 bytes, the last 20 of them cells nothing
wrote. Every later release already drops them unasked, confirmed by
building 3.3, 3.3a, 3.4, 3.5 and 3.5a and running the same join
against each -- well before `capture-pane -T` gives 3.4 and newer a
flag for it. `Pane::wait_until`, added yesterday, compares a joined
line for equality rather than `wait_for_text`'s substring, so it read
the pane's width instead of what ran in it and timed out on every
3.2a run in `just compat`.

what:
- Add `CaptureOptions::needs_wrap_trim` and `trim_wrap_padding`;
  `Pane::capture_with` applies it when a caller asked to join wrapped
  lines without asking to keep trailing spaces, and `Pane::wait_until`
  always does, since its own capture is exactly that combination
- Record the fix in the changelog
why: `just check` installs whatever tmux apt currently carries for
ubuntu-latest, now 3.4, inside `Server::run_shell`'s 3.3-3.4 range
where it refuses outright rather than trust output tmux is known to
drop. `a_caller_can_bound_one_command_with_tokio_timeout` used
`run_shell` only to hold a process open past its deadline, so the
refusal returned instantly, the outer `tokio::time::timeout` saw a
finished future well under its 400ms bound, and the test failed on
the assertion that it should not have.

what:
- Dispatch `run-shell sleep 3` through `Server::cmd` instead, which
  carries no output-capability gate and still blocks for the same
  process
why: The cancellation example bounds `Server::run_shell("sleep 3")`
with `tokio::time::timeout` and asserts the timeout wins. `just
check` installs whatever tmux apt currently carries for
ubuntu-latest, now 3.4, inside `run_shell`'s 3.3-3.4 range where it
refuses outright rather than trust output tmux is known to drop.
The refusal returned instantly, so the outer timeout saw a finished
future well under its 400ms bound and the doctest's assertion
failed -- the same cause the fixed
`a_caller_can_bound_one_command_with_tokio_timeout` test already
worked around, missed here because it lives in a README example
rather than a test.

what:
- Dispatch `run-shell sleep 3` through `Server::cmd` instead, which
  carries no output-capability gate and still blocks for the same
  process; the example never reads the output either way
why: The API-conventions rule still told a contributor to add an
`_or_empty` twin for every listing, the exact method design.md
records the crate removing because nothing called one and each was a
way to discard a failure reason by accident.

what:
- Replace the pairing rule with the propagate-and-unwrap_or_default
  convention the crate now follows
why: WRITING.md requires examples to propagate with ?, not expect,
since clippy denies expect_used outside tests and an example teaches
whatever it shows.

what:
- Pause's plan preview example returns Box<dyn Error> and uses
  ok_or(...)? instead of Option::expect
- KeyBinding's typed_key_bindings example does the same
why: wait_for_text confirmed a match by checking only that a
pattern sat above the cursor row. A line send_keys types and
submits scrolls above that row the instant tmux processes Enter,
so its own echo satisfied that check before the command it named
produced anything -- a match on typed text, not on output.

what:
- Record each pane's submitted lines (crates/tmux-mcp/src/echo.rs),
  keyed by tmux server generation and pane id, and mask them out of
  the confirmed screen by whole-word-bounded exact-text removal,
  aged out after 10s and capped per pane and in total
- Model send_keys' key names (submit, kill-line, erase, DC) to
  track what a dispatch adds to a pane's still-open line; a key it
  cannot model (arrows, Home, Tab) stops tracking that line rather
  than mask stale text, so it can then read as a match but never
  hides real output
- Record a submitted line before dispatch, since the terminal's
  echo can reach a waiting client first, but do not publish the
  pane's new current line until tmux confirms the dispatch: doing
  so eagerly raced a command whose own output has no trailing
  newline, where the next prompt lands on the same row the position
  rule alone would then exclude from matching forever
- Never roll a submitted line back on a failed dispatch: masking a
  line that never reaches the pane removes nothing real
- State the contract in send_keys' and wait_for_text's descriptions
- Add scenario tests for six echo-contract cases plus a resize
  control, run against the unfixed code first and shown to fail for
  the stated reason

paste_text and run_shell_command also type into a pane and share
this gap; left alone here. A submitted line that wraps across
terminal rows is not masked either, matching the position rule's
own existing limitation for wrapped output.
why: wait_for_text discounted a submitted line typed with
send_keys, but paste_text pastes a line just as literally and
run_shell_command types a short loader line to run its staged
frame; either one's own echo could still satisfy a later wait.

what:
- Route paste_text's dispatch through the same record: apply
  before the tmux paste-buffer call, commit or abandon on its
  result
- Route run_shell_command's dispatch the same way, recording the
  actual typed loader line (PreparedRun::typed_line), not the
  requested command text, since that is what the pane echoes; an
  unproven dispatch outcome is kept rather than abandoned, since a
  false match on a real echo outranks over-discounting
- Add real-tmux tests for both gaps, shown to fail against the
  prior commit for the stated reason
- State the widened contract in wait_for_text's and paste_text's
  descriptions, including the still-open wrapped-line gap
why: cancelling_a_line_send_cannot_leave_enter_undispatched used
fixed 1s and 5s budgets for two real-tmux channel waits, unlike
every other wait_for_channel call in this suite. CI's own run
timed out on the second wait even though tmux answered it, since
nothing here stretched for a loaded machine.

what:
- Scale both waits with libtmux::test::scaled, matching the
  convention crates/libtmux/tests/server_command.rs already uses
  for every wait_for_channel call there
why: Invalid select-layout values can terminate older tmux daemons.
The control-mode runner must reject them before any plan effects.

what:
- Reuse the window layout validator with the connected daemon version
- Check layouts before dispatching control-mode plan operations
- Cover daemon survival, refusal before effects, and valid presets
why: tmux parsed caller text as its own flags in commands and plans.

what:
- Guard command, format, key and access operands
- Exercise affected families against isolated tmux daemons
@tony tony changed the title API(fix): Preserve stream and scope failures Send caller text literally, surface every failure Sep 20, 2026
why: A closed command queue hid the actor's frame-limit failure while
creating a control connection or pane stream.

what:
- Close and drain owned events before returning setup failures
- Keep command refusals and partial-effect context during cleanup
- Cover both constructors, cancellation, and real tmux output
why: Readers need upgrade guidance for the final behavior.

what:
- Condense unreleased notes around observable changes
- Preserve API migration guidance and released notes
@tony
tony merged commit bc80bd4 into master Sep 20, 2026
9 checks passed
@tony
tony deleted the api-improvements branch September 20, 2026 01:15
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.

1 participant