Drive the herdr terminal workspace manager: panes, tabs, workspaces, and sibling coding agents

Drive the herdr terminal workspace manager from a session running inside it.
herdr drives workspaces, tabs, and panes, and hands whole tasks to coding agents running in other panesregister.ts publishes the session's subagents, teammates, and branch as metadata tokens on its herdr paneThe mod loads only when function hooks are enabled with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, and acts only in an interactive session inside a herdr pane (HERDR_ENV=1). Elsewhere it does nothing. It depends on the mod-events plugin, which logs each herdr call's exit code, stderr, and duration as a herdr.call event. herdr's own integration (herdr integration install) keeps reporting the agent's identity and lifecycle. The mod adds what herdr cannot see from outside the session, as tokens from source bendrucker:herdr:
| Token | Value |
|---|---|
subagents | ↳ and the count of live subagents |
teammates | ⇄ and the count of live teammates |
agents_waiting | ? and the count held on background work or a plan approval |
agents_idle | · and the count of teammates between turns |
branch | the session directory's current git branch |
A token is cleared while its count is zero and when the session exits. herdr shows a custom token only where config.toml names it in a sidebar row:
[ui.sidebar.agents.rows_by_agent]
claude = [
["state_icon", "agent", "$subagents", "$teammates", { token = "$agents_waiting", fg = "#e0405a" }, { token = "$agents_idle", dim = true }],
[{ token = "$branch", dim = true }],
]
bun test plugins/herdr runs the skill's bun tests, and bun scripts/mod-test.ts herdr runs the mod's.
mod/register.ts 216 lines1import type { AgentInfo, EngineInterface, On } from "claude-code";
2
3const MOD = "herdr";
4const SOURCE = "bendrucker:herdr";
5
6// A /clear or /resume ends the conversation, not the process, and no
7// session.start follows it.
8const CONTINUING = new Set(["clear", "resume"]);
9
10const ACTIVE = new Set(["pending", "running", "waiting", "idle"]);
11
12// While an agent is counted, a background subagent can finish or a teammate in
13// its own pane go idle with no event here, so the counts are re-read on a timer.
14const POLL_MS = 10_000;
15
16type Tokens = Record<string, string>;
17
18interface Pane {
19 bin: string;
20 id: string;
21}
22
23interface Beacon {
24 pane: Pane | undefined;
25 reported: Tokens;
26 inFlight: Promise<void> | undefined;
27 requests: number;
28 seq: number;
29 poll: { cancel: () => void } | undefined;
30}
31
32function count(agents: AgentInfo[], glyph: string, keep: (agent: AgentInfo) => boolean): string {
33 const n = agents.filter(keep).length;
34 return n === 0 ? "" : `${glyph}${n}`;
35}
36
37export function tokensOf(agents: AgentInfo[], branch: string): Tokens {
38 const active = agents.filter((agent) => ACTIVE.has(agent.status));
39 return {
40 subagents: count(active, "↳", (agent) => agent.teammateId === undefined),
41 teammates: count(active, "⇄", (agent) => agent.teammateId !== undefined),
42 agents_waiting: count(active, "?", (agent) => agent.status === "waiting"),
43 agents_idle: count(active, "·", (agent) => agent.status === "idle"),
44 branch,
45 };
46}
47
48const TOKEN_NAMES = Object.keys(tokensOf([], ""));
49
50// herdr drops a report whose seq is not above the last one, so the seq is taken
51// before any await and a slow report cannot land over a newer one.
52async function herdr(
53 $: EngineInterface,
54 beacon: Beacon,
55 pane: Pane,
56 args: string[],
57): Promise<boolean> {
58 beacon.seq += 1;
59 const argv = [
60 pane.bin,
61 "pane",
62 "report-metadata",
63 pane.id,
64 "--source",
65 SOURCE,
66 "--seq",
67 String(beacon.seq),
68 ...args,
69 ];
70 const startedAt = await $.clock.now();
71 const { exitCode, stderr } = await $.process
72 .run(argv, { timeoutMs: 3000 })
73 .catch((error: unknown) => ({ exitCode: -1, stderr: String(error) }));
74 const ms = (await $.clock.now()) - startedAt;
75 const ok = exitCode === 0;
76 void $.modEvents.emit({
77 mod: MOD,
78 event: "herdr.call",
79 ok,
80 ms,
81 detail: { op: "report-metadata", exitCode, stderr: stderr.trim() },
82 });
83 return ok;
84}
85
86async function branchOf($: EngineInterface): Promise<string> {
87 try {
88 const { exitCode, stdout } = await $.process.run(["git", "branch", "--show-current"], {
89 timeoutMs: 1000,
90 });
91 return exitCode === 0 ? stdout.trim() : "";
92 } catch {
93 // A session outside git, or without git installed, has no branch to show.
94 return "";
95 }
96}
97
98async function publish($: EngineInterface, beacon: Beacon, tokens: Tokens): Promise<void> {
99 const args: string[] = [];
100 for (const [name, value] of Object.entries(tokens)) {
101 if ((beacon.reported[name] ?? "") === value) continue;
102 args.push(...(value === "" ? ["--clear-token", name] : ["--token", `${name}=${value}`]));
103 }
104 const pane = beacon.pane;
105 if (args.length === 0 || pane === undefined) return;
106 if (await herdr($, beacon, pane, args)) {
107 beacon.reported = Object.fromEntries(
108 Object.entries(tokens).filter(([, value]) => value !== ""),
109 );
110 }
111}
112
113async function drain($: EngineInterface, beacon: Beacon): Promise<void> {
114 const seen = beacon.requests;
115 try {
116 const [agents, branch] = await Promise.all([$.agent.list(), branchOf($)]);
117 const tokens = tokensOf(agents, branch);
118 await publish($, beacon, tokens);
119 poll($, beacon, tokens.subagents !== "" || tokens.teammates !== "");
120 } catch (error) {
121 void $.modEvents.emit({
122 mod: MOD,
123 event: "refresh",
124 ok: false,
125 detail: { error: String(error) },
126 });
127 }
128 if (beacon.requests !== seen && beacon.pane !== undefined) return drain($, beacon);
129 beacon.inFlight = undefined;
130}
131
132function poll($: EngineInterface, beacon: Beacon, hasAgents: boolean): void {
133 if (hasAgents && beacon.pane !== undefined) {
134 beacon.poll ??= $.clock.every(POLL_MS, () => {
135 refresh($, beacon);
136 });
137 } else {
138 beacon.poll?.cancel();
139 beacon.poll = undefined;
140 }
141}
142
143// Events arrive in bursts, so a refresh that lands mid-flight is counted and
144// the running one reads again rather than racing it.
145function refresh($: EngineInterface, beacon: Beacon): void {
146 if (beacon.pane === undefined) return;
147 beacon.requests += 1;
148 beacon.inFlight ??= drain($, beacon);
149}
150
151/**
152 * Publishes what herdr cannot see from outside the session as metadata tokens
153 * on the pane hosting it: its subagents and teammates, how many of them wait or
154 * sit idle, and the branch. Lifecycle stays with herdr's own integration.
155 */
156export function register(on: On): void {
157 const beacon: Beacon = {
158 pane: undefined,
159 reported: {},
160 inFlight: undefined,
161 requests: 0,
162 seq: 0,
163 poll: undefined,
164 };
165
166 on("session.start", async ($, e, next) => {
167 const [env, id, bin, now] = await Promise.all([
168 $.env.get("HERDR_ENV"),
169 $.env.get("HERDR_PANE_ID"),
170 $.env.get("HERDR_BIN_PATH"),
171 $.clock.now(),
172 ]);
173 // Clock-based, so a restarted session's seqs stay above the last one's.
174 beacon.seq = Math.max(beacon.seq, now * 1000);
175 beacon.reported = {};
176 const isHosted = env === "1" && id !== undefined && id !== "" && e.isInteractive;
177 beacon.pane = isHosted
178 ? { bin: bin === undefined || bin === "" ? "herdr" : bin, id }
179 : undefined;
180 void $.modEvents.emit({ mod: MOD, event: "session.start", detail: { isHosted } });
181 refresh($, beacon);
182 return next(e);
183 });
184
185 on("turn.start", ($, e, next) => {
186 refresh($, beacon);
187 return next(e);
188 });
189
190 on("turn.complete", ($, e, next) => {
191 refresh($, beacon);
192 return next(e);
193 });
194
195 on("session.end", async ($, e, next) => {
196 if (CONTINUING.has(e.reason)) {
197 refresh($, beacon);
198 return next(e);
199 }
200 // Not waiting on a refresh in flight: session.end has a short budget, and
201 // the clear's higher seq makes herdr drop that refresh if it lands later.
202 const pane = beacon.pane;
203 beacon.pane = undefined;
204 poll($, beacon, false);
205 if (pane !== undefined) {
206 await herdr(
207 $,
208 beacon,
209 pane,
210 TOKEN_NAMES.flatMap((name) => ["--clear-token", name]),
211 );
212 }
213 return next(e);
214 });
215}
216