Skip to content

Typed operations, engines, workspace, query & MCP (#689)#690

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

Typed operations, engines, workspace, query & MCP (#689)#690
tony wants to merge 154 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 — an inert, statically-typed operation spine; a family of interchangeable engines (subprocess, concrete, control-mode, their async variants, and the native imsg easter-egg); lazy/async plans with ;-folding chainability; pure object-graph snapshots; a typed read surface; engine-typed facades; a declarative tmuxp-style workspace builder; a live pane query DSL; tmux 3.7 floating panes; an optional Model Context Protocol server; 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 checker-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, SplitWindowResult/CreateResult (captured ids), CapturePaneResult, ListPanes/Windows/Sessions/ClientsResult (snapshot-deriving rows), plus HasSessionResult, DisplayMessageResult, ShowOptionsResult, ShowBufferResult.
  • Closed Target sum, fail-closed OperationRegistry, stdlib serialization, and catalog() (registry-derived docs data).
  • LazyPlan (record → resolve SlotRef forward refs → execute). Folding is planner-based: pass a Planner — sequential (one tmux call per op), FoldingPlanner (;-chained tmux a ; b), or MarkedPlanner ({marked}-fold). All yield an identical PlanResult; only the dispatch count differs, and failure attribution matches tmux's cmdq_remove_group (first failed, rest skipped).
  • Read seam: ListPanes/ListWindows/ListSessions/ListClients 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.
  • 58 operations across client/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), report their tmux version for runtime gating, reap the throwaway session a bare tmux -C implies, and end event subscribers cleanly on engine death. The imsg engine speaks tmux's binary peer protocol directly (AF_UNIX + SCM_RIGHTS, PROTOCOL_VERSION 8) with a live parity test vs the subprocess engine.

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

Facades — libtmux.experimental.facade ("mode lives in the type"): the full eager / lazy / async × Server/Session/Window/Pane/Client matrix over the same ops; control mode is just an engine choice.

Declarative workspace — libtmux.experimental.workspace: a tmuxp-style Workspace declares a session as a tree of windows and panes and lowers to a Core LazyPlan. build/abuild fold the dispatches by default (a multi-pane window collapses to a handful of ;-chained + {marked} calls, identical result to an unfolded build); host steps (per-command sleeps, the opt-in wait_pane anti-race) stay hard fold boundaries. Threads env/shell/options through the IR, round-trips via to_dict, emits a build-event stream, supports floating panes (including cross-window overlays wired through a graphlib symbol table), and ships a load CLI for .tmuxp.yaml.

Live pane query — libtmux.experimental.query: panes() opens a lazy, chainable query over the panes a running server has (filter/order_by/limit/map, including filter(floating=True)), reading nothing until a terminal call. commands() records per-pane ops (send keys, resize, select, respawn, clear history, kill) into a LazyPlan that folds to one tmux dispatch. Resolves against a live engine or a plain sequence of snapshots (offline in tests).

Floating panes (tmux 3.7): available from the operation layer (a new-pane op with absolute geometry and zoom), the pane facades (new_pane()), the MCP surface (a curated tool), and workspace specs (Workspace float declarations).

MCP server — libtmux.experimental.mcp (optional libtmux[mcp] extra): a framework-agnostic tool projection (no hard MCP/pydantic dependency) plus a thin FastMCP adapter, launched as libtmux-engine-mcp. Three tiers: a curated vocabulary of intuitive verbs (~40) that mirror the ORM, a per-operation tool for every operation (hidden by default), and plan tools that preview or build a whole workspace. It is caller-aware (discovers the launching pane and refuses to kill or respawn its own pane/window/session), gates mutating and destructive tools behind a tiered LIBTMUX_SAFETY level until opted in, adds middleware (audit logging, read-only retry, tail-preserving output limits), serves a needle-free wait_for_output monitor and tmux:// resources, and ships recipe prompts.

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

  • A large experimental suite (~2,260 repo tests) + doctests. The pure spine/models/concrete/query tests need no tmux; the classic/control/async/imsg engines, the facades, workspace builds, and the MCP surface 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, ty, pytest, build-docs.

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; the MCP surface sits behind the optional mcp extra, with no MCP/pydantic dependency in the core projection.
  • 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.

@codecov

codecov Bot commented Jun 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 74.91737% with 1366 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.23%. Comparing base (4c6cfde) to head (e88e87b).

Files with missing lines Patch % Lines
scripts/mcp_swap.py 26.72% 314 Missing and 15 partials ⚠️
src/libtmux/experimental/engines/imsg/base.py 52.27% 165 Missing and 34 partials ⚠️
src/libtmux/experimental/engines/control_mode.py 65.70% 73 Missing and 34 partials ⚠️
...libtmux/experimental/engines/async_control_mode.py 76.31% 64 Missing and 26 partials ⚠️
src/libtmux/experimental/engines/imsg/v8.py 75.29% 46 Missing and 17 partials ⚠️
...rc/libtmux/experimental/mcp/vocabulary/_resolve.py 60.95% 46 Missing and 11 partials ⚠️
src/libtmux/experimental/mcp/middleware.py 73.80% 41 Missing and 14 partials ⚠️
docs/_ext/tmuxop.py 18.18% 36 Missing ⚠️
src/libtmux/experimental/mcp/vocabulary/pane.py 76.15% 32 Missing and 4 partials ⚠️
src/libtmux/experimental/mcp/__init__.py 47.61% 28 Missing and 5 partials ⚠️
... and 57 more
Additional details and impacted files
@@             Coverage Diff             @@
##           master     #690       +/-   ##
===========================================
+ Coverage   51.78%   75.23%   +23.44%     
===========================================
  Files          25      225      +200     
  Lines        3638    13638    +10000     
  Branches      733     1784     +1051     
===========================================
+ Hits         1884    10260     +8376     
- Misses       1448     2690     +1242     
- Partials      306      688      +382     

☔ 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 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 tony changed the title Typed operations and engines Typed operations & engines: spine, 6 engines, plans, models, facades (#689) Jun 21, 2026
tony added a commit that referenced this pull request Jun 21, 2026
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
@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

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

tony commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

Code review

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

🤖 Generated with Claude Code

1 similar comment
@tony

tony commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

Code review

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

🤖 Generated with Claude Code

tony added a commit that referenced this pull request Jun 27, 2026
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
tony added a commit that referenced this pull request Jun 28, 2026
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
tony added a commit that referenced this pull request Jul 4, 2026
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
tony added a commit that referenced this pull request Jul 4, 2026
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
@tony tony changed the title Typed operations & engines: spine, 6 engines, plans, models, facades (#689) Typed operations, engines, workspace, query & MCP (#689) Jul 4, 2026
@tony

tony commented Jul 5, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Fresh review of the current HEAD (4393b02a) — the ops / engines / facade / workspace / query / MCP surface was checked for bugs and AGENTS.md compliance and is clean. (The prior review comments predate ~12 days of commits.)

🤖 Generated with Claude Code

tony added 6 commits July 5, 2026 18:10
why: Operationalizes the typed-operations/engines architecture
(issues 688, 689) with the pure substrate that was absent from every
prototype branch: an inert, statically-typed operation value that
renders tmux commands, carries its result type, and serializes without
a live tmux server. Engines stay transport-agnostic over it. None of
this touches or changes existing public APIs.

what:
- Add libtmux.experimental.{ops,engines} packages (experimental, not
  under the versioning policy)
- ops: frozen Operation[ResultT] with class-level metadata as the
  single source of truth; pure render() with declarative version gating
  (LooseVersion); build_result() adapting raw output to typed results
- ops: typed Result base + raise_for_status() (CPython/requests
  precedent), SplitWindowResult/CapturePaneResult payloads
- ops: closed Target sum (PaneId/WindowId/SessionId/ClientName/NameRef/
  IndexRef/Special/SlotRef) with fail-closed validation
- ops: fail-closed OperationRegistry keyed by kind, with OpSpec views
  and predicate listing; stdlib dict serialization with round-trips
- ops: four seed operations (split-window, capture-pane, send-keys,
  select-layout) registered via @register
- engines: TmuxEngine/AsyncTmuxEngine protocols, CommandRequest/
  CommandResult, EngineSpec; run()/arun() execute bridge sharing one
  render/build path (sync vs await is the only divergence)
- tests: 111 pure, fixture-parametrizable unit tests + doctests, all
  runnable without a tmux server
why: Proves the operation/result contract is transport-agnostic -- the
same typed result whether produced by a real tmux subprocess or an
in-memory simulator -- and provides the offline engine that lets ops
doctests and tests run without a tmux server (issue 689 phases 2-3).

what:
- engines.subprocess: classic SubprocessEngine mirroring tmux_cmd
  (has-session stderr fold, backslashreplace, trailing-blank strip;
  tmux failure returned as data, only missing binary raises), with
  for_server() deriving -L/-S/-f/-2 flags from a live Server
- engines.concrete: deterministic in-memory engine (fabricated pane/
  window/session ids, canned capture lines) for tests and docs
- engines.registry: name-keyed engine registry (register/create/
  available), seeded with subprocess + concrete
- tests/experimental/contract: engine-agnostic operation contract run
  offline via concrete, plus classic-vs-concrete parity against a real
  tmux server (same result type + argv, payload may differ)
why: Completes the sync/async-symmetric execution story plus the
deferred-execution and documentation mechanisms from issue 689
(phase 5 + docs), still without touching any existing API.

what:
- engines.asyncio: real AsyncSubprocessEngine on
  create_subprocess_exec (terminates the child on cancellation; not a
  thread wrapper), mirroring the classic engine's output handling so it
  returns the same typed result
- ops.plan: LazyPlan records operations without touching tmux and
  resolves SlotRef forward refs at execute time via a sans-I/O
  generator; sync execute() and async aexecute() share one resolution
  core (run vs await arun is the only divergence); whole-plan
  serialization round-trips
- ops.catalog: registry-driven CatalogEntry list (scope, version
  gates, effects, safety, result type, summary) -- the single source a
  docs domain renders, so runtime and docs cannot drift
- tests: lazy resolution sync+async, plan serialization, catalog
  coverage, async-vs-sync classic parity against a real tmux server
why: Proves control mode is just another engine returning the same
typed result (issue 689 phase 4) -- an operation run over a persistent
tmux -C connection is indistinguishable, at the result level, from one
run via fork-per-call subprocess.

what:
- engines.control_mode: ControlModeEngine over one persistent tmux -C
  connection; run_batch pipelines commands and parses each command's
  %begin/%end/%error block into a CommandResult; selectors-based
  nonblocking reads with timeout; startup-ACK discard; lifecycle via
  close()/context manager (lock-guarded teardown)
- engines.control_mode: I/O-free ControlModeParser, unit-testable
  without tmux, adapted from the chain runner + protocol-engines parser
- register control_mode in the engine registry and export it
- tests: pure parser tests + real-tmux contract (split creates a real
  pane, batched commands, control-vs-concrete parity)
why: Demonstrates the "mode lives in the type" model from issue 689 --
EagerPane.split() returns a live EagerPane while LazyPane.split() returns
a deferred LazyPane, each a single statically-known return type, both
backed by the same SplitWindow operation. One Pane class with a
runtime-bound engine could not type these return values distinctly.

what:
- facade.pane.EagerPane: executes immediately, returns live handles
  (split -> EagerPane), typed results for capture/send_keys
- facade.pane.LazyPane: records into a LazyPlan, returns deferred handles
  (split -> LazyPane bound to the new pane's SlotRef), chainable
- seed of the wider Server/Session/Window/Pane/Client x mode matrix
- tests: eager live handles, lazy deferral + forward-ref resolution,
  and same-operation-backs-both-facades parity
why: Closes the two async gaps from issue 689: control mode and concrete
had no async sibling. The async control engine is the one async engine
that earns its place -- it adds an event stream subprocess cannot -- and
prior libtmux/mux control-mode work (surfaced across agent histories via
agentgrep, plus the asyncio-2 branches) shaped its correlation design.

what:
- engines.async_control_mode: AsyncControlModeEngine over a persistent
  tmux -C (create_subprocess_exec + one reader task). FIFO future
  correlation with skip-when-empty so unsolicited %begin blocks (hook-
  triggered commands and the startup ACK) never desync results; the
  startup ACK is consumed synchronously in start() to close the
  correlation race our whole-block parser would otherwise have. DEAD
  state fails pending commands on reader EOF/error. Cancellation via
  asyncio.wait_for (3.10 floor: no asyncio.timeout/TaskGroup). Bounded
  subscribe() notification stream with drop-counting. for_server() helper
- engines.control_mode: ControlModeParser now surfaces bare %-notification
  lines via notifications() (additive; the sync engine ignores them)
- engines.concrete: AsyncConcreteEngine sibling over shared simulation;
  removes the async test shim
- ControlNotification typed event value
- tests: parser notification/drain; async control vs real tmux (split,
  pipelined batch, concrete parity, live event stream, lifecycle)
tony added 29 commits July 5, 2026 18:11
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
why: The engine-ops streaming surface had two doc gaps a reader
could trip on: abuild's on_event docstring invited a slow async
sink (a fastmcp Context push) that would head-of-line-block the
fold, and astream reads like a drop-in build though it skips host
steps and can race send-keys against an unready shell.

what:
- runner/sets abuild(on_event=): note it is awaited inline on the
  dispatch coroutine (keep it fast and non-reentrant); a slow sink
  owns its own buffer and drains independently (the MCP _EventRing
  pattern); the Awaitable[None] type cannot enforce this
- plan.astream: it observes dispatch only and skips host steps
  (sleep/before_script/wait_pane); use Workspace.abuild for a full
  build
- comment the on_step closure so a future buffered mode uses
  bounded-queue backpressure, never drop -- BuildEvents carry
  unique tmux ids that must arrive exactly once
why: A window/session option or environment value typed as an int
in YAML (e.g. `main-pane-height: 35`) reached the ;-chain renderer
as an int and raised AttributeError in _escape_arg. The IR declares
these Mapping[str, str] and tmux wants string args (classic libtmux
str()s every arg), so analyze must coerce at ingest.

what:
- Add _str_map(): stringify the keys and values of an options or
  environment mapping
- Apply it to all 7 ingest sites (session/window/pane
  options/options_after/global_options/environment)
- Add parametrized coercion tests + a live folded-build regression
why: NewPane defaults to detached (-d), which does not focus the new
pane. The {marked} fold's untargeted `select-pane -m` then marks the
old active pane, so a NewPane >> SendKeys(slot) plan under the default
MarkedPlanner sent keys into the wrong pane. SplitWindow is unaffected
because it focuses its new pane.

what:
- _marked_decorates skips creators with detach=True; they dispatch
  alone so their decorates bind the captured pane id via the slot
- Add a parametrized regression: split and focused NewPane still mark,
  detached NewPane does not
why: subscribe() gated only on _closing, but a dead reader sets _dead,
not _closing. A subscribe() after the engine died -- e.g. the output
monitor or pull ring reconnecting once tmux closed stdout -- registered
a fresh queue and blocked forever on queue.get(), the exact hang the
close-subscribers work removed for the aclose() path.

what:
- Gate subscribe() on `_closing or _dead is not None`
- Add a regression mirroring the after-close test but marking dead first
why: AGENTS.md's namespace-import rule exempts only dataclass/field
from the dataclasses module; replace is a plain function and should be
called as dataclasses.replace, matching the sibling snapshots.py.

what:
- import dataclasses; use dataclasses.replace in filter/order_by/limit
why: AGENTS.md's shipped-vs-branch rule keeps never-shipped,
branch-internal lineage out of docstrings; the "chainable-commands"
and "libtmux-protocol-engines" prototypes are sibling branches the
reader never experienced. Describe only current state.

what:
- Trim prototype references in ops/plan.py, ops/_chain.py,
  engines/base.py, engines/registry.py
why: window_shell (a window's custom first-pane shell) rode new-window
for windows 2..N, but window 0 is created by new-session, which had no
shell parameter -- so the first window's first pane silently booted the
default shell instead of the configured one.

what:
- Add window_shell to NewSession (a trailing shell-command, mirroring
  how NewWindow appends it for later windows)
- compiler passes ws.windows[0].window_shell to NewSession
- Test that window 0's window_shell rides new-session
why: The wait_pane readiness guard skipped the wait when a first pane
set `shell=`, but the first pane's own shell is never applied (only
window_shell reaches its creator). So the pane launched the default
shell and send-keys raced the prompt -- the exact race wait_pane
exists to prevent.

what:
- _emit_pane_commands takes the shell the creator actually applies
  instead of re-deriving pane.shell|window_shell: first pane ->
  window_shell, split -> pane.shell|window_shell, float -> pane.shell
- Parametrized regression: a first-pane pane.shell still waits, a
  first-pane window_shell skips
why: AGENTS.md requires working doctests on all methods; the async
twins arun (fluent) and aexecute (plan) had none, unlike their
documented siblings run and astream.

what:
- Add runnable async doctests (asyncio.run over AsyncConcreteEngine)
why: EagerWindow and LazyWindow expose select_layout, but AsyncWindow
lacked it -- `await window.select_layout(...)` raised AttributeError,
breaking eager/lazy/async parity for the layout op.

what:
- Add AsyncWindow.select_layout mirroring the eager/lazy method
- Assert it in the async facade parity test
why: ImsgEngine executes against a live tmux server but did not
implement tmux_version(), unlike every other real-server engine. So
resolve_engine_version() fell back to "assume latest", and a
version-gated op run over imsg against an older server rendered a
too-new flag that tmux rejects -- diverging from the subprocess and
control-mode engines on the same server.

what:
- Add ImsgEngine.tmux_version(): memoized tmux -V via the resolved
  binary, mirroring SubprocessEngine
- Test it satisfies SupportsTmuxVersion and reports/memoizes a version
why: The run_and_wait / diagnose_failing_pane / interrupt_gracefully
prompts steer the agent to call wait_for_output, but that tool is
registered only on a streaming control-mode async server. On the sync
server (and non-streaming async) the agent was told to call a tool that
does not exist -> tool-not-found.

what:
- register_prompts(events_enabled=): register the wait_for_output
  prompts only when events streaming is enabled; build_dev_workspace
  always registers
- build_async_server passes its computed events_enabled; the sync
  build_server leaves it False
- Parametrized gate regression + a sync-server integration check
why: The audit summary redacted a sensitive arg (keys/text/command/...)
only when it was a str or dict; a non-conformant client sending e.g.
command=["secret"] fell through to the raw-passthrough branch and logged
the payload verbatim into the INFO audit record -- contradicting the
documented "payload-bearing arguments never reach the log raw" promise.
Schema validation rejects such calls, but _summarize_args runs first.

what:
- Shape-redact any other-typed sensitive value via _redacted_value_shape
- Doctest that a list-valued sensitive arg is redacted
why: AGENTS.md shipped-vs-branch keeps never-shipped, branch-internal
narrative out of docstrings; round 1 missed a few.

what:
- control_mode.py: drop the "chainable-commands runner /
  libtmux-protocol-engines parser" lineage sentence
- concrete.py: drop "preserving the historical behaviour" (an
  intermediate in-branch state)
- new_pane.py: drop the "first operation gated by min_version" ordinal;
  keep only the current-state VersionUnsupported clause
why: register_plan_tools' docstring said build_workspace is registered
only on the synchronous server, but the async branch registers it too
(dispatching to abuild_workspace) -- misleading a maintainer about tool
exposure.

what:
- State it registers on both servers, per-engine dispatch
why: The session start_directory lands on window 0's first pane, but
confirm() read the window's active_pane. When a non-first pane is
focused (or a split leaves focus off pane 0) with a different cwd,
confirm falsely reported "first pane cwd != declared".

what:
- Check the first window's first pane (index 0), not active_pane
- Live regression: a focused non-first pane at a different cwd still
  confirms ok
why: A tmux restart or socket blip killed the async control-mode
engine permanently -- the reader EOF'd, the engine went dead, and
subscribers had to be torn down. A supervisor that self-heals keeps
the event stream alive across the gap and lets subscribe() rely on
_closing alone instead of a sticky _dead gate.

what:
- Replace the one-shot start()/reader with a supervisor that spawns
  tmux -C, replays desired subscriptions/attach, runs the reader
  inline (one at a time), and reconnects with deterministic jittered
  backoff when the reader returns on EOF.
- Add add_subscription()/set_attach_targets() declarative state,
  replayed on every (re)connect; bump _generation per connect and
  reset parser/pending/attach before the new proc's bytes flow.
- Surface first-connect failure to every start() caller; reset the
  backoff on a healthy connect.
- Gate subscribe() on _closing only: a reconnecting (_dead) engine
  keeps its subscriber so the post-reconnect reader feeds it.
- Cover supervisor reconnect, attach replay, and attach reset;
  drop the now-obsolete _dead-gate sentinel test.
why: The engine's supervisor now reconnects on a tmux restart or
socket blip, but _broadcast_stream_end ends the subscribe stream on
the disconnect, so the pull ring's drain task completes. A bare
``is None`` guard never re-subscribed, silently freezing the cursor
after a reconnect.

what:
- Restart the drain in _EventRing._ensure_started when the prior task
  has completed (not just when unset).
- Clear a prior drain error at the start of _drain (a fresh attempt)
  rather than on restart, so a still-unread failure is not wiped
  before since() surfaces it -- a persistently dead stream must not
  read as empty-but-healthy.
- Cover restart-only-if-done and persistent-error surfacing with
  parametrized tests.
why: "Facade" is not idiomatic Python for these engine-typed classes.
They are the domain-shaped tmux nouns -- server/session/window/pane/
client -- so the package reads as libtmux.experimental.objects, and
an individual engine-bound class is a "wrapper" in prose. Retires the
facade/handle vocabulary.

what:
- Rename src/libtmux/experimental/facade -> objects and its tests,
  keeping the Eager/Lazy/Async class names unchanged.
- Reword package docstrings from "facade"/"handle" to "object".
- Update the three stray package references (ops.results,
  ops.new_pane, docs/experimental) to "wrapper".
why: A proc that connects then instantly dies still consumes its
startup ACK, so the supervisor counted it as a healthy connection and
reset the reconnect backoff to zero every loop. A persistently
flapping proc (fatal config, permission, or resource error) then
fork-stormed tmux at ~10 Hz instead of backing off.

what:
- Reset the backoff only for a connection that survived a minimum
  lifetime; a connect-then-immediately-die counts as a failed attempt,
  so the backoff escalates.
- Add _HEALTHY_CONNECTION_SECONDS and a regression test asserting a
  connect-then-die loop escalates instead of pinning at _backoff(0).
why: A reader that returned via an exception (not a clean EOF) leaves
the tmux -C process alive. The supervisor then reconnected and _spawn
overwrote _proc with the new process without terminating the old one,
orphaning a control client on every such reconnect.

what:
- Terminate a still-alive prior proc at the top of _spawn before
  replacing it; a clean-EOF proc has already exited (no-op).
- Add a regression test that _spawn terminates a live prior proc.
why: Quantify the experimental workspace builder's build cost across
engines and answer "which engine, how fast" reproducibly, without ever
touching the developer's live tmux server.

what:
- Add scripts/bench_engines.py, a self-contained PEP 723 script (uv
  run) that sweeps scenarios x engines x wait-modes and reports
  min/avg/median/p90/p95/p99/max as rich tables + JSON.
- Engines: classic; the builder on subprocess/control_mode/imsg/
  concrete; and a pipelined prototype that batches independent creates
  via run_batch.
- Sandboxed: per-run isolated sockets under a throwaway dir, TMUX
  unset, atexit teardown + orphan backstop -- the default server is
  never contacted.
- Subcommands: run (in-process grid), cell (one build, for hyperfine),
  profile (cProfile).
why: Version the measured build cost across engines (and the pipelining
prototype) alongside the harness so the numbers are reproducible and
reviewable.

what:
- Add scripts/bench-results/ with RESULTS.md (analysis: the engine
  grid, with/without shell-readiness wait, profile, and the ~79x
  reconciliation) plus grid.json / wait.json (raw per-run data from
  scripts/bench_engines.py).
why: The committed benchmark script failed a repo-wide `mypy .` sweep
with 5 errors. Four stem from the `typer` PEP 723 inline dependency,
which the repo's mypy environment cannot resolve (cascading into
untyped-decorator errors); one is a genuine Server|None narrowing gap.
The configured scope (files = [src, tests]) hides them, but a broad
`mypy .` surfaces them.

what:
- Add a file-level disable-error-code for the two typer environment
  artifacts (import-not-found, untyped-decorator), keeping every other
  check strict.
- Accept `Server | None` in ImsgForServer and assert non-None so the
  make_engine callable type narrows correctly.
why: "Concrete" named the in-memory simulator engine, but every real
engine (subprocess, control_mode, imsg) is equally a concrete
implementation. Across the Python/Rust ecosystem "Concrete*" is a
test-only convention for "a minimal instantiable ABC subclass", the
opposite of this docs/doctest workhorse, and the word already carries
its id/type sense throughout ops/query/objects. "Mock" names the engine
by its role: the no-tmux, in-memory stand-in.

what:
- Rename ConcreteEngine -> MockEngine and AsyncConcreteEngine ->
  AsyncMockEngine; module concrete.py -> mock.py
- EngineKind.CONCRETE ("concrete") -> EngineKind.MOCK ("mock");
  EngineSpec.concrete() -> EngineSpec.mock(); registry key becomes
  "mock" (available_engines() re-sorts accordingly)
- Rename the benchmark engine key/label; update RESULTS.md and
  grid.json to match
- Keep docstrings describing the in-memory simulation so the name's
  test-double flavor does not mislead doctest readers
- Leave "concrete" untouched where it means a concrete id/target/pane
  handle (ops, query, objects, workspace)
why: Rebasing onto master pulled in the gp-sphinx 0.0.1a33 docs-dep
bump; the sibling sphinx-autodoc-* pins in uv.lock had to move to match
or `uv lock --locked` fails.

what:
- Regenerate uv.lock: sphinx-autodoc-api-style and
  sphinx-autodoc-pytest-fixtures 0.0.1a32 -> 0.0.1a33
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