Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
203 commits
Select commit Hold shift + click to select a range
2850752
Ops(feat): Add typed operation and engine spine
tony Jun 21, 2026
5685a6b
Ops(feat): Add classic + concrete engines and contract suite
tony Jun 21, 2026
56df7d6
Ops(feat): Add async engine, lazy plans, and op catalog
tony Jun 21, 2026
10b85ab
Ops(feat): Add persistent control-mode engine
tony Jun 21, 2026
4e22b8b
Ops(feat): Add eager + lazy pane facades over the spine
tony Jun 21, 2026
e5ac18e
Ops(feat): Add async control-mode + concrete engines
tony Jun 21, 2026
e82847f
Ops(feat): Add AckResult for no-output operations
tony Jun 21, 2026
91d7cd3
Models(feat): Add pure object-graph snapshots
tony Jun 21, 2026
7059b2d
Ops(feat): Add read-seam list operations
tony Jun 21, 2026
b10c982
docs(experimental): Add tmuxop-catalog directive
tony Jun 21, 2026
ac83fa9
ControlMode(fix): Consume startup ACK, drain unsolicited blocks
tony Jun 21, 2026
1f2e163
Ops(feat): Add lazy-plan chainability (>> and ; folding)
tony Jun 21, 2026
78b4711
Facade(feat): Add Server/Session/Window facades + creation ops
tony Jun 21, 2026
aa70b55
Imsg(feat): Add native imsg engine + live parity test
tony Jun 21, 2026
cb83ec8
Facade(feat): Complete the facade matrix (Server/Session/Client)
tony Jun 21, 2026
1148d94
chore(deps[dev]): Add ty type checker + config
tony Jun 21, 2026
10d0e96
Ops(feat): Add pluggable planners + {marked} fold
tony Jun 21, 2026
ec97c12
Ops(feat): Add non-list read operations
tony Jun 21, 2026
653a91f
Ops(feat): Add pane mutation/creation operations
tony Jun 21, 2026
bba7e2a
Ops(feat): Add window mutation/navigation operations
tony Jun 21, 2026
6007808
Ops(feat): Add server/option/environment operations
tony Jun 21, 2026
e54e02d
Ops(feat): Add paste-buffer operations
tony Jun 21, 2026
d279620
docs(experimental): Document engines and lazy plans
tony Jun 21, 2026
a19d829
docs(CHANGES): Note experimental operations and engines
tony Jun 21, 2026
8b72090
Ops(fix): Correct move-window -k and paste-buffer -r
tony Jun 21, 2026
047145c
Ops(fix): Resolve SlotRef src_target in lazy plans
tony Jun 21, 2026
0fac0d7
Ops(fix): Skip all decorates when a marked-fold create fails
tony Jun 21, 2026
7721398
Ops(fix): Mark save-buffer readonly to match its effects
tony Jun 21, 2026
51e65db
Ops(docs): Fix PipePane parameter name in docstring
tony Jun 21, 2026
79fcc90
Ops(fix): Log imsg argv as a scalar tmux_cmd field
tony Jun 21, 2026
a9fd5aa
Ops(docs): Add doctests to the planner plan() methods
tony Jun 21, 2026
7adbcea
Ops(fix): Resolve decorate src_target in {marked} folds
tony Jun 21, 2026
3be31ce
Ops(fix): Keep ; a bare separator in control-mode engines
tony Jun 21, 2026
44d36f2
Ops(fix): Treat a blank captured id as no id in marked folds
tony Jun 21, 2026
4a39c7d
Ops(fix): Complete a marked fold whose creator does not capture
tony Jun 21, 2026
3ac9e33
Ops(fix): Drop create stdout when attributing marked decorates
tony Jun 21, 2026
05a0f98
Ops(fix): Target the concrete pane in marked decorate results
tony Jun 21, 2026
ca71a2a
Ops(fix): Decode SubprocessEngine output as UTF-8
tony Jun 21, 2026
0d94a7e
Models(refactor): Use namespaced dataclasses.replace in snapshots
tony Jun 21, 2026
d78a451
Ops(docs): Fix PipePane -o flag description
tony Jun 21, 2026
0a06f5d
Ops(fix): Mark save-buffer mutating (it writes a file)
tony Jun 21, 2026
b1b780f
Ops(fix): Correlate control-mode blocks per command and by flags
tony Jun 21, 2026
a9ae427
Ops(fix): Clear pending futures on async control-mode write failure
tony Jun 21, 2026
0e1b9a6
Ops(fix): Suppress ProcessLookupError on async cancel terminate
tony Jun 21, 2026
d367cf2
Engines(fix): Remove the unreachable asyncio engine kind
tony Jun 21, 2026
739da07
Ops(fix): Normalize tmux master version in operation gates
tony Jun 21, 2026
5b4d683
Ops(fix): Reject SendKeys literal+enter combination
tony Jun 21, 2026
03c6c01
Ops(fix): Keep all lines of a display-message result
tony Jun 21, 2026
6f9ee27
Ops(fix): Centralize the has-session stderr->stdout fold in the op
tony Jun 21, 2026
79ddd1f
Engines(docs): Note ConcreteEngine query-simulation limits
tony Jun 22, 2026
5bc3fb1
Engines(fix): Avoid imsg UnboundLocalError on socket() failure
tony Jun 22, 2026
7d9a7b9
Engines(fix): Return imsg exit result on clean close after MSG_EXIT
tony Jun 22, 2026
4a3e5d3
Engines(fix): Close imsg dup'd fds if the identify send never happens
tony Jun 22, 2026
d94e95f
Imsg(fix): Send identify LONGFLAGS frame once
tony Jun 22, 2026
0507330
Engines(test): Widen async control-mode coverage
tony Jun 22, 2026
7f0cd31
Ops(feat[send_keys]): Add suppress_history flag
tony Jun 22, 2026
823a0c1
Ops(feat): Capture implicit child ids on create
tony Jun 22, 2026
23e7b5b
Workspace(feat): Declarative WorkspaceBuilder on the typed-ops Core
tony Jun 22, 2026
0aee30f
Ops(feat): Serialize bindings + add plan preview
tony Jun 22, 2026
c1c89d1
Mcp(feat): Add framework-agnostic tool projection
tony Jun 23, 2026
65066c8
Mcp(feat): Add optional fastmcp adapter (libtmux[mcp])
tony Jun 23, 2026
48a3c94
Mcp(feat): Per-op + plan tools and a stdio server
tony Jun 23, 2026
640c5c7
Mcp(feat): Port mcp_swap config-swap dev script
tony Jun 23, 2026
6a76e3a
Tests(chore): Run the mcp adapter suite in the gate
tony Jun 23, 2026
194424a
Workspace(test): Cover analyzer, compiler, runner
tony Jun 23, 2026
8651f0c
Mcp(feat): Add grok + agy CLIs to mcp_swap
tony Jun 23, 2026
31a67f8
Mcp(feat): Caller-aware async tmux tool surface
tony Jun 23, 2026
5e3577a
Mcp(feat): Caller discovery + self-kill guards
tony Jun 23, 2026
0cefcbc
Mcp(fix): Harden self-kill guards + socket scoping
tony Jun 23, 2026
847547e
Mcp(feat): Needle-free pane-output monitor
tony Jun 23, 2026
790a520
Mcp(feat): Make wait_for_output discoverable
tony Jun 24, 2026
dd98909
Mcp(fix): Close self-kill guard deferrals
tony Jun 24, 2026
cc0da43
Mcp(fix): Harden wait_for_output monitor
tony Jun 24, 2026
720efa3
Workspace(feat): Thread env/shell/options through declarative tier
tony Jun 27, 2026
840b355
Workspace(feat): Add per-command Command (enter + sleeps)
tony Jun 27, 2026
6307831
Ops(feat): Add ForwardCaptureError + ShowOptionsResult.get_int
tony Jun 27, 2026
f65324c
Workspace(feat): Add Workspace.to_dict + read suppress_history
tony Jun 27, 2026
ed4c7ed
Workspace(feat): Add BuildEvent stream + on_event observer
tony Jun 27, 2026
92bba88
Workspace(feat): Add opt-in wait_pane readiness (anti-race)
tony Jun 27, 2026
0f8fd5c
Workspace(feat): Honor explicit Window.window_index placement
tony Jun 27, 2026
d2bd18a
Mcp(feat): Expose build_workspace on the async server
tony Jun 27, 2026
687d865
Mcp(feat[safety]): Add tier constants, resolver, ExpectedToolError
tony Jun 27, 2026
b02f886
Mcp(feat[middleware]): Port error-result + tail-preserving limiter
tony Jun 27, 2026
c187c1d
Mcp(feat[middleware]): Port safety, audit, readonly-retry middleware
tony Jun 27, 2026
d7c17c2
Mcp(feat[safety]): Wire safety gate + middleware into the builders
tony Jun 27, 2026
2010589
Mcp(feat[prompts]): Add recipe prompts in engine-ops vocabulary
tony Jun 27, 2026
3bcd9cb
Mcp(feat[resources]): Add tmux:// hierarchy resources over the engine
tony Jun 27, 2026
ca64705
Mcp(feat[lifespan]): Add engine-probe lifespan (async server)
tony Jun 27, 2026
3319607
Workspace(feat[cli]): Add `load` command for .tmuxp.yaml files
tony Jun 27, 2026
7036294
Workspace(feat): blank/pane empty-pane parity + cli --dry-run
tony Jun 27, 2026
42ca785
Workspace(fix): First window's start_directory for its first pane
tony Jun 27, 2026
535522a
Ops(feat): Per-step host hook and bounded planner
tony Jun 27, 2026
496a847
Workspace(feat): Fold build dispatches by default
tony Jun 27, 2026
78c5ee7
Workspace(feat): Fold --dry-run output
tony Jun 27, 2026
05bf39f
Ops(feat[new_pane]): Add floating pane operation
tony Jun 27, 2026
f30cd4e
Ops(fix[break_pane]): Work around tmux 3.7 break-pane crash
tony Jun 27, 2026
05acbb0
Engines(feat): Resolve engine tmux version for gating
tony Jun 27, 2026
f494c06
Workspace(feat[ir]): Add floating-pane declarations
tony Jun 27, 2026
7d35961
Workspace(feat[compiler]): Build floating panes from specs
tony Jun 27, 2026
03f472c
Workspace(feat[compiler]): Cross-window floats via symbol table
tony Jun 27, 2026
c801297
Query(feat): Add snapshot-backed live pane query
tony Jun 27, 2026
a037f3a
Query(feat): Add per-pane command building that folds
tony Jun 27, 2026
37e5316
Facade(feat[pane]): Add new_pane floating parity
tony Jun 27, 2026
2859c52
Mcp(feat[pane]): Add curated new_pane floating tool
tony Jun 27, 2026
4da0c0e
Mcp(feat[registry]): Surface whole-op min_version in schema
tony Jun 27, 2026
8bafdae
Mcp(fix[prompts]): wait_for_output uses target=
tony Jun 28, 2026
af2f8fa
Workspace(fix[analyze]): Reject bad command items
tony Jun 28, 2026
7da6255
Workspace(fix[env]): Inherit window env in splits
tony Jun 28, 2026
e53b9c0
docs(CHANGES) Note on updates
tony Jul 4, 2026
e12b819
Mcp(refactor): Drop dead is_conservative_caller
tony Jul 4, 2026
b3dfc88
Engines(fix): Report tmux version over control mode
tony Jul 4, 2026
4fa9795
Ops(docs): Fix stale fold comment in chain test
tony Jul 4, 2026
f50853c
Models(feat): Add PaneSnapshot.floating flag
tony Jul 4, 2026
8872189
Engines(fix): Reap control-mode phantom sessions
tony Jul 4, 2026
10fe4f4
Engines(fix): Close subscribers on engine death
tony Jul 4, 2026
edf8b8b
Query(feat): Split-type forward-ref pane handles
tony Jul 4, 2026
d017fb9
Fluent(feat): Add plan() forward-ref build tier
tony Jul 4, 2026
f8576e3
Workspace(feat): Add freeze (live server to IR)
tony Jul 4, 2026
2895aa8
Workspace(feat): Add variant expand + workspace sets
tony Jul 4, 2026
861b242
Ops(feat): Add plan explain() and astream()
tony Jul 4, 2026
afd04bb
Fluent(feat): Add sleep/wait host boundaries
tony Jul 4, 2026
8697241
Mcp(feat): Add explain_plan tool
tony Jul 4, 2026
ad0c210
Ops(feat): Add conditional find-or-create via ensure()
tony Jul 4, 2026
f3aff09
Fluent(feat): Add find_or_create_session
tony Jul 4, 2026
c291aa1
Test(fix): Clean up sessions in fluent live tests
tony Jul 4, 2026
016c851
docs(experimental): Document the fluent plan() builder
tony Jul 4, 2026
8a6faae
Mcp(fix): Preserve ensure across plan-tool serialization
tony Jul 4, 2026
32f2c75
Ops(test): Make the ensure probe test format-honest
tony Jul 4, 2026
f91e0f9
Workspace(docs): on_event and astream caveats
tony Jul 4, 2026
e644454
Workspace(fix): Stringify option/env values
tony Jul 5, 2026
f0de386
Ops(fix): Skip marked fold for detached creators
tony Jul 5, 2026
2645013
Engines(fix): End subscribe() after engine death
tony Jul 5, 2026
da5899d
Query(refactor): Namespace dataclasses.replace
tony Jul 5, 2026
9b44013
docs(experimental): Drop prototype lineage
tony Jul 5, 2026
f52c00b
Workspace(fix): Honor window_shell on window 0
tony Jul 5, 2026
aa52a69
Workspace(fix): wait_pane uses effective shell
tony Jul 5, 2026
44700b2
docs(experimental): Add arun/aexecute doctests
tony Jul 5, 2026
5fe4ae2
Facade(fix): Add AsyncWindow.select_layout
tony Jul 5, 2026
3f62ef1
Engines(fix): ImsgEngine reports tmux version
tony Jul 5, 2026
538be51
Mcp(fix): Gate wait_for_output prompts on events
tony Jul 5, 2026
fde1d16
Mcp(fix): Redact non-str sensitive audit args
tony Jul 5, 2026
d47c645
docs(experimental): Trim more branch narrative
tony Jul 5, 2026
33d9e14
Mcp(docs): Fix build_workspace registration claim
tony Jul 5, 2026
80d7943
Workspace(fix): Confirm cwd on the first pane
tony Jul 5, 2026
df323ed
Engines(feat[async_control_mode]): Add supervised reconnect
tony Jul 5, 2026
96163c9
Mcp(fix[events]): Restart the event drain after reconnect
tony Jul 5, 2026
0a1d920
Objects(refactor): Rename facade package to objects
tony Jul 5, 2026
b2ecbe8
Engines(fix[async_control_mode]): Escalate backoff on connect-then-die
tony Jul 5, 2026
fed66fc
Engines(fix[async_control_mode]): Terminate the prior proc on reconnect
tony Jul 5, 2026
f9e29b6
Scripts(feat[bench]): Add hermetic engine build-benchmark grid
tony Jul 5, 2026
a8116f3
Scripts(docs[bench]): Add engine benchmark results
tony Jul 5, 2026
953c6cb
Scripts(fix[bench]): Make bench_engines mypy-clean under `mypy .`
tony Jul 5, 2026
51102bc
Engines(refactor[mock]): Rename ConcreteEngine to MockEngine
tony Jul 5, 2026
1168bb3
Engines(refactor[connection]): Hold a ServerConnection
tony Jul 11, 2026
4eb5ea8
Chain(fix[_chain]): Wire the fold guard, import the rule
tony Jul 11, 2026
a8af3cd
Experimental(refactor[dedup]): Import instead of re-inline
tony Jul 11, 2026
df158c4
Experimental(refactor[dead-code]): Delete unread surface
tony Jul 11, 2026
2843390
Engines(feat[control-mode]): Add unescape_control_output
tony Jul 11, 2026
546432c
Skill(feat[bench]): Add engine-build benchmark skill
tony Jul 18, 2026
35ae572
Skill(chore[claude]): Symlink .claude/skills to .agents/skills
tony Jul 18, 2026
c9ad093
Mcp(fix[mcp_swap]): Point agy at the config it actually reads
tony Jul 18, 2026
f80939c
Scripts(feat[bench]): Add build-cost matrix + concurrency
tony Jul 18, 2026
a62cdf2
Scripts(feat[bench]): Add standalone contract subcommand
tony Jul 18, 2026
68eaf31
Scripts(docs[bench]): Document matrix, concurrency, contract
tony Jul 18, 2026
1ec6c72
Ops(fix[plan]): Surface failed creates instead of the reference
tony Jul 18, 2026
0c5f207
Ops(feat[observability]): Report lost id captures
tony Jul 18, 2026
b8c8fe2
Engines(feat[observability]): Trace dispatch and block framing
tony Jul 18, 2026
4d093af
Engines(fix[control-mode]): Carry tmux stderr on connection death
tony Jul 18, 2026
e8d334e
Scripts(fix[bench]): Keep cell servers alive, unblock the loop
tony Jul 18, 2026
be3711b
Scripts(fix[bench]): Reap stale scratch dirs on startup
tony Jul 18, 2026
73a4b9e
Mcp(feat[mcp_swap]): Add doctor subcommand and use-local --env
tony Jul 24, 2026
9d5ac6e
Mcp(feat[skill]): Add testing-mcp-with-cli-agents skill
tony Jul 24, 2026
e304b47
Docs(feat[domain]): Add operation domain
tony Jul 25, 2026
7750e38
Docs(fix[automation]): Prevent echo races
tony Jul 25, 2026
8c4d5d9
Docs(test[examples]): Isolate MyST sandboxes
tony Jul 25, 2026
0a2c6a0
Docs(feat[operations]): Add operation pages
tony Jul 25, 2026
42f159a
Docs(fix[domain]): Wrap operation cards
tony Jul 25, 2026
831f872
Docs(feat[querying]): Split query guides
tony Jul 25, 2026
257e002
Docs(style[format]): Apply ruff output
tony Jul 25, 2026
c83d5f5
Docs(fix[api]): Use native operation markup
tony Jul 25, 2026
9b84342
Retry(fix[clock]): Use monotonic time
tony Jul 25, 2026
06b7659
Docs(feat[operations]): Add live examples
tony Jul 25, 2026
f7f226d
Docs(fix[tmuxop]): Fix strict typing
tony Jul 25, 2026
f61ca98
Docs(fix[suspend]): Stabilize live example
tony Jul 25, 2026
efaec87
Engines(fix[control]): Avoid phantom sessions
tony Jul 25, 2026
0d21456
Engines(fix[async]): Restore 3.10 typing
tony Jul 25, 2026
9effe8a
Tools(fix[mcp-swap]): Guard recovery stack
tony Jul 25, 2026
514cf19
Experimental(fix[contracts]): Close API gaps
tony Jul 26, 2026
4e4c53e
Docs(feat[plans]): Teach async control plans
tony Jul 26, 2026
9f44712
Docs(test[attributes]): Enforce field prose
tony Jul 26, 2026
f436aa2
Docs(test[async]): Type tutorial results
tony Jul 26, 2026
b364b53
Docs(refactor[ops]): Describe class variables as attributes
tony Jul 26, 2026
d91f79b
Docs(docs[engines]): Teach every engine
tony Jul 26, 2026
282e493
Docs(fix[assets]): Drop stale tabs script
tony Jul 26, 2026
e3c7b09
Tests(style[imports]): Use from-import for submodules
tony Jul 27, 2026
c8081ac
Engines(style[control]): Strip the % prefix with removeprefix
tony Jul 27, 2026
a0c681d
Docs(style[domain]): Alias collections.abc.Set import
tony Jul 27, 2026
e08ec87
Mcp(refactor[events]): Name the done-format field tuple
tony Jul 27, 2026
0438b92
Mcp(style[adapter]): Parenthesize the instruction segments
tony Jul 27, 2026
544640a
Engines(fix[typing]): Return Self from context managers
tony Jul 27, 2026
4697a9b
Scripts(style[mcp-swap]): Take min instead of sorting
tony Jul 27, 2026
d726c79
Ruff(chore[BLE001]): Scope blind-except to the boundaries
tony Jul 27, 2026
5b2c88e
Scripts(fix[shebang]): Make both scripts really executable
tony Jul 27, 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