- name
- agterm
- description
- Drive agterm, a native macOS terminal, through its agtermctl CLI and local control socket. Use when running inside an agterm session and asked to control the terminal: create, rename, close, select or reorder sessions and workspaces; split panes; toggle the scratch terminal; run a program in an overlay and read its exit status; post a HUD panel or a desktop notification; show a native picker with caller-supplied choices or a question dialog with named buttons; display an image inline; type into a session, copy its selection or search its scrollback; manage windows; change font size; set the theme; reload or edit the keymap, the event hooks and the agterm-scoped ghostty config; subscribe to status, notification, lifecycle, pane-visibility and tree-change events. Covers the window/workspace/session addressing model and the AGTERM_* environment a spawned shell sees, attaching a session running on another Mac, the cookbook recipes, the running version, and diagnosing problems or filing an agterm bug or feature request.
- when_to_use
- Trigger on: agterm, agtermctl, AGTERM_SESSION_ID, and, from inside a session, plain requests such as split the pane, close the overlay, show a message over the session, show a question dialog, agtermctl ask, show an image inline, search the scrollback, attach a session from another Mac, what recipes are there, the keymap editor will not open.
- allowed-tools
- Bash(agtermctl *)
<!-- agterm-skill -->
# Driving agterm
agterm is a native macOS terminal. It exposes a programmatic control channel over a local unix
socket, driven by the companion CLI `agtermctl`. Use it to build and steer terminal layouts, run
programs in overlays, type into sessions, notify the user in the exact session you are working in,
and subscribe to control events. Events cover status, notifications, session lifecycle, split and
scratch pane visibility, and structural tree changes; `hooks.conf` runs a shell line on any of them. They do not stream terminal output; use `session text` to read a buffer.
## Am I inside agterm?
Each shell agterm spawns gets these environment variables. Check `AGTERM_ENABLED` before assuming
the control channel is available:
- `AGTERM_ENABLED=1` — this shell runs inside agterm.
- `AGTERM_SESSION_ID` — the current session's UUID (the session this shell belongs to).
- `AGTERM_WINDOW_ID` / `AGTERM_WORKSPACE_ID` — the owning window / workspace UUIDs.
- `AGTERM_SOCKET` — the absolute path to the control socket this app bound.
- `AGTERM_PANE` / `AGTERM_PANE_ID`: the surface's spawn role (`left`|`right`|`scratch`) and stable
per-surface token. The role is not rewritten after promotion or swap; the token resolves the LIVE slot.
Prefer `--pane-id "$AGTERM_PANE_ID"` where supported, including `session status`, `session restore` and
`session text`. The agent-status hook forwards both values for compatibility.
- `TERM_PROGRAM=agterm` / `TERM_PROGRAM_VERSION` (agterm's version): the terminal identity, replacing
the `ghostty` pair embedded libghostty would set. A tool that decides a capability from a list of
terminal names (Claude Code's OSC 8 hyperlinks) needs its own override; see troubleshooting.md.
The quick terminal is scratch (not in the tree) and belongs to no window, so of the `AGTERM_*` variables
it only gets `AGTERM_ENABLED` and `AGTERM_SOCKET` (no session/workspace/window ids). An untargeted `agtermctl` run
from it therefore resolves the active window like any other caller.
These variables are inherited by every process the session's shell spawns — including long-lived
daemons that outlive the shell. A tmux/screen server, a session manager (agent-deck and the like), or
any background service started from inside a session captures the spawning session's `AGTERM_*` and
passes it to every child it ever creates, so status hooks running in those children resolve
`$AGTERM_SESSION_ID` to the session that happened to start the daemon and report to the WRONG session.
Before starting such a process from inside agterm, scrub the variables
(`env -u AGTERM_ENABLED -u AGTERM_PANE -u AGTERM_PANE_ID -u AGTERM_SESSION_ID -u AGTERM_SOCKET -u AGTERM_WINDOW_ID -u AGTERM_WORKSPACE_ID <cmd>`);
see troubleshooting.md ("agent-status glyph updates the wrong session") for diagnosing and fixing an
already-poisoned tmux server.
## Running agtermctl
`agtermctl` must be on PATH (install it from agterm's **Help ▸ Install Command Line Tool…**). If it
is not on PATH, the user can install it, or you invoke it by absolute path.
- The socket path auto-resolves; usually no `--socket` is needed. To be explicit, pass
`--socket "$AGTERM_SOCKET"`.
- `--socket` and other options go **after** the subcommand: `agtermctl tree --json`, not
`agtermctl --json tree`.
- Add `--json` to any command to get the raw JSON response (machine-readable). Without it, ordinary
mutations print `ok`, batch close/move prints the affected session count, and `tree`/`list` print a
human listing.
- Commands other than `events` make one request per invocation. `events` polls with a fresh connection
for each request. Mutating commands return the affected/new id; batch session mutations return the
number actually changed. Create commands (`session new`, `session duplicate`, `workspace new`,
`window new`) print the new id.
## The model
A **window** is the top level: a named bundle rendered in its own on-screen macOS window. Each window
holds a tree of **workspaces**, each holding **sessions**. A session has a primary shell and can also
have: a **split** pane (a second shell side by side), a **scratch** terminal (a third full-coverage
shell, toggled like the split), and an ephemeral **overlay** (runs one program on top, then vanishes).
An overlay covers the whole session, or with `--pane left|right` exactly one split pane, leaving
the sibling pane visible and usable. The same session-wide slot also holds a **HUD**
(`session hud`), a small passive panel carrying a message instead of a program. A HUD can use the
whole session or one pane as its placement bounds. The session keeps focus and stays typable
under it.
One slot, so a session shows either a HUD or a program overlay, never both. Separately, the app has one
**quick terminal** (a scratch shell in a floating panel at 90% of the focused screen capped at 1100x700,
or whatever share Settings sets instead; not part of the tree and not owned by a window).
Inspect the live tree any time with `agtermctl tree --json` (workspaces → sessions, each with
`id`, `name`, `cwd`, `splitCwd`, `title`, `active`, `split`, `overlay`, `hud`, `ask`, `scratch`, `status`, `background`, `surfaces`). `title` is the raw OSC
terminal title (e.g. a remote host over SSH), omitted when none was reported — read it when a
session's local `cwd` is stale because it's connected to a remote. `splitCwd` is the split pane's last
reported directory, falling back to its restored directory, then the primary cwd. It is present for a
shown or hidden split and omitted without one or on older servers. `surfaces[].id` is the
control address for `surface zoom` and `surface cursor` (`left`, `right`, `scratch`, `overlay`,
`overlay-left`, or `overlay-right`), including hidden-but-alive split/scratch surfaces. The tree object also carries
read-only top-level fields — `idleMs` (ms since the last user input in the window), `autoFollowMs`
(the Auto-follow timeout in ms, omitted when Disabled), `sidebarVisible` (whether the window's
sidebar is currently shown — the read side of the write-only `sidebar` command), `sidebarMode`
(`tree` or `flagged` — the read side of `sidebar mode`), `sidebarWidth` (the sidebar divider position in
points — the read side of `sidebar width`, on `tree` only), `workspaceFilter`, `quickVisible` (whether the
quick terminal is shown — the read side of the write-only `quick` command; app-level, so every window
reports the same value), `zoomedSurface`, the four `dashboard*` fields, `pickPending`, `askPending` (GUI asks only), and `app` (the
serving app's `version`, plus `commit` when the build recorded one — the same value `agtermctl version`
returns). reference.md lists every one with its exact shape. List windows with
`agtermctl window list --json`; each window also reports `autoFollowMs`, `sidebarVisible`, `geometry`
(the live frame `{x, y, width, height, display}` in the units `window move`/`window resize` take — the
read side, so record it then restore the exact frame), and `fullscreen`/`zoomed`/`minimized` (the read side
of `window fullscreen`/`window zoom`/`window minimize`, so a script can act idempotently) — all omitted for
a closed window, but not the live `idleMs`, which is `tree`-only. A MINIMIZED window still reports its
`geometry` (the frame it comes back to), so a re-align script can include it.
## Addressing
Commands that target a session or workspace take `--target` (default `active`):
- `active` — the selected session / current workspace.
- a full UUID (case-insensitive), or a unique **prefix** of one (git-style). Zero matches → `notFound`
error; two or more → `ambiguous` error listing candidates.
`window.*` commands take the window id/prefix/`active` as a positional argument. Other commands accept
a global `--window <id|prefix|active>` to operate on a specific window's tree (default: the frontmost).
Scripts rarely type ids: create with `*.new` (capture the returned id), or act on `active`.
**Agents: `active` is almost never your own session.** `active` is the session the USER has selected in
the GUI; your shell runs in `$AGTERM_SESSION_ID`, and the user is usually on a different session while
you work. For any session-scoped command meant to act on *this* session — `session overlay open`,
`session scratch`, `session type`, `session text`, `session background`, `session status`, `session copy`,
… — pass `--target "$AGTERM_SESSION_ID"`. Omit it and
you open overlays / type into whatever the user has selected, not your own session.
## Restore modes
**Settings ▸ General ▸ Restore sessions** is global and takes effect after restarting agterm:
- **Fresh shells** restores the saved windows, workspaces, sessions, directories, and split layout with new shells.
- **Re-run commands** starts each captured foreground command again. It does not reconnect to the old process.
- **Live sessions** runs every primary and split pane through zmx and reattaches to the same process. It requires
zsh as the macOS login shell. Scratch, overlay, and quick terminals stay temporary.
On a clean quit, agterm leaves live daemons running and captures each open pane's foreground command as a
fallback. A surviving daemon ignores that payload on the next launch. If an orderly machine restart removed
the daemon, zmx creates it with the captured command and the pane remains live. A pane starts a fresh shell
instead when its window was closed before quit, a hard power loss or force quit skipped capture, or the
command is denylisted or carries a control character. SIGTERM leaves live daemons running but skips capture.
`tree --json` is the only backing indicator: primary and split entries report `surfaces[].backedByZmx`, and
the session-level `backedByZmx` is true only when every existing primary or split is backed. The sidebar has
no zmx glyph.
Switching to Fresh shells or Re-run commands and restarting ends every detached live process in the state
directory. A launch that still requests Live sessions but cannot use it preserves those processes.
Reattach keeps usable text, TUI state, and normal colors. It does not retain inline images, earlier OSC 133
prompt markers, program-changed palette entries, or hyperlink metadata already attached to cells. New output
after reattach behaves normally.
## Launching a program in a session
**Bind it at creation.** `session new --command` (and `scratch --command`) makes the program the session
process, so no shell line is involved:
```bash
agtermctl session new --cwd ~/proj --name worker \
--command "zsh -lc 'claude \"\$(cat ~/brief.md)\"'" # GUI PATH: wrap a non-default binary
```
In Fresh shells and Re-run commands modes, the session closes when this command exits unless `--wait` holds
the final output. In Live sessions mode the command is a create-only zmx payload, which bypasses the 1,024-byte
PTY input cap. A surviving daemon ignores the payload; a new daemon runs it, then starts the persistent shell.
The shell stays open after it exits and `--wait` adds no hold prompt. After a clean quit, a missing daemon
replays the captured running command inside a new persistent shell. The exclusions above start a fresh shell.
`session type` drives an ALREADY-RUNNING program — it is not a launcher. Its keystrokes land in a line
buffer you do not own: a newline submits (a multi-line brief becomes N premature Enters), and the user
or a concurrent agent writes to that same buffer. An untargeted `session type` from another agent hits
whatever is `active`, and `session new` focuses — so a just-created session is briefly `active`, a stray
prompt concatenates with yours, and the program starts on the merged line. (`--no-select` skips the
focus, but the newline and shared-buffer hazards of `type`-as-launcher remain — `--command` is still the
rule.) After `--command`, confirm in `tree --json` that the new node's `foreground` shows your program running, not a bare shell prompt.
## Command summary
Run `agtermctl <area> <cmd> --help` for exact flags. Full detail in **reference.md**; worked
examples in **examples.md**; installable community workflows in **cookbook.md**.
**tree** — print the workspace/session tree (`--json` for structured). Each session node carries
`foreground`/`splitForeground` (the live argv of each pane's foreground process, omitted when the pane
is at its shell prompt, or running a setuid/setgid program like `top` or `sudo` whose argv macOS won't
expose) — i.e. what each pane is currently running — `foregroundShell`/`splitForegroundShell` (the shell
holding each pane's foreground as a basename, present exactly when that pane's `foreground` is omitted
because a shell holds it, so an EXISTING pane with neither is one whose process could not be read; check
`hasSplit` before reading the split pair. Not a claim the pane is at a prompt and never permission to type —
a builtin like `read` runs inside the shell), `restoreCommand`/`splitRestoreCommand` (each pane's
persisted restore-command override set via `session restore` — the read side: omitted = auto-capture, `""`
= pinned to nothing (a plain shell), a command = the shell line that runs on the next launch), `status` (the agent-status set
via `session status`: `active`|`completed`|`blocked`, omitted when idle), `statusPane` (which pane set
that status: `left` (main) | `right` (split) | `scratch`, from `session status --pane`, omitted when
unset or idle), `statusBlink`/`statusColor`/`statusShape` (the status glyph's `--blink` flag, its `--color`
`#rrggbb` tint and its `--shape` silhouette from `session status`, omitted when idle / not blinking / using
the configured color or shape — the tint and the silhouette report the per-call override only),
`statusChangedAt` (when that status was last set, in epoch seconds — the same clock as an event's `ts`;
omitted when idle, and refreshed by a re-push of the SAME status, so `now - statusChangedAt` is how long
ago the status was last written — normally the agent's own push, though a pane promotion re-tags the
indicator and counts too; ephemeral, so it does not survive a restart), `background` (the background
spec — image/text watermark or solid color — set via `session background`, omitted when none — the read side of set/clear),
`unseen` (the unseen-notification badge count — raised by `notify`/OSC 9/777, cleared by `session
seen`; omitted when zero), `commandWait`/`splitCommandWait` (whether either pane's `--command` was
created with `--wait` to hold open after exit, the read side of `session new --wait`; each omitted for a
plain or non-holding pane), `overlaySizePercent` (an open overlay's floating-panel percent 1-100,
omitted for a full-pane overlay or no overlay so gate on `overlay` first; the read side of `session
overlay resize` for a record-then-restore zoom), `paneOverlays` (the panes covered by their own overlay —
`["left"]`, `["right"]` or `["left","right"]`, omitted when neither is; the read side of `session overlay
open --pane`, independent of the session-wide `overlay` flag),
`hud` (the message panel occupying the session-wide slot — `{message, detail?, spinner, backgroundColor?,
textColor?, sizePercent?, heightPercent?, position, pane?}`, the two percents being the panel's width and height
shares — omitted when none is up; the read side of `session hud`. `position` and `spinner`
always report the EFFECTIVE value, `center` and a static panel's `none` included, so a caller who omitted
them never has to know the defaults; `spinner` names the STYLE, so `none` is what a caller echoes back to
turn one off. While a HUD is up the node's `overlay` reads `false` and `overlaySizePercent` is omitted, so a
poll for "is a program covering this session" cannot mistake a message for one; HUD state is poll-only,
no event announces it),
`realized` (whether the session's MAIN pane has a live terminal; `false` means no shell was spawned.
`session text` then answers `session not realized` without realizing anything; `session type` brings up a
restored main pane still waiting its turn in a launch that replays commands, while any other unrealized
cause can still exhaust its poll and fail the same way. `session new` returns `ok` for a model
entry, which is weaker — libghostty will not create a surface while the display is asleep, so a session
created by a scheduled job overnight stays unrealized until the displays wake and then recovers itself.
Poll this after an unattended create),
`backedByZmx` (true only when every existing primary/split pane is currently zmx-backed; primary/split
entries in `surfaces` report their own Boolean, while scratch and overlays omit it),
`liveAttribution` and `splitLiveAttribution` (local Live pane attribution, including hidden splits;
[values and omission rules](reference.md#tree)),
`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),
`hasSplit` (whether a second pane exists at all, shown or hidden; omitted when there is none — read this
rather than `split`, which is false for a split hidden with ⌘D even though its pane is still alive),
`splitAxis` (`vertical` for left/right or `horizontal` for top/bottom; omitted without a split),
`splitRatio` (the primary-pane divider fraction 0.05-0.95 of the area below the titlebar, of a
session that has a split — shown or hidden; omitted when there's no split, or while the split has never
been shown — a shown split always reports a value, 0.5 when nothing set one) —
the read side of `session resize`, record it to restore the exact divider), `splitFocused`
(which pane holds focus in a session that has a split: `true` = split/right/bottom, `false` = primary/left/top; omitted
when there's no split; the read side of `session focus`, record it to restore focus), and `surfaces`
(`id`, `kind`, `active`, `visible`, and `backedByZmx` on primary/split entries) for `surface zoom` and
`surface cursor`. The tree top level carries `zoomedSurface`
(the control id of the currently zoomed surface, omitted when nothing is zoomed — the read side of
`surface zoom`, so a script can check the zoom state and record-then-restore). It also carries the read
side of the `dashboard` command (all omitted when no dashboard is open): `dashboardMembers` (the pane refs
the open dashboard shows, in grid order — `<session-id>:left` for a primary pane, `<session-id>:right` for
a split pane, so a split session appears as both), `dashboardHighlighted` (the highlighted cell's pane ref —
the one Enter jumps into, focusing that exact pane), `dashboardFontSize` (the absolute font size in points
applied to the cells, omitted when untouched), and `dashboardFontMode` (`auto`|`fixed`|`untouched`).
The top level also carries `pickPending`, the id of the native picker currently awaiting an answer in
that window, omitted when no pick is pending.
**events**: continuously print control events, subscribing from the current tail when no cursor is
given. Use `--json` for one bare event object per line; filter with repeatable or comma-separated
`--kind` over `status`, `notify`, `session.created`, `session.closed`, `tree.changed`, `pane.split`,
`pane.scratch`, `remote.opened` and `remote.closed`; resume with paired `--run RUN --after SEQ`; and set
page size with `--limit 1...1000`. The app retains 4,096 events for one process run. Cursor run changes,
expiry, and ahead-of-tail errors are fatal and are never silently rebaselined. There is no
terminal-output event stream.
**workspace** — `workspace new [name] [--collapsed]` (`--collapsed` creates it closed in the sidebar so you can fill
it with `session new --no-select` without it opening, and keeps it out of the focus set; a plain create
joins the marked set while the filter is applied, so it is visible) · `workspace rename <name>` ·
`workspace delete` · `workspace select` ·
`workspace go --to next|prev` (step the CURRENT workspace one place through the sidebar's visible order, wrapping,
and select the first session of the one it lands on — relative, so no `--target`, and unaffected by
whether a workspace is collapsed; `workspace move` REORDERS instead) ·
`workspace move --to up|down|top|bottom` ·
`workspace focus [on|off|toggle|add]` (mark ONE workspace in the sidebar's focus set — `on` marks it alone and
applies the filter, `off` unmarks it, `toggle` (default) replace-toggles, and `add` marks it alongside
the others WITHOUT switching the filter on; read membership back from the tree workspace node's
`focused` flag) ·
`workspace filter [on|off|toggle]` (apply or suspend that filter for the whole window WITHOUT losing the marked
set — no `--target`; read it back from the tree top-level `workspaceFilter`. Build a working set with
Ver no GitHub