Skip to content

Typed operations and engines, sharing the seam's values - #742

Open
tony wants to merge 207 commits into
engine-seam-minimalfrom
engine-ops-on-seam
Open

Typed operations and engines, sharing the seam's values#742
tony wants to merge 207 commits into
engine-seam-minimalfrom
engine-ops-on-seam

Conversation

@tony

@tony tony commented Aug 15, 2026

Copy link
Copy Markdown
Member

Stacked on #739. Base is engine-seam-minimal, not master, so the diff
here is only the experimental work. Review #739 first; this becomes a
master diff once #739 lands.

Summary

Builds the typed operations, engines, workspace, and MCP layers on top of the
command execution seam from #739, and makes the two share one set of values.

  • Rebased onto the seam. The 205 experimental commits touch no line of
    common.py, server.py, or neo.py, so replaying them produced no source
    conflict — only shared bookkeeping (uv.lock, CHANGES, pyproject.toml).
  • Converged the duplicate value types. libtmux/experimental/engines/
    carried its own CommandRequest, CommandResult and ServerConnection;
    those now come from libtmux.engines, so an experimental engine and the
    object API exchange the same values.

Why the convergence matters

Before it, an experimental engine satisfied isinstance(engine, TmuxEngine)
and then failed at runtime the moment it was injected into a Server: the two
CommandResult types were structurally similar but not interchangeable. Now
Server(engine=ControlModeEngine(...)) works, which is the point of the seam.

Changes by area

src/libtmux/experimental/engines/

  • base.py: imports the request, result, separator and engine protocol from
    libtmux.engines rather than redefining them. What stays is what only the
    experimental transports need — the argv encoders that split tmux's
    client-global options from its command argv, control-mode line rendering and
    unescaping, EngineSpec, and the async protocol.
  • connection.py: reduced to libtmux.engines.connection.ServerConnection.

Every other module keeps importing from libtmux.experimental.engines, so the
re-export redirects roughly 100 files without touching one of them.

Verification

  • Full suite green: 3263 passed. The one failure, test_new_session_shell_env,
    reproduces on master and is unrelated.
  • mypy src tests clean across 301 files; ruff check and ruff format clean.
  • The object API driven end to end through an experimental engine — session,
    window, pane, send_keys, capture_pane, has_session, kill — all pass.

Known follow-up, deliberately excluded

Core drops a trailing ; on an argument: cmd("display-message", "-p", "a;")
returns ['a'] through the default engine and ['a;'] through the experimental
one, because tmux parses the trailing ; as a command boundary. The
experimental encode_direct_argv already escapes it. Porting that into core
changes argv for every argument ending in ;, so it cannot ride inside a
no-op change and needs its own PR.

tony added 30 commits August 15, 2026 07:29
why: Operationalizes the typed-operations/engines architecture
(issues 688, 689) with the pure substrate that was absent from every
prototype branch: an inert, statically-typed operation value that
renders tmux commands, carries its result type, and serializes without
a live tmux server. Engines stay transport-agnostic over it. None of
this touches or changes existing public APIs.

what:
- Add libtmux.experimental.{ops,engines} packages (experimental, not
  under the versioning policy)
- ops: frozen Operation[ResultT] with class-level metadata as the
  single source of truth; pure render() with declarative version gating
  (LooseVersion); build_result() adapting raw output to typed results
- ops: typed Result base + raise_for_status() (CPython/requests
  precedent), SplitWindowResult/CapturePaneResult payloads
- ops: closed Target sum (PaneId/WindowId/SessionId/ClientName/NameRef/
  IndexRef/Special/SlotRef) with fail-closed validation
- ops: fail-closed OperationRegistry keyed by kind, with OpSpec views
  and predicate listing; stdlib dict serialization with round-trips
- ops: four seed operations (split-window, capture-pane, send-keys,
  select-layout) registered via @register
- engines: TmuxEngine/AsyncTmuxEngine protocols, CommandRequest/
  CommandResult, EngineSpec; run()/arun() execute bridge sharing one
  render/build path (sync vs await is the only divergence)
- tests: 111 pure, fixture-parametrizable unit tests + doctests, all
  runnable without a tmux server
why: Proves the operation/result contract is transport-agnostic -- the
same typed result whether produced by a real tmux subprocess or an
in-memory simulator -- and provides the offline engine that lets ops
doctests and tests run without a tmux server (issue 689 phases 2-3).

what:
- engines.subprocess: classic SubprocessEngine mirroring tmux_cmd
  (has-session stderr fold, backslashreplace, trailing-blank strip;
  tmux failure returned as data, only missing binary raises), with
  for_server() deriving -L/-S/-f/-2 flags from a live Server
- engines.concrete: deterministic in-memory engine (fabricated pane/
  window/session ids, canned capture lines) for tests and docs
- engines.registry: name-keyed engine registry (register/create/
  available), seeded with subprocess + concrete
- tests/experimental/contract: engine-agnostic operation contract run
  offline via concrete, plus classic-vs-concrete parity against a real
  tmux server (same result type + argv, payload may differ)
why: Completes the sync/async-symmetric execution story plus the
deferred-execution and documentation mechanisms from issue 689
(phase 5 + docs), still without touching any existing API.

what:
- engines.asyncio: real AsyncSubprocessEngine on
  create_subprocess_exec (terminates the child on cancellation; not a
  thread wrapper), mirroring the classic engine's output handling so it
  returns the same typed result
- ops.plan: LazyPlan records operations without touching tmux and
  resolves SlotRef forward refs at execute time via a sans-I/O
  generator; sync execute() and async aexecute() share one resolution
  core (run vs await arun is the only divergence); whole-plan
  serialization round-trips
- ops.catalog: registry-driven CatalogEntry list (scope, version
  gates, effects, safety, result type, summary) -- the single source a
  docs domain renders, so runtime and docs cannot drift
- tests: lazy resolution sync+async, plan serialization, catalog
  coverage, async-vs-sync classic parity against a real tmux server
why: Proves control mode is just another engine returning the same
typed result (issue 689 phase 4) -- an operation run over a persistent
tmux -C connection is indistinguishable, at the result level, from one
run via fork-per-call subprocess.

what:
- engines.control_mode: ControlModeEngine over one persistent tmux -C
  connection; run_batch pipelines commands and parses each command's
  %begin/%end/%error block into a CommandResult; selectors-based
  nonblocking reads with timeout; startup-ACK discard; lifecycle via
  close()/context manager (lock-guarded teardown)
- engines.control_mode: I/O-free ControlModeParser, unit-testable
  without tmux, adapted from the chain runner + protocol-engines parser
- register control_mode in the engine registry and export it
- tests: pure parser tests + real-tmux contract (split creates a real
  pane, batched commands, control-vs-concrete parity)
why: Demonstrates the "mode lives in the type" model from issue 689 --
EagerPane.split() returns a live EagerPane while LazyPane.split() returns
a deferred LazyPane, each a single statically-known return type, both
backed by the same SplitWindow operation. One Pane class with a
runtime-bound engine could not type these return values distinctly.

what:
- facade.pane.EagerPane: executes immediately, returns live handles
  (split -> EagerPane), typed results for capture/send_keys
- facade.pane.LazyPane: records into a LazyPlan, returns deferred handles
  (split -> LazyPane bound to the new pane's SlotRef), chainable
- seed of the wider Server/Session/Window/Pane/Client x mode matrix
- tests: eager live handles, lazy deferral + forward-ref resolution,
  and same-operation-backs-both-facades parity
why: Closes the two async gaps from issue 689: control mode and concrete
had no async sibling. The async control engine is the one async engine
that earns its place -- it adds an event stream subprocess cannot -- and
prior libtmux/mux control-mode work (surfaced across agent histories via
agentgrep, plus the asyncio-2 branches) shaped its correlation design.

what:
- engines.async_control_mode: AsyncControlModeEngine over a persistent
  tmux -C (create_subprocess_exec + one reader task). FIFO future
  correlation with skip-when-empty so unsolicited %begin blocks (hook-
  triggered commands and the startup ACK) never desync results; the
  startup ACK is consumed synchronously in start() to close the
  correlation race our whole-block parser would otherwise have. DEAD
  state fails pending commands on reader EOF/error. Cancellation via
  asyncio.wait_for (3.10 floor: no asyncio.timeout/TaskGroup). Bounded
  subscribe() notification stream with drop-counting. for_server() helper
- engines.control_mode: ControlModeParser now surfaces bare %-notification
  lines via notifications() (additive; the sync engine ignores them)
- engines.concrete: AsyncConcreteEngine sibling over shared simulation;
  removes the async test shim
- ControlNotification typed event value
- tests: parser notification/drain; async control vs real tmux (split,
  pipelined batch, concrete parity, live event stream, lifecycle)
why: Many tmux commands print nothing (rename-window, kill-pane,
select-window, ...). tmux returns CMD_RETURN_NORMAL on success or calls
cmdq_error on failure, framed in control mode as %end vs %error (see
tmux cmd-queue.c) -- they never cmdq_print. They still need a typed
result that records success/failure without inventing a payload.

what:
- results.AckResult: a typed acknowledgement (no payload) whose
  raise_for_status() still surfaces the error path; documents the tmux
  success/error mapping
- retarget send-keys and select-layout to AckResult (both print nothing)
- add no-output ops: rename-window (mutating), kill-window and kill-pane
  (destructive) -- exercising AckResult across scopes and safety tiers
- export AckResult and the new ops; refresh the catalog doctest
- tests: render + AckResult success/failure across the no-output ops and
  destructive safety metadata; update classic/control parity assertions
why: A neo-like read model is useful, but neo.Obj is one flat ~200-field
class fused to the query/dispatch pipeline. The experimental namespace
lets us try a decoupled, immutable, serializable snapshot layer without
any risk to the shipped ORM APIs.

what:
- libtmux.experimental.models: frozen PaneSnapshot / WindowSnapshot /
  SessionSnapshot / ServerSnapshot, each a typed core plus the full raw
  tmux-format tail in .fields (nothing tmux reported is lost)
- from_format() builds one node from a format mapping;
  ServerSnapshot.from_pane_rows() groups a flat "list-panes -a -F" row
  set into an ordered session/window/pane tree
- to_dict()/from_dict() round-trip the whole tree as plain data, with no
  live objects
- pure tests (no tmux): value coercion, tree grouping/order, round-trip
why: The list/show read commands overlap neo's reader. Rather than
touch the ORM, add a parallel typed read surface in experimental.ops
that yields immutable models snapshots. The render version must thread
into result parsing first, because the -F template is version-gated and
the parser must split against the same fields it was rendered with.

what:
- operation: thread `version` through build_result -> _make_result so
  payload parsing matches the version-gated render (backward compatible;
  existing overrides accept and ignore it); execute.run/arun pass it
- ops._read: re-export neo.get_output_format / parse_output and
  formats.FORMAT_SEPARATOR as the single source of truth (no copies)
- list-panes / list-windows / list-sessions ops (readonly,
  chainable=False) render the same -F template neo builds and parse rows
  into models snapshots
- ListPanesResult/.../ store JSON-friendly rows and derive typed views
  (.panes/.server/.windows/.sessions) via properties, so results
  serialize and round-trip with no special-casing
- tests: -F parity with neo, snapshot-tree build, serialize round-trip,
  and live list-panes/sessions/windows against a real tmux server
why: The operation catalog is registry-derived data, so rendering it in
docs keeps the operation reference from drifting from the code -- and the
docs gate then exercises catalog() on every build.

what:
- docs/_ext/tmuxop.py: an in-repo Sphinx directive `tmuxop-catalog` that
  walks libtmux.experimental.ops.catalog() and emits a table, with
  :scope:/:safety:/:primitive-only: filters; warns (not raises) on empty
- conf.py: add docs/_ext to sys.path and 'tmuxop' to extra_extensions
- docs/experimental.md: an experimental ops/engines overview embedding
  the catalog (full + readonly + destructive views), in the index toctree
why: The sync control engine skipped tmux's startup ACK with a fragile
one-shot flags==0 heuristic and had no defense against hook-emitted
%begin/%end blocks, so a stray block could desync request->result
alignment. The async engine already handles this; backport the approach.

what:
- consume the startup ACK synchronously at connect (_consume_startup),
  dropping the one-shot _startup_ack_pending heuristic, so the startup
  block can never be conflated with a command's result block
- drain buffered unsolicited blocks before each batch
  (_drain_unsolicited), so a hook-triggered command's block left over
  from a prior call is not mis-attributed to the next command
- drain notifications during reads to keep the parser buffer bounded
- regression test: many sequential commands stay aligned (first result
  is real; each call drains before reading its own block)

A hook firing mid-pipelined-batch still needs per-command number
correlation to disambiguate; single-command run() is robust.
why: The chainable-commands prototype folds independent commands into one
"tmux a ; b" dispatch. Our typed-op model is a better host for it -- the
Operation already carries a `chainable` classvar and the result Status
already reserves `skipped` for exactly the chain-drop case. So yes, lazy
mode can adopt the prototype's chainability.

what:
- mark output/creation ops non-chainable (capture-pane, split-window;
  list-* already were) so a fold never drops captured data or an id
- ops._chain: render_chain (join chainable ops with standalone ';',
  escaping a trailing-';' arg), ensure_chainable (fail closed), and
  attribute -- splitting one merged ';'-chain result into a typed result
  per op (success -> all complete; failure -> first failed, rest skipped,
  matching tmux cmd-queue.c cmdq_remove_group); plus OpChain with >>/then
- Operation.__rshift__/then compose into an OpChain; result_with_status()
  builds a result with an explicit status (skipped/failed attribution)
- LazyPlan.execute/aexecute gain fold=False (opt-in): maximal runs of
  chainable, resolved ops dispatch once via engine.run; the sans-I/O
  _drive yields _Single or _Chain so sync and async share the core;
  add_chain() records an OpChain
- tests: >> composition, render_chain, fold=one dispatch, fold-off=N
  dispatches, failure attribution, creators stay unfolded, add_chain
why: Extend the mode-in-the-type facades beyond the pane seed so a typed
return value distinguishes eager/lazy/async across scopes -- and add the
few creation ops the cross-scope navigation needs.

what:
- ops: NewWindow / NewSession (CreateResult, capture the new id),
  KillSession, RenameSession; generalize binding capture via
  Result.created_id (base None; SplitWindowResult -> new_pane_id;
  CreateResult -> new_id) so lazy plans bind window/session creations too
- facade: eager Server -> Session -> Window -> Pane navigation
  (EagerServer/EagerSession/EagerWindow); LazyWindow (records into a
  plan); AsyncPane / AsyncWindow (await arun) -- all over the same ops.
  Control mode stays an engine choice, not a separate facade family
- EagerServer.for_server() binds the classic engine to a live Server
- tests: offline navigation across scopes/modes (concrete engine), and a
  live eager Server -> Session -> Window -> Pane build against real tmux
  with cleanup
why: The native binary peer-protocol engine is the strongest proof the
operation/result contract is transport-agnostic -- the same typed
CommandResult whether produced by a subprocess, tmux -C, or by speaking
tmux's imsg protocol directly. Research confirmed it is pure-stdlib and
CI-verifiable; the prototype it is ported from only ever tested against a
fake socketpair server, never real tmux.

what:
- port engines/imsg/{types,v8,base}.py from libtmux-protocol-engines:
  ImsgEngine over AF_UNIX + sendmsg/recvmsg + SCM_RIGHTS fd-passing, and
  ProtocolV8Codec (=IIII header, IMSG_FD_MARK high bit of len,
  peerid=PROTOCOL_VERSION 8, IDENTIFY -> COMMAND -> WRITE_* -> EXIT
  handshake); posix_spawn local fallback for attach / start-server /
  no-server-running
- adapt to the experimental tuple CommandResult (drop the process field);
  add imsg.exc (ImsgError / ImsgProtocolError / UnsupportedProtocolVersion)
  and select the v8 codec directly; keep the version-mismatch retry
- register as the opt-in "imsg" engine; import-safe everywhere (AF_UNIX
  is only touched at runtime; tests skip without it)
- tests: v8 codec round-trip + MSG_COMMAND framing (no tmux), plus the
  live parity test the prototype lacked -- ImsgEngine vs SubprocessEngine
  return identical stdout/returncode for read-only commands against a
  real tmux server (runs across the CI tmux matrix)
why: Finish the mode-in-the-type matrix so every tmux scope has
eager/lazy/async facades, and add the client-scoped ops a Client facade
needs. The matrix is now 5 scopes x 3 modes, all over the shared spine.

what:
- ops: detach-client, refresh-client, switch-client (AckResult, client
  scope; switch-client renders -c/-t rather than the generic target)
- facade: LazyServer/AsyncServer, LazySession/AsyncSession, and the new
  client scope (EagerClient/LazyClient/AsyncClient); AsyncServer.for_server
  binds the async engine to a live Server
- tests: a lazy full Server->Session->Window->pane plan, async navigation,
  and eager/lazy/async client methods
why: The pre-commit gate now runs `uv run ty check`, so ty must be a
configured dev tool. Brings the ty setup from the add-ty-type-checker
branch and makes the experimental tree ty-clean.

what:
- add `ty` to the dev dependency group (uv.lock updated)
- add [tool.ty] (environment py3.10, src=src/tests) with the documented
  rule ignores for known ty false positives, ported verbatim
- fixes ty surfaced in experimental: Target is now a real union (ty
  rejects an implicit two-string type alias); OperationRegistry.list ->
  select so the `-> list[OpSpec]` return annotation is not shadowed by
  the method name
why: Make lazy-plan dispatch strategy pluggable and A/B-testable, and add
the chainable-commands {marked} lone-pane single-dispatch optimization
the plain ;-fold lacked.

what:
- ops.planner: Planner Protocol + PlanStep; SequentialPlanner (one
  dispatch per op), FoldingPlanner (;-fold maximal chainable runs),
  MarkedPlanner (fold a pane creation + the chainable ops decorating its
  slot into one "split -P -F ; select-pane -m ; ... -t {marked} ;
  select-pane -M" dispatch)
- _chain: render_marked / attribute_marked
- LazyPlan.execute/aexecute take planner= (default SequentialPlanner),
  replacing fold=bool; _drive consumes the planner's PlanStep units and
  stays sans-I/O so sync and async share it
- tests (NamedTuple + test_id): planner dispatch counts 3/2/1 with an
  identical PlanResult, marked single-dispatch rendering + fallback, and
  a live {marked} fold against a real tmux server
why: The read seam only covered the list-* family, leaving common
queries (existence, format evaluation, option dumps, attached
clients) outside the typed operation/result model.

what:
- Add has-session, display-message, show-options, list-clients ops,
  each rendering inert argv and parsing tmux output into a typed result
- Add HasSessionResult.exists, DisplayMessageResult.text,
  ShowOptionsResult.options, ListClientsResult.clients result types
- Add ClientSnapshot model (a leaf view, not part of the tree)
- has-session maps rc != 0 to exists=False (a valid answer, not failure)
- Wire ops/results/snapshot exports; update enumerating doctests/tests
- Add test_read_breadth.py (NamedTuple + test_id render/parse/round-trip
  cases plus live tmux coverage)
why: The operation surface lacked the pane verbs the ORM relies on
(select/resize/swap/break/join/move/respawn/pipe/clear-history),
blocking pane-level parity for engine-driven callers.

what:
- Add select-pane, last-pane, resize-pane, respawn-pane, pipe-pane,
  clear-history (single-target) ops
- Add swap-pane, join-pane, move-pane (dual-target) and break-pane
  (creates a window, captures #{window_id} into CreateResult)
- Add src_target field + src_args() helper on Operation for the -s
  source of dual-target commands; serialize handles src_target like
  target
- Wire ops/exports; extend the catalog kind-enumeration doctest
- Add test_pane_ops.py (NamedTuple + test_id render/round-trip cases
  plus live tmux coverage)
why: Window-level parity was missing the verbs the ORM uses to
navigate and rearrange windows, so engine-driven callers could not
select, move, or relink windows.

what:
- Add select-window, last-window, next-window, previous-window,
  resize-window, rotate-window, respawn-window, unlink-window
- Add swap-window, move-window, link-window (dual-target, via -s
  src_target)
- Wire ops/exports; extend the catalog kind-enumeration doctest
- Add test_window_ops.py (NamedTuple + test_id render/round-trip
  cases plus live navigation/swap/move/unlink coverage)
why: Engine-driven callers had no typed way to drive the tmux server
lifecycle or write options, environment, and hooks -- the write side
of the options surface that show-options already read.

what:
- Add start-server, kill-server, run-shell, source-file,
  suspend-client lifecycle ops
- Add set-option, set-window-option (the write counterpart to
  show-options), set-environment, set-hook
- Wire ops/exports; extend the catalog kind-enumeration doctest
- Add test_lifecycle_ops.py (NamedTuple + test_id render/round-trip
  cases plus live option/env/hook/run-shell/source-file coverage)
why: The paste-buffer family the ORM uses for clipboard interchange
had no typed operations, leaving buffer set/load/save/paste outside
the engine-driven surface.

what:
- Add set-buffer, delete-buffer, load-buffer, save-buffer,
  paste-buffer ops
- Add show-buffer read op + ShowBufferResult.text (buffer contents)
- Wire ops/results/exports; extend the catalog kind-enumeration and
  registry readonly doctests
- Add test_buffer_ops.py (NamedTuple + test_id render/round-trip
  cases plus a live set/show/save/delete and load/paste round-trip)
why: The experimental page described operations and the catalog but
not how to run them or compose multi-step plans, leaving the engine
choice and planner A/B story undocumented.

what:
- Add "Running an operation" (run/arun, raise_for_status policy)
- Add "Choosing an engine" (engine table, create_engine, async peers)
- Add "Lazy plans and planners" (LazyPlan slot refs, >> chaining,
  Sequential/Folding/Marked planners)
- All examples are executable doctests via the in-memory ConcreteEngine
why: Record the experimental operations/engines layer for the
upcoming release so the unreleased section tracks what landed.

what:
- Add a "What's new" deliverable under the unreleased 0.59.x section
  for the experimental operations and engines layer (#690)
- Defer the release lead paragraph until the version is cut
why: An adversarial review of the new ops against tmux's command
grammar found two defects: move-window could not request its
kill-on-collision behavior, and paste-buffer's -r flag was
documented as a space replacement it never performs.

what:
- MoveWindow: add kill (-k) field; tmux move-window's option string
  is "abdkrs:t:" and -k replaces any window already at the
  destination index
- PasteBuffer: rename no_format to no_replace and fix the docstring;
  -r keeps linefeeds instead of converting them to the default
  carriage-return separator (it has nothing to do with spaces)
- Add render cases for move-window -k/-r and paste-buffer -r
why: A LazyPlan resolved a forward SlotRef only for an op's target, so
a dual-target op (swap/join/move/break/link) whose src_target came
from an earlier plan.add(...) reached render() with the slot
unresolved and raised TypeError. serialize already handled both
fields; resolution did not.

what:
- Factor _resolve_slot() and resolve both target and src_target in
  _resolve()
- Add parametrized test_plan_resolves_src_target covering swap/join/
  move/break panes
why: In a {marked} fold, when the create step failed (no captured id)
attribute_marked still ran the chain attributor, which blamed the
first decorate as "failed" -- but tmux stopped at the create, so no
decorate ran. The first decorate was wrongly reported as the failure.

what:
- When new_id is None, mark every decorate "skipped" and return the
  create's failure (the failed-decorate path is unchanged: first
  blamed, rest skipped)
- Add parametrized test_attribute_marked for success/create-fails/
  decorate-fails
why: SaveBuffer declared safety="mutating" alongside
effects=Effects(read_only=True) -- contradictory. save-buffer reads a
tmux buffer and writes a file; it changes no tmux state, so it is a
read like its peer show-buffer. A consumer filtering on
safety=="readonly" wrongly omitted it.

what:
- Set SaveBuffer safety="readonly" and effects idempotent=True (matches
  ShowBuffer)
- Update the registry readonly doctest + test list
- Add a parametrized invariant test: safety=="readonly" agrees with
  effects.read_only for every registered op
why: The PipePane docstring documented a `command` parameter, but the
field is `command_line` (renamed to avoid the `command` classvar). A
reader following the docstring would hit a TypeError.

what:
- Rename the docstring parameter to `command_line` (the doctest
  already used the correct name)
why: The imsg engine logged extra={"tmux_command_argv": list(...)},
a non-scalar value that violates the logging schema (avoid ad-hoc
objects; prefer stable scalars).

what:
- Replace the list value with the documented scalar core key tmux_cmd
  holding the joined command line, in both imsg debug log calls
tony added 25 commits August 15, 2026 07:30
why: Wall-clock corrections can move backward and invalidate timeout
measurements in retry_until and its tests.

what:
- Measure retry deadlines with the monotonic clock
- Measure elapsed-time assertions with the same clock
why: Operation pages need real tmux proofs and API references that
match the normal gp-sphinx presentation.

what:
- Replace mock operation examples with live typed outcomes
- Add engine, tutorial, and result API references
- Align operation cards, source links, parameters, and badges
- Test documentation inventory, rendering, and tmux behavior
why: CI checks the documentation extension under a different package
root and against Sphinx's installed domain annotations.

what:
- Use package-relative imports for both Sphinx and mypy discovery
- Follow inherited Sphinx domain attribute typing
- Inspect public constructor signatures without unsafe __init__ access
why: tmux releases disagree on whether list-clients includes a
suspended client, making the documented output version-dependent.

what:
- Verify the real AckResult and empty command output
- Preserve the portable session-survival assertion
- Document client-list visibility as version-dependent
why: Deleting a control-owned session can race tmux's deferred global
notifications and terminate supported tmux servers.

what:
- Attach control clients to safe existing sessions without updating the
  environment
- Bootstrap empty or unsafe servers through tracked subprocess execution
- Serialize lifecycle transitions without blocking dependent fallback
  commands
- Cover live hook, concurrency, cancellation, and cross-version paths
why: CI type-checks against Python 3.10, where Task.cancelling is not
available in the asyncio stubs.

what:
- Replace the version-specific cancellation probe with a portable task wait
- Make the shared startup future's nullable type explicit
- Align lifecycle test overrides and terminal paths with their contracts
why: Whole-file backups could be overwritten or unwound out of order,
and malformed state could abort configuration recovery.

what:
- Preserve first backups and enforce per-config LIFO ordering
- Checkpoint state before config writes and each successful revert
- Fail closed on missing backups, corrupt state, and malformed configs
- Cover repeat swaps, partial failures, and recovery diagnostics
why: Experimental operations, engines, and agent tools could diverge from
their documented constructor, result, and command-boundary contracts.

what:
- Make Attributes the checked source for generated constructor API prose
- Expose only tmux-supported targets across Python, MCP, and payloads
- Preserve create results and tmux 3.7 composed-operation invariants
- Gate executable MCP payloads across every executable tool path
- Preserve literal arguments across every experimental tmux transport
- Add the direct YAML dependency and tested failure guidance
why: Show how typed Python chains become folded tmux command sequences
while one asynchronous control-mode client carries the work.

what:
- Add a tabbed live tutorial for forward references and planner folding
- Replace simulated plan examples with real tmux execution
- Test visible compiled commands against actual control-mode dispatches
- Add API destinations and navigation for plan concepts
why: First-party record fields must render with complete semantic
prose, while gp-sphinx a36 now owns documented-member deduplication.

what:
- Expand the runtime contract across supported record declarations
- Define inheritance, ordering, and exemption rules in AGENTS.md
- Remove the obsolete no-undoc-members workaround and source test
why: CI must type-check the untyped doctest dependency boundary and
the heterogeneous results produced by a compiled operation plan.

what:
- Describe the consumed doctest finder interface with a protocol
- Narrow the terminal plan result before reading message text
why: Operation's ten class variables were documented as a hand-written
definition list under Notes, because an Attributes entry for a class
variable used to be dropped from the build. gp-sphinx renders one now,
with the annotation and value alongside the prose, so the workaround
costs a reader the type and default it cannot state.

what:
- Move kind, command, scope, result_cls, chainable, primitive, safety,
  effects, min_version, and flag_version_map into the Attributes
  section, dropping the Notes list

Requires a gp-sphinx release carrying the class-variable rendering;
under the pinned 0.0.1a36 these descriptions do not render.
why: Engine users need transport-specific examples that expose real output
and boundaries without treating mock results as server evidence.

what:
- Add one executable first-success example per concrete engine
- Map every engine to a focused live or offline tutorial
- Test tutorial ownership and transport boundaries
why: gp-sphinx removes tabs.js after rendering, leaving pages with a missing
asset reference even though inline tabs operate through CSS.

what:
- Filter only tabs.js from Sphinx page contexts at late priority
- Verify final tab markup and assets with a one-page Sphinx build
why: ruff's default rule set, adopted on master, enforces PLR0402.
`import a.b as b` and `from a import b` bind the same name, and the
from-form is the one the rest of the suite uses.

what:
- Rewrite the engines and ops submodule imports in the docs tests

https://docs.astral.sh/ruff/rules/manual-from-import/
why: ruff's default rule set enforces FURB188. The conditional slice and
`str.removeprefix` are equivalent for a single-character prefix, and the
method says what the code is doing.

what:
- Replace the guarded slice in `ControlNotification.parse`

https://docs.astral.sh/ruff/rules/slice-to-remove-prefix-or-suffix/
why: ruff's default rule set enforces PYI025. Bare `Set` reads as the
`set` builtin at the use site, but it is the abstract collection; the
annotation accepts any set-like, not just `set`.

what:
- Import `collections.abc.Set` as `AbstractSet` and use it in
  `get_objects`' docnames annotation

https://docs.astral.sh/ruff/rules/unaliased-collections-abc-set-import/
why: ruff's default rule set enforces FLY002, which wants an f-string in
place of a static join. An f-string is the wrong shape here: tmux format
specifiers are `#{...}`, so every brace would need doubling, and the
eight fields would collapse onto one line. Binding the tuple keeps one
field per line and leaves the join non-static.

what:
- Extract the tmux format specifiers into `_DONE_FIELDS` and join that

`_DONE_FORMAT` is unchanged.

https://docs.astral.sh/ruff/rules/static-join-to-f-string/
why: ruff's default rule set enforces ISC004. Implicit concatenation
inside a list literal reads like a missing comma between elements, which
is how a segment silently merges into its neighbour. Explicit parens make
each element's extent unambiguous.

what:
- Wrap the four multi-line instruction segments in parentheses

The rendered instructions are byte-identical, with and without events.

https://docs.astral.sh/ruff/rules/implicit-string-concatenation-in-collection-literal/
why: ruff's default rule set enforces PYI034. Annotating `__enter__`,
`__aenter__`, and `__new__` with the concrete class loses the subclass:
`with SubEngine() as e` inferred the base, so subclass-only attributes
read as errors and the wrong type propagated to callers.

what:
- Return `Self` from `ControlModeEngine.__enter__`,
  `AsyncControlModeEngine.__aenter__`, and `CommandSeparator.__new__`
- Same for the forged separator in the engine base tests
- Import `Self` from `typing_extensions` under `TYPE_CHECKING`, matching
  the rest of the package's 3.10 backport pattern

https://docs.astral.sh/ruff/rules/non-self-return-type/
why: ruff's default rule set enforces FURB192. Sorting a whole name set
to read its first element states the intent less directly than asking for
the minimum.

what:
- Use `min()` for the two suggested-server picks in the doctor output

Ruff marks the fix unsafe because `sorted(x)[0]` raises `IndexError` on an
empty sequence where `min(x)` raises `ValueError`. Both call sites sit
behind an emptiness guard on the line above, so neither can be reached
with an empty set.

https://docs.astral.sh/ruff/rules/sorted-min-max/
why: ruff's default rule set enables BLE001, which fires at nine sites
where catching everything is the contract rather than a mistake. Each
handler records the failure for a caller instead of swallowing it, so
narrowing the except clause would lose the failure mode it exists to
report.

what:
- Scope per-file ignores, with the reason, to the async control-mode
  supervisor, the MCP event drainer, the error-result middleware, the
  schema fallbacks, and the safety-gate tier assertions

The supervisor and reader tasks re-raise `CancelledError` before the
catch-all, so cancellation still propagates.

https://docs.astral.sh/ruff/rules/blind-except/
why: ruff's EXE001 fires on CI but never locally: it short-circuits on
WSL, where every file reports executable, so a Linux runner sees a
shebang on a mode-644 file that this machine cannot. Both scripts also
declared PEP 723 dependencies their `python3` shebang could not satisfy,
so `./scripts/mcp_swap.py` died on a missing import.

what:
- Set mode 100755 on `scripts/bench_engines.py` and `scripts/mcp_swap.py`
- Point both shebangs at `uv run --script`, which resolves the inline
  dependency block

`uv run scripts/<name>.py`, the form the docs use, is unaffected.

https://docs.astral.sh/ruff/rules/shebang-not-executable/
why: `use-local` could only point the agents at this working copy, so
reviewing a branch meant checking it out first. The writer was also
careless with files it does not own: it replaced a symlinked config with
a regular file, dropped its permission bits, re-escaped non-ASCII text it
never read, appended a newline Claude never wrote, and recorded
`config_path` as recovery identity — so a link repointed after a swap
sent `revert` into someone else's file.

what:
- Add `use-local --pr N`, writing `uvx --from git+<remote>@refs/pull/N/head
  <entry>`; the ref lives on the base repo so fork PRs work unchanged and
  nothing is checked out for `revert` to clean up
- Probe that spec with one MCP `initialize` round trip before any config
  is touched, so a bad ref fails once here instead of inside every agent;
  `--no-preflight` skips it
- Resolve symlinks and carry the target's mode through `atomic_write`,
  and record the resolved `SwapEntry.target_path` for `revert` to use
- Take the file's own trailing-newline convention in `dump_config_bytes`
  and stop escaping characters outside the entry being swapped
- Compare argv exactly in `_points_at`, so `--entry` is not swallowed by
  the already-local short-circuit, and label a PR before the pin branch
- Cover pull-request targeting, JSON writer fidelity and symlinked
  configs, and guard that `fake_home` lists every registered CLI

The `libtmux-engine-mcp-dev` state slug, the entry-derived server name,
and the recovery-stack guards this repo added stay as they were.
why: Two more agent CLIs are installed here and neither could be swapped.
Both keep their MCP config in JSONC, which the JSON writer would have
reserialized -- stripping every comment out of a file the user wrote by
hand. opencode also disagrees with the other six about how one entry is
spelled: its container key is `mcp`, argv goes into a single `command`
array, and the environment table is called `environment` (an `env` key is
dropped in silence, and a scalar `command` is a decode error that stops
opencode starting at all).

what:
- Add a JSONC codec that edits by text splice, so comments, trailing
  commas, indent width and a missing final newline all survive a swap;
  values still come from stdlib `json`, so escape semantics are the
  standard library's
- Move the per-CLI branching onto `CLIInfo.container` and
  `CLIInfo.dialect`, so `get_server`, `set_server`, `delete_server` and
  `_all_server_specs` no longer carry a chain of CLI-name tests
- Register `opencode` ($XDG_CONFIG_HOME/opencode/opencode.jsonc) and `pi`
  (~/.pi/agent/mcp.json), normalising opencode's array command back to the
  portable scalar-plus-args spec so the already-local check still fires
- Ignore a relative XDG_CONFIG_HOME, which the spec requires and which
  otherwise made the recorded backup path depend on the working directory
- Say in `detect` that pi has no MCP client of its own: the file is read
  by the third-party pi-mcp-adapter, so without it the swap does nothing
- Derive the detect column width from the longest CLI name
- Cover both CLIs end to end plus JSONC fidelity, the one-delimiter member
  removal, key escaping, and comment survival on insert
@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.68116% with 839 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.67%. Comparing base (947eaa4) to head (b628d15).

Files with missing lines Patch % Lines
scripts/mcp_swap.py 78.79% 175 Missing and 50 partials ⚠️
src/libtmux/experimental/engines/imsg/base.py 54.96% 149 Missing and 28 partials ⚠️
src/libtmux/experimental/engines/control_mode.py 68.55% 94 Missing and 34 partials ⚠️
...libtmux/experimental/engines/async_control_mode.py 75.39% 84 Missing and 40 partials ⚠️
src/libtmux/experimental/engines/imsg/v8.py 76.47% 39 Missing and 17 partials ⚠️
src/libtmux/experimental/mcp/__init__.py 47.61% 28 Missing and 5 partials ⚠️
docs/_ext/tmuxop/render.py 84.86% 15 Missing and 8 partials ⚠️
docs/_ext/tmuxop/domain.py 88.18% 10 Missing and 5 partials ⚠️
src/libtmux/experimental/engines/base.py 84.52% 8 Missing and 5 partials ⚠️
src/libtmux/experimental/mcp/_policy.py 90.65% 5 Missing and 5 partials ⚠️
... and 6 more
Additional details and impacted files
@@                   Coverage Diff                    @@
##           engine-seam-minimal     #742       +/-   ##
========================================================
+ Coverage                52.35%   79.67%   +27.32%     
========================================================
  Files                       29      237      +208     
  Lines                     3870    16317    +12447     
  Branches                   755     2053     +1298     
========================================================
+ Hits                      2026    13000    +10974     
- Misses                    1542     2603     +1061     
- Partials                   302      714      +412     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tony added 2 commits August 15, 2026 07:40
why: engine-ops replays onto engine-seam-minimal without touching a line
of core dispatch, so the only reconciliation left is shared bookkeeping.

what:
- Merge both changelog entries under one unreleased release block
- Regenerate uv.lock against master's gp-sphinx floor
why: The experimental engines carried their own CommandRequest,
CommandResult and ServerConnection, so an engine written against them
returned values Core's object API could not read, and the two copies
could drift apart on the flags they emit.

what:
- Import the request, result, separator and engine protocols from
  libtmux.engines instead of redefining them
- Reduce the connection module to Core's ServerConnection
- Keep what only the experimental transports need: the argv encoders,
  control-mode rendering, EngineSpec and the async protocol
@tony
tony force-pushed the engine-ops-on-seam branch from cbf69fc to b628d15 Compare August 15, 2026 12:56
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