Typed operations and engines, sharing the seam's values - #742
Open
tony wants to merge 207 commits into
Open
Conversation
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
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
10 tasks
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
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
force-pushed
the
engine-ops-on-seam
branch
from
August 15, 2026 12:56
cbf69fc to
b628d15
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
common.py,server.py, orneo.py, so replaying them produced no sourceconflict — only shared bookkeeping (
uv.lock,CHANGES,pyproject.toml).libtmux/experimental/engines/carried its own
CommandRequest,CommandResultandServerConnection;those now come from
libtmux.engines, so an experimental engine and theobject 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 twoCommandResulttypes were structurally similar but not interchangeable. NowServer(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 fromlibtmux.enginesrather than redefining them. What stays is what only theexperimental 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 tolibtmux.engines.connection.ServerConnection.Every other module keeps importing from
libtmux.experimental.engines, so there-export redirects roughly 100 files without touching one of them.
Verification
test_new_session_shell_env,reproduces on
masterand is unrelated.mypy src testsclean across 301 files;ruff checkandruff formatclean.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 experimentalone, because tmux parses the trailing
;as a command boundary. Theexperimental
encode_direct_argvalready escapes it. Porting that into corechanges argv for every argument ending in
;, so it cannot ride inside ano-op change and needs its own PR.