Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
210 commits
Select commit Hold shift + click to select a range
27b096e
Ops(feat): Add typed operation and engine spine
tony Jun 21, 2026
73f5996
Ops(feat): Add classic + concrete engines and contract suite
tony Jun 21, 2026
8d74734
Ops(feat): Add async engine, lazy plans, and op catalog
tony Jun 21, 2026
0cbd8ba
Ops(feat): Add persistent control-mode engine
tony Jun 21, 2026
ff618f4
Ops(feat): Add eager + lazy pane facades over the spine
tony Jun 21, 2026
cb09ca3
Ops(feat): Add async control-mode + concrete engines
tony Jun 21, 2026
4fb59ba
Ops(feat): Add AckResult for no-output operations
tony Jun 21, 2026
4ba097c
Models(feat): Add pure object-graph snapshots
tony Jun 21, 2026
224b3c0
Ops(feat): Add read-seam list operations
tony Jun 21, 2026
9de9e05
docs(experimental): Add tmuxop-catalog directive
tony Jun 21, 2026
20ab282
ControlMode(fix): Consume startup ACK, drain unsolicited blocks
tony Jun 21, 2026
c95c930
Ops(feat): Add lazy-plan chainability (>> and ; folding)
tony Jun 21, 2026
87688a1
Facade(feat): Add Server/Session/Window facades + creation ops
tony Jun 21, 2026
a4ea369
Imsg(feat): Add native imsg engine + live parity test
tony Jun 21, 2026
69479d4
Facade(feat): Complete the facade matrix (Server/Session/Client)
tony Jun 21, 2026
7bd62ea
chore(deps[dev]): Add ty type checker + config
tony Jun 21, 2026
8f0f67e
Ops(feat): Add pluggable planners + {marked} fold
tony Jun 21, 2026
1d95d26
Ops(feat): Add non-list read operations
tony Jun 21, 2026
f903eba
Ops(feat): Add pane mutation/creation operations
tony Jun 21, 2026
075292a
Ops(feat): Add window mutation/navigation operations
tony Jun 21, 2026
1c1263a
Ops(feat): Add server/option/environment operations
tony Jun 21, 2026
d762d56
Ops(feat): Add paste-buffer operations
tony Jun 21, 2026
c1cc25e
docs(experimental): Document engines and lazy plans
tony Jun 21, 2026
2d37cd4
docs(CHANGES): Note experimental operations and engines
tony Jun 21, 2026
2d7553b
Ops(fix): Correct move-window -k and paste-buffer -r
tony Jun 21, 2026
802912a
Ops(fix): Resolve SlotRef src_target in lazy plans
tony Jun 21, 2026
b15ab9a
Ops(fix): Skip all decorates when a marked-fold create fails
tony Jun 21, 2026
833bbff
Ops(fix): Mark save-buffer readonly to match its effects
tony Jun 21, 2026
8ecacea
Ops(docs): Fix PipePane parameter name in docstring
tony Jun 21, 2026
e5508a0
Ops(fix): Log imsg argv as a scalar tmux_cmd field
tony Jun 21, 2026
061cd7a
Ops(docs): Add doctests to the planner plan() methods
tony Jun 21, 2026
5ce190c
Ops(fix): Resolve decorate src_target in {marked} folds
tony Jun 21, 2026
450451c
Ops(fix): Keep ; a bare separator in control-mode engines
tony Jun 21, 2026
a395c37
Ops(fix): Treat a blank captured id as no id in marked folds
tony Jun 21, 2026
b9d0ff2
Ops(fix): Complete a marked fold whose creator does not capture
tony Jun 21, 2026
dbcd245
Ops(fix): Drop create stdout when attributing marked decorates
tony Jun 21, 2026
465751e
Ops(fix): Target the concrete pane in marked decorate results
tony Jun 21, 2026
725f767
Ops(fix): Decode SubprocessEngine output as UTF-8
tony Jun 21, 2026
083cee6
Models(refactor): Use namespaced dataclasses.replace in snapshots
tony Jun 21, 2026
fa81bd7
Ops(docs): Fix PipePane -o flag description
tony Jun 21, 2026
ca84d30
Ops(fix): Mark save-buffer mutating (it writes a file)
tony Jun 21, 2026
2e495bb
Ops(fix): Correlate control-mode blocks per command and by flags
tony Jun 21, 2026
3c04f11
Ops(fix): Clear pending futures on async control-mode write failure
tony Jun 21, 2026
d20f6a2
Ops(fix): Suppress ProcessLookupError on async cancel terminate
tony Jun 21, 2026
bf33d1d
Engines(fix): Remove the unreachable asyncio engine kind
tony Jun 21, 2026
286e6ee
Ops(fix): Normalize tmux master version in operation gates
tony Jun 21, 2026
a96e41b
Ops(fix): Reject SendKeys literal+enter combination
tony Jun 21, 2026
bb53cff
Ops(fix): Keep all lines of a display-message result
tony Jun 21, 2026
29f133f
Ops(fix): Centralize the has-session stderr->stdout fold in the op
tony Jun 21, 2026
77e931c
Engines(docs): Note ConcreteEngine query-simulation limits
tony Jun 22, 2026
def44be
Engines(fix): Avoid imsg UnboundLocalError on socket() failure
tony Jun 22, 2026
94421f1
Engines(fix): Return imsg exit result on clean close after MSG_EXIT
tony Jun 22, 2026
a6b83b8
Engines(fix): Close imsg dup'd fds if the identify send never happens
tony Jun 22, 2026
4a12194
Imsg(fix): Send identify LONGFLAGS frame once
tony Jun 22, 2026
7fda16e
Engines(test): Widen async control-mode coverage
tony Jun 22, 2026
56612db
Ops(feat[send_keys]): Add suppress_history flag
tony Jun 22, 2026
11633c5
Ops(feat): Capture implicit child ids on create
tony Jun 22, 2026
7211221
Workspace(feat): Declarative WorkspaceBuilder on the typed-ops Core
tony Jun 22, 2026
bcbfe16
Ops(feat): Serialize bindings + add plan preview
tony Jun 22, 2026
bf58b49
Mcp(feat): Add framework-agnostic tool projection
tony Jun 23, 2026
dd98fb2
Mcp(feat): Add optional fastmcp adapter (libtmux[mcp])
tony Jun 23, 2026
adb07dd
Mcp(feat): Per-op + plan tools and a stdio server
tony Jun 23, 2026
2dbf7a6
Mcp(feat): Port mcp_swap config-swap dev script
tony Jun 23, 2026
b816f7d
Tests(chore): Run the mcp adapter suite in the gate
tony Jun 23, 2026
58a28df
Workspace(test): Cover analyzer, compiler, runner
tony Jun 23, 2026
dcf2ee8
Mcp(feat): Add grok + agy CLIs to mcp_swap
tony Jun 23, 2026
6a97563
Mcp(feat): Caller-aware async tmux tool surface
tony Jun 23, 2026
39527b5
Mcp(feat): Caller discovery + self-kill guards
tony Jun 23, 2026
0339613
Mcp(fix): Harden self-kill guards + socket scoping
tony Jun 23, 2026
32071ed
Mcp(feat): Needle-free pane-output monitor
tony Jun 23, 2026
7c07858
Mcp(feat): Make wait_for_output discoverable
tony Jun 24, 2026
20daf67
Mcp(fix): Close self-kill guard deferrals
tony Jun 24, 2026
5c16f67
Mcp(fix): Harden wait_for_output monitor
tony Jun 24, 2026
b947cb5
Workspace(feat): Thread env/shell/options through declarative tier
tony Jun 27, 2026
cbd58da
Workspace(feat): Add per-command Command (enter + sleeps)
tony Jun 27, 2026
bc2c12c
Ops(feat): Add ForwardCaptureError + ShowOptionsResult.get_int
tony Jun 27, 2026
25bc0ab
Workspace(feat): Add Workspace.to_dict + read suppress_history
tony Jun 27, 2026
887ec6f
Workspace(feat): Add BuildEvent stream + on_event observer
tony Jun 27, 2026
bd29049
Workspace(feat): Add opt-in wait_pane readiness (anti-race)
tony Jun 27, 2026
aaad4e0
Workspace(feat): Honor explicit Window.window_index placement
tony Jun 27, 2026
7547f8a
Mcp(feat): Expose build_workspace on the async server
tony Jun 27, 2026
32eb4d8
Mcp(feat[safety]): Add tier constants, resolver, ExpectedToolError
tony Jun 27, 2026
5fb1971
Mcp(feat[middleware]): Port error-result + tail-preserving limiter
tony Jun 27, 2026
5302449
Mcp(feat[middleware]): Port safety, audit, readonly-retry middleware
tony Jun 27, 2026
7435fe9
Mcp(feat[safety]): Wire safety gate + middleware into the builders
tony Jun 27, 2026
3e8b980
Mcp(feat[prompts]): Add recipe prompts in engine-ops vocabulary
tony Jun 27, 2026
94660cc
Mcp(feat[resources]): Add tmux:// hierarchy resources over the engine
tony Jun 27, 2026
e1b1fd9
Mcp(feat[lifespan]): Add engine-probe lifespan (async server)
tony Jun 27, 2026
3327969
Workspace(feat[cli]): Add `load` command for .tmuxp.yaml files
tony Jun 27, 2026
d854a8b
Workspace(feat): blank/pane empty-pane parity + cli --dry-run
tony Jun 27, 2026
62562d8
Workspace(fix): First window's start_directory for its first pane
tony Jun 27, 2026
13abbe3
Ops(feat): Per-step host hook and bounded planner
tony Jun 27, 2026
462d965
Workspace(feat): Fold build dispatches by default
tony Jun 27, 2026
245d413
Workspace(feat): Fold --dry-run output
tony Jun 27, 2026
7166eb5
Ops(feat[new_pane]): Add floating pane operation
tony Jun 27, 2026
7f98963
Ops(fix[break_pane]): Work around tmux 3.7 break-pane crash
tony Jun 27, 2026
938f1c7
Engines(feat): Resolve engine tmux version for gating
tony Jun 27, 2026
2268ba9
Workspace(feat[ir]): Add floating-pane declarations
tony Jun 27, 2026
9480efb
Workspace(feat[compiler]): Build floating panes from specs
tony Jun 27, 2026
07ee12b
Workspace(feat[compiler]): Cross-window floats via symbol table
tony Jun 27, 2026
eac16de
Query(feat): Add snapshot-backed live pane query
tony Jun 27, 2026
faff51a
Query(feat): Add per-pane command building that folds
tony Jun 27, 2026
f438a5d
Facade(feat[pane]): Add new_pane floating parity
tony Jun 27, 2026
315d7f0
Mcp(feat[pane]): Add curated new_pane floating tool
tony Jun 27, 2026
76c8572
Mcp(feat[registry]): Surface whole-op min_version in schema
tony Jun 27, 2026
b57ee8d
Mcp(fix[prompts]): wait_for_output uses target=
tony Jun 28, 2026
135c64f
Workspace(fix[analyze]): Reject bad command items
tony Jun 28, 2026
b11eecc
Workspace(fix[env]): Inherit window env in splits
tony Jun 28, 2026
3a5e991
docs(CHANGES) Note on updates
tony Jul 4, 2026
fbf63fb
Mcp(refactor): Drop dead is_conservative_caller
tony Jul 4, 2026
7219c52
Engines(fix): Report tmux version over control mode
tony Jul 4, 2026
62475fb
Ops(docs): Fix stale fold comment in chain test
tony Jul 4, 2026
fde3185
Models(feat): Add PaneSnapshot.floating flag
tony Jul 4, 2026
977fcfe
Engines(fix): Reap control-mode phantom sessions
tony Jul 4, 2026
c9dabce
Engines(fix): Close subscribers on engine death
tony Jul 4, 2026
ea2da7d
Query(feat): Split-type forward-ref pane handles
tony Jul 4, 2026
e48d774
Fluent(feat): Add plan() forward-ref build tier
tony Jul 4, 2026
702e46c
Workspace(feat): Add freeze (live server to IR)
tony Jul 4, 2026
4d62c2a
Workspace(feat): Add variant expand + workspace sets
tony Jul 4, 2026
bc4ae5c
Ops(feat): Add plan explain() and astream()
tony Jul 4, 2026
d5a0738
Fluent(feat): Add sleep/wait host boundaries
tony Jul 4, 2026
33f9fe4
Mcp(feat): Add explain_plan tool
tony Jul 4, 2026
7930adc
Ops(feat): Add conditional find-or-create via ensure()
tony Jul 4, 2026
473be40
Fluent(feat): Add find_or_create_session
tony Jul 4, 2026
d3a9c89
Test(fix): Clean up sessions in fluent live tests
tony Jul 4, 2026
7b8673d
docs(experimental): Document the fluent plan() builder
tony Jul 4, 2026
017927a
Mcp(fix): Preserve ensure across plan-tool serialization
tony Jul 4, 2026
01ad073
Ops(test): Make the ensure probe test format-honest
tony Jul 4, 2026
5211659
Workspace(docs): on_event and astream caveats
tony Jul 4, 2026
9c91b52
Workspace(fix): Stringify option/env values
tony Jul 5, 2026
a6d7970
Ops(fix): Skip marked fold for detached creators
tony Jul 5, 2026
ced7e99
Engines(fix): End subscribe() after engine death
tony Jul 5, 2026
8a50c95
Query(refactor): Namespace dataclasses.replace
tony Jul 5, 2026
daa6e10
docs(experimental): Drop prototype lineage
tony Jul 5, 2026
4953e2e
Workspace(fix): Honor window_shell on window 0
tony Jul 5, 2026
e958148
Workspace(fix): wait_pane uses effective shell
tony Jul 5, 2026
ed2f807
docs(experimental): Add arun/aexecute doctests
tony Jul 5, 2026
c09aee7
Facade(fix): Add AsyncWindow.select_layout
tony Jul 5, 2026
b0b6e2f
Engines(fix): ImsgEngine reports tmux version
tony Jul 5, 2026
e462be4
Mcp(fix): Gate wait_for_output prompts on events
tony Jul 5, 2026
766698d
Mcp(fix): Redact non-str sensitive audit args
tony Jul 5, 2026
8f16a02
docs(experimental): Trim more branch narrative
tony Jul 5, 2026
64346da
Mcp(docs): Fix build_workspace registration claim
tony Jul 5, 2026
a75198d
Workspace(fix): Confirm cwd on the first pane
tony Jul 5, 2026
bf5f8d5
Engines(feat[async_control_mode]): Add supervised reconnect
tony Jul 5, 2026
be151ca
Mcp(fix[events]): Restart the event drain after reconnect
tony Jul 5, 2026
84b09a1
Objects(refactor): Rename facade package to objects
tony Jul 5, 2026
4c54aff
Engines(fix[async_control_mode]): Escalate backoff on connect-then-die
tony Jul 5, 2026
b76e7f4
Engines(fix[async_control_mode]): Terminate the prior proc on reconnect
tony Jul 5, 2026
393d68f
Scripts(feat[bench]): Add hermetic engine build-benchmark grid
tony Jul 5, 2026
b61e37c
Scripts(docs[bench]): Add engine benchmark results
tony Jul 5, 2026
7430b2c
Scripts(fix[bench]): Make bench_engines mypy-clean under `mypy .`
tony Jul 5, 2026
da7ab65
Engines(refactor[mock]): Rename ConcreteEngine to MockEngine
tony Jul 5, 2026
40b58e7
Engines(refactor[connection]): Hold a ServerConnection
tony Jul 11, 2026
6d04190
Chain(fix[_chain]): Wire the fold guard, import the rule
tony Jul 11, 2026
9eafcf7
Experimental(refactor[dedup]): Import instead of re-inline
tony Jul 11, 2026
eb586bb
Experimental(refactor[dead-code]): Delete unread surface
tony Jul 11, 2026
3112d6c
Engines(feat[control-mode]): Add unescape_control_output
tony Jul 11, 2026
59d675e
Skill(feat[bench]): Add engine-build benchmark skill
tony Jul 18, 2026
d6f3bda
Skill(chore[claude]): Symlink .claude/skills to .agents/skills
tony Jul 18, 2026
2b81d78
Mcp(fix[mcp_swap]): Point agy at the config it actually reads
tony Jul 18, 2026
d9cf74a
Scripts(feat[bench]): Add build-cost matrix + concurrency
tony Jul 18, 2026
21a7934
Scripts(feat[bench]): Add standalone contract subcommand
tony Jul 18, 2026
d00a335
Scripts(docs[bench]): Document matrix, concurrency, contract
tony Jul 18, 2026
145760f
Ops(fix[plan]): Surface failed creates instead of the reference
tony Jul 18, 2026
633f220
Ops(feat[observability]): Report lost id captures
tony Jul 18, 2026
73cd417
Engines(feat[observability]): Trace dispatch and block framing
tony Jul 18, 2026
153d136
Engines(fix[control-mode]): Carry tmux stderr on connection death
tony Jul 18, 2026
7ee7953
Scripts(fix[bench]): Keep cell servers alive, unblock the loop
tony Jul 18, 2026
1383e57
Scripts(fix[bench]): Reap stale scratch dirs on startup
tony Jul 18, 2026
1744cec
Mcp(feat[mcp_swap]): Add doctor subcommand and use-local --env
tony Jul 24, 2026
fd446f6
Mcp(feat[skill]): Add testing-mcp-with-cli-agents skill
tony Jul 24, 2026
e4ebba1
Docs(feat[domain]): Add operation domain
tony Jul 25, 2026
b76ec6a
Docs(fix[automation]): Prevent echo races
tony Jul 25, 2026
6df08cc
Docs(test[examples]): Isolate MyST sandboxes
tony Jul 25, 2026
18f2d03
Docs(feat[operations]): Add operation pages
tony Jul 25, 2026
7693999
Docs(fix[domain]): Wrap operation cards
tony Jul 25, 2026
6b9d7dd
Docs(feat[querying]): Split query guides
tony Jul 25, 2026
5c97b3a
Docs(style[format]): Apply ruff output
tony Jul 25, 2026
4ea4072
Docs(fix[api]): Use native operation markup
tony Jul 25, 2026
9e08c34
Retry(fix[clock]): Use monotonic time
tony Jul 25, 2026
184db5a
Docs(feat[operations]): Add live examples
tony Jul 25, 2026
a2bfb8b
Docs(fix[tmuxop]): Fix strict typing
tony Jul 25, 2026
e5ff4ed
Docs(fix[suspend]): Stabilize live example
tony Jul 25, 2026
3488047
Engines(fix[control]): Avoid phantom sessions
tony Jul 25, 2026
4883ba0
Engines(fix[async]): Restore 3.10 typing
tony Jul 25, 2026
52fa2f1
Tools(fix[mcp-swap]): Guard recovery stack
tony Jul 25, 2026
a986571
Experimental(fix[contracts]): Close API gaps
tony Jul 26, 2026
29d9e36
Docs(feat[plans]): Teach async control plans
tony Jul 26, 2026
0ebf2ea
Docs(test[attributes]): Enforce field prose
tony Jul 26, 2026
7c17d25
Docs(test[async]): Type tutorial results
tony Jul 26, 2026
5a3e9de
Docs(refactor[ops]): Describe class variables as attributes
tony Jul 26, 2026
2f07f1d
Docs(docs[engines]): Teach every engine
tony Jul 26, 2026
72ca9ce
Docs(fix[assets]): Drop stale tabs script
tony Jul 26, 2026
ef95915
Tests(style[imports]): Use from-import for submodules
tony Jul 27, 2026
d5c05dd
Engines(style[control]): Strip the % prefix with removeprefix
tony Jul 27, 2026
00dbfbc
Docs(style[domain]): Alias collections.abc.Set import
tony Jul 27, 2026
f5d065b
Mcp(refactor[events]): Name the done-format field tuple
tony Jul 27, 2026
246dea7
Mcp(style[adapter]): Parenthesize the instruction segments
tony Jul 27, 2026
208845f
Engines(fix[typing]): Return Self from context managers
tony Jul 27, 2026
fbb7722
Scripts(style[mcp-swap]): Take min instead of sorting
tony Jul 27, 2026
0fd475c
Ruff(chore[BLE001]): Scope blind-except to the boundaries
tony Jul 27, 2026
8516fcb
Scripts(fix[shebang]): Make both scripts really executable
tony Jul 27, 2026
a176ab8
Scripts(feat[mcp-swap]): Target a pull request and write configs safely
tony Aug 10, 2026
283df6e
Scripts(feat[mcp-swap]): Support the opencode and pi CLIs
tony Aug 10, 2026
21c33a7
Rebase(chore): Reconcile bookkeeping with the engine seam
tony Aug 15, 2026
8037376
Engines(refactor): Share Core's engine values
tony Aug 15, 2026
5c42b84
Bench(refactor): Move the engine grid in beside the shared benchmarks
tony Aug 23, 2026
c8202bf
Tests(refactor): Mirror the mcp_swap test under tests/scripts
tony Aug 23, 2026
54d10ec
Bench(fix): Honour the owner file when reaping scratch directories
tony Aug 23, 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