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 itspawn (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.
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 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.
--jsonThe commands above print English for a person. A program that parses that English breaks the day a message is reworded. So six 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 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}]}
| 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 |
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,"text":"Tests pass.","lossy":false}
| 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) |
--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"}
| 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 |
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.
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).
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