Plugin compatibility¶
tmux-agent-wizard runs herdr plugins largely unmodified: same herdr-plugin.toml
manifest format, same trust-preview install gate, same HERDR_* environment
contract for the parts of it tmux can support. See
third_party/herdr/reference/plugins.mdx for the
herdr side of this contract, and src/plugin/ for the implementation.
taw plugin install|update|action invoke|pane open are taw CLI verbs over that
library; see taw plugin --help for the full command tree. Each commit of a GitHub
plugin lives in a folder of its own, plugins/github/<owner>/<repo>@<12-char sha>
under taw's state folder, and the registry's record names it; a built tree is never
moved, so a build that writes its own absolute path keeps a path that exists. taw
plugin update [id|source...] [--yes] asks the remote for each GitHub-installed
plugin's commit at its recorded ref, and fetches only when it is not the one installed,
into the new commit's folder beside the old one. It runs the same trust gate as
install and the build there, and only then switches the records, in one write, and
removes the old folder; a decline or a failed fetch or build removes the new folder and
leaves the installed copy untouched, and a folder left by Ctrl-C at the prompt (or by a
run that was killed) is removed at the next install or update of that repository, with
the records and the live folder untouched. A build only ever runs in a folder that run
made: install over an installed plugin at the commit it has, and an update to a commit
whose folder another record lives in, build in <repo>@<12-char sha>-<n> (the smallest
n from 2 up that is free), and which folder a record points at is decided by file
identity, so Owner/repo and owner/repo are one folder on a case-insensitive disk.
Plugins that share one checkout (installs from an older taw) update together. @<sha>
(and --ref <sha>) take a full 40-character commit and pin the plugin to it, and an
update of a pinned plugin whose folder is present is unchanged with nothing asked of
GitHub; a ref that looks like a short commit (7 to 39 hex characters) and that the remote
does not name is refused before any fetch with "give the full 40-character commit, or a
branch or tag". GitHub serves a commit of any fork of a repository under that
repository's address, so a pin should be a commit you have looked at; the trust preview
names the commit it fetched. A process a plugin started from its old folder keeps running
from it after an update (restart it, or run taw plugin startup once it has stopped).
Two taw plugin install or update runs of one repository at the same time are not
supported: run one at a time (there is no lock). A linked plugin is left alone, no
startup commands run, and the command exits 1 when any plugin failed, with every plugin
still listed.
The environment contract¶
Every plugin process ([[build]], [[startup]], an invoked action, a pane
command, a matching [[events]] hook) is launched with a working directory of the
plugin's own root and an environment built by src/plugin/env.rs. Each variable
below is set as both its herdr name and a TAW_ twin with the same value (e.g.
HERDR_BIN_PATH and TAW_BIN_PATH).
| Variable | Set by taw? | Notes |
|---|---|---|
HERDR_BIN_PATH |
yes | absolute path to the running taw binary, the portable way for a plugin to call back in, instead of a hardcoded herdr/taw on PATH |
HERDR_ENV |
yes | always 1 |
HERDR_PLUGIN_ID |
yes | |
HERDR_PLUGIN_ROOT |
yes | the installed/linked plugin directory |
HERDR_PLUGIN_CONFIG_DIR |
yes | <config>/plugins/<id>, created on demand |
HERDR_PLUGIN_STATE_DIR |
yes | <state>/plugins/state/<id>, created on demand |
HERDR_PLUGIN_CONTEXT_JSON |
yes | herdr's flat PluginInvocationContext fields (workspace_id, tab_id, focused_pane_id, focused_pane_cwd, focused_pane_agent, focused_pane_status, workspace_cwd, workspace_label, tab_label, worktree, selected_text, clicked_url, link_handler_id, invocation_source, correlation_id; null where taw has nothing, which is always so for the last six), and taw's own nested {"workspace":…,"tab":…,"pane":…,"caller_pane_id":…} beside them, plus any extra top-level fields the caller merges in (e.g. a worktree record); the fields of a pane's invocation are that pane's, its tab's and its workspace's |
HERDR_PLUGIN_EVENT |
yes | "startup" for startup hooks, "action" for action invocations and plugin-pane opens, or the event name (e.g. "pane.agent_status_changed") for event hooks |
HERDR_WORKSPACE_ID / HERDR_TAB_ID / HERDR_PANE_ID |
yes, when present | an action or pane opened with --pane has that pane's id and the ids of the tab and workspace it is in; with --tab, that tab's and its workspace's; with --workspace, that workspace's; an action invoked without --pane has the pane in front, as in herdr: the active pane of --tab or --workspace, else the pane the command runs in, else the active pane of tmux's current session |
HERDR_SOCKET_PATH |
never | see below |
HERDR_PLUGIN_ACTION_ID |
no | herdr sets this on action commands specifically; taw folds every invocation into the same HERDR_PLUGIN_EVENT/HERDR_PLUGIN_CONTEXT_JSON pair instead |
HERDR_PLUGIN_EVENT_JSON |
yes, on event hooks | herdr's event envelope, {"event": "worktree_created", "data": {"type": "worktree_created", ...}}, with the fields herdr's event has (null where taw has nothing). A worktree event's data.worktree has herdr's WorktreeInfo fields and data.workspace.worktree its WorkspaceWorktreeInfo (checkout_path, repo_root, repo_name, repo_key, is_linked_worktree). taw's own events (agent.done and the rest) carry their payload under data, with the kind in herdr's spelling |
HERDR_PLUGIN_ENTRYPOINT_ID |
no | herdr sets this on pane commands to identify which [[panes]] entrypoint launched them |
HERDR_PLUGIN_CLICKED_URL / HERDR_PLUGIN_LINK_HANDLER_ID |
no | see "Link handlers" below |
HERDR_SOCKET_PATH is never set¶
herdr's socket is its live JSON-RPC control channel; taw has no daemon and no
socket, so there is nothing to point this at. It is not just omitted: taw
actively strips HERDR_SOCKET_PATH/TAW_SOCKET_PATH from its own inherited
environment before running a plugin's build commands, so a taw invoked from
inside a real herdr session never leaks herdr's live socket path to a plugin
process it launches. A plugin that talks to the raw socket transport directly
(rather than going through HERDR_BIN_PATH) cannot work here at all; that is the
single largest compatibility gap, and it is deliberate: plugins that talk
raw JSON-RPC are unsupported.
PATH in the panes¶
Separately from herdr's variables, the plugin puts taw on PATH: tmux's global environment PATH
gets, in front, bin in taw's state folder ($TAW_STATE_DIR, else $XDG_STATE_HOME/tmux-agent-wizard,
else ~/.local/state/tmux-agent-wizard), a folder that holds only a taw link to the binary the plugin
resolved, so it can never shadow another program. Panes created from then on have it; existing panes
keep their PATH. A reload does not add the folder twice and moves the link to the binary resolved
then. @agent-wizard-path off (default on) and a reload takes the folder back out and leaves every
other entry as it was. tmux gives a pane the PATH of the command's client when that client is not
attached to a session, so a pane that a script outside tmux creates has that script's PATH, and so do
the first pane of a session started from a terminal and a window that a tmux new-window typed in a pane
opened before the load makes. A macOS login shell's path_helper keeps the folder but moves it behind the
system folders, so a taw in /usr/local/bin wins there. A session PATH (a user's update-environment
holding PATH) wins over the global one for panes made with keys.
The pane environment @agent-wizard-herdr-env gives¶
The table above is about the processes taw launches for a plugin. Separately, the tmux option
@agent-wizard-herdr-env (off unless on) puts herdr's environment into tmux's global
environment, so a program that follows herdr's guide for agents (Command Code, Crush, Kiro CLI,
Muse Code, Prime Agent: report only where HERDR_ENV=1, HERDR_PANE_ID and HERDR_BIN_PATH
are set) reports to taw from any pane created afterwards. The plugin sets three values when it
loads, and removes them on a reload with the option off while HERDR_PANE_ID is still its own
word:
| Variable | Value | Notes |
|---|---|---|
HERDR_ENV |
1 |
|
HERDR_BIN_PATH |
the taw binary the plugin runs, absolute |
herdr's agents run "$HERDR_BIN_PATH" pane report-agent ..., report-agent-session and release-agent, which take herdr's arguments, and since the resume command -- <command> too |
HERDR_PANE_ID |
%calling |
tmux gives every pane the one global value, so this is a fixed word: taw's pane verbs read it as the pane the process runs in ($TMUX_PANE), with or without the option, and fail with no current pane when $TMUX_PANE is not set |
HERDR_SOCKET_PATH is not set (taw has no socket), so an agent that reports through the socket
reports nothing. An agent that also requires HERDR_SOCKET_PATH (herdr's guide lists it among
the variables) reports nothing either. Nothing of this reaches a plugin process or a [hooks]
command: a plugin's action, startup or event hook, its build steps and its pane and popup
entrypoints get the table above, and HERDR_PANE_ID, HERDR_TAB_ID, HERDR_WORKSPACE_ID and
their TAW_ twins come from the invocation's context only, so a startup hook has none and an action or pane opened with
--pane has that pane's real id (a pane or popup tmux starts is run behind an env -u for the
names its context does not set). A hook names its pane in TAW_PANE.
Link handlers parse but do not dispatch¶
herdr-plugin.toml's [[link_handlers]] table is parsed (src/plugin/manifest.rs)
so a manifest that declares one still loads, but nothing in taw currently routes a
clicked terminal URL to a handler action; that would require intercepting a
modified mouse click on pane content, which tmux's own mouse mode does not expose
to a plugin the way herdr's own renderer does. HERDR_PLUGIN_CLICKED_URL and
HERDR_PLUGIN_LINK_HANDLER_ID are therefore never set, because the event that
would set them never fires.
herdr api snapshot and herdr api schema are not answered¶
herdr's api snapshot prints the whole session as one record: workspaces, tabs, panes, agents and
a layout record for every tab with the rectangle of each pane and each split. taw has the lists
(workspace list, tab list, pane list, agent list) and a split tree per tab (layout export),
but not the rectangles, so taw api snapshot and taw api schema do not exist and a plugin that
runs them (herdr-resurrect does) fails. taw session save and taw session restore do that job
for tmux: they keep each window, pane, folder and agent session and bring them back.
Compatibility matrix¶
The plugins below were installed with taw plugin install owner/repo and tried on 2026-10-27 on
Ubuntu with tmux 3.6, the first ten rows. A row says what happens under taw after the fixes that
trial led to. Plugin support is in beta: these ten are the only plugins run under taw so far.
| Plugin | Uses | Status | Why |
|---|---|---|---|
herdr-automatic-rename (qu8n) |
startup, event hooks, workspace/tab renames |
works, with a note | it needs jq. It names tabs [N] <program> as it does under herdr. It also numbers workspaces, and a workspace is a tmux session here, so your sessions become [1] main: a script or tmux attach -t main that names the session no longer matches |
dev-layout-bootstrap (ogulcancelik/herdr-plugin-examples) |
action, pane/tab verbs |
works | needs lua; the action made the three panes its README describes |
herdr-worktree-setup (tdi) |
build (npm ci), worktree.created event |
works | needs node and npm for its build; its [default] steps ran in the new worktree |
github-link-preview (ogulcancelik/herdr-plugin-examples) |
link handler, plugin pane open |
does not work for its purpose | it is opened by a click on a link, and tmux does not hand a click on a URL to a plugin (see "Link handlers parse but do not dispatch"). Its action and its plugin pane open --plugin ... --entrypoint ... --placement ... --direction ... --env ... --focus call work; only the click on a link cannot reach it in tmux |
herdr-reviewr (persiyanov) |
actions, pane process-info, panes, event hooks |
works | its open action (taw plugin action invoke open --plugin persiyanov.reviewr, from the pane you are in or with --pane) opens its pane beside that pane; the pane shows the repo's uncommitted changes, its keys work, and q or its close action closes it. Its auto-open on worktree.created is not tested yet |
herdr-resurrect (ntindle) |
action, herdr api snapshot, herdr api schema |
does not work | it saves by reading herdr api snapshot, herdr's whole-session record (with the rectangles of every pane layout), which taw does not print. Use taw session save and taw session restore, which do that job |
herdr-auto-title (kryptamine) |
herdr's socket API | does not work | it stops at once with "HERDR_SOCKET_PATH is not set": it talks to herdr's socket, which tmux does not have. It also needs go for its build |
herdr-radar (hhdebb) |
herdr's socket API | does not work | its startup fails (agent view: set failed): it talks to herdr's socket (lib/ipc.js). It needs node for its setup |
herdr-ohmyzsh (robbyrussell) |
startup, build | needs a tool of its own | it needs Oh My Zsh installed first, as its README says; its build reports that it is missing |
github-issue-herdr-plugin (nyanyaon) |
build (a script that fetches or builds a binary) | needs a tool of its own | there is no prebuilt binary for Linux on arm64, so its build needs cargo; without it the install stops with plugin_build_failed and the last lines the build printed |
herdr-file-viewer |
actions, panes (overlay/side/bottom) |
expected / untested | CLI-driven, same caveat as below; the pane-placement surface it needs (plugin.pane.open, as a popup or split) is implemented in src/plugin/panes.rs |
herdr-navigator |
actions, CLI verbs (workspace/tab/pane navigation) |
expected / untested | CLI-driven: the verbs it calls are implemented and covered by scripts/cli-sweep.sh |
herdr-plus |
mix of CLI-driven actions and raw socket calls | partial | the CLI-driven parts should work like the plugins above; whatever it does over the raw socket transport (HERDR_SOCKET_PATH) cannot, since that transport does not exist here |
herdr-browser |
browser content drawn inside a pane | ✗ | it was written for herdr's pane.graphics.* methods, which herdr removed in 0.9.2; herdr now renders the standard Kitty graphics an app prints, and tmux draws none in a pane (see docs/PARITY.md "Kitty graphics in a pane"); not degraded, unsupported |
"expected / untested" means: the plugin's declared needs (actions, event hooks,
overlay/split panes, HERDR_BIN_PATH CLI calls) all map onto something taw
implements or specifies, but no automated or manual test in this repository has
actually installed and run these specific third-party plugins. Re-classify a row
once someone does.
Events¶
All 19 of herdr's event names fire (third_party/herdr/reference/event-names.txt
is the list; matching_events in src/plugin/manifest.rs is what matches an
on = … against them). A hook receives HERDR_PLUGIN_EVENT set to the name and
HERDR_PLUGIN_CONTEXT_JSON holding whichever of workspace/tab/pane the event
is about, in the same record shapes the CLI returns.
There is no streaming equivalent: herdr's events.subscribe/events.wait need a
live socket, and a hook process launch is what replaces them here (see
docs/PARITY.md "Structural gaps"). An event is therefore delivered once, detached,
with no ordering guarantee against another plugin's hook for the same event.
| Event | Fired by |
|---|---|
pane.agent_status_changed |
the pipe-pane watcher, on every state transition it records: the event every other agent-facing one builds on (see docs/ARCHITECTURE.md "Watcher lifecycle") |
pane.created |
tmux's after-split-window hook (#{pane_id} is the new pane), whether the split came from taw pane split or a key; the verb itself emits nothing, so a pane is reported exactly once, and when the event is built the verb answers with the pane record it carries |
pane.closed |
tmux's after-kill-pane and window-unlinked hooks, diffing a pane roster kept in @agent_wizard_panes; tmux names no killed pane in any hook, so the id is recovered by comparing the roster with list-panes -a. Covers taw pane close and a hand prefix + x alike, once each. A process exiting is pane.exited, not a close (herdr keeps the two apart), and the roster cannot tell the two apart, since a pane that exited is gone from list-panes as a killed one is. So the pane-exited hook marks the pane in @agent_wizard_exited with tmux commands of its own, ahead of the job that fires pane.exited, and every detection leaves a marked pane out of what it reports. A detection reads the marks right after it lists the live panes, in the same tmux call: tmux runs the commands of a hook like pane-exited and window-unlinked from the server's own queue in the pass of its loop that sees the event, before any client's commands (3.2 and 3.6b alike), so a command that finds the pane gone finds its mark. Whoever drops a pane from the roster takes its mark off, and the marks are the last 400 characters at most. The exit's own take of the roster is a detection as well, so a pane killed in the same moment as another exits is reported by it, once. The marks keep the guarantee only while tmux's command queue is not blocked: a hook of your own that blocks tmux's command queue (a run-shell without -b, or an if-shell without -F, on any hook) holds the mark's command back behind it until it ends, for tmux runs nothing that is queued after a command that waits. A source of the plugin over itself (an upgrade of taw, a reload of tmux.conf) puts its new hook entries in before it takes the old ones off, so that a hook is never without one: an event in the moment between runs both, a duplicate where the other way lost it. The roster is written by a compare-and-set on a count of its writes, kept beside it in @agent_wizard_panes_generation: a writer reads the roster and the count, then the live panes, and its write lands only if the count is still what it read, else it reads again (at most 20 times). So a detection that read the panes before a pane's exit was handled cannot write them back over what that exit did, and two detections that overlap report one close once |
pane.exited |
the pane's process exiting while the pane itself stays (tmux remain-on-exit), from the same hook |
pane.focused |
tmux's after-select-pane hook |
pane.updated |
a pane's title, command, or reported agent metadata changing, as the watcher notices it |
tab.created |
tmux's window-linked hook (#{hook_window}/#{hook_session}), for taw tab create and a hand prefix + c alike, one event per window. When the event is built (a plugin or a [hooks] command listens for it), taw tab create answers with the records it carries, so the two are one read: the new window's name, which tmux's automatic-rename changes as its shell starts, is the same in both |
tab.closed |
taw tab close, and tmux's window-unlinked hook |
tab.focused |
tmux's session-window-changed hook |
tab.renamed |
taw tab rename, and tmux's after-rename-window hook |
workspace.created |
tmux's session-created hook (#{hook_session}), for taw workspace create, taw worktree create and a hand tmux new-session alike, one event per session. When the event is built, taw workspace create answers with the records it carries, as taw tab create does with tab.created, except that the workspace's focused is read again after the verb has moved a client onto it (the hook can read the session before the move, when no client is on it, and the event says what it read) |
workspace.closed |
taw workspace close, and tmux's session-closed hook. Each session closed is one event, so taw workspace close --group reports the workspace and each worktree workspace it closed |
workspace.focused |
tmux's client-session-changed hook (which is what fires when any client switches session, however it was asked to) |
workspace.renamed |
taw workspace rename, and tmux's after-rename-session hook |
workspace.updated |
a workspace gaining or changing worktree provenance, or its metadata being reported (taw workspace report-metadata) |
worktree.created |
taw worktree create (alongside workspace.created, tab.created, pane.created) |
worktree.opened |
taw worktree open, whether or not it had to create the session |
worktree.removed |
taw worktree remove (plus workspace.closed when the linked session was still open) |
layout.updated |
tmux's window-layout-changed hook, which fires for taw layout apply/set-split-ratio, taw pane resize/swap/move/zoom, and any resize or split done by hand |
taw plugin event <name> fires one event at every plugin that hooks it, which is how to
test a hook without waiting for the thing that normally triggers it. It fires the [hooks]
commands of that event too, and taw hooks test <event> is the way to try only those.
Hook-sourced payload fields are expanded through tmux's #{q:…} quoting (available since
tmux 3.1, below this plugin's 3.2 floor), so a session id such as $3 or a window name
containing $(…) reaches the plugin intact instead of being expanded by the hook's shell.
The popup itself is gated the same way: display-popup -B (a frameless full-size
popup) is used on tmux ≥ 3.3; below that the popup keeps its frame and title.
Hooks from the config¶
[hooks] in config.toml hears the same 19 events as a plugin, from the same code: a
command hooked to tab.created runs at the moment a plugin's [[events]] on = "tab.created"
does. It is told the event as TAW_* environment variables and, on standard input, as the
context a plugin gets in HERDR_PLUGIN_CONTEXT_JSON with "event" added. Nothing about the
plugin contract changes: a plugin keeps its own environment, log and manifest, and a [hooks]
command has none of them. A [hooks] command does get the environment tmux would give a new
pane of the session the event is about (the session's own over tmux's global environment, and
what either removes taken out) and TMUX beside the TAW_* variables, whichever process fired
the event, the watcher included; an event about no session, or one that is gone, has the global
environment alone. The README's "Hooks" section is the reference for the variables, the time
limit and the log.
Names that are not in event-names.txt and are taw's own:
| Name | Fires |
|---|---|
agent.blocked, agent.working, agent.idle, agent.done |
with pane.agent_status_changed, when the agent goes into that state. Only a change of state is one: a new agent in the same state, an agent leaving its pane and a change into unknown fire the status event alone. An agent whose process ends while it is working or blocked has its turn ended first, so agent.done (agent.idle for a pane being looked at) fires before it leaves; and an agent that only its launch hint names, read as unknown for a moment, fires the agent event of the state it comes back to |
sandbox.started, sandbox.stopped |
when the pane's watcher records that the agent in front of a pane runs in a container (@agent_container set) and when that container has left the pane (@agent_container taken off) or the pane has gone with it still in front (closed, or its tmux server ended), for a container taw sandbox started and for one typed by hand. The event's own sandbox field is {"profile", "runtime"}, what came or what left; agent is the agent in it. TAW_SANDBOX and TAW_RUNTIME say the two, here from the event's own field and on every other event about a pane in a sandbox from the pane's record, a notification and an agent.done included. A watcher started again announces no container that was already there, and a runtime that changes between two reads is a stop and then a start. Hooks only: no plugin event fires |
notification |
where taw shows a toast, so after the toast's delay and never for a pane you are looking at. TAW_KIND is request or done and TAW_MESSAGE is the toast's words on one plain line. taw notification show fires it too, with no pane, when no app holds the lease on that machine; under a fresh lease the app on the Mac that holds it makes the sound and runs the hook instead, with TAW_MACHINE set to that machine's name |
An agent whose integration reports its own state (kilo, kimi, mastracode, omp, opencode,
pi) fires these for what it reports, as any agent does for a change of state: taw pane
report-agent writes the state and nudges the pane's watcher, which takes the report in within a
moment, though the pane prints nothing, and announces the change, once, with the status event, the
agent.* event and the notification for the toast it earns. A turn shorter than one tick of the
watcher, working and then idle reported back to back, is announced as its start and its end,
from a stamp taw pane report-agent leaves on the pane. The same report again fires nothing. An
idle that ends a turn for a pane that is not being looked at is done, and stays done when
idle is reported again. Its end is announced too: when the pane's own shell is back at its prompt
and the last state taw had heard was working or blocked, the watcher writes the turn's end
(idle for a pane being looked at, done otherwise) as it does for any agent that exits, so the
status event, agent.done (agent.idle for a pane being looked at) and the done notification
fire once, and letting the pane go a read later is the status event alone. Watchers started by an
older taw announce no reports until they are started again: taw watch status marks them, and
taw watch attach-all --restart, run with the taw that is installed, starts each of them again.
Two more keys are not events. pre_close is asked before taw closes a session, a window or a
pane (taw workspace close, tab close, pane close, and the popup's x), and
pre_worktree_remove before it removes a worktree (taw worktree remove, and the popup's
x on a closed worktree). taw waits for them, and a non-zero exit refuses. A refusal is the
error code hook_refused, the one taw session open already reports for a create hook: the
message names the hook and carries what it wrote to stderr (the last 16 KiB at most), and
nothing is closed or removed. A hook that runs past [sessions] hook_timeout_seconds refuses
too, and 0 there means no limit here as it does for the create hooks, so a hook that never
ends holds the close. workspace close --group asks about every session in the group before
it closes any, and so does the popup's Close them too? answered with y (x on a session
with worktree sessions open, or on the last pane of its last window, since that closes the
session): one refusal keeps them all open. A removal the popup's remove anyway? has had
forced with y is asked about pre_worktree_remove as every removal is, before git runs
with --force, and can still be refused.
taw worktree remove also closes the worktree's session when it is open, and asks only
pre_worktree_remove about that; taw plugin pane close closes a plugin's pane without asking.
New result types, all additive:
| Verb | result.type |
Shape |
|---|---|---|
taw hooks list |
hooks_list |
{"timeout_seconds": 30.0, "veto_timeout_seconds": 10.0, "log": "<state>/logs/hooks.log", "variables": ["TAW_EVENT", ...], "sample": "...", "hooks": [{"name": "agent.done", "kind": "event", "when": "...", "commands": ["~/bin/log-finished"]}, ...], "unknown": [{"name": "agent.finished", "commands": ["true"]}]}. kind is event or veto; hooks lists every name, with an empty commands where nothing is hooked, unknown holds a name in the config that taw never fires, and sample says what hooks test runs against |
taw hooks test <event> [--pane <pane>] [--machine <name>] |
hooks_test |
{"event": "agent.done", "kind": "event", "limit_seconds": 30.0, "count": 1, "note": null, "sample": "...", "real_pane": null, "env": {"TAW_EVENT": "agent.done", ...}, "input": {"event": "agent.done", ...}, "hooks": [{"command": "...", "dir": "...", "ok": true, "status": 0, "timed_out": false, "failure": null, "stdout": "", "stderr": ""}]}, where stdout and stderr are the last 16 KiB of each at most, and a stream that was longer starts with the line (earlier output cut; the last 16 KiB follow). It runs the hooks for real against a sample whose ids (%999999, @999999, $999999) name nothing on any tmux server and are given to every event, with TAW_TEST set to 1, and sample says so. The session is called taw-hooks-test (taw-hooks-test--feat-x for the worktree events) and TAW_SESSION is =taw-hooks-test: there, the = being tmux's mark for exactly that name and the : saying the name is a session's, so that a hook that acts on it, a window command included, cannot reach a session of yours whose name only begins the same way; a session of yours called exactly taw-hooks-test is still reached, which the [ -n "$TAW_TEST" ] && exit 0 guard is for. With --pane <pane> (a tmux id or a public id) the ids, the session, the window and the directory are that real pane's, real_pane is its tmux id (null otherwise) and sample says a real pane was used; a pane that is not there is not_found before any hook runs. An event about something going away (a close, a removal or a process ending: workspace.closed, tab.closed, pane.closed, pane.exited, worktree.removed, pre_close, pre_worktree_remove) is refused with --pane unless --force is given, since the hook may close, remove or respawn what is really there: the message is on stderr, the exit status 2 as for a usage error, there is no envelope, and nothing runs; the refusal comes before the pane is looked up, and --force without --pane is a usage error. env holds the TAW_* variables only, since the rest of a hook's environment (tmux's global environment) may hold secrets. --machine <name> (the notification event only, and not with --pane) sets TAW_MACHINE to that name and puts machine in the JSON, for a name under [machines]; another name is refused on stderr as taw --machine refuses it, with exit status 2. A name that is not an event is invalid_argument |
taw hooks log [--lines N] |
hooks_log |
{"path": "<state>/logs/hooks.log", "count": 2, "lines": ["2026-09-30 10:11:12 UTC agent.done failed (exit status 6): ~/bin/push | curl: (6) Could not resolve host", ...]}, the last N (20) lines, oldest first |
Config keys, additive like the rest: [hooks] timeout_seconds (default 30, float, 0-3600, how
long an event hook may run before taw stops it; 0 means no limit), and under [hooks] any
event name or pre_close or pre_worktree_remove, whose value is a command line or a list of
them. taw config check warns about a name taw does not fire.
Result shapes and behaviour beyond herdr's¶
What a plugin reading taw's JSON gets from these verbs, beside the record shapes herdr's
CLI returns: taw's own fields and verbs, the error codes taw answers with, and where a verb
behaves in a way a plugin should know about.
| Verb | result.type |
Shape |
|---|---|---|
taw notification open-target |
ok |
{"pane_id": "%31", "target": "needs_you" \| "idle" \| "rest"}. target says the group of the pane it went to, which "next up" chose (the same rule as the popup's Tab): the next agent that needs you after the current pane, in tmux order, wrapping; else, with none that does, the next agent of any kind, idle (idle) or working, unknown or with no state reported yet (rest). With no agent pane at all the verb returns a not_found error rather than a result |
taw session open <path> |
session_open |
{"session": "oss/api", "created": true, "collision": "\"api\" is taken by ~/work/api", "switched": true}: session is the name actually used, created is false when a session already had that path, collision is null unless a shorter name was taken (and then holds the reason), and switched is false when there was no client to move. A pre-create hook that exits non-zero, or runs past [sessions] hook_timeout_seconds, refuses the open with the error code hook_refused: the message names the hook and carries what it wrote to stderr, or says it took too long, and nothing is created or switched to |
taw open [terms] |
none; session_open with --json |
For a person at a shell (tmux-session-wizard's t), so it prints nothing when it works. --json prints taw session open's own result for the directory it found, {"session": "api", "created": true, "collision": null, "switched": true}, under the envelope id cli:open, and attaches nothing; without it a terminal outside tmux is attached to the session. A failure is one line on stderr, taw open: <why>, with exit 1, or exit 2 for no terms outside tmux (there is no popup for the picker to open in) and for a run outside tmux with no terminal to attach (it says this is not a terminal, use --json to open without attaching before making anything), and for a relative path typed in a directory that cannot be read, which was removed under the shell (it says the current directory cannot be read; a path from the root or from ~, and a term, need no current directory); under --json it is the usual error envelope with exit 1, not_found for a term that no directory and no zoxide entry matches, hook_refused for a pre-create hook that refuses (nothing is created or switched to, as for taw session open), and invalid_argument for --json with no terms and for that relative path. With no terms inside tmux it opens the popup as taw ui open --new-session does: with no client attached it says no attached client to open the popup on, and a popup closed from outside ends it quietly, exit 0 |
taw sandbox [<agent>] [--profile <name>] [-- <agent args>] |
none | For a person at a shell, as taw open is, so it prints no envelope: it becomes the profile (a script) when it can, in the pane it is typed in, and returns when the agent exits. When it starts nothing it prints one line on stderr and exits with status 1. It takes no placement flag (--split, --window, --session, --worktree, --here, --no-focus): the pane is the one it is typed in, and a placement flag is refused as a usage error (several lines, Usage: taw sandbox ..., and the tip to make the place first), with exit status 2, apart from those one-line refusals |
taw sandbox new <name> [--from <profile>] (cli:sandbox:new) |
sandbox_profile |
{"name": "max", "path": "/home/you/.config/tmux-agent-wizard/sandbox/max", "from": "default"}: the file it made, executable, and the profile it copied. from is a string, default when --from is not given. A name that is taken, or that is no profile's name, is invalid_argument, and so is a default where taw writes its own (your config folder and taw's state folder are one), no folder for your profiles (TAW_CONFIG_DIR is not a full path), a profiles folder, or the config folder it is in, that is a link taw cannot follow, and a --from profile taw would refuse to run, with the reason; a --from that names no profile is not_found |
taw sandbox list (cli:sandbox:list) |
sandbox_list |
{"profiles": [{"name", "path", "yours", "default", "agents"}], "refused": [{"name", "why"}]}: the profiles taw can run, yours false for taw's own and default true for the one that runs when the config names none for an agent, agents the agents [sandbox.agents] gives each; and the profiles it would run but refuses, with why, in the words taw sandbox says it in (a default of yours that cannot run: not executable, not a file, or a link to a file that is not there; or an executable file whose #! line names its program by a path that is not a full one, or names one you cannot run; refused is empty when there are none). Any other file that is not executable or has a bad name is in neither list: taw config check says it |
taw agent start <name> --kind <kind> --pane <pane> --sandbox [--profile <name>] |
agent |
the agent record, as without --sandbox. It types taw sandbox <kind> [--profile <name>] [-- <agent args>] into the pane (taw's path in place of taw where the pane's PATH does not find it), at its prompt, and returns once taw sees the agent running in the container. --timeout is then 300000 by default, as 300000 is its most, because a first start builds an image; without --sandbox the default is 30000. --profile needs --sandbox |
every pane and agent record (taw pane get, taw pane list, taw agent list, taw agent get, a plugin's context) |
pane, pane_list, agent |
"sandbox": {"profile": "default", "runtime": "docker"}, and null outside a sandbox. profile is null for a container taw did not start (one typed by hand), and runtime is null while taw sees no container run the agent (a profile that is still starting). Nothing is removed or renamed |
taw ui open |
ui_open |
{"x": 0, "y": 50, "w": 200, "h": 49}, the display-popup geometry it computed and ran. --new-session (also a flag of taw ui) starts the popup with the new-session picker open, and Esc then closes the popup, where Esc on the picker n opens goes back to the tree. A popup closed from outside (taw popup close) is a normal end, with the same result. Its errors are tmux_error: no attached client to open the popup on when no client is attached, the popup ended with status N when the command in the popup ended with a status of its own and tmux said nothing, and tmux's own words when it refused |
taw integration status [target] |
integration_status |
{"integrations": [{"agent": "letta", "installed": true, "path": "~/.letta/hooks/taw-agent-session.sh", "version": 1, "current_version": 1, "outdated": false, "note": null, "experimental": true}]}. experimental is on every entry: true for an integration herdr lists as experimental (Letta), false for the rest, so a plugin can tell them apart the way herdr's own listing does. So are current_version (the version marker of the script this build installs, which version is measured against, integration by integration), outdated (whether installing again would change what is installed now: version is behind current_version or missing, or OpenCode's cli.json exists without taw's entry) and note (what is out of date or still to come and what to run, in plain words, or null; an OpenCode whose cli.json does not exist yet, because OpenCode 2 has never run, has a note and is not outdated). --outdated-only keeps the entries whose outdated is true. An installed Codex always has a note, which says that Codex asks once to review the new hook (Hooks need review) and that taw's hook must be trusted there, as the message of taw integration install codex does |
taw integration uninstall <agent> |
integration_uninstalled |
herdr's shape. It takes taw's own hook out of the agent's config and deletes the hook script and the saved copy of the config. A hook is taw's when it names the script's file name by a path whose folder is the script's folder, however that folder is spelled (a link, the two names the same folder has through links, or, for Claude, whose hook runner is a shell, a path that starts with an unquoted ~/, or with $HOME/ or ${HOME}/ unquoted or in double quotes, which is read against the home folder installs use, the folder being there or not). A hook that names the file by a path that cannot be resolved (a folder that has gone, ./hook.sh, ~other/..., another variable, a bare name, a ~/... or $HOME/... in single quotes or in the middle of a longer word, or any ~/... or $HOME/... for an agent not known to run a shell) might be taw's or might not, and the uninstall refuses it with the error code invalid_argument, naming the config file and the path it could not match, and changes nothing: not the config, not the script, not the saved copy. Remove the hook by hand if it is taw's, and uninstall again. The saved copy goes only when the config was given back from it or taw's entries were taken out of the config |
taw agent start <name> --kind <kind> --pane <pane> |
agent |
herdr's shape. The command is typed only once the pane's shell is at its prompt (nothing in front of the terminal but the shell, something drawn, its screen unchanged for 300 ms; a pane already there is typed into at once), because a shell that is still starting hands what is typed to whatever reads the terminal first, such as a question its startup asks. --timeout is one budget for the whole verb (default 30000 ms, at most 300000; a look at the pane already under way when it runs out is finished, a second or two on a busy machine): the wait for the shell counts against it, and the agent has what is left. A shell that has not come to its prompt within 15 seconds, or within the whole budget when that is less, fails the verb with the error code shell_not_ready: nothing was typed and the pane is not marked as holding an agent. A pane in a mode (copy mode, the clock, a chooser: #{pane_in_mode}) or with its input off (#{pane_input_off}, select-pane -d) fails it at once with the same code and the reason ("pane w1:p3 is in copy mode; leave it and try again", "pane w1:p3 takes no input; turn its input back on and try again"), since tmux gives what is sent to such a pane to the mode, which drops it with nobody attached, or to nothing, so waiting would only use up the whole budget with nothing typed. The pane is left in its mode, and is not marked; one put in a mode while the command is typed is found by the wait for the echo, sent no Enter and refused the same way, the message adding that the command may be left on its line ("pane w1:p3 is in copy mode, and the command typed into it may be left on its line; leave the mode, clear the line and try again"): the verb does not take the pane out of its mode to clear it. So is one put in a mode once the echo is seen, just before the Enter: the Enter is sent only while the pane takes input, in the one tmux call that asks (if-shell -F on #{pane_in_mode} and #{pane_input_off}), where the mode had it and the verb took the shell, idle with the command on its line, for an agent that did not start. The command goes as text, and its Enter after it, each only while the shell the wait found at its prompt is still in front of the pane, which the same call asks (#{pane_current_command}), as a restore sends its own: a pane put in a mode before the command is typed is typed nothing ("pane w1:p3 is in copy mode; leave it and try again"), and a program started in the pane after the look (one the user starts, or one a key binding runs, such as a history search) is sent nothing, and the verb fails with shell_not_ready ("pane w1:p3 had another program in front of its shell when the command was to be typed, so nothing was typed. Start the agent again once the shell is at its prompt.", or, for one that came in front after part of a very long command or between the command and its Enter, "pane w1:p3 had another program in front of its shell when the command was to be run, and the command typed into it may be left on its line; clear the line and start the agent again once the shell is at its prompt.") and puts back the pane's hint and pid. A key binding of the shell's that runs a program of its own directly on every key typed (a zsh widget or a bash bind -x that runs it as a command, not inside $(...)) has that program in front for a moment, and an Enter that comes then is held back the same way. A prompt such as starship's or oh-my-zsh's, and the bindings of atuin and fzf (C-r, Up, C-t, Alt-c and Tab, none of which the typed line sends), are not that. A pane closed while it waited fails with not_found. Never typed into: a pane whose process is not itself a shell (ssh, docker exec, a wrapper that does not exec), a shell that draws nothing (PS1='' in a blank pane), and a question the shell asks itself with a read of its own (zsh's compinit question about insecure directories, the oh-my-zsh update prompt). The look runs ps -o pgid=,tpgid=,comm= and pgrep -g <group> (the members of the terminal's foreground group; taw adds -a on macOS, which makes pgrep list its own ancestors, which it leaves out by default and so lists the group short when taw runs inside it): a ps with no tpgid column cannot tell, and then nothing is typed. The verb returns once the agent is really there: the pane's own shell is no longer in front, and the process in front is named for the agent, or its own integration reported it (@agent_reported_kind, or a state a hook wrote, which counts even at a look where ps cannot say whether the shell is in front), or the watcher has read a state for it off the screen with its kind's rules (any state but unknown; an agent run under node or a wrapper script is told by its own integration's report or by the watcher, which takes the 3 seconds a new agent reads unknown). A pane still shows what its last agent left, so the verb records the line it typed the command on (@agent_typed_line) once the shell has echoed the command there, and sends Enter after it (a shell that has not echoed it within a second has the line recorded where the command was typed), and the watcher reads only the rows below that line while it is on the screen: a state counts once something is drawn there. A shell that rewrites its prompt rows on Enter (a transient prompt) draws nothing below it (the rewritten row is the line when it holds the whole command after a prompt no wider than the one it replaced or than 16 columns (a prompt of two lines can end in a short ❯, and a transient prompt in its place can show the time), and nothing after it but, past two spaces or more, the right prompt zsh keeps on it unless TRANSIENT_RPROMPT is set, where the prompt row showed one; so is a command that wrapped and was taken back to one row, and a row of the agent's own that ends with the command, such as the footer that says to Run: mise upgrade claude, is not), and what the command prints first (an install's progress) is read as itself, unknown, with no notification, until a rule of the agent's kind matches, for a kind whose rules know its idle screen (an agent whose rules know only its work and its questions, such as OpenCode or Gemini, reads idle at any frame no rule knows, there as anywhere). The title the last agent left is not read as the new one's until tmux reports another, even when the new one sets the same title (Codex sets its folder's name): an agent whose idle only its title shows (Amp, Hermes, and Codex with [detection] supplements = false) reads idle at any frame no rule knows once it has drawn, as OpenCode does. A watcher started again (a plugin reload or an upgrade) reads an agent that drew before it as it did, so a done stays. With a right prompt on the prompt row, a row of the agent's at the bottom of a full pane that holds the command padded by spaces (a box or status row) can still be taken for the typed line. A row that ends in the bare command is taken for it when no more than the prompt's width (at least 16 columns) comes before it, as a tip of the agent's that ends run claude can be after a wide prompt. Once the line has gone off the screen (the agent's drawing scrolled it away, or a clear took it) or the agent has exited, the whole screen is read again: the line is taken for gone by the second read in a row that does not find it, and the first, which can come before the shell has finished redrawing it, reads nothing of the screen and keeps what the pane says, with the next read made at once. herdr reads the whole screen once a new agent's grace is over, last frame and all. What the pane recorded of its last agent (its report, state, session and name, and the @agent_pid recorded for it) is taken off before the command is typed, so a report the last agent left is not this one saying it started, and a second start in the pane of an agent that died is a new agent to the watcher, with the same 3 seconds of unknown when the watcher's first read after the start finds nothing in front of the pane naming it yet, and with none when its process names it at once, so that it is read from the rows below its line from that first read on. The pid of the agent's process is recorded as @agent_pid as soon as the job in front of the pane names the agent, by its processes' names or arguments; for an agent nothing names, it is the child of the pane's shell in front of its terminal that the verb's last two looks agree on, recorded once the wait is over (when the wait's last two looks did not agree, it looks again for up to 1.5 s, three times at the least however long a look takes on a busy machine, and past that until two agree or until the sixth, but begins none once --timeout has run out, when the pid is recorded only if the last two looks agree), so a helper the shell runs before the command (a preexec hook's, which names no agent and is gone a moment later) is not taken for the agent, and a verb that ends in timeout leaves the pid of what came up for the watcher. A command that was not found is not an agent: when the shell is back at its prompt with nothing running, the verb fails at once with the error code agent_not_started (a prompt that starts at the left edge of the screen is taken for a shell still working and ends in timeout, and a question the shell asks of its own after the command was typed cannot be told from its prompt) and puts back the @agent_kind_hint and the @agent_pid the pane had. The last agent's record and pid are taken off in one tmux invocation. A name that another pane's live agent already has fails the verb with invalid_argument, as agent rename does, before the wait for the shell and before anything is typed; a pane whose own shell is at its prompt with nothing running in it holds no live agent, whatever it records, since with no watcher nothing takes the record off an agent that has exited. A script that types into a pane the moment pane split returns waits for that pane's shell |
taw agent prompt <target> <text> --wait |
agent |
herdr's shape. --wait follows herdr's rule (src/api/wait.rs, prompt_agent): a prompt sent to an agent that is not working has to be followed by an observed working or blocked within 5 seconds (an idle or done the agent was already in does not count, and neither does a blocked it was already in), or the verb fails with the error code agent_prompt_stalled, herdr's own code and new here, or with timeout when --timeout is the shorter. A stalled prompt may still have been delivered. The wait for the end of the turn then takes the first of --until (default idle, done or blocked) within what is left of --timeout, which is one budget for the sending, the look for activity and that wait. An agent that is working when the prompt is sent has no such gate. A turn that began and ended between two looks is told by @agent_turn, which an agent that reports for itself moves with a report of work and with a report of a wait the pane was not already in: a report of the state the agent already was moves neither the stamp nor @agent_state_since, and is no activity, and a stamp that goes (the watcher takes it off with the agent's record) is no turn. An agent that leaves its pane during the look for activity or the wait for the end of the turn (its record taken off, or the pane closed), or whose process has gone while its record stands (for an agent only its kind hint names, such as one taw agent start started under a script or a wrapper: the pid recorded for it is dead or, with none recorded, the pane's own shell is idle; for any other agent, the pane's own shell is idle and nothing in front of it identifies as the agent, which is how a wait for an agent the user started by hand ends when no watcher takes its record off; a watcher is given two seconds to write the end of the turn first), or that another agent takes the pane from (another kind, another recorded pid where both say one, or, where no recorded pid shows it is the same process, another session id: a new session in the same process, as Claude's /clear and /resume make, is the same agent, which is herdr's reading too, since it compares the pane, the name and the kind, wait.rs lines 537 to 550), ends the wait with the error code agent_not_running, herdr's own (agent_wait_not_running, from wait_for_resolved_agent), unless it left with a status the wait was for: herdr answers a wait with the final status an agent is released with when the wait was for that status, and so does taw, from @agent_final_state, which the watcher leaves with the record it takes off and with the exit of an agent whose pane keeps its hint (and taw agent start does not, when it clears the last agent's record off a pane), when it is newer than the wait, to the millisecond. taw agent wait ends the same way, rather than run to its --timeout, or for ever without one. Unlike herdr, which refuses a prompt to a blocked agent with agent_blocked, taw sends it (see PARITY, "Known divergences") |
taw agent prompt <target> <text> and taw agent send-keys <target> <keys>... |
ok |
herdr's shape. Neither types anything into the pane of an agent that is not there, and each says so before anything is sent, in herdr's own codes and words (queue_agent_prompt, lines 111 to 215, and handle_agent_send_keys, lines 330 to 373, in src/app/api/agents.rs at v0.9.3; agent_target_error_body, src/app/agents.rs lines 297 to 300; runtime_hosts_agent, src/app/agents.rs line 427): a target that resolves to no pane is the error code agent_not_found ("agent target X not found"), for these two verbs only; a pane that hosts no agent is agent_not_ready ("agent X is not an active named agent"); and so is the pane of an agent that has gone from it while its record stands: "agent X is no longer the pane foreground process" for a prompt, and "agent X is not an active named agent" for keys, as herdr says them. herdr's runtime_hosts_agent asks of every pane that the live foreground job still identifies as the agent, by its process or its hint, and so does taw: a process in front that names another agent is not this one; for an agent taw agent start started or a session restore resumed, and only its kind hint names, the pid recorded for it stands for it while that process is in the foreground of the pane's terminal (its process group is the group the pane's own process, #{pane_pid}, says that terminal gives its input to, read with the pid's own in one ps call: a dead pid, a process suspended with C-z, with the shell or a job the user started in front of it, a job in the background, and a number the system has since given to a program in front of another terminal are not); and any other pane, or that one when its pid does not stand, hosts the agent only if the processes of its foreground job positively identify as it, by their names or their arguments (herdr's identify_agent_in_job, src/detect/mod.rs line 249, ported to src/detect/job.rs: node /path/to/bin/codex is codex, sh /path/to/pi is pi, and a container runtime is the agent its arguments start). A shell at its prompt, a shell started inside the pane (sh, ssh, nix develop, poetry shell, sudo -s), a pager, an editor and cat identify as no agent, whatever the pane records, which is what an agent started by hand leaves when it exits with no watcher to take its record off; a job the process table cannot be asked about names nothing. herdr tells a script by its name, so a script of the user's own called claude.js (or claude, codex, and so on) that node, bun, python or a shell runs is read as that agent whatever it does, as it is in herdr, and so here. taw adds four things herdr's reading lacks: bash's --rcfile and --init-file take a value, so bash --rcfile <dir>/bin/claude is no script called claude, a shell's -O, +O and +o take the name of an option, so bash -O extglob <dir>/bin/claude is claude, and the entries of the npm, pnpm and bun installs of Claude Code (@anthropic-ai/claude-code/cli.js) and Gemini CLI (@google/gemini-cli/dist/index.js) are those agents (README, "Beyond herdr"). A pane that a report is made for (taw pane report-agent, report-agent-session) while its recorded pid is dead has the pid taken off when the report comes from the job in front of the pane (the reporter's parent, or one of its ancestors, is in the process group the pane's terminal gives its input to, as the hook of a live agent is), since that reporter is not the dead pid's agent: the pid agent start recorded for an agent that has exited outlives it with the pane's hint, and said that an agent the user started by hand afterwards had gone. A report from anywhere else leaves the dead pid: the hook of the agent that died can report after it, from launchd or init, which took it in, and with the pid gone the watcher read the dead agent's last frame for good (working, for one that died mid-turn). A live agent that is itself a process with a shell's name (a script that bash runs, waiting in builtins, as the pane's own process) and whose arguments do not name it cannot be told from a shell at its prompt: the verbs that type refuse it, and a wait for it ends, after the grace, as a wait for an agent that has gone does (the watcher takes the same view of an agent that reports for itself). The text would otherwise land at the shell's prompt, and a shell may run it as a command. The check and the send are two steps, as they are in herdr (runtime_hosts_agent at line 165 of queue_agent_prompt, queue_user_input_submission at line 207), so the send is guarded: it is one tmux invocation that runs if-shell -F on the command tmux names for the pane being the one the check saw, and an agent that exits in between, which leaves the shell in front, is typed nothing, and the verb answers as it does for an agent that had gone. Nothing can close the gap fully: an agent that exits and leaves a command of the same name in front (a script whose shell is named like the shell that ran it) is not told apart. A prompt of more than a few KiB is sent in pieces, each guarded, since a tmux command line holds about 16 KiB. A pane in a mode (copy mode, the clock, a chooser) or with its input off is refused before anything is sent with shell_not_ready and the reason, as agent start refuses one, and left in its mode: tmux gives what is sent to such a pane to the mode, which drops it with nobody attached, or to nothing, and the verb answered as if the agent had been sent it. The guarded send asks that as well (#{pane_in_mode} and #{pane_input_off} in the same if-shell -F, with what it found printed back when it holds the send back), so a pane put in a mode between the check and the send is refused the same way, and a prompt whose text had gone when its Enter was held back says the prompt may be left on the agent's line. agent_not_running stays the code of a wait that ends (see taw agent prompt --wait above) |
taw session restore |
session_restored |
A command an agent reported for itself is typed in place of taw's own resume command for that agent, with the agent's word that it is there (@agent_reported_kind), which keeps it on the pane until the pane's own shell is back at its prompt, and each session is resumed in one pane: the plan says which pane has one saved in two (skip resume in pane %14: pane %12 resumes the same session). not_resumed holds the ids of the panes whose resume command was not typed because their shell had not come to its prompt within 15 seconds of the pane being made, or because the pane closed first, or because it takes no input (in copy mode or another mode, or with its input off: pane %12 is in copy mode; resume it with: claude --resume abc, said at once), or because another program was in front of its shell when the command was to be typed (pane %12 had another program in front of its shell when its command was to be typed, so nothing was typed; resume it with: claude --resume abc). The command is sent as text, and its Enter after it, each only while the shell the restore found at its prompt is still in front of the pane and the pane takes input, so a command of one word that tmux reads as the name of a key (Up) is typed and not pressed. Each has a line in plan, in plain words, with the command to run by hand (pane %12 did not reach its prompt; resume it with: claude --resume abc). A resume is typed as its pane's shell is ready, not when the slowest is, and after every session has been made. A pane or window that closed while a session was being made does not end the restore: a plan line says which window has fewer panes than were saved, and a session that closed under it is given up on (stopped restoring session 'alpha', with the windows it made and the ones it did not) while the sessions after it are still made. Every plan line that names a window names it by the number it had when the layout was saved and by its name (window 1 'zsh'), as the lines that say what is to be made do (create window 1 'zsh', select window 1 'zsh'). The agents of the windows made are typed all the same, and so are those of the sessions made before a later one failed (the error is still returned, with a line after its message for each pane that was left alone and the command to run in it). The panes resumed are marked as holding their agent (@agent_kind, and the session id or session path they were resumed from, a Claude pane resumed by id keeping its transcript path as well; a sandboxed pane keeps them in its kept resume, @agent_sandbox_resume, and in no plain option) as their command is typed. Autosave is held off while a restore runs (the server option @agent_wizard_restoring, set to the restore's process id and start time and cleared when it ends; autosave ignores it once that process is gone, so a restore that was killed does not leave it held, and when it is ten minutes old) and the restore saves once when it is done, if an autosave was asked for while it ran (the hooks ask on every new window and split), so the saved snapshot keeps the agents (not when it left a pane alone: the restore does not save over the snapshot it was restoring from, which still holds that pane's agent, but the next autosave records the layout as it is by then, so the resume it with line in plan is the record to keep; and without the hooks nothing asks, and a restore never rewrites the snapshot autosave keeps); the same never-typed-into cases as agent start apply, each listed in plan with the command to run by hand. A pane whose saved directory is gone (herdr 0.9.2, #4320) is typed nothing, since tmux opens it in your home folder: plan says pane %12: saved directory /home/you/api is gone, so tmux opened it in your home folder, and for an agent that was to be resumed goes on and nothing was typed; once the directory is back, resume it with: cd /home/you/api && claude --resume abc, with the pane in not_resumed; the pane gets no saved screen with [experimental] pane_history, carries herdr's restore_error (see taw pane get), and is saved as it was saved, its directory and its agent's session, until it is closed or has an agent of its own (taw names one in front of it, taw agent start starts one, or an agent reports its state or session), from when it is saved as it is. A pane that tmux opens somewhere else though its saved directory is there (one you have no permission to go into, which tmux opens your home folder for) is left alone the same way, and plan says where tmux opened it: pane %12: tmux opened it in your home folder and not in its saved directory /home/you/api, going on , so nothing was typed; resume it with: cd /home/you/api && claude --resume abc for an agent. A folder with a # in its name, which tmux read as a format, or one whose name ends in ;, which ended tmux's command, opens as itself, and a session or window named so is restored under its name (a session saved as C#Sharp came back as Charp, which the next restore did not find, and made again). With no --name, the layout is the one saved as default, or, when it has nothing to make (no session that is not open, and no window of an open session to add) and the tmux server's start is known, the newest layout of default taken before the server started: default.json if it was, otherwise the newest copy in session-snapshots/ whose own taken_unix is (never by the copy's file name or time), one layout and no older, and a restore that finds nothing to make there says so in the first plan line (nothing to restore: the layout saved at 16:23, before this tmux server started at 16:41, has every session open) and has created empty, as one with every session open does. --name is exactly that file. That look-back also adds the old layout's agent windows to open sessions, in any server, one that has run for days included, when their conversations run nowhere now, and --dry-run shows what it would add. A saved session that is open is not skipped whole: each of its saved windows with an agent pane whose conversation runs in no pane of the server (the same agent kind and session id; for an agent that reports its own resume command and has no id, the same command in the same directory; for a sandboxed agent with no id yet, the same session in its kept resume) is made again after the open session's last window (never into a number a closed window left free), with its saved name, layout, panes and directories, and its agents are resumed as in any session a restore makes; nothing already open is closed, moved, renamed, typed into or selected, the plain shell windows of an open session are not added, and a saved agent with no session to resume from does not count. plan says add saved window 1 'claude' to open session 'api' as window 3 for each (would add ... on --dry-run), added lists them (saved window 1 'claude' as window 3 of 'api', empty on --dry-run as created is), and skipped lists an open session only when nothing is added to it; --name follows the same rule, and a pane whose agent runs in another pane, in an added window or in a session made whole, is skip resume in pane %14: pane %12 already runs the same session or, on --dry-run, would not resume: pane %12 already runs the same session. With no tmux server running (tmux says so) every saved session is missing: the first is made by new-session -d, which starts the server with the user's own tmux config and, in that same tmux call, holds autosave off, the first plan line says started a tmux server (none was running), with your own tmux config (would start a tmux server (none is running), with your own tmux config on --dry-run, which starts nothing), started_server is true, and attach is how to attach (tmux attach -t '=api', null when no server was started); a plain restore with no server reads default.json, there being no server start to look back from. Three fields are in every restore, --dry-run included: snapshot_file (the file the layout was read from, <state>/sessions/<name>.json or a copy in session-snapshots/), snapshot_taken_unix (when that layout was taken, from the file) and server_start_unix (tmux's #{start_time} for the server, null when it could not be read). A restore with no --name that read a layout from before the server started, or after it, begins plan with restored the layout saved at 16:23, before this tmux server started at 16:41 (after for one taken since, would restore on --dry-run; a time carries its date when it is not the day it is compared with, the layout's against the server's start and the start's against today). A Claude pane is typed the profile it ran under: the folder above projects/ in the transcript path its Claude reported (<profile>/projects/<project>/<id>.jsonl, absolute, exactly those levels), as CLAUDE_CONFIG_DIR=<profile> claude --resume <id>, the profile quoted as every word of the line is; the default $HOME/.claude, a missing or relative path and another shape get the bare claude --resume <id>, and the project folder plays no part. Sandboxed panes and every agent but Claude are typed what they were (Codex's hook reports no home, so none is saved). --dry-run lists the line it would type in each pane it would resume, each as a plan line (window 0 'zsh', pane 1: would type: CLAUDE_CONFIG_DIR=/home/you/.claude-alt claude --resume abc; would not resume: <reason> or would not type: <reason> for a pane it would leave alone, a saved directory that is gone included) and in would_type, a list of {session, window, pane, line} (the window and pane numbers they had when saved), which is empty in a restore that makes anything. A Claude pane is typed the flags it was started with that set how the session runs, after the command and before the resume (CLAUDE_CONFIG_DIR=<profile> claude --effort max --model x --resume <id>): --effort, --model, --permission-mode and --agent with their values and --dangerously-skip-permissions, read by taw pane report-agent-session from the exact arguments of the Claude process in front of the pane (outside a sandbox), kept in the pane option @agent_claude_flags and in the snapshot's agent_claude_flags (an older snapshot has none and gets the line without them). The --effort typed is the level the session's last turn ran at when its transcript says (the effort of the last turn record that gives one in the last 2 MiB of the file at the saved transcript path, its perTurnEffort or its effort when that is null, which must be a regular file of the shape above; an effort set with /effort is on no command line), and the saved one when it does not. A prompt, print mode, the flags that choose a session and every other flag are never kept, and nothing is logged. |
taw pane get, taw pane list, and every pane record (session snapshot, agent records, events, a plugin's context) |
pane, pane_list |
restore_error, herdr's field: herdr's words for a pane that a session restore left as tmux made it because tmux did not open it in its saved directory (gone, or one tmux could not open), "Saved directory is unavailable. Restore the directory and restart this session.", and null on every other pane, where herdr leaves the field out. It stays on the pane until the pane is closed or has an agent of its own, where herdr's stays until the pane is closed. The record's cwd stays the directory the pane is in (your home folder, where tmux opened it), where herdr's record of such a pane gives the saved directory; the restore's plan names that directory |
taw worktree open |
worktree_opened |
herdr's shape without workspace, plus session_id, the running session's own tmux id (a session that open made has a new one, and the record's workspace_id stays the id it had when the worktree was created), which taw app uses to attach; null when no session of the record's name runs, or when tmux could not be asked for the sessions (the near side then attaches nothing). A worktree whose session is closed and whose checkout folder is gone is refused with not_found and worktree_not_found: the checkout of worktree feat/x is gone: <folder>, and no session is made (herdr's worktree_not_found), where tmux would make the session in your home folder without a word. taw worktree list leaves such a worktree out |
taw worktree remove --workspace <id> [--force] |
worktree_removed |
herdr's shape: {"worktree": {...}}. A checkout with modified or untracked files, or with submodules, is refused without --force with the error code worktree_dirty, which is new here (herdr has no such code); taw app asks remove anyway? uncommitted changes are lost on it, and y runs the verb again with --force. A removal whose worktree is already gone finishes |
taw pane report-agent <pane> --source <s> --agent <a> --state <st> [--seq <n>] [-- <command>...] |
ok |
in_order is false when the report was ignored because its --seq was not higher than the last one its source sent for the pane, or it had none and the source has sent one (herdr's order of reports); nothing of such a report is written. OpenCode's server plugin and its TUI plugin both report under integration:opencode, each numbering its reports with a counter of its own that starts at the microsecond it loaded, so when both report a pane's state, the one that loaded later wins, as herdr's do. A pane keeps the numbers of the last 32 sources to send one, in at most 2 KiB, the oldest going first. A --source of more than 128 bytes fails with invalid_argument (herdr takes any), and nothing of its report is applied, for this verb, report-agent-session and release-agent alike. What follows -- is the command that resumes the agent's session, which taw session restore types (README "Report an agent of your own"); one that cannot be kept fails with the error code invalid_resume_argv (herdr's), and nothing of the report is applied |
taw pane report-agent-session <pane> --source <s> --agent <a> [--seq <n>] [-- <command>...] |
ok |
in_order as for report-agent. A command from a source that does not hold the pane fails with the error code resume_not_accepted (herdr's), after the rest of the report is applied; invalid_resume_argv as for report-agent |
taw pane release-agent <pane> --source <s> --agent <a> [--seq <n>] |
ok |
in_order as for report-agent, and a release out of order is not made (released: false). A release also takes off the pane the resume command its source reported for that agent |
taw workspace close <w> [--group] |
ok |
{"workspace_id": "w1"}, plus "worktree_workspace_ids": ["w7"] when --group closed worktree workspaces along with it. A workspace whose worktree workspaces are still open is refused without --group, with the error code workspace_group_close_required (herdr's own, v0.9.1): the message names them and nothing is closed. A worktree workspace on its own, and any workspace with none open, close without the flag as before |
taw pane clear [<pane>] |
ok |
taw's own verb: herdr has the socket method pane.clear and the key keys.clear_pane, and no verb for it. {"pane_id": "w1:p2", "history_cleared": true, "screen_cleared": true}, with a note when something was left alone. On the alternate screen both flags are false and nothing is changed. With a program in front, or a shell that is not bash, zsh or fish, or synchronize-panes on in the pane's window (where tmux copies a key sent to one pane to all of them), only the history is cleared. When bash, zsh or fish is at its prompt (the test agent start uses, and that shell being the pane's own) it is sent Ctrl-L and redraws its prompt and the line being typed at the top, and screen_cleared is true once the screen shows it (the old screen gone into the history, or the cursor's row moved up): the verb returns after the redraw, which it waits for for at most three seconds. A screen that is clear already, the prompt at the top and nothing else on it, is not asked for anything and is true. screen_cleared is false with a note when the shell took Ctrl-L as text (a bash in readline's vi mode, or a zsh in a builtin such as read, which the verb takes back out with a Backspace wherever the ^L landed, or says it could not) or showed no sign of having answered. Errors: not_found for a pane that is not there, tmux_error for a tmux command that failed. @agent-wizard-clear-key binds it to a prefix key, and is empty (nothing bound) by default |
taw manifest show <id> |
manifest |
each entry of rules carries source: "manifest" for a rule from herdr's manifest, "taw" for one of taw's own supplements. A new supplement_rules count sits beside source (the manifest's own origin: bundled, remote or override) |
taw manifest list |
manifest_list |
each manifest carries supplement_rules, the number of taw rules appended to it (0 for every agent without a supplement file, and 0 for all of them when [detection] supplements is false) |
Machines¶
taw app and the machine verbs list the machines under [machines] (one name = "ssh destination" per line, or [machines.name] with ssh and taw) and go in to them, all over ssh. With no [machines] taw behaves as it always did. README's "Remote machines" is the guide; this is the contract.
| Verb | Envelope | Shape |
|---|---|---|
taw app [--go <target>] |
none | a screen, so a person's terminal only: it prints no envelope. --go opens on last, a pane of this machine as in %7, or <machine>:<id> as in studio:%16 |
taw machine list |
cli:machine:list |
result.machines: for each machine name, destination, taw, live, last_seen, taw_version, protocol and words, as the app last heard (no ssh) |
taw machine add <name> <ssh-host> [--taw <path>] |
cli:machine:add |
writes the machine into the config file under [machines] (a [machines.<name>] table with ssh and taw when --taw is given; a new table at the end when the file has none; a new file when there is none), keeping every comment and line of it; result.machine (name, destination, taw), result.config and result.note (the app shows the change the next time it opens). A name already listed, a name or destination that taw config check would drop, and a config that does not parse are invalid_argument, and the file is left as it was |
taw machine remove <name> |
cli:machine:remove |
deletes that machine's line, or its [machines.<name>] table, and nothing else; result.removed, result.config and result.note. A name that is not listed is not_found, and the file is left as it was |
taw image paste --pane <id> [--machine <name>] |
cli:image:paste (image_pasted: pane, machine, path) |
the image on this machine's clipboard into the pane, here or on that machine through its ssh (result.path is the file's real path; the path pasted has a backslash before each space it holds); the clipboard tool has 10 seconds to answer, and the popup and the app run each paste as this verb in a process of their own, in a session of their own, which finishes on its own after they have gone (a failure then is shown with display-message -c on the client the popup was on, given to the verb as a hidden --client; the app outside tmux has none, so none is shown); errors name the cause: no image on the clipboard (not_found), no clipboard to read on a Linux machine with neither WAYLAND_DISPLAY nor DISPLAY set (not_found), a clipboard tool that did not answer within 10 seconds (timeout), no clipboard tool (unsupported), the machine unreachable (machine_unreachable), the pane gone (not_found), the machine's taw too old to receive (unsupported); taw image paste and taw image receive paste into any pane they are given, a shell's too; only the list limits the paste to an agent's row |
taw image receive --pane <id> --bytes <n> |
cli:image:receive (image_received: pane, path) |
a PNG of exactly n bytes on standard input written into the state folder's images/ and its path pasted into the pane as a bracketed paste, with a backslash before each space the path holds; any other number of bytes is an error that says the image did not arrive whole (invalid_argument) and writes and pastes nothing; each call removes the image-<digits>.png and image-<digits>-<digits>.png files there older than 7 days, and an images that is a symbolic link is refused; what image paste --machine runs on the machine |
taw machine check [<name>] [--json] |
cli:machine:check with --json |
one line a machine, saying what it found; --json gives the envelope instead. Each machine is tried once, all at the same time |
taw machine feed [--protocol <n>] [--once] |
none | JSON lines, for the app to read over ssh (below) |
taw machine attach <machine> <id> |
none | becomes tmux on that machine over ssh: <id> is %N, $N or @N, that machine's own |
taw --machine NAME <verb> |
cli:machine for a failure on the way; the verb's own envelope otherwise |
the verb runs on that machine and its answer is printed as it was given. ui, app, machine, agent attach and open without --json are refused on stderr, status 2, with no envelope |
A forwarded verb waits as long as the far command runs, as a local one does: there is no time limit, and Ctrl-C ends it. machine_unreachable is the error code when no answer came: ssh could not log in or run, no taw is there, or the far taw ended by a signal (ssh reports 255 for it, so the message may be ssh's own last line or ssh exited 255 without saying why). Nothing is retried. The far command line is POSIX-quoted and run as sh -c '<line>' taw <args>, so a fish or csh login shell changes an argument that holds a backslash.
The feed is taw machine feed: protocol 1 is the only one, and --protocol names the one the app speaks. Each line is one JSON object with a type. The machine sends:
type |
Fields |
|---|---|
hello |
taw (its version), protocol, host, server (<tmux server pid> <start unix seconds>, or empty with no server). Always first |
state |
spaces, panes, agents, worktrees: everything the list draws of that machine, open_worktrees (left out when empty): the records of its worktrees one of its sessions runs, which the list never draws and the app uses to name a session's worktree sessions when it asks before closing it, and, beside a folder that sits behind a link there, the folder resolved on the machine (real_path, real_repo_root), which the app matches by and never draws |
toast |
kind, pane (that machine's id), text, title, body, agent, agent_kind, location, lease (the machine saw a fresh lease, so the app that holds it speaks the toast) |
ping |
none |
left |
holder, pane, go: an attach there ended |
and the app sends lease (holder, the app's <host>-<pid>) and walk (you, the name the app gives that machine, and stops, the walk's ids in the app's own names). Every field is read with a default, so a field a newer taw adds does not stop an older one.
Config keys and aliases¶
Keys taw reads beyond herdr v0.8.2's config, for a plugin or a config generator that
writes config.toml. Unknown keys are warnings rather than errors, so writing any of these
for a taw that does not know them is safe.
| Key | Default | Covers |
|---|---|---|
[keys] preset |
"modal" |
the popup's base keymap; "search" is the type-to-filter one, a pure fzf search in which ctrl-d on an empty query quits, as fzf's end of input does (a [keys] entry bound to ctrl+d answers first). Per-action entries override it. The text fields (the filter, the rename and branch prompts, the new session picker) take herdr's fixed editing keys, ctrl-b, ctrl-f, ctrl-d, alt-d, alt-backspace, ctrl-backspace and ctrl-y among them (herdr v0.9.1); they are not [keys] entries, tmux's prefix and prefix2 win over any of them, and in the search preset's query the word keys (alt-b, alt-f, alt-d) go by fzf's words, letters and digits, where the other fields go by herdr's |
[ui] depth |
"agents" |
"sessions", "agents", "all" |
[ui] layout |
"tree" |
"tree" or "flat" |
[ui] preview |
true |
|
[ui] show_git |
true |
|
[ui] projects_in_filter |
false |
also list the directories the n picker lists (zoxide, [sessions] project_dirs, path completion), the first eight, just under the sessions while the / filter is open; Enter on one opens or creates its session |
[ui.sidebar.agents], [ui.sidebar.spaces] token rules, hide |
unset | herdr v0.9.1's row token rules: up to 16 per styled token, the first whose condition the token's text meets wins. One condition, equals, contains, starts_with (with ignore_case), gt or lt (a finite number, compared strictly), then fg, bold and dim over the token's own, or hide = true, which takes the token and the space before it out of the row. state_icon and git_status take no rules, a malformed rule is an error in taw config check, and a key a token does not know (hide outside a rule, rule for rules) is a warning that names it |
[ui.toast] done_delay_seconds |
5 |
float, 0-3600 |
[sessions] name |
"directory" |
"directory", "full-path", "short-path" |
[sessions] zoxide |
true |
false stops the picker listing zoxide's directories and zoxide add after an open; taw open <term> asks zoxide for its match either way |
[sessions] project_dirs |
[] |
|
[sessions] pre_create_hook, post_create_hook |
unset | a command line, run as <hook> <session> <dir> |
[sessions] hook_timeout_seconds |
10 |
float, 0-3600: how long a create hook may run before taw stops it, and how long [hooks] pre_close and pre_worktree_remove may take to answer; 0 means no limit for all of them. A pre hook, or one of those two, that is stopped refuses (hook_refused) |
[detection] supplements |
true |
|
[session] startup_per_agent_delay_ms |
100 |
milliseconds between successive agent resumes in one session restore, across every session it recreates; 0 starts them all at once (herdr v0.9.2's own key) |
Aliases accepted for herdr's own spellings:
| Written | Read as |
|---|---|
[ui] agent_panel_sort = "priority" |
"attention" |
[ui] agent_panel_sort = "spaces" / "workspaces" |
"sessions" |
[ui.sidebar.spaces] |
[ui.sidebar.sessions] (one table under two names) |
[keys] goto, workspace_picker |
filter |
[keys] new_workspace |
new_session |
[keys] rename_workspace |
rename |
[keys] close_workspace |
close |
[keys] next_workspace / previous_workspace |
next_session / previous_session |
[keys] settings |
options |
[keys] open_notification_target is not accepted: that key is read from the
@agent-wizard-notification-key tmux option, so the same key works inside the popup
and out, after either of tmux's prefix keys, prefix or prefix2. prefix+… as a key
spec is refused for the same reason: that chord is tmux's. Accepted and ignored:
terminal.kitty_graphics, ui.pane_borders, theme.custom.light.*,
theme.custom.dark.*.
What a plugin author should expect to work¶
[[build]],[[startup]],[[actions]],[[events]](see "Events" above), and[[panes]]withplacement = "overlay"(adisplay-popup) or"side"/"bottom"(asplit-window).- Calling back into
tawviaHERDR_BIN_PATHfor any CLI verb, including reading the same JSON record shapes (pane,agent,workspace,tab,worktree) herdr's own CLI returns. min_herdr_versionchecked against[plugins] compat_herdr_version(default"0.9.3"), the same trust-preview gate (--yesor an interactiveybefore any[[build]]/[[startup]]command runs), and the same config/state directory layout (HERDR_PLUGIN_CONFIG_DIR,HERDR_PLUGIN_STATE_DIR).
What will not work¶
- Anything that dials
HERDR_SOCKET_PATHdirectly instead of going throughHERDR_BIN_PATH. - Anything that depends on
HERDR_PLUGIN_ACTION_IDorHERDR_PLUGIN_ENTRYPOINT_IDto distinguish invocations, rather than readingHERDR_PLUGIN_EVENTandHERDR_PLUGIN_CONTEXT_JSON. [[link_handlers]]: parsed, never dispatched (see above).- Kitty graphics, or any other rendering that assumes herdr's own terminal
renderer rather than a plain tmux pty (herdr removed its
pane.graphics.*methods in 0.9.2). completion_seqin an agent record: taw's agent records do not carry it (docs/PARITY.md, "Known divergences").
Socket methods taw has no verb for¶
herdr's v0.9.3 socket schema (third_party/herdr/reference/socket-api-schema.json, protocol 22) has 102
methods. Most are reachable over argv, with herdr's own field names: each group has its verbs under
taw <group> --help. Some are reached under another name: pane.wait_for_output is taw pane wait-output,
pane.focus_direction is taw pane focus --direction, integration.list is taw integration status,
server.agent_manifests is taw manifest list, server.reload_config is taw config reload,
server.reload_agent_manifests is taw manifest reload, pane.send_input is taw pane send-text and
send-keys (taw pane run sends text and Enter), and pane.clear is taw's own taw pane clear, which clears less
(docs/PARITY.md, the row of v0.9.2's #4383).
These 21 have no verb:
| Method | Why |
|---|---|
events.subscribe, events.wait |
a stream needs a live socket: see "Events" above |
workspace.move, workspace.move_block |
tmux has no session order to set: docs/PARITY.md, "Pinned" |
ping |
taw has no server to ask |
server.stop, server.live_handoff |
the server is tmux's: tmux kill-server stops it, and tmux has no live handoff to a new binary |
server.ssh_agent.register |
keeps a forwarded ssh agent working in herdr's remote panes; not ported |
pane.scroll, pane.copy_motion, pane.copy_search, pane.selection.read |
herdr's own copy mode, scroll and selection, driven by a client's viewport and its content revision; tmux's copy mode, scroll and selection are tmux's own |
pane.edit_scrollback |
opens the focused pane's scrollback in your editor (herdr's prefix+e); not ported: tmux capture-pane -p -S - gives the same text to any editor |
pane.link.resolve, pane.link.activate |
a link under a cell of herdr's renderer: see "Link handlers parse but do not dispatch" above |
command.invoke, client_shell.surface.set |
herdr's client shell, the surface its own UI draws; there is no such surface in tmux to invoke a command on or hand over |
release_notes.dismiss, product_announcement.dismiss |
herdr's own update notes and announcements; taw shows neither |
pane.clear_agent_authority |
herdr's own CLI has no verb for it either; taw pane release-agent is the one that gives up a pane's hook authority, for a named source and agent |
tab.move |
tmux's move-window and swap-window reorder windows; taw has no verb that calls them |