Skip to content

Add experimental typed chain API for tmux command sequences#685

Open
tony wants to merge 42 commits into
masterfrom
chainable-commands-experiment-00
Open

Add experimental typed chain API for tmux command sequences#685
tony wants to merge 42 commits into
masterfrom
chainable-commands-experiment-00

Conversation

@tony

@tony tony commented Jun 14, 2026

Copy link
Copy Markdown
Member

Summary

  • Add libtmux._experimental.chain, an experimental typed API for composing an ordered set of tmux commands that runs as one native tmux ... \; ... 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.
  • Five layers over one substrate — an argv intermediate representation, a target-safe deferred query/expression layer, an async facade, a live-tmux connection layer (sync + async), and a chainability contract — all compiling down to a single ir.CommandChain and dispatching once.
  • Forward references resolve to not-yet-created objects. A pane, window, or session reference can be forward-declared — split a pane, then address the pane that split will create before it exists. One object type carries both states (concrete row with typed metadata, or a pending placeholder that fails closed until tmux assigns the real id). A ForwardPlan resolves 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.
  • Chainability is enforced, not just declared. The compile path rejects a command whose output would be consumed mid-chain (show-option, capture-pane), and a deferred result handle won't hand back output until the chain has run.
  • A typed escape hatch lets callers issue any tmux command bound to a pane, window, or session target, so the layer isn't limited to its first-class builders.
  • Bundled a small, non-experimental targeting fix to Session.kill_window / Pane.break_pane, now with regression tests.
  • Document everything under a new docs/experiment/ tree with runnable doctest examples; the package is explicitly outside the versioning policy and not re-exported from top-level libtmux.

Changes by area

Experimental package — src/libtmux/_experimental/chain/

Module Role
ir.py Immutable argv IR: CommandCall, 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.py Typed targets, command values, dual-purpose PaneRef/WindowRef/SessionRef rows (concrete or forward) with bound .cmd/.window/.session namespaces (each carrying a raw(name, *args) escape hatch) and forward creation verbs (split, new_window, break_pane); pending metadata fails closed with ForwardDataUnavailable. Lazy PaneQuery with .map (data-only rows) and .commands (one-or-more commands per row), CommandPlan (pure to_chain(snapshot), one-dispatch run, and run_deferred returning resolved per-command handles).
_resolve.py Multi-dispatch resolution for independent forward handles: ForwardPlan/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.py Async facade mirroring the query/dispatch API; reuses the sync compile path so one expression still yields one CommandChain. Provides async run/run_deferred.
_connection.py Live-tmux bridge: snapshot_from_session (skips panes missing a window/session id), SessionPlanExecutor, AsyncSessionPlanExecutor.
chain.py Chainability contract: COMMAND_SPECS / is_chainable (static, wired into to_chainChainabilityError) plus DeferredCommandResult / DeferredOutputUnavailable (dynamic — a two-state handle that resolves to the chain's merged result after dispatch).

Forward references and multi-dispatch resolution

  • Dual-purpose refs (plan.py). PaneRef/WindowRef/SessionRef each model one type in two states: a concrete row (typed pane_index/active/title) built via .concrete(...), or a forward placeholder whose creation verbs (split, new_window, break_pane, top-level new_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 raises ForwardDataUnavailable rather than silently returning None (pending-attribute pattern).
  • Single sans-I/O core (_resolve.py). Resolution is a pure generator that yields a Dispatch/SnapshotRequest and resumes via .send(result); two ~10-line drivers (sync runner.cmd, await runner.cmd) share it verbatim, so the N-dispatch logic is never duplicated and the generator suspends at a yield between dispatches (never blocking the loop).
  • Independent handles → N dispatches. A native \; 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 -F stdout. 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_sessionnew_window (targets the session via the captured $N:) → split.
  • Single pane handle → one {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 with select-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 with select-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.py

  • Fix Session.kill_window and Pane.break_pane to 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 on master. kill_window documents the one case it cannot disambiguate (a window name containing :).

Tooling — pyproject.toml, src/libtmux/pytest_plugin.py

  • Add pytest-asyncio (dev + testing); set asyncio_mode = "strict" with asyncio_default_fixture_loop_scope = "function", and mark the async test modules explicitly. Inner pytester runs 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.md

  • Add an experiment landing page plus a per-module autodoc page; wire experiment/index into the toctree; mark libtmux._experimental.* not-public. (Follow-up: a _resolve autodoc page is not yet wired in.)

Design decisions

  • One substrate, layered API: everything compiles down to ir.CommandChain, so the IR, expressions, async, and connection layers all share one dispatch path and one set of guarantees.
  • Forward refs are one type, two states: a PaneRef is concrete or pending, reusing its existing .cmd/.window/.session namespaces in both states rather than introducing a parallel "deferred" object hierarchy; pending metadata fails closed.
  • The resolver picks the cheapest correct strategy: a single pane handle resolves in one {marked} invocation, several independent handles in the minimum N — chosen automatically from the plan's shape, not by the caller.
  • Sync and async share one core: the multi-dispatch resolver is a sans-I/O generator with two thin drivers, mirroring how _async reuses the sync to_chain; the async executor offloads the sync core via asyncio.to_thread.
  • Chainability is a real gate: to_chain rejects a non-chainable command with ChainabilityError; raw CommandCall >> CommandCall composition is the explicit, unchecked escape hatch.
  • Deferred results are two-state: a chained call has no result of its own until the chain runs; run_deferred dispatches 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).
  • Typed, fail-closed targets: row-bound commands carry PaneTarget / WindowTarget / SessionTarget; CommandCall rejects 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.
  • Module names follow common Python conventions: ir for the intermediate representation (mypy mypyc/ir, polars plans/ir), plan for the deferred form (datafusion LogicalPlan), chain for fold-into-one-dispatch, _resolve for the multi-dispatch core, and _connection + *Executor for the live bridge (django db/backends, dagster Executor.execute(plan), stdlib concurrent.futures.Executor).

Test plan

  • uv run ruff check . --fix --show-fixes — clean
  • uv run ruff format . — clean
  • uv run mypy — clean (strict, over src + 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:

  • the Session/Pane targeting fix and its regression tests (a real shipped-behavior fix), and
  • a test-only change making the retry_until timing tests deterministic under load.

Refs #683. Builds on the API survey in #684.

@codecov

codecov Bot commented Jun 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.03476% with 172 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.13%. Comparing base (42cf219) to head (b34c006).

Files with missing lines Patch % Lines
src/libtmux/_experimental/chain/control.py 66.05% 68 Missing and 24 partials ⚠️
src/libtmux/_experimental/chain/plan.py 89.94% 27 Missing and 10 partials ⚠️
src/libtmux/_experimental/chain/_resolve.py 91.03% 13 Missing and 12 partials ⚠️
src/libtmux/_experimental/chain/ir.py 90.00% 4 Missing and 3 partials ⚠️
src/libtmux/_experimental/chain/_async.py 90.47% 6 Missing ⚠️
src/libtmux/_experimental/chain/_connection.py 92.50% 2 Missing and 1 partial ⚠️
src/libtmux/pane.py 50.00% 0 Missing and 1 partial ⚠️
src/libtmux/session.py 75.00% 1 Missing ⚠️
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.
📢 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 tony force-pushed the chainable-commands-experiment-00 branch from ca3eded to b782ba7 Compare June 14, 2026 19:55
@tony tony changed the title Add experimental chainable-commands API for tmux command sequences Add experimental typed chain API for tmux command sequences Jun 14, 2026
@tony

tony commented Jun 14, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@tony tony force-pushed the chainable-commands-experiment-00 branch 5 times, most recently from 19deb3c to c913663 Compare June 20, 2026 11:19
@tony

tony commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@tony tony force-pushed the chainable-commands-experiment-00 branch from b1b7d3b to 4b2a499 Compare June 20, 2026 14:35
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
tony added 15 commits June 21, 2026 08:19
…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
tony added 26 commits June 21, 2026 08:19
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
@tony tony force-pushed the chainable-commands-experiment-00 branch from 551f688 to b34c006 Compare June 21, 2026 13:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant