Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
207 commits
Select commit Hold shift + click to select a range
dca94e9
Ops(feat): Add typed operation and engine spine
tony Jun 21, 2026
550a4e2
Ops(feat): Add classic + concrete engines and contract suite
tony Jun 21, 2026
21a44a8
Ops(feat): Add async engine, lazy plans, and op catalog
tony Jun 21, 2026
0022024
Ops(feat): Add persistent control-mode engine
tony Jun 21, 2026
dd70485
Ops(feat): Add eager + lazy pane facades over the spine
tony Jun 21, 2026
a70cbe1
Ops(feat): Add async control-mode + concrete engines
tony Jun 21, 2026
3430133
Ops(feat): Add AckResult for no-output operations
tony Jun 21, 2026
7e37728
Models(feat): Add pure object-graph snapshots
tony Jun 21, 2026
4065280
Ops(feat): Add read-seam list operations
tony Jun 21, 2026
fa2cc9a
docs(experimental): Add tmuxop-catalog directive
tony Jun 21, 2026
456d1ed
ControlMode(fix): Consume startup ACK, drain unsolicited blocks
tony Jun 21, 2026
5ed2512
Ops(feat): Add lazy-plan chainability (>> and ; folding)
tony Jun 21, 2026
c988038
Facade(feat): Add Server/Session/Window facades + creation ops
tony Jun 21, 2026
09fcc96
Imsg(feat): Add native imsg engine + live parity test
tony Jun 21, 2026
57e4bd9
Facade(feat): Complete the facade matrix (Server/Session/Client)
tony Jun 21, 2026
5a5aea7
chore(deps[dev]): Add ty type checker + config
tony Jun 21, 2026
262a2ba
Ops(feat): Add pluggable planners + {marked} fold
tony Jun 21, 2026
b6d2589
Ops(feat): Add non-list read operations
tony Jun 21, 2026
a4e0c61
Ops(feat): Add pane mutation/creation operations
tony Jun 21, 2026
d2994d4
Ops(feat): Add window mutation/navigation operations
tony Jun 21, 2026
5fbb93b
Ops(feat): Add server/option/environment operations
tony Jun 21, 2026
e4c7905
Ops(feat): Add paste-buffer operations
tony Jun 21, 2026
99ff70a
docs(experimental): Document engines and lazy plans
tony Jun 21, 2026
ad0a829
docs(CHANGES): Note experimental operations and engines
tony Jun 21, 2026
9d54121
Ops(fix): Correct move-window -k and paste-buffer -r
tony Jun 21, 2026
b703d7b
Ops(fix): Resolve SlotRef src_target in lazy plans
tony Jun 21, 2026
2f289bf
Ops(fix): Skip all decorates when a marked-fold create fails
tony Jun 21, 2026
9f92d51
Ops(fix): Mark save-buffer readonly to match its effects
tony Jun 21, 2026
b221781
Ops(docs): Fix PipePane parameter name in docstring
tony Jun 21, 2026
8c55d4c
Ops(fix): Log imsg argv as a scalar tmux_cmd field
tony Jun 21, 2026
3751865
Ops(docs): Add doctests to the planner plan() methods
tony Jun 21, 2026
bb6df2b
Ops(fix): Resolve decorate src_target in {marked} folds
tony Jun 21, 2026
2be8510
Ops(fix): Keep ; a bare separator in control-mode engines
tony Jun 21, 2026
1838303
Ops(fix): Treat a blank captured id as no id in marked folds
tony Jun 21, 2026
d62aee4
Ops(fix): Complete a marked fold whose creator does not capture
tony Jun 21, 2026
382e9f2
Ops(fix): Drop create stdout when attributing marked decorates
tony Jun 21, 2026
0df4519
Ops(fix): Target the concrete pane in marked decorate results
tony Jun 21, 2026
7dffafd
Ops(fix): Decode SubprocessEngine output as UTF-8
tony Jun 21, 2026
dce1f82
Models(refactor): Use namespaced dataclasses.replace in snapshots
tony Jun 21, 2026
97a718f
Ops(docs): Fix PipePane -o flag description
tony Jun 21, 2026
1e95188
Ops(fix): Mark save-buffer mutating (it writes a file)
tony Jun 21, 2026
c21433e
Ops(fix): Correlate control-mode blocks per command and by flags
tony Jun 21, 2026
c72ed38
Ops(fix): Clear pending futures on async control-mode write failure
tony Jun 21, 2026
008963a
Ops(fix): Suppress ProcessLookupError on async cancel terminate
tony Jun 21, 2026
d842a56
Engines(fix): Remove the unreachable asyncio engine kind
tony Jun 21, 2026
35a68a2
Ops(fix): Normalize tmux master version in operation gates
tony Jun 21, 2026
e12db8b
Ops(fix): Reject SendKeys literal+enter combination
tony Jun 21, 2026
62fb406
Ops(fix): Keep all lines of a display-message result
tony Jun 21, 2026
79d78f3
Ops(fix): Centralize the has-session stderr->stdout fold in the op
tony Jun 21, 2026
0b7a5e2
Engines(docs): Note ConcreteEngine query-simulation limits
tony Jun 22, 2026
46b0fe5
Engines(fix): Avoid imsg UnboundLocalError on socket() failure
tony Jun 22, 2026
2e63c06
Engines(fix): Return imsg exit result on clean close after MSG_EXIT
tony Jun 22, 2026
ff93086
Engines(fix): Close imsg dup'd fds if the identify send never happens
tony Jun 22, 2026
6d2c9e6
Workspace(fix[env]): Inherit window env in splits
tony Jun 28, 2026
2f38cca
docs(CHANGES) Note on updates
tony Jul 4, 2026
0763aa5
Mcp(refactor): Drop dead is_conservative_caller
tony Jul 4, 2026
607cb48
Engines(fix): Report tmux version over control mode
tony Jul 4, 2026
f1d21c1
Ops(docs): Fix stale fold comment in chain test
tony Jul 4, 2026
1d664bb
Models(feat): Add PaneSnapshot.floating flag
tony Jul 4, 2026
29466a8
Engines(fix): Reap control-mode phantom sessions
tony Jul 4, 2026
9d92226
Engines(fix): Close subscribers on engine death
tony Jul 4, 2026
d5de992
Query(feat): Split-type forward-ref pane handles
tony Jul 4, 2026
53e93d8
Fluent(feat): Add plan() forward-ref build tier
tony Jul 4, 2026
2eaff75
Workspace(feat): Add freeze (live server to IR)
tony Jul 4, 2026
a2d4883
Workspace(feat): Add variant expand + workspace sets
tony Jul 4, 2026
5f35562
Ops(feat): Add plan explain() and astream()
tony Jul 4, 2026
60d6493
Fluent(feat): Add sleep/wait host boundaries
tony Jul 4, 2026
cc6f1d7
Mcp(feat): Add explain_plan tool
tony Jul 4, 2026
e31e5cd
Ops(feat): Add conditional find-or-create via ensure()
tony Jul 4, 2026
d7d782b
Fluent(feat): Add find_or_create_session
tony Jul 4, 2026
bc7c8f5
Test(fix): Clean up sessions in fluent live tests
tony Jul 4, 2026
6d70ab4
docs(experimental): Document the fluent plan() builder
tony Jul 4, 2026
8837c3e
Mcp(fix): Preserve ensure across plan-tool serialization
tony Jul 4, 2026
427303c
Ops(test): Make the ensure probe test format-honest
tony Jul 4, 2026
07a5de5
Workspace(docs): on_event and astream caveats
tony Jul 4, 2026
81082e4
Workspace(fix): Stringify option/env values
tony Jul 5, 2026
9958191
Ops(fix): Skip marked fold for detached creators
tony Jul 5, 2026
e9dfc79
Engines(fix): End subscribe() after engine death
tony Jul 5, 2026
6c86963
Query(refactor): Namespace dataclasses.replace
tony Jul 5, 2026
34e97f0
docs(experimental): Drop prototype lineage
tony Jul 5, 2026
2286a9f
Imsg(fix): Send identify LONGFLAGS frame once
tony Jun 22, 2026
b49eff3
Engines(test): Widen async control-mode coverage
tony Jun 22, 2026
fe3695b
Ops(feat[send_keys]): Add suppress_history flag
tony Jun 22, 2026
462a659
Ops(feat): Capture implicit child ids on create
tony Jun 22, 2026
6f68a12
Workspace(feat): Declarative WorkspaceBuilder on the typed-ops Core
tony Jun 22, 2026
11fe141
Ops(feat): Serialize bindings + add plan preview
tony Jun 22, 2026
58ac9f1
Mcp(feat): Add framework-agnostic tool projection
tony Jun 23, 2026
d3061c5
Mcp(feat): Add optional fastmcp adapter (libtmux[mcp])
tony Jun 23, 2026
d44a449
Mcp(feat): Per-op + plan tools and a stdio server
tony Jun 23, 2026
14da077
Mcp(feat): Port mcp_swap config-swap dev script
tony Jun 23, 2026
1c45d9f
Tests(chore): Run the mcp adapter suite in the gate
tony Jun 23, 2026
de52c16
Workspace(test): Cover analyzer, compiler, runner
tony Jun 23, 2026
0102f9a
Mcp(feat): Add grok + agy CLIs to mcp_swap
tony Jun 23, 2026
a498aab
Mcp(feat): Caller-aware async tmux tool surface
tony Jun 23, 2026
fed9c48
Mcp(feat): Caller discovery + self-kill guards
tony Jun 23, 2026
a50e850
Mcp(fix): Harden self-kill guards + socket scoping
tony Jun 23, 2026
1c2df69
Mcp(feat): Needle-free pane-output monitor
tony Jun 23, 2026
1e43383
Mcp(feat): Make wait_for_output discoverable
tony Jun 24, 2026
af2bb05
Mcp(fix): Close self-kill guard deferrals
tony Jun 24, 2026
df7169f
Mcp(fix): Harden wait_for_output monitor
tony Jun 24, 2026
11fe5ab
Workspace(feat): Thread env/shell/options through declarative tier
tony Jun 27, 2026
9b86015
Workspace(feat): Add per-command Command (enter + sleeps)
tony Jun 27, 2026
f2c0d5d
Ops(feat): Add ForwardCaptureError + ShowOptionsResult.get_int
tony Jun 27, 2026
52bc577
Workspace(feat): Add Workspace.to_dict + read suppress_history
tony Jun 27, 2026
a095428
Workspace(feat): Add BuildEvent stream + on_event observer
tony Jun 27, 2026
38b28a8
Workspace(feat): Add opt-in wait_pane readiness (anti-race)
tony Jun 27, 2026
3891911
Workspace(feat): Honor explicit Window.window_index placement
tony Jun 27, 2026
588c4cd
Mcp(feat): Expose build_workspace on the async server
tony Jun 27, 2026
ea17715
Mcp(feat[safety]): Add tier constants, resolver, ExpectedToolError
tony Jun 27, 2026
0f9c9c4
Mcp(feat[middleware]): Port error-result + tail-preserving limiter
tony Jun 27, 2026
cd97409
Mcp(feat[middleware]): Port safety, audit, readonly-retry middleware
tony Jun 27, 2026
6efdac7
Mcp(feat[safety]): Wire safety gate + middleware into the builders
tony Jun 27, 2026
c27ffd3
Mcp(feat[prompts]): Add recipe prompts in engine-ops vocabulary
tony Jun 27, 2026
9a2e872
Mcp(feat[resources]): Add tmux:// hierarchy resources over the engine
tony Jun 27, 2026
2debb9c
Mcp(feat[lifespan]): Add engine-probe lifespan (async server)
tony Jun 27, 2026
35601c3
Workspace(feat[cli]): Add `load` command for .tmuxp.yaml files
tony Jun 27, 2026
7944fd9
Workspace(feat): blank/pane empty-pane parity + cli --dry-run
tony Jun 27, 2026
ae0e337
Workspace(fix): First window's start_directory for its first pane
tony Jun 27, 2026
98c567a
Ops(feat): Per-step host hook and bounded planner
tony Jun 27, 2026
e0da7b3
Workspace(feat): Fold build dispatches by default
tony Jun 27, 2026
0d90820
Workspace(feat): Fold --dry-run output
tony Jun 27, 2026
36bbc3a
Ops(feat[new_pane]): Add floating pane operation
tony Jun 27, 2026
451dd98
Ops(fix[break_pane]): Work around tmux 3.7 break-pane crash
tony Jun 27, 2026
a326ac1
Engines(feat): Resolve engine tmux version for gating
tony Jun 27, 2026
006f17a
Workspace(feat[ir]): Add floating-pane declarations
tony Jun 27, 2026
cd4c847
Workspace(feat[compiler]): Build floating panes from specs
tony Jun 27, 2026
a286fe5
Workspace(feat[compiler]): Cross-window floats via symbol table
tony Jun 27, 2026
2978db4
Query(feat): Add snapshot-backed live pane query
tony Jun 27, 2026
3ac7e48
Query(feat): Add per-pane command building that folds
tony Jun 27, 2026
476c95a
Facade(feat[pane]): Add new_pane floating parity
tony Jun 27, 2026
8d94ad8
Mcp(feat[pane]): Add curated new_pane floating tool
tony Jun 27, 2026
b236762
Mcp(feat[registry]): Surface whole-op min_version in schema
tony Jun 27, 2026
78d2cee
Mcp(fix[prompts]): wait_for_output uses target=
tony Jun 28, 2026
a073250
Workspace(fix[analyze]): Reject bad command items
tony Jun 28, 2026
650aba7
Workspace(fix): Honor window_shell on window 0
tony Jul 5, 2026
b3c2584
Workspace(fix): wait_pane uses effective shell
tony Jul 5, 2026
e46c58d
docs(experimental): Add arun/aexecute doctests
tony Jul 5, 2026
d73c6da
Facade(fix): Add AsyncWindow.select_layout
tony Jul 5, 2026
9b5086d
Engines(fix): ImsgEngine reports tmux version
tony Jul 5, 2026
a5a8be1
Mcp(fix): Gate wait_for_output prompts on events
tony Jul 5, 2026
d0f5352
Mcp(fix): Redact non-str sensitive audit args
tony Jul 5, 2026
01cee08
docs(experimental): Trim more branch narrative
tony Jul 5, 2026
909e341
Mcp(docs): Fix build_workspace registration claim
tony Jul 5, 2026
c370982
Workspace(fix): Confirm cwd on the first pane
tony Jul 5, 2026
9d5afa4
Engines(feat[async_control_mode]): Add supervised reconnect
tony Jul 5, 2026
9d3d663
Mcp(fix[events]): Restart the event drain after reconnect
tony Jul 5, 2026
de5b233
Objects(refactor): Rename facade package to objects
tony Jul 5, 2026
e6e14ef
Engines(fix[async_control_mode]): Escalate backoff on connect-then-die
tony Jul 5, 2026
0250a43
Engines(fix[async_control_mode]): Terminate the prior proc on reconnect
tony Jul 5, 2026
9a3a316
Scripts(feat[bench]): Add hermetic engine build-benchmark grid
tony Jul 5, 2026
123ee33
Scripts(docs[bench]): Add engine benchmark results
tony Jul 5, 2026
4dc6401
Scripts(fix[bench]): Make bench_engines mypy-clean under `mypy .`
tony Jul 5, 2026
ece4c24
Engines(refactor[mock]): Rename ConcreteEngine to MockEngine
tony Jul 5, 2026
56312df
Engines(refactor[connection]): Hold a ServerConnection
tony Jul 11, 2026
5e042cc
Chain(fix[_chain]): Wire the fold guard, import the rule
tony Jul 11, 2026
981d88c
Experimental(refactor[dedup]): Import instead of re-inline
tony Jul 11, 2026
23a7342
Experimental(refactor[dead-code]): Delete unread surface
tony Jul 11, 2026
a1967d4
Engines(feat[control-mode]): Add unescape_control_output
tony Jul 11, 2026
24e5a2d
Skill(feat[bench]): Add engine-build benchmark skill
tony Jul 18, 2026
87d8efa
Skill(chore[claude]): Symlink .claude/skills to .agents/skills
tony Jul 18, 2026
f74f79d
Mcp(fix[mcp_swap]): Point agy at the config it actually reads
tony Jul 18, 2026
265271c
Scripts(feat[bench]): Add build-cost matrix + concurrency
tony Jul 18, 2026
15f44c2
Scripts(feat[bench]): Add standalone contract subcommand
tony Jul 18, 2026
90249f7
Scripts(docs[bench]): Document matrix, concurrency, contract
tony Jul 18, 2026
34a8749
Ops(fix[plan]): Surface failed creates instead of the reference
tony Jul 18, 2026
8ed1f2d
Ops(feat[observability]): Report lost id captures
tony Jul 18, 2026
abf945b
Engines(feat[observability]): Trace dispatch and block framing
tony Jul 18, 2026
b555d46
Engines(fix[control-mode]): Carry tmux stderr on connection death
tony Jul 18, 2026
4ec0060
Scripts(fix[bench]): Keep cell servers alive, unblock the loop
tony Jul 18, 2026
82a128d
Scripts(fix[bench]): Reap stale scratch dirs on startup
tony Jul 18, 2026
878a0ab
Mcp(feat[mcp_swap]): Add doctor subcommand and use-local --env
tony Jul 24, 2026
991e525
Mcp(feat[skill]): Add testing-mcp-with-cli-agents skill
tony Jul 24, 2026
20dad7b
Docs(feat[domain]): Add operation domain
tony Jul 25, 2026
45b911c
Docs(fix[automation]): Prevent echo races
tony Jul 25, 2026
e694d04
Docs(test[examples]): Isolate MyST sandboxes
tony Jul 25, 2026
ce2cd7d
Docs(feat[operations]): Add operation pages
tony Jul 25, 2026
9ced987
Docs(fix[domain]): Wrap operation cards
tony Jul 25, 2026
9d91a4f
Docs(feat[querying]): Split query guides
tony Jul 25, 2026
86703f7
Docs(style[format]): Apply ruff output
tony Jul 25, 2026
7e712ee
Docs(fix[api]): Use native operation markup
tony Jul 25, 2026
6ec954f
Retry(fix[clock]): Use monotonic time
tony Jul 25, 2026
9f3a2a0
Docs(feat[operations]): Add live examples
tony Jul 25, 2026
b02f501
Docs(fix[tmuxop]): Fix strict typing
tony Jul 25, 2026
2368f90
Docs(fix[suspend]): Stabilize live example
tony Jul 25, 2026
1d775e5
Engines(fix[control]): Avoid phantom sessions
tony Jul 25, 2026
08178b9
Engines(fix[async]): Restore 3.10 typing
tony Jul 25, 2026
93f4cb4
Tools(fix[mcp-swap]): Guard recovery stack
tony Jul 25, 2026
238ffaa
Experimental(fix[contracts]): Close API gaps
tony Jul 26, 2026
5b99cef
Docs(feat[plans]): Teach async control plans
tony Jul 26, 2026
a50f420
Docs(test[attributes]): Enforce field prose
tony Jul 26, 2026
83308b8
Docs(test[async]): Type tutorial results
tony Jul 26, 2026
218c382
Docs(refactor[ops]): Describe class variables as attributes
tony Jul 26, 2026
7f6419c
Docs(docs[engines]): Teach every engine
tony Jul 26, 2026
f4885a3
Docs(fix[assets]): Drop stale tabs script
tony Jul 26, 2026
188aa99
Tests(style[imports]): Use from-import for submodules
tony Jul 27, 2026
f22d877
Engines(style[control]): Strip the % prefix with removeprefix
tony Jul 27, 2026
ce9f86d
Docs(style[domain]): Alias collections.abc.Set import
tony Jul 27, 2026
cdb7a72
Mcp(refactor[events]): Name the done-format field tuple
tony Jul 27, 2026
8d963b1
Mcp(style[adapter]): Parenthesize the instruction segments
tony Jul 27, 2026
de41d95
Engines(fix[typing]): Return Self from context managers
tony Jul 27, 2026
afef2bc
Scripts(style[mcp-swap]): Take min instead of sorting
tony Jul 27, 2026
2d6cf11
Ruff(chore[BLE001]): Scope blind-except to the boundaries
tony Jul 27, 2026
dcfbfaa
Scripts(fix[shebang]): Make both scripts really executable
tony Jul 27, 2026
7371b16
Scripts(feat[mcp-swap]): Target a pull request and write configs safely
tony Aug 10, 2026
8422a16
Scripts(feat[mcp-swap]): Support the opencode and pi CLIs
tony Aug 10, 2026
62bc558
Rebase(chore): Reconcile bookkeeping with the engine seam
tony Aug 15, 2026
bbb1e27
Engines(refactor): Share Core's engine values
tony Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
85 changes: 85 additions & 0 deletions .agents/skills/benchmarking-engine-builds/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
name: benchmarking-engine-builds
description: Use when measuring or profiling how fast libtmux.experimental engines build tmux workspaces — comparing classic vs subprocess/control_mode/imsg/mock/pipelined, chasing a build-latency regression, reading percentile grids, or finding where a control-mode build spends its time (cProfile). Runs scripts/bench_engines.py hermetically on throwaway sockets.
---

# Benchmarking engine builds

## Overview

`scripts/bench_engines.py` times how long each experimental engine takes to
build a tmux session structure (`W` windows × `P` panes-per-window), sweeping
shapes × engines × wait-modes and reporting min/avg/median/p90/p95/p99/max.

**Hermetic and safe to run beside a live tmux session:** every server gets its
own socket under a throwaway `mkdtemp` dir, `TMUX` is unset before libtmux is
imported, and an `atexit` hook kills every spawned server. The default tmux
server is never contacted.

It is a PEP 723 script — **always launch it with `uv run`**, never `python`, or
its inline deps (`rich`, `typer`, editable `libtmux`) won't resolve.

## When to use

- Comparing engine build cost (which engine is fastest for a given shape).
- Checking whether a change to the ops/plan/engine layer moved build latency.
- Reading percentile spread (is p99 blowing out?) rather than a single number.
- Locating the hot path inside one engine's build (`profile` → cProfile cumtime).

## Quick reference

Run from the repo root.

| Command | What it does |
|---|---|
| `uv run scripts/bench_engines.py run` | full engine grid (the clean signal) |
| `uv run scripts/bench_engines.py matrix --shapes 1x4,3x3,5x4` | 4-axis factorial: which choice drives build cost |
| `uv run scripts/bench_engines.py concurrency --transport control_mode --k 4` | K builds sync-serial vs async-`gather` |
| `uv run scripts/bench_engines.py contract` | mock-parity ops-language check only (for CI) |
| `uv run scripts/bench_engines.py profile --engine control_mode --shape 8x4` | cProfile one engine, print slowest by cumtime |
| `uv run scripts/bench_engines.py cell control_mode 8x4` | one isolated build (for wrapping in hyperfine) |

`run` flags: `--shapes 1x1,1x4,3x3,5x4,8x4`, `--engines classic,subprocess,control_mode,imsg,mock,pipelined`,
`--wait` (ALSO measure with shell-readiness wait), `--runs 20`, `--warmup 3`,
`--json-out grid.json`. Shape is `windows x panes-per-window`.

`matrix` sweeps five expression layers (`imperative`, `plan-seq`, `plan-fold`,
`ws-seq`, `ws-fold`) × transport {subprocess, control_mode} × mode {sync, async}
against a `classic` reference. `mock` is the offline correctness **oracle**, not
a results row: `matrix --check` (default on) and `contract` assert every layer ×
mode renders identical tmux argv to it, so the benchmark doubles as an
ops-language contract test.

Engines: `classic` (Server/Session/Window/Pane API) · `subprocess` (one fork
per op) · `control_mode` (one persistent `tmux -C`) · `imsg` (AF_UNIX one-shot) ·
`mock` (offline, in-memory Python floor) · `pipelined` (prototype: batch
independent creates via `run_batch`).

## Reading the results

- **`control_mode` is the fastest shipped engine** (~21× classic at 32 panes)
because it avoids a per-op `tmux` fork. `pipelined` edges it (~1.4×) by
batching independent creates into ~3 round-trips.
- **Builds are tmux-server-bound, not round-trip-bound** — one shell fork per
pane dominates, so cutting round-trips helps less than the count implies.
`mock` (~1–2 ms) is the pure-Python floor: the plan/compile layer is
negligible; the time is tmux.
- **`profile` shows ~68% in `select.epoll.poll`** inside `_read_blocks`: each
created id is read back before the next op targets it. Latency-bound.

## Common mistakes

- Running with `python` instead of `uv run` — PEP 723 deps don't resolve.
- **Comparing `--wait` against no-wait across engines.** Shell startup
(~0.8–2.1 s) dwarfs a fast build, so the ~20× engine win collapses to ~1.5×
once both sides wait. Compare engines with matching readiness policies.
- Trusting hyperfine whole-process wall time over the in-process grid — Python
startup + import dwarfs a 3 ms build and understates the builder. The
in-process `run` grid is the clean signal.
- Expecting `mock` under `--wait`: it has no real panes and is skipped.

## Results & reproduction

Committed results live in `scripts/bench-results/`: `RESULTS.md` (narrative +
tables), `grid.json` (no-wait grid), `wait.json` (wait comparison). Regenerate
the raw JSON with `--json-out`.
216 changes: 216 additions & 0 deletions .agents/skills/testing-mcp-with-cli-agents/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
---
name: testing-mcp-with-cli-agents
description: >-
Test an MCP server by driving real CLI agents (Claude, Codex, Cursor, Gemini,
Grok, agy) against it — isolating each CLI's config and the server's own
backend state instead of trusting unit tests alone. Use this whenever
verifying MCP-server behavior end-to-end, checking that a local branch or
checkout works across installed agent CLIs, comparing trunk-vs-branch
behavior, driving an interactive agent TUI to exercise approval flows or
cancellation, or reproducing a bug through a live client. Reach for it even
when the user only says "test the MCP", "does the branch work in the agents",
"drive the CLI to call the tool", or "check it across Codex/Gemini/Cursor".
---

# Testing an MCP server through real CLI agents

Unit tests prove the server's internals; they don't prove a real agent can
discover a tool, clear its approval gate, call it, and survive cancelling it
mid-flight. This skill exercises that whole path by pointing installed CLI
agents at a checkout and driving them. Here the server is libtmux's
tmux-control MCP (`libtmux-engine-mcp`, registered under the `libtmux` slug),
and its backend-isolation lever is a scratch tmux socket
(`LIBTMUX_SOCKET=<scratch>` → an isolated `tmux -L <scratch>` server) — the
thing that scratches every side effect a tool call would otherwise make.

## The core idea: isolate two things, never zero

Driving an MCP server through a real CLI mutates two things you don't want
touched. Isolate both and the whole exercise is safe and observable:

1. **The CLI's config.** Use a throwaway config-home or project config so the
real `~/.codex`, `~/.claude.json`, etc. are never written. Each CLI's lever
is in `references/cli-matrix.md`.
2. **The server's backend / side effects.** Point the server at a *scratch*
backend via its own env var or flag, so tool calls never touch real state
and you can assert against that scratch backend as independent ground truth.

What "scratch backend" means depends on the server:

| Server kind | Scratch-backend lever | Ground-truth check |
|---|---|---|
| tmux control (libtmux-mcp) | `LIBTMUX_SOCKET=<scratch>` → an isolated `tmux -L <scratch>` server | `tmux -L <scratch> list-windows` |
| search / index (agentgrep) | a scratch index/store dir via the server's data-dir env/flag | inspect the scratch index, not the real store |
| filesystem | a temp working root | check the temp tree |
| external API | a sandbox/base-URL override or a recording | the sandbox's own state |

The principle is identical everywhere: the server writes only to scratch, and
you verify against scratch — so "the agent said it worked" is separated from
"the tool actually did it," and a destructive tool can't harm anything real.

## Climb only as high as the question needs — three fidelity layers

### Layer 0 — Direct MCP smoke, no CLI at all

Fastest and most deterministic. Drive the server over stdio from a tiny FastMCP
client against a scratch backend and assert the wire contract directly: the tool
list, a couple of representative calls, an error path. Use this to answer "is
the tool surface and result shape correct?" before spending a CLI on it.
Normalize result shapes before asserting — `structuredContent` is often
`{"result": [...]}`, and single-value returns can arrive as a bare string.

### Layer 1 — Headless CLI one-shot

Proves the real client can discover and call the tools, scriptably, with no
send-keys. Every CLI has a non-interactive mode. Run a cheap discovery proof
first (does the client *see* the server?) — but the cheapest proof differs
sharply per CLI: grok's `mcp doctor` does a real handshake, codex's `mcp get`
only parses config, some CLIs have nothing short of a model call.
`references/cli-matrix.md` has the verified per-CLI invocation, isolation lever,
and approval-bypass flag. Two recurring surprises: some `mcp list`/`list-tools`
subcommands read the *ambient* config and ignore your isolated one, and a
mutating tool call needs a per-CLI approval-bypass flag or it hangs on a no-TTY
prompt.

### Layer 2 — Interactive, driven by tmux send-keys

The high-fidelity path, and the only one that exercises approval flows, live
streaming, multi-turn, and cancellation. Run the agent's TUI in a **harness**
tmux socket (`tmux -L cli-harness`, separate from any socket the server itself
uses) and drive it. Create a wide harness so the TUI does not wrap:

```console
$ tmux -L cli-harness new-session -d -s agent -x 220 -y 50
```

Launch the CLI with its isolated backend:

```console
$ tmux -L cli-harness send-keys -t agent 'cd /repo && <cli launch with backend isolation>' Enter
```

Poll until the prompt renders:

```console
$ tmux -L cli-harness capture-pane -p -t agent | tail -5
```

Type the task:

```console
$ tmux -L cli-harness send-keys -t agent 'Use the libtmux MCP to <do a thing>'
```

Submit it as a separate event:

```console
$ tmux -L cli-harness send-keys -t agent Enter
```

If prompted, answer the approval gate:

```console
$ tmux -L cli-harness send-keys -t agent 'y' Enter
```

Capture what the agent rendered:

```console
$ tmux -L cli-harness capture-pane -p -t agent | tail -30
```

Finally, assert ground truth against the scratch backend, not the transcript.
Layers 0 and 1 can be fooled by a hallucinated success line; the scratch backend
cannot.

## Two failure modes that waste the most time

**Approval gates hang naive harnesses.** The first tool use pops an approval
dialog. A driver that types the prompt and immediately waits for output waits
forever. Pre-approve with the CLI's trust/approval flags (see the matrix), or
detect the prompt via `capture-pane` and answer its keystroke before waiting.

**Sleeping instead of waiting is flaky, and blind typing doesn't submit.** Poll
`capture-pane` for a stable completion marker rather than `sleep N`. Send the
prompt text and `Enter` as **separate** `send-keys` events — then one Enter
submits; batching text+Enter in one call is what leaves the prompt unsent. And a
CLI launched inside a `-L` harness pane runs in a non-login shell that lacks your
mise/node/uv shims, so `export` the needed bin dirs before launching it.

## High-value test: cancellation / teardown

Cancellation is invisible to the tool list and only reachable through Layer 2.
With a long-running tool (a wait, a big scan): start it, then while the TUI shows
"working / esc to interrupt" send `Escape` to that pane. `Esc` during the working
phase cancels the in-flight tool call while keeping the MCP server subprocess
alive — the exact client-cancellation a server's teardown path must survive;
`Esc` after a turn finishes just enters edit-previous mode. Then assert the
scratch backend is clean and no child process leaked.

## Comparing two versions (trunk vs a branch)

Two worktrees, two scratch backends, same prompt. Diff three things: the **tool
surface** (a Layer-0 `tools/list` dump or `mcp list-tools`, diffed), the
**rendered agent behavior** for the same prompt (capture-pane transcripts), and
the **scratch-backend state** afterward.

## Wiring a checkout into the CLIs: mcp_swap

`scripts/mcp_swap.py` rewrites each CLI's config to run a local checkout, with
backup/revert. Detect installed CLIs:

```console
$ uv run scripts/mcp_swap.py detect
```

Inspect the effective environment and configuration hazards:

```console
$ uv run scripts/mcp_swap.py doctor --server libtmux-engine
```

Check the current swap state:

```console
$ uv run scripts/mcp_swap.py status --server libtmux-engine
```

Preview a local swap:

```console
$ uv run scripts/mcp_swap.py use-local --server libtmux-engine --env KEY=VALUE --dry-run
```

Apply it:

```console
$ uv run scripts/mcp_swap.py use-local --server libtmux-engine --env KEY=VALUE
```

Revert the latest swap:

```console
$ uv run scripts/mcp_swap.py revert
```

Run `doctor` first — it reports which server name each CLI points at (and warns
when the repo is registered under a name other than the derived default),
un-reverted swaps and orphaned backups, missing backups (revert would fail), and
auth-overriding env vars like `OPENAI_API_KEY`. Use `--env` to inject the
backend-isolation var (e.g. an isolated socket or data dir) at swap time.

**Prefer zero-mutation isolation for a test.** mcp_swap is for a swap you *want*
to persist. To just exercise a checkout, use each CLI's throwaway
config-home / project-config lever (`references/cli-matrix.md`) — all were
verified to drive the server with the real config confirmed byte-identical
afterward, and no swap state touched. `use-local` mutates real configs, so
dry-run first and always `revert` at the end; and the machine may already carry
an un-reverted swap, so `revert` returns you to *that* state, not a pristine one
(check `doctor` first).

## When NOT to reach for the full harness

If the question is purely "is the tool surface correct?" stay at Layer 0 —
booting six CLIs to answer a wire-contract question is wasted effort. Escalate to
Layers 1 and 2 only when the client's discovery, approval, streaming, or
cancellation behavior is what's actually in doubt.
Loading
Loading