Skip to content

Typed operations & engines: spine, 6 engines, plans, models, facades (#689)#690

Open
tony wants to merge 41 commits into
masterfrom
engine-ops
Open

Typed operations & engines: spine, 6 engines, plans, models, facades (#689)#690
tony wants to merge 41 commits into
masterfrom
engine-ops

Conversation

@tony

@tony tony commented Jun 21, 2026

Copy link
Copy Markdown
Member

Summary

Implements the typed operations + engines architecture under libtmux.experimental.{ops,engines,models,facade} — an inert, statically-typed operation spine; a family of interchangeable engines (subprocess, concrete, control-mode, async-subprocess, async-control, and the native imsg easter-egg); lazy/async-lazy plans with ;-folding chainability; pure object-graph snapshots; a typed read surface; engine-typed facades; and a docs catalog generated from the registry.

Operationalizes #688 (architecture) per the plan in #689. Touches no existing public API — everything is additive under libtmux.experimental (explicitly outside the versioning policy). Nothing is generated at runtime; everything is statically typed and mypy-strict clean.

What's delivered

The spine — libtmux.experimental.ops (pure, no tmux):

  • Operation[ResultT]: frozen, keyword-only, class-vars as the single source of truth (kind/command/scope/result_cls/effects/safety/chainable/version gates). Pure render() with declarative version gating; build_result() adapts raw output to a typed result (version-threaded so read parsing matches the gated render).
  • Typed Result hierarchy with opt-in raise_for_status(): AckResult (no-output commands — success/failure only), SplitWindowResult/CreateResult (captured ids), CapturePaneResult (lines), ListPanes/Windows/SessionsResult (snapshot-deriving rows).
  • Closed Target sum, fail-closed OperationRegistry, stdlib serialization, and catalog() (registry-derived docs data).
  • LazyPlan (record → resolve SlotRef forward refs → execute) with chainability: >> / OpChain composition and execute(fold=True) folding chainable runs into one tmux a ; b dispatch, attributing per-op status (success → all complete; failure → first failed, rest skipped, matching tmux's cmdq_remove_group).
  • Read seam: ListPanes/ListWindows/ListSessions ops render the same -F template neo uses (imported, not copied) and parse into models snapshots — a typed read surface parallel to neo, leaving the ORM untouched.
  • ~17 operations across pane/window/session/server scopes.

Engines — libtmux.experimental.engines (all behind TmuxEngine/AsyncTmuxEngine, all returning the same CommandResult):

Family Sync Async
Subprocess (classic) SubprocessEngine AsyncSubprocessEngine
Concrete (in-memory) ConcreteEngine AsyncConcreteEngine
Control mode (tmux -C) ControlModeEngine AsyncControlModeEngine (event stream via subscribe())
Native imsg (binary protocol) ImsgEngine (opt-in easter egg)

Control engines use an I/O-free bytes ControlModeParser with FIFO/skip correlation (startup-ACK consumed up front; unsolicited hook blocks skipped). The imsg engine speaks tmux's binary peer protocol directly (AF_UNIX + SCM_RIGHTS, PROTOCOL_VERSION 8) and has a live parity test vs the subprocess engine the prototype never had.

Models — libtmux.experimental.models: frozen Pane/Window/Session/ServerSnapshot (typed core + raw field tail), from_pane_rows() builds the whole tree from one list-panes -a query, round-trips to plain dicts — neo-like but decoupled and serializable.

Facades — libtmux.experimental.facade ("mode lives in the type"): eager Server→Session→Window→Pane navigation, LazyWindow/LazyPane, AsyncWindow/AsyncPane — all over the same ops; control mode is just an engine choice.

Docs: an in-repo tmuxop-catalog Sphinx directive renders catalog() into the operation reference (exercised by the docs gate), so the reference can't drift from the code.

Testing

  • ~240 experimental tests + doctests; the pure spine/models/concrete tests need no tmux, while classic/control/async/imsg engines and the facades are validated against a real tmux server via the libtmux fixtures.
  • Cross-engine contract suite: same typed result across engines; serialization round-trips.
  • Full repo gate green: ruff, ruff format, mypy --strict, pytest (1501 passed, 2 skipped), build-docs. (The occasional test_retry.py timing flake is pre-existing and unrelated — passes in isolation.)

Design notes

  • Revises Design typed operations and engines #688: execution mode lives in the facade type, not a runtime-bound engine attribute (return types differ by mode).
  • Per-engine error policy: classic reproduces today's behavior; newer engines return typed results with opt-in raise_for_status(). Same result shape across engines.
  • Core is stdlib-dataclass-only; an OTel/MCP edge can sit behind an extra.
  • imsg is opt-in and non-default: it depends on tmux's internal protocol (v8), is POSIX-only, and cannot host attach (which falls back to a local spawn).

Refs #688, #689.

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
@codecov

codecov Bot commented Jun 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.45954% with 699 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.92%. Comparing base (42cf219) to head (f86e025).

Files with missing lines Patch % Lines
src/libtmux/experimental/engines/imsg/base.py 52.53% 152 Missing and 35 partials ⚠️
src/libtmux/experimental/engines/control_mode.py 62.80% 71 Missing and 35 partials ⚠️
...libtmux/experimental/engines/async_control_mode.py 65.65% 47 Missing and 21 partials ⚠️
src/libtmux/experimental/engines/imsg/v8.py 74.11% 49 Missing and 17 partials ⚠️
docs/_ext/tmuxop.py 18.18% 36 Missing ⚠️
src/libtmux/experimental/engines/asyncio.py 56.60% 14 Missing and 9 partials ⚠️
src/libtmux/experimental/engines/subprocess.py 70.21% 8 Missing and 6 partials ⚠️
src/libtmux/experimental/ops/_ops/set_option.py 73.68% 5 Missing and 5 partials ⚠️
src/libtmux/experimental/ops/_ops/new_session.py 74.28% 4 Missing and 5 partials ⚠️
src/libtmux/experimental/ops/plan.py 92.17% 6 Missing and 3 partials ⚠️
... and 41 more
Additional details and impacted files
@@             Coverage Diff             @@
##           master     #690       +/-   ##
===========================================
+ Coverage   51.30%   68.92%   +17.61%     
===========================================
  Files          25      148      +123     
  Lines        3487     7713     +4226     
  Branches      686     1079      +393     
===========================================
+ Hits         1789     5316     +3527     
- Misses       1403     1890      +487     
- Partials      295      507      +212     

☔ 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 4 commits June 21, 2026 09:08
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
@tony tony changed the title Typed operations and engines: inert op spine (#689) Typed operations and engines: spine + 4 engines + facades (#689) Jun 21, 2026
@tony tony changed the title Typed operations and engines: spine + 4 engines + facades (#689) Typed operations and engines Jun 21, 2026
tony added 9 commits June 21, 2026 09:57
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)
@tony tony changed the title Typed operations and engines Typed operations & engines: spine, 6 engines, plans, models, facades (#689) Jun 21, 2026
tony added 11 commits June 21, 2026 12:01
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
@tony

tony commented Jun 21, 2026

Copy link
Copy Markdown
Member Author

Code review

Found 2 issues:

  1. LazyPlan resolves a forward SlotRef only for target, never for src_target, so a dual-target op (JoinPane, SwapPane, MovePane, BreakPane, SwapWindow, MoveWindow, LinkWindow) whose src_target comes from an earlier plan.add(...) reaches render() with the slot unresolved and raises TypeError: cannot render an unresolved SlotRef. (bug: _resolve() substitutes operation.target but not operation.src_target, even though serialize.py already handles both)

def _resolve(
operation: Operation[t.Any],
bindings: dict[int, str],
) -> Operation[t.Any]:
"""Substitute a :class:`SlotRef` target with a captured concrete id."""
target = operation.target
if not isinstance(target, SlotRef):
return operation
try:
concrete = bindings[target.slot] + target.suffix
except KeyError as error:
msg = (
f"slot {target.slot} has no captured id yet; a plan step can only "
f"target an earlier step that creates an object"
)
raise OperationError(msg) from error
return dataclasses.replace(operation, target=_target_from_id(concrete))

  1. SaveBuffer declares contradictory metadata: safety = "mutating" together with effects = Effects(read_only=True), where read_only is documented as "does not change tmux state". Its read peer ShowBuffer uses safety = "readonly", and a consumer filtering registry.select(lambda s: s.safety == "readonly") would omit save_buffer despite effects.read_only=True. (bug: inconsistent safety/effects declarations)

result_cls = AckResult
safety = "mutating"
effects = Effects(read_only=True)

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tony added 6 commits June 21, 2026 14:03
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: The three pluggable dispatch strategies (Sequential/Folding/
Marked) had docstrings but no examples, the clearest gap among the
methods a review flagged for missing doctests.

what:
- Add concise, engine-free doctests to SequentialPlanner.plan,
  FoldingPlanner.plan, and MarkedPlanner.plan showing the PlanStep
  output each produces
@tony

tony commented Jun 21, 2026

Copy link
Copy Markdown
Member Author

Code review

Found 1 issue:

  1. LazyPlan resolves forward SlotRefs on every dispatch path except the {marked} fold's decorates. _drive resolves the create op but builds decorates raw, so a chainable dual-target decorate (SwapPane/JoinPane/MovePane) whose src_target is a forward slot reaches render_marked unresolved and raises TypeError: cannot render an unresolved SlotRef. The single-op and chain paths both call _resolve; this one does not. (bug: decorates = [self._operations[i] for i in decorate_idx] skips _resolve, so src_target SlotRefs survive into render under MarkedPlanner)

create_idx, *decorate_idx = step.indices
create = _resolve(self._operations[create_idx], bindings)
decorates = [self._operations[i] for i in decorate_idx]
merged: CommandResult = yield _Chain(
render_marked(create, decorates, version),
)

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tony added 10 commits June 21, 2026 15:40
why: The earlier src_target fix covered the single-op and chain
dispatch paths but not the {marked} fold: _drive built decorates raw,
so a chainable dual-target decorate (swap/join/move) whose src_target
is a forward slot reached render_marked unresolved and raised
TypeError. A decorate's target is the same-fold create (addressed via
{marked}); only its src_target -- which points at an earlier bound
step -- can and must be resolved.

what:
- Add _resolve_src() (resolves only a SlotRef src_target, leaving the
  {marked}-bound target to render_marked) and use it for decorates in
  the _drive marked branch
- Add parametrized test_marked_plan_resolves_decorate_src_target
  (swap/join/move decorate referencing an earlier bound pane)
why: ControlModeEngine and AsyncControlModeEngine serialized each
command with shlex.join, which quotes a folded chain's standalone ";"
token to "';'". tmux -C then read it as a literal argument, so a
FoldingPlanner/MarkedPlanner chain over control mode ran as one
malformed command and the chained commands silently never executed.

what:
- Add render_control_line() to engines/base.py: shell-quote each token
  but leave a standalone ";" bare
- Use it in both control-mode run_batch payloads (the only send path);
  drop the now-unused shlex imports
- Add parametrized test_render_control_line (plain, quoted, chain)
why: attribute_marked derived new_id from stdout[0].strip() and only
guarded against None. A whitespace-only capture became "" and passed
the guard, so the plan would bind an empty id and later raise
ValueError when Special("") was constructed during slot resolution.

what:
- Coerce a blank captured id to None (`... or None`) so it is never
  bound as ""
- Add test_attribute_marked_blank_stdout_is_no_id
why: When a {marked} fold's creator emits no id (capture=False) but
tmux succeeded, attribute_marked took the no-id branch and forced
returncode `or 1`, falsely reporting the create as failed and all
decorates as skipped even though every command ran.

what:
- In the no-id branch, when returncode==0 and no stderr, report the
  create complete and all decorates complete (a non-capturing success)
- Add the capture_false_success case to test_attribute_marked
why: On a successful create with a failing decorate, attribute_marked
passed the whole merged result -- whose stdout[0] is the create's
captured pane id -- to the chain attributor, so the failed decorate's
result carried the pane id as its stdout instead of error output.

what:
- Attribute decorates over a copy of the merged result with stdout
  cleared, so a failed decorate is not credited with the new pane id
- Add test_attribute_marked_failed_decorate_drops_create_stdout
why: After a {marked} fold, decorate results kept operation.target set
to Special("{marked}"), the render-time register -- not the created
pane. Serializing or replaying such a result would re-dispatch to
whatever {marked} points at later, not the intended pane.

what:
- Attribute decorates over copies retargeted to PaneId(new_id), so each
  decorate result's operation addresses the concrete pane (render_marked
  still uses {marked} for the live dispatch)
- Add test_attribute_marked_decorate_target_is_concrete_pane
why: SubprocessEngine ran tmux with text=True but no explicit
encoding, so on a non-UTF-8 locale it decoded with the platform
default -- diverging from common.tmux_cmd (which pins encoding="utf-8"
per #679) and breaking the docstring's byte-for-byte claim, e.g. the
format separator (U+241E) could be mangled.

what:
- Pass encoding="utf-8" to the Popen call
- Drop the unused logging import / module logger
- Add test_subprocess_engine_decodes_utf8 (monkeypatched Popen kwargs)
why: snapshots.py imported `replace` directly from dataclasses, but the
project's import convention only exempts `dataclass` and `field` from
namespace imports; the rest of the experimental tree already calls
dataclasses.replace via the namespace.

what:
- Import dataclasses and call dataclasses.replace at the 4 call sites;
  drop `replace` from the from-import (no behavior change)
why: The toggle docstring said -o stops the pipe "if already piping
the same command", but tmux's -o makes no command comparison -- it
only opens the pipe when no pipe is already open on the pane.

what:
- Reword the toggle parameter to describe -o accurately
why: save-buffer was tiered "readonly", but it writes paste-buffer
contents to a filesystem path -- a side effect. Under the MCP
safety vocabulary readonly means no side effects, so an auto-approve
gate keyed on safety=="readonly" would wave through arbitrary file
writes. effects.read_only stays true: it changes no tmux state.

what:
- Set SaveBuffer safety="mutating" (effects.read_only kept)
- Relax the registry invariant to "readonly => read_only" (an op may
  be read_only w.r.t. tmux yet still mutating via an external effect)
- Drop save_buffer from the readonly registry doctest and test list
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant