SLOPSHOPPER

herdr

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

newprocesstimer
★ 17v?MITupdated 2026-10-07bendrucker/claude/plugins/herdr
A shopper browsing a rack in a slop shop
README

Herdr

Drive the herdr terminal workspace manager from a session running inside it.

Contents

  • Skill: herdr drives workspaces, tabs, and panes, and hands whole tasks to coding agents running in other panes
  • Mod: register.ts publishes the session's subagents, teammates, and branch as metadata tokens on its herdr pane

Mod Setup

The 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:

TokenValue
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
branchthe 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 }],
]

Tests

bun test plugins/herdr runs the skill's bun tests, and bun scripts/mod-test.ts herdr runs the mod's.

Source 1 files
mod/register.ts 216 lines
1import 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