Add experimental typed chain API for tmux command sequences#685
Open
tony wants to merge 42 commits into
Open
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #685 +/- ##
===========================================
+ Coverage 51.30% 64.13% +12.82%
===========================================
Files 25 41 +16
Lines 3487 5211 +1724
Branches 686 842 +156
===========================================
+ Hits 1789 3342 +1553
- Misses 1403 1523 +120
- Partials 295 346 +51 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ca3eded to
b782ba7
Compare
chain API for tmux command sequences
Member
Author
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with Claude Code |
19deb3c to
c913663
Compare
Member
Author
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with Claude Code |
b1b7d3b to
4b2a499
Compare
why: Promote the converged chainable-commands design from the PR #684 research into a documented, typed experimental API, beginning with the argv IR substrate. Establishes the _experimental package as a home for in-progress designs (mirroring _internal/docs/internals), explicitly outside the versioning policy. what: - Add src/libtmux/_experimental/ + chainable_commands subpackage - Add ir.py: CommandCall, CommandChain (argv/argvs/then/>>/run), CommandSpec, CommandRunner/CommandResultLike protocols, and ;-escaping, all with doctests - Add tests/_experimental/chainable_commands/test_ir.py (pure argv + live tmux one-dispatch and stop-on-error) - Add docs/experiment/ landing + IR autodoc page; wire into docs/index.md toctree; mark _experimental not-public in public-api.md
737a9d4 to
4516a9c
Compare
This was referenced Jun 20, 2026
…ive adapters why: Add the headline layer of the chainable-commands design -- a typed, target-safe deferred query that compiles to one native tmux command sequence -- plus the live-tmux bridge so plans resolve and dispatch against a real server in a single invocation. what: - Add plan.py: typed PaneTarget/WindowTarget/SessionTarget, command values (SendKeys/ResizePane/SelectLayout), PaneRef rows with bound .cmd/.window namespaces, lazy PaneQuery (filter/order_by/limit/all/first/map/commands), CommandPlan with pure to_chain(snapshot) and one-dispatch run() - Reuse ir.CommandChain (resolve the lab's duplicate-CommandChain collision) - Add adapters.py: snapshot_from_session() and SessionPlanRunner (PlanRunner over a live Session); cast Server to CommandRunner for clean mypy + ty - Add tests/_experimental tests: pure plan semantics + live snapshot/dispatch - Add plan + adapters autodoc pages; grow docs/experiment/index with deferred-plan examples and toctree entries
…nc adapter why: An async host (e.g. an MCP server) is the real awaitable boundary for this design. Add an async facade so snapshot resolution and dispatch are awaitable while command construction stays synchronous, preserving the one-plan = one-native-dispatch guarantee, plus a live async adapter over the sync core. what: - Add aio.py: async PaneQuery/MappedPaneQuery/CommandPlan wrapping the sync engine; to_chain reuses the sync compile path so one plan still yields one ir.CommandChain; run() dispatches via an async runner - Add adapters.AsyncSessionPlanRunner (AsyncPlanRunner over a live Session via asyncio.to_thread) - pyproject: add pytest-asyncio (dev + testing), asyncio_mode="auto", and asyncio_default_fixture_loop_scope="function" (matching pytest-asyncio's own config and sibling projects) - pytest_plugin/test_pytest_plugin: the plugin's pytester-based doctests/tests spawn sync inner pytest sessions; pass `-p no:asyncio` so the now-installed pytest-asyncio plugin does not load there and emit its loop-scope deprecation - Add tests/_experimental/test_aio.py: async plan semantics (pytest-asyncio auto) plus live async snapshot/dispatch integration - Add aio autodoc page; grow docs/experiment/index with an async example, card, and toctree entry
why: A tmux command sequence is dispatched once, so a command may only fold into a chain when its output is not consumed mid-chain. Wire the static and dynamic halves of that rule together so a chain compiler has one place to decide what may merge. what: - Add chainability.py: COMMAND_SPECS registry + is_chainable() (static half, via CommandSpec.chainable); DeferredCommandResult raising DeferredOutputUnavailable on output access (dynamic half); ChainabilityError for non-chainable commands - Add tests/_experimental/test_chainability.py covering the static flags and deferred output rejection - Export the chainability surface from the package; add the chainability autodoc page, grid card, and toctree entry
…on names why: Use plain libtmux/Python vernacular for the experimental docs so the layers read clearly to newcomers, while keeping every link, example, and toctree entry. what: - Rename the five sections: Command IR -> Intermediate representation, Deferred plan -> Expressions, Async facade -> Async, Live-tmux adapters -> Connecting to live tmux sessions, Chainability contract -> Chainability - Reorder the layer bullets and grid cards to match the toctree; tighten the landing prose and adopt "expression" vocabulary in the worked examples - Update each api page heading to the new section name
why: Minimal installs should import the experimental chain package without the dev or testing dependency groups present. what: - Add a python -S subprocess import of libtmux._experimental.chain to test_chainability.py, asserting the package loads with only stdlib
why: Object-level cmd wrappers add target context, so only Server should be documented as directly safe for raw command sequences. what: - Limit CommandRunner direct-dispatch guidance to Server - Point object-level usage to session.server or SessionPlanExecutor
why: Bare tmux window targets resolve against the server's current session, which can differ from the Session or Pane object issuing the command. what: - Target break-pane destination windows at the source pane's session - Prefix bare Session.kill_window names and indexes with the owning session id - Preserve explicit window ids and fully qualified tmux targets
…eric why: CommandPlan is only ever constructed as CommandPlan[None] — the ResultT type parameter advertised a result abstraction that does not exist. what: - Remove Generic[ResultT] from the sync and async CommandPlan; flatten to a plain CommandPlan - Drop the now-unused ResultT TypeVar from plan.py and _async.py - Point the assert_type call sites at the non-generic CommandPlan
why: An empty `-t ''` resolves to tmux's current/attached target, silently defeating the typed-target guarantee. The snapshot adapter coalesced a missing window/session id to "" and CommandCall accepted "" as a target, so a hand-built PaneRef/TmuxSnapshot could emit a mis-resolving command. what: - Reject an empty-string target in CommandCall.__post_init__ (None and integer targets such as 0 stay valid) - Skip panes missing a window/session id in snapshot_from_session rather than coalescing with `or ""` - Add a CommandCall empty-target rejection test
why: is_chainable/ChainabilityError were exported and documented as deciding which commands may share one invocation, yet no compile path enforced them — so a non-chainable command (show-option, capture-pane) could be silently folded into a one-dispatch chain and lose its output. what: - CommandPlan.to_chain raises ChainabilityError when a mapped command is non-chainable; raw CommandCall >> composition stays the explicit, unchecked escape hatch - Async to_chain reuses the sync compile path, so the rule applies there too - Add tests for the rejected (show-option) and allowed (rename-window) cases
why: asyncio_mode="auto" took suite-wide ownership of every coroutine test on a mostly-synchronous library. Strict mode plus an explicit marker on the single async test module removes that global blast radius without changing behavior. what: - pyproject: asyncio_mode "auto" -> "strict" - test_async.py: add module-level pytestmark = pytest.mark.asyncio - Keep -p no:asyncio in the isolated inner pytester runs and document why: those runs don't read this project's asyncio_default_fixture_loop_scope, so the suppression is independent of asyncio_mode -- removing it reintroduces pytest-asyncio's loop-scope deprecation warning regardless of strict/auto.
why: A composed sequence dispatches once, so a failure surfaces as a single aggregate result with no record of which command broke. Per the logging standards, emit a structured debug record of the rendered argv at the dispatch point. what: - Add module loggers to ir and _async - CommandChain.run and async CommandPlan.run log "tmux command sequence dispatched" (tmux_cmd, tmux_subcommand) and "complete" (tmux_exit_code) - Add a caplog test asserting the structured record schema
why: The targeting fix in 5ddf036 changed shipped break-pane and kill-window behavior but shipped no test. These regression tests fail on master and pass with the fix, locking in the cross-session behavior. what: - test_break_pane_targets_owning_session: a pane breaks out into its own session's window even when another session is the server's current one - test_kill_window_targets_owning_session: a bare window name is scoped to the owning session, leaving an identically-named window in another session intact
why: The project requires a working doctest on every method; several chain query verbs, command compilers, and bound builders relied only on class-level doctests for coverage. what: - Add doctests to PaneQuery.filter/order_by/limit (sync and async facades) - Add doctests to the SendKeys/ResizePane/SelectLayout to_call compilers and the bound pane/window command builders - Add doctests to SessionPlanExecutor.cmd and snapshot
why: The bound vocabulary covered three commands with no way to issue an arbitrary tmux command without dropping out of the typed/target-safe layer. Consumers like tmuxp (per-session set-option loops) need to reach commands that have no first-class builder. what: - Add raw(name, *args) to the pane- and window-scoped bound namespaces, binding the namespace's typed target; raw commands still pass through the chainability check at compile time - Add a session-scoped namespace (PaneRef.session / BoundSessionCommands) so the most-looped per-session set-option calls are expressible - Add tests for target binding across the three scopes and for chainability enforcement on the escape hatch
why: A bare window name is scoped to the owning session, but a name containing ":" is ambiguous with tmux session:window target syntax and is passed through unchanged. Document the behavior and the @-window-id workaround. what: - Note that ":"/"@" targets are treated as already-qualified and pass through - Clarify in the parameter docs that bare names/indexes are session-scoped
…tract why: The chainability contract's static half (is_chainable) is wired into to_chain, but the dynamic half -- DeferredCommandResult, documented as "won't hand back output until the chain has run" -- was exported and self-tested yet never produced or resolved by any code path. what: - Make DeferredCommandResult a two-state handle: unresolved raises DeferredOutputUnavailable; resolve(result) returns a copy bound to the chain's merged result (a `\;` dispatch is not separable per command, so every handle reflects the same result) - Add CommandPlan.run_deferred (sync + async): dispatch once, return one resolved handle per command; empty plans return () - Cover the resolved state machine and run_deferred across sync and async
why: The five wall-clock assertions (`0.9 <= elapsed <= 1.1`) flaked under load when run with --reruns 0 — nominal elapsed (~1.0s) sat ~100ms under the ceiling. The two "succeeds after 3 calls" tests also raced their 1s budget: under load only two of three calls fit, spuriously timing out. what: - Success cases: assert on behavior (call count + result) with a generous budget so all calls fit regardless of load; drop the wall-clock assertion - Timeout cases: keep the raises/returns assertion and the deterministic lower bound (retry_until only times out once elapsed >= budget); drop the fragile upper bound
why: run_deferred also dispatches to a live server, so the plan module docstring's "only CommandPlan.run touches a live server" was inaccurate. what: - Note that both run and run_deferred touch a live server
…window/session why: A ref should work two ways -- concrete (a snapshot row) or forward (an object a creation verb will make, resolved lazily at dispatch) -- so a caller can split a pane, then split the pane that split just created, in one native `\;` invocation while reusing the existing command namespaces. what: - Add PendingTarget (active/marked runtime token) and the _target_arg seam: a forward target renders to a tmux token through the unchanged .cmd/.window/.session namespaces, with no parallel command vocabulary - Make PaneRef dual-purpose: a concrete() constructor plus guarded metadata (pane_index/active/title raise ForwardDataUnavailable on a forward ref), and split()/break_pane() creation verbs - Add WindowRef/SessionRef siblings and module-level new_session(); share do/to_chain/run via one _ForwardRef mixin (Self-typed) - Resolve forward refs at dispatch across all three scopes (live tests), and export the new public surface
… dispatches
why: One `\;` invocation can address only the active or single marked object, so
holding two independent forward handles needs N dispatches -- each creation run
alone with `-P -F` to capture its real id, then substituted into downstream
commands. The resolution must work for sync AND async without duplicating logic
or blocking the event loop.
what:
- Add ir.SlotRef (an unresolved "id of slot N" target) plus the one-line
_target_arg seam that passes it through to the resolver
- Add _resolve.py: a sans-I/O resolution core -- a generator that yields a
Dispatch/SnapshotRequest and resumes via .send(result), the same trampoline
asyncio itself uses -- driven by ~10-line sync and async drivers that share it
verbatim (the only divergence is await)
- Add ForwardPlan/ForwardHandle: a builder handing out independent handles that
reuse the .cmd/.window/.session namespaces via SlotRef, resolved over N
dispatches (run_resolving / run_resolving_async); from_pane/from_query seeding
- Capture each creation's stable id (#{pane_id}) and fold downstream commands
into one trailing `\;` chain with ids substituted; live sync, async, and
query-seed tests plus a pure sans-I/O core test
why: The multi-dispatch builder created panes only -- `split()`. A forward plan
that wants two independent windows in a fresh session (or any session/window
handle) had no vocabulary for it, even though the sans-I/O core already captures
`#{window_id}`/`#{session_id}` per scope. Extending the builder makes the
N-dispatch resolver address every tmux scope, not just panes.
what:
- Add ir.SlotRef.suffix: a captured id can render qualified -- `new-window -t
$N:` reuses a plain `$N` capture without a second binding; _subst appends it
- Generalize ForwardPlan._split into _create(parent, kind, name, args) and add
ForwardPlan.new_session (session handle) + ForwardHandle.new_window (window
handle, targets the session via SlotRef(slot, ":"))
- Thread the handle's kind through ForwardHandle so it stays one type across
scopes; guard creation verbs with _require so new_window()/split() fail fast
at build time on the wrong scope instead of erroring in tmux
- Factor _split_args shared by the plan- and handle-level split
- Cover the new scopes: a pure scope-guard test plus live sync/async tests that
build a session, two independent windows, and a split inside each
…dispatch
why: A single-handle forward plan (split one pane, decorate it) still cost two
dispatches -- create+capture, then a substituted decorate chain -- because a
freshly-created id can't be substituted mid-`\;`-chain. tmux's marked-pane
register sidesteps that: the mark set by one command is visible to a later
command in the SAME invocation, so a lone pane handle resolves in ONE dispatch.
what:
- Add a plan-shape analyzer (_marked_eligible): exactly one pane `_Create` is the
one shape that folds to a single dispatch -- the marked register is a single
server-wide slot (so >=2 independent handles still need N dispatches) and only
a non-detached split leaves its result active to be marked (so a detached
session create stays multi-dispatch)
- Add _marked_invocation: `<split -P -F '#{pane_id}'> \; select-pane -m \;
<decorate -t {marked}>... \; select-pane -M` -- capture the id for bindings,
mark the new active pane, address it via {marked} (resolves for window/session
decorates too), and clear the register so no server-wide mark leaks
drive() picks the strategy by shape; the multi-dispatch path is unchanged
- Unify the `-P -F` capture into one _capturing helper shared by both strategies
- Cover it: an analyzer-classification test, a pure single-dispatch fold test,
and a live test asserting one invocation + correct {marked} decorate + that the
mark is cleared afterward
why: Record the new libtmux._experimental.chain command system in the unreleased notes so downstream users learn it exists. what: - Add a sections-only ### What's new entry under the placeholder (no lead paragraph, no version) describing one-call command chains and lazy pane/window/session references
…h fails why: A failed split/new-window/new-session prints nothing on stdout, so the resolver's `bindings[slot] = result.stdout[0].strip()` raised an opaque IndexError instead of surfacing tmux's actual error -- a downstream consumer hitting a bad target or an out-of-space split got a confusing stack trace. what: - Add ForwardDispatchError carrying the offending argv + tmux result, with a clear message including the exit code and stderr - Route both capture sites in drive() through _capture_id(), which raises on a nonzero exit or empty stdout rather than indexing into nothing - Export ForwardDispatchError; cover with a pure test (failed result) and a live test (split against a bogus target)
… environment why: A forward split/new-window/new-session could set only -h/-v + shell and a name -- a downstream workspace builder couldn't give a new pane its working directory or env, and these must ride the create command itself (a later decorate is too late), so the whole builder was forced onto a hybrid path. what: - Add start_directory= and environment= to split(), new_window(), new_session() (plus window_shell= on new_window and width=/height= on new_session) - Render them as libtmux does -- a concatenated `-c<dir>` and one `-e<k>=<v>` per var -- via a shared _location_args helper - Cover with a pure render test across all three verbs and a live test asserting a forward split lands in the requested start_directory
…and windows why: A ForwardPlan could only seed a pane (to split) or a query, and new_window lived only on a forward session handle -- so a downstream workspace builder could not add windows to a session it already created, and seeding forced the verbose six-field PaneRef.concrete(...) boilerplate. The pre-existing seed had no handle, so its first/existing pane could not be decorated in the plan. what: - Generalize ForwardHandle to bind either a SlotRef (forward) or a concrete id (an existing object), so one handle type spans created and seed objects - Add ForwardPlan.from_window / from_session (and let from_pane accept a live libtmux Pane, a chain ref, a typed target, or a bare id via _id_of) - Expose ForwardPlan.seed -- a handle to the existing seed -- so it can be .do()-decorated, split(), or new_window()'d by scope; add ForwardPlan.new_window to add a window to the seed session - Cover with pure render tests across all three scopes, a seed-decorate test, and a live test adding a window+split to an existing session
…jects why: A resolved plan only handed back Resolved.bindings (slot -> id string), so a downstream consumer that needs a libtmux Pane/Window/Session (to focus a pane, attach a session, or keep building) had to hand-roll server.panes.get(pane_id=...) lookups for every slot. what: - Add Resolved.pane/window/session(slot, server) -- look the captured id up in the server's QueryLists and return the typed libtmux object - Cover with a live test building a session/window/pane and asserting each helper round-trips its slot to the matching object id
…rom-scratch why: run_resolving needs a PlanRunner (cmd + snapshot), but the only runners were session-backed -- so a ForwardPlan().new_session(...) plan (which has no pre-existing session) had to borrow an unrelated session's executor just to reach server.cmd. A bare Server had no clean way to drive a creation plan. what: - Add ServerPlanRunner / AsyncServerPlanRunner backed by a live Server: cmd dispatches straight through server.cmd; snapshot() is empty since a server runner is for creation, not query seeding (a query-seeded plan still wants a SessionPlanExecutor) - Export both; cover with doctests and a live test creating a whole session -> window -> pane tree through ServerPlanRunner alone
…nvironment why: The bound command namespaces only typed send_keys/resize_pane/select_layout, so a workspace builder setting window/session options, renaming, or selecting had to drop to .raw(...) for almost everything -- the common path, untyped. what: - Add set_option/select to the pane namespace; set_option/rename/select to the window namespace; set_option/set_environment/rename to the session namespace - Each builds the right tmux command with its scope flag (-p/-w / none) bound to the namespace target; doctest each render, plus a live test using a typed verb as a forward-plan decorate
…ult window why: new_session is born with one window and pane the plan couldn't address, so a pure forward build had to create all windows and then kill the orphan default. There was no way to decorate or build onto the session's initial pane/window. what: - Add ForwardHandle.initial_pane / initial_window (session handles only): pane- and window-kind handles bound to the session, which resolves to its active pane/window -- so the default window can be renamed, decorated, or split instead of orphaned - Cover with a pure scope-guard test and a live test that renames + splits a new session's default window (one window, two panes, no orphan)
…tch fold
why: The lone-pane single-dispatch path marks the new pane with select-pane -m and
clears it with -M, which unconditionally drops whatever pane the user had marked
server-wide. A long-running consumer (an MCP against a user's live server) would
silently clobber their mark.
what:
- Add preserve_mark to run_resolving / run_resolving_async (threaded to drive as
allow_marked): when set, the resolver never takes the {marked} fold, so the
server-wide mark is left untouched (at the cost of the lone-pane optimization)
- Cover with a pure test (no select-pane emitted) and a live test asserting a
pre-existing user mark survives a preserve_mark resolve
… caveats why: The package exposes two forward-reference systems -- the linear dual-ref chain (PaneRef.split()...) and the multi-handle ForwardPlan -- with no guidance on which to reach for, and the merged-result / experimental caveats were only implicit. what: - Document the two forward shapes in the package docstring: a linear chain (single line of descent, folds to one dispatch) vs ForwardPlan (independent handles, minimum dispatches, ids captured), with a rule of thumb for choosing - Note the merged-result caveat (a `\;` sequence returns one result, so per-command output needs individual calls or run_deferred) alongside the experimental notice
why: Unknown tmux commands could previously enter native chains, hiding output-producing or blocking commands behind one merged subprocess result. what: - Treat unregistered commands as non-chainable - Add explicit specs for chain-layer commands used by typed plans - Raise clearer chainability errors during plan compilation
why: Command specs declared target scope, but typed raw builders did not reject known commands bound to the wrong object namespace. what: - Add a chain scope-validation helper and error - Validate known commands from pane/window/session raw builders - Cover wrong-scope command bindings with parametrized tests
why: MCP callers need per-command output from batched chain commands, which native semicolon dispatch cannot attribute after folding. what: - Add an experimental ControlModeRunner scoped to chain commands - Batch rendered command calls over one tmux -C client - Document and test per-command stdout and mid-batch error handling
why: Control-mode command output can legitimately begin with tmux control tokens. Treating those lines as notifications truncated stdout and could desynchronize a pending command block. what: - Preserve token-shaped output while a command block is pending - Close pending blocks only on the matching guard number - Add parser regressions for event-shaped and mismatched guard output
why: Forward decorations enter folded dispatches too, so they must obey the same chainability contract as other chain compilation paths. what: - Check ForwardHandle.do decorations with ensure_chainable - Cover output-command and unknown-command raw decoration bypasses
why: The marked-pane fast path must not change the order callers authored. Seed decorations before a split need to execute before the created pane exists. what: - Skip the marked fast path when decorations precede the lone pane create - Cover seed-decoration, split, and child-decoration dispatch ordering
why: A marked fast-path dispatch can fail after marking the newly created pane but before the final unmark command runs, leaving tmux server state changed after the resolver raises. what: - Issue a best-effort unmark dispatch after failed marked chains with a captured id - Preserve the original forward dispatch error - Add a live regression that verifies no pane remains marked after failure
551f688 to
b34c006
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
libtmux._experimental.chain, an experimental typed API for composing an ordered set of tmux commands that runs as one nativetmux ... \; ...invocation instead of one subprocess per command. Promotes the converged design from the Explore typed chainable tmux command APIs #684 research survey into real, documented, typed modules. Refs Typed command descriptors for native tmux command chains #683.ir.CommandChainand dispatching once.ForwardPlanresolves independent handles across all three scopes in the minimum number of dispatches, and folds a single pane handle back into one invocation via tmux's marked-pane register.show-option,capture-pane), and a deferred result handle won't hand back output until the chain has run.Session.kill_window/Pane.break_pane, now with regression tests.docs/experiment/tree with runnable doctest examples; the package is explicitly outside the versioning policy and not re-exported from top-levellibtmux.Changes by area
Experimental package —
src/libtmux/_experimental/chain/ir.pyCommandCall,CommandChain(argv/argvs/>>/run),CommandSpec,SlotRef(an unresolved "id of forward slot N" target), runner protocols,;-escaping. Rejects an empty-string target at construction; logs each one-shot dispatch (tmux_cmd/tmux_subcommand/tmux_exit_code).plan.pyPaneRef/WindowRef/SessionRefrows (concrete or forward) with bound.cmd/.window/.sessionnamespaces (each carrying araw(name, *args)escape hatch) and forward creation verbs (split,new_window,break_pane); pending metadata fails closed withForwardDataUnavailable. LazyPaneQuerywith.map(data-only rows) and.commands(one-or-more commands per row),CommandPlan(pureto_chain(snapshot), one-dispatchrun, andrun_deferredreturning resolved per-command handles)._resolve.pyForwardPlan/ForwardHandle(hand out independent handles across pane/window/session scopes),Resolved(slot→concrete-id bindings). A sans-I/O generator core yields a request and resumes via.send(result)— the same trampoline asyncio itself uses — driven identically by a sync (run_resolving) or async (run_resolving_async) driver. A plan-shape analyzer folds a lone pane handle into one{marked}invocation and otherwise resolves over N dispatches._async.pyCommandChain. Provides asyncrun/run_deferred._connection.pysnapshot_from_session(skips panes missing a window/session id),SessionPlanExecutor,AsyncSessionPlanExecutor.chain.pyCOMMAND_SPECS/is_chainable(static, wired intoto_chain→ChainabilityError) plusDeferredCommandResult/DeferredOutputUnavailable(dynamic — a two-state handle that resolves to the chain's merged result after dispatch).Forward references and multi-dispatch resolution
plan.py).PaneRef/WindowRef/SessionRefeach model one type in two states: a concrete row (typedpane_index/active/title) built via.concrete(...), or a forward placeholder whose creation verbs (split,new_window,break_pane, top-levelnew_session) return another ref. The propagated parent target keeps a forward child addressable (e.g. a forward pane still knows the concrete window it was split in). Pending metadata access raisesForwardDataUnavailablerather than silently returningNone(pending-attribute pattern)._resolve.py). Resolution is a pure generator that yields aDispatch/SnapshotRequestand resumes via.send(result); two ~10-line drivers (syncrunner.cmd,await runner.cmd) share it verbatim, so the N-dispatch logic is never duplicated and the generator suspends at ayieldbetween dispatches (never blocking the loop).\;sequence can only address the active (or single marked) object with no-t— a fixed argv token can't carry a freshly-created id, which escapes only as-P -Fstdout. So independent handles run one creation per dispatch (-P -F '#{pane_id}'to capture the real id), then fold downstream commands into one trailing\;chain with the captured ids substituted. The builder spans all three scopes:new_session→new_window(targets the session via the captured$N:) →split.{marked}dispatch. A plan-shape analyzer detects the one case that folds back to a single invocation: a lone pane creation. It captures the id, marks the new (active) pane withselect-pane -m, addresses it through tmux's server-wide{marked}register for every downstream command (verified against the tmux source to resolve within the same\;sequence), and clears the mark withselect-pane -M. Two or more independent handles, or a detached session creation, stay on the N-dispatch path automatically.Production fix —
src/libtmux/pane.py,src/libtmux/session.pySession.kill_windowandPane.break_paneto scope a bare window target to the owning session, so it no longer resolves against the server's current session. Covered by regression tests that fail onmaster.kill_windowdocuments the one case it cannot disambiguate (a window name containing:).Tooling —
pyproject.toml,src/libtmux/pytest_plugin.pypytest-asyncio(dev + testing); setasyncio_mode = "strict"withasyncio_default_fixture_loop_scope = "function", and mark the async test modules explicitly. Innerpytesterruns keep-p no:asyncio(they're isolated and don't read this project's loop-scope config).Docs —
docs/experiment/,docs/index.md,docs/project/public-api.mdexperiment/indexinto the toctree; marklibtmux._experimental.*not-public. (Follow-up: a_resolveautodoc page is not yet wired in.)Design decisions
ir.CommandChain, so the IR, expressions, async, and connection layers all share one dispatch path and one set of guarantees.PaneRefis concrete or pending, reusing its existing.cmd/.window/.sessionnamespaces in both states rather than introducing a parallel "deferred" object hierarchy; pending metadata fails closed.{marked}invocation, several independent handles in the minimum N — chosen automatically from the plan's shape, not by the caller._asyncreuses the syncto_chain; the async executor offloads the sync core viaasyncio.to_thread.to_chainrejects a non-chainable command withChainabilityError; rawCommandCall >> CommandCallcomposition is the explicit, unchecked escape hatch.run_deferreddispatches once and hands back one resolved handle per command (each reflecting the chain's single merged result, since a\;dispatch is not separable per command).PaneTarget/WindowTarget/SessionTarget;CommandCallrejects an empty-string target, the snapshot adapter skips rows missing an id, and creation verbs are guarded to their valid scope, so a command cannot silently mis-target.irfor the intermediate representation (mypymypyc/ir, polarsplans/ir),planfor the deferred form (datafusionLogicalPlan),chainfor fold-into-one-dispatch,_resolvefor the multi-dispatch core, and_connection+*Executorfor the live bridge (djangodb/backends, dagsterExecutor.execute(plan), stdlibconcurrent.futures.Executor).Test plan
uv run ruff check . --fix --show-fixes— cleanuv run ruff format .— cleanuv run mypy— clean (strict, oversrc+tests)uv run pytest --reruns 0— passes (incl. new doctests, async tests, live-tmux integration covering forward refs / N-dispatch / single-{marked}-dispatch, and the targeting regression tests)just build-docs— builds (experiment toctree + autodoc resolve)Scope note
Two commits are separable from the experimental feature, in case you'd prefer them in their own PRs:
Session/Panetargeting fix and its regression tests (a real shipped-behavior fix), andretry_untiltiming tests deterministic under load.Refs #683. Builds on the API survey in #684.