Files
tty7/docs/cli/reference.mdx
T
l0ng-ai c14f05829f fix(cli): flag a row as an orphan by the same test the reaper uses
Half a change, found by reading the docs rather than the code. `pane ls --all`
learned to count strays by whether anybody is attached; the per-row `orphan`
flag beside that count did not. The same JSON object contradicted itself, and
the reference says `--orphans` "closes what `pane ls --all` lists as orphaned"
— which had stopped being true for exactly the rows that matter, the panes a
window is adopting during a restore.

A script filtering `.panes[] | select(.orphan)` would have gone after panes
the reaper deliberately spares.

The row, the count, the doctor's row and the reaper now make one test, held by
a test that walks all four. The reference states both halves of what an orphan
is, since one of them is the half that keeps the command from taking a session
that is still coming up.
2026-08-23 13:15:26 +08:00

487 lines
25 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: "The verbs an agent drives, their flags, and the JSON they emit under --json."
---
The verbs below are the ones a script or an agent reaches for. tty7 also groups
its management verbs under `ws`, `tab`, `pane`, `machine` and `server`; two of
the verbs documented here are shortcuts into them — `ls` is `ws ls` and `split`
is `pane split`. (`new` is not: it creates a workspace *and* its first tab,
where `ws new` leaves it empty.) Those groups are not repeated on this page.
`tty7 ws --help` and its siblings list them, and being generated from the same
definitions the CLI runs on, they cannot drift from it.
## 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. A verb that *fails* prints its message on stderr and no JSON at all, so a reader parsing stdout must check the exit code first. Three verbs are exceptions, because there the bad news is the answer: `pane close` still prints `{"closed":[…],"failed":[…]}`, `wait` still prints the state it gave up in, and `doctor` still prints its whole report — an unreachable server is the finding, and `tty7 doctor || alert` needs both the exit code and the rows. (`run` is not an exception — a child exiting nonzero is the verb succeeding, and its JSON is printed as usual.) |
| `-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 client that has *claimed* the workspace over a control
link, and a claim lasts exactly as long as that client's link. A GUI claims a
workspace it is showing as a **remote** one; a GUI window on a workspace of its
own machine does not claim anything, because there is no second client to
arbitrate against. So `-` for a local workspace means "no remote client holds
this", not "nothing has it open" — with a window sitting on it, `tty7 ls` still
prints `-`.
### `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.
`exit_code_known` is false when the pane ended without leaving a status behind
for the server to read in the short window it waits. `run` then exits 1 as a
stand-in and says so on stderr, and this flag is how a caller tells that 1 from
a command that really did exit 1.
A command **killed by a signal** reports `exit: 1` — not the `128+N` a shell
would give — with `exit_code_known: false` and the same stderr note as any
other unknown code, because a status that says "terminated by a signal" is not
an exit code the daemon will invent one from. That flag is exactly how a
caller tells a command the OOM killer took from one that exited 1 on its own,
so an orchestrator does not have to read the pane to find out.
### `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`.
A newline inside `TEXT` is Enter, because typing one is. `tty7 send %1
$'deploy\nyes'` runs `deploy` and leaves `yes` typed at the prompt it asked
for — so text that came from somewhere else runs a line at a time, and `--enter`
is the way to submit on purpose.
`--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.
A pane restored after its daemon died counts as resized. The screen it was
seeded with was recorded at whatever size it had then, so unless the new pane
comes back that same shape, plain `capture` answers with the restore banner and
the new prompt alone — everything above the banner needs `--scrollback`.
**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.
Both forms cover the same span: the grid `--plain` replays through is sized to
hold whatever the segment can fill, so it never answers with less of the pane
than the raw form does. Rendering a full ring costs proportionate memory for
the moment it takes — a few hundred MB for the 8 MiB maximum, and nothing worth
measuring for an ordinary capture.
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.
A pane the server is not running is an error, not an empty answer, so "nothing
is running in it" and "there is no such pane" stay apart. A pane no workspace
holds is still one the server runs, and still reports — that is what
`pane ls --all` surfaces it for.
JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}]}` —
`addr` is the address the socket is bound to (`*`, `0.0.0.0`, `127.0.0.1`,
`[::1]`, or a specific interface).
### `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` | The foreground command has exited; the pane is back to its bare shell |
| `exit` | The pane itself is gone. Ends every wait whether it was asked for or not |
`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.
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.
`wait` is the one address-taking verb that does **not** refuse a pane the
server has no record of — it answers `exit`, `matched: true`, `stale: true` and
exits `0`, where `capture` and `send` would exit `1`. It cannot do otherwise:
the server forgets a pane once it is reaped, so a pane that finished and was
cleaned up and a pane id that never existed are the same question to it, and
refusing would break the first — which is the ordinary end of an
orchestration. `stale: false` is the only thing that says a pane moved into its
state while you were watching; a stale `exit` means it was already gone,
whatever the reason. Check the id itself with `pane ls --all` if you need to
tell a typo from a finished worker.
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.
[Orchestration →](/agents/orchestration)
### `tty7 events`
Streams server events until interrupted, one per line — layout changes and
workspace preemption. `--json` makes it NDJSON. Blocks forever; run it with a
timeout or in the background.
Without `--json` each line is a sentence meant to be read, and its wording is
not a format to parse — `--json` below is. The kinds and payloads are stable;
the prose is not.
Because it blocks forever, returning at all means the server closed the stream:
that exits 1 and says so on stderr, so a loop consuming the lines does not read
the end of its input as the end of the work.
Each line is one externally tagged object, `{"<kind>": {…}}`:
| Kind | Payload |
|---|---|
| `layout` | `{"workspace","delta"}` — the bulk of the stream |
| `preempted` | `{"workspace","by"}` — another client took the workspace |
| `layout_resync` | no payload; the tree moved in a way no delta describes, so re-read it |
`delta` is externally tagged the same way. The kinds are `workspace_created`,
`workspace_renamed`, `workspace_deleted`, `workspace_touched`,
`active_tab_changed`, `tab_created`, `tab_closed`, `tab_renamed`, `tab_moved`,
`tab_regrouped`, `tab_restructured`, `ratio_changed` and `pane_facts`.
<Note>
**Two things that sound like their own events are not.** A pane exiting and an
agent changing status both arrive as a `layout` delta — `pane_facts` carrying
that pane, with `"live": false` for the exit and `agent.status` for the agent.
There is no pane-exit line and no agent-status line, whatever the shape of the
wire protocol suggests, so watch `pane_facts` for both.
For agent status specifically, `tty7 wait` and `tty7 agents` are the
supported way to ask; neither is built on this stream.
</Note>
### `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.
A `$TTY7_WS` or `$TTY7_PANE` naming something this server does not have is
marked `GONE` rather than reported as merely set — a shell outlives the
workspace it was opened in, and that is why the address-taking verbs suddenly
fail on an id you have no reason to doubt.
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","orphans","status","routes"},"config":{"ok","state","dir_writable","shell_problem"},"hooks":{"installed","outdated","not_installed"}}`
— the context fields are booleans, not values, and each `hooks` field is a list
of agent slugs. `context` also carries `workspace_gone` and `pane_gone` when a
server answered and could be asked; both are absent when none did, so that "it
names nothing here" stays distinct from "nobody could check". `config.ok` is
false only when the file failed to parse and tty7 is running on defaults;
`config.state` is the same sentence the table prints. `config.dir_writable` is
probed by writing, not read off the mode bits, because a read-only mount or an
ACL leaves a `0700` directory that refuses every write — false means settings
cannot be saved and `tty7 new` cannot file a workspace. `server.orphans` counts the panes the
server is running that no workspace holds — an interrupted `run` leaves one,
and each is a live shell holding a pty. It is a row and a number, not an exit
code: one stray is ordinary, and `tty7 pane close --orphans` ends them.
`config.shell_problem` is null unless the
configured `shell` names something that cannot be launched — missing, a
directory, not executable — in which case every new tab and every `tty7 new`
fails; a shell given as a bare name is left to `PATH` and never reported here,
and under `-m` the field is null because the shell resolves on the far machine.
Any of those three exits `1`, so `tty7 doctor || alert` fires. The extra rows the table can show —
unread keys, unusable `custom_shells` — are prose, not JSON fields.
## `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` | Take it back from whoever holds it | `{"attached","took_over_from"}` |
| `ws detach WORKSPACE` | Let go without interrupting anything | `{"detached"}` |
`ws attach` is a takeover, not a subscription: the claim belongs to the
connection that made it, and the CLI's connection ends when the command does.
So it prints `took over from HOST` when it displaced someone and nothing when
it did not, and either way the workspace reads as unattached again immediately
afterwards. Displacing the holder is the whole of what it does from here —
staying attached is a thing only a client that keeps its link open can do.
For the same reason `ws detach` reports `{"detached"}` whether or not there was
a claim to release: it says "you do not hold this", which was already true.
<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"}` |
`INDEX` counts from **0**, unlike the `@N` address beside it — `tab move @1 0`
leaves the first tab where it is, and `tab move @1 1` puts it second. It is a
position in the workspace's own tab list, not an `@N`, so it does not move a tab
between workspaces. Past the end it clamps to last rather than failing, the way
`split --ratio` does; `to` in the JSON echoes the number you asked for, so read
the position back from `tab ls` rather than from the reply.
`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 and nobody is watching | `{"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 *and* no client is attached to it.
Both halves matter: a window restoring a layout spawns each pane, attaches to
it, and only then files it into the tree, so for that moment the tree holds
nothing for a pane that is very much wanted. 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
so are panes a window is attached to, which is what keeps it from taking a
session that is still being restored —
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. Not only an interrupted `run`: a `run` that is **in progress
right now** is an orphan too. `run --ws W` stamps its pane with `W` as the
owner and files it into no tab until `--keep` does, so while the command
executes the pane reads exactly like a leftover — same `orphan: true`, same
`owner`. Nothing in the listing tells the two apart, so `pane ls --all` cannot
answer this one for you.
In practice that means `--orphans` from one shell, or one agent, will kill a
`tty7 run` started from another. Close orphans by id when anything might be
running, and keep `--orphans` for a machine you know is idle.
</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` | Hand the panes to a new server of this build; **sessions keep running** — the shells, and what they are running, carry across. A pane using tty7's own SSH client is the exception: that connection drops and has to be reopened |
| `server restart --hard` | Stop, then start — **every pane on the machine dies**, exactly as `stop` |
<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