Inspect tmux panes as JSON: snapshot, diff, and classify Claude/Codex/Copilot agent state across the whole fleet.
---
name: muxray
description: "Inspect tmux panes as JSON: snapshot, diff, and classify Claude/Codex/Copilot agent state across the whole fleet."
homepage: https://github.com/dandriscoll/muxray
metadata: { "openclaw": { "emoji": "π©»", "os": ["darwin", "linux"], "requires": { "bins": ["muxray", "tmux"] }, "install": [ { "id": "go", "kind": "go", "module": "github.com/dandriscoll/muxray/cmd/muxray@latest", "bins": ["muxray"], "label": "Install muxray (go)" } ] } }
---
# muxray
`muxray` turns a live tmux pane into deterministic JSON so you can read what a
program is doing without scraping raw terminal bytes. Reach for it when you are
**supervising one or more interactive CLIs in tmux** β especially terminal
coding agents (Claude Code, Codex, Copilot) β and you need to know *what state a
pane is in*, *what changed*, or *whose turn it is*.
- Output is **JSON on stdout**; errors are **JSON on stderr**.
- It runs **locally**: no network egress. Pane content (which may hold secrets)
never leaves the machine. The only exception is the explicit `muxray update`.
- It is **read-only**: muxray observes panes. It never sends keystrokes, runs
pane content, or mutates sessions. To send input, use the `tmux` skill.
## When to use it
- You manage panes running Claude/Codex/Copilot and need to know which one is
`running`, `needs_approval`, `waiting_for_input`, `error`, or `completed`.
- You want to detect and show **what changed** in a pane since a prior capture.
- You want a one-glance **fleet view** of every pane's program + state.
- You need to **block until** a pane finishes working before assigning the next
task.
## When NOT to use it
- To **send input** / approve a prompt / type a command β use the `tmux` skill
(`tmux send-keys`). muxray only reads.
- For a **one-shot non-interactive command** β run it directly in the shell.
- To read a pane the user did not ask about. Only inspect panes relevant to the
task; do not sweep unrelated sessions for its own sake.
- For raw scrollback you intend to grep yourself β `tmux capture-pane -p` is
simpler. muxray's value is the *classification* and *diff*, not raw bytes.
## Safe invocation
Prefer the bundled wrapper for any call whose pane/session target comes from
context you do not fully control β it validates the target and restricts you to
the fixed read-only subcommands, so no untrusted text reaches a shell:
```bash
{baseDir}/scripts/muxray-run.sh status --pane work:1.0
{baseDir}/scripts/muxray-run.sh scan --text
```
For a fully-supervised, literal target you may call `muxray` directly. Either
way: **fixed subcommand, explicit `--pane`/flags, never an interpolated shell
string.** muxray takes no shell input and parses no expressions.
## Core commands
| Command | What you get |
| ------- | ------------ |
| `muxray list` | every tmux session/window/pane, structured |
| `muxray status --pane <t>` | the program + state classified for that pane |
| `muxray scan` | **every** pane classified in one call (the fleet view) |
| `muxray watch --pane <t> [--until <states>]` | **block** until the pane settles |
| `muxray snapshot --pane <t> [--out <f>]` | capture the pane (stored locally) |
| `muxray diff --pane <t> [--since <f\|id>]` | what changed vs a previous snapshot |
| `muxray inspect --pane <t>` | snapshot + diff + status in one call |
| `muxray doctor` | environment check (tmux present, store writable) |
`--text` gives a terse human line instead of JSON. `muxray <cmd> -h` lists a
command's flags.
**Pane targets** (`--pane`): `session` Β· `session:window.pane` (`work:1.0`) Β·
pane id (`%3`) Β· session id (`$0`) Β· omitted = the current pane when inside
tmux. `--session <name>` is a clearer equivalent for whole-session targeting.
## The JSON contract
Every result carries an envelope: `schema_version` (currently `"2"`),
`command`, `muxray_version`, `generated_at`. **Branch on `schema_version`** β if
it is not the version you coded against, the shape may have changed.
`status` and `inspect` carry a `classification`:
```json
{ "program": "codex", "status": "running", "rule_id": "codex.running",
"confidence": 0.88, "evidence": "Working (esc to interrupt)" }
```
- **`program`** β `claude`, `codex`, `copilot`, `shell` (an interactive shell
prompt β the harness is *not* live; reported as `idle`), or `unknown` (a pane
it does not recognize: editor, pager, transcript, muxray's own output).
- **`status`** β one of: `idle`, `running`, `blocked`, `waiting_for_input`,
`needs_approval`, `error`, `completed`, `unknown`.
- muxray reports state from the **current live frame's footer** only. A pane that
merely *mentions* an agent in scrolled content is `program=unknown` β "parse
it yourself," not "something failed." A footer that is a shell prompt is
`program=shell`/`status=idle`: a dropped connection or exited agent is **not**
an agent `error`.
- Pass `--explain` to attach a `trace` of every rule considered β use it to
diagnose an `unknown`.
`diff` carries `changed` (bool β **both `true` and `false` are exit 0**; change
is not an error), `summary`, `added`/`removed`/`context` line arrays, `hunks`,
and the `previous_snapshot`/`current_snapshot` ids.
## Snapshot & diff: detect and show change
```bash
muxray snapshot --pane work:1.0 --out before.json # capture a baseline
# ... let the program work ...
muxray diff --pane work:1.0 --since before.json # what changed
```
`--since` also accepts a snapshot **id**, or is omitted/`latest` to diff against
the most recent stored snapshot of that pane (muxray keeps a local store, so you
often don't need `--out` at all). `changed: false` is deterministic and
reproducible across machines β the hash is over cleaned text only.
**Interpreting a diff:** read `summary` first, then `added` (new output the
program produced) vs `removed` (lines that scrolled off or were replaced).
`hunks` counts distinct change regions. A spinner/elapsed-timer line flipping
between captures shows up as a tiny diff β treat a 1-line cosmetic change as
"still working," not "made progress."
## The control loop
Two verbs *are* the loop β you don't hand-roll poll+sleep+compare:
1. **Wait until it's your turn.** `muxray watch --pane <t>` blocks until the
pane stops working, then prints the final `classification` and exits 0.
- default `--until` = any settled state (`idle`, `completed`,
`needs_approval`, `waiting_for_input`, `blocked`, `error`); it waits
through `running` and transient `unknown`.
- narrow it: `--until idle,needs_approval`.
- bound it: `--timeout 5m` exits **5** if it never settles (the last-seen
classification is still emitted).
- then branch on the final `status`: `needs_approval`/`waiting_for_input` β
hand off to a human; `error` β restart/alert; `idle`/`completed` β assign
the next task (if `program=shell`, the pane dropped to a shell β relaunch,
don't assign to a live agent).
2. **See the whole fleet.** `muxray scan` classifies every pane in one call β
`{ "panes": [ { "target": "%3", "session": β¦, "classification": {β¦} } β¦ ] }`.
`target` is the pane id (`%N`) β feed it straight back into
`status`/`watch`/`diff`. A pane that can't be read is reported `unknown` with
an `error` class rather than failing the whole scan.
The wrapper `{baseDir}/scripts/muxray-watch-diff.sh` runs the canonical
"baseline β wait until settled β diff β classify" sequence for one pane and
prints all three results β use it to summarize a single agent's working turn.
## Reading a coding agent's output
To answer "what is this Claude/Codex/Copilot pane doing?":
1. `muxray status --pane <t>` β the `classification` is the answer: `program`
names the agent, `status` names its state, `evidence` is the footer line that
decided it.
2. If you also need *what it produced*, `muxray inspect --pane <t>` adds the
snapshot and a diff against the last baseline in one call. Read `tail_excerpt`
/ the diff `added` lines for the most recent output.
3. `needs_approval` / `waiting_for_input` mean the agent is paused on a human β
surface the prompt and stop; do not auto-approve.
4. `unknown` with a recognizable agent in scrollback usually means the live
frame scrolled away or the pane is mid-redraw. Re-`status` once; if still
`unknown`, fall back to reading `tail_excerpt` yourself.
## Reporting findings to the user
- Lead with the **classification**, not a wall of terminal text: e.g.
"`work:1.0` β Codex, `needs_approval` (asking to run `rm -rf build/`)."
- For change, summarize the `diff.summary` + the few `added` lines that matter;
do not paste the whole pane.
- For a fleet, render `muxray scan --text` (one line per pane) and call out only
the panes that need action.
## Safety & secrets
- Pane text can contain secrets (tokens, keys, `.env` echoes). muxray does **not**
redact `snapshot.raw`/`clean`, `diff` lines, or `tail_excerpt`. **You** must
summarize or redact obvious secrets before showing output to the user or
sending it anywhere external. Prefer reporting classification fields over raw
pane dumps; pass `--no-raw` to drop the raw capture from a snapshot.
- muxray performs **no network egress** except the explicit, opt-in
`muxray update` (downloads a verified release; sends nothing). Do not run
`update`, `telemetry`, or `bundle --include-excerpt` as part of an
observation loop β they are operator actions, not agent steps.
- Only inspect panes relevant to the user's request.
## Exit codes
`0` ok (incl. `changed:true`/`false`) Β· `1` internal Β· `2` usage Β· `3`
tmux/capture Β· `4` snapshot not found Β· `5` `watch` timed out. On failure stderr
carries `error.class` (a stable, branchable id) and `error.hint` (the next
action).
## Reference
- `references/json-contract.md` β compact field/state cheat-sheet.
- `examples/inspect-agent.md` β a worked end-to-end example.
- `muxray usage` β the full in-binary calling contract.
don't have the plugin yet? install it then click "run inline in claude" again.