The phux agent CLI
evolvingThe phux agent CLI
TL;DR. The structured CLI surface an AI agent drives without a TTY:
phux ls / snapshot / send-keys / run / wait / watch, plus new to create a
session. This file is the agent contract. Per ADR-0030, the structured agent
state — cells, command results, semantic events — is a local projection over
the shared engine, and the CLI plus its versioned JSON schemas are what an
agent depends on, not a structured wire tier. It documents each verb, its JSON
shape, the read-act-wait loop, and the exit codes each verb mirrors.
0. The thesis: structured agent state is a projection
phux does not own terminal semantics; libghostty does, and both ends of the wire run that engine (ADR-0013). It follows that any structured view of a terminal — a cell grid, an OSC-133 command-boundary stream, a command’s captured output — is computed by a consumer from the engine it already has, not transmitted as a second model on the wire (ADR-0030).
So the agent contract is not a structured wire protocol. It is this CLI and
the versioned JSON schemas its --json verbs emit. The wire carries opaque
terminal bytes plus lifecycle and metadata; the structured shapes below are a
local projection an agent reads through the CLI
(ADR-0022: agents are a projection, the
CLI plus JSON schema is the contract). An agent that wants to own its own
projection — run the engine and read its grid directly — should copy
phux-web, the reference carry-your-own-engine consumer
(ADR-0030 §4).
The live wire does expose agent affordances: GET_SCREEN, ROUTE_INPUT,
GET_TERMINAL_STATE, SUBSCRIBE_TERMINAL_EVENTS, and an AgentEvent push
frame, documented in ../spec/L1.md. Read those as
engine-convenience snapshots over the shared engine — a convenience for
consumers that have not adopted the carry-your-own-engine pattern — not a
normative structured contract and not a license to add new structured wire
surface (ADR-0030 §2).
1. What this is, what this isn’t
This document is the agent-facing CLI surface, parallel to the
TUI’s product surface and the MCP adapter. The TUI
projects the source-of-truth Terminal to VT bytes (it renders, like tmux);
agents project it to structured data — cells, OSC-133 marks, command results.
The agent surfaces nest:
- This CLI is the canonical, stable agent contract: the verbs and their
--jsonshapes are what an agent depends on. mcp.mdis a thin adapter that wraps the samephux-clientfunctions name-for-name over JSON-RPC stdio.sdk.mddocumentsphux-clientitself — the library crate the CLI and MCP adapter are both built from. It exists today; it is L1-shaped and follows the same projection pattern.
All three are unprivileged consumers
(ADR-0017); none holds a
protocol-level privilege. The wire underneath stays additive and versioned,
normative under ../spec/.
The selector grammar is owned by tui.md §3; this file links there
rather than restating the table (the doc system’s one-fact-one-home rule). The
decision rationale lives in
ADR-0022; client-side selector resolution
in ADR-0021.
Side-effect-free against a live pane. snapshot, run, send-keys, and
wait neither attach nor resize the target pane: the reads issue the
GET_SCREEN control command (the server walks its own grid), and input rides
ROUTE_INPUT (events route to a pane by id). An agent can drive — or just
watch — a pane a human is also attached to, without disturbing that human’s
view.
2. The structured CLI surface (verb catalog)
phux is one binary; the verbs below are its agent-facing subcommands.
tui.md §1 has the full CLI table; this section zooms into the
agent verbs and their JSON. Exit codes are collected in §5.2.
phux ls [--json] [--socket P]— list sessions. Does not auto-start a server (liketmux ls): with none running it reports as much and exits non-zero.--jsonemitsSessionListJson(§4.1).phux snapshot [TARGET] [--json] [--scrollback[=N]] [--cells] [--socket P]— side-effect-free pane read viaGET_SCREEN.TARGETis optional (defaults to the focused/last session).--jsonemitsScreenState(§4.2); without it, a boxed text view.phux send-keys TARGET KEYS... [--socket P]— route named keys or literal strings to one resolved pane by id (ROUTE_INPUT).TARGETis required. No JSON.KEYSare tmux-shaped: named keys (Enter,Tab,Escape,Up,C-c,M-x) or a literal string sent character by character.phux run TARGET CMD... [--timeout SECS] [--json] [--socket P]— run a command in a pane and capture its exit code, output, and duration via printed sentinels (assumes a POSIX shell: sh/bash/zsh).TARGETis required.--jsonemitsRunResult(§4.3). The exit code mirrors the child (§5.2). Flags must precedeCMD, or clap’strailing_var_argswallows them into the command line.phux wait [TARGET] [--until TEXT] [--idle MS] [--timeout SECS] [--json] [--socket P]— poll the side-effect-free screen read until a condition holds.--untiltakes precedence over--idle; with neither, it settles on idle.--jsonemits the finalScreenState. Exit 0 when the condition is met, 124 on timeout. Two gotchas: flags must precedeTARGET; and--untilmatches any visible row, including the shell’s echo of the command you just typed — match on text that appears only in command output, never the command itself.phux watch [TARGET] [--json] [--socket P]— stream a pane’s live events (the push half of the agent surface; see../spec/L1.md). Subscribes to the server’s event stream scoped to the resolved pane and prints one event per line until EOF (server gone) or Ctrl-C; the subscription neither attaches nor resizes the pane. With--json, each line is a JSON object{ "event": <name>, "terminal"?: "@id", ... }and stdout stays pure JSON (diagnostics on stderr); otherwise a compact tab-separated human line. Event names:title_changed(carriestitle),bell,dirty,idle,pane_spawned,pane_closed(carriesexit_status), plus the deferredcommand_started/command_finished(carries a nullableexit_code— see the gap note below).watchcutswait’s poll-floor latency: awatchconsumer wakes the instant an event fires rather than on the next poll tick. It is additive —waitstill works without it, and a dropped event (full mailbox) falls back to polling. Deferred:command_started/command_finishedare wire-allocated but not emitted by the current server (the OSC-133 command boundary is not cleanly observable without disturbing the per-consumer state-sync synthesizer);command_finished.exit_codeis likewise always null until that shell-integration plumbing lands. The mechanism and the lifecycle/title/bell/dirty/idle events ship today.phux new [-s NAME] [-c CWD] [-- COMMAND...] [--json] [--socket P]— create a new session. Without--jsonit creates and attaches: an explicit-s NAMEthat already exists is an error (like tmux’s duplicate-session refusal); an omitted name is auto-assigned the smallest free numeric name (tmux-style); a server is auto-spawned if none is running. With--jsonit creates the session without attaching (no attach, no resize), then prints the seed pane id as JSON and exits.--jsonrequires an explicit-s NAMEand errors if that name is already in use (create-only, never create-or-attach). Shape in §4.4.
Not implemented. split and detach do not exist as subcommands today
(tracked as bead phux-99te). The shipped verbs are the twelve in
tui.md §1; the agent-relevant subset is the catalog above plus
kill and attach.
How new decomposes on the wire. Session create is no longer an L1
session verb. Per
ADR-0030 §5,
the session lifecycle verbs were removed from L1 and decompose into substrate
primitives plus L3 metadata: new is SPAWN_TERMINAL plus an L3 metadata
write on the phux.session.create/v1 key (the assigned identity is read back
via phux.session.created/v1), and rename is an L3 metadata SET on the
phux.session.name/v1 key. Grouping conventions are owned by
../spec/L3.md. The user-facing UX of new is unchanged; the
divergence is on the wire, where the migration to this decomposition is tracked
against ADR-0030 (full CollectionId removal is bead phux-0bmc).
Socket precedence (once, for every verb). The --socket argument wins,
then the PHUX_SOCKET environment variable, then the daemon default:
$XDG_RUNTIME_DIR/phux/phux.sock, falling back to /tmp/phux-$UID/phux.sock.
3. Targeting: the selector grammar
One grammar, every targeted command — kill, snapshot, wait, send-keys,
and run all share TARGET. It is resolved client-side against a server
snapshot (ADR-0021); the server
never parses a selector.
The full grammar table and CLI examples live in tui.md §3. In one
line, the forms are: . (current), = (last), name (session), name:N /
name:tag (window), name:N.M (pane), and @N (opaque id).
A selector that names several panes (a whole session or window) narrows to a
single pane: the focused pane when it is among the matches, else the first in
snapshot order (the pick_target_pane tiebreak the MCP tools share).
Optionality differs per verb: snapshot and wait default TARGET to the
last-focused session; send-keys and run require it.
4. JSON contracts (the per-verb machine shapes)
Each --json verb emits a versioned, plain-data struct from phux-core or
phux-client. These structs are the stable agent contract
(ADR-0022); they are a local projection
over the shared engine, and the wire underneath stays additive and versioned.
Each struct carries its own schema_version, tracked independently.
4.1 SessionListJson — phux ls --json
Defined in crates/phux-core/src/session_list.rs (LS_SCHEMA_VERSION = 1).
Shape, name-sorted:
{
"schema_version": 1,
"sessions": [
{ "name": "work", "windows": 3, "attached": true }
]
}
windows is the window count; attached is a bool — whether any client is
attached. Cross-surface gotcha: the MCP phux_ls tool (mcp.md
§3.1) surfaces the raw wire fields window_count / attached_client_count;
the CLI’s --json projects them to windows / attached. The two surfaces do
not share identical keys — do not carry a parser across them.
4.2 ScreenState — phux snapshot --json (and phux wait --json)
Defined in crates/phux-core/src/screen.rs (SCHEMA_VERSION = 3). The same
struct the server returns from GET_SCREEN, not an agents-specific shape.
Fields:
| Field | Type | Meaning |
|---|---|---|
schema_version | u32 | Contract version (currently 3); the pin/branch signal. |
pane | u32 | Wire-local id of the captured pane. |
cols, rows | u16 | Grid dimensions. |
cursor | Option<{x,y,visible}> | Viewport-relative, zero-based; None when the cursor is not viewport-resident (scrollback or hidden). |
lines | Vec<String> | Viewport rows, top to bottom, right-trimmed. |
scrollback | Vec<String> | History rows above the viewport, oldest first; empty unless requested. |
cells | Option<Vec<CellInfo>> | Per-cell marks and styles; present only with --cells. |
scrollback is tri-state (mirrors mcp.md §3.2): flag absent →
viewport only; --scrollback or --scrollback=0 → all retained history;
--scrollback N → the most-recent N rows. On the wire this is None /
Some(0) (all) / Some(n).
--cells populates cells with a sparse Vec<CellInfo> — only cells
carrying a non-default style or an OSC-133 mark, in row-major order, skipping
the right half of double-width glyphs. Each CellInfo is
{ col, row, semantic?, style }:
semanticisSemanticContent—Input(typed input) orPrompt(shell prompt).Outputis the default for every cell and is collapsed to absence, sosemanticisSomeonly for marked input vs prompt.styleisCellStyle: nine SGR booleans (bold,faint,italic,underline,blink,inverse,invisible,strikethrough,overline) plusfg/bg, each aCellColortagged enum withkindofdefault,palette({ index }), orrgb({ r, g, b }). The tag distinguishes “terminal default” from “explicitly black”.
Back-compat. scrollback and cells are #[serde(default)] (and cells
is skip_serializing_if None), so a cells = None snapshot serializes to
exactly the pre-cells shape, and an older consumer reading a newer payload
ignores extra keys. schema_version is the bump signal.
4.3 RunResult — phux run --json (on completion)
Defined in crates/phux-client/src/run.rs:
{
"command": "cargo test",
"exit_code": 0,
"output": "...",
"duration_ms": 8123,
"truncated": false
}
exit_code(i32) is the child’s$?, parsed out of a printed sentinel (runbrackets the command withBEGIN/RCmarkers — it does not rely on shell integration).outputis the rows between theBEGINandRCmarkers.duration_ms(u64) is wall-clock from submit to sentinel-seen, including poll latency — an upper bound on the child’s runtime, not a precise measurement.truncatedistruewhen theBEGINmarker had scrolled out of the viewport, sooutputis best-effort visible context; a full capture needs scrollback.
On timeout, run --json emits no JSON. RunOutcome::TimedOut carries the
command, elapsed time, and last screen internally, but the CLI’s --json path
serializes only the completed RunResult. The timeout signal is the exit code
(125 — see §5.2), printed alongside a stderr diagnostic. An agent must read the
exit code here and must not expect an outcome: "timed_out" body — that shape
exists in the MCP phux_run tool (mcp.md §3.4), not in the CLI’s
--json output.
4.4 phux new --json
phux new --json -s NAME emits a small fixed object naming the created session
and its seed pane’s wire-local id, then exits 0 without attaching:
{ "session": "NAME", "terminal_id": 2 }
It is create-only: --json requires an explicit -s NAME and errors (exit 1)
if that name is already in use. Unlike the versioned ScreenState /
RunResult / SessionListJson shapes, this is a flat ad-hoc object with no
schema_version. The wire decomposition behind it is in §2.
5. The read-act-wait loop and exit-code mirroring
5.1 The loop
The canonical agent pattern is read → act → wait → read: snapshot the pane,
send input or run a command, wait for the result to land, snapshot again. A
worked example in sh:
phux send-keys build "cargo test" Enter
phux wait build --until "test result:" --timeout 120
phux snapshot build --json --scrollback 200 > out.json
When you only want a command’s exit code and output, the one-shot phux run is
the higher-level alternative — it brackets the command with sentinels and
mirrors $?:
phux run build "cargo test" --json
The contrast: run is “I want the exit code”; send-keys plus wait is “I am
driving an interactive or long-lived program.” Because run mirrors the
child’s code (§5.2), phux run ... && next composes like a shell
(ADR-0022 §3).
5.2 Exit-code mirroring
Exit codes are not uniform across verbs:
| Verb | Exit codes |
|---|---|
ls | 0 ok; 1 no server / unexpected result. |
snapshot | 0 ok; 1 failure (no server, serialize error, resolve miss). |
send-keys | 0 ok; 1 failure (no server / refused / miss). |
run | the child’s own code clamped to 0..=255 (negative or >255 saturate to 255); 125 when phux gave up waiting for the sentinel (--timeout); 1 for no server / refused target / other. |
wait | 0 condition met; 124 on --timeout; 1 no server / parse / read error. |
new | 0 ok; 1 duplicate -s name / failure. |
kill | 0 ok; 1 selector miss / no server / parse; 2 server-side refusal. |
Why run uses 125, not 124. run mirrors the child’s own code into
0..=255, and 124 is a code real commands produce — notably GNU timeout.
So run reserves 125 (the wrapper-failure convention, as used by env and
timeout) for “phux itself gave up,” keeping it distinct from a child that
legitimately exited 124. wait, which wraps nothing, uses 124 for its own
timeout. kill is a control-plane verb (not strictly an agent read) but shares
TARGET; its 0/1/2 triad is listed for completeness.
6. Relationship to the other agent surfaces
The CLI verbs here are the stable contract. The MCP adapter exposes
them name-for-name (phux_ls ↔ ls, and phux_snapshot / phux_send_keys /
phux_run / phux_wait ↔ the matching subcommands) over the same
phux-client functions — same client-side resolution, same tiebreaks.
sdk.md documents phux-client, the library crate those surfaces
are built from. All three are unprivileged consumers
(ADR-0017); the wire
underneath stays additive and versioned under ../spec/
(ADR-0022).