shux — terminal multiplexer with a JSON-RPC API + pixel snapshotter
Install
npx skills add indrasvat/shux --global --yes
shux is a Rust terminal multiplexer (sessions / windows / panes, like tmux)
that also exposes a length-prefixed JSON-RPC surface over UDS and TCP,
atomic declarative templates, optimistic concurrency on every entity, a
sealed event bus, and a built-in rasterizer that returns PNG bytes for any
pane — no terminal emulator in the loop.
When to reach for it
Pick shux instead of the alternatives when any of these apply:
- You need to drive a TUI from outside (agent, CI, script) without a human at the keyboard.
- You want a PNG of what a terminal looks like right now, headless, no display server.
- You're running scripted CLI/REPL interactions that need typed keystrokes and known wait points.
- You're doing visual regression on a TUI you built (Bubbletea, Charm, ratatui, anything).
- You need that regression check to run in CI, against committed goldens, and fail the
build when the rendering drifts —
shux lens gate.
- You're asked to verify terminal UI behavior: layout, alignment, keyboard navigation, color rendering, or screenshot evidence.
- You want declarative workspace templates that apply atomically.
If you're a human at a keyboard and tmux works for you, keep using tmux.
When a human does attach to shux, the normal interactions should still feel
modern: click panes to focus, drag borders to resize, drag visible text to copy
via OSC 52, and right-click a visible selection for the inline Copy / Clear
menu. Reserve prefix copy mode for scrollback, search, and keyboard-only
selection.
Where shux artifacts live: .shux/
Run shux init once per project. It creates a top-level .shux/ dir:
.shux/
├── templates/ # spec.toml files you commit (committed)
├── scripts/ # automation scripts you commit (committed)
├── goldens/ # reference frames for the manual verify loop (committed)
├── out/ # snapshots, diffs, logs, anything ephemeral (gitignored)
└── .gitignore # ignores `out/`
When you write code that produces shux artifacts:
- Put templates under
.shux/templates/ (apply with shux state apply .shux/templates/<name>.toml).
- Put driver scripts under
.shux/scripts/.
- Write snapshots, diffs, debug logs into
.shux/out/ (gitignored by default).
- Commit reference frames to
.shux/goldens/ when you diff by hand.
shux lens gate is different: it keeps each scenario's goldens beside the scenario
file (goldens/<scenario>/, overridable with --golden-dir), and at the cell tier a
golden is a .capture.json — reviewable text, not a PNG. Don't hand-place gate goldens
under .shux/goldens/.
Never pollute .claude/, ~/, or the project root with shux output.
80% quickstart (three RPCs)
# 1. Spawn a session running any command (or shell). Capture the
# pane_id from the response so the next calls can target it. CLI
# session creation starts in the caller's current directory unless
# you pass --cwd.
RESP=$(shux --format json session create demo -d --title demo -- lazygit)
PID=$(echo "$RESP" | jq -r .pane_id)
# 2. Drive it.
shux pane set-size -s demo --cols 200 --rows 60
shux pane send-keys -s demo --text 'j' # text input
shux pane send-keys -s demo --data 'Gw==' # base64 control (here: Esc)
# 3. Get a PNG back.
shux --format json pane snapshot -s demo \
| jq -r .png_base64 | base64 -d > frame.png
# Tear down when done.
shux session kill demo
Almost every CLI verb maps 1:1 to an RPC method — RPC dots become CLI spaces
(session.create ↔ shux session create). The exception is the system.*
namespace: the RPC is system.version, but the CLI verb is shux version
(there is no shux system). Drop to the raw form with
shux rpc call <method> --params @file whenever you'd rather write the payload
in JSON directly, or when a method has no CLI wrapper. --params - reads stdin.
For declarative multi-pane workspaces, use a template:
# spec.toml
[session]
name = "review"
[[windows]]
title = "vivecaka"
[[windows.panes]]
command = ["vivecaka", "--repo", "cli/cli"]
shux state apply spec.toml # atomic — all or nothing
Leave nothing running
shux is a daemon plus long-lived PTY children. Anything you spawn outlives your
command unless you kill it, so treat cleanup as part of the task, not an afterthought:
shux session kill <name-or-id> # every session you created, including scratch ones
shux session list --include-scratch # verify: nothing of yours is left
shux daemon stop # then stop the daemon itself
- Kill scratch sessions explicitly.
lens run reaps its scratch session on --ttl
and at --max-runtime, but that is a backstop, not your cleanup — a run that ends
early still holds the session until the TTL expires.
- Isolate, so cleanup can't overreach. Export a short, private
XDG_RUNTIME_DIR (e.g. /tmp/mygate) before any scripted or CI use. Your daemon then
lives on its own socket, under its own directory. Keep the path short — a long one
overruns the Unix-socket length limit.
- Stop the daemon with
shux daemon stop. Killing a session does not stop the daemon;
it keeps running and will show up in anyone's process-hygiene check. daemon stop
SIGTERMs exactly the daemon recorded in this runtime dir's pidfile, waits for it to go,
and exits 0 when none is running — so it is safe in a cleanup trap that may run twice.
shux daemon status reports whether one is up. There is no daemon start — any client
command starts one on demand, creating the runtime dir if needed.
- Never
pkill -f shux. It kills other checkouts', other agents', and the user's own
sessions. Note also that pgrep -f "$XDG_RUNTIME_DIR" does not find a shux daemon —
the runtime dir is not in its argv, so that matches nothing and reports success while the
daemon lives on.
lens — prove a TUI change worked, with pixel proof
You fixed a rendering bug, or built a new TUI screen, and need to prove
it — not just "it compiled", but "here's the before/after pixels and the
exact cells that changed". That's lens: run (spawn hidden, no shell)
→ settle (block until the screen stops repainting — no sleeps) →
glance (atomic PNG+text+revision of one frame) → drive
(pane.send_keys, already above) → diff (exactly which cells changed,
with a heat-map PNG). Five commands total, and shux lens --help prints
this recipe on demand:
RUN=$(shux --format json lens run --size 120x30 -- ./my-tui)
PANE=$(echo "$RUN" | jq -r .result.pane_id)
shux pane wait-settled "$PANE" --quiet 300ms --timeout 10s
REV=$(shux --format json pane glance "$PANE" --checkpoint --png before.png | jq -r .result.revision)
shux pane send-keys -s "$(echo "$RUN" | jq -r .result.session_id)" -p "$PANE" --text 'q'
shux pane wait-settled "$PANE" --quiet 300ms --timeout 10s
shux pane diff "$PANE" --since "$REV" --heat delta.png
shux session kill "$(echo "$RUN" | jq -r .result.session_id)"
lens run spawns into a hidden, quota-bounded, self-cleaning scratch
session — excluded from session list unless you pass
--include-scratch, auto-reaped --ttl after the command exits or at
--max-runtime regardless. Reach for lens run when you're spawning
something new to verify; reach for plain session create + pane snapshot when you're screenshotting a pane a human (or a longer-lived
workflow) already owns.
Glance/diff output is exactly what's on screen — secrets included, no
automated redaction. Don't glance a pane you didn't spawn yourself unless
the user asks you to.
Full grammar, exit-code table, checkpoint/FIFO semantics, and the
--wait signal-death caveat: references/lens.md.
lens gate — keep a TUI from regressing, in CI
lens proves a change worked once, by hand. lens gate is the CI form: a committed
TOML scenario drives the TUI in a deterministic sandbox, captures named frames, and
compares them against committed goldens — like insta/jest --ci snapshots, except
the snapshot is a terminal frame including colour. A text diff cannot see
bright_green become green; the cell tier can.
shux lens gate scenario.toml # exit 0 while nothing moved
shux lens gate scenario.toml --report - # report.json for CI to parse
shux lens gate scenario.toml --update # re-bless an INTENDED change
Exit codes are frozen: 0 pass · 1 regression · 2 usage · 3 infra · 4 could not write
the report · 5 child died · 6 update refused. A frame with no committed golden is a regression, so a golden can
never be self-minted in CI.
When it fails, --out gets a heat PNG per frame marking the changed cells, and
report.json carries diff.regions (row + column span of every changed run) — usually
enough to localize without opening anything.
Two things bite everyone on their first scenario, both because the sandbox starts from an
empty environment in a scratch directory:
- Your tool is not on the sandbox
PATH (/usr/local/bin:/usr/bin:/bin) — anything
from Homebrew or ~/.local/bin needs an explicit [env] PATH = "…".
- The child's cwd is a temp dir, not your project — set
cwd (relative to the scenario
file) to run a program that lives beside it.
And blessing is for intended changes only: if the gate is red and you did not mean to
change the UI, --update hides the bug instead of fixing it. Read the heat PNG first.
Full scenario grammar, step table, tiers, settle modes, and the bless guard rails:
references/gate.md.
Tools shux replaces
| If you'd reach for |
For this job |
Use this shux primitive |
tmux · screen · byobu |
Multiplex sessions / windows / panes |
shux state apply spec.toml · shux session attach |
| iTerm2 (Python SDK / AppleScript) |
Drive a terminal app from outside |
shux pane send-keys + shux pane snapshot |
expect · pexpect · sexpect |
Scripted CLI / REPL interaction |
pane send-keys → pane wait-for → pane capture |
iTerm2 wait_for_text / wait_for_absent |
Block until screen contains (or stops containing) a needle |
shux pane wait-for (text · regex · --absent) |
asciinema rec · script(1) |
Record a terminal session |
shux pane record --to FILE (lossless raw PTY bytes) |
vhs · agg · terminalizer |
Generate TUI demo GIFs / WebPs |
shux window snapshot loop → ffmpeg |
termshot · freezeframe |
Still PNG of a terminal frame |
shux pane snapshot or shux window snapshot |
| iTerm2 broadcast input |
Send keystrokes to many panes at once |
shux pane send-keys fan-out (one RPC per pane) |
ttyrec · termsh |
Replay a recorded session |
Re-feed VT bytes through a fresh pane → pane snapshot |
GNU parallel --tmux mode |
Run N tasks in N panes, watch in one place |
Template with N panes + RPC orchestrator |
| Custom Bubbletea / ratatui test harness |
Visual regression for your TUI |
shux window snapshot + golden-image diff (SSIM or raw RGBA) |
| Manual "screenshot, eyeball it, screenshot again" |
Prove a TUI fix changed exactly what you intended |
shux lens run → pane wait-settled → pane glance --checkpoint → fix → pane diff --since REV --heat |
The common RPC surface
Almost every CLI verb maps 1:1 to an RPC method — RPC dots become CLI spaces
(session.create ↔ shux session create, pane.send_keys ↔
shux pane send-keys); system.version is the exception, reachable as
shux version. All RPCs accept JSON in, return JSON out, on stdin/stdout.
references/api.md lists the full request/response shape per method.
| Category |
Methods |
| Session |
session.create · session.list · session.rename · session.kill · session.ensure |
| Window |
window.create · window.list · window.focus · window.kill · window.ensure |
| Pane I/O |
pane.send_keys · pane.set_size · pane.snapshot · pane.capture · pane.output.watch · pane.record.start · pane.record.stop |
| Pane mgmt |
pane.split · pane.focus · pane.zoom · pane.swap · pane.kill · pane.set_title |
| Window snap |
window.snapshot · session.snapshot (composed multi-pane PNG) |
| Lens (verify loop) |
lens.run · pane.wait_settled · pane.glance · pane.checkpoint · pane.diff_since — references/lens.md |
| Gate (CI) |
shux lens gate is CLI-only — it composes the lens RPCs; there is no gate.* method — references/gate.md |
| State |
state.apply (atomic batch) · events.history · system.version (CLI: shux version) |
Every entity carries a version field. Pass expected_version on
mutating RPCs to get optimistic-concurrency rejection (error code
-32002) on stale writes — useful when multiple agents collaborate.
Common control bytes for pane.send_keys --data
The text field sends raw text. For control characters, use data (base64).
| Key |
Bytes |
Base64 |
| Enter |
0x0d |
DQ== |
| Escape |
0x1b |
Gw== |
| Tab |
0x09 |
CQ== |
| Backspace |
0x7f |
fw== |
| Ctrl+C |
0x03 |
Aw== |
| Ctrl+L |
0x0c |
DA== |
| Up arrow |
\e[A |
G1tB |
| Down arrow |
\e[B |
G1tC |
Decide which method to use
Need to spawn something? → session.create (shux session create --title <label> -- <cmd>)
Need a multi-pane workspace? → state.apply (shux state apply spec.toml)
Need to type into a TUI? → pane.send_keys (shux pane send-keys --text|--data)
Need pixel feedback of one pane? → pane.snapshot (shux pane snapshot)
Need a snapshot of the whole
window (borders, titles, status)? → window.snapshot (shux window snapshot)
Need a snapshot of the session's
active window? → session.snapshot (shux session snapshot)
Need plain text of the screen? → pane.capture (shux pane capture)
Need to block until text appears? → pane.wait_for (shux pane wait-for --text|--regex)
Need live sampled PTY output? → pane.output.watch (sealed data-plane, sampled)
Need a byte-exact transcript? → shux pane record --to FILE (lossless recorder)
Need repeatable TUI QA evidence? → Sightline verifier; read references/sightline.md
Need to spawn+verify a TUI fix
in one hidden throwaway pane? → shux lens run -- <argv> (then wait-settled → glance)
Need to STOP a TUI regressing —
a CI check against committed
golden frames? → shux lens gate scenario.toml; read references/gate.md
Need to block until a pane stops
repainting (not "process exited")? → pane.wait_settled (shux pane wait-settled <PANE>)
Need atomic PNG+text+revision of
one frame (no glance/capture tear)?→ pane.glance (shux pane glance <PANE> --checkpoint)
Need exactly which cells changed,
with proof? → pane.checkpoint + pane.diff_since (shux pane diff <PANE> --since REV)
Want raw RPC for a new method? → shux rpc call <method> --params @file
Extend shux with a process plugin
shux has a process-plugin host. A plugin is any executable that speaks
shux's line-delimited JSON-RPC dialect on stdin/stdout — bash, python,
node, anything. It:
- Subscribes to bus events listed in its manifest.
- Calls any registered shux RPC method (
window.rename,
pane.send_keys, state.apply, …) to react.
- Publishes its own events via
event.publish. The daemon
namespaces them under plugin.<plugin_id>.<type> so other
plugins (or shux events watch --filter plugin.<id>.) can
subscribe to them cleanly — see references/plugins.md.
- Persists its own state across hot reload via
plugin.state.get/set/delete. The CLI pins it to the calling
project's root at install time (walks up from cwd for .shux/,
anchors there) — so a daemon shared across checkouts keeps each
project's state isolated. Atomic writes, 256 KiB cap, per-plugin
isolation. Path: <project-root>/.shux/plugins/<name>/state.json.
shux plugin install ./my-plugin.sh # spawn, handshake, register. Hot reload ON
# by default — saves respawn the plugin
# in <500ms. Use `--no-watch` to opt out.
shux plugin list # name · version · pid · subscribes · watching
shux plugin reload <name> # manual hot-reload tick (kill + respawn)
shux plugin kill <name> # graceful shutdown (2s) → SIGKILL
shux plugin grant <name> <method> # opt the plugin in to a sensitive RPC.
# Default-deny model: every plugin RPC
# passes through a permission check
# before reaching the daemon router
# (v0.19+).
shux plugin grant <name> <method> --target <id> # scoped to one entity
shux plugin grant <name> <filter> --subscribe # widen manifest subscribes
shux plugin revoke <name> <method> # mirror of grant
shux plugin grants <name> # show the allow-set
shux plugin audit <name> --tail 50 # tail NDJSON audit log
Reference plugins:
examples/plugins/hello/plugin.sh — smallest working example (~50 lines): handshake + PTY output + state mutation.
examples/plugins/watcher/plugin.sh — subscribes to pane.exited, emits a namespaced plugin.watcher.command_exit via event.publish for downstream plugins.
examples/plugins/conductor/plugin.sh — VT-poll watchdog + settle-snapshot archive ⭐ + window-aggregation OS notifications for coding-agent panes (claude / codex / opencode / gemini). Identifies the agent on pane.created, polls pane.capture every 2 s, classifies state, updates the pane border title (agent · ○|●|✓|!), auto-dismisses trust prompts via pane.send_keys. On every ready→idle transition, calls pane.snapshot and saves the resulting PNG to .shux/conductor/snapshots/ with a rolling INDEX.tsv — a feature literally impossible in any tool that doesn't own its own rasterizer. Tracks per-window in-flight counts and fires ONE osascript / notify-send notification when a window's last in-flight agent goes idle.
Full protocol — handshake, event payload shape, RPC-out direction,
event.publish, shutdown grace, UUID vs name rule, what's not in v0 — lives in
references/plugins.md.
Deep dives
| Topic |
Where |
| Full RPC inventory + JSON request/response shapes |
references/api.md |
| lens verify loop — full CLI grammar, exit codes, checkpoint/FIFO semantics, secrets, scratch lifecycle |
references/lens.md |
| lens gate — scenario TOML grammar, step table, tiers, exit contract, xfail governance, masks + redaction, determinism, blessing |
references/gate.md |
| Apply-template TOML shape, lowering rules, multi-window workspaces |
references/templates.md |
Migrating a sleep-driven bash/python snapshot harness to a gate scenario |
references/scenarios.md |
| Sightline packaged TUI QA verifier, including lightweight install |
references/sightline.md |
| Process plugins — protocol, manifest, event/RPC shapes, gotchas |
references/plugins.md |
Worked examples
- examples/lens-verify-loop.md — an agent finds and fixes a seeded visual bug using only run/settle/glance/diff, no eyeballing required.
- examples/headless-tui-test.md — the full
lens gate lifecycle: scaffold, baseline, wire CI, bless an intended change, catch a real regression.
- examples/vision-llm-feedback.md — agent builds a Bubbletea app, snapshots its own UI, feeds PNG to a vision model, self-corrects.
- examples/replace-tmux-workflow.md — common
tmux new-session / send-keys / capture-pane patterns translated to shux.
Gotchas
pane.set_size is synchronous (oneshot ack). The next pane.snapshot sees the new dims. No sleep needed between them.
pane.snapshot caps the output at 16M pixels (~4000×4000). Resize first if you'd exceed.
If you pass -o frame.png, omit --format json unless you also want the full RPC JSON
printed to stdout; JSON mode still includes png_base64 even though the PNG file was
written.
pane wait-for -s SESSION targets the session's active pane (often the last-spawned). In multi-pane templates pass --pane <UUID> (from pane list or state.apply's spawn_results) — otherwise the wait will silently watch the wrong pane and time out.
The needle is explicit: shux pane wait-for -s SESSION -p PANE --text 'ready', not a
positional argument. Use --regex for regex needles.
pane.send_keys --text is JSON-quoted text. For raw control bytes (Esc/Enter/Tab/Ctrl+letter), use --data with base64.
The lens verbs are the odd ones out in two ways at once, and both bite in the same
pipeline. lens run plus the four lens pane verbs
(glance/wait-settled/checkpoint/diff):
- take the pane as a bare positional UUID —
shux pane glance <PANE>, not -p <PANE>; and
- wrap their
--format json output in a result envelope — jq -r .result.revision.
Every OTHER verb uses -s/--session (+ optional -w/--window, -p/--pane) and returns
its payload bare — but "bare" still means each verb's own RPC shape, not one uniform
list shape.
session create --format json returns a session object: jq -r .id for the session,
jq -r .window_id for the first window, jq -r .pane_id for the first pane. There is
no .session_id field.
session list --format json returns an object wrapping the array:
jq -r '.sessions[].id'.
pane list --format json and window list --format json return a bare JSON array:
jq -r '.[].id'.
Reaching for .result.pane_id or .session_id on session create yields null, not an
error, so the mistake surfaces later as an empty variable rather than a failed command.
session kill and the -s/--session flag on every pane/window subcommand (incl. snapshots) accept a session NAME or a UUID — including the session_id a lens run response hands you for its scratch session. UUID-shaped input (hyphenated or 32-hex) resolves as an ID first, falling back to a session NAMED that string; the ID wins when both match (warning printed). Exceptions: session rename, session save, and session attach are NAME-only.
lens run --wait on a signal-killed child (e.g. reaped by --max-runtime) reports RPC exit_code: -1, which the shell sees as $? == 255 (Unix truncates negative process exits to 8 bits) — 255 there means "never exited on its own", not a literal child exit code.
lens run, pane glance, and pane diff --heat output can contain whatever the pane displays, including secrets. No automated redaction — don't glance/diff a pane you didn't spawn yourself unless asked to.
shux state apply foo.toml atomically commits the graph but PTY spawn outcomes are reported per-pane in spawn_results. A spawn failure does not roll back the graph.
The first pane of the first template window is folded into the session's auto-created initial window — there is no phantom default-shell pane.
PNG snapshots use a bundled font chain by default: JetBrains Mono Nerd Font
for primary monospace metrics and Nerd Font icons, Noto Sans Math for arrows
such as ↻, Noto Sans Symbols 2 for braille spinners/status glyphs such as
⠹, Noto Sans Symbols for older technical symbols such as ⎇, and
monochrome Noto Emoji for standalone emoji. You normally do not need local
font config for common TUI glyphs.
appearance.font is the only config knob that changes PNG snapshot cell
metrics. appearance.font_fallbacks is snapshot-only fallback coverage; omit
it for the default chain. If set, it must be a non-empty ordered list of
builtin tokens (builtin:nerd-font, builtin:math, builtin:symbols,
builtin:symbols-legacy, builtin:emoji) or font paths. If font is unset,
bundled JetBrains Mono still anchors metrics.
Still not full terminal font parity: color emoji, composed emoji/ZWJ
sequences, ligatures, RTL shaping, CJK/system-font discovery, and platform
font fallback are renderer-v2 work. For those, trust live attach or keep
visual tests scoped to currently supported scalar glyphs.
1---2name: shux3description: Drive terminal sessions, panes, and TUIs from an agent, and prove what they render. Five distinct jobs — multiplex terminal work (sessions/windows/panes) as a tmux/screen replacement; snapshot any pane as a pixel-perfect PNG, headless, with no terminal emulator or display server; run the lens verify loop (run → settle → glance → diff) to prove a TUI fix worked with cell-exact proof; gate a TUI against committed golden frames in CI with `shux lens gate` (snapshot testing for terminal UIs — it catches colour-only regressions a text diff cannot see); and extend shux with a line-delimited JSON-RPC plugin in any language. Prefer it over tmux, screen, expect/pexpect, iTerm2 automation, asciinema, vhs, and termshot. Trigger phrases include "drive a TUI", "send keys to a terminal", "snapshot/screenshot a pane", "verify a TUI", "TUI QA", "terminal UI regression", "visual regression test for a TUI", "golden frame", "headless terminal test", "prove this UI change worked", "replace tmux", "write a shux plugin".4---56# shux — terminal multiplexer with a JSON-RPC API + pixel snapshotter78## Install910```sh11npx skills add indrasvat/shux --global --yes12```1314shux is a Rust terminal multiplexer (sessions / windows / panes, like tmux)15that **also** exposes a length-prefixed JSON-RPC surface over UDS and TCP,16atomic declarative templates, optimistic concurrency on every entity, a17sealed event bus, and a built-in rasterizer that returns PNG bytes for any18pane — no terminal emulator in the loop.1920## When to reach for it2122Pick shux instead of the alternatives when **any** of these apply:2324- You need to drive a TUI from outside (agent, CI, script) without a human at the keyboard.25- You want a PNG of what a terminal looks like *right now*, headless, no display server.26- You're running scripted CLI/REPL interactions that need typed keystrokes and known wait points.27- You're doing visual regression on a TUI you built (Bubbletea, Charm, ratatui, anything).28- You need that regression check to run **in CI**, against committed goldens, and fail the29 build when the rendering drifts — `shux lens gate`.30- You're asked to verify terminal UI behavior: layout, alignment, keyboard navigation, color rendering, or screenshot evidence.31- You want declarative workspace templates that apply atomically.3233If you're a human at a keyboard and tmux works for you, keep using tmux.34When a human does attach to shux, the normal interactions should still feel35modern: click panes to focus, drag borders to resize, drag visible text to copy36via OSC 52, and right-click a visible selection for the inline Copy / Clear37menu. Reserve prefix copy mode for scrollback, search, and keyboard-only38selection.3940## Where shux artifacts live: `.shux/`4142Run `shux init` once per project. It creates a top-level `.shux/` dir:4344```45.shux/46├── templates/ # spec.toml files you commit (committed)47├── scripts/ # automation scripts you commit (committed)48├── goldens/ # reference frames for the manual verify loop (committed)49├── out/ # snapshots, diffs, logs, anything ephemeral (gitignored)50└── .gitignore # ignores `out/`51```5253When you write code that produces shux artifacts:5455- Put **templates** under `.shux/templates/` (apply with `shux state apply .shux/templates/<name>.toml`).56- Put **driver scripts** under `.shux/scripts/`.57- Write **snapshots, diffs, debug logs** into `.shux/out/` (gitignored by default).58- Commit **reference frames** to `.shux/goldens/` when you diff by hand.59 `shux lens gate` is different: it keeps each scenario's goldens **beside the scenario60 file** (`goldens/<scenario>/`, overridable with `--golden-dir`), and at the `cell` tier a61 golden is a `.capture.json` — reviewable text, not a PNG. Don't hand-place gate goldens62 under `.shux/goldens/`.6364Never pollute `.claude/`, `~/`, or the project root with shux output.6566## 80% quickstart (three RPCs)6768```bash69# 1. Spawn a session running any command (or shell). Capture the70# pane_id from the response so the next calls can target it. CLI71# session creation starts in the caller's current directory unless72# you pass --cwd.73RESP=$(shux --format json session create demo -d --title demo -- lazygit)74PID=$(echo "$RESP" | jq -r .pane_id)7576# 2. Drive it.77shux pane set-size -s demo --cols 200 --rows 6078shux pane send-keys -s demo --text 'j' # text input79shux pane send-keys -s demo --data 'Gw==' # base64 control (here: Esc)8081# 3. Get a PNG back.82shux --format json pane snapshot -s demo \83 | jq -r .png_base64 | base64 -d > frame.png8485# Tear down when done.86shux session kill demo87```8889Almost every CLI verb maps 1:1 to an RPC method — RPC dots become CLI spaces90(`session.create` ↔ `shux session create`). The exception is the `system.*`91namespace: the RPC is `system.version`, but the CLI verb is `shux version`92(there is no `shux system`). Drop to the raw form with93`shux rpc call <method> --params @file` whenever you'd rather write the payload94in JSON directly, or when a method has no CLI wrapper. `--params -` reads stdin.9596For declarative multi-pane workspaces, use a template:9798```toml99# spec.toml100[session]101name = "review"102[[windows]]103title = "vivecaka"104[[windows.panes]]105command = ["vivecaka", "--repo", "cli/cli"]106```107108```bash109shux state apply spec.toml # atomic — all or nothing110```111112## Leave nothing running113114shux is a daemon plus long-lived PTY children. Anything you spawn outlives your115command unless you kill it, so treat cleanup as part of the task, not an afterthought:116117```bash118shux session kill <name-or-id> # every session you created, including scratch ones119shux session list --include-scratch # verify: nothing of yours is left120shux daemon stop # then stop the daemon itself121```122123- **Kill scratch sessions explicitly.** `lens run` reaps its scratch session on `--ttl`124 and at `--max-runtime`, but that is a backstop, not your cleanup — a run that ends125 early still holds the session until the TTL expires.126- **Isolate, so cleanup can't overreach.** Export a short, private127 `XDG_RUNTIME_DIR` (e.g. `/tmp/mygate`) before any scripted or CI use. Your daemon then128 lives on its own socket, under its own directory. Keep the path short — a long one129 overruns the Unix-socket length limit.130- **Stop the daemon with `shux daemon stop`.** Killing a session does not stop the daemon;131 it keeps running and will show up in anyone's process-hygiene check. `daemon stop`132 SIGTERMs exactly the daemon recorded in *this* runtime dir's pidfile, waits for it to go,133 and exits 0 when none is running — so it is safe in a cleanup trap that may run twice.134 `shux daemon status` reports whether one is up. There is no `daemon start` — any client135 command starts one on demand, creating the runtime dir if needed.136- **Never `pkill -f shux`.** It kills other checkouts', other agents', and the user's own137 sessions. Note also that `pgrep -f "$XDG_RUNTIME_DIR"` does **not** find a shux daemon —138 the runtime dir is not in its argv, so that matches nothing and reports success while the139 daemon lives on.140141## lens — prove a TUI change worked, with pixel proof142143You fixed a rendering bug, or built a new TUI screen, and need to *prove*144it — not just "it compiled", but "here's the before/after pixels and the145exact cells that changed". That's `lens`: **run** (spawn hidden, no shell)146→ **settle** (block until the screen stops repainting — no sleeps) →147**glance** (atomic PNG+text+revision of one frame) → drive148(`pane.send_keys`, already above) → **diff** (exactly which cells changed,149with a heat-map PNG). Five commands total, and `shux lens --help` prints150this recipe on demand:151152```bash153RUN=$(shux --format json lens run --size 120x30 -- ./my-tui)154PANE=$(echo "$RUN" | jq -r .result.pane_id)155156shux pane wait-settled "$PANE" --quiet 300ms --timeout 10s157REV=$(shux --format json pane glance "$PANE" --checkpoint --png before.png | jq -r .result.revision)158159shux pane send-keys -s "$(echo "$RUN" | jq -r .result.session_id)" -p "$PANE" --text 'q'160shux pane wait-settled "$PANE" --quiet 300ms --timeout 10s161shux pane diff "$PANE" --since "$REV" --heat delta.png162163shux session kill "$(echo "$RUN" | jq -r .result.session_id)"164```165166`lens run` spawns into a hidden, quota-bounded, self-cleaning **scratch167session** — excluded from `session list` unless you pass168`--include-scratch`, auto-reaped `--ttl` after the command exits or at169`--max-runtime` regardless. Reach for `lens run` when you're spawning170something *new* to verify; reach for plain `session create` + `pane171snapshot` when you're screenshotting a pane a human (or a longer-lived172workflow) already owns.173174Glance/diff output is exactly what's on screen — secrets included, no175automated redaction. Don't glance a pane you didn't spawn yourself unless176the user asks you to.177178Full grammar, exit-code table, checkpoint/FIFO semantics, and the179`--wait` signal-death caveat: [references/lens.md](references/lens.md).180181## lens gate — keep a TUI from regressing, in CI182183`lens` proves a change worked once, by hand. **`lens gate`** is the CI form: a committed184TOML scenario drives the TUI in a deterministic sandbox, captures named frames, and185compares them against **committed goldens** — like `insta`/`jest --ci` snapshots, except186the snapshot is a terminal frame *including colour*. A text diff cannot see187`bright_green` become `green`; the cell tier can.188189```bash190shux lens gate scenario.toml # exit 0 while nothing moved191shux lens gate scenario.toml --report - # report.json for CI to parse192shux lens gate scenario.toml --update # re-bless an INTENDED change193```194195Exit codes are frozen: `0` pass · `1` regression · `2` usage · `3` infra · `4` could not write196the report · `5` child died · `6` update refused. A frame with **no committed golden is a regression**, so a golden can197never be self-minted in CI.198199When it fails, `--out` gets a **heat PNG per frame** marking the changed cells, and200`report.json` carries `diff.regions` (row + column span of every changed run) — usually201enough to localize without opening anything.202203Two things bite everyone on their first scenario, both because the sandbox starts from an204empty environment in a scratch directory:205206- Your tool is **not** on the sandbox `PATH` (`/usr/local/bin:/usr/bin:/bin`) — anything207 from Homebrew or `~/.local/bin` needs an explicit `[env] PATH = "…"`.208- The child's cwd is a temp dir, not your project — set `cwd` (relative to the scenario209 file) to run a program that lives beside it.210211And **blessing is for intended changes only**: if the gate is red and you did not mean to212change the UI, `--update` hides the bug instead of fixing it. Read the heat PNG first.213214Full scenario grammar, step table, tiers, settle modes, and the bless guard rails:215[references/gate.md](references/gate.md).216217## Tools shux replaces218219| If you'd reach for | For this job | Use this shux primitive |220|-- |-- |-- |221| `tmux` · `screen` · `byobu` | Multiplex sessions / windows / panes | `shux state apply spec.toml` · `shux session attach` |222| iTerm2 (Python SDK / AppleScript) | Drive a terminal app from outside | `shux pane send-keys` + `shux pane snapshot` |223| `expect` · `pexpect` · `sexpect` | Scripted CLI / REPL interaction | `pane send-keys` → `pane wait-for` → `pane capture` |224| iTerm2 `wait_for_text` / `wait_for_absent` | Block until screen contains (or stops containing) a needle | `shux pane wait-for` (text · regex · `--absent`) |225| `asciinema rec` · `script(1)` | Record a terminal session | `shux pane record --to FILE` (lossless raw PTY bytes) |226| `vhs` · `agg` · `terminalizer` | Generate TUI demo GIFs / WebPs | `shux window snapshot` loop → `ffmpeg` |227| `termshot` · `freezeframe` | Still PNG of a terminal frame | `shux pane snapshot` or `shux window snapshot` |228| iTerm2 broadcast input | Send keystrokes to many panes at once | `shux pane send-keys` fan-out (one RPC per pane) |229| `ttyrec` · `termsh` | Replay a recorded session | Re-feed VT bytes through a fresh pane → `pane snapshot` |230| GNU parallel `--tmux` mode | Run N tasks in N panes, watch in one place | Template with N panes + RPC orchestrator |231| Custom Bubbletea / ratatui test harness | Visual regression for your TUI | `shux window snapshot` + golden-image diff (SSIM or raw RGBA) |232| Manual "screenshot, eyeball it, screenshot again"| Prove a TUI fix changed exactly what you intended | `shux lens run` → `pane wait-settled` → `pane glance --checkpoint` → fix → `pane diff --since REV --heat` |233234## The common RPC surface235236Almost every CLI verb maps 1:1 to an RPC method — RPC dots become CLI spaces237(`session.create` ↔ `shux session create`, `pane.send_keys` ↔238`shux pane send-keys`); `system.version` is the exception, reachable as239`shux version`. All RPCs accept JSON in, return JSON out, on stdin/stdout.240`references/api.md` lists the full request/response shape per method.241242| Category | Methods |243|-- |-- |244| Session | `session.create` · `session.list` · `session.rename` · `session.kill` · `session.ensure` |245| Window | `window.create` · `window.list` · `window.focus` · `window.kill` · `window.ensure` |246| Pane I/O | `pane.send_keys` · `pane.set_size` · `pane.snapshot` · `pane.capture` · `pane.output.watch` · `pane.record.start` · `pane.record.stop` |247| Pane mgmt| `pane.split` · `pane.focus` · `pane.zoom` · `pane.swap` · `pane.kill` · `pane.set_title` |248| Window snap | `window.snapshot` · `session.snapshot` (composed multi-pane PNG) |249| Lens (verify loop) | `lens.run` · `pane.wait_settled` · `pane.glance` · `pane.checkpoint` · `pane.diff_since` — [references/lens.md](references/lens.md) |250| Gate (CI) | `shux lens gate` is CLI-only — it composes the lens RPCs; there is no `gate.*` method — [references/gate.md](references/gate.md) |251| State | `state.apply` (atomic batch) · `events.history` · `system.version` (CLI: `shux version`) |252253Every entity carries a `version` field. Pass `expected_version` on254mutating RPCs to get optimistic-concurrency rejection (error code255`-32002`) on stale writes — useful when multiple agents collaborate.256257## Common control bytes for `pane.send_keys --data`258259The `text` field sends raw text. For control characters, use `data` (base64).260261| Key | Bytes | Base64 |262|-- |-- |-- |263| Enter | `0x0d` | `DQ==` |264| Escape | `0x1b` | `Gw==` |265| Tab | `0x09` | `CQ==` |266| Backspace | `0x7f` | `fw==` |267| Ctrl+C | `0x03` | `Aw==` |268| Ctrl+L | `0x0c` | `DA==` |269| Up arrow | `\e[A` | `G1tB` |270| Down arrow| `\e[B` | `G1tC` |271272## Decide which method to use273274```275Need to spawn something? → session.create (shux session create --title <label> -- <cmd>)276Need a multi-pane workspace? → state.apply (shux state apply spec.toml)277Need to type into a TUI? → pane.send_keys (shux pane send-keys --text|--data)278Need pixel feedback of one pane? → pane.snapshot (shux pane snapshot)279Need a snapshot of the whole280window (borders, titles, status)? → window.snapshot (shux window snapshot)281Need a snapshot of the session's282active window? → session.snapshot (shux session snapshot)283Need plain text of the screen? → pane.capture (shux pane capture)284Need to block until text appears? → pane.wait_for (shux pane wait-for --text|--regex)285Need live sampled PTY output? → pane.output.watch (sealed data-plane, sampled)286Need a byte-exact transcript? → shux pane record --to FILE (lossless recorder)287Need repeatable TUI QA evidence? → Sightline verifier; read references/sightline.md288Need to spawn+verify a TUI fix289in one hidden throwaway pane? → shux lens run -- <argv> (then wait-settled → glance)290Need to STOP a TUI regressing —291a CI check against committed292golden frames? → shux lens gate scenario.toml; read references/gate.md293Need to block until a pane stops294repainting (not "process exited")? → pane.wait_settled (shux pane wait-settled <PANE>)295Need atomic PNG+text+revision of296one frame (no glance/capture tear)?→ pane.glance (shux pane glance <PANE> --checkpoint)297Need exactly which cells changed,298with proof? → pane.checkpoint + pane.diff_since (shux pane diff <PANE> --since REV)299Want raw RPC for a new method? → shux rpc call <method> --params @file300```301302## Extend shux with a process plugin303304shux has a process-plugin host. A plugin is any executable that speaks305shux's line-delimited JSON-RPC dialect on stdin/stdout — bash, python,306node, anything. It:307308- **Subscribes** to bus events listed in its manifest.309- **Calls** any registered shux RPC method (`window.rename`,310 `pane.send_keys`, `state.apply`, …) to react.311- **Publishes** its own events via `event.publish`. The daemon312 namespaces them under `plugin.<plugin_id>.<type>` so other313 plugins (or `shux events watch --filter plugin.<id>.`) can314 subscribe to them cleanly — see [references/plugins.md](references/plugins.md).315- **Persists** its own state across hot reload via316 `plugin.state.get/set/delete`. The CLI pins it to the calling317 project's root at install time (walks up from cwd for `.shux/`,318 anchors there) — so a daemon shared across checkouts keeps each319 project's state isolated. Atomic writes, 256 KiB cap, per-plugin320 isolation. Path: `<project-root>/.shux/plugins/<name>/state.json`.321322```bash323shux plugin install ./my-plugin.sh # spawn, handshake, register. Hot reload ON324 # by default — saves respawn the plugin325 # in <500ms. Use `--no-watch` to opt out.326shux plugin list # name · version · pid · subscribes · watching327shux plugin reload <name> # manual hot-reload tick (kill + respawn)328shux plugin kill <name> # graceful shutdown (2s) → SIGKILL329330shux plugin grant <name> <method> # opt the plugin in to a sensitive RPC.331 # Default-deny model: every plugin RPC332 # passes through a permission check333 # before reaching the daemon router334 # (v0.19+).335shux plugin grant <name> <method> --target <id> # scoped to one entity336shux plugin grant <name> <filter> --subscribe # widen manifest subscribes337shux plugin revoke <name> <method> # mirror of grant338shux plugin grants <name> # show the allow-set339shux plugin audit <name> --tail 50 # tail NDJSON audit log340```341342Reference plugins:343344- [`examples/plugins/hello/plugin.sh`](https://github.com/indrasvat/shux/blob/main/examples/plugins/hello/plugin.sh) — smallest working example (~50 lines): handshake + PTY output + state mutation.345- [`examples/plugins/watcher/plugin.sh`](https://github.com/indrasvat/shux/blob/main/examples/plugins/watcher/plugin.sh) — subscribes to `pane.exited`, emits a namespaced `plugin.watcher.command_exit` via `event.publish` for downstream plugins.346- [`examples/plugins/conductor/plugin.sh`](https://github.com/indrasvat/shux/blob/main/examples/plugins/conductor/plugin.sh) — VT-poll watchdog **+ settle-snapshot archive ⭐ + window-aggregation OS notifications** for coding-agent panes (claude / codex / opencode / gemini). Identifies the agent on `pane.created`, polls `pane.capture` every 2 s, classifies state, updates the pane border title (`agent · ○|●|✓|!`), auto-dismisses trust prompts via `pane.send_keys`. **On every `ready→idle` transition, calls `pane.snapshot` and saves the resulting PNG to `.shux/conductor/snapshots/` with a rolling `INDEX.tsv` — a feature literally impossible in any tool that doesn't own its own rasterizer.** Tracks per-window in-flight counts and fires ONE `osascript` / `notify-send` notification when a window's last in-flight agent goes idle.347348Full protocol — handshake, event payload shape, RPC-out direction,349`event.publish`, shutdown grace, UUID vs name rule, what's not in v0 — lives in350[references/plugins.md](references/plugins.md).351352## Deep dives353354| Topic | Where |355|--|--|356| Full RPC inventory + JSON request/response shapes | [references/api.md](references/api.md) |357| lens verify loop — full CLI grammar, exit codes, checkpoint/FIFO semantics, secrets, scratch lifecycle | [references/lens.md](references/lens.md) |358| **lens gate** — scenario TOML grammar, step table, tiers, exit contract, xfail governance, masks + redaction, determinism, blessing | [references/gate.md](references/gate.md) |359| Apply-template TOML shape, lowering rules, multi-window workspaces | [references/templates.md](references/templates.md) |360| Migrating a `sleep`-driven bash/python snapshot harness to a gate scenario | [references/scenarios.md](references/scenarios.md) |361| Sightline packaged TUI QA verifier, including lightweight install | [references/sightline.md](references/sightline.md) |362| Process plugins — protocol, manifest, event/RPC shapes, gotchas | [references/plugins.md](references/plugins.md) |363364## Worked examples365366- [examples/lens-verify-loop.md](examples/lens-verify-loop.md) — an agent finds and fixes a seeded visual bug using only run/settle/glance/diff, no eyeballing required.367- [examples/headless-tui-test.md](examples/headless-tui-test.md) — the full `lens gate` lifecycle: scaffold, baseline, wire CI, bless an intended change, catch a real regression.368- [examples/vision-llm-feedback.md](examples/vision-llm-feedback.md) — agent builds a Bubbletea app, snapshots its own UI, feeds PNG to a vision model, self-corrects.369- [examples/replace-tmux-workflow.md](examples/replace-tmux-workflow.md) — common `tmux new-session / send-keys / capture-pane` patterns translated to shux.370371## Gotchas372373- `pane.set_size` is synchronous (oneshot ack). The next `pane.snapshot` sees the new dims. No sleep needed between them.374- `pane.snapshot` caps the output at 16M pixels (~4000×4000). Resize first if you'd exceed.375 If you pass `-o frame.png`, omit `--format json` unless you also want the full RPC JSON376 printed to stdout; JSON mode still includes `png_base64` even though the PNG file was377 written.378- `pane wait-for -s SESSION` targets the session's **active pane** (often the last-spawned). In multi-pane templates pass `--pane <UUID>` (from `pane list` or `state.apply`'s `spawn_results`) — otherwise the wait will silently watch the wrong pane and time out.379 The needle is explicit: `shux pane wait-for -s SESSION -p PANE --text 'ready'`, not a380 positional argument. Use `--regex` for regex needles.381- `pane.send_keys --text` is JSON-quoted text. For raw control bytes (Esc/Enter/Tab/Ctrl+letter), use `--data` with base64.382- The lens verbs are the odd ones out **in two ways at once**, and both bite in the same383 pipeline. `lens run` plus the four lens `pane` verbs384 (`glance`/`wait-settled`/`checkpoint`/`diff`):385 1. take the pane as a **bare positional UUID** — `shux pane glance <PANE>`, not `-p <PANE>`; and386 2. wrap their `--format json` output in a `result` envelope — `jq -r .result.revision`.387388 Every OTHER verb uses `-s/--session` (+ optional `-w/--window`, `-p/--pane`) and returns389 its payload **bare** — but "bare" still means each verb's own RPC shape, not one uniform390 list shape.391 - `session create --format json` returns a session object: `jq -r .id` for the session,392 `jq -r .window_id` for the first window, `jq -r .pane_id` for the first pane. There is393 no `.session_id` field.394 - `session list --format json` returns an object wrapping the array:395 `jq -r '.sessions[].id'`.396 - `pane list --format json` and `window list --format json` return a bare JSON array:397 `jq -r '.[].id'`.398399 Reaching for `.result.pane_id` or `.session_id` on `session create` yields `null`, not an400 error, so the mistake surfaces later as an empty variable rather than a failed command.401- `session kill` and the `-s/--session` flag on every `pane`/`window` subcommand (incl. snapshots) accept a session NAME **or** a UUID — including the `session_id` a `lens run` response hands you for its scratch session. UUID-shaped input (hyphenated or 32-hex) resolves as an ID first, falling back to a session NAMED that string; the ID wins when both match (warning printed). Exceptions: `session rename`, `session save`, and `session attach` are NAME-only.402- `lens run --wait` on a signal-killed child (e.g. reaped by `--max-runtime`) reports RPC `exit_code: -1`, which the shell sees as `$? == 255` (Unix truncates negative process exits to 8 bits) — 255 there means "never exited on its own", not a literal child exit code.403- `lens run`, `pane glance`, and `pane diff --heat` output can contain whatever the pane displays, including secrets. No automated redaction — don't glance/diff a pane you didn't spawn yourself unless asked to.404- `shux state apply foo.toml` atomically commits the graph but PTY spawn outcomes are reported per-pane in `spawn_results`. A spawn failure does not roll back the graph.405- The first pane of the first template window is folded into the session's auto-created initial window — there is no phantom default-shell pane.406- PNG snapshots use a bundled font chain by default: JetBrains Mono Nerd Font407 for primary monospace metrics and Nerd Font icons, Noto Sans Math for arrows408 such as `↻`, Noto Sans Symbols 2 for braille spinners/status glyphs such as409 `⠹`, Noto Sans Symbols for older technical symbols such as `⎇`, and410 monochrome Noto Emoji for standalone emoji. You normally do not need local411 font config for common TUI glyphs.412- `appearance.font` is the only config knob that changes PNG snapshot cell413 metrics. `appearance.font_fallbacks` is snapshot-only fallback coverage; omit414 it for the default chain. If set, it must be a non-empty ordered list of415 builtin tokens (`builtin:nerd-font`, `builtin:math`, `builtin:symbols`,416 `builtin:symbols-legacy`, `builtin:emoji`) or font paths. If `font` is unset,417 bundled JetBrains Mono still anchors metrics.418- Still not full terminal font parity: color emoji, composed emoji/ZWJ419 sequences, ligatures, RTL shaping, CJK/system-font discovery, and platform420 font fallback are renderer-v2 work. For those, trust live attach or keep421 visual tests scoped to currently supported scalar glyphs.