roost exposes tmux's scripting as small agent-shaped commands, so you (or a script, or one agent) can drive the others:
roost send api "run the tests" # paste a prompt + Enter into the "api" agent
roost read api # print the reply that agent just gave
roost screen api 20 # print what is ON its screen: last 20 non-blank lines
roost wait-done api # block until "api" is done/idle
roost wait-done api 300 # ...with a 5-minute timeout
Targets are [SESSION:]WINDOW — a bare name (api) resolves against the default main session; qualify it (roost send b:api …, or by index b:2) to reach an agent in another session.
Combine them to orchestrate parallel work:
for w in api web worker; do roost send "$w" "update the changelog"; done
for w in api web worker; do roost wait-done "$w"; done
echo "all three agents finished"
An agent (or you) can coordinate the fleet from inside roost. Targets are stable ids (for example %12) captured from spawn / split / view / whoami — capture them in a variable and reuse them. Friendly session:index and name forms still work too.
roost whoami — this agent's own target (its %N)roost spawn NAME [cmd] — open a co-agent window without attaching; prints its %Nroost split [-h|-v] [-t P] [-n NAME] [cmd] — a helper pane in your current window (prints its %N); compose layouts by splitting a specific pane, -n NAME labels it (border, tab, switcher) instead of showing the raw process nameroost view [-n NAME] cmd… — show cmd to the human: a pane beside you if your pane is wide enough, a window if it is not. Never steals focus, and a second call with the same NAME replaces the first view instead of adding another. See belowroost send TARGET "…" — reliably paste a prompt into an agent and submit it (refuses a 🛑 blocked target; see below)roost wait-done TARGET / roost read TARGET — wait for it to finish, then read the replyroost screen TARGET — what is on that pane's screen, chrome and allroost reply "…" — record what you just said, so another agent's read gets itroost close TARGET — end one agent its harness's own way, then close its pane (see below)spawn (window) is for a co-agent you wait-done on independently. split (pane) is for a helper you send / read in-place. view is for neither — it is for a human, and it picks pane or window for you. State is per-pane, so wait-done %N waits on that one pane whichever way it was created; give it a window target instead and it waits for every agent pane in that window.
roost viewspawn and split open things you will talk to. view opens a thing a person will read — a diff, a plan, a log tail, a build watcher:
roost view git diff main...HEAD
roost view -n tests 'npm test -- --watch'
roost view -n logs tail -f /tmp/build.log
It names no command of its own: whatever you hand it is what runs, so a pager, a renderer and tail -f all reach a human the same way. Four things it decides for you:
ROOST_VIEW_MIN_COLS columns wide (default 180, so both halves still clear 80) the view splits in beside you. Narrower than that and it opens a window instead, rather than squeezing two unreadable columns out of one pane.roost view with the same NAME (default view) kills the previous one first, anywhere on the server. That is what makes the next handover current rather than the fifth tab in a row. Panes under another name, and panes a human opened by hand, are never touched.%14
roost view: open in the pane to the right (%14).
roost view: press ctrl-s then l to go there; ctrl-s then h comes back.
The pane id goes to stdout — so v="$(roost view …)" captures one id and nothing else — and the directions go to stderr, where the human reads them.
A view is a handover, not a dashboard: most commands read their subject once, at startup, so what is on screen is the moment it opened. Call view again when there is something new to show.
read returns the reply, not the screenroost read is not a screenshot. Each agent records its last message onto its own pane as the turn ends — the Claude Code hook, the opencode plugin and the GitHub Copilot CLI extension all do this — and read returns that recording, whole. No line count is needed, and none is applied.
This matters because a full-screen agent draws furniture. The bottom of a Claude Code, opencode or copilot pane is an input box and status bars, so scraping the last few lines returns those, not the answer. A copilot pane is the starkest case: it draws on the alternate screen, so its last visible lines are a box outline and the footer ← open sidebar · / commands · ? help · tab next tab, with the agent's answer scrolled out of reach above.
When nothing has been recorded, read falls back to scraping the screen and says so on stderr:
roost read: no recorded reply for 'api' — showing the pane's screen instead.
The notice goes to stderr, so roost read api | grep … and loops over several agents stay clean. Three things cause it:
roost screen for those.Stop hook predates this feature. Run roost doctor on that machine — it names the exact fix.Never treat a fallback result as an agent's answer. If the notice appeared, the reply was not collected.
roost read --renderAgents answer in markdown. roost read prints it raw, because that is what a
script wants. Add --render (or -r) when a person is reading it, and the
reply is piped through preen instead —
which renders diffs as well as markdown, and agents send a lot of diffs:
roost read --render api
roost read -r %12
roost read -r api 20 # the line count still describes the screen
The flag goes in front of the target, and only in front of it. A flag in
the line-count slot is refused with a usage line rather than quietly read as a
number — roost read api --render used to print unrendered text at exit 0 and
say nothing.
It is opt-in and changes nothing else: plain roost read still emits the reply
byte for byte, so existing pipelines and greps are unaffected.
A screen fallback is never rendered. A recorded reply is markdown because
an agent wrote it; a pane's screen is terminal output, and a markdown renderer
deletes the characters in it that look like syntax — <ttyUSB0> disappears,
2*3*4 becomes 234, _low_ becomes low. Losing bytes out of the one
output you read to work out what a pane is doing is worse than not colouring
it, so the screen goes through untouched and --render says why on stderr.
--render is for eyes, never for a parser. Rendering is lossy by design:
use Vec<T> here comes back as use Vec here, because a renderer reads <T>
as markup. roost catches the case where a render comes back visually empty and
prints the raw text instead, but it cannot catch a render that merely lost
some of its characters. Anything that reads the output — a script, a grep,
another agent — should use plain roost read.
Neither a missing nor a failing preen costs you the text. In both cases the
raw text is printed, the reason goes to stderr, and the exit status is still
zero — the reply is the payload, the rendering is a convenience.
A recorded reply stays on the pane until the next turn replaces it, which is
deliberate — clearing it at the start of a turn would throw away an answer you
were merely slow to collect. So roost read says on stderr when what it is
handing you is not current:
A turn that finishes having said nothing — it ended on a tool call, or was
interrupted — clears the reply instead, so roost read gives you the
self-announcing screen fallback rather than the previous turn's answer wearing
this turn's badge.
An agent whose harness roost has no plugin for can still take part, the same way it can badge itself with roost state:
roost reply "the tests pass; two lint warnings in src/api.ts"
roost state done
Record the reply before reporting done. wait-done returns the moment the badge stops being working, so the other order leaves a gap in which a reader gets a screen scrape instead of the answer.
A reply longer than 12 KB is stored truncated, keeping the beginning, with a marker line saying how much was dropped.
To appear in roost status --json with a session and a directory, it can say who it is once, when its session starts:
roost identify --session "$MY_SESSION_ID" --cwd "$PWD" --harness mytool --transcript "$MY_LOG"
--transcript is optional. The session id is 1–128 letters, digits, ., _, : or -; the harness name is 1–32 lower-case letters, digits, _ or -; the two paths must be absolute, one line, and at most 1024 bytes. Anything else is refused with exit 2 and a line on stderr, and nothing is recorded. Calling it again with the same values costs one tmux read and writes nothing. Outside roost it does nothing and exits 0, like roost state.
roost send proves a new turn began, and names itDelivering the text is not the same as the agent acting on it. Between the Enter and the agent's prompt-submit hook stamping ⏳ working, its badge still reads ✅ from the previous turn — so wait-done returns at once and read hands back the previous turn's reply. A well-formed answer to somebody else's question, at exit 0, silently.
So on a target whose badge reads ✅ done or 💤 idle, send waits — briefly, and bounded — for a new turn to begin, and prints one line naming it:
out="$(roost send api "run the tests")"; rc=$?
read -r pane turn <<<"$out"
if [ -n "$turn" ]; then
roost wait-done --turn "$turn" "$pane" 300
roost read --turn "$turn" "$pane" # the reply to THIS prompt, provably
else
roost wait-done api 300 # no turn numbers here; see below
roost read api
fi
Branch on $turn. An empty capture is a normal outcome, and roost wait-done --turn "" … is refused at exit 1 — so passing it straight through turns "this target has no turn numbers" into what looks like a failure of the agent.
Take the exit status off send, not off the read: read succeeds whatever send did, so read -r pane turn <<<"$(roost send …)" discards the code in the table below.
Two fields: the %N pane the text went into, and the turn that prompt started. The pane is not decoration — a window target such as api covers every agent pane in that window, and --turn needs the single pane your text reached. send resolves it for you.
You get both fields or neither. Nothing is printed when there is no turn to name, and each case is ordinary:
send is exactly as fast as it always was;working or blocked — it is mid-turn, the prompt queues inside the harness, and the turn it will take is not the next number;ROOST_RECORD_DIR=""), so there are no turn numbers at all. send still proves a turn began; it just cannot name it;The wait is bounded by @roost-send-turn-timeout, in seconds, default 10. Anything that is not a whole number of seconds reads as 10; anything over 120 is clamped to 120; leading zeros are stripped, so 08 is eight seconds and 00 is the same as 0. Set it to 0 to turn the whole thing off and get the pre-existing behaviour back:
tmux -L roost set-option -g @roost-send-turn-timeout 0
roost send --fromEvery prompt that starts a numbered turn is recorded with who sent it, beside that turn in the pane's kept replies (replies/000007.sent_by next to replies/000007). roost read --json shows it as sent_by, and roost send --json says what was kept.
me="$(roost whoami)"
roost send --from "$me" api "run the tests"
The label is one word of at most 64 bytes, with no space and no control character, and it does not begin with a dash (so a mistyped --from --json is an error, not a sender called --json); anything else is refused with exit 2 before anything is pasted. A bare %N is kept as pane:%N, the spelling the default uses, so one sender has one name in the record. Without --from, the label is pane:%N when the send came from a pane on this server, and human otherwise — a send typed at a shell outside roost.
Three things to know:
--from records who sent it; it does not tell the receiving agent. Until roost adds an envelope, keep writing [from %N] at the front of a prompt when the receiver needs to reply to you.send names a turn (the %N TURN line), so every case above that prints nothing keeps nothing: a pane that is not an agent, a target already mid-turn, replies not recorded, a turn that errored. A pane straight from roost spawn claude has no badge until its first prompt, so that first prompt is not numbered and keeps no label either; every later one does.--from takes whatever word you give it. The default pane:%N is checked against the server; a label you pass is not.roost sendroost send verifies its own submit rather than trusting a fire-and-forget send-keys, and its exit code says what went wrong:
| exit | meaning | what to do |
|---|---|---|
4 | submitted, but no turn began inside the bound — or the pane went away first. The text was delivered | do not send it again: the prompt may be running. roost screen it, and roost doctor if the badge never moves |
3 | the target is 🛑 blocked — a permission dialog is open | wait and retry the same target, or pass --force |
2 | the target is unusable — it does not exist, or its pane is dead | re-resolve the target |
1 (roost send: message) | delivery to a valid target failed — the text never reached the pane, could not be confirmed to have reached it, or was delivered but never left the input line even after retrying extra Enters | retry the same target, or roost screen it to see what is stuck; do not re-resolve |
1 (usage: message) | a missing argument | caller bug, not a delivery failure |
4 is not 1, and the difference is the one that costs you. 1 means nothing was submitted, so re-sending is safe. 4 means the text is already in the agent. A caller that treats them alike runs the prompt twice.
roost send delivers your message as a bracketed paste, not as typing.
That distinction is the whole reason long messages work. Typed at speed, an agent's input box could keep only the last chunk of a big message and throw the front away — measured against Claude Code 2.1.263, a 3502-byte briefing arrived as its last 436 bytes, with no error and exit 0. The receiving agent answered a message whose first three thousand bytes it had never seen.
A bracketed paste tells the application "this is one block of pasted text", which is a different code path in the agent, and the same bytes then arrive whole. If you have been writing briefings to a file and sending the path instead, you no longer need to.
Two consequences worth knowing:
[Pasted text #1] instead of your text. That is the
agent's rendering of a paste, not a truncation. The full message is submitted.roost send also confirms the message actually reached the pane before it
presses Enter, and retries if it did not — a pane that is still booting can
discard input, which is the other way a briefing used to vanish. If it cannot
confirm delivery it submits nothing and exits 1, so a caller in a loop
retries instead of moving on. It gives up after 15 seconds by default:
tmux -L roost set-option -g @roost-send-ready-timeout 30
The message passes through one tmux buffer on the way to the pane, and roost is deliberate about it:
roost-send), so sends never pile up.-w) is never passed.You can check the first two yourself with prefix + =, which lists tmux's
buffers: a send should add nothing to it.
send pastes your text, waits a beat, then presses Enter. If a permission
dialog is open at that moment, the text goes into the dialog and the Enter
activates whatever option is highlighted. One agent driving another could
therefore answer a prompt that existed to ask you — silently, because the
dialog swallows the text and the submit verification is satisfied.
So send refuses when the target's badge is 🛑 blocked. That is not screen
scraping: the agent reports its own state through a hook, so the signal is
exact.
roost send api "run the tests" # exits 3 if api is blocked
roost send --force api "run the tests" # send anyway
--force must come before the target. Anywhere later it would be
indistinguishable from a message that happens to start with that word.
In a loop, exit 3 is the one code worth retrying on — and 4 must never be retried, because the text is already in the agent:
while :; do
roost send api "run the tests" && break
rc=$?
# 1 and 2 will not fix themselves, and 4 already delivered the text —
# resending it would run the prompt twice.
[ "$rc" -eq 3 ] || exit "$rc"
sleep 10 # blocked: wait for the human, then retry
done
roost wait-done --turn NPlain wait-done asks "is this target still busy?". On a target that has not yet stamped itself busy the answer is no — from the previous turn — so it returns at once. --turn N asks the question that cannot be answered by an older turn: has turn N been recorded?
out="$(roost send api "run the tests")"; rc=$?
read -r pane turn <<<"$out"
[ -n "$turn" ] && roost wait-done --turn "$turn" "$pane" 300
It needs the %N pane, which is why send prints one. Turns are numbered per pane, so a window target covering several agent panes has no single numbering to wait on, and wait-done --turn refuses it rather than guessing at the active pane. It also refuses a target whose replies are not recorded: turn N would never be written, so the wait could only ever time out. Both refusals happen before any waiting, at exit 1, naming which one it is.
Pass a timeout. As with plain wait-done, omitting TIMEOUT_SEC waits with no limit — and a turn that never arrives has nothing to end the wait. An agent that exited leaves a pane whose badge still reads ✅: not busy, not an error, and no turn coming.
roost wait-donewait-done does not treat "stopped being busy" as success. A window target gives the same answer a %N pane target would, for every agent pane in the window.
| exit | meaning | what to do |
|---|---|---|
0 | done — every agent pane is no longer working or blocked, including a pane that finished and then closed while you waited | roost read it |
1 (is in error state) | an agent pane is 💥 error; a second line names the reason when the adapter recorded one | go and look, or re-prompt |
1 (timed out) | still busy when the timeout ran out | wait longer, or roost screen it |
1 (usage:) | the timeout is not a whole number of seconds — abc, -5, 1.5 or an empty string. Refused at once, before any waiting | fix the argument; omit it, or pass 0, to wait with no limit |
1 (--turn message) | --turn was given something it cannot answer: a value that is not a positive whole number, a target that is not a %N pane, or a pane with no usable reply record. Refused at once, before any waiting | pass the %N and the turn number that roost send printed |
1 (timed out waiting for turn N) | the turn you named was still not recorded when the timeout ran out | roost screen the pane; the agent may never have started it |
2 (died) | an agent pane closed, or its process exited, or an agent started from the pane's shell prompt exited, while its badge read working or blocked — the message names the pane | re-resolve or respawn; do not wait again |
2 (is gone) | the target did not exist when wait-done started | re-resolve the target |
An agent you typed at a shell prompt in the pane is caught too: when its hook marks it busy, roost notes which job holds the pane's terminal, and wait-done reports died once that job is gone and the shell has the terminal back.
Three limits:
is gone cannot tell "finished, then closed" from "died". A pane that is already gone has no badge left to read. If a one-shot agent may have closed before you started waiting, check its result another way.caffeinate) that outlives it for up to 300 seconds, and wait-done waits until no process the agent started is left. A shorter timeout exits 1. A Claude killed between replies is caught at once.exec and keeps running after the agent dies, the script still holds the terminal, so wait-done waits for its timeout and exits 1. Run the agent directly, or end the script when the agent ends.A target that was gone used to exit 0. If a script relied on that, it now sees 2. A set -e script stops on a dead agent rather than continuing.
roost closespawn and split open an agent. close ends one, and closes its pane:
roost close %12 # ask the agent to exit, wait up to 10 s, close the pane
roost close api 30 # a window name works too, if the window has one pane
roost close --force %12 # a working or blocked agent: close it without asking
It does three things, in order:
/exit for Claude Code, opencode and copilot, and /quit for codex and pi. The harness then does whatever it does on a clean exit. A pane with no badge (a shell, a log tail) is not an agent, so nothing is typed into it. Nothing is typed into a pane where only a shell is left, either, even if an old badge is still on it.What it refuses, and does not change:
roost wait-done, or pass --force.--force, the pane is closed and the dialog is not answered.close takes one pane. Give it the pane's %N, which roost status lists.roost kill SESSION is the command for that.--force closes the pane without asking.--force lifts the first two refusals and the last one. It never types anything: it closes the pane at once. It does not lift the window and session refusals, because those protect panes other than the one you named.
If the harness opens a dialog while it exits, for example "background work is still running", the pane goes 🛑 blocked. close then stops with exit 1. It answers nothing, and the pane stays open for you.
The kept replies are not deleted. After a close, roost read %12 still prints that agent's last reply. roost forget is the only command that deletes kept replies.
roost close| exit | meaning |
|---|---|
0 | the pane is closed |
1 | refused, or the agent did not leave in time, or it opened a dialog. The pane is still open. stderr says which |
2 | the target was already gone. One line on stderr |
--jsonThe commands above print English for a person. A program that parses that English breaks the day a message is reworded. So seven commands also print a JSON document when you pass --json:
roost status --json
roost whoami --json
roost read --json api # flags go before the target, as with --render
roost screen --json api 20
roost send --json api "run the tests" # flags go before the target, as with --force
roost close --json %12 # flags go before the target, as with --force
roost state done --json # the flag goes AFTER the state
Without --json, every one of them prints exactly what it printed before, byte for byte, with the same exit status.
"schema": 1 and "command" come first in every document. Check schema before you read anything else.null, not a missing key. An empty list is [].working, blocked, done, error, idle), not badges.lossy says when text may not be what the agent wrote. An agent's reply is stored as raw bytes. If those bytes are not valid UTF-8, each broken sequence becomes U+FFFD (�) and the document says "lossy": true. On tmux 3.4 and 3.5a it can also be true for another reason; see below.whoami outside roost, exits with the same status and the same stderr as the plain command, and stdout is empty. Check the exit status first.schemaschema goes up only for a change that can break you: a field removed or renamed, a field's type or meaning changed, or a change to when a document is printed. These do not change schema, so write your code to allow them:
--jsonroost status --json{"schema":1,"command":"status","running":true,"socket":"roost","socket_kind":"name","sessions":[{"name":"main","windows":2,"attached_clients":1}],"panes":[{"id":"%3","session":"main","window_id":"@1","window_index":1,"window_name":"api","pane_index":1,"name":null,"command":"claude","state":"working","since":1789000100,"session_id":"0f5c3a52-7d1e-4b8f-9c2a-51e3f0a7b6d4","cwd":"/home/you/src/api","harness":"claude","transcript":"/home/you/.claude/projects/-home-you-src-api/0f5c3a52-7d1e-4b8f-9c2a-51e3f0a7b6d4.jsonl"}]}
| field | type | meaning |
|---|---|---|
running | bool | a roost server answered. With no server: false, and both lists are []. Exit 0 either way |
socket | string | the socket name or path roost used |
socket_kind | "name" or "path" | whether tmux needs -L (a name) or -S (a path) for it |
sessions[].name | string | session name |
sessions[].windows | integer | number of windows |
sessions[].attached_clients | integer | clients attached to it; 0 when nobody is looking |
panes[].id | string | the pane's %N — use this as the target |
panes[].session | string | its session |
panes[].window_id | string | its window's @N |
panes[].window_index, pane_index | integer | the session:window.pane numbers |
panes[].window_name | string | window name |
panes[].name | string or null | the name given with spawn, split or view -n; null when there is none |
panes[].command | string | what the pane is running |
panes[].state | string or null | the agent state; null for a pane no hook has stamped (a plain shell) |
panes[].since | integer or null | epoch seconds when that state was set |
panes[].session_id | string or null | the agent's own session id — the one its harness resumes by. Not the same as session, which is the tmux session |
panes[].cwd | string or null | the directory the agent's session started in, absolute |
panes[].harness | string or null | which agent it is: claude, codex, opencode, copilot, pi, or the name an adapter passed to roost identify |
panes[].transcript | string or null | the path of the agent's transcript, when its harness keeps one file per session (Claude Code, codex, pi) |
The last four are always present, and null until the agent's first event says who it is — a pane that has never run an agent has four nulls. Nothing clears them when the agent exits, so they stay until another agent in the pane says who it is: a pane back at its shell prompt still names the last agent that ran there. They follow the agent, not the pane: clearing a conversation gives it a new session_id, and resuming one brings its old session_id back in a new pane. roost records what the harness says now and never links the two.
Two names are not the obvious ones, on purpose. The key is session_id, not session, because session has meant the tmux session since schema 1, and giving a key a new meaning would be a breaking change. And roost keeps the transcript in the tmux option @roost-transcript-path, not @roost-transcript: that older option holds a different record (the time a permission dialog opened, then a path), and it is removed each time a dialog is declined, so the transcript would disappear with it.
A codex pane is identified at its first prompt, because that is when codex first says anything. On codex-cli 0.157.1 it is not identified at all: that version runs every agent's hooks in one shared background process, which cannot tell roost which pane is whose, so a codex pane's four fields stay null rather than risk naming another agent's session. An opencode 2 pane is identified from the chat in front: switching tabs identifies the chat you switched to, and the home screen leaves the pane's identity as it was. At startup that may be another agent's, from a harness that ran in that pane before. Its cwd is where opencode was started, and it has no transcript, because opencode 2 keeps its conversations in its own storage.
roost whoami --json{"schema":1,"command":"whoami","pane":"%7"} — pane is your own %N. Outside roost it exits 1, as the plain command does.
roost read --json{"schema":1,"command":"read","target":"api","pane":"%3","source":"reply","state":"done","stale":false,"error_reason":null,"turn":7,"text":"Tests pass.","lossy":false,"sent_by":"pane:%1"}
| field | type | meaning |
|---|---|---|
target | string | the target as you typed it |
pane | string or null | the %N it resolved to |
source | "reply" or "screen" | whether text is the recorded reply or the screen fallback described above |
state | string or null | the agent state when it was read |
stale | bool | true when this is a stored reply on a pane that is working, blocked or error — the reply is from an earlier turn (see "A reply is never served as fresher than it is") |
error_reason | string or null | why the pane is in error, when its adapter recorded a reason |
text | string | the reply, whole; or the last LINES non-blank lines of the screen. No trailing newline |
lossy | bool | text may differ from the bytes the agent wrote: invalid UTF-8 was replaced, or tmux 3.4/3.5a had already rewritten the reply (below) |
turn | integer or null | the kept turn whose file supplied text, numbered as roost send numbers them. null when replies are not recorded, when text is the screen, or when what the pane holds is not a kept turn's reply |
sent_by | string or null | who sent the prompt that started that turn: the roost send --from label, else pane:%N or human (see "Who sent it"). null whenever turn is null, and for a turn that was not started through roost send |
--json and --render cannot be combined: one is for programs and the other is for people.
On tmux 3.4 and 3.5a, a reply can arrive already rewritten. Those versions store a control byte, DEL, or a byte of invalid UTF-8 as escape text (\001, \r, \377) the moment the reply is recorded, and they do not escape a backslash, so the escape text and the same characters typed on purpose are identical afterwards. roost cannot tell them apart, so it does not guess: text is exactly what tmux holds (plain roost read prints the same), and lossy is true whenever text contains such a sequence on a server that writes them. tmux 3.6 and later keep the bytes, and there lossy means only replaced UTF-8. A blank screen gives "text": "" and exits 0.
roost screen --json{"schema":1,"command":"screen","target":"api","pane":"%3","lines":20,"text":"…","lossy":false} — target, pane, text and lossy as for read. lines is the count you asked for (default 40), or null when you passed something other than a count, such as +3. A blank screen gives "text": "" and exits 0.
roost send --json{"schema":1,"command":"send","target":"api","pane":"%3","turn":7,"started":true,"state":"working","sent_by":"pane:%1"}
| field | type | meaning |
|---|---|---|
target | string | the target as you typed it |
pane | string or null | the %N the text was delivered to — for a window target, the pane it resolved to |
turn | integer or null | the turn this prompt started, for wait-done --turn and read --turn. null when there is no number to give: the target is not an agent, it was already mid-turn, or its replies are not recorded |
started | bool | true when roost proved a new turn began. false means it did not watch — the target had no badge, or was already working/blocked, or the bound is set to 0 |
state | string or null | the agent state just after the wait |
sent_by | string or null | who sent the prompt, as kept beside the turn: the --from label, else pane:%N or human (see "Who sent it"). null whenever turn is null: with no turn there is nothing to keep it beside |
A send that fails prints nothing on stdout and keeps its exit status, as every --json command does — including exit 4, where the text was delivered but no turn began. Read the exit status first.
A turn of null with started of true is the ordinary shape when replies are not being recorded: roost proved the agent acted, but has no numbering to name the turn with.
roost state STATE --jsonFor an adapter or a script that badges itself. It sets the state exactly as roost state STATE does, then tells you what landed:
{"schema":1,"command":"state","requested":"done","state":"done","pane":"%7","recorded":true}
| field | type | meaning |
|---|---|---|
requested | string | the state you passed |
state | string or null | the state as recorded (an unknown word is recorded as idle); null when nothing was recorded |
pane | string or null | the pane it was set on; null outside roost |
recorded | bool | the pane's state was read back and matches. false means the badge did not land — outside roost, or on a server or pane that is not the one you think |
It exits 0 even when recorded is false, as the plain command always has. Put --json after the state: roost state --json with the flag first still means "set idle", as it always did.
roost close --json{"schema":1,"command":"close","target":"%12","pane":"%12","state":"done","harness":"claude","exit_command":"/exit","forced":false}
| field | type | meaning |
|---|---|---|
target | string | the target as you typed it |
pane | string | the %N that was closed |
state | string or null | the badge the pane had when close started; null for a pane with no badge |
harness | string or null | the harness roost found in the pane; null when there was no badge, or when it could not tell |
exit_command | string or null | the text roost typed to ask the agent to exit; null when nothing was typed (no badge, --force, the agent had already left, or only a shell was left in the pane) |
forced | bool | --force was given |
A close that fails prints nothing on stdout, as every --json command does. Read the exit status first.
wait-done does not take --json yet. send --json is new; schema did not change for it, because adding a command that takes --json is not a breaking change (see above).
roost eventsstatus --json says where every pane is now. roost events says each time one moves, so a program can react instead of polling:
roost events # every line still in the log, for this server
roost events --follow # ...then each new one as it happens
roost events --since "$cursor" # only what came after a line you already have
roost events --pane %3 # one pane only
It prints one JSON object per line. It needs no --json: there is no English form.
{"schema":1,"ts":1789000100,"server":"1789000000-4242","pane":"%3","window":"@1","session_id":null,"event":"state","from":"working","to":"blocked","turn":7,"reason":"Bash: npm test","cursor":"1789000000-5151:1:1834"}
| field | type | meaning |
|---|---|---|
schema | integer | 1. The rules above apply: new fields can appear, and a value you do not know is unknown, not an error |
ts | integer | epoch seconds when the line was written |
server | string | the server's boot key. A %N is reused after a server restart; this is not |
pane | string | the pane's %N |
window | string | its window's @N |
session_id | string or null | the agent's own session id, when the pane has one recorded; null when not. Not panes[].session in status --json, which is the tmux session's name |
event | string | state when the state changed. blocked_on when the pane is still blocked, but on a new dialog. closed when the pane is gone |
from | string or null | the state before; null for a pane no hook had stamped. For closed, the pane's last state |
to | string or null | the state now. null when the pane has no state: roost cleared a 🛑 after a declined dialog, or the pane closed |
turn | integer or null | the turn this belongs to, numbered as roost send numbers them; null when replies are not recorded, and on closed |
reason | string | for blocked, what the dialog asks for (Bash: npm test); for error, why; for a 🛑 roost cleared, the dialog was declined or dismissed; otherwise "". Never any prompt or reply text |
cursor | string | where this line is in the log. Pass it to --since to resume after it |
What writes a line.
blocked. Two dialogs in a row are blocked → blocked, and without that line a reader would keep showing the first.send, read and wait-done clear it as they find it, and write blocked → null.roost events --reconcile for each of those, and it writes one closed line per pane that is gone.A hook call that changes nothing writes nothing. A Stop that roost holds back because a dialog is still open writes nothing either, because the badge did not move.
Resume where you stopped. Keep the cursor of the last line you handled. After a restart, roost events --since "$cursor" --follow prints exactly the lines after it, and nothing twice.
--follow ends when the server it follows stops, after printing the last lines, and says so on stderr. It checks twice a second, and costs nothing between checks.
| exit | meaning | what to do |
|---|---|---|
0 | printed what there was (perhaps nothing). With --follow: the server stopped | with --follow, start again when a server is back |
3 | the cursor is no longer in the log: it was rotated away, or the log was cleared | re-read roost status --json, then start again without --since |
2 | no roost server is running on this socket | start one, or point ROOST_SOCKET at the right one |
1 | a usage error, a --since that is not a cursor, or the log is off | fix the call, or turn the log on |
Where it lives. In events/ inside the kept-replies directory — ${XDG_STATE_HOME:-~/.local/state}/roost/panes/events — readable by you alone. ROOST_EVENTS=/some/dir moves it, and ROOST_EVENTS="" turns it off. It is also off whenever kept replies are off (ROOST_RECORD_DIR=""), unless ROOST_EVENTS names a directory; then it is on, and turn is null. It keeps at least the newest ROOST_EVENTS_KEEP lines (default 5000) and at most a quarter more.
Clearing it. roost forget --all clears the log. roost forget TGT and roost forget --gone do not: they remove kept replies only, so a pane's lines — with the dialog text in reason — stay until they rotate out. Cutting one pane's lines out would move every cursor after them.
What it does not report:
roost kill with no session, closing the last session, or a server that dies: tmux runs no hook for its panes, so they get no closed line. --follow ends when its server stops, which is the signal.closed line either. Neither does an agent pane whose lines have all rotated out — one that sat still while the fleet wrote more than ROOST_EVENTS_KEEP lines. A reader that keeps a pane list should check it against roost status --json now and then.For LLM agents, install the portable skill so they know the loop:
npx skills add beatzball/roost --skill roost
Or copy skills/roost/SKILL.md into your agent's instructions.
Run agents on another machine and drive them from here — tmux-native, no daemon. roost must be installed on the remote:
roost ssh devbox # ssh -t devbox roost → attach the remote agent view
roost ssh devbox new api # forward any subcommand to the remote roost