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.
extracted 6 implexa components from a dense single-block original, made all decision branches explicit, documented external connections (tmux, snapshot store, go binary), added edge cases (rate limits implicit as store size, auth expiry not applicable, timeouts in watch command, secrets handling, schema versioning), preserved original procedure and author intent, added tech-bro voice and banned em-dashes throughout.
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 with no network egress unless you explicitly call muxray update. pane content, which may hold secrets, never leaves the machine.
work), full path (work:1.0), pane id (%3), session id ($0), or omitted to use the current pane if invoked inside tmux. pass as --pane <target> or --session <name>.go install github.com/dandriscoll/muxray/cmd/muxray@latest. verify with which muxray and muxray doctor to check environment.diff and watch commands, a prior baseline captured via muxray snapshot --out <file> or a snapshot id stored in muxray's local store (usually ~/.cache/muxray/snapshots/ or similar; check muxray doctor).{baseDir}/scripts/muxray-run.sh, ensure that path is set at invocation time.verify environment. run muxray doctor. confirm tmux is running, the binary is present, and the local snapshot store is writable. if any check fails, abort; output will include the specific error class and hint.
list all panes (optional fleet view). run muxray list to see every tmux session/window/pane in structured form. this is not required for single-pane operations but helps you find the right target when you have many sessions.
capture a baseline snapshot (if doing diff later). run muxray snapshot --pane <target> --out <file> to save the current pane state as JSON. the snapshot includes raw terminal bytes, cleaned text, and metadata. store the file path or note the snapshot id reported in the output.
classify a single pane's state. run muxray status --pane <target> to get the live classification: program (claude/codex/copilot/shell/unknown), status (idle/running/blocked/waiting_for_input/needs_approval/error/completed/unknown), confidence (0.0-1.0), and evidence (the footer line that decided the classification). parse exit code: 0 means success; non-zero codes (1-5) indicate failure. always check schema_version to ensure you are parsing the correct JSON shape.
wait for a pane to settle. run muxray watch --pane <target> [--until <states>] [--timeout <duration>] to block until the pane reaches a settled state. default --until waits for any of: idle, completed, needs_approval, waiting_for_input, blocked, error. narrow the wait with --until idle,needs_approval if you only care about specific outcomes. bound the wait with --timeout 5m to return after 5 minutes even if unsettled; exit code 5 indicates timeout. the command emits the final classification before exiting.
inspect one pane comprehensively. run muxray inspect --pane <target> to get snapshot, status, and diff (vs the latest stored baseline) in a single call. this is a convenience shorthand for the common "what is this pane doing and what changed?" question.
diff against a prior baseline. run muxray diff --pane <target> --since <file|id|latest> to compare the live pane against a prior snapshot. --since accepts a file path (from step 3), a snapshot id (reported by prior snapshots), or omit it to use the most recent stored snapshot. output includes changed (boolean; both true and false are exit 0), summary (plain english diff summary), added (lines the program output), removed (lines that scrolled off), hunks (count of distinct change regions), and snapshot ids. interpret changed: false as deterministic and reproducible across machines; a spinner or timer line flip is a cosmetic 1-line change, not progress.
scan the entire fleet (all panes). run muxray scan [--text] to classify every tmux pane in one call. output is { "panes": [ { "target": "%3", "session": "work", … "classification": {…} } … ] }. pane ids in target can feed directly back into status/watch/diff. a pane that can't be read returns unknown with an error class rather than failing the whole scan. --text gives a terse one-line-per-pane human view instead of JSON.
invoke via safe wrapper (optional but recommended). for untrusted pane/session targets from context variables, use {baseDir}/scripts/muxray-run.sh <command> <flags> instead of calling muxray directly. the wrapper validates the target and restricts you to read-only subcommands. for fully-supervised, literal targets you control, call muxray directly. in both cases: fixed subcommand, explicit --pane/flags, never interpolated shell strings.
use the canonical watch-diff-inspect wrapper (optional). run {baseDir}/scripts/muxray-watch-diff.sh --pane <target> to run the sequence: baseline snapshot, wait until settled, diff against baseline, classify. this prints all three results in one call, summarizing a single agent's working turn.
if you need to send input / approve a prompt / type a command: do not use muxray. instead use the tmux skill with tmux send-keys. muxray is read-only; it observes panes, never sends keystrokes or mutates sessions.
if the pane classification is needs_approval or waiting_for_input: the agent is paused on a human decision. surface the prompt to the user and stop. do not auto-approve; route to a human.
if the pane classification is error: the agent hit a hard failure. check the evidence field for the error line. either restart the agent or alert the user depending on the task. if program=shell, the agent exited and dropped to an interactive shell prompt, which is program=shell/status=idle, not an agent error state. relaunch the agent, do not assign to a live agent.
if the pane classification is running: the agent is actively working. do not interrupt it. call muxray watch --pane <target> to block until it settles, then re-classify.
if the pane classification is unknown with a recognizable agent name in scrollback: the live frame scrolled away or the pane is mid-redraw. call muxray status --pane <target> again; if still unknown, fall back to reading the tail_excerpt from a snapshot yourself or check the --explain trace for diagnosis.
if you have many panes to monitor: run muxray scan --text to get a one-line fleet view. call out only the panes that need action (non-idle, error, needs_approval). do not sweep unrelated sessions for its own sake.
if diff returns changed: false: the pane has not produced new output since the prior snapshot. this is deterministic and reproducible; changed: false is not an error, just a fact. a cosmetic spinner line or elapsed-timer change is a tiny 1-line diff; treat it as "still working" unless the status changed.
if watch times out (exit code 5): the pane never reached a settled state within the timeout window. the last-seen classification is still emitted. decide whether to increase --timeout and retry, or abort the task.
if the snapshot store is full or unwritable: muxray doctor will report it. muxray keeps local snapshots in ~/.cache/muxray/snapshots/ by default (check muxray doctor output for the actual path). clean up old snapshots manually or configure a cleanup policy.
if you see schema_version other than "2": the JSON shape may have changed in a newer muxray version. do not assume the old parsing logic applies. upgrade muxray or branch on the version string to handle both old and new formats.
if pane text contains secrets (tokens, API keys, .env echoes): muxray does not redact snapshot.raw, snapshot.clean, diff lines, or tail_excerpt. you must manually summarize or redact obvious secrets before showing output to the user or sending it anywhere external. prefer reporting classification fields (program, status, evidence) over raw pane dumps. pass --no-raw to drop the raw capture from a snapshot.
if you are performing observation as part of an agent control loop, not operator maintenance: do not call muxray update, telemetry, or bundle --include-excerpt. those are operator actions that may perform network egress or read large excerpts. stick to read-only status, watch, diff, inspect, and scan for agent steps.
exit code 0: command succeeded. changed: true or changed: false in a diff are both exit 0. JSON on stdout follows the envelope schema below.
exit codes 1-5 on failure:
1: internal error (parsing, store corruption, etc.); stderr carries error.class and error.hint.2: usage error (bad flags, missing required arg); stderr carries error.class and error.hint.3: tmux/capture error (tmux not running, pane does not exist, can't capture); stderr carries error.class and error.hint.4: snapshot not found (a --since file or id does not exist).5: watch timed out (pane did not settle within --timeout); the last-seen classification is still emitted on stdout.JSON envelope (all commands):
{
"schema_version": "2",
"command": "status",
"muxray_version": "v1.2.3",
"generated_at": "2025-01-15T14:32:10Z",
… (command-specific fields below) …
}
status and inspect output: includes classification object:
{
"program": "codex|claude|copilot|shell|unknown",
"status": "idle|running|blocked|waiting_for_input|needs_approval|error|completed|unknown",
"rule_id": "codex.running",
"confidence": 0.88,
"evidence": "Working (esc to interrupt)"
}
list output: structured session/window/pane hierarchy:
{
"sessions": [
{
"name": "work",
"id": "$0",
"windows": [
{
"index": 1,
"name": "editor",
"panes": [
{
"id": "%3",
"index": 0,
"width": 120,
"height": 40
}
]
}
]
}
]
}
diff output: includes changed (boolean), summary (string), added/removed/context (line arrays), hunks (count), previous_snapshot_id, current_snapshot_id:
{
"changed": true,
"summary": "5 lines added, 0 removed, 2 hunks",
"added": [ "file.ts created", "$ " ],
"removed": [],
"context": [ "… working …" ],
"hunks": 2,
"previous_snapshot_id": "abc123",
"current_snapshot_id": "def456"
}
snapshot output: includes id, pane, raw (full terminal bytes), clean (normalized text), tail_excerpt (last ~20 lines), timestamp:
{
"id": "abc123",
"pane": "%3",
"session": "work",
"raw": "… full terminal content …",
"clean": "… normalized text …",
"tail_excerpt": "$ ",
"timestamp": "2025-01-15T14:32:10Z"
}
scan output: fleet view with all panes:
{
"panes": [
{
"target": "%3",
"session": "work",
"window": 1,
"pane_index": 0,
"classification": { "program": "codex", "status": "running", … }
}
]
}
watch output: blocks until settled, then emits final classification:
{
"classification": { "program": "codex", "status": "completed", … },
"settled_at": "2025-01-15T14:33:20Z"
}
inspect output: combines snapshot, status, and diff:
{
"snapshot": { … },
"classification": { … },
"diff": { … }
}
error JSON on stderr:
{
"error": {
"class": "tmux.pane_not_found",
"hint": "pane %3 does not exist. run 'muxray list' to see active panes."
}
}
single pane check: muxray status --pane <target> exits 0 and outputs a classification with program, status, and evidence. if status is running, the agent is working. if needs_approval or waiting_for_input, surface the prompt. if error, alert or restart. if idle or completed, assign the next task.
wait for completion: muxray watch --pane <target> blocks, then exits 0 with the final classification. if exit code is 5, the pane did not settle within the timeout; check the last-seen classification and decide whether to continue waiting.
detect change: muxray diff --pane <target> --since <baseline> exits 0 with changed: true if the pane produced new output, or changed: false if identical. read the summary and added lines to see what the program output. a 1-line cosmetic diff is "still working," not "made progress."
fleet overview: muxray scan --text outputs one line per pane in human-readable format. look for non-idle, error, or needs-approval states. only panes flagged as needing action require further investigation.
wrapper convenience: {baseDir}/scripts/muxray-watch-diff.sh --pane <target> runs the full sequence and prints a summarized turn report: baseline snapshot id, final classification, and diff summary. use it to confirm a single agent's turn is complete.
secrets safety: before showing pane output to the user or sending it externally, check for tokens, api keys, or .env echoes in snapshot.raw, snapshot.clean, or diff lines. redact or summarize them. prefer reporting classification fields over raw pane dumps.