Files
tty7/docs/cli/reference.mdx
T
l0ng-ai 48d712143e fix(cli): answer wait --until free on remote and SSH panes (#840)
`pane_is_free` read the pane's local process tree and folded two very
different answers into one `false`. On a pane that is only the near end
of a connection that tree describes the tunnel: a pane routed to a
remote daemon has no local pty at all, so `DaemonPane::procs` returned
`Default::default()` and the tree came back empty; a pane whose shell is
running `ssh` has a tree whose depth-1 process is the `ssh` itself, busy
for exactly as long as you are logged in. Either way `free` was
unreachable structurally, `seen_busy` was set on every poll — which
suppressed the one hint that would have pointed at the gap — and the
wait rode the whole `--timeout` before recommending `--until free`, the
flag that had just failed.

Freeness is now three-valued: free, busy, or "nothing here can answer",
and the last one carries its reason.

On a remote pane freeness is the far shell's own OSC 133 prompt marks.
That is sound because the near shell cannot be at a prompt while the
connection owns its pty, so a prompt mark on such a pane can only have
come from the far side. Two things had to change for the daemon to be
able to say it. The reader suppresses relayed prompt marks so a
foreground program cannot engage the local line editor — right for the
editor, and precisely wrong here — so `ShellState` now keeps the mark's
own unsuppressed reading beside the editor's. And "not at a prompt" on a
remote pane means nothing until the far shell has proved it reports at
all, since the newest mark is otherwise the near shell's own "I started
`ssh`", which nothing will ever supersede; a latch records the first
prompt mark that arrives while the pane is remote, and is cleared on
every hop. `PaneProcs` carries all of this to the CLI in a new optional
`context` — remote target, whether this machine holds the pty, the
mark's reading, the latch — so the answer still costs one request per
poll and an older server, which omits the field, keeps today's
tree-only behaviour.

When the far host has no shell integration the honest answer is that
this machine cannot tell an idle remote prompt from a running remote
command. `wait` says so — exit 1, `status: unknown`, a `free_unknown`
string naming the host — after one poll of grace for a handshake still
in flight, and only when `free` was the only state that could still
answer, so `--until done,free` keeps waiting on `done`. Without that it
would hang forever on a wait with no `--timeout`. The `no-agent` timeout
hint now fires only for a caller who did not already pass `--until
free`, and the reason freeness never resolved is printed and put in the
JSON in its place.

Deliberately left alone: on a local pane the process tree still holds
the verdict. A prompt mark can only turn a busy tree into free — which
is what fixes a plain `ssh` pane on Windows, where the daemon has no way
to name the pane as remote — never the other way round, so no pane that
reads busy today can start reading free because an integration went
quiet. `free` therefore now means "will take input" rather than strictly
"back to the bare shell": a pane sitting at a nested shell's prompt is
free, and the reference says so. The handoff record is unchanged, so a
pane mid-`ssh` that survives an exec comes back reporting "cannot
determine" until the far side's next prompt rather than guessing.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:54:46 +08:00

373 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Command reference"
description: "Every verb, its flags, and the JSON it emits under --json."
---
## Global flags
Accepted anywhere on the line, before or after the subcommand.
| Flag | Effect |
|---|---|
| `-m, --machine <MACHINE>` | Route to a linked machine over the local server's existing link. Matches the full link key (`me@devbox:22`) or the bare host (`devbox`). SSH links only; a down link, or a jump/proxy chain, is refused with a reason rather than dialled fresh. |
| `--json` | One JSON object on stdout instead of the human table. |
| `-q, --quiet` | No output on success. Errors still go to stderr. |
## Environment
Set inside every tty7 pane, inherited by anything launched from one.
| Variable | Meaning |
|---|---|
| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both accepted). Default target of `split`, `send`, `capture`, `procs`, `wait`, `pane close`. |
| `TTY7_WS` | This pane's workspace id. Default for `run --keep`, `tab new`, `tab ls`, `ws tree`. |
| `TTY7_CONFIG_DIR` | The server's config dir — how the CLI finds the right server. You never pass a socket path. |
Outside a tty7 shell, address-taking verbs fail with
`not inside a tty7 shell — pass an explicit %pane/@tab/workspace`.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Success |
| `1` | The command failed; one line on stderr, prefixed `tty7:` |
| `2` | Usage error — unknown verb, missing argument, bad type |
| `124` | `tty7 wait` timed out (the `timeout(1)` convention) |
| `141` | Unix only: the reader hung up — piping into `head -1`, say — and SIGPIPE ended it, exactly as it ends `cat`. Not a failure. Windows reports 0 for the same thing, having no signal to imitate. |
| *other* | Only from `tty7 run`, which passes the child's exit code through |
If `run` cannot learn the child's code it prints a note to stderr and exits 1
with `"exit_code_known": false` in the JSON — that is how you tell a real 1 from
a stand-in.
## Top-level verbs
### `tty7 [PATH]`
No subcommand means the GUI. A running window is asked to come forward and open
a tab at `PATH`; if none is registered, the app is launched instead.
JSON: `{"path","delivered","launched"}` — `delivered` says an existing window
took it, `launched` that a new process was started.
Without `PATH` it just activates the app. `-m` is refused: this verb drives the
GUI on *this* machine.
### `tty7 ls`
Same as `ws ls`. Table: `WORKSPACE NAME TABS PANES ATTACHED`.
JSON: `{"workspaces":[{"id","name","tabs","panes","attached"}]}`.
`ATTACHED` names the host holding the workspace — a GUI window, or another
client — and is `-` when nobody is.
### `tty7 run [--keep] [--cwd DIR] [--ws WORKSPACE] -- CMD...`
Spawns a pane running `CMD`, streams its output to stdout, waits, and exits with
its code. The command must come after `--`.
- `--keep` leaves the pane alive as a new tab afterwards. Needs a workspace, so
it requires `--ws` or `$TTY7_WS` — without one it is an error, not a silent
fallback.
- `--cwd` sets the working directory. `--ws` also sets the pane's `TTY7_WS`.
- Interrupting `run` can leave the pane behind as an orphan — see
`pane ls --all`.
JSON: `{"pane","exit","exit_code_known","kept"}`, printed **after** the streamed
output. The combined stream is not valid JSON — read the last line.
### `tty7 new [PATH] [--open]`
Creates a workspace plus its first tab and shell, at `PATH` if given. Prints the
workspace id. JSON: `{"id","pane","opened"}`.
`--open` also puts a window on it, if a GUI is running on this machine. Without
it the workspace still appears in the switcher; it just waits to be opened.
### `tty7 split [%PANE] (--v|--h) [--ratio R]`
Alias of `pane split`. Splits `%PANE` (default `$TTY7_PANE`), spawning a shell
in the same cwd. Exactly one axis is required — `--v`/`--vertical` puts the new
pane below, `--h`/`--horizontal` to the right. `--ratio` (default `0.5`) is the
share kept by the *existing* pane, clamped to `0.05``0.95` — a `--ratio 70`
silently becomes `0.95`, not an error. Prints `%NN`. JSON: `{"pane"}`.
### `tty7 send [%PANE] [TEXT] [--enter] [--key KEY]…`
Types `TEXT` into the pane as keystrokes; `--enter` is shorthand for `--key
enter` — it appends CR to the text, or presses Enter on its own when there is
none, so `tty7 send %42 --enter` runs whatever pane 42 already has typed. With
one argument the text is the argument and the pane comes from `$TTY7_PANE` —
but a lone `%42` (or bare `42`, the shape `pane ls --json` prints) is rejected
as a missing-text error rather than typed, unless a `--key` gives it something
to do. `--enter` is that key only for the `%`-marked spelling: `tty7 send 83
--enter` is refused, because it reads as much like typing `83` into your own
pane as like pressing Enter in pane 83, and the error names both ways to say
which (`send %83 --enter`, `send %PANE 83 --enter`). A `%` followed by a digit
that still doesn't parse (`%3x`) is an address error, never text for your own
pane — while text that merely starts with `%` (`%s/foo/bar/`, `%!sort`) types
as given, as does anything unmarked that is not a plain number (`3x`, `+5`). To
type an address-shaped string, name the pane as well: `tty7 send %42 %3x`.
`--key` presses a key instead of typing characters, which is what a pane wants
once something is already running in it: answering a prompt that only takes
arrow keys, closing a TUI with `escape`, stopping a build with `C-c`. Repeat it
for a sequence, and it composes with `TEXT` — the text goes first.
| | |
|---|---|
| Named | `enter` `escape` `tab` `backtab` `space` `backspace` `delete` `up` `down` `right` `left` `home` `end` `pageup` `pagedown` |
| Chords | `C-<char>` (Ctrl, e.g. `C-c`), `M-<char>` (Alt) |
| Aliases | `return` `cr` `esc` `del` `bs` `shift-tab` `pgup` `pgdn` `pgdown` |
Names are case-insensitive, and an unknown one is a usage error (exit `2`)
raised before anything is sent — half a key sequence in a live pane is worse
than none. One case exception: Alt is a prefixed ESC, so its character goes
out exactly as written and `M-X` is not `M-x` (Ctrl is unaffected — `C-c`
and `C-C` are the same byte). Each keystroke is delivered as its own event,
200 ms apart, so a
raw-mode TUI reads a sequence as a sequence rather than as a paste.
JSON: `{"pane","sent","enter","keys"}`.
### `tty7 capture [%PANE] [--plain] [--scrollback]`
The pane's replay. Two independent choices:
**How much** — the newest scrollback segment by default, the whole ring with
`--scrollback`. The ring splits into segments on resize, so for a pane that was
never resized the two are identical.
**In what form** — without `--plain`, the stored bytes with ANSI escapes intact,
decoded as UTF-8 (invalid bytes become U+FFFD). With `--plain`, those bytes
replayed through a terminal grid and printed as the text they produced.
Either way it is a snapshot, not a stream: it collects the replay, settles for
~300 ms, and returns. Call it again for a newer one.
JSON: `{"pane","text"}`.
### `tty7 procs [%PANE]`
The process tree inside the pane, indented by depth, `*` on the foreground
process — then a second table of ports those processes are listening on. Prints
`nothing running in this pane` when both are empty.
JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}],"context":{...}}` —
`addr` is the address the socket is bound to (`*`, `0.0.0.0`, `127.0.0.1`,
`[::1]`, or a specific interface).
`context` says how much of the pane the process list actually covers:
`{"remote","local_pty","at_prompt","remote_prompt_seen"}`. The tree is always
*this* machine's, so on a pane that is the near end of a connection it lists the
tunnel rather than the work — `local_pty` is `false` for a pane routed to a
remote daemon (nothing here to walk), and `remote` names the host otherwise.
`at_prompt` is the pane's own shell integration talking, absent until it emits
its first mark. Older servers omit `context` entirely.
### `tty7 agents`
Every pane running a recognised coding agent. Table:
`PANE AGENT STATUS MESSAGE`, status one of `idle` / `working` / `waiting` /
`done`. JSON: `{"agents":[...]}`, plus a `"diagnostics"` array when an agent's
status hook is missing or out of date — that is why an agent can be listed with
a status that never moves.
### `tty7 wait [%PANE] [--until STATE,…] [--changed] [--timeout SECS] [--interval MS]`
Blocks until the pane reaches one of the named states.
| Flag | Default | |
|---|---|---|
| `--until` | `waiting,done,exit` | See the state table below |
| `--changed` | off | Only wake on a state the pane moved into *after* the wait began |
| `--timeout` | none | Give up after N seconds, exiting `124` |
| `--interval` | `500` | Poll interval in ms (503,600,000) |
The states come from two places. Four are the agent's own status, as reported
by its [hooks](/agents/status); the last three are facts about the pane:
| State | Means |
|---|---|
| `idle` `working` `waiting` `done` | The agent's status |
| `no-agent` | Nothing is reporting status here — a plain shell, or an agent whose hooks are not installed |
| `free` | Nothing is running in front of the pane's shell — it will take input now |
| `exit` | The pane itself is gone. Ends every wait whether it was asked for or not |
| `unknown` | Only ever reported, never awaited: freeness could not be determined at all. See below |
`free` is how you wait for a **command** rather than an agent, and it is the
one state that costs a second request per poll — so it is only checked when you
name it, and only when none of the agent states you asked for already matched.
With `--changed` it means "something ran and then finished", which is what you
want directly after a `send`; a command quick enough to finish inside one
`--interval` is never seen running, and the timeout says so.
Two things answer it. On a pane whose shell is on this machine it is the process
tree: nothing deeper than the shell means free. On a **remote or SSH pane** that
tree describes the near end of the connection — the `ssh` itself, busy for as
long as you are logged in, or nothing at all for a pane routed to a remote
daemon — so there it is the far shell's own
[prompt marks](/reference/shell-integration) that decide. A prompt mark on such a
pane can only have come from the far side, because the near shell cannot be at a
prompt while the connection holds its pty. Marks outrank a deeper process on a
local pane too, which is why `free` means "will take input" rather than strictly
"back to the bare shell": a pane sitting at a nested shell's prompt is free.
When the far host has no shell integration loaded there is nothing on this side
that can tell an idle remote prompt from a running remote command. `wait` says
so — `status: unknown`, exit `1`, and a `free_unknown` string naming the host —
rather than polling to the end of `--timeout`. It gives one poll of grace first,
for a handshake still in flight, and it only gives up when `free` was the only
state that could still answer: `--until done,free` keeps waiting on `done`.
The reply carries the agent's message and native session id. The JSON's `stale`
flag says whether the answer might belong to the previous turn.
JSON: `{"pane","status","matched","stale","activity","message","session_id"}`.
A timeout exits `124` with the same object plus `"timed_out": true` —
`matched` is `false` there, and `stale` still says whether the pane moved
while you watched, and `free_unknown` carries the reason when `free` was asked
for and never resolved.
[Orchestration →](/agents/orchestration)
### `tty7 events`
Streams server events until interrupted, one per line — pane exits, agent status
changes, workspace preemption, layout deltas. `--json` makes it NDJSON. Blocks
forever; run it with a timeout or in the background.
### `tty7 status`
Same as `server status`: pid, uptime, pane count, dialect versions, build,
socket path. JSON is the `ServerStatus` object itself (`pid`, `uptime_secs`,
`panes`, `control_version`, `protocol_version`, `build`, `socket`).
### `tty7 doctor`
The install check: the three environment variables, whether the server answers,
whether its control and protocol versions match this binary, pid/uptime/panes,
how many machine links exist, and where each agent's
[status hooks](/agents/status) stand. Adds a note when you are not inside a
tty7 shell.
The hooks row is the one that explains a mystery: without them an agent reports
nothing, so `tty7 agents` shows it standing still and `tty7 wait` sits there
until it times out. Outdated hooks fail the same quiet way. Hooks are a local
install, so under `-m` the row reads `unknown`.
JSON: `{"context":{"config_dir","workspace","pane"},"server":{"reachable","dialect_ok","build","status","routes"},"hooks":{"installed","outdated","not_installed"}}`
— the context fields are booleans, not values, and each `hooks` field is a list
of agent slugs.
## `ws` — workspaces
Address a workspace by name, by full id, or by a unique id prefix (the 8-char
prefix `tty7 ls` prints). An ambiguous name or prefix is an error that lists the
candidates.
| Command | Effect | JSON |
|---|---|---|
| `ws ls` | Every workspace | `{"workspaces":[...]}` |
| `ws tree [WORKSPACE]` | One workspace as a tree: tabs, split axes and ratios, panes with cwds | The whole workspace object: `{"id","name","last_active","active_tab","tabs":[{"id","name","sidebar_group","root",…}]}` |
| `ws new [NAME]` | An empty workspace (no tab, no pane) | `{"id","name"}` |
| `ws rename WORKSPACE NAME` | Name or rename | `{"id","name"}` |
| `ws rm WORKSPACE` | Delete the workspace and hang up its panes | `{"removed"}` |
| `ws attach WORKSPACE` | Become its controlling client | `{"attached","took_over_from"}` |
| `ws detach WORKSPACE` | Let go without interrupting anything | `{"detached"}` |
<Warning>
`ws rm` hangs up the panes the workspace held. If the command reports that
some panes could not be hung up, they keep running as orphans with no
workspace — find them with `pane ls --all` and close them one by one.
</Warning>
Prefer `tty7 new <path>` over `ws new` when you want something usable: `ws new`
leaves an empty workspace you then have to populate, while `tty7 new --json`
hands back both ids at once.
The `root` node in `ws tree --json` is externally tagged, so a leaf is
`{"Leaf":{"pane":31}}` and a split is `{"Split":{"axis","ratio","a","b"}}` with
`a`/`b` nested the same way.
## `tab` — tabs
`@N` numbers tabs across the **whole machine** in tree order, densely from `@1`.
The numbering shifts whenever any workspace or tab is created or removed, so
resolve it immediately before use. A full tab UUID also works: `@<uuid>`.
| Command | Effect | JSON |
|---|---|---|
| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…]}]}` |
| `tab new [WORKSPACE] [--cwd DIR]` | Add a tab with a fresh shell | `{"tab","pane"}` |
| `tab close @TAB` | Close the tab and every pane in it | `{"closed"}` |
| `tab rename @TAB NAME` | Name or rename | `{"tab","name"}` |
| `tab move @TAB INDEX` | Reposition within its workspace | `{"tab","to"}` |
`GROUP` is the heading the GUI's sidebar files the tab under, shown by its last
segment. Read-only from here: with the default repo grouping the GUI recomputes
it from the tab's working directory.
`label` falls back through the best evidence available — the name if someone set
one, else the agent running there, else the last segment of the cwd, else the
foreground process. `name` stays literal, so a script can tell a real name from
a stand-in.
## `pane` — panes
| Command | Effect | JSON |
|---|---|---|
| `pane ls [WORKSPACE]` | Panes with their workspace, tab, cwd, live flag | `{"panes":[…]}` |
| `pane ls --all` | The server's whole pane registry, including orphans | `{"panes":[…],"orphans":N}` |
| `pane split …` | Identical to top-level `split` | `{"pane"}` |
| `pane close [%PANE…]` | Close panes; their shells are hung up | `{"closed":[…]}` |
| `pane close --orphans` | Close every pane no workspace holds | `{"closed":[…]}` |
`--all` is the one that shows leaks. Each entry is
`{"pane","workspace","orphan","owner","title","cwd","live"}`: `owner` is the id
of the workspace that may attach to the pane (absent when none may), and
`orphan: true` means no workspace holds it. An interrupted `run` leaves orphans
here, as does a `ws rm` that reported panes it could not hang up.
`--orphans` is the reaper for exactly those. It closes what `pane ls --all`
lists as orphaned and nothing else — panes a workspace holds are untouched —
and reports an empty list rather than an error when there is nothing to clean
up, so a script does not have to guard it. A pane that cannot be closed does
not abandon the rest of the batch: the rest are still attempted, the complaint
goes to stderr, and the verb exits 1 with `{"closed":[…],"failed":[…]}` — the
list a retry needs.
<Warning>
`--orphans` closes every orphan on the machine, and an orphan can still be
doing real work — an interrupted `run` leaves the command running. Look at
`pane ls --all` first.
</Warning>
`title` is usually the running command — `claude`, `nvim`, `cargo` — which makes
`pane ls --all --json` a quick way to find "the pane running X".
## `machine` — remotes
`machine ls` lists the local machine plus every link the server holds:
`MACHINE KIND CONNECTED`. JSON: `{"machines":[{"key","kind","connected"}]}`.
## `server` — the daemon
| Command | Effect |
|---|---|
| `server status` | Same as `tty7 status` |
| `server logs` | Tail the server log; prints the path, and says so when logging was never enabled (`TTY7_LOG=info` before the server starts) |
| `server start` | Bring up a server on this machine |
| `server stop` | Stop it — **every pane on the machine dies** |
| `server restart` | Stop, then start — same consequence |
<Warning>
Do not run `start`, `stop`, or `restart` on someone else's behalf. They change
or destroy what the user's GUI is attached to.
</Warning>
## Not implemented yet
These parse and then exit 1 with an explanation:
- `ws stop` — the control dialect has no workspace-stop request yet
- `machine connect` / `machine disconnect` — use the GUI's connection manager