The wake API answered the trust dialog for us

@bejoyfuuul

RelayRoom 0.8.0 adds herdr as a multiplexer beside tmux. The feature list is not the interesting part. The interesting part is that three times in this release we trusted a description instead of a measurement, and all three were wrong.

They are not three separate stories. They are the same failure at three different distances from the code: somebody else's documentation, somebody else's status field, and our own release note.

The furthest one: the method built for this

herdr exposes agent.prompt, which is the method that exists for exactly what a pager does - hand a prompt to an agent. It is documented as refusing when the agent is blocked.

Measured on herdr 0.8.0, in a pane no human had touched, with Claude Code sitting on "Is this a project you trust?" and herdr itself reporting agent_status: blocked, it did not return the documented refusal. It sent the input, and its trailing Enter answered the security dialog. Then it ran the wake text as a prompt.

The commit that removed it puts the judgement in one line:

A wake that can choose "trust" on the user's behalf is worse than a wake that arrives late.

That is the shape of the risk in a tool whose whole job is delivering text into an agent's session. A delivery mechanism that can also press buttons is not a delivery mechanism.

The fix is not "call a different method"

The obvious repair is to use a lower-level call and move on. What made this design rather than a substitution is a measured asymmetry: pane.send_text is inert against the same dialog. The dialog stays up, and the text is buffered until a human answers.

So the asymmetry becomes the algorithm:

  1. pane.send_text - stage. Harmless at a dialog, because nothing submits.
  2. pane.read - is the staged text actually in the input box? If not, defer. Nothing was submitted and no dialog was answered.
  3. pane.send_keys - Enter, and only because step 2 saw the text.
  4. pane.read - gone from the box means submitted; still there means it failed.

Verification is the safety gate here, not a check standing beside one. And there is deliberately no dialog detector, which is the second distance.

The middle one: somebody else's status field

The natural way to write this is to ask herdr whether a dialog is up. herdr answers that question - agent_status - and we did not use it.

Its status comes from screen-manifest matching, and in our runs it called the same dialog blocked in one workspace and idle in another minutes apart. In a later run it read done while the agent was visibly mid-turn. A text matcher of our own would have been that same fragility one layer further down.

So the design never asks "is a dialog up". It asks "did our text reach the input box", which is a fact about our own action rather than a judgement about someone else's screen.

Two more corrections came out of running it, and neither was visible to the unit tests.

A single read races the renderer. The first delivery to a live idle agent reported "staged text did not appear - deferring" for a wake that was in the input box a moment later. pane.send_text returns before the terminal paints, so one read after it measures the renderer, not the delivery. The commit names why the tests missed it: the mock answers instantly, so the test was more cooperative than the terminal it stands in for. Verification now polls a bounded window of about three seconds, and the safety property is unchanged, because a dialog never shows the text at all.

"Still on screen" is not "not submitted". Delivering mid-turn reported failure for a wake that had been submitted and answered, because Claude Code keeps a submitted prompt visible in its composer until the turn it queued behind starts. Every wake landing mid-turn - which is most of them - would have been delivered twice.

The replacement for that check is worth reading, because the first replacement was also wrong. Reading herdr's state_change_seq, a counter of transitions, looked like the fact the screen could not give. Then review asked what else moves it:

before   {"seq":60,"status":"done"}
+2s      {"seq":61,"status":"working"}   <- our submit
+6s      {"seq":62,"status":"done"}      <- the turn ending. Nothing was sent

A turn ending moves it too. So a swallowed Enter, during a turn that happens to finish inside the confirm window, reads exactly like a successful submit: a wake marked delivered and never seen. Chasing that turned up the stranger finding - the screen check had been matching the echo. A submitted prompt stays in the transcript above the input box, so a wake still "appeared on screen" 26 seconds after it had been submitted and answered.

The confirm now reads the lines between the last two full-width rules, which is where Claude Code draws its input box, and asks whether the wake is still there. Structural, never matching words. An unrecognised layout returns null and the caller falls back to the counter with a log line saying the confirmation is the weaker kind.

The bias is written into the code rather than assumed: an unconfirmed delivery is re-queued and costs a duplicate wake, while a wrongly confirmed one costs silence. And what the counter is for is written down too - it rules out "nothing happened at all", and it is not proof that our text was submitted, because the full set of transitions that move it is unmeasured.

The nearest one: our own release note

The third distance is the shortest, and it is the one we control completely.

The changeset for this release said two things the code does not do. up restores the launch flags after a herdr server restart - it does not; --restart does. And the dashboard shows which multiplexer is actually delivering - at that moment nothing rendered it; apps/web/src contained no reference to the field.

The commit that fixed both named the shape, and this is the sentence worth stealing:

a release note written from the change I made rather than from the change a user can observe. The heartbeat sending a field felt like "the dashboard shows it", and up being the recovery command felt like "up restores it", and neither survives reading the code at the point the user would land.

Both errors are the same as the first two, moved closer. The documented behaviour of agent.prompt was a description of intent. agent_status was a description of a screen. A changeset written from a diff is a description of what changed in the repository, and a user stands somewhere else entirely.

One sentence that was true for nine minutes

The corrected note said "This release carries the data only; the dashboard does not render it yet." That was true when the pull request merged at 11:32:41Z.

The next pull request merged at 11:41:34Z and made it false, which its own description says out loud:

This PR makes true the sentence #146's changeset left open.

We are not offering that as a fourth mistake. It is the one place in this story where the process worked. A comment in code has no date on it, which is why a comment that stops being true just sits there. A release note is assembled at a known moment from changesets that are themselves dated, so a sentence with a nine-minute lifetime got reconciled before anything shipped, and the released changelog describes the state users actually got.

The difference is not care. It is that one of these artifacts has a scheduled moment where somebody has to look at it again.

The practical note, if you run herdr

After the herdr server itself restarts, a part started with --bypass needs ./rr.sh up --restart.

herdr restores the layout and brings each agent back on its own conversation, so the fleet looks fine. Measured on a six-part fleet, the restored command is a bare claude --resume <id> - every flag from the original launch is gone. A part started with --bypass comes back without it and stalls at the first permission prompt, which shows up later as an agent that stopped for no visible reason.

A plain ./rr.sh up will not fix that, and says so rather than pretending: launch flags are applied when an agent starts, so against a pane that already holds one it applies nothing. --restart replaces the agent, which is what puts the flags back. What that costs is stated too - it relaunches with --continue, which picks the most recently touched conversation in the project rather than the exact session herdr had just restored.

One more, for anyone upgrading: an rr.sh from 0.7.0 or earlier does not reject --use-herdr. It ignores it and starts a tmux session, because that generation picked out the flags it recognised and dropped the rest. The command succeeds, the session is tmux, and nothing says otherwise. This bit a user on the day the flag shipped. From this release an unknown option is an error that names the flag, but the copies already written cannot be fixed, so grep multiplexer .relayroom/config.json is how you find out what an earlier run actually did.

What to take

Not "test more". The specific thing:

A description of behaviour and a measurement of behaviour are different kinds of claim, and they are easy to write down in the same voice. "The docs say it refuses when blocked" and "we watched it refuse when blocked" look identical in a design note, in a changeset, and in your own head an hour later. Only one of them survives contact with a running system.

So when you write the sentence, write which one it is. Every herdr behaviour in this release was measured against a running herdr server rather than read off documentation, and the delivery path was checked end to end - a message sent through a real hub's MCP, delivered over the socket into a live pane, with the hub recording the wake as delivered. That sentence is in the pull request because it is the only part of the verification that a reader cannot check for themselves.

RelayRoom is the coordination layer these agents work in: relayroom.dev. The release is v0.8.0, and the operational details are in agent setup, updating and troubleshooting.

The wake API answered the trust dialog for us - RelayRoom