phux
guidesagents and automation

Agent CLI

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...

evolving document
Full source summary

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.

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 (like tmux ls): with none running it reports as much and exits non-zero. --json emits SessionListJson (§4.1).

  • phux snapshot [--json] [--scrollback[=N]] [--cells] [--tail[=N]] [--unwrap] [--socket P] [TARGET] — side-effect-free pane read via GET_SCREEN. TARGET is optional (defaults to the focused session). --json emits ScreenState (§4.2); without it, a boxed text view.

    --tail[=N] returns the last N rendered rows — history above the viewport, then the viewport, oldest first. Bare --tail is 80 rows; --tail 0 is all retained history, capped at 10 000. The viewport is a floor and is never returned in part, because rows, cursor, and cells are grid coordinates and a partial grid would lie about them; a window narrower than the viewport therefore returns more rows than you asked for, never fewer. Dropped rows set truncated (§4.2).

    --unwrap joins soft-wrapped rows into logical lines — rows as written rather than as painted. It cannot be combined with --cells, whose coordinates do not survive the join, and after it soft_wrap reports no wrapped rows because the returned projection has none.

    Both are client-side projections of the same side-effect-free GET_SCREEN: neither adds a wire field, neither moves a live pane, and §1’s viewport-safety claim for snapshot is unchanged.

  • phux send-keys [--socket P] TARGET KEYS... — route named keys or literal strings to one resolved pane by id (ROUTE_INPUT). TARGET is required. No JSON. KEYS are 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 before Enter/Return becomes 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). TARGET is required; TEXT is the payload, read from stdin when omitted (git diff | phux paste review). No JSON, like send-keys; exit codes mirror send-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 in ESC[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 with phux send-keys TARGET Enter to run what you pasted. Prefer paste for anything multiline or indented when you want insertion without submission: ordinary send-keys literals type character by character, while a dedicated paste arrives intact. The submission shorthand phux send-keys TARGET "text" Enter is also bracketed-paste-aware. Pastes are trusted by default — the agent vouches for content it composed, the same ungated input authority send-keys has. --untrusted opts 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. One paste call is one atomic INPUT_PASTE event; there is no guarantee across separate fire-and-forget calls, so never split one logical payload (one command, one file body) across multiple paste/send-keys invocations expecting it to arrive whole — see input.md §5.1 for why an interior drop is worse than a whole-event drop, and use APPLY_INPUT or PUT_FILE at the protocol layer when a payload cannot fit in one event.

  • 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). TARGET is required. --json emits RunResult (§4.3). The exit code mirrors the child (§5.2). Flags must precede TARGET, or clap’s trailing_var_arg swallows them into the command line.

  • phux wait [--until TEXT] [--regex PATTERN] [--idle MS] [--tail[=N]] [--output-only] [--timeout SECS] [--json] [--socket P] [TARGET] — poll the side-effect-free screen read until a condition holds. A text condition (--until or --regex, mutually exclusive) takes precedence over --idle; with none of them it settles on idle. --json emits the final ScreenState as read — the projections below scope the match, not the emitted document. Exit 0 when the condition is met, 124 on timeout.

    Matching is against the lines as written, not as painted. Rows the terminal soft-wrapped at its right edge are joined into logical lines first, so a needle that straddles a wrap is found. This is a behavior fix rather than a flag: before it, --until on text that happened to fall across the wrap silently never matched and the wait ran to its timeout, which looked like a hang and appeared only for long lines. Against a server that does not report wrap bits (soft_wrap absent, §4.2) rows are matched verbatim — the old behavior, detectable rather than guessed at.

    --regex PATTERN is a Rust regular expression matched one logical line at a time, so ^ and $ anchor to a line you can see and no pattern spans two of them. An invalid pattern is a usage error (exit 2) raised while parsing the command line, before any connection or poll, rather than a wait that quietly never matches.

    --tail[=N] scopes matching to the last N logical lines and reads that much history to do it; without it only the viewport is read, as before. Bare --tail is 80, --tail 0 is all retained history capped at 10 000. Three things to know about the count: it is over logical lines, after wrapped rows are joined, so it can never bisect a wrapped run; it ignores the blank rows under the cursor, because a grid is always full height and counting them would make a small window match nothing on a pane whose prompt sits near the top; and unlike snapshot --tail the viewport is not a floor — this window scopes a search rather than describing a returned grid, so --tail 3 really does mean three lines. It counts the prompt block already back on screen, so leave room for it. --tail also narrows --idle: only those lines have to hold still, which is one way to settle a pane with a spinner further up.

    --output-only drops lines the shell marked as your own typed input (OSC-133 Input, the same marks snapshot --cells exposes), so a wait cannot be satisfied by the echo of the command that started the work. A command too long for one row is dropped whole; prompt-marked text is kept; history rows carry no marks and are always treated as output. It needs a shell with OSC-133 integration — with no marks at all it filters nothing and phux says so on stderr before the wait begins, because failing closed would hang a wait that is otherwise fine.

    The gotchas that remain: flags must precede TARGET; a bare --tail reads the next word as N, so spell N out when you also pass a target (--tail 80 build, not --tail build, which is a usage error); and without --output-only, --until/--regex still match any line including the shell’s echo of the command you just typed — on a pane with no shell integration, match on text that appears only in command output, never the command itself.

  • phux watch [--until EVENT]... [--timeout SECS] [--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 (carries title), bell, dirty, idle, pane_spawned, pane_closed (carries exit_status), asked (carries id, question, suggestions, and nullable elapsed_seconds), command_started / command_finished (the latter carries a nullable exit_code), and agent_state.

    Repeating --until turns the stream into a gate: it prints through the first matching event and exits 0. Accepted names are agent_state, asked, bell, command_finished, command_started, dirty, idle, pane_closed, pane_spawned, title_changed, and unknown; an unknown name is refused before connecting. --timeout bounds either a gate or a plain stream and exits 124 without appending a summary to the NDJSON. Server EOF before an --until match exits 1; Ctrl-C remains a clean 0.

    agent_state is the derived-agent half of the stream, not an L1 event: watch also subscribes to the resolved pane’s phux.agent/v1 L3 record (../spec/L3.md §3.7), so every transition the server-side detector publishes (ADR-0046) arrives as a line. It carries name, optional kind and session, state, attention, and from — the state the same pane was last seen in within this watch run, absent on the first record for a pane. attention is the effective level L3 §3.7 derives from state, because the detector never writes the field itself. A deleted record (or a value that is not a readable record, which L3 §3.7 reads as “no declared agent”) emits a line with state present and null, keeping name and from from the last record seen, rather than being dropped: a consumer waiting on an agent has to learn that it went away. The subscription is one (scope, key) pair against the resolved pane, so agent_state lines are always scoped to that pane; there is no fleet-wide agent-state stream, because L3 has no wildcard-Terminal scope. Watch the panes you care about, one stream each.

    watch cuts wait’s poll-floor latency: a watch consumer wakes the instant an event fires rather than on the next poll tick. It is additive — wait still works without it, and a dropped event (full mailbox) falls back to polling.

    No schema_version on this stream, by design (ADR-0071). Every other --json document in this catalog stamps a schema_version; this NDJSON stream deliberately does not, on either of the two shapes you might expect instead — a per-line field (repeated overhead on a hot, high-volume path, for a value that essentially never changes mid-run) or a versioned header line (invisible to the consumer shape this stream actually has: one that attaches, disconnects, reconnects, or tails an existing pipe, and so may never see line one). The stream is versioned by the binary (phux --version), and the compatibility unit is the event name vocabulary above: a consumer ignores an event value or a field it does not recognize, the same way the reference decoder already renders an unknown wire event tag generically instead of failing the stream. A shape-breaking change to an existing event is a breaking change to the CLI’s frozen JSON surface exactly like any other and requires the major version bump ADR-0071 already mandates for one — nothing here is exempt, only unmarked per line. Command boundaries (phux-foz.4): command_started / command_finished are sourced from a direct scan of the raw PTY byte stream for OSC 133 ; C / OSC 133 ; D shell-integration marks — C emits command_started, D emits command_finished with the shell-reported exit code in exit_code when the mark carries one (OSC 133 ; D ; n). exit_code is null only when the shell’s D mark omits the code or the shell has no OSC-133 integration at all; it is not always null. A pane whose shell never emits OSC-133 marks (no shell integration configured) never produces these two events — dirty/idle remain the fallback command-boundary signal in that case. Remaining caveat: idle (and by extension the fallback boundary) depends on the PTY actually going quiet; a live shell prompt whose program keeps repainting (for example a blinking cursor or a redrawing status line) can keep generating chunks that never settle, so idle cadence is only as good as the prompt’s own quiescence.

  • phux rec -o PATH [--format FMT] [--from FILE] [--duration SECS] [--fps N] [--idle-limit SECS] [--max-bytes N] [--cast-version N] [--json] [--socket P] [TARGET] — record a pane and export it as an asciinema cast, an animated GIF, or an APNG. -o/--out is the only required argument; its extension picks the format (.cast, .gif, .png/.apng; no extension means GIF) unless --format overrides it. Capture subscribes with ATTACH_TERMINAL and is viewport-safe in the same sense as snapshot and watch: it neither attaches the session nor resizes the pane. --from re-renders an existing cast offline and never contacts the server. --json emits the result object (§4.14). Ctrl-C stops the capture and still writes the artifact, so an open-ended phux rec -o demo.gif under a subprocess deadline is the scripted shape. Full surface in recording.md.

  • phux play FILE [TARGET] [--speed N] [--idle-limit SECS] [--loop [N]] [--no-fit] [--close] [--split AXIS] [--ratio F] [--json] [--socket P] — create a pane whose PTY is fed from a recording, and print its Terminal id. The pane is an ordinary one: snapshot it, resize it, rec it, watch it, kill it. TARGET names the pane the new one is placed beside (default .) and is never written to — there is no way to play into a pane that already has a shell. The verb returns as soon as the pane exists; it does not block for the length of the recording, so an agent that wants the final screen should poll phux snapshot rather than wait on this process. The pane holds its final frame until killed unless --close is given, which is what makes that poll safe. --json emits the result object (§4.16). Full surface in recording.md §6.

  • 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 a phux-ask title sentinel themselves. It resolves TARGET client-side, does not attach or resize, and asks the server to emit the normal asked event on the existing watch stream. --json echoes the reported { schema_version, 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 q cycles asking panes and C-a Q returns 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 declared phux.agent/v1 record (ADR-0040; see agent set below) reports straight from it with agent_record provenance and no heuristics; otherwise state is inferred from already-phux-shaped evidence: session/pane metadata, OSC/title hints, side-effect-free snapshot --cells, and enabled plugin [[agents]] declarations. list covers every pane; show returns the selected pane; explain keeps the same state but expands the evidence trail in the human view. --json emits AgentStateJson (§4.7). States are unknown, idle, working, blocked, or done; each state carries confidence and ordered provenance so consumers can show why phux believes it.

  • phux agent explain --file PATH --kind KIND [--title TEXT] [--format auto|json|text] [--json] — the offline half of explain. It evaluates the compiled detection manifests (ADR-0046) against a captured screen and contacts no server at all; --file - reads stdin. PATH is phux snapshot --json output or a plain text screen, one viewport row per line, and --format auto (the default) picks JSON when the first non-whitespace byte is {. --kind is required and takes a kind slug or one of its binary aliases (claude-code resolves to claude): offline there is no foreground process group to identify the agent from, so a miss enumerates the loaded manifests rather than guessing. A capture carries no OSC title, so --title supplies one for title-scoped rules; without it every title rule reads an empty region, and the report says so. The output is the evidence, not the answer: the text every region resolved to on that screen, then every rule — matched and unmatched — with its predicate tree annotated node by node. A rule scoped to a region that comes back empty cannot match however well it is written, and because the detector fails safe to idle, nothing else makes that visible. --file conflicts with TARGET; --kind, --title, and --format require --file. --json emits AgentExplainJson (§4.7).

  • 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 whole phux.agent/v1 L3 record (docs/spec/L3.md §3.7, ADR-0040; last writer wins). An agent integration calls it (or issues the equivalent SET_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.

    A declared state outranks the detector only while the pane is still occupied by the agent it describes. Omitting --state writes the literal "unknown", which is not a declaration: the record supplies identity and the detector fills state in, preserving your name, kind, and session. Supplying any other --state stands the detector’s derivation down on that pane — deliberately, because a lifecycle hook is better evidence than a screen rule (ADR-0046 point 8). The declaration then ends in one of three ways: agent clear (or any DELETE_METADATA), the pane being reaped, or the server withdrawing it. A withdrawal is what happens when the declaring process dies without clearing — a SIGKILL, a force-closed pane, any exit that skips the integration’s cleanup path. On positive evidence that the pane’s occupant is gone or has changed, the server sets state to "unknown" and clears attention, and stops there: it never substitutes a derived value for your declaration and never deletes a record it did not author (docs/spec/L3.md §3.7, normative). Your name, kind, and session survive untouched; only the claim about lifecycle is dropped, and the derivation resumes from there. Positive evidence is an observation the server successfully made and which found no such agent — a server that cannot see the pane’s foreground process holds the declaration rather than guessing, so a declared record is never withdrawn by a failed query. Consumers see this as an ordinary transition into unknown (§5.1), which is a departure, never a completion.

  • 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. This is the only verb that removes the record; the withdrawal described above empties the state and keeps the identity, so a withdrawn pane still resolves by name and kind.

  • phux agent wait [--until STATE]... [--timeout SECS] [--json] [--socket P] [TARGET] — block until the pane’s agent transitions into a lifecycle state. --until repeats and ORs over idle, working, blocked, done, defaulting to idle,blocked,done — the three ways a turn ends. Detection manifests cannot honestly derive done from a screen, but the bundled Claude shim reports the Stop hook through REPORT_AGENT_STATE; therefore --until done is meaningful on an instrumented Claude pane. Other agents need their own lifecycle integration and may otherwise time out. unknown is not spellable: it is departure, not a state to wait for. A satellite TARGET is refused (satellite_target, exit 2) as soon as the selector resolves, before the wait subscribes to anything: phux.agent/v1 is hub-local, so a hub has no record for a remote pane and can never be told one changed. Run the wait on the satellite’s own server. phux watch still carries that pane’s agent events across the hub — it is the metadata half that does not federate, not the event half. TARGET is optional (the focused pane). --timeout is in seconds and is unbounded when omitted, matching phux wait; always pass one from a script. --json emits AgentWaitJson (§4.7).

    It is satisfied only by an observed transition, never by a level read of the current state, for the reason ../spec/L3.md §3.7 states normatively: a level read of state asserts only that nothing contrary is being asserted, and idle is the weakest value in the vocabulary — normally the reference detector’s fail-safe fallthrough (ADR-0046). Claude’s captured OSC 9;4 remove signal is one positive-idle source, but a level read still says nothing about whether that transition occurred after this wait’s baseline. A completion gate that fired on a level would also report success instantly on crashed panes and panes with no manifest. §5.1 has the loop.

    The verb subscribes to the pane’s phux.agent/v1 key before reading the pre-wait baseline, on one connection, so no transition falls in the gap; it also re-reads GET_METADATA on the phux wait cadence, because the change notification is droppable and the detector is edge-filtered. That re-read is level-triggered recovery of an edge — it goes through the same must-have-changed rule — never a level gate. The deliberate consequence: a pane already resting in a target state when the wait begins times out rather than succeeding, and the timeout diagnostic names the state it held so one phux agent show recovers. That is a loud false negative in place of a silent false positive.

  • phux agent send-keys [--expect-agent NAME] [--expect-kind KIND] [--json] [--socket P] TARGET KEYS... — the agent-addressed sibling of phux send-keys, differing from it in exactly one way: it re-reads the pane’s phux.agent/v1 record immediately before writing and refuses if the occupant is not the agent you named. phux send-keys addresses a pane and deliberately checks no identity — use that one when a pane is what you mean. A pane with no record is refused rather than written to.

    --expect-agent matches name, and a detector-written name is a per-kind constant, not a per-pane label. A detection manifest is written once per agent kind, so every pane the detector recognizes as Claude carries name = "claude", and so does every pane running the hook shim. --expect-agent claude therefore asserts “a Claude is in this pane”, which is a real and useful check but not an identity — it passes on any of twelve Claude panes. The detector will not invent claude-7 to paper over this: the pane id is the per-pane identity and it is already the selector you used. If you want a name that distinguishes one pane from another, set it yourself with phux agent set @7 --name reviewer; an explicitly set name is never overwritten by the detector, and --expect-agent reviewer then means what it looks like. Use --expect-kind when the kind is what you actually care about.

    Every key spec is validated before any byte is written, so a typo in the third key cannot leave the first two delivered; unlike phux send-keys, a near-miss chord (C-cc, a bare M-) is refused rather than typed as literal text. The identity read and acknowledged APPLY_INPUT are ordered on one connection. Success means write_all plus flush completed on the PTY master: the bytes reached the kernel tty queue, not necessarily the agent. INPUT_DELIVERY_UNKNOWN is terminal; read the pane and do not resend under a new operation id. The server currently has one acknowledged input lane, so serialize concurrent acknowledged writes.

    What the check can now rely on is that a stale kind does not sit beside a live state. When the pane’s occupant changes — a Claude killed and a Codex started in the same pane, or the same kind restarted as a new process — the server corrects kind and drops state to "unknown" in one write, rather than letting the new occupant’s derived state accumulate under the old occupant’s label. A record that reads kind: claude, state: working is therefore evidence about a live Claude, not a leftover. The exception is a kind you set explicitly: the server preserves an explicit writer’s kind (§3.7 of the spec requires it), so if you declared one, keeping it accurate is yours to do. --json emits the shape in §4.7.

  • phux agent prompt [--expect-agent NAME] [--expect-kind KIND] [--wait] [--until STATE]... [--timeout SECS] [--json] [--socket P] TARGET TEXT — submit one single-line prompt plus Enter as one acknowledged, idempotent operation. Raw newlines are refused. --wait holds the same process and connection across delivery and waits only for a post-write transition, so the fused form cannot miss a fast turn between two commands. --until and --timeout require --wait; states default to idle,blocked,done. Timeout exits 124 after reporting that delivery occurred. The acknowledged input lane is per server, with one admission slot and one execution thread; prompt fleets serially. An OK is a kernel-queue receipt, not proof of consumption. If delivery is unknown, inspect the pane and do not resend.

  • phux agent answer --id ID (--choice N|--text TEXT) [--allow-unlisted] [--json] [--socket P] TARGET — answer the exact ask still live on the pane. --choice is one-based into the published suggestions. Free text must equal a suggestion unless --allow-unlisted is explicit. A stale id, anonymous ask, or pane no longer asking is refused with nothing written; a valid answer is one acknowledged paste-plus-Enter operation.

  • phux agent start --kind KIND --target TARGET [--integration ID] [--timeout SECS] [--no-wait] [--force] [--json] NAME [-- ARGS...] — start an agent inside an existing shell pane. It never creates, splits, moves, or focuses layout. --integration defaults to the unique enabled integration whose [agent_identity] kind matches --kind (so --kind claude starts claude-code with no second flag), falling back to the kind slug itself; two enabled integrations claiming one kind are refused by name (ambiguous_integration) rather than picked between, and the explicit flag remains the override. Without --no-wait, success requires the first detector publication after submit; a kind with no manifest is refused because readiness would be unenforceable. Timeout exits 124 after the command was typed. Before writing, the verb reads the server-owned phux.pane-occupant/v1 record: a foreground process other than the pane’s original shell is refused, while a confirmed pane shell works even without OSC-133 shell integration. A contradictory OSC-133 busy mark still refuses because the process observation is periodic. --force skips only this available-shell precondition.

  • phux agent install-claude [--shell zsh|bash|fish] [--real PATH] — make plain interactive claude invocations 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. Noninteractive/admin invocations such as claude -p, claude mcp, and claude --version bypass phux.

    Only the session-start hook writes the record, and it declares identity only — --name claude --kind claude, never a --state. Per-turn hooks feed working, blocked, and done to the server detector with phux agent report-state; they do not write metadata. A hook that declared a state would stand the ADR-0046 detector down on that pane for the record’s lifetime, and claude.toml is the deepest manifest phux ships. The per-turn hooks write nothing to the record because an identity write also replaces the derived state (§3.7 records are replaced wholesale, not merged), which a repeated write turns into a false departure edge. The detector publishes hook evidence immediately, then resumes ordinary screen derivation, so a missed cleanup hook cannot latch stale state. Blocked notifications still emit phux ask, so phone and TUI fleet views see attention without screen inference; that path is unchanged and keeps the hook’s exact timing. The Stop hook is again an honest done producer, so agent wait --until done is satisfiable on shim panes.

    The installed shim is version-stamped (# phux-shim-schema: N on the second line). install-claude reports installed, reinstalled, or upgraded ... (schema N -> 4) so you can tell a no-op from a real migration. Upgrading the phux binary does not rewrite an already installed shim. Schema 1 declares state and stands the detector down; schema 2 rewrites the record on every hook and can publish a false departure; schema 3 writes identity once; schema 4 adds detector-ingress lifecycle reports. phux doctor reports a stale installed schema and names phux agent install-claude as the repair.

  • 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 resize [--json] [--socket P] TARGET COLSxROWS — set one resolved pane’s grid, with no TTY. TARGET is required; COLSxROWS is two whole numbers of cells, each at least 1 (120x40). This is the only way to size a pane without a terminal: every other path derives geometry from an attached client’s viewport, and a caller with no TTY reports 80x24. Nothing attaches and nothing subscribes, so the call cannot itself shrink the pane it is sizing.

    The new size applies immediately, even with someone attached, and it is not permanent against an attached view: under the default defaults.window-size = "smallest" policy the next attach, detach, or window resize recomputes the pane’s geometry from the attached views and supersedes it. window-size = "manual" is the setting under which an explicit resize holds (tui.md §4.2). You do not have to guess which happened — the verb reads the server’s real geometry back before exiting and exits 1 when it is not the requested one. Shape in §4.15.

  • phux new [-s NAME] [-c CWD] [-- COMMAND...] [--json] [-e KEY=VALUE]... [--socket P] — create a new session. Without --json it creates and attaches: an explicit -s NAME that already exists is an error (like tmux’s duplicate-session refusal); an omitted name starts from defaults.session-name-template and gains a numeric suffix when needed; a server is auto-spawned if none is running. With --json it creates the session without attaching (no attach, no resize), then prints the seed pane id as JSON and exits. --json requires an explicit -s NAME — enforced by the parser itself, so omitting -s is a usage error (exit 2) — and errors if that name is already in use (create-only, never create-or-attach). Repeat --env KEY=VALUE to add seed-process environment entries; --env requires --json. 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. A template may declare bounded native fresh/resume argv through a dedicated PHUX_*_SESSION_ID environment name; the identity is one opaque, non-option argv element and cannot become executable or evaluator source. Fixed plugin-owned interpreter wrappers remain valid. Launch atomically publishes and confirms the exact Terminal-scoped resume record before succeeding. --list inventories integrations; --print/--dry-run resolves the same final argv without a server; --target places the launched pane beside an exact local pane. Successful --json launch 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; vertical means side-by-side and horizontal means stacked; R is 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 NAME routes 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 [--split horizontal|vertical] [--ratio R] [--json] [--socket P] — insert an already-created local pane beside an existing layout leaf. This never spawns: create NEW_PANE separately first. Both selectors must each match exactly one pane in the same session. --split is the same axis flag spawn and launch take (h / v shorthands accepted): vertical means a vertical divider (side-by-side panes); horizontal means a horizontal divider (stacked panes) and is the default. The pre-unification boolean --horizontal / --vertical spellings have been removed. Shape in §4.12.

  • phux move-pane SOURCE TARGET [--split horizontal|vertical] [--ratio R] [--json] [--socket P] — collapse SOURCE out of its old position and insert it beside TARGET. When the panes belong to different sessions, the live Terminal is re-parented without restarting its process or changing its id, then both sessions’ layout envelopes are updated. 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 and satellite selectors; insert-pane and swap-pane also reject cross-session selectors. None changes 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. --json emits 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-pane phux.agent/v1 records and asked state when a server answers on the socket (phux-r82.10). No reachable server degrades to the declared manifest values. --json emits ConfiguredAgentsJson (§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. --json emits PluginActionOutput (§4.8). Exit code mirrors the action’s process status; timeout exits 125.

  • phux workspace inspect [PATH] [--json] — inspect the local git repository containing PATH and 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. Native agent sessions established by phux launch are copied from their exact Terminal into archive schema 2. 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. A saved native agent identity is resumed only after the current enabled integration resolves to the same owning plugin. Restore starts new processes; it does not claim to resurrect the original PTYs.

  • phux worktree new BRANCH [--repo PATH] [--session NAME] [--json] — create the git worktree and its bound phux session. --json returns the facts from that create without a follow-up lookup: {schema_version, branch, path, session, terminal_id}. terminal_id is the seed pane’s numeric id. The sibling open and remove verbs also accept --json; all three use the shared workspace error code for git failures.

  • phux --skill — print the agent skill compiled into this exact binary (phux skill is equivalent). Add =quick, =agent, =terminal, or =full; bare output is full. Every scope is derived from one source. Prefer it to a copied checkout example when teaching another agent: CI checks that it names every visible top-level and agent verb.

  • phux --capabilities --json — socketless installed-build discovery: phux and wire versions, every visible command path from the live parser, skill scopes, CLI JSON contract versions (including intentional unversioned results and streams), and actual sibling/PATH discovery of the phux-mcp companion. It reports compile-time capability, not negotiated running-server state; use status --json for that. Its MCP launcher is phux mcp, and phux mcp --schema prints the authoritative tool input catalog.

  • phux doctor [--json] — inspect the installation, including the on-disk Claude shim schema. A stale shim is a warning, not a failed doctor run, and the remedy is to rerun phux agent install-claude.

  • phux host <add|ls|rm> [--role remote|satellite] [--json] — manage both machine registries through one namespace (--role remote, the default, edits [[remote]]; --role satellite edits [[satellites]]). These never contact a running server and never open a transport; they only edit local config. --json emits the host registry document (§4.10); failure paths leave stdout empty and report one contract line on stderr. Formerly the separate phux remote and phux satellite verbs, absorbed into this one namespace (ADR-0066).

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. Self-detach (C-a d, FrameKind::Detach) is still an interactive TUI-only action — it ends the calling client’s own attachment, and a headless caller was never attached to end. Forcibly detaching other clients is a different, request/response operation (Command::DetachClients, backing phux detach [SESSION]); it has no CLI --json today, but is reachable headlessly via the MCP phux_detach tool (mcp.md §3.8), which talks to it directly over the wire rather than shelling out. 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, and phux_detach requires it too — not because it destroys data (the session and its panes keep running), but because it forcibly ejects whatever human or agent is currently attached without their say-so. 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 a nonce-correlated phux.session.created/v1/<request_token> one-shot result), 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. GroupId’s retention as an opaque grouping key is settled, not a remnant awaiting removal (bead phux-0bmc closed as resolved-by-rename).

The alternate-screen transcript harvest in ADR-0078 remains proposed and is not a usable read surface. snapshot --tail continues to expose only retained main-screen history.

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.

View exact source