The phux agent CLI
The phux agent CLI
TL;DR. The structured CLI surface an AI agent drives without a TTY:
create with new, place configured agents or explicit argv with launch /
spawn, reshape exact existing panes with insert-pane / move-pane /
swap-pane, act through run, send-keys, or paste, observe through bounded wait /
watch, and raise advisory human attention with ask. Plugin, workspace, and
satellite verbs provide the surrounding configuration and inventory surfaces.
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/. Installing phux-mcp does not make its
tools visible to a host; see Registering with a host
for the Claude Code command and generic stdio configuration.
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.
Viewport-safe against a live pane. snapshot, run, send-keys,
paste, and wait neither attach nor resize the target pane: reads issue
GET_SCREEN, and input rides ROUTE_INPUT to a pane id. snapshot/wait
are side-effect-free; run/send-keys/paste deliberately mutate the live
PTY. None changes an attached human’s local focus or viewport.
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 [--json] [--scrollback[=N]] [--cells] [--socket P] [TARGET]— side-effect-free pane read viaGET_SCREEN.TARGETis optional (defaults to the focused session).--jsonemitsScreenState(§4.2); without it, a boxed text view.phux send-keys [--socket P] TARGET KEYS...— 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 literal strings. Literals normally type character by character. A contiguous literal run immediately beforeEnter/Returnbecomes one trusted paste followed by the real Enter key; this honors live bracketed-paste mode so agent TUIs cannot absorb the submit key into a fast text burst.phux paste [--untrusted] [--socket P] TARGET [TEXT]— deliver a payload to one resolved pane as a single paste event (ROUTE_INPUT).TARGETis required;TEXTis the payload, read from stdin when omitted (git diff | phux paste review). No JSON, likesend-keys; exit codes mirrorsend-keys(§5.2). The server picks the delivery form from the pane’s live terminal state: when the pane’s program has bracketed paste (DEC mode 2004) switched on, the payload arrives wrapped inESC[200~/ESC[201~markers as one block; otherwise the raw bytes are delivered as if typed. A paste INSERTS; it does not SUBMIT. Paste-aware shells and REPLs (bash on readline 8.1+, python 3.13+‘s PyREPL) buffer the bracketed block and wait for a real Enter — follow withphux send-keys TARGET Enterto run what you pasted. Preferpastefor anything multiline or indented when you want insertion without submission: ordinarysend-keysliterals type character by character, while a dedicated paste arrives intact. The submission shorthandphux send-keys TARGET "text" Enteris also bracketed-paste-aware. Pastes are trusted by default — the agent vouches for content it composed, the same ungated input authoritysend-keyshas.--untrustedopts into the server’s safety gate: the payload is classified, and the pane’s untrusted-paste policy (reject, by default) may silently drop an unsafe payload — notably anything multiline — so reserve the flag for content you did not compose and cannot vouch for.phux run [--timeout SECS] [--json] [--socket P] TARGET CMD...— 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 precedeTARGET, or clap’strailing_var_argswallows them into the command line.phux wait [--until TEXT] [--idle MS] [--timeout SECS] [--json] [--socket P] [TARGET]— 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 [--json] [--socket P] [TARGET]— 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),asked(carriesid,question,suggestions, and nullableelapsed_seconds), 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 ask TARGET [--id ID] [--suggest TEXT...] [--elapsed-seconds SECS] [--json] [--socket P] QUESTION— report that an agent in a pane is blocked on a human-answerable question. This is the opt-in hook ingress from ADR-0036: configured plugin actions or first-party integrations call it instead of writing aphux-asktitle sentinel themselves. It resolvesTARGETclient-side, does not attach or resize, and asks the server to emit the normalaskedevent on the existing watch stream.--jsonechoes the reported{ event, terminal, id, question, suggestions, elapsed_seconds }object after the server accepts the payload. Empty questions, empty suggestions, excessive suggestion counts, and unknown panes fail without emitting an event. The reference TUI presents that event as advisory attention:C-a qcycles asking panes andC-a Qreturns to the saved local origin. A headless agent reports the ask and prints that guidance; it does not move focus.phux agent <list|show|explain> [TARGET] [--json] [--socket P]— project public agent state. A pane carrying a declaredphux.agent/v1record (ADR-0040; seeagent setbelow) reports straight from it withagent_recordprovenance and no heuristics; otherwise state is inferred from already-phux-shaped evidence: session/pane metadata, OSC/title hints, side-effect-freesnapshot --cells, and enabled plugin[[agents]]declarations.listcovers every pane;showreturns the selected pane;explainkeeps the same state but expands the evidence trail in the human view.--jsonemitsAgentStateJson(§4.7). States areunknown,idle,working,blocked, ordone; each state carries confidence and ordered provenance so consumers can show why phux believes it.phux agent set [TARGET] --name NAME [--kind K] [--state S] [--attention A] [--session L] [--socket P]— declare the target pane’s agent identity by writing the wholephux.agent/v1L3 record (docs/spec/L3.md§3.7, ADR-0040; last writer wins). An agent integration calls it (or issues the equivalentSET_METADATA) when it starts, changes state, or hands off, instead of encoding lifecycle into its OSC title. The declared record outranks title/screen heuristics in every consumer, and the reference TUI labels the pane’s window/sidebar tab from it. States:unknown|idle|working|blocked|done; attention:none|low|normal|high(defaults derive from state). Prints the confirmed record as@N<TAB>json.phux agent clear [TARGET] [--socket P]— delete the declared record (DELETE_METADATA); consumers fall back to the OSC-title and screen heuristics. Prints@N<TAB>-on confirmation.phux agent install-claude [--shell zsh|bash|fish] [--real PATH]— make plain interactiveclaudeinvocations enter phux automatically. The installer leaves the real Claude binary untouched, writes a phux-owned shim under$XDG_DATA_HOME/phux/shims, and adds one marked PATH block to the detected shell rc. Outside phux, the shim creates and attaches a new session in the caller’s working directory; inside a pane it runs Claude in place. Claude lifecycle hooks publishworking,blocked, anddonerecords, and blocked notifications also emitphux ask, so phone and TUI fleet views see attention without screen inference. Noninteractive/admin invocations such asclaude -p,claude mcp, andclaude --versionbypass phux.phux agent uninstall-claude— remove only the phux-owned shim, hook settings, manifest, and marked shell-rc block. User shell configuration and the real Claude installation are otherwise untouched.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 starts fromdefaults.session-name-templateand gains a numeric suffix when needed; 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.phux launch INTEGRATION [--list|--print] [--target TARGET [--split horizontal|vertical] [--ratio R]] [-c CWD] [--json] [--socket P] [-- ARGS...]— resolve an enabled plugin integration and spawn it through its identity wrapper.--listinventories integrations;--print/--dry-runresolves argv without a server;--targetplaces the launched pane beside an exact local pane. Successful--jsonlaunch shape is in §4.13.phux spawn [--satellite NAME] [--target TARGET [--split horizontal|vertical] [--ratio R]] [-c CWD] [-- COMMAND...] [--json] [--socket P]— spawn a terminal without attaching (SPAWN_TERMINAL). With--target, the new pane is owned by the target’s exact local window and inserted beside it;verticalmeans side-by-side andhorizontalmeans stacked;Ris finite and strictly between 0 and 1. Without placement flags, the pane joins the server’s most recently active session (legacy behavior). The new terminal id prints on success.--satellite NAMEroutes the spawn through a federation hub (phux server --hub) to the named registry satellite and prints the satellite-tagged id, which every satellite-capable verb can address through the hub. Does not auto-start a server. Typed failures (unknown/unrouted satellite, unreachable link) exit nonzero with the diagnostic on stderr. Shape in §4.11.phux insert-pane TARGET NEW_PANE [--horizontal|--vertical] [--ratio R] [--json] [--socket P]— insert an already-created local pane beside an existing layout leaf. This never spawns: createNEW_PANEseparately first. Both selectors must each match exactly one pane in the same session.--verticalmeans a vertical divider (side-by-side panes);--horizontalmeans a horizontal divider (stacked panes) and is the default. Shape in §4.12.phux move-pane SOURCE TARGET [--horizontal|--vertical] [--ratio R] [--json] [--socket P]— collapseSOURCEout of its old position and insert it besideTARGETin the same session. Shape in §4.12.phux swap-pane FIRST SECOND [--json] [--socket P]— exchange two leaf positions without changing split geometry. Shape in §4.12. All three spatial verbs reject multi-match, satellite, and cross-session selectors and do not change an attached client’s local focus.phux plugin <list|link|unlink|enable|disable|validate> [--json]— manage declarative plugin manifest entries in the local config registry. This never contacts a running server and never executes plugin commands.--jsonemits the plugin registry document (§4.5); failure paths leave stdout empty and report diagnostics on stderr.phux config agents [--json] [--socket PATH]— project configured plugin[[agents]]declarations into a flat agent-state list, merged with live per-panephux.agent/v1records and asked state when a server answers on the socket (phux-r82.10). No reachable server degrades to the declared manifest values.--jsonemitsConfiguredAgentsJson(§4.6).phux config run PLUGIN ACTION [--timeout SECS] [--cwd PATH] [--json]— execute one action declared by an enabled configured plugin manifest. The command runs as argv from the plugin root; there is no implicit shell expansion.--jsonemitsPluginActionOutput(§4.8). Exit code mirrors the action’s process status; timeout exits125.phux workspace inspect [PATH] [--json]— inspect the local git repository containingPATHand every checked-out worktree reported by git. This never contacts a running server and never creates, deletes, or checks out worktrees. Agents use the JSON shape (§4.9) to choose a checkout before creating a session (phux new -c <worktree>) or mapping existing sessions and panes back to repo paths.phux workspace save [--socket P] [--output PATH]— capture the running phux workspace as a typed JSON archive. With no--output, the archive is printed to stdout. This contacts the server but does not attach or resize.phux workspace restore ARCHIVE [--socket P]— recreate sessions missing from a saved archive. Restore starts new processes; it does not claim to resurrect the original PTYs.phux satellite <list|add|remove> [--json]— manage the hub-side federation satellite registry. This never contacts a running server and never opens a satellite transport; it only edits[[satellites]]in local config.--jsonemits the satellite registry document (§4.10); failure paths leave stdout empty and report diagnostics on stderr.
insert-pane is intentionally not named split: it edits topology around a
pane that already exists and performs no implicit spawn. Spawn-and-place remains
a separate operation. detach is still an interactive TUI action. The shipped
verbs are listed in tui.md §1.
Destructive boundary. An agent must resolve and display the exact target,
snapshot relevant state, explain what will be lost, and obtain affirmative human
confirmation before kill or a destructive signal. The MCP signal adapter also
requires confirm: true for interrupt/terminate/kill. A watcher ending is not
proof of completion; verify inventory or terminal state under a finite bound.
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 GroupId 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, watch,
send-keys, paste, run, ask, launch/spawn placement, and the three
spatial verbs 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), name (session), name:N / name:tag
(window), name:N.M (pane), and @N (opaque id). = is explicitly
unsupported for headless commands because they have no attached-client MRU.
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, wait, and watch may omit a target;
send-keys, paste, run, ask, and every spatial verb require it. launch/spawn
use an optional target only for explicit local placement. Spatial and placement
targets are stricter than the selected-pane tiebreak: each must resolve to one
exact local pane.
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 = 2).
Version 2 adds the aggregate terminals inventory. Shape, name-sorted:
{
"schema_version": 2,
"sessions": [
{ "name": "work", "windows": 3, "attached": true }
],
"terminals": ["@3", "devbox/@7"]
}
windows is the window count; attached is a bool — whether any client is
attached. terminals is the complete addressable inventory in snapshot order,
using the canonical direct selector syntax. Satellite entries intentionally do
not imply a hub-local session/window join. 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.
4.5 Plugin registry — phux plugin ... --json
The plugin lifecycle surface is config-local. It edits or reads
[[plugins]] entries and validates referenced phux-plugin.toml manifests;
it does not load plugin code into phux and does not run plugin commands.
phux plugin list --json and phux plugin validate --json emit:
{
"schema_version": 1,
"plugins": [
{
"id": "example.agent-tools",
"name": "Agent Tools",
"version": "0.1.0",
"min_phux_version": "0.0.2",
"description": null,
"manifest": "./plugins/agent-tools/phux-plugin.toml",
"manifest_path": "/abs/path/phux-plugin.toml",
"plugin_root": "/abs/path",
"enabled": true,
"platforms": null,
"build": [],
"actions": [],
"events": [],
"panes": [],
"links": []
}
]
}
validate --json also carries "valid": true. link, enable, and
disable wrap the same plugin object under "plugin"; unlink wraps the
removed object under "removed". The registry JSON enumerates declarative
actions, event hooks, pane providers, and link handlers from each manifest but
does not execute them. Invalid or missing manifests are hard failures: exit
nonzero, stdout empty, stderr diagnostic.
4.6 ConfiguredAgentsJson — phux config agents --json
phux config agents --json emits configured plugin agent declarations as a
consumer-ready list, merged with live runtime state when a server answers
(phux-r82.10). Schema history: version 1 was the pure manifest projection;
version 2 (current) makes state/attention the effective values —
runtime phux.agent/v1 record first, declared manifest baseline as fallback
— and adds live, source, declared, and runtime:
{
"schema_version": 2,
"live": true,
"agents": [
{
"plugin_id": "example.agent-tools",
"plugin_enabled": true,
"id": "codex",
"label": "Codex",
"description": "Coding agent",
"state": "blocked",
"attention": "high",
"source": "runtime",
"declared": { "state": "working", "attention": "normal" },
"runtime": {
"terminal": "@3",
"name": "codex",
"kind": "codex",
"state": "blocked",
"attention": "high",
"asked": false
},
"contexts": ["workspace", "pane"]
}
]
}
state is one of unknown, idle, working, blocked, or (runtime only)
done. attention is one of none, low, normal, or high. live is
whether a server answered; with live: false every row is source: "manifest". source is "runtime" when a live phux.agent/v1 record
matched the row (record kind slug, else lowercased name, equals the
agent id — the same identity derivation as phux agent), "manifest"
otherwise; runtime is null for manifest rows. When several panes declare
the same agent, the most attention-worthy binding is reported. Attention
follows the record’s convention: declared value first, else derived from
state (blocked→high, working→normal, done/unknown→low, idle→none). An
active ADR-0035 ask on the matched pane sets runtime.asked and elevates a
record that declares no state to blocked; a declared record state
outranks the ask sentinel (ADR-0040). Invalid manifests are hard failures
and leave stdout empty on --json, preserving the script contract.
4.7 AgentStateJson — phux agent ... --json
phux agent list --json, phux agent show --json [TARGET], and
phux agent explain --json [TARGET] emit the same versioned shape. explain
differs only in the human output; JSON always includes the evidence trail:
{
"schema_version": 1,
"agents": [
{
"terminal": "@3",
"session": "work",
"window": "window-0",
"agent": { "id": "codex", "label": "Codex", "kind": "codex" },
"state": "blocked",
"confidence": 0.95,
"attention": "high",
"title": "phux-ask[deploy]:Approve deploy??s=Yes|No",
"cwd": "/repo",
"sources": [
{
"kind": "title_ask",
"signal": "phux-ask title sentinel",
"confidence": 0.95,
"observed": "phux-ask[deploy]:Approve deploy??s=Yes|No"
}
],
"explanation": "waiting on a reported human-answerable ask"
}
]
}
agent.kind is codex, claude, opencode, pi, omp, plugin,
declared, or unknown. state is unknown, idle, working, blocked,
or done; attention is none, low, normal, or high. sources is
sorted by descending confidence and is the provenance contract: current
sources include agent_record, title_ask, screen, semantic_cells,
identity, and plugin_report. A structured phux.agent/v1 record outranks
heuristics; without one, a plugin report remains lower precedence than a live
phux-ask title sentinel or an explicit blocked/completed screen cue.
Unknown/missing signals stay unknown or low-confidence idle.
This is a public clean-room projection. It does not copy external agent manifests or private tradecraft rules; built-in recognition comes from publicly observable process identity and captured pane chrome, plus optional local phux plugin declarations.
4.8 PluginActionOutput — phux config run --json
Defined in crates/phux-plugin/src/lib.rs (schema_version = 1). Shape:
{
"schema_version": 1,
"plugin_id": "example.agent-tools",
"action_id": "summarize",
"command": ["python3", "summarize.py"],
"cwd": "/path/to/plugin",
"outcome": "completed",
"exit_code": 0,
"stdout": "...",
"stderr": "",
"duration_ms": 42
}
outcome is "completed" or "timed_out". exit_code is null when the OS
does not provide a process code or when phux kills the child on timeout. The
runtime executes the manifest’s argv directly from the plugin root, captures
stdout/stderr lossily as UTF-8, inherits the phux process environment, and adds
PHUX_PLUGIN_ID, PHUX_PLUGIN_ACTION_ID, and PHUX_PLUGIN_ROOT.
4.9 Workspace commands — phux workspace ...
phux workspace inspect --json is repo-local. It shells out to git’s porcelain worktree
listing and reports the current worktree plus siblings as a stable JSON
projection:
{
"schema_version": 1,
"repo": {
"path": "/abs/path/repo",
"head": "012345...",
"branch": "main",
"detached": false
},
"worktrees": [
{
"path": "/abs/path/repo-feature",
"head": "89abcd...",
"branch": "feature",
"detached": false,
"current": false
}
]
}
For detached worktrees, branch is null and detached is true. Missing
or non-git paths are hard failures: exit nonzero, stdout empty, stderr
diagnostic. The command is intentionally read-only; creation and deletion stay
in git/plugin/provider territory rather than the terminal substrate.
phux workspace save emits a separate archive shape:
{
"schema_version": 1,
"sessions": [
{
"name": "agent-bench-codex",
"active": true,
"windows": [
{
"name": "0",
"active": true,
"panes": [
{
"active": true,
"title": "codex",
"cwd": "/repo",
"command": null,
"cols": 120,
"rows": 40
}
]
}
]
}
]
}
command is nullable because process argv is not always known. Plugin-authored
archives may fill it, and workspace restore uses it when present; otherwise it
starts the default shell in the saved cwd when available. Existing session names
are skipped, and restore prints a summary JSON document with restored and
skipped_existing arrays.
Restored sessions are fresh PTYs. The archive preserves window/pane metadata and
split-layout shape for inspection and future replay, but the current restore
command only recreates missing sessions and their seed process. Use phux upgrade for live PTY handoff across a server re-exec; do not present workspace
restore as resurrecting already-running processes.
4.10 Satellite registry — phux satellite ... --json
The satellite lifecycle surface is config-local. It edits or reads
[[satellites]] entries and does not dial remote hosts.
phux satellite list --json emits:
{
"schema_version": 1,
"satellites": [
{
"name": "devbox",
"endpoint": "ssh://devbox",
"enabled": true
}
]
}
add --json wraps the same satellite object under "satellite"; remove --json wraps the removed object under "removed". Invalid names, invalid
endpoint URIs, duplicate configured names, and refused registry writes are hard
failures: exit nonzero, stdout empty, stderr diagnostic.
4.11 phux spawn --json
phux spawn --json emits a small fixed object naming the spawned terminal:
{
"terminal_id": 7,
"satellite": null
}
satellite is the registry name when the spawn was routed with
--satellite NAME (in which case terminal_id is the id on that
satellite — address the pane through the hub by the pair), and null
for a local spawn (address it as @7). Failures — no route to the named
satellite, unreachable satellite link, server-side spawn failure — exit
nonzero with stdout empty and the typed diagnostic on stderr.
4.12 Spatial layout edits
Each successful --json spatial edit emits a schema_version: 1 document.
Common fields are operation and session_id; insert adds
target_terminal_id, new_terminal_id, direction, and ratio; move adds
source_terminal_id, target_terminal_id, direction, and ratio; swap adds
first_terminal_id and second_terminal_id. direction retains the CLI’s
user-facing divider meaning (vertical = side-by-side, horizontal = stacked),
not the layout tree’s internal child-axis enum.
{
"schema_version": 1,
"operation": "insert-pane",
"session_id": 3,
"target_terminal_id": 7,
"new_terminal_id": 9,
"direction": "vertical",
"ratio": 0.3
}
With --json, failures emit a versioned JSON error on stderr and leave stdout
empty: { "schema_version": 1, "error": { "code": "...", "message": "..." } }.
Stable codes include invalid_selector, selector_miss,
selector_not_single, satellite_target, cross_session, invalid_ratio,
layout_missing, pane_not_in_layout, and pane_already_in_layout.
4.13 phux launch --json
A successful launch returns the resolved integration identity, final argv, and new local terminal id:
{
"schema_version": 1,
"terminal_id": 11,
"integration": "codex",
"plugin": "com.phux.agent-tools",
"argv": ["phux-agent-wrap.sh", "codex"]
}
phux launch --list --json instead returns
{ "schema_version": 1, "integrations": [...] }; --print --json returns the
resolved cwd, working_directory, and argv without spawning. Placement does
not add a second result shape: --target, --split, and --ratio affect the
persisted topology while the launch JSON remains the object above.
5. The read-act-wait loop and exit-code mirroring
5.1 The loop
The single-pane pattern is read → act → wait → read: snapshot the pane, send
input or run a command, wait for the result to land, snapshot again. Every wait
must carry a finite timeout; a CLI watch is an unbounded stream and must run
under a child-process deadline. A worked example in sh:
phux send-keys build "cargo test" Enter
phux wait --until "test result:" --timeout 120 build
phux snapshot --json --scrollback 200 build > 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 --json build "cargo test"
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).
When the input itself is a block of text — a heredoc body, an indented code
snippet for a REPL, a multiline SQL statement — use paste, then submit
explicitly:
phux paste repl "$(cat snippet.py)"
phux send-keys repl Enter
send-keys without a trailing Enter would type the block character by
character, letting the REPL’s auto-indent mangle every indented line; paste
delivers it as one bracketed block (when the pane’s program supports DEC mode
2004) and the program inserts it verbatim, waiting for the explicit Enter.
The fleet extension is discover → create → place → shape → act → observe →
surface asks → verify. See the executable
examples/agents/orchestrate-placed-fleet:
it launches/spawns with explicit placement, serializes topology edits, watches
agent panes concurrently under hard bounds, and prints C-a q / C-a Q human
guidance without changing focus.
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). |
paste | 0 ok (including a paste the pane’s untrusted policy silently dropped); 1 failure (no server / refused / miss / unreadable stdin). |
ask | 0 accepted; 1 no server, unknown pane, or invalid ask payload. |
agent | 0 ok; 1 no server, unknown pane, or JSON render failure. |
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. |
rename | 0 renamed; 1 no server or transport failure; 2 unknown source session or destination name already exists. |
launch / spawn | 0 spawned/resolved/listed; 1 invalid integration, placement, server, or spawn failure. |
watch | streams until Ctrl-C, EOF, or caller termination; use a subprocess bound rather than treating its eventual signal status as agent outcome. |
plugin | 0 ok; 1 invalid/missing manifest, invalid config, refused registry write, or unknown plugin id. |
workspace | 0 ok; 1 missing git repo, invalid git output, no server for save/restore, invalid archive, or JSON render failure. |
satellite | 0 ok; 1 invalid name/endpoint, duplicate configured name, invalid config, refused registry write, or unknown satellite name. |
insert-pane / move-pane / swap-pane | 0 ok; 1 transport failure; 2 selector, ratio, session, or layout refusal. |
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
OpenCode integration selects a host-specific six-tool subset;
the Pi integration exposes nineteen bounded tools, including spatial
placement and topology edits. The MCP adapter exposes 22 strict
tools, including paste, launch/spawn, bounded watch, ask, spatial edits, agent
state, and workspace parity, over JSON-RPC stdio. Adapter guides link here instead of
redefining CLI syntax.
sdk.md documents phux-client, the library crate those surfaces
are built from. These adapters are unprivileged consumers
(ADR-0017); the wire
underneath stays additive and versioned under ../spec/
(ADR-0022).