# pi-tmux-harness

[![npm version](https://img.shields.io/npm/v/pi-tmux-harness?style=flat-square)](https://www.npmjs.com/package/pi-tmux-harness)
[![License](https://img.shields.io/github/license/bagatelia/pi-tmux-harness?style=flat-square)](LICENSE)

Tmux session/keystroke/capture exposed as native [pi coding agent](https://github.com/badlogic/pi-mono) tools, so any agent in pi can drive other terminal programs (most importantly: another pi instance, claude code, copilot CLI, lazygit, htop, gh) for adversarial testing — without ad-hoc `bash → tmux send-keys → sleep → grep` chains.

## Why

When testing pi extensions, you end up writing this kind of fragile shell:

```bash
tmux new-session -d -s pi-test 'pi'
sleep 8                                    # how long is enough?
tmux send-keys -t pi-test '/myextension' Enter
sleep 12                                   # guess again
tmux capture-pane -t pi-test -p | tail -25 | grep "expected output"
```

Every `sleep` is a guess. Wrong guesses produce flaky tests. The `grep` is eyeball-driven assertion.

This extension turns that into:

```typescript
tmux_new({ command: "pi", cwd: "/path/to/repo" })
tmux_expect({ name, pattern: "\\[Extensions\\]", timeoutMs: 30000 })  // ready signal
tmux_send({ name, text: "/myextension foo" })
tmux_expect({ name, pattern: "expected output regex", timeoutMs: 15000 })
tmux_capture({ name })  // full snapshot for further assertions
tmux_kill({ name })
```

The killer feature is `tmux_expect`: poll `capture-pane` until a regex matches or timeout fires. Eliminates ~90% of the fragile sleep+grep we used to write.

## Install

```bash
pi install npm:pi-tmux-harness
```

Then restart pi (or start a new session). The 7 tools below register automatically.

## Tools

All sessions go to an isolated tmux socket (`-L pi-harness`) so they never appear in your normal `tmux ls` output, can't accidentally be attached to, and `kill all` is bounded.

| Tool | Purpose |
|---|---|
| `tmux_new` | Spawn a detached tmux session running any command (default: shell). Returns the session name. |
| `tmux_send` | Send literal text. Optional `enter: true` (default) appends Enter. |
| `tmux_send_key` | Send a named special key (Escape, Tab, Up/Down, C-c, C-d, BSpace, etc.) — uses tmux's native key names. |
| `tmux_capture` | Snapshot the pane. Configurable scrollback lines + trim. |
| `tmux_expect` | Poll capture until a regex matches or timeout. Returns `{matched, match?, elapsedMs, lastSnapshot}`. **The fragile-sleep killer.** |
| `tmux_kill` | Kill a session. Pass `name: "all"` for every managed session. |
| `tmux_list` | List sessions managed by this extension (with elapsed time + cwd). |

## Hardening

This extension has been through ~10 rounds of multi-model code review. The defenses include:

- **Regex DoS protection** in `tmux_expect`: AST-level rejection of obviously catastrophic patterns (nested quantifiers like `(a+)+`, `(a{1,2})+`, etc.) + 32KB input cap on the regex search window
- **Tmux flag injection blocked**: strict allowlist `/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/` on session names, validated at every tool call. Rejects names starting with `-` (which would be parsed as tmux flags) and reserved names like `all`
- **Tmux command-separator blocked**: `tmux_send_key` rejects `;`, `{`, `}`, and control bytes anywhere in key names (would otherwise escape the `send-keys` command context)
- **Exact-target syntax**: every `-t name` invocation uses tmux's exact-match form (`=name` for session targets, `=name:` for pane targets) to defeat tmux's default prefix matching
- **AbortSignal honored** at every poll boundary in `tmux_expect`
- **Bounded LRU** on the managed-session map (hard cap 64; rejects new sessions when at cap)
- **Tracker bookkeeping** for prune-on-list when sessions disappear

## Per-agent reference (drive these via the harness)

The harness is agent-agnostic — it doesn't care what's in the tmux pane. Put the right binary in `tmux_new`'s `command` and use the right ready-signal in `tmux_expect`.

### pi (recursive testing)

```jsonc
tmux_new({ command: "pi", cwd: "/path/to/repo", width: 220, height: 50 })
tmux_expect({ name, pattern: "\\[Extensions\\]", timeoutMs: 30000 })  // pi finished startup
tmux_send({ name, text: "/t why does my parser leak memory" })
tmux_expect({ name, pattern: "\\[Triage\\]", timeoutMs: 15000 })
tmux_send_key({ name, keys: ["Escape"] })  // close any modal
tmux_send_key({ name, keys: ["C-d"] })     // quit pi
```

### Claude Code

```jsonc
tmux_new({ command: "claude", cwd: "/path/to/repo" })
tmux_expect({ name, pattern: "\u2502\\s*>\\s*\u2502|Try .*claude", timeoutMs: 30000 })
tmux_send({ name, text: "explain the auth flow" })
tmux_send_key({ name, keys: ["Escape"] })  // interrupt running operation
tmux_send({ name, text: "/exit" })
```

### GitHub Copilot CLI

```jsonc
tmux_new({ command: "copilot", cwd: "/path/to/repo" })
tmux_expect({ name, pattern: "GitHub Copilot|Welcome|Trust this folder|copilot>", timeoutMs: 30000 })
// If "Trust this folder?" appears, accept it:
tmux_send_key({ name, keys: ["Enter"] })
tmux_send({ name, text: "summarize this repo" })
tmux_send_key({ name, keys: ["Escape"] })  // cancel "Thinking..."
tmux_send({ name, text: "/exit" })
```

### Generic shell (sanity check)

```jsonc
tmux_new({ command: "/bin/sh" })
tmux_send({ name, text: "echo READY_$(date +%s)" })
tmux_expect({ name, pattern: "READY_\\d+", timeoutMs: 5000 })
```

## When to use harness vs `-p` print mode

| Need | Use |
|---|---|
| One-shot answer, parse stdout | `claude -p` / `copilot -p` / `pi -p` directly. No harness needed. |
| Multi-step interactive flow with cancels / modals / slash commands | Harness. |
| Drive a TUI you don't control (lazygit, htop, gh, etc.) | Harness. |
| Stream structured events to a supervisor | `claude --output-format stream-json` directly. No harness needed. |
| Test that a pi extension renders a specific message at a specific point | Harness with `tmux_expect`. |

## Requirements

- tmux (≥ 3.2 recommended for full key-modifier support)
- Pi coding agent

## License

MIT
