rook.app · Documentation · Command reference
Dozens of sessions in named workspaces, every agent's status on its row, full control from the CLI.
Rook is a native macOS terminal built for running a flock of AI coding agents at once. A rook is the bird that solves problems on its own — and the tower that houses the whole flock; the app is both. Rather than scattering shells across tabs, it organizes them into named workspaces, each holding the sessions for one project or context, so several agent-driven sessions run side by side and you move between them without losing track of which is which. Running several coding agents at once means many long-lived sessions, each progressing on its own, and a tabbed terminal loses them quickly. Rook keeps them organized and makes it obvious which session needs you. None of this is limited to agents — it is also a capable general-purpose terminal for everyday multi-project work.
The design is deliberately minimal: it covers the use cases above and stops there. Features come in two kinds. One is just enough to get the work done. The other is the small set of things other terminals get wrong, done the way they should have been. There is no deep agent integration and no attempt to invent a new way of working with agents. You get a sensible minimum out of the box, plus a complete control API and CLI on top. Almost everything is scriptable, so anything past the defaults you build yourself instead of waiting for it to ship.
Rook is a fork of agterm by Umputun (MIT) — see Attribution.
What it does:
- Workspaces. Sessions are grouped under named workspaces like "work" and "personal", which keeps a screen of concurrent sessions organized. You reach a session by name, by recency, or from the keyboard.
- Control API and CLI. A bundled tool,
rookctl, drives almost everything over a local socket: create sessions, type into them, run a program in an overlay and read its exit status, move and resize windows, or post a notification tied to a specific session. A script or an agent can set up and drive its own layout, and send you a notification from the session it was working in. - Splits, scratch, and overlays. Split a session into two shells, open a scratch terminal over it, or run a program in a full or floating overlay without disturbing the shell underneath.
- Agent skill. An installable skill (Help ▸ Install Agent Skill…) teaches Claude Code or Codex the control model and the
rookctlcommands, so an agent running inside Rook can build its own layout, run overlays, manage windows, and show images inline without you explaining the API. - Agent status. A coding agent reports its state (active, blocked, or completed) onto its session's row, so you can see which of many running agents needs you. Status hooks for Claude Code, Codex, and other agents install from Help ▸ Install Agent Status Hooks….
For the real terminal work, rendering, VT parsing, and shell I/O, Rook embeds Ghostty's engine (libghostty); everything above it is the app's own code.
More screenshots
An agent's interactive prompt mid-session, with attention glyphs on the sessions that need you:
The attention list, collecting every session that needs you, sorted blocked then active then completed:
A split session (agent and shell side by side) with the action palette open:
A full-screen diff TUI running inside a session:
A file manager in a floating overlay over the active session:
The fuzzy session palette for jumping to any session by name:
A session's right-click context menu:
The keymap editor:
A split session, two panes side by side on different color themes:
A file open in the quick terminal, the window's shared scratch overlay:
Pre-built releases are for Apple Silicon (arm64) Macs running macOS 14 or later.
Releases are signed with a Developer ID certificate and notarized by Apple, so macOS Gatekeeper opens them with no extra steps.
Homebrew:
brew install --cask jokius/apps/rookThe cask also installs the rookctl command-line tool, so cask users should not run the in-app installer as well.
Note
Homebrew upgrade note (July 2026). A recent Homebrew change (installed-cask metadata stored as JSON) can make brew upgrade fail for Rook with It seems there is already an App at '/Applications/rook.app'. It affects third-party tap casks in general, not only rook. Recover with a one-time reinstall, which rewrites the install receipt:
brew reinstall --cask --force RookRegular brew upgrade works afterward. This is an upstream Homebrew issue, and the note will be removed once it is fixed.
Direct download:
Download the latest .dmg from the releases page, open it, and drag rook.app into /Applications.
The app's Help menu has three one-time installers. None are needed to use Rook as a terminal; each connects it to a wider workflow, and you can run any of them later.
- Install Command Line Tool… puts the bundled
rookctlon yourPATH(a symlink in/usr/local/bin) so you can script the app from a shell. The Homebrew cask already installs it, so cask users can skip this one. See Scripting Rook. - Install Agent Status Hooks… lets a coding agent (Claude Code, Codex, or others) report its state onto its session's sidebar row, so you can tell at a glance which of several running agents is active, blocked, or finished. See Agent status.
- Install Agent Skill… teaches Claude Code or Codex how to drive Rook through
rookctl, so an agent running inside a session can build its own layout, run overlays, and manage windows without you explaining the API. It drives the app through the command-line tool, so install that one too.
Build steps
Requirements:
- macOS 14 or later.
- Xcode 26 with
xcodegenonPATH, plus its Metal Toolchain (auto-downloaded on first setup). - Homebrew, for the
zig@0.15formulascripts/setup.shbuilds libghostty with.
scripts/setup.sh # build libghostty from ghostty source + stage resources (idempotent; first run takes a few min)
scripts/run.sh # setup, generate the Xcode project, build Debug, launchA Makefile wraps these as a convenience front door: make run (build Debug + launch), make build (Debug, no launch), make release (Release build), make deploy (Release build + copy to /Applications), make test, and make dist VERSION=x.y.z (release DMG — signed + notarized when a Developer ID cert is present, otherwise ad-hoc). Run make with no target to list them.
scripts/build.sh produces a Release build without launching. The unit tests run independently of Xcode and libghostty:
cd rookCore && swift testscripts/test.sh is a wrapper for the same command. UI behavior (rename, close, move, drag, add-session) is covered by XCUITests in rookUITests/ that drive the running app through the accessibility API:
xcodebuild test -project rook.xcodeproj -scheme Rook -destination 'platform=macOS'Rook arranges terminals into a small hierarchy. These are the only terms you need; the sidebar, menus, and shortcuts all map onto them.
Session. A session is one running shell with a name, a working directory, and its own scrollback. It is the unit you work in and the row you see in the sidebar. A new session takes its name from the basename of its directory; rename it to pin a custom name, clear the name to go back to the basename. New sessions open in your home directory by default, or in the current session's directory, or in a fixed folder (set in Settings). A session runs until you close it or its shell exits, and it comes back on the next launch with its directory, font size, and split state restored.
Panes. A session can split into two shells side by side. Both panes are part of the same session and share one sidebar row; a split is one session with two terminals, not two sessions. One pane is focused at a time, and the divider position is remembered.
Scratch terminal. Every session has an extra shell, the scratch terminal, that you toggle on over the session and hide again without killing it. It opens in the session's directory and is for a quick aside next to your main work. It belongs to that one session and is not restored across launches.
Quick terminal. The quick terminal is a single throwaway shell per window, not tied to any session. It drops over whatever session is active, for a command unrelated to what you are working on, and hiding it keeps the shell alive. It is not restored across launches.
Overlay. An overlay runs one program in a temporary terminal over a session and disappears when the program exits, leaving the session as it was. It is mostly driven from the control API to launch an interactive program (a diff viewer, a process monitor) over a session without replacing its shell. See Scripting Rook.
Terminal zoom. Zoom fills the whole window with one terminal surface — a pane, the scratch, an overlay, or the quick terminal — hiding the sidebar and collapsing the title bar to a slim strip that keeps the traffic lights and an exit button. Cmd+Shift+Return toggles it on the active surface (rebindable as toggle_terminal_zoom; the exit button, ⌘W, and View ▸ Toggle Terminal Zoom all leave it). It is a view mode, not a layout change: entering closes transient chrome (an open palette or search), and exiting restores split ratios, focus, and visibility exactly as they were. Everything else keeps running behind the zoomed surface, and a script can zoom any surface by id with rookctl surface zoom. Distinct from macOS window zoom and full screen, which size the window itself.
Dashboard. For watching several agents or builds at once, the dashboard shows sessions' live output side by side in a grid (laid out ceil(sqrt(n))), overlaid on the window. The cell unit is a session+pane: a non-split session is one cell, and a split session shows as two cells — its left/primary and right/split panes. It is view-only — no cell takes input; the keyboard navigates a highlight between cells with the arrow keys, Enter jumps into the highlighted session and focuses that exact pane (and closes the grid), and Esc closes it. It is opened over the control channel with rookctl dashboard <ids…> — or with rookctl dashboard --mru to fill the grid from the window's most-recently-used sessions instead of naming ids — and closed with --close (or Enter/Esc). The most-recently-used grid also has a built-in opener: ⌘⇧D (or Navigate ▸ Dashboard, or the command palette's Dashboard) toggles it, auto-sized, so the recent-sessions view is one keystroke away without a script. Cell fonts can be sized absolutely with --font-size or scaled to the grid with --auto-size; the nine-cell cap counts panes, so a set whose panes exceed nine is capped with the drop reported, and --window picks a window (default frontmost). The dashboard and terminal zoom are mutually exclusive. While it is open the title bar strips down to an exit button, so nothing behind the grid can steal its keyboard.
Markdown preview. Cmd-click a Markdown file in the terminal — a plan.md an agent just wrote, a README.md a build printed — and it opens rendered in a preview panel on the right of the session, instead of bouncing you to Finder or an editor. Rook renders it itself (headings, lists, code blocks, quotes, links, GFM tables), so nothing is launched, and the panel watches the file: an agent rewriting the plan re-renders it live. The panel is per session, its width is remembered per window, and the open file survives a restart. Links inside the document work by the same rules — a [plan](./plan.md) cross-link re-targets the panel, a web link opens in the browser. Close it with the ✕ in the header or View ▸ Close Markdown Preview (there is no "open" menu item — a preview needs a file, so it starts from a click or from rookctl session markdown open <path>). Images, syntax highlighting inside code blocks, and task-list checkboxes are not rendered.
Workspace. A workspace is a named group of sessions for one project or context, for example "work" or "personal". Sessions belong to a workspace and can move between workspaces while still running, keeping their shell and scrollback. There is always at least one workspace.
Window. A window is a whole set of workspaces and sessions in its own on-screen macOS window, with its own sidebar. Each window has its own sessions, so "work" and "personal" can run as two separate windows at once, each with its own tree. You keep a library of windows and open one per on-screen window; the windows open at quit reopen on the next launch with their frames.
Flagging and focus. Two ways to cut down a busy sidebar. Flag a few sessions from different workspaces to get a flat working-set view of just those; a flag is durable and survives a move. Focus marks a set of workspaces and hides every row outside it — one workspace to zoom in on a single project, or a handful to watch several at once. The two are independent.
Marking and filtering are separate steps, because a set has to be built before it is worth applying.
Focus on a workspace row (or View ▸ Focus Workspace) replaces the whole set with that one workspace and applies the filter at once — the everyday zoom-to-one.
Add to Focus (the row's context menu, View ▸ Add Workspace to Focus, the action palette) marks a workspace alongside whatever else is marked and deliberately does not switch the filter on: an add that filtered would collapse the tree onto the first member and hide the very rows the next add needs.
So you mark two or three rows with the whole tree still in front of you, then apply them in one flip.
A marked row draws its icon in a heavier weight so the set is legible while you build it — heavier rather than filled, because a filled icon already means a flagged session (a workspace whose custom icon is an emoji or an image file has no weight to vary, so it shows no marker; its membership is still on its context menu and in rookctl tree).
The grid button in the sidebar footer is that flip: it applies or lifts the filter without touching the set, and its glyph fills while the filter is on, so it is the indicator as much as the control.
It is disabled with nothing marked, and it is the only affordance that still works while the filter is off — every other one acts on a workspace row, and with the filter off there is no focused row to act on.
View ▸ Toggle Workspace Filter is the same thing from the menu (keyless by default, bindable as toggle_workspace_filter), and Clear Focus drops the whole set rather than merely suspending it.
A focus survives an involuntary jump.
Landing outside the set because something pulled you there — auto-follow to a blocked session, attention navigation, clicking a notification, the dashboard, the recent-sessions popover — lifts the filter so you can see where you are, but the set stays marked, and the footer button (or rookctl workspace filter on) puts it back.
Only an explicit unmark — Remove from Focus on the row, Clear Focus, rookctl workspace focus off — forgets a workspace, because that is you saying you are done with it.
Over the control channel the state reads back as three fields, and a script wanting to know what is on screen needs all three.
Each workspace node carries marked (is this workspace in the set, filter applying or not) and focused (is the tree filtered down to a set this workspace is in, right now); the tree carries a top-level workspaceFilter (is the filter applying at all).
focused keeps its old meaning exactly — it is marked && workspaceFilter — so a script written against it before the set existed still reads true.
A workspace row is on screen when sidebarVisible && sidebarMode == "tree" && (!workspaceFilter || marked), and neither shorter form is safe: focused alone reports nothing visible whenever the filter is off (when in fact the whole tree is), and marked alone ignores a hidden sidebar and the flat flagged view.
Sidebar session rows support Shift-click range selection and Cmd-click toggling for batch work. Right-clicking inside a multi-selection keeps the batch for Flag/Unflag, Close, and Move to; right-clicking outside narrows to the clicked row. Dragging from a selected row moves the selected sessions as one ordered block.
Finder integration. In the tree view, drag folders from Finder onto a workspace or session row to open one session per folder there; drop on empty sidebar space to use the focused/current workspace. Collapsed workspaces spring open while you hover and close again if you cancel. Dropping more than 20 folders at once is rejected. Reveal in Finder in the session context menu or main menu selects the focused pane's current directory (and is disabled if that directory no longer exists). Folder-picking panels also start in the focused pane's directory when it is available.
Pasting an image. ⌘V with an image on the clipboard — a ⌘⇧4 screenshot, Copy Image from a browser, a selection copied out of Preview — writes it to a temporary PNG and pastes that file's path into the terminal, as does dropping an image straight onto the terminal. A pty carries no pictures, so the path is the image: a coding agent reads the file and attaches it, which is what makes "here is a screenshot of the bug" work without leaving the terminal. Copying an image file (in Finder) still pastes the file's own path, unchanged. The temporary PNGs live in the system temp directory and macOS reaps them.
Notifications. A program in any session can raise a desktop notification (via OSC 9 / 777, or the control API). It shows as a banner and a count badge on the session's row; clicking the banner jumps to the exact pane that raised it. The badge clears when you visit the session, or headlessly with rookctl session seen — so an orchestrator driving a session over the socket can acknowledge its notifications without pulling focus to it (rookctl tree --json reports each session's unseen count). For a coding agent that just needs to say it is waiting on you, Agent status is usually the better fit.
Agent status. A coding agent in a session can report its state (active, blocked, completed) onto that session's row, so a screen of concurrent agents shows which one needs you. See Agent status for wiring it up.
Rook is built to run from the keyboard. Every action has a shortcut and appears in the menus, and three fuzzy palettes cover the rest (type to filter, Enter to run, Esc to dismiss):
- the session switcher (Ctrl-P) jumps to any open session by name or working directory;
- the action palette (Ctrl-Shift-P) runs any command by name (new, rename, close, split, toggle scratch, move a session, change font size, and so on);
- the custom-commands palette (Ctrl-Shift-O) lists the shell commands you define in
keymap.conf.
For jumping back to sessions you have been working in, a Ctrl-Tab switcher walks a most-recently-used list across every workspace, macOS app-switcher style: hold Ctrl and tap Tab to move through it, release to switch, and a single tap flips straight back to the session you were just in. The list survives a relaunch, so the switcher works right after your sessions restore. Shortcuts also step between adjacent sessions, panes, and windows. The same recently-used history decides where you land when you close the session you are in: rook returns you to the session you were most recently working in, not to whichever row happens to sit next to the one that closed. The pick stays inside the closing session's workspace (in the flagged view, within the flagged set), widening only when the close leaves nothing there; if no recent session qualifies it falls back to the adjacent row. The switcher also has a mouse form: a clock button in the title bar opens a popover of the window's recently-used sessions (the current one excluded), and clicking a row switches to it — the same recency, without the keyboard.
Settings (Cmd+,) has six tabs. General covers mouse scroll speed and right-click-to-paste, where a new session opens, an opt-in toggle to re-run each pane's foreground command on restart, an opt-in Resume agent conversations toggle that rides on it (a restored Claude Code or Codex pane comes back on the conversation it was on, not a blank one — see Resuming agent conversations), an opt-in confirmation before closing a session, and whether to load your global Ghostty config. Appearance sets the terminal font and theme (512 bundled themes), the toolbar mode, the window background opacity and blur, the sidebar tint, the sidebar font size, and how much the inactive split pane dims; a "Follow system appearance" toggle (off by default) reveals a second picker for the other appearance, so the theme tracks macOS light/dark mode live. The toolbar has three modes: Normal shows the title with the working directory beneath it, Compact (the default) is a single title row, and Hidden drops the whole titlebar row and the window's traffic-light buttons for a full-bleed terminal with no chrome — an invisible strip along the top edge still moves the window and double-click-zooms it, and you close, minimize, or zoom the window from the keyboard or the Window menu. Interface hides or shows individual title-bar and sidebar-footer chrome elements — the sidebar toggle, the session and window name, the scratch/split/quick-terminal buttons, the recent-sessions clock, and the sidebar footer's new-workspace, new-session, workspace-filter, flagged-view, and workspace add-session controls — all shown by default, so you can pare the chrome down to just what you use. Notifications toggles the banner, the unseen-count badge, and the title-bar attention indicator, plus an optional notification sound (default None, attached to the banner so it follows the banner toggle and Do Not Disturb) and an opt-in dock bounce (None / Once / Until focused) for a background notification. Agent Status sets each status glyph's color and its shape (Default keeps the built-in semantic symbol; the alternatives are a plain circle, square, triangle, diamond, capsule, or star), whether a blocked or completed session washes its whole sidebar row in that color, the blocked-session sound, and an idle timeout to auto-follow blocked sessions. Key Mapping points at the directory holding keymap.conf, lists any parse errors, and reloads it. Changes apply live to the open terminals.
The theme picker (View ▸ Select Theme…, or the action palette) previews each bundled theme on the open terminals as you move through the list, so you see it before committing. Enter commits and syncs it to Settings; Esc reverts to the one you started on. While following the system appearance, the picker edits the theme for the appearance you are in; the control channel drives both slots with rookctl theme set --light NAME --dark NAME (or either flag alone).
Accessibility. Rook honors two macOS System Settings ▸ Accessibility ▸ Display switches. Reduce Motion stops the agent-status glyph's pulse in the sidebar — the glyph and its color stay, so a --blink status still stands out, it just no longer repeats. Reduce Transparency draws the window opaque and unblurred, and puts the command palette and the Ctrl-Tab switcher on a solid window background instead of a translucent material. Both are effective overrides, not settings edits: your saved opacity, blur, and materials are left untouched and come back verbatim the moment you turn the system switch off — the Appearance tab keeps its sliders live and says so. Flipping either switch applies immediately, with no relaunch.
rook can be driven from a script over a local unix-domain socket through a companion CLI, rookctl. This is for personal scripting — fire-and-forget commands that manage workspaces and sessions, inject text, and invoke control actions, plus a read-only event feed you poll for what the app just did (see Watching for events). Everything stays request/response: nothing is pushed to you, and there is no terminal-output streaming — to read a terminal's buffer, ask for it with session text.
The sections below cover the common cases. All 74 commands, with every argument, return value, and error, are documented in the Command reference. (That count is the scriptable set — it excludes debug.appearance, a UI-test-only command with no rookctl subcommand.)
The app bundles rookctl inside rook.app. The easiest way to put it on your PATH is Help ▸ Install Command Line Tool…, which symlinks the bundled binary into /usr/local/bin (the first entry in macOS's default PATH). When that directory is user-writable it installs silently; otherwise it asks once for an administrator password.
To let a coding agent drive Rook without you explaining the API, install the bundled agent skill with Help ▸ Install Agent Skill…. Claude Code and Codex share the same skill format, so it installs to whichever you have, ~/.claude/skills/rook/ and/or ~/.codex/skills/rook/. The skill teaches the agent the control model and the full rookctl command set, so an agent running inside Rook can create sessions, run overlays, manage windows, and reload the keymap on its own. It drives the app through rookctl, so install the CLI too.
rookctl also lives in the rookCore Swift package and builds standalone without Xcode or libghostty:
cd rookCore && swift build -c release
# the binary is at rookCore/.build/release/rookctlEach command targets a session or workspace by its UUID, a unique prefix of that UUID (git-style), or the keyword active (the selected session / current workspace). --target defaults to active, so the current one rarely needs to be named. Mutating commands normally print the affected id; batch session close and session move accept repeated --target options and print the number of sessions actually changed. tree prints the workspace and session tree. Add --json for the raw response, or --socket PATH to override the socket path. The exit code is zero on success, non-zero on error.
--workspace/--target take an id, a unique id prefix, or active — never a name. (session new also accepts --workspace-name <name> to target a workspace by its sidebar label, plus --create-workspace to make it when none matches — the two are mutually exclusive with --workspace.) To create a workspace and then open a session in it, capture the printed id:
rookctl tree # print the workspace/session tree with ids
ws=$(rookctl workspace new work) # create a workspace, capture its id
rookctl session new --workspace "$ws" --cwd ~/src/rook # open a session in it, print its id
rookctl session new --command "ssh user@host" # run a command as the session's process (like kitty launch; no typed command, closes on exit)
rookctl session new --command "sh -c 'clear; ssh user@host'" # --command is argv-style (no shell); wrap in sh -c for ;, $VAR, redirects
rookctl session new --name "myhost" --command "ssh user@host" # pre-name the session (sidebar label set at creation)
rookctl session new --workspace-name servers --create-workspace --name "myhost" # open in the "servers" workspace, creating it if absent (idempotent)
rookctl session new --after active # create right after the current session (--before to precede it); the anchor's workspace is used
rookctl session new --cwd ~/src/rook --no-select # create in the background: appended, but the current session stays selected and focused
rookctl session new --command "zsh -lc 'make test'" --wait # hold the session open on a press-any-key prompt after the command exits (needs --command)
rookctl session duplicate # duplicate the current session into a fresh shell in its directory, right after it
rookctl session type --target 9f3c $'make test\n' # inject text into a session by id prefix
echo 'make test' | rookctl session type --target active --stdin
rookctl session type --target 9f3c --target a1b2 $'go\n' # broadcast the same line into several sessions at once
rookctl session type --flagged $'go\n' # broadcast into every flagged session of the window
rookctl session go --to next # step to the next session (next|prev|first|last; stops at ends)
rookctl session move --to up # reorder the active session within its workspace (up|down|top|bottom)
open -a rook ~/src/rook # (outside Rook, or Finder ▸ Open With ▸ Rook on a folder) add a session there to the last-active window — works while Rook is running
rookctl session move "$ws" # relocate the active session to another workspace (appends)
rookctl session move --after 9f3c # place the active session right after another (--before to precede it); relocates cross-workspace if the anchor lives elsewhere
rookctl session move "$ws" --target 9f3c --target abcd # move a batch as one ordered block; --after/--before also accept repeated --target
rookctl session close --target 9f3c --target abcd # close a batch with one grace-period undo
rookctl workspace move --to top # reorder a workspace among its siblings (up|down|top|bottom)
rookctl workspace collapse # fold one workspace shut in the sidebar tree (expand reopens it; reads back as collapsed on tree --json)
rookctl workspace new staging --collapsed # create it already folded, so filling it with session new --no-select doesn't pop it open
rookctl session split toggle # split the active session
rookctl session resize --split-ratio 0.7 # set the split divider (left-pane fraction); or --grow-left/--grow-right D
rookctl session scratch toggle # show/hide the active session's scratch terminal (on|off|toggle)
rookctl session filetree toggle # show/hide the active session's file-tree panel (on|off|toggle|refresh|reroot <path>)
rookctl session markdown open ./plan.md # render a Markdown file in the session's preview panel (open|close|toggle)
rookctl session flag on # flag the active session for the flagged working-set view (on|off|toggle|clear)
rookctl session reveal --target 9f3c # reveal the focused pane's cwd in Finder
rookctl session seen --target 9f3c # clear a session's unseen-notification badge without visiting it (focus-free)
rookctl sidebar mode flagged # show only the flagged sessions as a flat list (tree|flagged|toggle)
rookctl workspace focus on # mark the active workspace ALONE and apply the filter (on|off|toggle|add; off is the unmark)
rookctl workspace focus add --target "$ws" # add one more workspace to the marked set, leaving the filter exactly as it is
rookctl workspace filter on # apply the set you just built — or restore a filter an involuntary jump lifted (on|off|toggle)
rookctl workspace color "#ff8800" # tint the workspace's sidebar icon (#rrggbb, or clear); persisted
rookctl workspace icon hammer.fill # set the workspace's sidebar icon (SF Symbol, emoji, svg/png/jpeg path, or clear)
rookctl session search "error" # open the search bar and highlight matches; prints the "N of M" counter
rookctl session search --next # step to the next match (--prev steps back, --close hides the bar)
rookctl quick toggle # toggle the quick terminal (show|hide|toggle)
rookctl quick type 'ls -la'$'\n' # type into the frontmost window's quick terminal (or --stdin); quick text reads it back
rookctl surface zoom # fill the window with the active terminal surface (show|hide|toggle)
rookctl surface zoom show --target "surface:$ROOK_SESSION_ID:right" # zoom a specific surface by id (ids in tree --json)
rookctl dashboard "$a" "$b" "$c" --auto-size # view-only grid; a split session is two cells, capped at 9 panes (--font-size N | --auto-size; --close)
rookctl dashboard --mru --auto-size # ...or fill it from the window's most-recently-used sessions (no ids)
rookctl font inc # increase the session's (main pane's) font size
rookctl font dec --pane right # shrink just the split pane's font (--pane left|right|scratch)
rookctl theme set --light "Builtin Light" --dark Dracula # set the light/dark theme slots (--dark none turns following off)session type types the text as real keystrokes, and every newline is a real Return press — so a trailing newline submits the command, and a multi-line payload runs line by line (a multi-line shell construct like a for loop is entered across the shell's continuation prompts and runs as one command). Note the $'…\n' quoting: a literal \n inside plain single quotes reaches the CLI as two characters, not a newline; use $'…\n' or pipe a real newline via --stdin. Typing goes to the session's left (main) pane by default; --pane right types into the split pane instead (an error when the session has no split), and --pane scratch reaches the session's scratch terminal even while it is hidden. One session type can address many sessions at once: repeat --target to name them, or --flagged to hit every flagged session in the window — the same line into a whole flock of agents, with --pane applying to all of them. The two selectors are mutually exclusive, and --select works only with a single target. Targets are resolved all-or-nothing, so one unknown id fails before any shell sees the text; the typing itself is best-effort, and a partial run answers with the count that did take it (3 sessions affected) rather than pretending it all worked — re-running a broadcast that half-succeeded would type into those sessions twice. An empty flagged set is a plain 0 sessions, not an error. session text takes the same --pane, so an agent can read a hidden scratch's output (e.g. a deploy you ran there) without leaving it open. font inc|dec|reset also takes --pane left|right|scratch, so you can resize just the split pane's font (an error when there is no split); only the main pane's size is remembered across a restart.
session copy returns the target session's selected text in the response (it does not touch the system clipboard), so a script can move a selection from one session to another:
sel=$(rookctl session copy --target 9f3c) # the selected text in session 9f3c
rookctl session type --target work --select "$sel" # paste it into another sessionWith no selection it exits non-zero with no selection. The selection must be made in the terminal (drag/Shift-click); session copy only reads it.
session paste pastes the system clipboard into a session (the socket analogue of ⌘V), and session select-all selects the session's entire buffer (the analogue of ⌘A) — pair select-all with copy to grab the whole buffer:
printf 'deploy staging' | pbcopy
rookctl session paste --target work # lands at the prompt, not submitted
rookctl session select-all --target 9f3c # then read it all back
rookctl session copy --target 9f3cThese are also the Edit menu's Copy/Paste/Select All (⌘C/⌘V/⌘A), which act on the focused terminal (or a focused text field).
session overlay open runs a program in an ephemeral terminal on top of a session (full size, hiding the single/split content underneath). It is meant for launching an interactive program over a session — the overlay grabs focus, and when the program exits the overlay vanishes and the session reappears unchanged:
rookctl session overlay open "revdiff HEAD~3" --target 9f3c # review the last 3 commits over session 9f3c
rookctl session overlay open "htop" # on the active session
rookctl session overlay open "htop" --size-percent 70 # a floating, framed panel at 70% of the pane
rookctl session overlay open "revdiff HEAD~3" --size-percent 80 --background-color "#2a1a3a" # tint the overlay pane
rookctl session overlay open "revdiff HEAD~3" --target 9f3c --follow # switch the user to session 9f3c as the overlay opens
rookctl session overlay open "make test" --wait # keep the overlay open after exit (press a key to close)
rookctl session overlay open "make test" --block # block until it exits; exit with its status
rookctl session overlay resize --size-percent 60 --target 9f3c # resize an open overlay to a floating 60% panel
rookctl session overlay resize --full --target 9f3c # switch it back to the full-pane overlay
rookctl session overlay close --target 9f3c # close it from a scriptBy default an overlay opens on its --target without switching the active session — full and floating both run their program in the background and appear when the user visits that session; pass --follow to select the target as the overlay opens (a no-op if it is already active). session overlay resize changes an already-open overlay in place — --size-percent N (1–100) makes it a floating panel, --full switches it back to full size — and the program keeps running across the change. By default it closes the instant the program exits; --wait keeps it on a "press any key to close" prompt so you can read the program's final output. A * (overlay) tag in rookctl tree marks a session whose overlay is open.
--block runs the program in the overlay (rendering normally) and blocks until it exits, then exits with the program's status — useful in a script that needs the outcome of an interactive run. The program's output stays its own concern: a TUI writes its result to its own file (for example revdiff --output=…) which the script reads, while --block reports only the exit status (the overlay never captures stdout). --block can't be combined with --wait; session overlay result reports the last overlay's exit status on demand for a manual open → poll flow.
By default the overlay fills the pane, drawn translucent, hiding the session beneath it. Pass --size-percent N (1–100) for a floating variant instead: an opaque, framed panel sized to N% of the pane in both dimensions and centered in it, with the session still visible around it. Useful for a small auxiliary program (a picker, a monitor) that you want floating over — not replacing — the terminal you're working in. It composes with --block (a blocking floating overlay). Like a full overlay it opens in the background and runs even when the target is not active; pass --follow to switch the user to the target as it opens.
A session's terminal surface is created lazily — it does not exist until the session has been shown at least once. Injecting text into a never-shown session therefore fails with session not realized unless you pass --select, which selects the session (realizing its surface) before injecting:
id=$(rookctl session new --cwd ~/src/rook)
rookctl session type --target "$id" --select $'echo hello\n'rookctl window drives the named windows. window list prints id name [open] [active] (raw with --json); the other subcommands take a window id, a unique prefix, or active (the frontmost):
rookctl window list # id name [open] [active]
w=$(rookctl window new work) # create and open a window, capture its id
rookctl window select "$w" # raise it (opening it first if it was closed)
rookctl window rename "$w" personal # rename it
rookctl window close "$w" # close its on-screen window (the bundle is kept)
rookctl window delete "$w" # delete it (the last window can't be deleted)
rookctl window minimize "$w" on # park it in the Dock (on|off|toggle; the mode alone means the active window)
rookctl window new parked --minimized # create it already in the Dock, so building a set of windows never steals focuswindow minimize on a natively full-screen window is an error rather than a silent ok, because AppKit ignores miniaturize there — leave full screen first (window fullscreen). window list --json reports each open window's minimized, alongside fullscreen and zoomed, so a script can record which windows it put away and restore exactly those; a minimized window still reports its geometry (the frame it comes back to), so record-then-restore works while it is parked.
A global --window <id> option on the session, workspace, tree, and font commands targets a specific window's tree instead of the frontmost one (the window must be open). Without it, those commands act on the frontmost window:
rookctl tree --window "$w" # the tree of window $w
rookctl session new --window "$w" --cwd ~/src/rook # open a session in window $wInside a session's shell, rook injects environment variables a script can read: ROOK_ENABLED=1, ROOK_WINDOW_ID, ROOK_WORKSPACE_ID, ROOK_SESSION_ID, ROOK_SOCKET (the live control-socket path), ROOK_PANE (which pane this shell runs in — left for the main pane, right for the split, or scratch; unset in an overlay), and ROOK_PANE_ID (a stable per-surface token, set wherever ROOK_PANE is). The two differ in what they survive: ROOK_PANE is the shell's role at spawn time and goes stale when panes move (close the main pane and the split is promoted into it, still carrying right), while ROOK_PANE_ID names the surface itself and never changes, so the app can resolve the shell's current role from it. The agent-status hooks forward it as session status --pane-id; a script normally just leaves that to the hook.
So a script running in a session can drive its own window without hard-coding ids:
rookctl session new --window "$ROOK_WINDOW_ID" --cwd . # open a sibling session in this window
rookctl session type --target "$ROOK_SESSION_ID" $'\n' # type into this very session
rookctl tree --socket "$ROOK_SOCKET" # reach the same Rook this shell runs inPolling tree --json tells you the shape of the tree, not what happened to it: a status that flipped and flipped back between two polls never happened as far as the poll can tell. rookctl events prints what the app did instead, one line per event:
rookctl events # human lines: time, kind, name, details
rookctl events --json # one bare JSON object per line — pipe it into jq
rookctl events --kind status --kind session.created # only these kinds (repeatable, or comma-separated)
rookctl events --kind status,tree.changed --limit 20 # at most 20 events per read (1–1000, default 100)
rookctl events --run "$run" --after 41 # resume from a cursor instead of starting from nowFour kinds fire today: status (an agent-status transition, carrying the status, the pane, and the blink and color it resolved to), session.created and session.closed (each naming the session), and tree.changed — a per-window "the tree moved" signal, debounced by 100 ms so that a batch move or a workspace reopen is one event rather than N, and named by its window rather than a session. A fifth kind, notify, is part of the protocol and --kind notify is accepted, but nothing emits one yet, so that filter stays silent; a notification's arrival is still only readable as the session's unseen count in tree.
Events live in a bounded in-memory ring of 4096 entries belonging to one run of the app. It is not a log: nothing is written to disk, and the ring does not survive a restart. Each entry carries a monotonic sequence number and the ring carries a UUID for the app run; together those are a cursor. --run and --after are a pair — one without the other is an error — and every reply hands back the next value to send with the following read. Omit both and you subscribe from now: no history is replayed, so a watcher that starts up late is not buried under 4096 events it does not care about. rookctl events does all of this for you — it starts at the tail, feeds each reply's next into the next request, and waits 250 ms only after a read that came back empty, so a burst drains at full speed.
Underneath is the ordinary one-request-one-connection events.read command. The CLI simply asks again; no connection is held open and nothing is pushed.
A cursor the ring cannot honor fails loudly rather than resetting itself. Three errors say so — event run changed (the cursor is from an earlier run of the app), event cursor is ahead of the current sequence, and event cursor expired (the events after your cursor have been evicted from the ring) — and all three answer ok:false while still carrying the ring's current anchor, so a watcher that accepts the gap can rebaseline from that very reply. Deciding to accept it is the part that is not taken from you: re-anchoring silently would hand a consumer that missed events a quiet "nothing happened", which is exactly the blind spot the event feed exists to remove.
rook reads a user-editable, kitty-flavored keymap file at ~/.config/rook/keymap.conf. It does two things: rebind the built-in menu shortcuts, and define custom shell commands bound to keys (and listed in the action palette). The file is optional — the app ships with working defaults, and a commented starter keymap.conf is written on first launch. The directory holding it can be changed in Settings ▸ Key Mapping (the field shows the active path, with a "Choose…" picker and "Use Default").
The format is line-based with two verbs. Blank lines and lines starting with # are ignored:
# rebind a built-in to a single chord (mods joined by +; no leader sequences for built-ins)
map cmd+shift+l toggle_split
map ctrl+shift+k command_palette
# define custom commands ("name" shows in the palette; chord is optional)
command "Open in Zed" cmd+shift+e open -a Zed {AGT_SESSION_PWD}
command "Lazygit" ctrl+a>g rookctl session overlay open lazygit --socket {AGT_SOCKET}
command "Deploy" ./deploy.sh
A chord is modifier words joined by + and a base key, e.g. cmd+shift+e or ctrl+\``. The modifiers are ctrl, cmd, opt, and shift. The base key is a single character or one of tab, space, return, delete, left, right, up, down. A key you type with Shift is written as shift+(the base key, not the shifted symbol):shift+/for?, shift+5for%, shift+=for+, shift+.for>. A custom command's chord may also be a leader sequence — chords separated by >, e.g. ctrl+a>g(pressctrl+a, then g). A commandwith no chord is palette-only. A custom command's chord must include a modifier: a bare key likeais rejected with a diagnostic and the line is treated as palette-only, so a binding can't silently shadow a plain terminal key (and a palette-only shell line that happens to start with a single-character token isn't swallowed as a binding). An arrow needs a modifier on amapline too:map left focus_left_paneis rejected withbare arrow chord 'left' needs a modifier; map skipped. A rebound built-in becomes a menu key equivalent, and those have no pass-through for text fields — a bare arrow would be swallowed before it reached the inline rename field, a palette's search field, or any Settings field. map cmd+shift+left previous_session` is fine.
Chords are written in Latin and work on any keyboard layout. Rook resolves a chord by the layout, not by the character a key happens to produce: on a layout that can type ASCII (US, Dvorak, Colemak, French, German) the produced character is used, exactly as before; on one that cannot (any Russian, Greek, Hebrew, Arabic, or Thai layout) every key is matched by its physical position. So cmd+o fires on the key where o lives, whether that key prints o or щ. The flip side: on a non-Latin layout a chord can only name a Latin key — you cannot bind the Cyrillic character a key prints.
This applies to keymap.conf. A keybind in ghostty.conf is matched by libghostty itself, where a bare letter is matched against the character the layout produces and only the key_ form is physical — so write keybind = super+key_c=copy_to_clipboard, not super+c, if you want it to fire on a non-Latin layout.
The bindable built-in action names are:
new_window rename_window delete_window
new_workspace rename_workspace delete_workspace
new_session open_directory rename_session duplicate_session
close_session reopen_recent undo_close clear_status
increase_font_size decrease_font_size reset_font_size
toggle_split toggle_scratch toggle_search
toggle_sidebar toggle_file_tree toggle_flag toggle_flagged_view
focus_workspace toggle_workspace_filter
focus_left_pane focus_right_pane
previous_session next_session first_session last_session
previous_attention_session next_attention_session
quick_terminal session_palette command_palette
custom_command_palette show_attention dashboard
select_theme toggle_fullscreen toggle_terminal_zoom
The shell line of a command may use these {AGT_X} tokens, expanded at fire time (the same values are also exported as $AGT_X environment variables on the spawned process):
{AGT_SESSION_ID} {AGT_SESSION_NAME} {AGT_SESSION_PWD}
{AGT_WORKSPACE_ID} {AGT_WORKSPACE_NAME}
{AGT_WINDOW_ID} {AGT_WINDOW_NAME}
{AGT_PANE} {AGT_SELECTION} {AGT_SOCKET}
The context is resolved from the focused pane's session, so a custom command runs in that session's working directory and can read its current selection. {AGT_PANE} is the pane the shortcut fired from — left (main), right (split), or scratch (the session's scratch terminal) — so a script can route a follow-up rookctl session type --pane "$AGT_PANE" back into the very pane it was invoked in. A custom command runs as a detached /bin/sh -c; a non-zero exit (or a spawn failure) posts a notification banner. A launcher chord — one that references no session/workspace/selection token — still fires when its window has no sessions left (e.g. after an all-SSH window's connections drop), while a session-scoped chord does nothing in that state (its {AGT_SESSION_PWD} and friends would be empty).
Because it runs detached with no controlling terminal, a custom command suits fire-and-forget launches — GUI apps (open -a …), scripts, one-off shell lines — not interactive or full-screen programs: a TUI like lazygit run bare has no TTY to draw into and exits immediately. The Lazygit example above launches it the right way, in an overlay terminal that does have a TTY (rookctl session overlay open, passing {AGT_SOCKET} so the CLI reaches this very app; add --size-percent 80 for a floating panel instead of full-size). A per-session scratch terminal (rookctl session scratch on --command lazygit) works too.
A {AGT_X} token is substituted raw into the shell line — convenient, but unsafe for content you don't control. {AGT_SELECTION} is the obvious case, but a remote host can also set the session title (OSC) and report the working directory (OSC 7), so {AGT_SESSION_NAME} and {AGT_SESSION_PWD} are equally unsafe to interpolate raw. For any such content prefer the matching $AGT_X environment variable, quoted, e.g. "$AGT_SELECTION" — the shell quotes it for you so it can't inject syntax.
Open the file in your editor with File ▸ Edit Keymap… or the ⌃⇧P palette ("Edit Keymap"): it opens in a 95% overlay running $VISUAL/$EDITOR (falling back to vi), and reloads automatically when you save and quit. The editor is resolved through your interactive login shell, so an $EDITOR/$VISUAL set anywhere your normal terminal picks it up (including ~/.zshrc) is honored.
After editing the file, apply it with File ▸ Reload Keymap, the action palette (⌃⇧P → "Reload Keymap"), or rookctl keymap reload. A malformed line never discards the rest of the file — it surfaces in the diagnostics list in Settings ▸ Key Mapping (and keymap.reload returns the diagnostic count) while the good lines still apply. rookctl keymap list is the read side of that count: it prints the config path, every built-in with the chord the keymap resolved for it (* marks one you overrode, - an action with no key), your custom commands, the diagnostics with their line numbers, and — separately — the key equivalents the menu bar is actually carrying, (disabled) where an item is inert. Comparing the two halves is the point; see Troubleshooting for when they disagree.
v1 limitations:
- Built-in rebinds are single-chord only; leader sequences (
ctrl+a>g) work only for custom commands. - The literal
+and>can't be a bare key token (they are the chord-joiner and leader separators), but those keys are still bindable asshift+=andshift+..increase_font_size's default ⌘+ therefore can't be written back into the file, though you canmapthe action to any chord that can. - The Ctrl-Tab MRU session switcher and Ctrl-1/Ctrl-2 pane focus are not rebindable yet; they keep their current keys.
- The action palette shows a built-in's shortcut as a macOS menu glyph (
⇧⌘D,⌥⌘←), resolved from the live keymap so a rebind updates it; a custom command shows its raw kitty chord instead (cmd+shift+e).
rook builds its terminal config from these sources, each overriding the one before it:
ghostty's bundled defaults → ~/.config/ghostty/config → <config dir>/ghostty.conf → Rook Settings
(lowest) (your global config, (rook-scoped, (UI wins)
OFF by default) always loaded)
Rook is self-contained: by default it does not read your global ~/.config/ghostty/config, so a config written for the standalone Ghostty.app never silently changes rook. Turn on Settings ▸ General ▸ Use my global Ghostty config to fold it into the chain.
<config dir>/ghostty.conf is the place to customize rook. It sits next to keymap.conf (default ~/.config/rook/ghostty.conf; the directory is the one set in Settings ▸ Key Mapping), is always loaded, and is scoped to Rook so the standalone Ghostty.app never reads it. Put any ghostty config key there to override the bundled defaults for Rook only. The keys Rook manages from its Settings window (font, theme, background opacity and blur, scroll speed) still win, because the generated Settings file loads last, so set those in Settings and put everything else here. The file is optional: a commented starter is written on first launch and stays a no-op until you edit it.
One bundled default is worth knowing about: Rook ships cursor-click-to-move = false. With shell integration on, libghostty otherwise moves the shell's input cursor to wherever you click in the prompt — and the double-click you use to select a word there triggers it too, dragging the cursor off to the click. Put cursor-click-to-move = true in ~/.config/rook/ghostty.conf if you want the click-to-move behavior back; a TUI's own mouse handling (vim, htop) is a different path and is unaffected either way.
A common use is making the macOS Option key send Alt:
macos-option-as-alt = true
Put that in ghostty.conf. It also works in your global ~/.config/ghostty/config once you enable the toggle above. The full key reference is at https://ghostty.org/docs/config.
Programs running in the terminal can read and write the macOS clipboard over OSC 52. Rook prompts before a program reads your clipboard, because a read hands its contents (which may include passwords or tokens) back to the program; a normal ⌘V paste is never prompted. Clipboard writes go through by default, matching other terminals so a remote tmux/vim yank still reaches your clipboard. To gate writes too, set clipboard-write = ask (prompt) or clipboard-write = deny (block) in ghostty.conf. Each prompt offers Don't ask again this session, which remembers your choice until Rook quits.
A ⌘-click on a file — a file:// link of the kind ls --hyperlink, eza, and many compilers emit, or a plain path printed in the output (plan.md, ./docs/x.md, ~/notes.md, even src/foo.ts:42, whose :line suffix is dropped) — reveals it in Finder instead of opening it. A terminal renders untrusted program output, so a link could point at a .app or .command; revealing selects the file without running it, which is the security boundary — actually opening it stays a separate, explicit action. A bare path is resolved against the clicked pane's own working directory and only acts when it hits a real file, so ordinary prose doesn't bounce Finder. A local Markdown file (.md, .markdown, .mdx) is the one exception, and it does not weaken the boundary: Rook renders it itself, in the Markdown preview panel, so nothing is handed to the system to launch. Web (http/https) and mailto links still open in the browser as before. A file:// link that names another host is ignored rather than revealed, so a stray link can't trigger a Finder network mount.
Open the file with File ▸ Edit ghostty.conf… or the ⌃⇧P palette ("Edit ghostty.conf"): it opens in a 95% overlay running $VISUAL/$EDITOR (falling back to vi), the same as Edit Keymap, and reloads when you save and quit. Apply edits made elsewhere with File ▸ Reload Config, the action palette ("Reload Config"), or rookctl config reload. A malformed line does not break the load: the bad lines are skipped and the good ones still apply. The diagnostic count (shown in a banner and returned by config.reload) covers every ghostty config source, not just ghostty.conf, because the diagnostics do not record which file they came from. The Console log shows the offending line.
Rook runs with TERM=xterm-ghostty, which a stock remote host has never heard of — and ncurses programs refuse to start on an unknown terminal (nano dies with Error opening terminal: xterm-ghostty.; vim survives only because it falls back to its builtin termcap). So Rook enables ghostty's SSH shell integration by default (shell-integration-features = no-cursor,no-title,ssh-env,ssh-terminfo): the first time you ssh to a host, the terminal uploads its terminfo entry into the remote ~/.terminfo (no root needed) and prints Setting up xterm-ghostty terminfo on <host>…; later connections to that host skip straight through. If the upload fails, the connection falls back to TERM=xterm-256color rather than breaking, and COLORTERM=truecolor is forwarded either way.
Two caveats. The upload needs infocmp locally and tic on the remote host. And on the first connection to a new host, a remote command passed on the command line (ssh host 'do-thing') runs twice — the terminfo upload rides the same invocation; interactive ssh host is unaffected, as is every later connection. If that matters to you, drop ssh-terminfo from shell-integration-features in ghostty.conf and install the terminfo yourself: infocmp -x xterm-ghostty | ssh host -- tic -x -.
Hosts already set up are remembered in ~/Library/Application Support/rook/ssh-terminfo-hosts; delete a line (or the file) to force a re-upload after a host loses its ~/.terminfo.
Rook detects which agent a session is running, on its own. When a session's focused pane runs claude or codex, its sidebar row swaps the terminal glyph for that agent's logo, so a flock of sessions reads at a glance — who is a Claude, who is a Codex, who is just a shell. Nothing sets it and nothing needs wiring up: Rook reads the pane's foreground process (it owns the terminal, so it simply asks), which also means it is right for an agent whose hooks you never installed. The logo takes the place of the split and flagged icon variants while the agent runs. Over the control channel, rookctl tree --json reports it as each session's agent field (claude/codex, omitted otherwise) — the classified form of the raw foreground argv on the same node.
Rook also strips the progress marker a coding agent writes into the terminal title (Claude Code's ✳ and its spinner), so the row shows the task and the icon shows the agent, instead of both fighting for the same line.
That is what the session RUNS. What follows is what an agent REPORTS about its turn — the two are independent, and a session can have either without the other. A coding agent running in a session can flag its status on that session's sidebar row, so you can tell at a glance which of many concurrent agents needs you. The status shows as a small tinted SF Symbol just left of the notification badge: active is a blue ellipsis, blocked an amber exclamation, completed a green check, and idle is nothing. Both halves of that glyph are yours to change: Settings ▸ Agent Status sets each status's color and its shape — leave the shape on Default to keep the built-in symbol above, or pick a plain circle, square, triangle, diamond, capsule, or star so the state reads by outline as well as by hue (color blindness, a monochrome theme, a small glyph). The glyph shows on every non-idle session, the selected one included. A one-time completed flash auto-clears once you visit the session.
A session that needs you — blocked or completed — also washes its whole row in that status's color (the row background and the session name), so a full sidebar reads at a glance instead of asking you to hunt for a small glyph. active deliberately keeps the glyph alone: an agent at work is the steady state, and washing it would leave half the sidebar colored. The selected row keeps its normal selection color (its status is on screen anyway) and still shows the glyph. Turn the wash off with Highlight blocked and completed rows in Settings ▸ Agent Status; the colors are the same ones the glyphs use, and session status --color overrides both for a single call.
A collapsed workspace rolls its children's statuses up onto its own row, so folding a workspace shut never hides an agent waiting on you. The row wears the glyph of the most important state inside it — blocked beats completed beats active — and nothing when every session is idle. Expanding the workspace drops the parent glyph again: the session rows carry their own, and a second copy on the parent would only repeat one of them. This differs from the unseen-notification badge, which aggregates a count and therefore shows whether the workspace is folded or not. The winning session's glyph rides up whole, so a --color/--shape/--blink an agent set on it reads the same on the parent row. Nothing new to script: it is derived entirely from the per-session status that rookctl tree --json already reports, alongside each workspace's collapsed.
When the sidebar is hidden the per-session glyphs go with it, so the same signal is available two more ways. An optional title-bar bell (turn on Show attention indicator in Settings ▸ Notifications; off by default) reflects the window at a glance: dimmed when nothing needs attention, plain when a session is active or completed, and a filled amber bell when any session is blocked. Clicking it opens a popover of just this window's non-idle sessions, each with its status glyph, sorted blocked → active → completed; clicking a row jumps to that session and reveals its blocked pane. Pressing ⌃⇧I, choosing Navigate ▸ Go to Attention…, or the action palette's "Show Attention" opens the same list as a searchable palette (Enter jumps). Over the control channel, rookctl tree --json now reports each session's status (omitted when idle) and statusPane (left|right|scratch — which pane set the status, omitted when idle or unset).
Auto-follow blocked sessions. When several agents run at once, a session that blocks is easy to miss. Turn on Settings ▸ Agent Status ▸ Auto-follow blocked sessions (Disabled by default, or a 5s/10s/30s/60s/5m idle timeout) and, after you have been idle from input for that long, the window selects and focuses the oldest blocked session, so you are pulled to whatever agent is waiting. It is per-window and window-wide (crossing workspaces within the window), and walks the blocked sessions oldest-first. A block it pulls you to is muted once you move on: auto-follow shows each waiting block once, then stays quiet, and re-arms for a session only when it leaves blocked and blocks anew — so it won't repeatedly yank you back to the same one after you've looked. Being parked on a blocked session still suppresses further jumps while you stay on it. The opt-in Don't auto-follow away from a running session (off by default) also holds the selection put while the current session is active. Over the control channel, rookctl tree --json reports the window's idleMs (milliseconds since your last input, live) and autoFollowMs (the configured timeout in milliseconds, omitted when Disabled); rookctl window list --json reports autoFollowMs per window (as of the last refresh), but not the live idleMs.
For a coding agent this overlaps with a desktop notification: both are ways for a session to get your attention, and in agentic use either can carry the same "I need you" signal, so the two are largely interchangeable. The difference is what stays behind. A notification (OSC 9/777 or rookctl notify) is a one-shot banner and badge with no lasting state. Agent status is a typed, persistent state that stays on the row until you act on it, tells working (active) apart from waiting (blocked) and finished (completed), and powers the attention list, the title-bar bell, and attention navigation (⌃⌥↑/↓). So for an agent flagging that it needs you, prefer agent status: it is more accurate and plugs into the attention UI, while a notification is best kept for a one-off nudge that needs no follow-up.
An agent sets it over the control channel:
rookctl session status active --target "$ROOK_SESSION_ID" # agent started working
rookctl session status blocked --target "$ROOK_SESSION_ID" # waiting on you
rookctl session status completed --auto-reset --target "$ROOK_SESSION_ID" # done; clears when seen
rookctl session status blocked --sound default --target "$ROOK_SESSION_ID" # waiting on you, with a beep
rookctl session status blocked --color '#ff0000' --target "$ROOK_SESSION_ID" # per-call red tint
rookctl session status blocked --shape triangle --target "$ROOK_SESSION_ID" # per-call shape, readable without color
rookctl session status blocked --pane right --target "$ROOK_SESSION_ID" # a split-pane agent tags its pane
rookctl session status idle --target "$ROOK_SESSION_ID" # clear it<state> is one of idle | active | completed | blocked. --blink pulses the icon for attention. --auto-reset makes the indicator clear back to idle the moment you visit (select) the session — used for a finished result you only need to notice once; without it the status is kept until something changes it. --sound plays a one-shot sound when the status is set — default for the system alert sound, or a system sound name (Basso, Blow, Bottle, Frog, Funk, Glass, Hero, Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink, plus any custom sound in ~/Library/Sounds); it is optional and entirely caller-driven, so the agent decides when a status change is worth an audible nudge. If you'd rather have a blocked prompt always make a sound without touching the hooks, set Settings ▸ Agent Status ▸ Blocked sound to a system sound (default None) — it plays whenever a session becomes blocked, and an explicit --sound on the call still overrides it. --color (#rrggbb) overrides the glyph tint for that one call — it rides the status, so the next session status without --color reverts to the configured color; use it to distinguish states beyond the fixed palette (say, a caller-specific blocked color). --shape (circle, square, triangle, diamond, capsule, star) is the same idea on the other axis: it swaps the glyph for that plain filled silhouette for one call, so the state is readable when the tint is not — color blindness, a monochrome theme, or just a small glyph. It rides the status exactly like --color, so the next session status without --shape drops back to the standing shape, and an unknown name is rejected before the status changes. It is an override: it beats the shape you configured for that status in Settings ▸ Agent Status, which in turn beats the built-in semantic glyph (active an ellipsis, blocked an exclamation, completed a check, idle nothing). With neither set nothing moves — the semantic glyphs already say what happened without a legend, and the shape is an optional second discriminator on top of them, not a new default. It reads back on tree as each session's statusShape, next to statusColor and statusBlink — the per-call override only, so a session drawing your configured shape reports no statusShape at all (just as one drawing your configured color reports no statusColor), and a script can record and restore the override without freezing your standing preference into it. --pane (left|right|scratch, defaulting to left = the main pane when omitted) records which pane set the status, which has two effects: a status set from a background pane survives foreground typing in a different pane (only a keystroke in the owning pane clears it), and any GUI selection of the session (auto-follow, attention nav ⌃⌥↑/↓, plain session nav, the command palettes, and a sidebar click) reveals and focuses the tagged pane — flipping to the split, or showing a hidden scratch — instead of the main pane, so an agent running in a split or scratch should set its own pane to be found (the control session go next-attention only steps the selection, it does not itself move focus into the pane). It reads back on tree as each session's statusPane. --pane-id takes the shell's $ROOK_PANE_ID — the surface's stable token — and, when it still matches a live surface of that session, decides the pane instead of --pane; the hooks forward it for you, so you rarely pass it by hand. It exists because the baked role goes stale: close the main pane and the split is promoted into it while its shell still says right, so after a re-split both shells would claim the same pane and a blocked would land on the wrong one. An absent or unrecognized token just falls back to --pane, so shells started before the token existed keep working. The target session can live in any window, frontmost or not. Typing into a session that's flagged for your attention (blocked or completed) clears its status back to idle, so answering a prompt or re-engaging with a finished session drops the glyph immediately. An active (working) session is left alone for ordinary typing — except an interrupt keystroke, Esc or Ctrl-C, which cancels the agent and also clears the glyph, so dismissing a prompt drops it at once even if the blocked waiting-state hadn't appeared yet.
To wire this up automatically, Help ▸ Install Agent Status Hooks… installs a hooks package. It copies the scripts to ~/.config/rook/agent-status/ (baking in the bundled rookctl's path so the hooks work even without the CLI on your PATH), adds a source line to ~/.zshrc, ~/.bashrc, and ~/.config/fish/config.fish for the generic shell integration, and merges four Claude Code hooks into ~/.claude/settings.json (backing up the prior file as .bak, or leaving it untouched and skipping the merge if it isn't valid JSON): a prompt sets active, each tool that runs re-asserts active (so the status returns to active when work resumes after you answer a permission prompt), the Stop event sets completed --auto-reset, and a permission prompt sets blocked. It is idempotent — re-running refreshes the baked path and is a clean no-op for entries already present.
Claude Code subagents do not move the row. Claude Code fires those same hooks inside a subagent (the Task tool) and stamps them with the same session id, so a flock of working subagents used to keep re-asserting active over the completed the main thread had already reported — the row said "working" while it was actually your turn. The installed hook now reads the hook payload's agent_type (absent on the main thread, the subagent's own type inside one) and drops a subagent's progress report, so the row tracks the main thread only. A subagent's permission prompt is never dropped: that is a real question waiting on you, and answering it clears the glyph through the keystroke in that pane. Because an installed hook is a copy, re-run Help ▸ Install Agent Status Hooks… once to pick this up.
And a finished turn with agents still running does not say "done". Claude Code's Stop event means the main thread ended its turn, not that the work is finished — in a swarm the lead hands the turn back while its backgrounded teammates keep grinding, and the Stop → completed --auto-reset hook lit the sidebar's done-checkmark and called you over for nothing. The Stop payload lists those teammates in background_tasks, so when at least one of them is a live subagent (an entry whose type is subagent and whose status is running — a backgrounded bun run dev is not one, and would hold the row forever) the hook reports active --blink instead. Nothing has to re-light the checkmark: Claude Code wakes the main thread when the last agent lands, and its next Stop carries an empty list. The trade-off is deliberate — if the lead ends its turn to ask you something while teammates are still running, the row shows active rather than the checkmark. Today it lies the other way, calling you when it is far too early, and being pulled over for nothing costs more than a question noticed a minute late.
Nor does an agent your agent started. A nested agent process — a claude -p … the pane's agent spawns to do a side job — is the main thread of its own session, so it carries no agent_type and the filter above waves it through; it used to report completed when it finished, and the row said "done" while your real agent kept working. Rook now applies to session status the same ownership test session agent already used: rookctl reports its nearest agent ancestor, and a status is dropped when that pid differs from the pane's foreground process (the call still succeeds, it simply changes nothing). The two mechanisms cover different cases and you need both — a nested process has its own pid but no agent_type, an in-process subagent has an agent_type but shares the pane's pid.
The check is deliberately narrow, because a false drop loses a blocked and leaves you staring at a silent row while your agent waits for you. It only ever discards a report when all three hold: the call names the caller's own session (--target "$ROOK_SESSION_ID", which is exactly what the hook passes), an agent is the pane's foreground process, and the pids differ. So flagging another session from an orchestrator still works — no pid is sent for a cross-session call, since it would be a claim about a pane the caller isn't in. So does an agent running under tmux or ssh: there the pane's foreground process is the wrapper, not the agent, and Rook cannot judge ownership at all, so it doesn't try. Same for rookctl session status from your own shell or a script. Only a provable mismatch is dropped.
For Codex, the installer merges a matching set of lifecycle hooks into ~/.codex/config.toml (writing a .bak first, and only when you already have a ~/.codex directory). Codex's SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PermissionRequest, and Stop events run a dedicated installed adapter. PermissionRequest is only a candidate signal because it fires before Auto Review decides whether a person is needed; the adapter keeps the session active during automatic review and changes it to blocked only after a real approval or structured question dialog appears in that pane. Stop reads the turn's final assistant message and reports blocked when it contains a ?, because Codex often asks in plain prose that no dialog watcher can see; any other final message (including none at all) sets completed --auto-reset. That question check is deliberately blunt, so it also fires on a question you have already answered or on a ? inside a URL — if blocked gets noisy, it is the knob to narrow. The Codex-specific lifecycle and prompt recognition stays entirely in the installed hook package; Rook's status runtime only receives the same generic active, blocked, and completed updates as it does from any caller. Re-running the installer upgrades an older Rook-managed Codex hook block and preserves Codex's hook trust records. This also replaces an earlier notify script that guessed "waiting on you" from final-message text; the installer removes that old notify line for you. The merge parses your config first, so it preserves your comments and layout; if the file already defines its own hooks or isn't valid TOML, Rook leaves it untouched and shows you the block to add by hand instead. Codex requires changed command hooks to be reviewed before they run, so open Codex and run /hooks once after installing or upgrading them.
For Pi, the installer drops a lifecycle extension into ~/.pi/agent/extensions/ (only when you already have a ~/.pi/agent directory). It reports active when the agent starts and completed when it settles; Pi has no permission or question event, so a Pi session never shows blocked. Like the others the extension delegates to the shared status wrapper and no-ops outside a Rook session.
A generic bash/zsh/fish shell/integration.sh (or .fish) covers any agent launched as a shell command: it flags active while a command matching ROOK_AGENT_RE runs and idle at the next prompt. The default regex matches gemini, cursor-agent, aider, opencode, crush, and goose; Claude Code, Codex, and Pi are excluded by default because their own hooks/extensions drive finer per-turn state that the coarse process-level active/idle would only fight. Override ROOK_AGENT_RE before sourcing to change the set. All hooks are no-ops outside a Rook session.
The same installer also drops in rook-agent-session.sh and wires it to the SessionStart event of both Claude Code and Codex. That hook is what makes resuming agent conversations work: it reports the conversation the pane is on, which nothing else can know. If you installed the hooks before this feature landed, re-run Help ▸ Install Agent Status Hooks… to pick it up (it is idempotent).
Installing copies the scripts, so an installed copy never updates itself. That matters for the --pane-id forwarding above: hooks installed before it landed keep reporting the pane by its baked role alone, which is the mis-attribution case described there. Re-run Help ▸ Install Agent Status Hooks… once to refresh the copy, then start a fresh shell.
Restoring a pane that was running claude or codex gets you the agent back — but on a blank conversation, which is rarely what you wanted after a restart. Turn on Settings ▸ General ▸ Resume agent conversations (off by default; it rides on Restore running commands on restart, which is the mechanism that re-runs the pane's command) and the restored pane comes back on the conversation it was on.
The conversation id is not in the process table, so only the agent itself can report it. Its SessionStart hook does that over the control channel:
rookctl session agent claude --from-hook # the hook pipes its JSON payload on stdin
rookctl session agent codex --id "$CONVERSATION_ID"
rookctl session agent claude --clear # forget the pane's conversation<agent> is claude or codex. --from-hook reads the hook's JSON payload from stdin and takes session_id out of it (no jq needed). --config-dir defaults to the agent's own environment (CLAUDE_CONFIG_DIR / CODEX_HOME), so a conversation stored under a work or personal profile resumes against that profile; --pane defaults to $ROOK_PANE, so a split-pane agent reports its own pane without being told (scratch is rejected — the scratch terminal is not restored). Rook only accepts the report from the pane's own agent: it compares the reporting hook's nearest agent ancestor with the pane's foreground process, so a nested claude -p an agent spawns cannot overwrite the pane's conversation with its own throwaway one (the call still succeeds; it just writes nothing). The remembered conversation reads back on rookctl tree --json as each session node's agentSession (and splitAgentSession for the split pane): {kind, id, configDir?}. That is distinct from the node's agent field, which is merely which agent the pane runs, observed from the process.
On restart the pane's shell gets a line like env CLAUDE_CONFIG_DIR='<profile>' claude --resume <id> (Codex: env CODEX_HOME='…' codex resume <id>), with the flags the pane was originally started with carried over (claude --model opus comes back as claude --resume <id> --model opus; any previous --resume/--continue/--fork-session is dropped so it cannot fight the new one). The env prefix is deliberate: it runs the binary from your PATH, bypassing any shell function or alias you wrap claude in — a wrapper that re-picks the profile from the current directory would otherwise resume against the wrong config root and find no conversation.
Limitations, on top of the restore limitations the feature inherits: it only works after a clean quit (a force-quit captures nothing); without the SessionStart hook installed there is no conversation id, so the pane falls back to claude --continue / codex resume --last — the last conversation in that directory, which means two panes in the same directory both land on the same one; and an agent running behind tmux or over ssh is not restored at all.
Where the logs and config live, how to read them, and the common problems (a keymap editor that will not open, a custom action that does nothing, missing notifications) are covered in docs/troubleshooting.md. For a bug, open an issue; for a feature request or question, start a Discussion.
Restore reconstructs the structure, not the running processes. Three limitations follow from the design:
- Live processes are not reattached — true process survival would require a tmux-style backend, which is out of scope. By default a restored session re-spawns a fresh login shell in its saved working directory. The optional Restore running commands on restart toggle (General settings, off by default) re-runs the command each pane had in the foreground at the last clean quit, so a gate
ssh,tail -f, ortopcomes back — but it is a re-run, not a reattach: only a single-process command restores faithfully (pipelines and compound lines do not); a force-quit or crash captures nothing; and the programs named inrestore-denylist.conf(in the config directory, seeded with the terminal multiplexerstmux/screen/zellij, one command name per line) are skipped so they start fresh rather than re-launching — everything else, includingpython manage.py runserverornode server.js, is restored. Edit that file to add or remove entries. A coding agent re-run this way comes back on a blank conversation unless you also turn on Resume agent conversations — see Resuming agent conversations. - The saved working directory depends on the
GHOSTTY_ACTION_PWDcallback, which only fires when the shell has Ghostty shell-integration / OSC 7 active (auto-injected for zsh, bash, fish, and nu when the shell-integration resources are present). If the working directory is never reported, a session restores to the directory it was created in. - The live working directory is persisted on quit and on every structural change (adding, closing, moving, renaming, or selecting a session), but not on every
cd— OSC 7 fires on each prompt redraw, so saving each one would thrash the disk. A crash or force-quit therefore loses only the working-directory changes made since the last structural change or quit.
Rook is a fork of agterm by Umputun (MIT). Every feature described above — the workspace/session model, the sidebar, the control API, agent status, the multi-window library — originates there. Rook renames the app, the CLI, and the environment, and carries the project forward under its own brand; the upstream copyright stays in LICENSE.
Rook embeds libghostty, the terminal engine from Ghostty (MIT). It does all the real terminal work: rendering, VT parsing, and shell I/O. Rook builds it from upstream source at a pinned commit via scripts/setup.sh, with no fork and no prebuilt binary.
The way Rook drives libghostty's C API from a SwiftUI/AppKit app, under the Swift 6 strict-concurrency toolchain, was learned from macterm (thdxg/macterm, MIT). The libghostty bridge files (GhosttyApp, GhosttyCallbacks, GhosttyResources, GhosttySurfaceView, WindowAppearance) are adapted from it and each carries an attribution comment.
SwiftUI guidance during development came from the SwiftUI Agent Skill by Antoine van der Lee (MIT). Special thanks to @ksenks for recommending it.
The sidebar's agent logos are the Claude and OpenAI marks from simple-icons (CC0-1.0). The icons are public domain; the marks themselves remain the trademarks of Anthropic and OpenAI and are used here only to identify the agent running in a session.
MIT — see the LICENSE file. Copyright (c) 2026 Umputun (upstream agterm) and (c) 2026 jokius (Rook).










