Fluent forward-ref build tier: split-type pane handles + plan()#700
Merged
Conversation
why: a fluent build needs to target a pane an earlier op will create. Splitting the concrete PaneRef into two handle types makes reading an id off a not-yet-created pane a static type error under mypy and ty, not a runtime surprise. what: - Add _PaneRefBase (shared verbs: cmd, split, do), ForwardPaneRef (no snapshot reads), and PaneRef (concrete: pane_id/active/pane) - split() records the create and returns a forward handle; do() threads a recorder into a fluent chain - Widen BoundPaneCommands.target to Target so a forward SlotRef flows - Cover the split/do verbs and the concrete-vs-forward read surface
why: the split-type pane handles give forward-ref panes, but a user still needs a fluent entry to declare a whole session tree. plan() records a session/window/pane build into one LazyPlan that folds to a few tmux dispatches instead of one call per operation. what: - Add experimental/fluent.py: plan() -> PlanBuilder, SessionRef, WindowRef - Name-address sessions/windows (folds stay intact); reach a pane via the creator's captured first-pane SlotRef (a forward handle) - run()/arun() default to MarkedPlanner; preview() is a pure argv dry-run - Cover the build shape, an offline fold over ConcreteEngine, and a live build
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## engine-ops #700 +/- ##
==============================================
+ Coverage 74.41% 74.74% +0.33%
==============================================
Files 217 223 +6
Lines 12724 13257 +533
Branches 1678 1758 +80
==============================================
+ Hits 9468 9909 +441
- Misses 2599 2666 +67
- Partials 657 682 +25 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
why: analyze() and to_dict() cover the config->IR->dict round-trip, but there was no way back from a LIVE server to declarative IR. freeze() closes the loop -- reverse-analyze a running session into a Workspace you can rebuild elsewhere (tmuxp `freeze`). what: - Add experimental/workspace/freeze.py: freeze(ServerSnapshot) -> Workspace (pure), plus freeze_server/afreeze_server rebuilding the whole tree from one list-panes -a read - Export freeze/freeze_server/afreeze_server from the workspace package
why: building several near-identical sessions (per-app, per-env) meant
declaring each by hand and dispatching each separately. expand() fans one
workspace into named variants; WorkspaceSet folds N workspaces into one
rebased plan so the whole batch builds in a single folded run.
what:
- Add workspace/expand.py: expand(workspace, variants) renders $name /
${name} tokens, leaving unknown tokens intact
- Add workspace/sets.py: WorkspaceSet + compile_workspaces/build_workspaces
/abuild_workspaces, rebasing each workspace's SlotRefs and host steps by a
per-workspace offset into one LazyPlan
- Export expand, WorkspaceSet(+Result/Compiled), build/abuild/compile_workspaces
why: folding collapses N ops into a few dispatches, which hides per-op structure -- a caller can't see why the chain broke where it did, and had no way to observe a plan step by step as it ran. what: - LazyPlan.explain(planner): annotate each dispatch step with why it is a boundary (marked-fold/folded/created-id/capture/single) -- pure, no I/O - LazyPlan.astream(engine): async-generator twin of aexecute over the same drive core, yielding a StepDone per bound step and a terminal PlanDone; pull-based, so backpressure needs no buffer and the loop never blocks - Export StepExplanation, StepDone, PlanDone, PlanEvent
why: some builds need a real pause between dispatches -- a fixed delay, or waiting for a pane's shell prompt before sending the next command. A host step is a true blocker: it can't fold into a tmux chain, so the fold must break around it. what: - PlanBuilder.sleep(seconds): pause after the last recorded op - PlanBuilder.wait(pane): poll the pane's cursor until its prompt draws - run()/arun() bound the planner (BoundedPlanner) at each host boundary and run the recorded step from the on_step hook, sync and async
why: an agent composing a plan over MCP could preview argv and query a result schema, but couldn't see why a planner folds or breaks the plan into the dispatches it does -- the fold hides that structure. what: - plan_tools.explain_plan(plan, planner): per-step boundary reasons (marked-fold/folded/created-id/capture/single), pure over a serialized plan - Register a readonly explain_plan tool in the fastmcp adapter - Export explain_plan from the mcp package
why: rebuilding a workspace should reuse a session/window that already exists rather than error or duplicate it. Doing that in the pure, serializable plan model means the "does it exist?" branch has to live at execution time, not build time. what: - LazyPlan.ensure(index, probe): mark a create conditional -- the driver runs the probe (a read returning the object's capture format), and on success binds the slot to the found ids and skips the create - Reuse the create op's own build_result to parse the probe's ids, so a found object binds the same self/window/pane subrefs a created one would - Carry the probe through to_list/from_list so the conditional round-trips - Cover found-reuses / absent-creates and the serialization round-trip
why: give the fluent builder an idempotent session entry, so re-running a build reuses the live session instead of duplicating it -- the tmuxp load -a shape, in the forward-ref builder. what: - PlanBuilder.find_or_create_session(name): record the same create as new_session, made conditional via LazyPlan.ensure with a display-message probe that captures the session's id, first window, and first pane - Cover the recorded shape and a live idempotent double-build
why: the fluent live tests created sessions on the session-scoped shared server and never removed them. On tmux 3.3a/3.5 under xdist, that leaked session perturbed the phantom-reap tests, which assert an exact global session count -- CI failed there though the local gate (newer tmux) did not. what: - Kill the created session in a finally block (test_build_session_live, test_find_or_create_session_is_idempotent_live) via a _kill_named helper
why: the fluent forward-ref build tier had only method/module doctests; the experimental page covered the Core ops/engines/plans but not the declarative surface a user reaches for first. what: - Add a "Building fluently with plan()" section to the experimental page, proportional to the other sections: forward-ref handles, fold-unless- true-blocker, and find_or_create_session, with runnable ConcreteEngine doctests
why: the MCP plan tools rebuilt a serialized plan with add() + operation_from_dict, which drops the `ensure` probe that to_list emits -- so a find-or-create plan round-tripped through MCP silently became an unconditional create (a duplicate-session footgun). what: - Route _plan_from_dicts through LazyPlan.from_list, which carries the ensure probe, so a conditional create survives the MCP round-trip
why: the fake engine returned all three ids for any display-message, so
the ensure test asserted a pane binding while probing only #{session_id}
-- it passed on ids the probe never requested and would not catch a real
probe/capture format mismatch.
what:
- Make _FindEngine format-aware (return only the ids the probe requests)
- Probe the full capture format in test_ensure_probes_then_creates
- Add test_ensure_probe_must_match_create_capture: a session-only probe
binds no pane subref, guarding the format contract
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
A fluent, forward-ref build tier over the typed-operation spine — the headless best-of-breed DX for driving tmux through
libtmux.experimental. Stacked on #690 (base:engine-ops); strictly additive, mypy + ty strict, CI green across tmux 3.2a→master.What's delivered
The design's north star — four thin recorders over one executor — landed end-to-end.
Fluent forward-ref build tier (
fluent.py,query.py):plan()→PlanBuilder/SessionRef/WindowRef, over split-type handlesPaneRef(concrete, from a query row) vsForwardPaneRef(a pane an earlier op will create). Reading a pane id off a forward handle is a static type error under both mypy and ty — the attribute is structurally absent. Structural verbs (.split) return forward handles; leaf commands under.cmdrecord ops;.do()chains.Fold-unless-true-blocker:
.run()/.arun()default to a folding planner, collapsing a session build into a handful of;-chained +{marked}dispatches. The chain breaks only at a true blocker — a create whose id must round-trip, or a host boundary.PlanBuilder.sleep()/.wait()record host boundaries (BoundedPlanner);LazyPlan.explain()reports why each dispatch is a boundary.Conditional find-or-create:
LazyPlan.ensure()+PlanBuilder.find_or_create_session()— the create is made conditional at execution (probe, reuse-or-create), so the plan stays pure and serializable.Streaming:
LazyPlan.astream()— a pull-based async-generator twin ofaexecuteover the same sans-I/O core; backpressure is intrinsic, the loop never blocks between dispatches.Declarative fan-out + round-trip (
workspace/):freeze(live server → IR),expand(variant fan-out),WorkspaceSet(N workspaces → one folded plan).MCP frontend:
explain_plantool alongside the existing preview/execute/result-schema plan tools.Testing
83 new tests — NamedTuple-parametrized cases, a doctest on every method over
ConcreteEngine(no tmux), plus live builds against real tmux, and a narrative section on the {ref}experimentaldocs page with runnable examples. Full repo gate green (ruff, mypy, ty, pytest, build-docs) on each commit; CI green across 8 tmux versions.Known follow-ups (non-blocking)
_plan_from_dictsrather thanLazyPlan.from_list, so a serializedensure(find-or-create) is not carried over MCP yet.find_or_createis wired for sessions only;astreamis Core-tier and minimal; a mermaid plan-graph docs directive is still to come.