0.8.0 · macOSClaudeDeck was complicated for exactly one reason. Claude Code has no control API, so every fact the plugin needed had to be stolen from somewhere it was not offered.
To know an agent was blocked, it installed hooks and dispatched them. To know how full the context window was, it patched the statusline. To read what a pane was showing, it ran a PTY and kept a ring buffer. To jump to the right terminal tab it drove AppleScript, which meant Accessibility, which meant TCC, which meant codesigning. To know which project a shell belonged to, it resolved shell PIDs and watched the projects directory.
herdr is a terminal multiplexer built for coding agents. It already tracks all of that, and it exposes it on a Unix socket speaking NDJSON. Agent lifecycle, idle and working and blocked and done, is a first-class concept in the protocol rather than something you infer.
So the interesting artifact of this rebuild is not what I wrote. It is what came out.
What all that code was for#
| ClaudeDeck subsystem | It existed because | HerdDeck instead |
|---|---|---|
| Hook dispatch (post) | nothing tells you an agent is blocked | agent_status is a field in the protocol |
| Permission round-trip (post) | approval meant parking an HTTP response on a Promise | herdr already knows a pane is waiting |
| Statusline auto-patcher (post) | context fullness had to be smuggled out per turn | a statusline delegate you install yourself, never patched in behind your back |
| PTY runner and ring buffer (post) | reading a pane meant owning the terminal | herdr owns the terminal |
| AppleScript, codesigning, TCC (post) | focusing a tab needed Accessibility | one socket call |
| Shell-PID resolver, project watcher | mapping a shell to a project | herdr tracks it |
Two things fell out of that beyond the line count, and only one of them was planned.
It works for every agent herdr recognises. Claude Code, Codex, OpenCode. The old design was Claude-shaped because hooks are Claude-shaped. The new one asks herdr what is running and gets an answer that was never Claude-specific in the first place.
Remote came close to free. herdr splits client from server, so the server holds the state and a remote server speaks the identical protocol. Forward the socket over SSH and the same daemon code drives agents on another machine.
I want to be careful about the word “free”, because there is a caveat and the obvious guess about it is wrong. herdr --remote makes the human terminal experience a thin client, but it does not expose a local API socket proxying the remote server. The local herdr-client.sock is a TUI attach endpoint and nothing more. The daemon needs its own forward. And the auto-sync that keeps herdr versions matched only fires on a human --remote attach, never on the daemon’s forward, so a box you rarely attach to can quietly drift to an older protocol. Every target therefore gets pinged and version-checked on connect and drops to a warning state on mismatch, rather than being assumed compatible.
The bet, and it was a bet#
I would rather leave this part out.
I have a post on this blog about trialling herdr, with exit conditions written down before the trial started and a verdict due on August 16.
HerdDeck was built between August 6 and August 9.
So I deleted a working codebase and rebuilt my hardware controller on a substrate that was still on probation, a week before the trial I had written exit conditions for was due to conclude. If the verdict had gone the other way, the sunk cost would have been the whole rebuild plus the ClaudeDeck code I had already thrown away.
The verdict has since landed and it went my way. herdr graduated, which is the least interesting thing about it. It graduated on two of its three conditions, because the third turned out to be unanswerable from inside the window: a month of deliberately using one tool makes that tool look load-bearing whichever way the comparison would actually have gone. The trial is the data.
That is the same problem I had already created, one layer down and worse. Building HerdDeck did not merely make herdr look useful. It made herdr useful, to me, on hardware I reach for every day. A trial gets harder to fail once you have built something on the thing being trialled, and I built four days of work on it before the verdict was in.
A favourable answer does not make the sequence defensible and I am not going to dress it up as one. What makes it survivable rather than reckless is narrow: the deletion is reversible in the only sense that matters, because ClaudeDeck is still a repository and still works. What it cost is a real option, the ability to evaluate herdr without also evaluating four days of my own work sitting on top of it.
What the protocol charged#
These are the things you cannot learn by reading a schema, and they are the actual price of the substrate.
One request per connection. herdr answers the first NDJSON line on a connection and closes it. events.subscribe is the exception: it converts its connection into a long-lived stream with a fixed subscription set. A client that wants to change what it is subscribed to has to open a new stream, which turns every subscription change into a make-before-break problem rather than a mutation.
Event names are inconsistent. Lifecycle pushes use underscores, like pane_created, with data.type repeating the name. Status pushes use dots, like pane.agent_status_changed. The cache accepts both spellings so no caller has to normalise first, which is a workaround rather than a fix.
Container closes do not cascade into pane events. Closing a workspace or a tab emits workspace_closed or tab_closed and nothing else. No pane_closed for the panes inside. A cache that only listens for pane events keeps zombie entries forever, which is exactly what mine did until I noticed.
workspace.close is asynchronous. The ok response returns immediately. The pane processes take one to two seconds to wind down before workspace_closed fires.
New subscribers get synthetic replay. A fresh events.subscribe stream receives pane_created for panes that already existed. This is harmless under the ordering below, and it looks exactly like corruption if you are not expecting it.
Per-pane subscriptions are all-or-nothing. pane.agent_status_changed requires a pane_id and there is no wildcard. One stale id fails the entire batch. That one produced the most instructive bug in the project, and it is the rest of this post.
None of the above is a criticism of herdr, which is a good substrate and the reason the rebuild was worth attempting. It is the answer to the question “what did it cost”, which is the question a post like this usually skips.
The ordering that took three tries#
The naive connect sequence is: snapshot the state, then subscribe to changes. That loses every transition landing in the gap between the two. The reverse, subscribe then snapshot, double-counts.
What actually works has five steps, and step four is the load-bearing one.
Buffered events are either already reflected in the snapshot or strictly newer than it, and state_change_seq tells you which. Replayed events carrying no sequence number get dropped whenever the pane is already cached, and applied only when they describe something the snapshot missed, such as a pane created after it was taken.
When the recovery mechanism prevents recovery#
On a busy session the daemon reconnected 24 times in 12 seconds.
The cause is the all-or-nothing subscription rule meeting reality. Short-lived panes, popups and plugin panes, routinely vanish between their pane_created event and the resubscribe that event triggers. herdr fails the entire subscribe batch when one pane_id in it is stale. Tearing down the connection on that failure and reconnecting rebuilds the identical doomed batch, which fails identically, which tears down the connection.
An endless online to connecting flap, in which the recovery mechanism was the only thing preventing recovery.
The fix prunes the vanished pane out of the cache and retries, bounded:
/** Bound on per-open prune retries, so a pathological server can't spin
* this loop forever. */
const MAX_STALE_PANE_PRUNES = 8;
function stalePaneId(err: unknown): string | null {
if (!(err instanceof HerdrApiError) || err.code !== "pane_not_found") return null;
return /pane (\S+) not found/.exec(err.message)?.[1] ?? null;
}Reconnects went from 24 in 12 seconds to one.
The bound matters more than the prune. Retry-until-it-works against a server that will never agree is the same flap with extra steps, so the loop gives up after eight prunes and throws with a message naming the count. A recovery path that cannot fail is not a recovery path.
What it came to#
Four days, 36 commits on main, 35 of them merged through pull requests behind seven required checks. Four packages, 504 tests across 32 files, and close to one line of test for every line of source, which was not a target so much as a description of where the difficulty lived.
The difficulty did not live in the Stream Deck. It lived in the seam.
Lessons#
- Changing the substrate is a deletion strategy. If a dependency already tracks the thing you are inferring, the win is measured in subsystems removed, not features added.
- Ask what a protocol costs before you claim it saved you something. Event naming, connection lifetime and subscription granularity are the parts no schema documents and the parts you will actually pay for.
- Subscribe first and buffer, then snapshot, then replay against the sequence numbers. Snapshot-then-subscribe drops transitions and subscribe-then-snapshot double counts.
- If one bad id fails a whole batch, reconnecting rebuilds the same bad batch. Prune the offending item and retry, bounded, or the recovery path becomes the outage.
- Building on a tool you are still evaluating makes the evaluation harder to fail. Write down that you did it, because sunk cost will not remind you later.
References#
- herdr: https://herdr.dev (source: https://github.com/herdrdev/herdr)
- HerdDeck: https://github.com/nickboy/herddeck. Engineering notes, including the protocol behaviour above, in
docs/engineering-notes.md - ClaudeDeck, the predecessor: https://github.com/nickboy/claudedeck
- The herdr trial this rebuild jumped ahead of: Running six agents made tab patrol my biggest time sink. So: a herdr trial.
- The hook layer HerdDeck deleted: The Claude Code hooks docs are wrong. Here’s what’s actually on the wire.
- The PTY runner HerdDeck deleted: I split my daemon in two so a Node subprocess could own the PTY
- The codesigning and TCC path HerdDeck deleted: TCC pins your Accessibility grant to a cdhash. Every rebuild breaks it.
