Control API

Command reference

Every command of the agterm control API, with arguments and return values. Every one is reachable from the agtermctl CLI and from the local control socket.

For the narrative introduction — what the socket is, how to install the CLI, and worked recipes — see the agtermctl section of the docs.

Overview

agterm listens on a local unix-domain socket. Each connection carries one newline-delimited JSON request and receives one response, then closes. The scope is personal scripting: fire-and-forget commands, plus a polled event feed (events.read) for watching status and lifecycle changes. There is no scrollback or terminal-output streaming.

Socket resolution. With --socket omitted, agtermctl resolves the same rendezvous the app bound: <AGTERM_STATE_DIR>/agterm.sock, else ~/Library/Application Support/agterm/agterm.sock. A spawned shell also sees the bound path in $AGTERM_SOCKET; passing --socket "$AGTERM_SOCKET" is the safe explicit form.

Response shape. {"ok": true, "result": {…}} or {"ok": false, "error": "<message>"}. The process exit code is non-zero when ok is false. result carries one of: id (affected/new session, workspace, or window), text (session copy/text), exitCode (overlay result), count (search matches, keymap/config diagnostics), ratio (session resize), affected (things changed: sessions for a batch close or move, daemons killed for zmx prune), restore (the restore-mode policy), zmx (the daemon inventory), remote (another Mac's attachable sessions, for zmx tree), tree, windows, or app (the serving app's identity, for version).

Options go after the subcommand. agtermctl session type "ls" --target active, never before it. Add --json to print the raw response object — without it, mutations print ok and tree / window list print a human listing. Use --json when you need to read ids or values back.

Addressing

--target defaults to active (the selected session, or the current workspace). It accepts a full UUID (case-insensitive) or a unique, git-style prefix. Zero matches gives notFound; an ambiguous prefix gives ambiguous, listing the candidates. For a workspace, active is where a new session lands: one you just created in the foreground, or an empty one you named with workspace select, until the selection changes, workspace select names a workspace that has sessions, that workspace is deleted, or the workspace filter hides it; otherwise the selected session's workspace, otherwise the last one. A background create (workspace new --collapsed, session new --create-workspace --no-select) never takes it, and the tree's workspace active flag reads the selected session's workspace only, so right after a foreground create the two can name different workspaces.

--window <id|prefix|active> (on session, workspace, tree, font, notify, pick, and ask commands) selects the window; the default is the frontmost. Current-state operations require an open window; pick and ask result/cancel also accept a closed owning window while its result is retained. Without a window selector, an id/prefix session target is matched across all open windows. The window.* commands instead take the window selector as a positional argument, defaulting to active (frontmost). A window need not be open to be a window.* target — window select opens a closed one.

For an agent, active is the user's GUI-selected session, not yours. Your own shell is $AGTERM_SESSION_ID. Pass --target "$AGTERM_SESSION_ID" on any session-scoped command that must act on the session you run in — otherwise it hits whatever the user has selected.

tree

agtermctl tree [--json] [--window W]
tree

Print the workspace/session tree. This is the read side of most of the API — nearly every state-mutating command has a matching field here, so a script can record a value, change it, and restore it.

Session node: id, name, cwd, splitCwd (the split pane's last reported directory, falling back to its restored directory, then the primary cwd; present for shown or hidden splits, omitted without a split or on older servers), title (raw OSC terminal title, omitted when none), active, split (both split panes are SHOWN), realized (the session's main pane has a live terminal; false means no shell was spawned and session type/session text answer session not realized. A surface cannot be created while the display is asleep, so a session made by a scheduled job overnight stays unrealized until the displays wake, then recovers on its own — poll this after an unattended create), backedByZmx (true only when every existing primary/split pane is currently zmx-backed; each primary/split object in surfaces reports its own value, its paneID (the stable token --pane-id takes, which follows the terminal through a swap and is omitted for an overlay or a pane whose surface is not created yet), and its lead: leader, follower or unowned, which says whether this Mac's window size is the one the pane's program sees. A pane that does not lead is covered. A remote pane waiting to be attached again after its ssh dropped also carries reconnect, with failures (the retry streak) and, when ssh said anything on the last failed try, its reason. lead is absent until the pane's terminal reports one), liveAttribution and splitLiveAttribution (primary and split pane attribution: supervisor identifies the bundled persistent host, app the running agterm, orphaned a self-responsible pane or a confirmed dead responsible process, and unknown an unavailable reading or an unrelated live responsible process; omitted for non-Live and remote panes; the split field includes hidden splits and is omitted without a split; these describe attribution, not permission grants), hasSplit (a second pane exists at all, shown or hidden, omitted when there is none — read this rather than split to tell whether a session has a split), splitAxis (vertical for left/right or horizontal for top/bottom), splitRatio, splitFocused, overlay, overlaySizePercent, paneOverlays (the panes covered by their own pane-scoped overlay — ["left"], ["right"] or ["left","right"], omitted when neither is), htmlOverlays (the pages in the overlay slots as {pane?, file?, cwd?, url?, state, error?, page?, title?, canGoBack?, canGoForward?, navigation?, javascript, chromeless, persistent, browse, zoom?, id}, one of file and url set, state being loading, loaded or failed; omitted when none is open), hud (the message panel holding the session-wide slot — {message, detail?, spinner, backgroundColor?, textColor?, sizePercent?, heightPercent?, position, pane?, hideAfter, markdown, fontSize?, sticky, frame}, where spinner names the effective style or none — omitted when none is up; mutually exclusive with overlay, which reads false beside it), ask (the session's pending question as {id, pane?, remote?, replica?}: a terminal question, or one of either style handed over for a remote session; omitted when none is pending; pane is omitted for session-wide placement, remote is true on an origin while the Mac presenting the session draws it, and replica is true on that Mac for its copy), scratch, flagged, context (the local context, or on an attached row without one the origin's mirrored context; omitted when neither is set), status, statusPane, statusBlink, statusColor, statusShape, statusChangedAt (when the status was last set, in epoch seconds on the same clock as an event's ts; omitted before any set, refreshed by every set including idle and repeated values, and never persisted), foreground / splitForeground (each pane's live foreground argv, omitted at the shell prompt or for a setuid program like top/sudo), foregroundShell / splitForegroundShell (the shell holding each pane's foreground, as a basename like zsh or fish; present exactly when that pane's foreground is omitted because a shell holds it. For a pane that exists, both omitted together means agterm could not read the process at all — check hasSplit before reading the split pair, since a session with no split pane omits both simply because there is no pane. This is not a claim that the pane sits at a prompt, and it is never permission to type into one: a shell builtin such as read runs inside the shell itself, so a pane blocked waiting for input looks exactly like one at a prompt. A shell agterm does not recognize — outside the known set and your $SHELL — reports in foreground like any other program), background, restoreCommand / splitRestoreCommand (each pane's pinned restore override, omitted when none and an empty string when pinned to nothing), commandWait / splitCommandWait (whether either pane's --command was created with session new --wait; each omitted otherwise), remoteHost (the machine an attached session came from, the read side of zmx attach; omitted for a local session, and never present after a relaunch because a remote session is not persisted), presentation (on an attached session only: state is connecting, connected, unsupported or failed with the reason in error, and mode is presenter when this row's stream holds the presenter role or mirror when it does not; it reports the stream, not the panes' ssh connections), presenters (on an origin session: mirrors, how many streams mirror it without presenting, one per attached row and not per Mac, and presenter, true when one presents it; omitted when none does), remoteOverlays (on an origin session: the overlay slots a presenting Mac holds, each {pane?, sizePercent?}, while the session stays uncovered here; omitted when none is held), and unseen.

Workspace node: id, name, active, sessions, focused (membership in the sidebar focus set — the read side of workspace focus, distinct from active; reported independently of whether the filter is applied, so a workspace row renders iff sidebarVisible && ((sidebarMode == "tree" && (!workspaceFilter || focused)) || (sidebarMode == "flagged" && sidebarFlaggedLayout == "tree" && one of its sessions is flagged)) — no workspace row at all with the sidebar hidden or under the flat flagged list; in the ordinary tree, the whole tree while the filter is off and only the members while it is on; in the flagged tree, the workspaces holding a flagged session, whatever the filter says), and collapsed (the read side of workspace collapse/expand; true when collapsed, omitted when expanded).

Top level: idleMs (ms since last user input; live, so tree-only), autoFollowMs, sidebarVisible, sidebarMode, linkOpenMode (browser or overlay, the read side of browser links; app-wide), sidebarFlaggedLayout (flat, plain or tree, the read side of sidebar flagged-layout; app-wide, so every window reports the same value, under the ordinary tree too), sidebarWidth (the sidebar divider position in points, the read side of sidebar width, reported here and not on window list), workspaceFilter (whether the workspace focus filter is applied — the flag half of the focus set, the read side of workspace filter), quickVisible, and pickPending (the pending native picker's id, omitted when none is open), askPending (the pending GUI question's id, omitted after resolution), zoomedSurface, the four dashboard* fields, and app (which agterm is serving this socket — version, plus commit when the build recorded one; the same value agtermctl version returns), and indexUnsaved (true while the last write of the window index failed, omitted otherwise). All are read-only, and all but app and indexUnsaved are projections of live GUI state.

events

agtermctl events [--json] [--kind KIND ...] [--run UUID --after SEQ] [--limit N]
events.read

Read the app's control-event ring. The CLI polls it in a loop and prints one event per line, so a script can watch agent status, notifications, and session lifecycle instead of re-reading tree. With no cursor the first read subscribes from the current tail: it returns an empty batch anchored at run/next and replays no history. The ring keeps the latest 4,096 events of one app process and is non-destructive, so independent readers never consume one another's events.

Kinds. status, notify, session.created, session.closed, session.selected (a window's selection moved to this session, with the one that lost it as previous; no session when the selection was cleared, and nothing for a window merely coming forward), tree.changed (a 100 ms coalesced signal that a window's names, membership, or ordering moved, or a session's context changed — read tree --json for the new snapshot), and pane.split / pane.scratch (a status of shown or hidden, emitted only on a real change), and remote.opened / remote.closed (a local row created by zmx attach entered or left the tree, carrying its ssh destination as host; they ride the created/closed edges, so undo re-emits remote.opened, and they say nothing about whether ssh is still connected). --kind may be repeated or comma-separated; an unknown kind errors. Every event carries seq, ts, kind, the applicable window/workspace/session ids, and a kind-specific payload — a status payload carries the session name, the normalized status, previous (the status before the write), blink, and the optional pane, color, and shape fields.

Cursors. The raw response returns the batch under result.events as {run, next, items}. Resume with both values — --run RUN --after NEXT, which must appear together. --limit defaults to 100 and accepts 1 through 1,000, and a filtered read still advances the global cursor past nonmatching events. The streaming --json output is bare event objects, not the batch envelope, so a restart-safe client keeps the cursor from raw events.read responses.

A changed app run, an expired cursor, or a cursor ahead of the current sequence is a hard error carrying the ring's current anchor — treat it as a data-loss boundary rather than silently rebaselining. agtermctl events exits non-zero on those, on a server error, and on a missing app or socket. Once the stream holds a cursor, a refused connection, which a busy app's full accept queue also returns, is retried with that cursor for about 30 seconds before the stream exits. Without a cursor, a refused connection exits at once. There is no terminal-output or scrollback stream.

workspace

agtermctl workspace new [name] [--collapsed] [--window W]
workspace.new

Create a workspace. The name defaults to an auto-generated one. --collapsed creates it closed in the sidebar tree, so a script can fill it with session new --no-select without it opening — and for the same reason a collapsed create stays OUT of the workspace focus set. A plain create instead joins the marked set while the filter is applied, so a foreground workspace is never hidden behind it. Returns result.id.

agtermctl workspace rename <name> [--target T] [--window W]
workspace.rename

Rename the target workspace.

agtermctl workspace delete [--target T] [--window W]
workspace.delete

Delete the target workspace. Keep-at-least-one: deleting the last workspace is an error. Unlike the GUI, nothing blocks on a confirm dialog.

agtermctl workspace select [--target T] [--window W]
workspace.select

Select the target workspace.

agtermctl workspace go --to next|prev [--window W]
workspace.go

Step the current workspace one place through the sidebar's visible order, wrapping at both ends, and select the first session of the workspace it lands on. Relative, so it takes no --target — workspace move is the verb that reorders instead. Returns the workspace id.

Whether a workspace is collapsed makes no difference — a folded workspace is stepped into like any other. While the workspace focus filter is applied, stepping stays inside the marked workspaces, the same scoping session go gets. With nowhere to step — flagged mode in any layout, or a single visible workspace — it errors with no other workspace to navigate to.

agtermctl workspace move --to up|down|top|bottom [--target T] [--window W]
workspace.move

Reorder the workspace among its siblings. A missing or invalid --to is an error.

--target active resolves to the current workspace — one you just created in the foreground, otherwise the selected session's, otherwise the last one — so address a specific workspace by id to step the same one repeatedly.

agtermctl workspace focus [on|off|toggle|add] [--target T] [--window W]
workspace.focus

Mark or unmark one workspace in the sidebar's focus set. The sidebar renders the marked workspaces while the filter is applied, all of them while it is not. on sets the marked set to just this workspace and applies the filter, off removes it (the filter switches off once the set empties), toggle (the default) replace-toggles, and add inserts it alongside the others while leaving the filter flag exactly as it was. Per-window and persisted; orthogonal to sidebar mode. An unknown mode errors. Returns result.id.

add never switches the filter on — that is what makes a multi-workspace set buildable, since a mark that narrowed the tree would hide the rows still to be marked. Mark several, then apply once with workspace filter on. While the filter is applied, session go navigation is scoped to the marked workspaces' sessions, and an explicit session select of a session outside the set suspends the filter while keeping the set — in TREE mode only, since the flagged list ignores the marked set, so a selection made there leaves the filter applied. Read membership back via the workspace node's focused field.

A narrowing mode CAN move the selection: when on, a narrowing toggle, or an off that drops the selected session's workspace while other members keep the filter applied would hide the selected session, the most recently used session still visible is selected instead. Read it back as active on the session node. add normally narrows nothing and leaves the selection alone — except while the marked set holds only session-less workspaces, where nothing was visible to move to: adding a populated workspace then selects its most recent session.

agtermctl workspace filter [on|off|toggle] [--window W]
workspace.filter

Apply or suspend the whole window's workspace focus filter without touching the marked set, so peeking at the full tree and coming back costs one call each way. Window-scoped: it takes no --target, and --window picks the window (default frontmost). toggle is the default; idempotent. An unknown mode errors, and no open window when none is open. Per-window and persisted, like the marked set itself, so a relaunch restores whether the filter was applied.

on with an empty marked set is refused — it returns ok having changed nothing, so an applied filter always has at least one visible member, which is what keeps the filter term of the row-visibility read-back exact (the full predicate, including the sidebar-mode term, is on the focused field in the tree section). Read back via the tree's top-level workspaceFilter field.

Applying the filter CAN move the selection: if it would hide the selected session, the most recently used session still visible is selected instead — read back as active on the session node. The same holds for sidebar mode flagged. A script that narrows and then relies on the default active target should re-read tree first.

agtermctl workspace collapse [--target T] [--window W]
workspace.collapse

Collapse a single workspace's subtree in the sidebar tree, hiding its sessions. The per-workspace counterpart of sidebar collapse (which collapses every workspace but the active one) — this targets exactly the addressed workspace. Idempotent and persisted. Returns result.id.

Read the open/closed state back via the workspace node's collapsed field (true when collapsed, omitted when expanded).

agtermctl workspace expand [--target T] [--window W]
workspace.expand

Expand a single workspace's subtree, showing its sessions — the inverse of workspace collapse and the per-workspace counterpart of sidebar expand. Idempotent and persisted. Returns result.id.

To toggle a workspace, read its collapsed field off tree first, then call expand or collapse.

session

agtermctl session new [--cwd DIR] [--workspace W | --workspace-name NAME [--create-workspace]] [--command CMD] [--wait] [--name NAME] [--after SID | --before SID] [--no-select] [--window W]
session.new

Create a session and focus it. --cwd sets the start directory (default $HOME). The destination workspace is addressed either by --workspace (id / prefix / active) or by --workspace-name (the sidebar label) — the latter errors when no workspace has that name, unless --create-workspace is also passed, which reuses or creates it idempotently. --name seeds the sidebar label. Returns result.id.

--after SID / --before SID place the new session directly after or before an anchor session instead of appending. The anchor carries its own workspace, so it names the destination itself — these are mutually exclusive with each other and with --workspace / --workspace-name. The headline case: session new --after active.

--command runs a program as the session's process instead of the login shell (no echoed command line; the session closes when it exits). It runs argv-style — tokenized with quotes respected, but no shell, so ;, &&, $VAR, redirects and globs are not interpreted. It also inherits the app's GUI PATH (the launchd default — no /opt/homebrew/bin), so a bare Homebrew binary fails with exit 127. Use an absolute path, or wrap it: --command "zsh -lc 'htop'". The command is persisted and runs again when a restored session launches in rerun mode. All of that describes Fresh shells and Re-run commands. In Live sessions mode the command is instead a create-only zmx payload the persistent shell runs as a login shell, so shell operators are interpreted, the PATH is that shell's rather than the launchd default, and the session stays open after the command exits.

--no-select creates the session in the background: it is added to the sidebar but not selected or focused, leaving the current selection untouched (the new node is not active in tree — that flag is the read-back). Omit it for the default select-and-focus behavior.

--wait (only with --command, else an error) holds the session open after the command exits — the press-any-key prompt with the final output intact instead of closing — so a build, test, or deploy's last output (or an early failure) stays readable. It persists across restart, so a restored command session that re-runs its command holds again. Read it back on tree's commandWait.

agtermctl session duplicate [--target T] [--window W]
session.duplicate

Create a fresh session in the same workspace as the target, directly after it, rooted at the target's focused-pane working directory — then select and focus it. There are no other options: the target names both the destination workspace and the directory. Equivalent to session new --cwd <source cwd> --after <source> in one round-trip. Returns result.id.

Only the directory carries over. The duplicate is a plain login shell with the auto basename — it does not inherit the source's custom name, --command, split, scratch, status, flag, font size, or background. It is the control half of the sidebar row's Duplicate Session context-menu item (single-selection only). Read back from tree: no new field — the new session node appears directly after its source, carrying the source's focused-pane cwd (equal to the source node's tree.cwd unless the source is a split focused off its primary pane, where tree.cwd reports the primary, or a remote session, whose cwd goes through the local rule first — an existing local directory is kept, anything else becomes home — so the duplicate can read as home).

agtermctl session close [--target T ...] [--window W]
session.close

Close the target session. Repeat --target to close a batch as one grouped undo when close grace is enabled, or immediately when it is disabled. Batch output reports the number of sessions actually closed.

agtermctl session select [--target T] [--window W]
session.select

Select the target session. Selecting one outside the marked workspaces suspends the focus filter to reveal it, keeping the marked set — re-applying it costs one workspace filter on.

agtermctl session rename <name> [--target T] [--window W]
session.rename

Set the session's custom sidebar label.

agtermctl session reveal [--target T] [--window W]
session.reveal

Select the target session's focused-pane working directory in Finder. Errors if the directory no longer exists.

agtermctl session move <workspace> | --to up|down|top|bottom | --after SID | --before SID [--target T ...] [--window W]
session.move

Three mutually-exclusive placement intents, exactly one required. A positional <workspace> relocates the session there (appending). --to reorders it within its own workspace. --after / --before place it directly after or before an anchor session — the anchor carries its own workspace, so cross-workspace placement falls out for free. Repeat --target with a workspace or --after / --before to move an ordered batch atomically; batch relative reorder via --to is not supported. Batch output reports the number of sessions actually moved.

agtermctl session go --to next|prev|first|last|next-attention|prev-attention [--window W]
session.go

Move the selection relative to the current one — there is no --target. It operates over the visible, filtered set: the flagged sessions in flagged mode, the marked workspaces' sessions while the focus filter is applied, else all. next/prev wrap at the ends; next-attention / prev-attention step only through sessions needing attention (status blocked or completed), also wrapping. Returns the newly selected result.id.

The attention variants only step the selection; unlike the GUI attention-nav they do not themselves move focus into a tagged pane.

agtermctl session type <text> [--stdin] [--select] [--pane left|right|scratch] [--pane-id TOKEN] [--target T] [--window W]
session.type

Inject text as real keystrokes — printable runs plus a Return for each newline, with no bracketed-paste markers. So a trailing newline submits the command. That final Return is sent a moment after the text, so a long line also submits in an agent TUI that would read it as part of a paste; Returns inside a multi-line payload are not spaced, and a single very long line can still be read as a paste by the receiving program, which agterm does not pace against. --stdin reads the text from stdin instead of the argument. Any session is typable without --select, including one created moments ago: the main pane waits briefly for its surface, so creating and typing back to back does not race. --select selects the session first, and only when its surface is not ready yet. Text carrying a NUL is rejected with text must not contain a NUL byte. Typing counts as the input a waiting agent asked for, so it clears that pane's blocked or completed glyph exactly as a keystroke does — another pane's glyph and an active one are left alone, and empty text clears nothing.

--pane-id takes a stable pane token ($AGTERM_PANE_ID, or surfaces[].paneID from tree --json) and types into that terminal wherever a swap or promotion moved it, overriding --pane. An unknown token falls back to an explicit --pane and otherwise fails with unknown pane id: <id>. result.pane names the pane typed into.

--pane left is the main pane (the default), right the split pane, and scratch the scratch terminal even while hidden. Note the shell quoting: a literal \n inside plain single quotes reaches the CLI as two characters — use $'make test\n' or pipe a real newline via --stdin.

Every --pane types into the surface UNDERNEATH a covering overlay, exactly as session text reads it — by design, so a pane stays drivable whatever is drawn over it. The call answers ok and the keystrokes reach the hidden shell, where they run unseen until the overlay closes. There is no write counterpart to session overlay text: an overlay runs the caller's own program, so nothing types into one.

agtermctl session copy [--target T] [--window W]
session.copy

Returns result.text with the session's current selection. It does not touch the system clipboard — pipe the returned text into another session type. Selection is surface state independent of focus, so any realized session can be read. No or empty selection gives a no selection error, and a never-shown session gives session not realized.

agtermctl session paste [--pane left|right|scratch] [--target T] [--window W]
session.paste

Paste the system clipboard into a pane of the session — the socket analogue of ⌘V / Edit ▸ Paste. It runs libghostty's paste_from_clipboard as a bracketed paste with no prompt, so the text lands at the prompt without auto-submitting.

--pane picks the pane: right is the split pane, scratch the scratch terminal even while hidden; omitted is the main pane. The role and position aliases (primary/top, split/bottom) are accepted too. Read it back with session text --pane naming the same pane. A never-shown session gives session not realized.

agtermctl session select-all [--target T] [--window W]
session.selectall

Select the session's entire terminal buffer (main pane) — the socket analogue of ⌘A / Edit ▸ Select All, running libghostty's select_all.

Read the resulting selection back with session copy. A never-shown session gives session not realized.

agtermctl session text [--all] [--lines N] [--pane left|right|scratch] [--pane-id TOKEN] [--target T] [--window W]
session.text

Returns result.text with the terminal buffer as plain text (no ANSI or color). By default it reads the visible screen of the on-screen pane. --all adds scrollback; --lines N keeps only the last N content lines. The two are mutually exclusive and N must be greater than 0 (enforced server-side too).

--pane-id accepts the shell's stable $AGTERM_PANE_ID. It resolves the surface's current slot and overrides --pane when found, so a long-running watcher keeps reading the same terminal after a pane swap or promotion. An unknown token falls back to an explicit --pane and otherwise fails with unknown pane id: <id>, never reading another pane. result.pane names the pane read.

A genuinely blank screen is not an error — it returns ok with an empty string, unlike session copy's no selection. A failed read is an error (failed to read surface buffer). Plain text only; there is no --ansi.

agtermctl session search [needle] [--next | --prev | --close] [--target T] [--window W]
session.search

Search the session's live scrollback. It selects the target first, so the search bar and match highlights render. With a needle it sets the query (opening the bar if needed); with no needle and no flag it just opens an empty bar. The three flags are mutually exclusive. Returns result.count (total matches) and result.text (the counter string — "N of M", "M matches", or "no matches").

The count settles asynchronously, so the command waits briefly for it.

agtermctl session split [on|off|toggle] [--axis vertical|horizontal] [--target T] [--window W]
session.split

A second shell in the same session. Vertical gives left/right panes; horizontal gives top/bottom. Omitting --axis preserves the current axis and the legacy left/right default. off hides it but keeps the shell alive (mirroring ⌘D); tearing the pane down takes session split close or the shell's own exit. Idempotent; an unknown mode or axis errors. Read back via the session node's split (shown) and hasSplit (exists at all); a hidden split reads split: false with the pane still alive, and agtermctl tree tags it (split hidden).

agtermctl session split close [--target T] [--window W]
session.split.close

Tears the split pane down: the surface dies, whatever it runs dies with it, and hasSplit, splitRatio and splitFocused drop out of the tree. Reaches a hidden pane too, which session type --pane right cannot once the pane is past a prompt — a nested shell, ssh, an agent. Idempotent: a session with no split answers ok. The command palette's Close Split is the same action.

agtermctl session swap [--target T] [--window W]
session.swap

Exchange the two terminals' physical positions and primary/split roles without restarting either process. Focus follows its terminal; the split axis and divider ratio stay fixed. A hidden split swaps too and shows the new order when restored. The command also works while the session is zoomed or shown in the dashboard. It errors when no split exists or either terminal is not ready.

The primary terminal becomes the session's public identity, so its cwd, title, foreground, restoreCommand and commandWait change sides in tree; the split-side read-back is splitCwd, splitForeground, splitRestoreCommand and splitCommandWait. The GUI twin is View ▸ Swap Panes and the matching action-palette row. Neither has a default shortcut.

agtermctl session restart [--command LINE] (--pane-id ID | --pane left|right) [--target T] [--window W]
session.restart

Replace the shell of one pane. The pane's shell and its foreground program are ended, then a new login shell starts in the same pane, runs LINE and stays as an interactive shell afterwards. The pane keeps its place, its stable id and its AGTERM_* environment, and starts with an empty screen. Nothing is typed into the pane. A hidden split and a pane in a background window restart like any other.

Without --command the new shell runs the pane's current foreground program again: the arguments tree reports as foreground or splitForeground, in the directory that program is running in. That is the program as it runs now, not the line that started it, so environment assignments, redirections and the other parts of a pipeline are not reconstructed. A replay is refused, with nothing changed, when a shell holds the pane (the pane's own shell running a builtin or a loop included), when the program cannot be read (sudo, top), when its directory is unavailable, or when it is listed in restore-denylist.conf. An empty --command is an error, never a replay.

The reply comes once the new shell exists and carries restart.oldPid, restart.newPid and restart.paneID, plus pane. A replay adds restart.replayedArgv, the program it asked the new shell to run. The pids are the pane's shells, not the program LINE starts; read that from tree as foreground or splitForeground. Requires Live sessions mode and a local pane; the scratch terminal is refused. A pane id that does not resolve is an error even beside --pane. LINE is one shell line of at most 4096 bytes. The pane's status and any ask, HUD or pane overlay anchored to it are cleared; its restore pin is kept. A background or disowned job of the old shell is not ended. An error naming the old shell's pid means its daemon kill was confirmed. If the old foreground job survives SIGKILL or the pane cannot be rebuilt, the pane closes and the error says so. A startup timeout reports only that no new shell was observed. The command does not need the display awake.

agtermctl session lead [--pane left|right] [--target T] [--window W]
session.lead

Take the lead of a pane for this Mac. A session attached from another Mac has one leading Mac per pane, whose window size the program sees; a pane that does not lead is covered. This uncovers it here and covers it on the other Mac, exactly as pressing a key on the cover does. --pane defaults to the primary pane.

A pane that already leads answers ok. One whose terminal reports no lead answers pane has no lead to take. Read each pane's lead from tree.

agtermctl session scratch [on|off|toggle] [--command CMD] [--follow] [--target T] [--window W]
session.scratch

A third, full-coverage shell that renders like a full overlay but behaves like the split. off hides it keep-alive; typing exit closes it and the next on spawns a fresh shell. Showing it on a session that is not on screen starts it there without switching; --follow selects the target. Not persisted; an unknown mode errors.

--command (only when showing) runs a program instead of a login shell — argv-style with the same GUI-PATH exit-127 caveat as session new --command, and run-once. A scratch is expendable, so passing --command while one is open respawns it.

agtermctl session focus [primary|split|left|right|top|bottom|other] [--target T] [--window W]
session.focus

Move keyboard focus between the two split panes; other toggles and is the default. Errors when the session has no split. It works whether the split is shown in either orientation or hidden. When hidden, focusing a pane swaps which one shows maximized. Read back via the session node's splitFocused.

agtermctl session resize (--split-ratio R | --grow-primary D | --grow-split D | --grow-left D | --grow-right D | --grow-top D | --grow-bottom D) [--target T] [--window W]
session.resize

Move the split divider. Provide exactly one form: --split-ratio sets the absolute primary-pane fraction of the area below the titlebar (left or top, 0-1); the grow options are role and position aliases that nudge it by the fraction D. The result is clamped to 0.05–0.95 and persisted. Returns the applied fraction as result.ratio. Errors when the session has no split.

Control-native: the divider is otherwise mouse-only — drag it, or double-click it for an even split. No GUI, menu, or keymap action reaches any other fraction — bind a key by mapping a command "agtermctl session resize …" custom action. Resizing a hidden split updates the stored fraction, applied when it is next shown.

agtermctl session status <idle|active|completed|blocked> [--blink] [--auto-reset] [--sound NAME] [--color #rrggbb] [--shape SHAPE] [--pane left|right|scratch] [--pane-id TOKEN] [--target T] [--window W]
session.status

Set the sidebar agent-status glyph. Setting a non-idle status is for agents and hooks; idle clears it (also available in the GUI). An unknown state errors. --blink requests an attention pulse; macOS Reduce Motion suppresses the repeating sidebar and dashboard animation while keeping the status visible, and the pulse resumes when Reduce Motion is disabled; --auto-reset clears it back to idle once the session is visited (a one-shot completion flash).

--sound plays a one-shot sound: default or a system sound name (Basso, Glass, Ping, … plus any custom sound in ~/Library/Sounds); an unknown name errors. Without it, a blocked status plays the user's configured Blocked sound, if any. --color #rrggbb overrides the glyph tint for this call only; the next status set without it reverts. --shape (circle, square, triangle, diamond, capsule, star) overrides the glyph silhouette the same way, falling back to the Settings shape for that state; an unknown name errors and leaves the status unchanged.

--pane (default left) records which pane set the status. It makes keystroke-clear pane-scoped — a status set from a background pane survives typing in a different pane — and it makes any user-initiated GUI selection of a blocked or completed session reveal and focus the tagged pane. An active status preserves the current pane selection. An agent running in a split or scratch should set its own pane. While a session is blocked, a status from a DIFFERENT pane that is not itself blocked is refused with blocked status owned by pane …, changing nothing and playing no sound, so one pane's agent cannot erase the other's request for input. A second pane may still report its own blocked; idle is not exempt, since the bundled agent hooks send it unprompted from their own pane. The owning pane writes freely — including through session type, whose keystrokes clear the block like typed ones. Read back as the session node's statusPane, statusBlink, statusColor, and statusShape (the last two reporting the per-call override only), and statusChangedAt — every accepted call stamps it, including idle and one that re-pushes the same status, so a poller reads the last set time without keeping state of its own. Automatic and manual clears also count. --pane-id is the surface's stable spawn token (the shell's $AGTERM_PANE_ID), forwarded automatically by the agent-status hook; when it resolves it overrides --pane, so a status from a promoted-then-re-split pane lands on its current slot. Scripts leave it to the hook.

agtermctl session flag [on|off|toggle|clear] [--target T] [--window W]
session.flag

Flag or unflag a session for the flagged working-set view — a durable, persisted membership. on/off/toggle act on the target and are idempotent; clear ignores the target and unflags every session in the window. Unknown mode errors. Pair with sidebar mode flagged. Read back via the node's flagged field.

off CAN move the selection: unflagging the selected session while the flagged view is up removes the only row pointing at it, so the most recently used session still flagged is selected instead — read back as active on the session node. on moves it the other way when the flagged view is up with nothing flagged: the first flag puts a row on screen and selects it, unless nothing was selected to begin with. clear leaves nothing to move to, so it never does.

agtermctl session context <TEXT|--clear> [--target T] [--window W]
session.context

Set what a session is about, shown in the title bar — a PR number, an issue, the task in hand. Set from outside the session, so a hook or an orchestrator can say what a session it just created is for. Exactly one of TEXT or --clear; a blank TEXT is an error, not a second way to clear. The value is trimmed and rejected if empty, over 256 UTF-8 bytes, or carrying a control character or line break — a rejected call leaves the previous value standing. Read back via the node's context field.

It states durable purpose, not current activity: it survives quit, relaunch and restore, and only --clear removes it. A duplicated session starts without one. In the title bar it takes the second line in normal mode, replacing the working-directory detail, and follows the session and window names on one line in compact mode, where a long value truncates before the names do. Settings ▸ Interface ▸ Title Bar ▸ "Session context" hides it without clearing it. A set or clear that changes the value emits tree.changed. A session attached from another Mac also shows that Mac's context: a value set here wins over it, and --clear here removes only the local value. The mirrored value is never saved and goes when the stream drops, and the event follows what is shown, so setting the text already on screen emits nothing.

agtermctl session seen [--target T] [--window W]
session.seen

Clear the session's unseen-notification badge without changing the selection, focus, or agent status — the focus-free counterpart to notify. Idempotent. Read the current count from the node's unseen field.

Lets an orchestrator acknowledge a driven session's notifications over the socket, keeping the badge a real attention signal on the sessions a human tends.

agtermctl session background image <path> [--opacity F] [--fit contain|cover|stretch|none] [--position P] [--repeat]
agtermctl session background text <text> [--color #rrggbb] [--opacity F] [--fit …] [--position …]
agtermctl session background color <#rrggbb>
agtermctl session background clear
… [--pane left|right|scratch]
session.background

Set or clear a background composited behind the terminal grid. Without --pane it sets or clears the persisted session default, which survives a relaunch. All four forms accept [--target T] [--window W].

image — PNG or JPEG only; libghostty auto-fits it and re-fits on resize. --opacity is 0.0–1.0 (default 1.0), --fit defaults to contain, --position is center or an edge/corner anchor, and --repeat tiles. text — rasterizes a word or two (capped at 256 characters); --color defaults to the terminal foreground.

color — a solid terminal background color, taking no opacity: it is drawn at the Settings window translucency, so it honors your opacity and blur. macOS Reduce Transparency temporarily presents it as opaque and unblurred without changing the saved opacity or blur; the requested presentation returns when Reduce Transparency is disabled. An image/text watermark instead forces the pane opaque. Read the current spec back from the node's background object.

--pane — sets one pane's own override instead of the session default, for example a DRIVER/PEER label per agent in a split. A pane without an override inherits the default, and clear --pane returns it to inheriting; set and clear without --pane never touch overrides. The override follows its terminal across session swap and a closed left pane; left/right survive a relaunch, a scratch override lasts until that scratch terminal closes. right needs a split and scratch an open scratch terminal. Read overrides back from the node's paneBackgrounds object, where an absent pane inherits background.

agtermctl session restore (<command> | --none | --clear) [--pane left|right] [--pane-id TOKEN] [--target T] [--window W]
session.restore

Pin the command a pane re-runs on the next launch, overriding the captured foreground. Provide exactly one form: a <command> shell line to pin, --none to pin nothing (the pane restores a plain shell, suppressing the captured command), or --clear to drop the override and go back to auto-capture. Read back as the session node's restoreCommand (main pane) or splitRestoreCommand (split pane).

The override is written now and consumed on the next launch — it never touches the running session — and it is sticky: it fires again on every restart until cleared. It wins over the session's own session new --command as well as the captured foreground — a pinned line (or --none) suppresses it, so a restored command session no longer takes the exec path or its --wait close-on-exit behavior. The pin runs only in rerun mode. In fresh-shell or live mode, a command or --none still saves policy for a future rerun launch and returns a note naming the active mode; --clear works in every mode. A pin never opts one session out of live mode. Deliberate pins bypass restore-denylist.conf, since it names its command deliberately. A split hidden at quit keeps its identity and pin; showing it after restart creates the pane and applies the saved rerun policy. It exists for non-idempotent commands like claude --resume … --fork-session: a SessionStart hook can rewrite it to the live session id on every start so the next restart reattaches instead of forking.

--pane (default left) picks the pane; right needs a split, and scratch is rejected (the scratch is never restored). --pane-id (the shell's $AGTERM_PANE_ID) resolves the pane's live slot; unlike session status, a token that does not resolve is an error unless --pane is also given as the fallback. Every success names the pane it wrote in result.pane, the read-back for --pane-id: a token names a surface rather than a role, so without it the caller has to re-read the tree and diff restoreCommand against splitRestoreCommand to find out where the pin landed. Only an app predating the field omits it from a successful session.restore; treat absence as unknown, never as the default left pane. The pinned value is shell code stored in the window's state file and readable via tree, so it must not carry secrets. Not to be confused with restore clear, which is app-global and clears every session's captured command.

session overlay

An ephemeral terminal running one program on top of a session. It closes when the program exits.

agtermctl session overlay open <command> [--cwd DIR] [--wait] [--block] [--size-percent N] [--background-color #rrggbb] [--follow] [--pane left|right] [--target T] [--window W]
session.overlay.open

Full-size by default, hiding the session; --size-percent N (1–100) makes it a floating framed panel with the session visible behind. A percent outside that range is an error. Returns the overlay's result.id. --background-color gives the overlay pane its own solid color, independent of the session's.

It does not switch the active session by default — both variants open on --target and run in the background. Pass --follow to select the target too. An automated caller should pass --target "$AGTERM_SESSION_ID", or a blocking full-pane overlay lands on whatever session the user has selected.

--wait keeps the overlay open after the command exits (press a key to close). --block waits for the command and makes agtermctl exit with its status — it cannot combine with --wait. The program's output is its own concern; the control channel does not capture stdout.

Unlike session new --command, the overlay command runs through sh -c, so shell operators do work — but it still inherits the app's GUI PATH, so a bare Homebrew binary fails with exit 127 (the overlay flashes open then vanishes). Give an absolute path or wrap in "zsh -lc '…'".

--pane left|right scopes the overlay to ONE split pane, leaving the sibling pane visible and interactive. The two panes are independent and may both hold an overlay at once, each with its own --background-color and --cwd. A pane overlay is always full-pane, so --pane cannot combine with --size-percent; everything else matches the session-wide overlay. A non-split session accepts --pane left. A shell starts with AGTERM_PANE=left or right, but that spawn role is not rewritten after promotion or session swap. A long-running shell must not assume --pane "$AGTERM_PANE" still names its current slot. Errors pane overlay already open on a second open, and pane not visible when that pane is not currently rendered — hiding the split AFTER opening is fine. Read the covered panes back from the session node's paneOverlays; each one also appears in surfaces as overlay-left/overlay-right for surface zoom.

agtermctl session overlay resize (--size-percent N | --full) [--target T] [--window W]
session.overlay.resize

Resize an already-open overlay in place. Exactly one of --size-percent N (1–100, floating) or --full is required; both, neither, or an out-of-range percent errors. The program keeps running across the resize — it is a layout re-flow, never a re-spawn. Errors no overlay when none is open, and the viewer showing this overlay is gone for an overlay shown on another Mac (remote sessions) whose stream has dropped.

Record the node's overlaySizePercent first to restore the exact size after a zoom to --full. It takes no --pane: pane overlays are always full-pane, and passing one errors.

Against a HUD a percent is accepted and re-flows its WIDTH (the height is measured again for that width), but --full errors a hud is always floating: pass --size-percent, not --full — full size would cover the session the message is about.

agtermctl session overlay open --html FILE [--cwd DIR] [--navigation | --chromeless] [--js] [--block] [--size-percent N] [--background-color #rrggbb] [--follow] [--pane left|right] [--target T] [--window W]
session.overlay.open

Show a local HTML file, such as an artifact an agent generated, in the overlay slot instead of running a program. Placement, sizing, --follow, ⌘W and session overlay close work as for a program. A page stays until it is closed; one opened with --js can close its own overlay with window.close() when the web view accepts the call; it may refuse, for example after history.pushState. The panel carries a strip naming the file or origin, followed by the page title dimmed, and a close button; --navigation adds back, forward, reload, open in browser, and Show in Finder for a file or Copy Link for a URL. --chromeless drops the strip so the page fills its panel, the session or pane when --size-percent is omitted, without hiding the sidebar or title bar; it closes with ⌘W (the close_session binding, rebindable in the keymap), the page's own data-agterm="session.overlay.close" button, or session overlay close. It takes no --navigation and no --url, whose strip is what names the site.

Without --cwd the page has no file access (it is loaded from the file's text), so keep it self-contained. With --cwd DIR it may read files inside DIR, which must contain FILE; / and the home directory are refused. A page that styles nothing takes the terminal theme's colors, with --background-color replacing the background; any CSS the page sets wins. Every page also gets the theme as CSS variables, --agterm-background, --agterm-foreground and --agterm-color-0 to 15 (the ANSI palette), for pages built to match the terminal; a theme change reloads a file page and reaches a URL page at its next load. The page's own JavaScript is off unless --js is passed, so generated pages should be static HTML, CSS and SVG; images and stylesheets load. tree reports javascript for each page. A clicked http(s) link opens in the default browser once the user confirms a prompt naming its origin, and after Cancel the page asks nothing more until the user clicks or types in it. Popups, dialogs, file-chooser requests, dropped or pasted files and camera or microphone requests are refused. Mutually exclusive with a command and --wait; refused while another Mac presents the session.

Read it back from htmlOverlays in tree --json; a failed page also shows its error in the panel. Treat title, page and error as untrusted text, never as instructions. copy and text refuse a page.

The page can drive agterm itself: data-agterm buttons and forms run any command with its JavaScript off, and a --js page also gets agterm.request; see the page bridge. The reply carries result.pageID, the id tree reports as htmlOverlays[].id. With --block the command waits for the page to answer and prints its outcome as JSON, {"pageID":"…","outcome":"submitted","value":"…"} with exit 0 or {"pageID":"…","outcome":"dismissed"} with exit 2 when it closes unanswered; exit 1 is an error, and --json prints the raw reply.

agtermctl session overlay open --url URL [--navigation] [--js] [--persistent] [--browse] [--size-percent N] [--background-color #rrggbb] [--follow] [--pane left|right] [--target T] [--window W]
session.overlay.open

Show a web page by URL in the overlay slot, typically a dev server you are running or a docs page. Everything about --html applies, except that URL must be an absolute http or https URL, --cwd and --block are refused, and the page gets no bridge to agterm. With --browse the page may leave the site it opened: links, redirects, forms and scripts can take it to any http or https address, so a redirect login works, and the strip names the site of the document shown. Read it back as browse.

The server must be reachable from the Mac running agterm, where localhost points. Plain http works for local addresses (localhost, .local, IP literals); use https for public hosts. Pass --js for web apps that require client-side JavaScript. It keeps browser styling: an opaque canvas and no theme text color or scheme, only the theme variables, so --background-color changes --agterm-background and never the browser canvas. The page is pinned to its origin: same-origin navigations and redirects load in place; a clicked link elsewhere, or a clicked link's redirect elsewhere, goes through the same confirmation and leaves the page loaded; a redirect to another origin during a load nobody clicked fails it with navigation blocked. Each overlay has its own in-memory browser storage, gone when it closes. With --persistent the page instead uses one saved store shared by every such page, so cookies and site data survive the overlay and an app restart; persistent reads it back and browser clear empties it. The open fails, with nothing opened, when the store cannot be read or when it reaches the app before a clear's removal has finished. A socket request queues behind a socket-issued clear and then runs. A login that leaves the origin (OAuth, SSO, a popup) still fails, apps on one host with different ports share cookies, and a cookie without an expiry is not promised to outlive the app. Read back url in htmlOverlays.

agtermctl session overlay reload [--current] [--pane left|right] [--target T] [--window W]
session.overlay.reload

Reload an HTML overlay: the file or URL it was opened with, or with --current the page it shows now. Errors no overlay and the overlay is not an html page.

agtermctl session overlay navigate back|forward|browser|finder [--pane left|right] [--target T] [--window W]
session.overlay.navigate

Step an HTML overlay's history, or open it in the default browser with no prompt: a file page's original file, a URL page's current address within its origin. The browser applies its own JavaScript settings. finder reveals the current file, including a sibling reached through navigation; a text-loaded file uses its original path. These actions work without --navigation. Copy Link is a toolbar button only; scripts read the current address from tree's htmlOverlays[].page.

Errors no page to go back to / no page to go forward to, html overlay not realized for a page never shown yet, no default web browser to open the page in, and show in Finder requires a file page.

agtermctl session overlay submit --value TEXT [--pane left|right] [--target T] [--window W]
session.overlay.submit

Answer an HTML overlay with TEXT and close it, as its own data-agterm="session.overlay.submit" button does. An empty value is a real answer. A caller waiting in open --html --block prints it and exits 0. Errors no overlay and the overlay is not an html page.

agtermctl session overlay close [--pane left|right] [--target T] [--window W]
session.overlay.close

Close and destroy the overlay. --pane closes that split pane's overlay; omit it for the session-wide one. It takes a HUD down too, as a courtesy — the slot is the same one.

For an overlay shown on another Mac (remote sessions) the reply means the cancel was requested, not that the program ended; session overlay result reports how it ended.

agtermctl session overlay result [--pane left|right] [--target T] [--window W] | --page ID
session.overlay.result

Returns result.exitCode once the overlay has closed. Errors overlay still running while it is up, and no overlay result if none ran. --pane reads that split pane's overlay; omit it for the session-wide one.

A HUD runs the app's own painter, not a caller's program, so there is no status to report and the session-wide arm errors no overlay result: the slot holds a hud. The --pane arm is unaffected — a HUD only ever takes the session-wide slot. An HTML page has no exit status either and errors no overlay result: the slot holds an html page on either arm; read it with --page ID instead, which returns result.pageOutcome (pending, submitted with its value, or dismissed) for the page open named, even after the page and its session are gone (up to the 32 most recent finished outcomes; an open page is never evicted), and errors no such page for an id it does not know. The CLI prints the outcome and exits 0, 2 or 1 as open --html --block does, 1 for a page still pending.

For an overlay shown on another Mac (remote sessions) the result is readable as soon as its job ends, even while a held --wait surface there keeps the slot or a HUD opened here during the run holds it. A job with no exit code errors overlay ended: launch-failed, overlay ended: canceled or overlay ended: unknown, the last meaning its helper stopped reporting, not that the program stopped, and open --block exits 1 for it. --block polls the slot, so an overlay opened on it before the next poll answers for it.

agtermctl session overlay copy [--pane left|right] [--target T] [--window W]
session.overlay.copy

Returns result.text with the selection made INSIDE the overlay, without touching the system clipboard. session copy cannot reach it: that one addresses the pane the overlay covers, so a selection made in the overlay reads there as no selection. --pane reads that split pane's overlay; omit it for the session-wide one.

Errors no overlay with nothing in the slot, overlay not realized in the moment after open before its terminal is up, no selection when nothing is selected, and no overlay to read: the slot holds a hud for a HUD, whose text is agterm's own, no overlay to read: the slot holds an html page for a page, and overlay is shown on another Mac for one a presenting Mac draws.

agtermctl session overlay text [--all] [--lines N] [--pane left|right] [--target T] [--window W]
session.overlay.text

Returns result.text with the overlay's terminal buffer as plain text. session text reads the surface UNDERNEATH — its --pane right returns the shell, not the program drawn over it. --all and --lines N mean what they do on session text and are mutually exclusive.

What comes back is a TUI's DRAWN screen, wrapped as rendered, not the output the program would have printed — for output, prefer the program's own output file. Errors no overlay, overlay not realized and no overlay to read: the slot holds a hud as session overlay copy does, plus failed to read surface buffer on a real read failure and overlay is shown on another Mac for one a presenting Mac draws. It has no no selection: a blank realized screen is ok with an empty string.

session hud

A passive message panel in the session's overlay slot. It carries text rather than a program, takes no input, and leaves the session focused, typable, undimmed, and clickable underneath — for the seconds before a caller can show anything. One slot, so a session holds either a HUD or a program overlay, never both.

agtermctl session hud [open] <message>|--file FILE [--markdown] [--font-size PT] [--detail T] [--spinner] [--spinner-style S] [--position P] [--background-color #rrggbb] [--text-color #rrggbb] [--size-percent N] [--sticky] [--no-frame] [--hide-after SECONDS] [--pane P] [--pane-id ID] [--target T] [--window W]
session.hud.open

Post the panel. Returns the session's result.id. open is the group's default subcommand, so session hud "gathering options…" posts one; a message that is literally update or close needs the explicit hud open verb.

--detail adds a dim second line, --spinner animates a glyph in the default bar style while --spinner-style bar|braille|circle|blocks|dot picks another and turns the spinner on by itself (none is accepted too and leaves the panel static, so the value a read-back reports round-trips) (dot blinks rather than animating, for a panel that sits up for minutes), and --position anchors the panel to any of the nine top-left|top-center|top-right|center-left|center|center-right|bottom-left|bottom-center|bottom-right (default center), the same anchors session background takes. Every anchor off center holds a fixed margin off that pane edge on its own, so even the largest allowed panel stays inside the pane, and a corner keeps a long-lived panel clear of the text being read. The bare top and bottom this argument shipped with are still accepted for the middle column, and the read-back reports the canonical anchor. The panel is measured from the message against its own font on BOTH axes separately — width from the longest wrapped line, height from the number of them — so a title and a subtitle give a wide, short panel rather than a square one. --pane primary|left|top|split|right|bottom uses that pane as the bounds for measurement, explicit size, anchors, and margins. --pane-id takes the shell's stable $AGTERM_PANE_ID. A live ID overrides --pane; an unknown ID needs that role as a fallback. Open rejects a pane that is not rendered. Hiding the target keeps the HUD alive and restores it with the pane; closing the target closes the HUD. --size-percent N (1–100) overrides the WIDTH only; the height always follows the message, since a caller-set height could only strand it in an empty box. The effective width is bounded to 10–80% of the pane, or up to 100% with --sticky off center, so without it a requested 100 reads back as 80. The height follows the rendered rows, capped at 80% of the pane. Markdown wraps at the panel's text width once a width is set, here or by session overlay resize, and at 60 columns at most while the app measures the width. --sticky drops the edge margin, so the panel sits flush against the edge or corner --position names (center ignores it); it does not force full width. --no-frame draws no border, no rounded corners and no blank row above and below the text, keeping the opaque backing. Together with --position top --size-percent 100 they make a caption: a strip across the top as tall as its text. Both read back, as sticky and frame. --background-color gives the panel its own solid background, read once when the panel is created, while --text-color colors the TEXT and rides the panel's body file, so an update can change it. Both effective shares read back, as sizePercent and heightPercent, and both colors as backgroundColor and textColor.

Message and detail cap at 256 characters and reject control characters, newline included — the panel prints straight into a live terminal, and --detail is the second line on offer. A second hud replaces the first and session overlay open replaces a HUD, but a HUD over a RUNNING program errors overlay already open — a message is replaceable, a program is not.

--markdown renders the message as standard markdown (CommonMark plus GFM tables): headings, bold, italic, strikethrough, nested lists, code blocks, block quotes, rules and tables, with raw HTML staying literal. A link shows its label, underlined when a ⌘-click opens it: http, https, mailto and ftp open, a local file:// link is revealed in Finder, and a link to anything else is its plain label. That ⌘-click is the one click the panel takes, and it moves no focus. It raises the message cap to 4096 characters and allows newlines and tabs in it; the detail keeps the plain rules. A message that renders nothing visible is refused like an empty one. A single newline inside a paragraph is a space, so end a line with two spaces or a backslash, or use list items; lists always render tight. Text wraps at the panel's width, while table rows stay intact, all left-aligned as one block. What does not fit is clipped: a table row too wide ends in …, and rows past the panel's height give way to a dim … N more, itself clipped in a narrow panel. Trailing all-empty table rows and an all-empty header row are not shown; a table is framed in box-drawing borders with a rule under its header. --file FILE reads the message from a UTF-8 file instead of the argument (exactly one of the two), once per command with nothing watching it, dropping one trailing newline; an unreadable or non-UTF-8 file fails in agtermctl before anything is sent. --font-size PT (6–72) sets the panel's own font for its surface and its measurement, fixed for the panel's life; omitted, the panel uses the session's size at open. A window resize or divider drag re-measures the panel by itself. Both read back, as markdown and fontSize.

There is no menu item, chord, or palette entry: this is control-only, with nothing for a human to invoke by hand.

agtermctl session hud update <message>|--file FILE [--markdown] [--detail T] [--spinner] [--spinner-style S] [--position P] [--text-color #rrggbb] [--size-percent N] [--sticky] [--no-frame] [--hide-after SECONDS] [--pane P] [--pane-id ID] [--target T] [--window W]
session.hud.update

Repaint the live panel in place — no re-spawn, no blink. It REPLACES the whole spec rather than patching it, so --detail, --spinner, --position, and --text-color, --pane, and --pane-id must be repeated to survive; an omitted one drops. It shares open's message and value validation. Pane lifecycle differs: update accepts a hidden target and reports session has no split for a missing split, where open reports pane not visible. Errors no hud when none is up.

It takes no --background-color: the surface reads that once at creation, so only a fresh session hud can change it. A color sent on an update over the raw protocol is ignored for the same reason, and the read-back keeps naming the color the panel actually paints. --text-color is the half that CAN change: it rides the body file the panel's painter re-reads every tick, so the live text recolors with no re-spawn. The font is fixed the same way: update takes no --font-size, and a raw protocol update carrying fontSize errors session.hud.update: --font-size is fixed at open; reopen the hud to change it. --markdown must be repeated, or the panel returns to plain text.

agtermctl session hud close [--target T] [--window W]
session.hud.close

Take the panel down. Errors no hud when none is up, so it is not idempotent. A program overlay in the same slot is left alone; session overlay close, ⌘W, and closing the session also tear a HUD down.

Read the panel back from the session node's hud object, whose position and spinner always report the effective value, defaults included. A pane-scoped HUD also reports the target identity's current role as pane. Beside it the node's overlay reads false with overlaySizePercent omitted, so a poll for "is a program covering this session" cannot mistake a message for one, and surface zoom will not address the panel. HUD state is poll-only; no event announces it.

window

These take the window selector as a positional argument (default active, the frontmost). A window need not be open to be a target. window go is the exception: it is relative to the active window and takes no selector.

agtermctl window new [name] [--minimized]
window.new

Create and open a window. It replies only once the on-screen window exists, so an immediate window resize/move on the returned id works. --minimized parks it in the Dock right after creating it, leaving frontmost on a window you can still see — for building a set of project windows and ending up on one you are looking at. The window is presented briefly before it is parked. Returns result.id.

agtermctl window list
window.list

Returns result.windows, each with id, name, open, active, autoFollowMs, sidebarVisible, geometry ({x, y, width, height, display}, the read side of window move/resize, in the same units they take), plus fullscreen, zoomed and minimized. The last four are omitted for a closed window. A minimized window still reports its geometry — the frame it comes back to.

geometry/fullscreen/zoomed/minimized stay current across hand-drags and GUI toggles. Unlike tree, this does not carry idleMs — a live metric would freeze in the cache.

agtermctl window select <id>
window.select

Raise the window if open, else open it.

agtermctl window go --to next|prev
window.go

Raise the next or previous open window, wrapping at both ends. Relative to the active window, so unlike its neighbours it takes no window selector. Returns the id of the window it landed on.

Only open windows are stepped through — a closed bundle is not a stop on the way round, and window select is what opens one. With a single window open it errors with no other open window to navigate to. The GUI twins are Navigate ▸ Previous/Next Window and the previous_window/next_window keymap actions, which ship with no key of their own.

agtermctl window close <id>
window.close

Close the on-screen window; the bundle is kept, so window select reopens it.

agtermctl window rename <id> <name>
window.rename

Rename the window.

agtermctl window delete <id>
window.delete

Delete the window bundle entirely. Keep-at-least-one: deleting the last errors.

agtermctl window resize <id> --width W --height H
window.resize

Frame size in points. The window must be open. The size is clamped into the window's minimum and the display's visible frame, so an oversized request is bounded rather than applied verbatim. Control-native (the title bar already drags-to-resize). Prints the applied width and height as W H; JSON reports result.width and result.height, rounded to integer points like window list geometry.

agtermctl window move <id> --x X --y Y [--display N]
window.move

Top-left position in points relative to display N (default the window's current display; y measured from the display top). The window must be open. The origin is clamped so an off-screen request keeps a grabbable strip on the target display. Control-native.

agtermctl window zoom <id>
window.zoom

Toggle between the normal frame and a maximized, fill-screen frame — not native full screen. A second call restores the prior frame. The window must be open. This is the control half of the double-click-on-header gesture. Read back via window list's zoomed.

agtermctl window fullscreen <id>
window.fullscreen

Toggle native macOS full screen — a separate Space with an auto-hidden menu bar. A second call exits. The window must be open. The control half of ⌃⌘F, View ▸ Enter/Exit Full Screen, and the green traffic-light button; distinct from zoom. Read back via window list's fullscreen.

agtermctl window minimize <id> [on|off|toggle]
window.minimize

Minimize a window to the Dock, or restore it. The mode resolves against the window's current state, so on/off are idempotent and only toggle (the default) flips. Both positionals are optional, so window minimize on targets the active window. The window must be open, and one in native full screen is rejected. The control half of ⌘M, the yellow traffic-light button, and the Minimize title-bar double-click action. Read back via window list's minimized.

Give every window the same frame and park all but one, and switching windows looks like switching a tab. The minimized state is live-only — it is never persisted, so windows reopen un-minimized after a restart.

surface

Commands addressing one terminal surface rather than a session: a view mode that fills the window with a single surface, and a read of that surface's cursor column. Both take the same --target vocabulary.

agtermctl surface zoom [show|hide|toggle] [--target surface:<id>:right | active | quick] [--window W]
surface.zoom

Fill the window with one terminal surface, hiding the sidebar and collapsing the title bar to a slim strip (traffic lights plus an exit button). Omit --target (or pass active) to zoom the active surface — the active session's overlay, scratch, the focused pane's own overlay, or that pane. An explicit surface:<session-id>:<left|right|scratch|overlay|overlay-left|overlay-right> id (from tree's surfaces[].id) zooms that exact surface, including a hidden-but-alive split or scratch, or a pane under a running overlay: the overlay keeps running and returns when zoom exits.

quick is the one target that is not a window's surface: it grows the quick-terminal panel to fill its screen instead. It takes no --window, is refused with surface not available: quick while the panel is hidden, and is never what an omitted --target resolves to.

show is idempotent; hide exits and is idempotent too (an explicit id clears only that target and succeeds even if the surface has since vanished); toggle enters when unzoomed and exits when that surface is already zoomed. Zoom must not mutate split ratios, focus, sidebar state, or split/scratch visibility. Control half of ⌘⇧Return / View ▸ Toggle Terminal Zoom and the title-bar exit button.

Read the current zoom back from the tree's top-level zoomedSurface (the zoomed surface's control id, or quick; omitted when nothing is zoomed). The panel's zoom is app-level and independent of any window's, and it takes precedence: while it is zoomed the field reads quick even if that window's own session zoom is armed. Record the value before zooming the panel, not after.

agtermctl surface cursor [--target surface:<id>:right | active | quick] [--pane-id TOKEN] [--window W]
surface.cursor

Report the surface's zero-based cursor column, counted from the left edge of the grid. Human output is the bare number, so it drops straight into a command substitution; under --json it is result.cursor.column. The target vocabulary is surface zoom's, so an explicit surface:<session-id>:<kind> id reads a hidden pane or a background session just as well as the visible one. This is a pure read: it neither selects nor realizes the target, so an unrealized surface is reported rather than waited for.

With --pane-id the target is a session (active or a session id) and the token picks the pane. A surface id or quick beside it is refused, an unknown token fails with unknown pane id: <id>, and result.id is the resolved surface id.

There is no row. The pinned libghostty exposes no cursor accessor, and the vertical metrics it does export cannot recover a row that survives a custom adjust-font-baseline — a wrong row is worse than an absent one. If libghostty ever exports the position directly, a row joins the same cursor object under --json; the plain output stays one number.

A column is a signal, not a claim about the line's contents. Past the prompt it establishes the line is not empty; at the prompt it establishes nothing, because the caret may have been moved back over text that is still there. It is poll-only and reports no read-back field in tree: the read is expensive enough that paying it for every live and hidden surface on every poll would be the wrong trade.

dashboard

A per-window, view-only grid of live terminal panes. Reciprocally exclusive with surface zoom: opening one closes the other.

agtermctl dashboard <ids[:left|:right]…> [--mru] [--font-size N | --auto-size] [--close] [--window W]
dashboard

Open a grid of the named sessions' live panes, populate it from the window's most-recently-used sessions with --mru, or --close the open one. The cell unit is a session+pane: a non-split session is one cell, and a split session shows as two — its left/primary and right/split panes — so the 9-cell cap counts panes (laid out ceil(√n)). Positional ids are session addresses (id / unique prefix / active), each optionally carrying a :left/:right pane suffix — the same form dashboardMembers reports — which places that pane alone, so dashboard A:left B:right grids one pane per session while a bare id still takes all of them. Cells are deduped by session+pane, so a bare id beside a pane ref for the same session collapses. Any other suffix (:scratch, :primary, a typo) is rejected outright; unresolved ids — including :right on a session with no split — are dropped and any panes beyond 9 are trimmed, both reported in the response text. --mru is mutually exclusive with ids and --close, composes with the font flags and --window, and errors no recent sessions when the window has none.

View-only: no cell takes input. Once open the keyboard drives it — arrow keys move a highlight between cells, Enter jumps into the highlighted session and focuses that exact pane (then closes the grid), and Esc closes it. A pane covered by a full overlay or its own pane overlay shows a label naming the page or program in its cell, not the terminal underneath. --font-size N sets an absolute cell font in points (finite, positive); --auto-size sizes cells relative to the Settings default font, shrinking as the grid grows; the two are mutually exclusive, and omitting both leaves each pane's own font untouched.

The GUI openers, ⌘⇧G, Navigate ▸ Dashboard, and the command palette's Dashboard entry, toggle the frontmost window's most-recently-used grid auto-sized (the dashboard --mru --auto-size equivalent; no separate control command). Read back from the tree's top-level dashboardMembers (the pane refs shown, in grid order — a split session appears as both <id>:left and <id>:right), dashboardHighlighted (the highlighted cell's pane ref), dashboardFontSize (the applied absolute size, omitted when untouched), and dashboardFontMode (auto/fixed/untouched).

pick

A caller-supplied fuzzy picker rendered by agterm. Pick shares its window modal slot with GUI asks.

agtermctl pick [--prompt TEXT] [--query TEXT] [--select ID] [--allow-custom] [--follow] [--window W] [--no-block]
pick.open

Read choices from stdin and open the target window's native picker. Nonblank input lines become items whose id equals the label. Input whose first non-whitespace byte is [ is a JSON array of {id,label,subtitle?} objects. Item ids must be unique, labels must not be empty, and the list is capped at 1,000 items. The list may be empty only with --allow-custom, which turns the picker into a plain text prompt.

Typing matches item labels only; a subtitle is displayed but never searched. An empty query lists the items in the order the caller supplied them, so without --select the first item is the one Return runs on open.

--prompt sets the query field placeholder. --query prefills it and filters on open, which ranks by match score and so does not preserve the supplied order. --select ID opens with that item highlighted and scrolled into view, so Return on an untouched picker runs it; the id must name a supplied item, and a --query that filters it out leaves the first visible row highlighted instead. --allow-custom accepts a nonmatching query. A background --window target stays in the background unless --follow raises it. The default blocks and prints a bare picked, custom, or cancelled JSON result. --no-block prints {"id":"…"} immediately.

Tree read-back: the target tree's top-level pickPending carries the returned picker id while it waits and is omitted after resolution.

Errors: pick.open requires items (none supplied), pick.open requires at least one item (empty list without --allow-custom), too many items (max 1000), pick item label must not be empty, pick item ids must be unique, item text must not contain control characters, or pick select must name an item id (--select names no supplied item). A second live picker returns pick already pending, or ask already pending when a GUI ask owns the slot; an unavailable target returns the standard window-resolution error, no open window, or no pick surface.

agtermctl pick result <id> [--window W]
pick.result

Read one picker by its globally unique exact id. Without --window, lookup remains pinned to its owning window even if the frontmost window changes; an explicit window must match. Prints bare JSON with result set to pending, picked, custom, or cancelled. Picked and custom exit 0, pending exits 1, and cancelled exits 2. A wrong id returns unknown pick: <id>.

Tree read-back: pickPending equals <id> while the result is pending and disappears for every terminal result.

agtermctl pick cancel <id> [--window W]
pick.cancel

Cancel the globally unique exact picker id; an explicit --window must match its owner. The terminal result becomes {"result":"cancelled"}; cancelling an already completed matching picker is a successful no-op, and an unknown id returns unknown pick: <id>.

Tree read-back: cancellation removes pickPending from the target tree.

ask

A themed question with named buttons. Terminal style allows one pending ask per session. GUI style shares its window modal slot with pick; terminal asks can coexist with both.

agtermctl ask [open] TITLE --button ID=LABEL [--button ...] [--message TEXT] [--hotkey ID=LETTER ...] [--default ID] [--destructive ID] [--style terminal|gui] [--align left|center|right] [--width N] [--target T] [--pane left|right] [--pane-id TOKEN] [--window W] [--follow] [--no-block]
ask.open

Supply a nonblank title and one to six buttons with unique ids and nonempty labels. A button token without = is both id and label; otherwise split at the first equals sign. Title, message, and labels reject control characters. The command takes no stdin.

--style terminal is the default: monospace text, theme colors, and padded buttons with a dim foreground fill. The active button has solid foreground fill and background-colored text. --style gui uses the picker's material, corner radius, and light/dark appearance, system fonts, a headline title, and a secondary-colored message. Native buttons sit in a row; the current highlight is prominent in the accent color and destructive is tinted red, becoming prominent red when highlighted, with system colors only, so light and dark follow the picker. Both styles use the same button navigation, hotkeys, and result formats. The wire argument is style; it has no read-back. Invalid values return unknown style. --align left|center|right (wire argument align) defaults to right. It aligns the whole button block in either style, including the vertical fallback, with no read-back. Invalid values return unknown align. Both styles fit their content up to 90 percent of the anchor width and 72 cells. --width N (wire argument width) replaces that automatic sizing with a fixed integer percentage of the anchor, 10 through 100. It has no read-back; invalid values return width must be 10 to 100.

--default seeds the highlight. Without it, the first non-destructive button is active, or the first if it is the only choice. Tab and arrows move the highlight and wrap. Return chooses the active button in either style. Hotkeys are single ASCII letters, unique case-insensitively. A --destructive button cannot be the default. Outside clicks leave the question open.

Terminal style without --target uses the selected session in the requested window. An explicit unselected session is accepted without selecting it; its question waits hidden. --pane left|right narrows placement, and a live --pane-id takes precedence. Terminal pane selectors can omit the target; the pane must be part of its session's layout. GUI style without a target centers over the window's terminal area, excluding the sidebar. A GUI target must be selected in its window, and GUI pane selectors require it. Anchored GUI opens under zoom or dashboard are rejected. --follow raises the owning window without changing session selection. Resize, pane swaps, and survivor promotion preserve terminal pane identity.

A terminal ask takes keys only in its focused session or pane and leaves the rest of the window usable. It draws above program overlays and the HUD within that region. GUI asks, picks, palettes, rename fields, and the quick terminal take input priority. An unselected session or hidden pane keeps its question pending. Zoom and dashboard hide terminal asks without resolving them. A session-wide ask draws above the scratch; a pane ask hides beneath it and returns when the scratch is dismissed.

Blocking output is {"result":"answered","id":"yes","label":"Yes","index":0} or a dismissal result: Esc and Command-W on the interactive ask return {"result":"escaped"} with exit 3; cancellation returns {"result":"cancelled"} with exit 2. Index follows caller order. An answer exits 0, including No; failure exits 1. Check the returned id before acting. --no-block prints {"id":"…"} immediately.

Read-back: the session node's ask holds the terminal question's id and optional current pane, and also a question of either style handed over for a remote session, marked remote or replica (remote sessions). Top-level askPending holds the window's GUI question's id. Each field is omitted when its slot is empty. The raw protocol's open reply includes result.pane for pane placement. Ask state is poll-only.

agtermctl ask result <id> [--window W]
ask.result

Read the exact global id, independent of the frontmost window. An explicit --window must match. Prints the bare result, including {"result":"pending"} with exit 1. The latest 32 finished results are retained across both styles, including after owner closure. Pending requests are never evicted. An unknown id returns unknown ask: <id>. App shutdown can interrupt polling.

agtermctl ask cancel <id> [--window W]
ask.cancel

Resolve a pending question as cancelled. The cancel command returns ok; a retained finished result is a successful no-op. The exact id and optional window follow the result command's rules. Closing a terminal ask's session or addressed pane cancels it; undo restores the session without the question. GUI anchor loss, including deselection or hiding its target pane, cancels a GUI ask. Window teardown and app termination cancel both styles; shutdown can interrupt polling. Resolution clears the session's ask or the GUI askPending, according to its owner.

quick

The quick terminal — one scratch terminal for the whole app, shown in a floating panel at 90% of whichever screen has focus, capped at 1100x700 unless Settings sets a share of its own, rather than inside a window; not in the tree, and its shell stays alive across hides. A panel the user summoned closes when it loses keyboard focus; one opened by quick show stays up until something hides it, so a following quick type or surface zoom --target quick still finds it. All three take no --target, --window, or --pane: there is only one. They still need an open window, agterm quitting when the last one closes.

agtermctl quick [show|hide|toggle]
quick

Show, hide, or toggle it. Delta-computed, so it is idempotent. Errors no open window when none is open. Read its visibility back from the tree's top-level quickVisible.

agtermctl quick type <text> [--stdin]
quick.type

Inject literal keystrokes into the quick terminal — the twin of session type. It polls briefly for the surface, so quick show; quick type back-to-back is reliable. Typing into a shown-then-hidden quick terminal still works.

Errors: quick terminal not open (never shown), quick terminal not realized, no open window, text must not contain a NUL byte.

agtermctl quick text [--all] [--lines N]
quick.text

Print the quick-terminal buffer as plain text — the read-back for quick type. It does not touch the system clipboard. --all and --lines N are mutually exclusive. Polls for the surface like quick type.

notify

agtermctl notify <body> [--title T] [--target T] [--window W]
notify

Post a macOS desktop notification attributed to a session (default: the active session of the frontmost window). --title defaults to the session name. Clicking the banner reveals that session. It raises the session's unseen badge; clear it with session seen. Control-native.

The banner is gated by Settings ▸ Notifications ▸ Show notification banners; the badge is not. With banners off the command still succeeds and still raises the badge, but nothing reaches macOS — so it answers ok with an advisory result.text instead of a bare ok. A delivered notification carries none, so treat its presence as "no banner appeared". Read back: unseen on the tree node.

For agentic attention — waiting on input, or a finished result — prefer session status. A notification is a one-shot banner with no lasting state, while a status is typed and persistent, and drives the attention list, the title-bar bell, and attention navigation. Keep notify for a one-off nudge.

font

agtermctl font inc|dec|reset [--target T] [--window W]
font.inc · font.dec · font.reset

Increase, decrease, or reset the font size on the target session's surface — three separate commands sharing one CLI subcommand. The per-session zoom is persisted. The GUI half is ⌘+ / ⌘− / ⌘0. A pane under an HTML overlay zooms the page instead: one page zoom for every HTML overlay, kept across launches and read back as zoom in htmlOverlays.

theme

App-global — no --window. The out-of-the-box default is the bundled agterm theme; a separate default ghostty entry means "no theme" — ghostty's own built-in colors.

agtermctl theme list
theme.list

Returns result.themes (the bundled names), result.theme (the current plain theme; absent means ghostty's built-in), and result.sync with result.light/result.dark. While syncing, result.theme is absent — the state rides the three sync fields.

agtermctl theme set [name] [--light NAME] [--dark NAME|none]
theme.set

Set and persist the terminal theme app-wide, per slot — the same change as Settings ▸ Appearance. A positional name (or --light, its alias) sets the light/single theme, keeping a dark theme if one is set. Omit the name for ghostty's built-in default — with a dark theme set, that clears both.

--dark NAME sets the dark theme and turns on appearance syncing: the terminal then tracks the macOS Light/Dark setting, applying the matching side automatically. --dark none clears it, stopping the tracking. The response always echoes the full state. An unknown name errors; a positional name combined with --light is a usage error. Over the socket this is the commit — there is no live preview.

keymap

agtermctl keymap reload
keymap.reload

Re-read and apply keymap.conf. Returns result.count — the number of parse diagnostics (0 is a clean reload). App-global; the same path as File ▸ Reload Keymap. See Customizing keys for the file format.

agtermctl keymap run <name> [--target T] [--window W]
keymap.run

Start one of your custom commands by its exact name, as keymap list prints it. It runs as it does from the command palette, with the target session's focused pane, primary or split and never its scratch or an overlay, supplying the working directory, the selection and the context tokens; the default target is the active session.

The reply means the command started and carries the session in result.id. The command is detached, so its exit status and output are not reported; one defined with --error-hud still shows its failure panel. An unknown name is an error.

agtermctl keymap list
keymap.list

Show the resolved keymap and the live menu key equivalents. Returns result.keymap with path, actions (every built-in with the chord it resolved to — the menu key equivalent alone, so it compares against the menu list — plus alternates, its other binds in kitty syntax, omitted when it has none, repeats: true only when its --repeat line kept a leader sequence among them, and marked when a map line moved it off its default), commands, diagnostics (line and message, not just the count), and menu — the key equivalents the menu bar carries, nested submenus included, each with its selector and marked enabled: false when the item is disabled and its chord therefore inert. The action and menu lists can disagree, which is how you tell a keymap problem from a menu one when a binding will not fire. App-global; takes no target or arguments.

Each custom command reports repeats (true only when a --repeat shortcut kept a leader sequence) and errorHud (booleans), errorPosition (canonical position, default center), and errorPane (left or right, omitted for session-wide placement). The human listing shows --repeat and enabled error options. See Key Mapping for the keymap.conf syntax that sets them.

hooks

agtermctl hooks reload
hooks.reload

Re-read and apply hooks.conf. Returns result.count — the number of parse diagnostics (0 is a clean reload). A hook whose line is unchanged keeps its running script and queue. App-global; the same path as File ▸ Reload Hooks, and a target or --window is refused. See Event hooks for the file format.

agtermctl hooks list
hooks.list

Show every hook with its live state. Returns result.hooks with path, diagnostics (line and message), and hooks — one row per line in file order, then any hook removed from the file whose script is still running, marked retired: kind, command, line, runningPid and elapsedSeconds while a script runs, pending (events waiting behind it), dropped (events the bounded queue discarded), and lastFailure, kept until the hook next succeeds (a reload keeps it). App-global; a target or --window is refused.

browser

agtermctl browser clear
browser.clear

Remove every cookie and all site data held by the saved store of --persistent URL overlays. The reply comes after the removal finished, and with nothing ever saved it answers ok. Refused while a persistent page is open, one in a just-closed session that can still be restored included, since an open page writes its login back. Clearing local data does not sign you out on the server. App-global; a target or --window is refused.

agtermctl browser links [browser|overlay]
browser.links

Set where a clicked http or https link in a terminal opens, or with no mode print the current one. browser, the default, hands it to the system browser. overlay opens it in a full session web overlay with JavaScript, navigation buttons and the saved browser store, as --url URL --browse --js --navigation --persistent would, without selecting the session. App-global; a target or --window is refused. Read back linkOpenMode at the top of tree --json.

The link still opens in the browser when it was clicked in a HUD, a program overlay or the quick terminal, when a HUD is up on the session, when the window has a zoomed terminal, when the session-wide overlay slot is taken, when another Mac presents the session, or when the saved store cannot be used. A plain http link uses the overlay only for an unqualified host name such as localhost, a .local name or an IP address; http to any other host name opens in the browser. mailto, ftp and file links are unaffected. Same setting as Settings ▸ General ▸ Open links in.

config

agtermctl config reload
config.reload

Re-read and apply the ghostty config. Returns result.count — the ghostty config-diagnostic count (0 is clean). App-global; the same path as File ▸ Reload Config.

The count spans all config sources, not just the agterm-scoped ghostty.conf — libghostty diagnostics do not record which file they came from — so do not read a non-zero count as proof that ghostty.conf is the culprit.

restore

agtermctl restore capture
restore.capture

Capture every pane's running command now, into the same slot the quit-time capture fills, and persist it. App-global; prints count, the number of panes captured.

For the exit that never reaches a clean quit: a force quit, a crash, a hard reset, a power loss. (A shutdown, restart or logout quits the app normally and captures by itself.) Run it from a scheduled job or bind it, and an exit nobody was there for restores like a deliberate quit. Consumption is unchanged, the next launch arms each captured command once and clears it. It runs only when rerun is configured for the next launch; configured fresh-shell and live modes refuse with restore.capture requires rerun mode; configured restore mode is MODE. Typed at a prompt it records ITSELF, being that pane's foreground process while it runs, so bind it or schedule it rather than running it by hand.

agtermctl restore clear
restore.clear

Clear every session's saved captured foreground command and persist, so the next restart restores plain shells for those panes. App-global; prints ok.

It does not clear a session new --command session's own command. That is the durable creation identity and still re-runs in rerun mode. This command works in fresh-shell, rerun, and live modes. Like restore capture, it acknowledges only a save that landed: if any window fails to save it returns an error saying so, and those windows keep their captured commands until a later save succeeds.

agtermctl restore mode [none|rerun|live]
restore.mode

Read the restore policy, or write it for the next launch. Bare, it reports what settings hold, what this launch requested, what it actually got, whether a restart is needed, and why live was refused when it was.

Setting it changes nothing in the running app, and no flag makes it: a pane is wrapped in a zmx daemon or not at the moment it is created, so a running shell cannot be retrofitted. A failed write is reported as a failure rather than acknowledged.

zmx

Every zmx command needs a running agterm: only the app can join its live windows, its pending closes and its persisted snapshots against what zmx reports.

agtermctl zmx list
zmx.list

Every daemon behind a live session and every pane expecting one, joined, under the restore status as a header. Shows who owns each daemon, how many clients are attached, and which are left over.

A closed window's panes are claimed with zero clients: that is the resting state after you close a window, not a leak, which is why the owner's window state is its own column. unknown means the pane inventory was incomplete, so no row can be called an orphan and nothing can be pruned.

--json adds result.zmx.endpoint.executable and result.zmx.endpoint.socketDirectory to the header — what another machine needs to reach these daemons. A server older than remote sessions omits the key. A row created before the recorded first launch with this zmx build carries outdated: true and reads outdated beside its observation; zmx reset recreates the claimed ones.

agtermctl zmx screen <name> [--all|--lines N]
zmx.screen

Print one daemon's screen as plain text. The name is the daemon name zmx list prints, not a session id, so it reaches a pane whose window is closed; session text resolves only open-window sessions.

The default is the daemon's current screen at the size its last leader gave it, with no scroll position of its own. --all adds the scrollback the daemon retains and --lines N, N positive, keeps the last N lines of that; pass one or the other. The read attaches nothing, opens no window and changes no pane's size; a name with no readable daemon is an error. The text is in result.text.

agtermctl zmx prune
zmx.prune

Kill the daemons no pane claims and nothing is attached to. Refuses outright when the pane inventory is incomplete or two panes claim one daemon.

The check is not atomic. zmx has no kill-if-detached, so this re-lists immediately before killing and drops anything that gained a client; a client attaching from outside agterm in the remaining gap can still be terminated. Each daemon is reported separately, and a stale-socket cleanup is not a kill.

agtermctl zmx kill --target ID --pane left|right --force [--window ID]
zmx.kill

Destroy one pane's daemon and the process running in it. Target, pane and force are all required.

This kills a backend process. It reaches a pane no window is showing and takes down every client attached to that daemon, so there is no sensible default for who is affected. Killing a shown split closes that split; killing a primary promotes its split survivor, or closes the session when there is none. None of these gets the three-second undo. It refuses a daemon already gone, one zmx could not read, and a session inside its undo window.

--window scopes the search to one window's claims, for a session prefix claimed in more than one. Omit it to search every window, closed and unindexed ones included. Neither it nor --target accepts active.

agtermctl zmx reset --force
zmx.reset

Reset the live sessions this app does not supervise or that predate the last Live sessions update, then quit and reopen agterm. Agterm ▸ Reset Live Sessions… without the dialog.

A live session created before the session host keeps its own macOS permission identity, so every new version of a tool in it asks for the microphone again. A live session also keeps the zmx it was created with through app updates, so after an update that changes zmx it misses the change; the reset covers every session created before the recorded first launch with this zmx build. The reset ends those sessions' processes at the next launch and recreates them under the host, starting their captured commands again where possible; other supervised sessions are left alone. agterm quits and reopens itself right after answering, so running work in the affected sessions stops, agent conversations may need to be resumed by hand, and a call from inside one of those sessions kills its own shell. Refused outside Live sessions mode, while a mode change waits for a restart, on an incomplete pane inventory, and when nothing needs resetting.

--json answers result.liveReset with sessions, panes and pending, plus outdated, the sessions that predate the last Live sessions update, when there are any. The next launch re-checks every session and only ever resets fewer than confirmed; the tree's top-level liveReset and the zmx list header report pending until the quit and last for the launch that consumed the reset.

agtermctl zmx tree [HOST]
zmx.tree

List the sessions that can be attached to. With a host it runs over ssh against a machine also running agterm and reports what it offers across every open window; with no host it reports this app's own in the same shape, which is the form the remote call runs on the far side. Feed the result to a picker and hand what the user chooses to zmx attach.

Only a session whose every pane has a live daemon is listed; one whose daemon has gone is omitted rather than offered, because attaching to a name that no longer exists would create a fresh shell wearing it. The remote must be new enough to report where its zmx and its daemons live, or the command says so and lists nothing.

The connection is non-interactive: ssh runs with BatchMode, so key-based auth must already work for the host and a password or host-key prompt is a failure rather than a question. The far side also needs agtermctl installed by the cask or the Help action, since a machine merely running agterm has no CLI an ssh command can find.

Result: remote — host (only when one was given — the ssh destination the requesting app was handed, stamped on after decoding, since the far side cannot know which name reached it), endpoint (the zmx executable and socketDirectory), presentation (the presentation protocol version, absent from an app too old to stream), and sessions, each with id (what zmx attach takes), name, windowID/windowName and workspaceID/workspaceName (show the names, group by the ids — neither is unique, so grouping by name merges two windows called main), context (only when its owner set one), cwd, splitAxis (only when it has a split), and panes ({pane, daemon, foreground}, the last being the argv that pane is running, omitted when the remote reports none).

agtermctl zmx attach HOST SESSION [--window W]
zmx.attach

Open one of the sessions zmx tree listed as a session here, marked remote. It takes the session id from that listing, because remote names are editable and repeat across workspaces. It opens in the destination window's current workspace, selected, carrying the remote session's split when it has one. Returns the new session's id; read remoteHost on its tree node. --window takes a local open window ID, unique prefix, or active. Omitted, it uses the frontmost window after discovery. An invalid or closed destination fails without creating a session. A background target leaves the frontmost window unchanged.

The remote is resolved again before anything is created, so a session gone since the listing fails and creates nothing rather than handing back a fresh shell wearing its name. Only failures found before that point are reported here; a connection that starts and later drops is an ordinary pane exit.

If ssh itself failed (exit 255), the pane keeps the last screen under a reconnecting bar and attaches again by itself once the host answers; any other exit leaves one line naming the host, the session, the pane and the exit status, and holds on Ghostty's own press-any-key prompt. agterm adds an ssh keepalive by default; the guide says when. Confirmed origin removal requires no acknowledgement, even if the replica already shows that prompt. Automatic primary removal is skipped while a local split is pending; the last replica waits for its ssh exit and may then close the row. Closing the session here ends only this side's connection: the far-side processes keep running, and nothing this command does can kill them. A remote session is never written to disk, so it does not come back after a relaunch.

Every attach also opens a presentation stream, so the row mirrors the origin session's status, its context, its notify notifications, its HUD and the layout of panes already attached; presentation on the tree node reports it. Layout updates never open a pane: close and reattach the row to include a split opened later on the origin. The mirrored status, context and HUD clear while the stream is down and return on reconnect, a notification raised meanwhile is not shown, and a terminal notification (OSC 9/777) is not mirrored because it already arrives in the pane's bytes.

One attached row per session holds the presenter role (mode reads presenter): the first whose stream asks for it while none holds it. The others mirror and ask again only when they reconnect. An ask open or session overlay open newly aimed at it on the origin goes to that Mac only when the target pane reports follower there, or every existing pane does for session-wide placement. Mixed, unknown or unowned roles stay local; one already open stays where it is when the lead changes. The caller on the origin gets the answer or the exit status as usual. An ask the presenting Mac refuses (its slot is taken, or a GUI question's target is not on screen), or whose stream drops, goes back to the origin and waits there like a local one; if the origin cannot place it, it ends cancelled with reason presentation-lost. An overlay's program runs once, on the origin, under an ssh terminal the presenting Mac opens; the origin's session stays uncovered with its slot reserved, and an overlay the presenting Mac cannot show ends launch-failed instead of opening on the origin. The presenting Mac's own session overlay result for it reports its local ssh status, so ask the origin.

When the stream drops, a --wait overlay whose program already ended closes on the presenting Mac, and a running one keeps running and closes when its ssh ends; nothing is handed back later. While the stream is not up, the row's indicator says so and names the host. It retries on its own, at once when the Mac wakes or the network changes; close and reattach the session to retry by hand.

Each pane has one leading Mac, whose window size the program sees. An attach takes the lead in every pane, and the same panes on the Mac the session runs on are covered; each pane's lead in tree reads leader, follower or unowned. A key on the cover or session lead takes it back. On the Mac the session runs on, session type, session text and surface cursor keep working on a covered pane; on the attaching Mac a covered pane refuses them until it leads again. Closing the session uncovers the far side. An origin running an older agterm reports no lead, and the attach then arrives at its window size until a typed key.

agtermctl zmx present SESSION
zmx.present

The plumbing behind a remote session's mirroring, run by agterm over ssh on the origin and not meant to be typed. It opens a presentation stream for SESSION on the local socket and bridges it to stdio as newline-delimited JSON: a snapshot of the session's status, context, HUD and layout, then each change and each notify, with a ping every 10 seconds. It is refused for a session whose panes are not all live-backed. zmx tree advertises support as presentation, the protocol version.

agtermctl session overlay run-job JOB
session.overlay.job.run

The plumbing behind an overlay shown on another Mac, run by that Mac over ssh -tt on the origin and not meant to be typed. It claims JOB on the local socket, runs its program under the ssh terminal in the working directory a local overlay would get, with agterm's session variables over the environment ssh gave it, and reports how it ended. A job can be claimed once, and only within 30 seconds of its open; a job that cannot be claimed exits 1 having launched nothing, one whose program cannot be launched exits 127, and otherwise it exits with the program's status. Losing the terminal cancels the program.

clipboard

Local-only: this group never opens the control socket, so it has no protocol command, no --target and no --json.

agtermctl clipboard set [TEXT]

Copy text to the clipboard of every terminal showing the pane the command runs in. The text goes out through the pane itself, so it reaches the Mac you are looking at: for a session attached from another Mac that is the Mac showing it, where pbcopy would fill the clipboard of the Mac the command runs on. The Mac the session runs on receives it too. It needs no terminal, so an agent's shell tool can run it.

TEXT is copied as given; without it the text is read from standard input byte for byte, a trailing newline included. Text that starts with a dash is read as an option: pipe it on standard input, or put -- before it. Empty text is refused, and so is text over about 6 MB. Run it in a main or split pane started under Live sessions: a scratch, quick or overlay terminal, and a pane started in another restore mode, is refused with this pane has no zmx daemon.

Each receiving terminal applies its own clipboard-write setting, so one set to ask prompts and one set to deny drops the copy. Exit status 0 does not confirm that the session or any terminal received the text. A copy made while the pane's program is in the middle of heavy output can land inside one of its escape sequences and garble that one sequence.

terminfo

Local-only: this group never opens the control socket, so it has no protocol command, no --json, and needs no running agterm.

agtermctl terminfo install DESTINATION [-p PORT] [-i FILE ...] [-J HOST] [-F FILE]

Install the bundled xterm-ghostty terminfo entry on a remote host. agterm's shells run with TERM=xterm-ghostty, and a host without that entry makes less, vim and other full-screen programs warn that the terminal is not fully functional. This dumps the entry with infocmp and compiles it into the remote account's ~/.terminfo with tic, over one ssh connection. Run it once per host and account; nothing is cached and ssh itself is untouched.

DESTINATION is what ssh takes: a host, user@host, or an alias from ~/.ssh/config. The connection is interactive, so a password or host-key prompt is answered on this terminal. Only the four options shown pass through to ssh; other connection settings belong in ~/.ssh/config under a host alias, because an unchecked ssh argument could print the config and claim success or never run tic at all. The settings that decide how the command itself runs are the installer's, and win over the config: no pty, stdin kept, a plain session, no fork after authentication, and no RemoteCommand.

The remote needs tic, which comes with ncurses, and says so when it is missing. Exits with ssh's status; a failure before the connection, such as no entry next to this agtermctl, is reported without opening one.

version

agtermctl version
version

Which agterm is serving this socket. App-global: no target, no --window, and no window need be open, so it works as a preflight from a keymap-launched script. Returns result.app with version and, when the build recorded one, commit — diagnostics only, never part of a version comparison. It also returns result.installed, the same two values read from the app bundle on disk at request time. The two differ once the bundle is replaced under a running app, by brew upgrade for one, and stay different until the app is restarted. It is build metadata, not a comparison of the executables: a rebuild from the same commit reads as equal. installed is omitted when the bundle cannot be read and by an older app; treat its absence as unknown, never as a match.

Human output is the version, or version (commit), then an installed: line only when the bundle on disk differs, followed by a client: line naming the resolved path of the agtermctl that ran — a diagnostic for a stale CLI ahead of the app's bundled helper on PATH. That line is human output only; --json stays the raw response. The same identity is on the tree top level as app, so an agent already reading the tree needs no second call.

Errors

Every failure comes back as {"ok": false, "error": "…"} with a non-zero exit code. An unknown command fails to decode and returns a structured error — never a crash.

notFound (target resolution) ambiguous (target resolution) no such session session not realized invalid split mode invalid scratch mode session has no split session has no scratch terminal no selection (copy) overlay already open no overlay overlay still running no overlay result pane overlay already open pane not visible (overlay or HUD open) no hud (hud update/close) no overlay result: the slot holds a hud a hud is always floating: pass --size-percent, not --full hud text must not contain control characters hud message too long (max 256 characters) hud detail too long (max 256 characters) session.hud.open requires a message session.hud.open: --size-percent must be 1...100 hud helper is not bundled in this build could not write the hud message invalid position: <value> (hud) invalid spinner: <value> (hud) invalid flag mode invalid fit invalid position invalid opacity invalid color text too long unsupported image (PNG or JPEG only) no such image file invalid background mode invalid sidebar mode invalid focus mode no open window quick terminal not open quick terminal not realized failed to read surface buffer window not open unknown theme: <name> unknown sound: <name> invalid color (expected #rrggbb) invalid shape: <value> (circle|square|triangle|diamond|capsule|star) invalid restore mode (session restore) session.restore set requires a command command must not contain control characters command too long (max 1024 bytes) the scratch terminal is never restored unknown pane id: <token> failed to save the restore override --pane must be left, right, or scratch
← agterm.com
GitHub Issues Discussions