A roost server wires the claude and opencode 1.x you start in its panes by itself,
typed by hand or started by roost spawn, with files under
~/.config/roost/wiring/ and no edit to your own config. That covers the
badges on this page for those two. Setup explains how it works
and every way to turn it off.
roost install below is still what wires codex, pi and copilot, and what
wires claude and opencode for a pane where you turned roost's wiring off. Having
both is fine: the Claude hooks are the same commands in both places and run
once, and the opencode plugin registers its hooks once even when opencode loads
it from both places.
If you installed roost with curl … | sh, this is already done — that
installer wires your agents in the same step as the PATH line. Otherwise one
command does the whole page:
roost install
It touches only harnesses you already have, prints the plan before it writes
anything, and asks once. It creates the three adapter symlinks (opencode, pi,
copilot), merges roost's hooks into Claude's settings.json, writes
codex's hooks.json, and turns on copilot's EXTENSIONS flag. roost update
is a real alias for the same command — it re-wires, it does not fetch new
roost code — so run either one again after installing a new harness or moving
your checkout.
It is safe to repeat. Hooks you already have are kept and roost's join them, a
second run adds no duplicates, every file it edits is backed up beside itself
as <name>.roost-bak-<timestamp>, and anything at an adapter path that is not
roost's is left exactly as found and named rather than replaced.
roost install --dry-run # print the plan, write nothing
roost install --only pi # one harness at a time
roost install --print-only # never edit JSON — print the blocks to paste
roost install --help # the rest of the flags
Two steps it cannot do, because both are prompts a human answers: codex's
"Trust all and continue", and copilot's per-directory "wants to: handle
permission requests". Both are described in their own sections below, and
roost install lists whichever apply when it finishes.
Everything below is that same wiring by hand. Reach for it on a machine with
neither python3 nor jq, when roost refused to touch a config file because
it is not roost's, or when you would simply rather see what goes where.
The badges are driven by Claude Code hooks. roost install merges them
into ~/.claude/settings.json for you, keeping whatever is already in those
events. To do it by hand, print the snippet:
roost hooks
Merge it into your ~/.claude/settings.json (under "hooks"). It wires:
| Claude hook | state |
|---|---|
UserPromptSubmit | ⏳ working |
PermissionRequest | 🛑 blocked |
Notification (matcher: permission_prompt) | 🛑 blocked |
PostToolUse (--tool-hook) | ⏳ working |
PostToolUseFailure (--tool-hook) | ⏳ working |
Stop | ✅ done |
StopFailure | 💥 error |
Some of those are subtler than they look:
PermissionRequest is what paints 🛑, and it fires as the dialog opens. Measured on Claude Code 2.1.278: 11–16 ms after the dialog appears. The hook prints nothing and decides nothing — an allow or a deny on this event would answer the dialog on your behalf, and roost never does that.Notification is the fallback, and it must be scoped to permission_prompt. Unmatched, it also fires for idle_prompt and auth_success — so a finished agent would go red. It arrives a flat 6.00 s after PermissionRequest, and a dialog answered faster than that fires no Notification at all — which is why it is not enough on its own, and why it stays wired only for a Claude too old to have PermissionRequest.PostToolUse and PostToolUseFailure are what clear 🛑. No hook fires when you answer a permission dialog, so they are the first observable events after you approve. You need both: PostToolUse fires only when the tool succeeds, so a command that exits non-zero after you said Yes would otherwise leave the window red for the rest of the turn.--tool-hook on those two is about background agents. A background agent finishing a tool call fires PostToolUse on the main pane, and without the flag that cleared a 🛑 a different agent's dialog had put there. The flag lets the hook read the payload's agent_id — and it reads it only when the pane already shows 🛑, so the common case is still one lookup and an exit. Leave the flag off and every badge is still correct except that one.--permission-request-hook and --notification-hook record the path of Claude's own transcript beside the 🛑, and roost send, read and wait-done read that transcript before believing the badge. When the newest records in it are Claude's own "request interrupted" records, written after the badge, they clear it. They never set a state, and anything they cannot prove leaves the badge red.StopFailure is what ends a failed turn. When a turn ends on an API error — a rate limit, an overload, a model that does not exist — Claude fires StopFailure instead of Stop, and nothing after it. roost badges that 💥 error, and roost wait-done exits 1 with the reason, for example Claude ended the turn on an API error (rate_limit). While Claude is still retrying, the pane stays ⏳ working, because it is. A subagent's API error does not change the badge: the main turn goes on.If you wired Claude before StopFailure or PermissionRequest was added, run roost install again. Your other hooks keep working, so nothing looks wrong for a while. Without StopFailure, a turn that fails leaves the pane reading ⏳ working on an agent that has stopped. Without PermissionRequest, a permission dialog is painted 🛑 six seconds late — and one answered faster than that is never painted at all, so roost send types into it. roost doctor warns about a settings.json missing either one, and roost install adds just that entry and leaves the rest as it found them.
A long-running roost server needs one more step. roost also generates a settings file of its own for the panes it opens, and it does that when the server starts — so a roost server that has been up since before the upgrade keeps handing out the old hooks even after roost install. roost doctor names that file too. roost wiring on regenerates it.
scripts/roost-agent-state is a no-op unless it runs inside a roost pane, so it is safe in your global Claude settings — running claude elsewhere does nothing. It also returns early when the state is already correct, which keeps it cheap on PostToolUse (that fires on every single tool call, and Claude waits for the hook to exit).
Badges are not Claude-only. Any agent can report its state through one public command:
roost state working # or: blocked, done, error, idle
It reads $TMUX_PANE to find its own pane, and does nothing at all outside a roost session — so it is safe to wire into a global config.
This is for opencode 1.x. opencode 2 is not supported yet. opencode 2 has a
new plugin format and does not load roost's adapter, and it says so only in its
own log — so an opencode 2 pane runs normally and simply never shows a badge,
and roost read falls back to the screen. Nothing you link or install changes
that; roost doctor tells you when the opencode on your PATH is 2.x. The
work is tracked in issue 150.
roost also leaves opencode 2 unwired, so that your own opencode configuration
stays in effect — Setup has the reason, and what to do in a pane
that was open before you upgraded. Everything below describes opencode 1.x,
which still works.
opencode has an adapter in this repo. roost install --only opencode links
it. By hand:
mkdir -p ~/.config/opencode/plugin
ln -s "$HOME/path/to/roost/adapters/opencode/roost.js" ~/.config/opencode/plugin/roost.js
A symlink rather than a copy, so updating roost updates the plugin. roost doctor prints this exact command with the real path for your checkout, so run that if you are unsure what to fill in — it also confirms once the plugin is linked, and ends the line with , or run: roost install.
One thing to check before you rely on it: the plugin runs inside the opencode process that owns the pane, which is how it knows which pane to badge. A plain opencode in a roost pane is exactly that. opencode attach against a detached opencode serve is not — there the plugin runs in the server, and the pane you are looking at is never badged.
| opencode signal | state |
|---|---|
session.status type busy | ⏳ working |
permission.asked | 🛑 blocked |
permission.replied | ⏳ working |
session.status type retry, the first in a row | ⏳ working |
session.status type retry, the second in a row | 💥 error |
session.error | 💥 error |
session.error because you pressed Esc | ✅ done |
session.idle | ✅ done, plus the turn's reply |
roost read returns the agent's own last answer, not a scrape of its screen — opencode is a full-screen TUI, so a scrape returns its input box and footer. The reply is the last piece of assistant text in the turn. Three things arrive on that same event and are all excluded: the model's reasoning, your own prompt, and text opencode injects itself.
Four things about this table are unlike the other harnesses, and they are worth knowing because they change what a badge means.
One retry is not an error. opencode retries a failing provider several times inside a single turn. The first retry keeps the pane ⏳ working, because a single retry is often a blip that heals itself; only a second consecutive retry turns it 💥. So a pane that flickers and recovers never goes red, and a pane that is genuinely stuck goes red once and stays there rather than flapping.
Pressing Esc ends a turn ✅ done, not 💥 error. opencode reports your interrupt through the same channel as a real failure. Badging your own keystroke as a crash — and sending you a desktop notification about it — would be worse than saying nothing, so roost reads that one case as a finished turn.
A dead turn stays 💥 and does not flip back to ✅. opencode emits its ordinary end-of-turn signal after it reports the error, a minute or so later. roost swallows that one, so the badge you are left with is the one that tells you to look. The related consequence is in the next section.
A subagent's work does not badge your pane, but its questions do. When a turn delegates to a subagent, the child's own start, finish and speech arrive on the same stream as the parent's, and unfiltered they would stamp your pane ✅ done partway through the turn and publish the child's answer as yours. Those are filtered out. A subagent's permission dialog is deliberately not filtered: the human who has to answer it is sitting at this pane, so it still badges 🛑.
roost wait-doneroost wait-done does not treat an errored pane as finished. It prints roost: '<target>' is in error state, not done and exits 1, rather than reporting success on a turn that produced nothing. When the adapter recorded why — codex does, because its 💥 is inferred — a second line names the pane and the reason.
That matters most in a script. If you loop roost wait-done over several agents, exit 1 means error or timeout, and the message tells you which; exit 2 means the agent died or the target is gone (see the exit table). Under set -e a script will stop on a dead agent rather than carrying on, which is the intended behaviour but a change in flow if you had assumed non-zero meant "still busy, try again".
tests/live/opencode-smoke.sh drives real opencode against a local model to check the adapter end to end. It is not part of tests/run.sh — run it by hand after an opencode upgrade.
GitHub Copilot CLI has an adapter in this repo. roost install --only copilot
links it and turns on the feature flag below. By hand:
mkdir -p ~/.copilot/extensions/roost
ln -s "$HOME/path/to/roost/adapters/copilot/extension.mjs" ~/.copilot/extensions/roost/extension.mjs
A symlink rather than a copy, so updating roost updates the extension. roost doctor prints this exact command with the real path for your checkout.
The directory name is yours; the file name is not — copilot looks for extension.mjs and nothing else. User scope (~/.copilot/) rather than a project's .github/extensions/ matters: only user scope is loaded in both interactive and copilot -p mode.
Two things gate it, and both are silent when they are not met. roost install does the first for you; roost doctor checks the first and reminds you of the second.
Extensions are off by default. roost install merges the flag into ~/.copilot/settings.json. By hand, either launch your panes as copilot --experimental, or put this there yourself:
{ "enabledFeatureFlags": { "EXTENSIONS": true } }
Without one of them copilot never reads the adapter, and says nothing about having skipped it.
Copilot asks once per directory to approve the extension — "wants to: handle permission requests". Answer Yes. Denying it stops the extension loading; there is no global pre-approval, so a new worktree asks again. This is one of the two steps no installer can do for you: it is a prompt in copilot's own TUI, and nothing on disk records the answer.
That second dialog is the price of the 🛑 badge, and it is worth being clear about why. Copilot only tells an extension a permission dialog has opened if that extension registers a permission handler — so without one, a pane sits on ⏳ working while you stare at a prompt, and roost send will happily paste into it. roost registers the handler and returns the SDK's observe-only result: it sees the dialog and badges the pane, and your dialog still opens and you still choose. roost never answers a permission prompt.
| copilot signal | state |
|---|---|
assistant.turn_start | ⏳ working |
a permission dialog, or an ask_user question | 🛑 blocked |
| your answer to either | ⏳ working |
session.error | 💥 error |
session.idle | ✅ done |
roost read returns the agent's own last answer, not a scrape of its screen — copilot is a full-screen TUI, so a scrape returns its input box and footer.
tests/live/copilot-smoke.sh drives real copilot against a local model to check the adapter end to end. It is not part of tests/run.sh — run it by hand after a copilot upgrade. It needs no GitHub account: it points copilot at a local ollama, which turns off the requirement.
pi has an adapter in this repo. roost install --only pi links it. By hand:
mkdir -p ~/.pi/agent/extensions
ln -s "$HOME/path/to/roost/adapters/pi/roost.ts" ~/.pi/agent/extensions/roost.ts
A symlink rather than a copy, so updating roost updates the extension. roost doctor prints this exact command with the real path for your checkout.
The file name is yours; the .ts is not — pi discovers *.ts (and */index.ts) and loads it through jiti, so there is no build step. Install it globally under ~/.pi/agent/ rather than in a project's .pi/extensions/: a project-local extension loads only after you have trusted that project, so a fresh worktree would badge nothing and say nothing about why.
| pi signal | state |
|---|---|
agent_start | ⏳ working |
a confirm / select / input / editor dialog opening mid-turn | 🛑 blocked |
| that dialog closing | ⏳ working |
agent_settled, last assistant message stopReason: "error" | 💥 error |
agent_settled, anything else | ✅ done |
roost read returns the agent's own last answer, not a scrape of its screen — pi is a full-screen TUI, so a scrape returns its input box and token counter.
pi ships no permission prompts. That is a deliberate design choice, not a gap — pi's own docs list "permission popups" among the things it intentionally omits, alongside MCP, sub-agents and plan mode. pi runs bash without asking.
So nothing on a stock pi install ever asks you a question mid-turn, and a pi pane never goes 🛑. What that costs you, precisely:
roost next-blocked will never find a pi pane. Nothing is waiting for you, so there is nothing to jump to.roost send will never refuse a pi pane with exit 3. That refusal exists so one agent cannot paste into another's permission dialog. A pi pane has no dialog to paste into, so a pi pane is always safe to send to — the refusal is not missing, it is not needed.If you install a permission gate of your own — pi ships examples/extensions/permission-gate.ts as the pattern — roost sees its dialog and badges the pane 🛑 for as long as it is open. That works because pi hands every extension the same ctx.ui object, so roost can watch a dialog it did not raise. roost never answers one. It calls your gate's dialog and returns exactly what your gate returned; you still choose.
pi -p or --mode json pane is not badgedThe adapter reports only when a human is attached to the pane (pi's ctx.hasUI, which is true in interactive and RPC mode and false in -p print mode and --mode json).
That is not caution about drawing to a headless terminal. pi's sub-agent pattern — its shipped examples/extensions/subagent/ — runs each sub-agent as a separate pi --mode json -p process, and a child process inherits its parent's $TMUX_PANE and loads the same global extensions. Without the gate every sub-agent would badge the pane it was launched from, stamping ✅ done while the real agent was still working, and publishing its own answer as the pane's reply.
The price is that a pane where you type pi -p "…" yourself stays unbadged. Report state by hand there, the way any other harness does:
roost state working && pi -p "…" && roost state done
tests/live/pi-smoke.sh drives real pi against a local model to check the adapter end to end. It is not part of tests/run.sh — run it by hand after a pi upgrade. It needs no account: it points pi at a local ollama.
Codex has an adapter in this repo. Unlike the symlinked ones it is wired through hooks, like Claude Code — roost install --only codex writes ~/.codex/hooks.json for you. To do it by hand, print the config:
roost hooks codex
Write the JSON object it prints (not the comment lines — hooks.json is strict JSON) into ~/.codex/hooks.json. Then, either way, start codex once and answer "Trust all and continue" at its Hooks need review prompt.
That second step is not optional, it is the other step no installer can do for you, and skipping it looks exactly like success. Until you answer it, codex skips every hook and says nothing about having done so — your turns run normally and the pane simply never badges. roost doctor reads the trust entries and tells you which of the five are missing.
One thing roost install will not do here: if ~/.codex/hooks.json already points at a different roost checkout, it refuses and says so rather than rewriting it. That is deliberate, for the reason in the next paragraph but one — rewriting the command string re-hashes the handler, and codex then silently skips a hook a machine had already trusted.
| codex hook | state |
|---|---|
UserPromptSubmit | ⏳ working |
PostToolUse | ⏳ working |
PermissionRequest | 🛑 blocked |
Stop, with a reply | ✅ done, plus the turn's reply |
Stop, with no reply | 💥 error |
Interrupt (No or Esc, or Esc mid-answer) | 💤 idle, previous reply dropped |
PostToolUse is what clears 🛑. No hook fires when you answer a permission dialog, so it is the first observable event afterwards — the same mechanism as Claude Code's.
Interrupt clears it after No or Esc, when the tool never runs. It badges 💤 idle and drops the previous turn's reply, because no Stop follows it. It was added after the other four hooks, so a machine that trusted roost's hooks earlier must answer "Trust all and continue" at codex's "Hooks need review" once more. roost doctor says when that one is missing.
roost read returns the agent's own last answer rather than a scrape of its screen: codex's Stop payload carries last_assistant_message, spelled exactly as Claude Code spells it.
A codex 💥 error is roost's guess, not codex's report. Codex has twelve hook events and none of them reports an error, so a turn that cannot reach its model still ends with Stop. What gives it away is that its Stop carries no reply. roost badges that 💥 error, and roost wait-done exits 1 with two lines: roost: '<target>' is in error state, not done, then the pane and the reason — codex ended the turn with no reply.
Three things this adapter cannot do, and you should know them before you trust the badge.
python3 nor jq, or one whose python3 is on PATH but does not run — the macOS /usr/bin/python3 stub before the Command Line Tools are installed — because roost does not fall back to jq once it has found a python3. If a codex pane goes ✅ suspiciously fast, look at it.Stop at all, so it is not caught by this. But an interrupt while codex is still answering has not been measured, and roost cannot tell the two apart. A false 💥 is the safer mistake: it makes you look.SessionEnd fires within milliseconds of Stop under codex exec, so reporting it would erase the ✅ that turn just earned.Changing the config yourself will switch the badges off. Codex stores a hash of each hook entry when you trust it and silently skips any entry that no longer matches — a changed command, or even a changed timeout. If you edit hooks.json, run codex and re-trust through /hooks. For the same reason, what roost hooks codex prints stays fixed across roost releases; roost changes adapters/codex/roost-codex-hook instead, which codex re-reads every run.
It has changed once. The Interrupt entry now asks for a timeout of 3 rather than 10, because 3 is codex's cap for that event: it was clamping the 10 down and printing a warning with your hooks.json path in it on every start. The hook ran with three seconds before and runs with three seconds now — only the warning is gone. If your machine was wired before that, run roost install. roost doctor tells you while the old number is still there.
Codex may not ask you anything, and that is not a failed install. On codex 0.154.0 a timeout is not part of the hash stored when you trust a hook, so the new number lands under your existing trust entry and nothing is prompted; an appended argument to the command still moves that hash, which is how we know the measurement is real. An older codex does hash the timeout. So if "Hooks need review" appears, answer "Trust all and continue" once, for Interrupt alone — and if it does not appear, you are already done.
One thing that trust prompt does not do: it approves a path, not the code at that path. Anyone who can write adapters/codex/roost-codex-hook changes what runs, with no second review. That is codex's model, not something roost adds.
tests/live/codex-smoke.sh drives real codex against a local model to check the adapter end to end. It is not part of tests/run.sh — run it by hand after a codex upgrade. It needs no OpenAI account: it points codex at a local ollama through a small proxy, which turns off the requirement.