A herdr peer alarm and board: every 30 seconds it reads the agents of this herdr workspace and toasts when one is blocked, when one finishes, and ALL DONE once…

A herdr peer alarm and board: toasts when another agent in this herdr workspace needs you or has finished, and a peers pane listing every peer with the HERDR-ORCH requests this session sent them. Nothing is drawn in the band.
▲ w1:p6 "Explore ML training platform alternatives" is blocked
✓ w1:p7 "Compare Impeccable design skills" finished
★ ALL DONE · 5 peers idle
Every 30 seconds a $.clock timer runs herdr agent list (5-second limit) and keeps the agents of this workspace (HERDR_WORKSPACE_ID), leaving out this pane (HERDR_PANE_ID). It compares them with the last poll, kept in $.state under peers.seen (pane id → state, since when, agent, title):
blocked (or a new pane that appears blocked). One toast per change, not one per poll.working to idle or done.idle or done, after at least one was seen working in this session. It fires once, then waits until a peer is working again. A quiet workspace never chimes.The first poll after the start (or after /peer-alarm on) only takes a baseline: a peer already blocked then is not announced. A failed herdr read changes nothing.
The timer runs only in a herdr pane (HERDR_ENV=1 with a workspace and pane id) while the alarm is on. Outside herdr nothing runs.
/peer-alarm board opens the peers pane (focused) and reads herdr at once:
2 peers in w1 · 1 blocked · read every 30 s [ r: refresh ] [ q: close ]
▲ blocked 4m w1:p7 codex "Compare Impeccable design skills"
req dd-skin · ⏳ 21m left · no result.md
● working 12m w1:p6 claude "Review round 4"
req apr-alp-r4 · round 4/5 · result.md ✓ completed
▲ blocked, ● working, ○ idle, ✓ done, ? unknown) with the time in that state, the pane id, the agent and the title. Blocked peers come first and are drawn amber. The time counts from the first read that saw the pane in that state, so a pane first seen by this read has none yet. A board last read more than two ticks ago (closed since, alarm off) may have missed changes, so its times are not carried over./peer-alarm off silences the toasts, not the board), and reads the result file of each spoke shown on a row with $.fs: ~/.local/state/herdr-orch/<req>/result.md. result.md ✓ completed, ✗ failed or ▲ blocked when its first two lines are exactly req: <req> and status: <status> and a non-blank line follows (POLICY.md section 4), result.md (no status) when not (as while the spoke is still writing it), no result.md when it is missing. Closed, nothing is read for it.ctrl+x tab): r polls now (the twin is /peer-alarm board again), q closes it (/peer-alarm board close).A prompt this session submits (typed, pasted, a peer's message, a herdr injection) is read for HERDR-ORCH envelope headers, POLICY.md section 2:
[HERDR-ORCH v0] req=<id> from=<agent-name>@<pane-id> type=<type>
at the start of a line, or after herdr agent prompt <pane> " on it; type one of request, handoff, progress, blocked, needs-approval, completed, failed, or followup (not in POLICY.md's list, but the hubs send it; it only updates a request already kept); the request id letters, digits, ., _ or - only. Each is kept in $.state under peers.requests (the newest 40):
to=<pane> on the header line, or the pane after herdr agent prompt; else the sender (from=) when it is not this pane (a spoke's report reaching this session); else the first other pane id in the prompt.request, handoff or followup from another pane that names no other pane (the hub's brief to this session), and a report this pane sends (to its hub), are left out, so a spoke's board never lists its hub as a spoke.round 4/5, round 4 of 5, round 4 or a -r4 suffix of the request id; the ceiling from round n/m, ceiling 5 or max rounds 5.deadline: 45 minutes (or 2 hours) in the prompt, counted from the first envelope of the request seen here; POLICY.md's default of 30 minutes for a request or handoff that names none. A deadline a followup names counts from the follow-up. A result file ends the countdown.A row shows the newest request of its pane. Envelopes the hub sends from a Bash call (herdr agent prompt ... run by Claude) are not prompts of this session and are not seen.
Silent by default. With sound on, a blocked peer plays two low beeps (assets/blocked.wav, 0.4 s) and ALL DONE a rising three-note chime (assets/done.wav, 0.6 s) through $.audio.play (afplay on macOS); ✓ finished has no clip. Both clips are generated by tools/make-sounds.py (standard library only): python3 -I tools/make-sounds.py.
/peer-alarm list | board [close] | on | off | sound on|off
(Claude Code ships a /peers command of its own, so the command is /peer-alarm; the mod keeps the name peers.)
list (also /peer-alarm alone): reads herdr now and answers every peer's pane, agent, state, time in that state and title, for example w1:p6 claude working 12m "Explore ML platforms". The time counts from the first poll that saw the pane in that state, so it is left out for a pane the watcher has not yet seen so.board: opens the board pane and reads herdr now (again, while it is open: the twin of r); board close closes it (the twin of q).on, off: start or stop the watcher; kept in the mod's store (peers.watch), which every session shares, so either one typed in any pane applies to every pane of the workspace within 30 s. on starts from a fresh baseline.Only an interactive terminal session watches: a headless claude -p run started from a herdr pane inherits the herdr env but never polls or beeps.
sound on, sound off: kept in the store (peers.sound), winning over the sound option; sound alone answers which applies.| Option | Values | Default |
|---|---|---|
sound | off, on | off |
From the local marketplace folder (~/.claude/mods):
claude plugin install peers@zehui-mods --scope user
herdr agent list and nothing else, and reads only ~/.local/state/herdr-orch/<req>/result.md files.peers.hooks/register.tsx 337 lines1// The peers mod: a herdr peer alarm. Every 30 seconds a $.clock timer runs
2// `herdr agent list`, keeps the agents of this workspace other than this pane,
3// and compares them with the last poll (kept in $.state under peers.seen):
4// a peer that becomes blocked, one that finishes, and ALL DONE once every peer
5// rests after work each raise one toast, and a clip when sound is on. The
6// timer runs only in an interactive terminal session in a herdr pane
7// (HERDR_ENV=1), never in a headless -p child; while /peer-alarm is off (from
8// any pane: the store is shared) it keeps running but reads nothing.
9//
10// The board pane (/peer-alarm board) is one row per peer, blocked first; while
11// it is open the same tick reads herdr whether or not the alarm is on, and the
12// result file of each spoke this session sent a HERDR-ORCH request to, with
13// $.fs. The requests come from the envelope headers in this session's prompts
14// (prompt.submit). Nothing is drawn in the band.
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
17
18import type { Board, OrchRequest } from '../types'
19import type { Alert, Peer } from './kit'
20import {
21 COMMAND,
22 HERDR_TIMEOUT_MS,
23 NEEDS_HERDR,
24 SOUND_BLOCKED,
25 SOUND_DONE,
26 STORE_SOUND,
27 STORE_WATCH,
28 TICK_MS,
29 USAGE,
30 isSoundOn,
31 isWatchOn,
32 listText,
33 parsePeers,
34 step,
35} from './kit'
36import { PANE, PANE_TITLE, RESULT_MAX_BYTES, boardRows, isSafeReq, parseEnvelopes, recordEnvelopes, requestsByPane, resultPath, resultStatus } from './orch'
37import { drawPane } from './pane'
38
39const seen = atom({ plugin: 'peers', key: 'seen' } as const, null)
40const isArmed = atom({ plugin: 'peers', key: 'isArmed' } as const, false)
41const board = atom({ plugin: 'peers', key: 'board' } as const, null)
42const requests = atom({ plugin: 'peers', key: 'requests' } as const, {})
43
44// The poll's timer. A reload drops it with the environment; session.start
45// (raised again for a fresh load) starts it again.
46let ticker: Timer | null = null
47// The baseline poll's 10 ms timer, cancelled with the ticker.
48let baseline: Timer | null = null
49// One poll at a time: a slow herdr never stacks polls.
50let isPolling = false
51// Bumped by every stop: a start that awaited across a later start or stop sees
52// a newer generation and installs no timer, so no timer is ever orphaned.
53let watchGen = 0
54// Bumped by every forget (/peer-alarm on or off, or a tick that finds the alarm
55// off): a poll that awaited across one keeps nothing and raises nothing.
56let pollGen = 0
57
58const stopTick = () => {
59 watchGen += 1
60 ticker?.cancel()
61 ticker = null
62 baseline?.cancel()
63 baseline = null
64}
65
66type HerdrIds = { workspace: string; pane: string }
67
68/** This pane's herdr workspace and pane, or null outside herdr. */
69async function herdrIds($: EngineInterface): Promise<HerdrIds | null> {
70 if ((await $.env.get('HERDR_ENV')) !== '1') return null
71 const workspace = await $.env.get('HERDR_WORKSPACE_ID')
72 const pane = await $.env.get('HERDR_PANE_ID')
73 return workspace && pane ? { workspace, pane } : null
74}
75
76/** This workspace's peers now, or null when herdr did not answer with its JSON. */
77async function readPeers($: EngineInterface, ids: HerdrIds): Promise<Peer[] | null> {
78 try {
79 const r = await $.process.run(['herdr', 'agent', 'list'], { timeoutMs: HERDR_TIMEOUT_MS })
80 return r.exitCode === 0 ? parsePeers(r.stdout, ids.workspace, ids.pane) : null
81 } catch {
82 return null
83 }
84}
85
86async function storeGet($: EngineInterface, key: string): Promise<unknown> {
87 try {
88 return await $.store.get(key)
89 } catch {
90 return undefined
91 }
92}
93
94/** Toasts each alert; with sound on, the beeps for any blocked and the chime for ALL DONE. */
95async function raise($: EngineInterface, options: PluginOptions, alerts: readonly Alert[]): Promise<void> {
96 if (alerts.length === 0) return
97 for (const a of alerts) $.ui.toast(a.text, { timeoutMs: 8_000 })
98 if (!isSoundOn(await storeGet($, STORE_SOUND), options.sound)) return
99 const clips = [
100 ...(alerts.some(a => a.kind === 'blocked') ? [SOUND_BLOCKED] : []),
101 ...(alerts.some(a => a.kind === 'alldone') ? [SOUND_DONE] : []),
102 ]
103 for (const asset of clips) void $.audio.play({ asset }).catch(() => {})
104}
105
106/** Whether the board pane is open in this session. */
107async function isBoardOpen($: EngineInterface): Promise<boolean> {
108 try {
109 return (await $.ui.panes()).some(p => p.id === PANE)
110 } catch {
111 return false
112 }
113}
114
115/**
116 * The result file of each request the board shows (the newest of each peer's
117 * pane), read with $.fs: missing is null, present is its status ('' when its
118 * first two lines are not a valid header). Only changed records are written.
119 */
120async function readResults($: EngineInterface, b: Board): Promise<void> {
121 const home = await $.env.get('HOME')
122 if (!home) return
123 const shown = new Set(b.rows.map(r => r.pane))
124 const found: Record<string, string | null> = {}
125 for (const r of requestsByPane(await read($, requests)).values()) {
126 if (!shown.has(r.pane) || !isSafeReq(r.req)) continue
127 const path = resultPath(home, r.req)
128 try {
129 const stat = await $.fs.stat(path)
130 found[r.req] = stat.kind === 'file' && stat.size <= RESULT_MAX_BYTES ? resultStatus(await $.fs.read(path), r.req) : ''
131 } catch {
132 // No result file yet.
133 found[r.req] = null
134 }
135 }
136 const isChanged = (l: Record<string, OrchRequest>) => Object.entries(found).some(([req, v]) => l[req] !== undefined && l[req].result !== v)
137 if (!isChanged(await read($, requests))) return
138 await update($, requests, l =>
139 Object.fromEntries(Object.entries(l).map(([req, r]) => [req, req in found ? { ...r, result: found[req] ?? null } : r])),
140 )
141}
142
143/**
144 * One poll: read the peers; while the alarm watches, step against the last
145 * poll, keep the result and raise the alerts; while the board is open, keep
146 * its rows and read its spokes' result files.
147 */
148async function poll($: EngineInterface, options: PluginOptions, isWatch: boolean, isBoard: boolean): Promise<void> {
149 if (isPolling) return
150 isPolling = true
151 const gen = pollGen
152 try {
153 const ids = await herdrIds($)
154 if (!ids) return
155 const peers = await readPeers($, ids)
156 // A failed read changes nothing: no peer is taken to have gone.
157 if (!peers) return
158 const now = await $.clock.now()
159 let alerts: Alert[] = []
160 if (isWatch) {
161 const r = step(await read($, seen), peers, now, await read($, isArmed))
162 // A forget ran while this poll waited: its reset stands.
163 await update($, seen, prev => (gen === pollGen ? r.seen : prev))
164 await update($, isArmed, prev => (gen === pollGen ? r.isArmed : prev))
165 alerts = r.alerts
166 }
167 // A board opened while this poll waited on herdr gets its rows from it too.
168 if (isBoard || (await isBoardOpen($))) {
169 const rows = boardRows(peers, await read($, seen), await read($, board), now)
170 const b: Board = { at: now, workspace: ids.workspace, rows }
171 await update($, board, () => b)
172 await readResults($, b)
173 }
174 if (isWatch && gen === pollGen) await raise($, options, alerts)
175 } finally {
176 isPolling = false
177 }
178}
179
180async function tick($: EngineInterface, options: PluginOptions): Promise<void> {
181 const isWatch = isWatchOn(await storeGet($, STORE_WATCH))
182 // Off, maybe from another pane ($.store is shared by every session): the
183 // timer keeps running, so a later on from any pane takes effect here too,
184 // but the alarm reads nothing and the last poll is forgotten, so the first
185 // poll after on is a fresh baseline. An open board still reads herdr.
186 if (!isWatch && ((await read($, seen)) !== null || (await read($, isArmed)))) await forget($)
187 const isBoard = await isBoardOpen($)
188 if (!isWatch && !isBoard) return
189 await poll($, options, isWatch, isBoard)
190}
191
192/** `r` and `/peer-alarm board`: one poll now, the board's rows included. */
193async function pollNow($: EngineInterface, options: PluginOptions): Promise<void> {
194 await poll($, options, isWatchOn(await storeGet($, STORE_WATCH)), true)
195}
196
197/** Opens the board pane and reads herdr for it now; the answer of /peer-alarm board. */
198async function openBoard($: EngineInterface, options: PluginOptions): Promise<string> {
199 if (!(await herdrIds($))) return NEEDS_HERDR
200 const isOpen = await isBoardOpen($)
201 await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
202 await pollNow($, options)
203 return isOpen
204 ? 'Peers board read again.'
205 : `Peers board open: r polls now, q closes (/${COMMAND} board close); read every 30 s while open.`
206}
207
208/** Closes the board pane (`q`, `/peer-alarm board close`); false when it was not open. */
209async function closeBoard($: EngineInterface): Promise<boolean> {
210 if (!(await isBoardOpen($))) return false
211 await $.ui.close({ id: PANE })
212 return true
213}
214
215/** Keeps the HERDR-ORCH requests in a prompt this session submitted (its envelope headers). */
216async function noteEnvelopes($: EngineInterface, text: string): Promise<void> {
217 if (!text.includes('[HERDR-ORCH v0]')) return
218 const ids = await herdrIds($)
219 if (!ids) return
220 const envs = parseEnvelopes(text, ids.pane)
221 if (envs.length === 0) return
222 const at = await $.clock.now()
223 await update($, requests, l => recordEnvelopes(l, envs, at))
224}
225
226/**
227 * Starts the watcher in a herdr pane: a first poll now (from a timer; it only
228 * takes a baseline), then a tick every 30 seconds, which reads herdr while
229 * /peer-alarm is on. Answers whether it started.
230 */
231async function startWatch($: EngineInterface, options: PluginOptions): Promise<boolean> {
232 stopTick()
233 const gen = watchGen
234 if (!(await herdrIds($))) return false
235 // A later start or a stop ran while this one waited: it owns the watcher now.
236 if (gen !== watchGen) return false
237 baseline = $.clock.after(10, () => {
238 baseline = null
239 void tick($, options).catch(() => {})
240 })
241 ticker = $.clock.every(TICK_MS, () => {
242 void tick($, options).catch(() => {})
243 })
244 return true
245}
246
247/** Whether this session draws in a terminal (empty in a headless -p run). */
248async function isTerminal($: EngineInterface): Promise<boolean> {
249 try {
250 return (await $.session.surfaces()).includes('terminal')
251 } catch {
252 return false
253 }
254}
255
256/** Forgets the last poll, so the next one is a fresh baseline with ALL DONE disarmed. */
257async function forget($: EngineInterface): Promise<void> {
258 pollGen += 1
259 await update($, seen, () => null)
260 await update($, isArmed, () => false)
261}
262
263async function listAnswer($: EngineInterface): Promise<string> {
264 const ids = await herdrIds($)
265 if (!ids) return NEEDS_HERDR
266 const peers = await readPeers($, ids)
267 if (!peers) return `herdr agent list gave no answer (/${COMMAND} list)`
268 return listText(ids.workspace, peers, await read($, seen), await $.clock.now())
269}
270
271export const register: Register = (on, options) => {
272 on('session.start', async ($, e, next) => {
273 const started = await next(e)
274 await $.command.register({
275 name: 'peer-alarm',
276 description: 'herdr peer alarm: list this workspace\'s agents, open the board pane, turn the alarm on or off, or its sound',
277 argumentHint: 'list | board [close] | on | off | sound on|off',
278 })
279 // Only a person's terminal session watches: a headless -p child inherits the
280 // herdr env and would poll and beep a second time.
281 if (e.isInteractive && e.surface === 'terminal') await startWatch($, options).catch(() => false)
282 return started
283 })
284
285 // The command and its matcher are spelled literally, so the host reads it as answering its own command.
286 on('command.run', { command: 'peer-alarm' }, async ($, e) => {
287 const [verb = 'list', arg = ''] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
288 switch (verb) {
289 case 'list':
290 return { text: await listAnswer($) }
291 case 'board':
292 // `/peer-alarm board` opens the pane and polls now (the twin of `r`); `board close` is the twin of `q`.
293 if (arg === 'close') return { text: (await closeBoard($)) ? 'Peers board closed.' : 'The peers board is not open.' }
294 return { text: await openBoard($, options) }
295 case 'on': {
296 await $.store.set(STORE_WATCH, 'on')
297 await forget($)
298 const isStarted = (await isTerminal($)) && (await startWatch($, options))
299 return { text: isStarted ? 'Peer alarm on: this herdr workspace is read every 30 s.' : `Peer alarm on, but idle here: ${NEEDS_HERDR}` }
300 }
301 case 'off':
302 // The timer keeps running and reads nothing while off, so a later on
303 // from any pane wakes every pane's watcher.
304 await $.store.set(STORE_WATCH, 'off')
305 await forget($)
306 return { text: `Peer alarm off (/${COMMAND} on to watch again).` }
307 case 'sound':
308 if (arg === 'on' || arg === 'off') {
309 await $.store.set(STORE_SOUND, arg)
310 return { text: `Peer alarm sound ${arg}.` }
311 }
312 return { text: `Peer alarm sound is ${isSoundOn(await storeGet($, STORE_SOUND), options.sound) ? 'on' : 'off'} (/${COMMAND} sound on|off).` }
313 default:
314 return { text: USAGE }
315 }
316 }).catch(($, e, next) => (next.called ? undefined : next(e)))
317
318 // The requests this session sends or answers: the HERDR-ORCH envelope headers
319 // in its prompts, kept once the prompt entered. Nothing is changed.
320 on('prompt.submit', async ($, e, next) => {
321 const r = await next(e)
322 try {
323 if (!e.command && r.drop === undefined) await noteEnvelopes($, e.text)
324 } catch {
325 // A failed note keeps the requests as they were.
326 }
327 return r
328 }).catch(($, e, next) => (next.called ? undefined : next(e)))
329
330 on('ui.render', { component: 'Pane', requestId: 'peers' }, ($, e) =>
331 drawPane($, e, {
332 refresh: () => void pollNow($, options).catch(() => {}),
333 close: () => void closeBoard($).catch(() => false),
334 }),
335 )
336}
337hooks/kit.ts 140 lines1// Shared by register.tsx and the tests: the constants, the parser of
2// `herdr agent list`, the transition step that turns two polls into alerts,
3// and the texts. The state atoms live in register.tsx only: the engine reads
4// a state source only from a const of the calling file.
5import type { PeerSeen, PeerStatus } from '../types'
6
7/** The poll's period. */
8export const TICK_MS = 30_000
9/** `herdr agent list`'s limit. */
10export const HERDR_TIMEOUT_MS = 5_000
11export const COMMAND = 'peer-alarm'
12/** /peer-alarm on|off, kept across sessions; anything but 'off' watches. */
13export const STORE_WATCH = 'peers.watch'
14/** /peer-alarm sound on|off, kept across sessions; '' or absent: the `sound` option applies. */
15export const STORE_SOUND = 'peers.sound'
16/** The bundled clips, relative to the plugin's directory (tools/make-sounds.py writes them). */
17export const SOUND_BLOCKED = 'assets/blocked.wav'
18export const SOUND_DONE = 'assets/done.wav'
19/** A title longer than this is cut, with an ellipsis, in toasts and the list. */
20export const TITLE_MAX = 48
21export const NEEDS_HERDR = `The peer alarm needs a herdr pane (HERDR_ENV=1 with a workspace and pane id) (/${COMMAND} list)`
22export const USAGE = `Usage: /${COMMAND} list | on | off | sound on|off`
23
24/** One agent of this workspace, other than this pane, from `herdr agent list`. */
25export type Peer = {
26 pane: string
27 agent: string
28 status: PeerStatus
29 title: string
30}
31
32/** What a poll raises: one toast each, and a clip for blocked and ALL DONE when sound is on. */
33export type Alert = { kind: 'blocked' | 'finished' | 'alldone'; text: string }
34
35const STATUSES: readonly PeerStatus[] = ['idle', 'working', 'blocked', 'done', 'unknown']
36
37const asStatus = (v: unknown): PeerStatus => (STATUSES.includes(v as PeerStatus) ? (v as PeerStatus) : 'unknown')
38
39const asText = (v: unknown): string => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '')
40
41/**
42 * The peers in `herdr agent list`'s JSON (`.result.agents[]`): only those in
43 * `workspace`, never `selfPane`, in herdr's order. Null when the output is not
44 * that JSON, so a bad read never looks like every peer leaving.
45 */
46export function parsePeers(stdout: string, workspace: string, selfPane: string): Peer[] | null {
47 let agents: unknown
48 try {
49 agents = (JSON.parse(stdout) as { result?: { agents?: unknown } }).result?.agents
50 } catch {
51 return null
52 }
53 if (!Array.isArray(agents)) return null
54 const peers: Peer[] = []
55 for (const a of agents) {
56 if (!a || typeof a !== 'object') continue
57 const r = a as Record<string, unknown>
58 const pane = asText(r.pane_id)
59 if (!pane || pane === selfPane || asText(r.workspace_id) !== workspace) continue
60 peers.push({
61 pane,
62 agent: asText(r.agent) || 'agent',
63 status: asStatus(r.agent_status),
64 title: asText(r.terminal_title_stripped).trim(),
65 })
66 }
67 return peers
68}
69
70/** ` "title"`, cut to TITLE_MAX characters; '' when there is no title. */
71export function quoted(title: string): string {
72 if (!title) return ''
73 const chars = [...title]
74 const cut = chars.length > TITLE_MAX ? chars.slice(0, TITLE_MAX - 1).join('') + '…' : title
75 return ` "${cut}"`
76}
77
78const isRest = (s: PeerStatus): boolean => s === 'idle' || s === 'done'
79
80export type StepResult = { seen: Record<string, PeerSeen>; isArmed: boolean; alerts: Alert[] }
81
82/**
83 * One poll against the last: the new `seen`, whether ALL DONE is armed, and
84 * the alerts. With no last poll (`prev` null) it only takes a baseline: no
85 * alerts, armed when a peer is working. Afterwards: a pane that becomes
86 * blocked (or first appears blocked) raises ▲; one that goes from working to
87 * idle or done raises ✓; a peer going working arms ALL DONE; and once every
88 * peer rests (idle or done) while armed, ★ ALL DONE fires and disarms.
89 */
90export function step(prev: Record<string, PeerSeen> | null, peers: readonly Peer[], now: number, wasArmed: boolean): StepResult {
91 const seen: Record<string, PeerSeen> = {}
92 const alerts: Alert[] = []
93 let isArmed = wasArmed
94 for (const p of peers) {
95 const old = prev?.[p.pane]
96 const isSame = old !== undefined && old.status === p.status
97 seen[p.pane] = { status: p.status, since: isSame ? old.since : now, agent: p.agent, title: p.title }
98 if (p.status === 'working') isArmed = true
99 if (prev === null || isSame) continue
100 if (p.status === 'blocked') alerts.push({ kind: 'blocked', text: `▲ ${p.pane}${quoted(p.title)} is blocked` })
101 else if (old?.status === 'working' && isRest(p.status)) alerts.push({ kind: 'finished', text: `✓ ${p.pane}${quoted(p.title)} finished` })
102 }
103 if (prev !== null && isArmed && peers.length > 0 && peers.every(p => isRest(p.status))) {
104 alerts.push({ kind: 'alldone', text: `★ ALL DONE · ${peers.length} ${peers.length === 1 ? 'peer' : 'peers'} idle` })
105 isArmed = false
106 }
107 return { seen, isArmed, alerts }
108}
109
110/** `45s`, `12m`, `1h 05m`. */
111export function durationText(ms: number): string {
112 const s = Math.max(0, Math.floor(ms / 1000))
113 if (s < 60) return `${s}s`
114 const m = Math.floor(s / 60)
115 if (m < 60) return `${m}m`
116 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
117}
118
119/**
120 * The /peer-alarm list answer: one line per peer (pane, agent, state, time in that
121 * state, title). The time is known only while the watcher has seen the pane in
122 * the same state; it counts from the first poll that saw it so.
123 */
124export function listText(workspace: string, peers: readonly Peer[], seen: Record<string, PeerSeen> | null, now: number): string {
125 if (peers.length === 0) return `No other agents in herdr workspace ${workspace}.`
126 const lines = peers.map(p => {
127 const s = seen?.[p.pane]
128 const time = s && s.status === p.status ? ` ${durationText(now - s.since)}` : ''
129 return `${p.pane} ${p.agent} ${p.status}${time}${quoted(p.title)}`
130 })
131 return [`${peers.length} ${peers.length === 1 ? 'peer' : 'peers'} in herdr workspace ${workspace}:`, ...lines].join('\n')
132}
133
134/** The watcher's switch: anything but a stored 'off' watches. */
135export const isWatchOn = (stored: unknown): boolean => stored !== 'off'
136
137/** Sound: /peer-alarm sound on|off when stored, else the `sound` option (only 'on' plays). */
138export const isSoundOn = (stored: unknown, option: unknown): boolean =>
139 stored === 'on' || stored === 'off' ? stored === 'on' : option === 'on'
140hooks/orch.ts 262 lines1// The board's pure parts: the HERDR-ORCH envelope parser and the request
2// records it feeds, the result file's check, the board rows and the words the
3// pane draws. Shared by register.tsx, pane.tsx and the tests.
4//
5// The envelope header is POLICY.md section 2 (herdr-setup):
6// [HERDR-ORCH v0] req=<id> from=<agent-name>@<pane-id> type=<type>
7// and the result file is section 4: ~/.local/state/herdr-orch/<req-id>/result.md,
8// valid iff its first two lines are exactly `req: <id>` and `status: <status>`
9// and a non-blank line follows.
10import type { Board, BoardRow, OrchRequest, PeerSeen, PeerStatus } from '../types'
11import type { Peer } from './kit'
12import { TICK_MS, durationText, quoted } from './kit'
13
14export const PANE = 'peers'
15export const PANE_TITLE = 'Peers'
16/** The envelope types POLICY.md lists. */
17export const ENVELOPE_TYPES = ['request', 'handoff', 'progress', 'blocked', 'needs-approval', 'completed', 'failed'] as const
18/**
19 * Not in POLICY.md's list, but the hubs send it (the same `req=`, a later
20 * round): taken only as an update to a request already kept, never a new one.
21 */
22export const FOLLOWUP = 'followup'
23/** POLICY.md section 4: a brief that names no deadline has 30 minutes. */
24export const DEFAULT_DEADLINE_MS = 30 * 60_000
25/** The requests kept; the oldest beyond this are dropped. */
26export const MAX_REQUESTS = 40
27/** A result file over this size is taken as present but not read. */
28export const RESULT_MAX_BYTES = 65_536
29/** The folder of the result files, under the home folder. */
30export const ORCH_DIR = '.local/state/herdr-orch'
31/** The result statuses POLICY.md allows. */
32const RESULT_STATUSES = ['completed', 'failed', 'blocked']
33
34// At a line's start, or after `herdr agent prompt <pane>` and an opening quote
35// there (the hub's own Bash line, pasted or typed); never mid-sentence.
36const HEADER = /^[^\S\n]*(?:herdr agent prompt\s+\S+\s+)?["'`]?\[HERDR-ORCH v0\] req=(\S+) from=(\S+)@(\S+) type=(\S+)(.*)$/gm
37/** A herdr pane id: `w1:p6`, `wZ:p1`. */
38const PANE_ID = /\bw[A-Za-z0-9]+:p\d+\b/g
39
40/** One envelope header found in a prompt, with what the prompt around it says. */
41export type Envelope = {
42 req: string
43 /** The sender's agent name and pane (`from=`). */
44 fromAgent: string
45 fromPane: string
46 type: string
47 /** The spoke the envelope concerns; '' when the text does not say. */
48 target: string
49 round: number | null
50 ceiling: number | null
51 /** The deadline the text names, in milliseconds; null when none. */
52 deadlineMs: number | null
53}
54
55/** A request id safe to use as one folder name: letters, digits, `.`, `_`, `-`, never `.` or `..`. */
56export const isSafeReq = (req: string): boolean => /^[A-Za-z0-9._-]{1,128}$/.test(req) && req !== '.' && req !== '..'
57
58const num = (s: string | undefined): number | null => {
59 const n = s === undefined ? NaN : Number(s)
60 return Number.isInteger(n) && n > 0 && n < 1_000 ? n : null
61}
62
63/** `round 4/5`, `round 4 of 5`, `round=4`, else a `-r4` req suffix; the ceiling from the same or `ceiling 5`, `max rounds 5`. */
64export function roundOf(text: string, req: string): { round: number | null; ceiling: number | null } {
65 const pair = /\bround[ =:]*(\d+)\s*(?:\/|of)\s*(\d+)/i.exec(text)
66 const alone = /\bround[ =:]*(\d+)\b/i.exec(text)
67 const suffix = /-r(\d+)$/i.exec(req)
68 const cap = /\b(?:ceiling|max[ _-]?rounds)[ =:]*(\d+)/i.exec(text)
69 return {
70 round: num(pair?.[1]) ?? num(alone?.[1]) ?? num(suffix?.[1]),
71 ceiling: num(pair?.[2]) ?? num(cap?.[1]),
72 }
73}
74
75/** `deadline: 45 minutes`, `Deadline 2 h`, `DEADLINE: 90 min` in milliseconds; null when the text names none. */
76export function deadlineOf(text: string): number | null {
77 const m = /\bdeadline\b[^0-9\n]{0,12}(\d+)\s*(minutes?|mins?|m|hours?|hrs?|h)\b/i.exec(text)
78 const n = num(m?.[1])
79 if (!m || n === null) return null
80 return /^h/i.test(m[2] ?? '') ? n * 3_600_000 : n * 60_000
81}
82
83const isBrief = (type: string): boolean => type === 'request' || type === 'handoff'
84/** A hub-to-spoke envelope: a brief or a follow-up; every other type is a spoke's report. */
85const isHubToSpoke = (type: string): boolean => isBrief(type) || type === FOLLOWUP
86
87/**
88 * The spoke an envelope concerns: a `to=<pane>` on its header line or a
89 * `herdr agent prompt <pane>` in the text; else the sender, when that is not
90 * this pane (a spoke's report reaching this session); else the first other
91 * pane id in the text. '' when none. null when this pane is the spoke: a
92 * brief or follow-up from another pane with no other target named (sent to
93 * this pane), or a report this pane sends (its own, to its hub).
94 */
95function targetOf(text: string, rest: string, type: string, fromPane: string, selfPane: string): string | null {
96 const isIncoming = fromPane !== selfPane
97 if (!isIncoming && !isHubToSpoke(type)) return null
98 const to = /\bto=(w[A-Za-z0-9]+:p\d+)\b/.exec(rest)?.[1] ?? /\bherdr agent prompt\s+(w[A-Za-z0-9]+:p\d+)\b/.exec(text)?.[1]
99 if (to && to !== selfPane) return to
100 if (isIncoming && isHubToSpoke(type)) return null
101 if (isIncoming && /^w[A-Za-z0-9]+:p\d+$/.test(fromPane)) return fromPane
102 const others = [...text.matchAll(PANE_ID)].map(m => m[0]).filter(p => p !== selfPane && p !== fromPane)
103 return others[0] ?? ''
104}
105
106/**
107 * Every envelope header in a prompt's text, in order, with the target, round
108 * and deadline the text gives; an envelope that makes this pane the spoke
109 * (see targetOf) is left out, so the board never lists the hub as a spoke.
110 */
111export function parseEnvelopes(text: string, selfPane: string): Envelope[] {
112 const found: Envelope[] = []
113 for (const m of text.matchAll(HEADER)) {
114 const [, req = '', fromAgent = '', fromPane = '', rawType = '', rest = ''] = m
115 // A one-line header in quotes ends its type with the closing quote.
116 const type = rawType.replace(/["'`]+$/, '')
117 if (!isSafeReq(req) || (!(ENVELOPE_TYPES as readonly string[]).includes(type) && type !== FOLLOWUP)) continue
118 const target = targetOf(text, rest, type, fromPane, selfPane)
119 if (target === null) continue
120 found.push({
121 req,
122 fromAgent,
123 fromPane,
124 type,
125 target,
126 ...roundOf(text, req),
127 deadlineMs: deadlineOf(text),
128 })
129 }
130 return found
131}
132
133/**
134 * The requests after these envelopes, submitted at `at`: a new request starts
135 * its deadline clock now (the text's deadline, else 30 minutes for a request
136 * or handoff); a known one keeps its start, takes a pane, round or deadline
137 * the new envelope names, and its type. A follow-up only updates a known
138 * request, and a deadline it names counts from the follow-up. The newest
139 * MAX_REQUESTS are kept.
140 */
141export function recordEnvelopes(prev: Readonly<Record<string, OrchRequest>>, envs: readonly Envelope[], at: number): Record<string, OrchRequest> {
142 const next: Record<string, OrchRequest> = { ...prev }
143 for (const env of envs) {
144 const old = next[env.req]
145 const isFollowup = env.type === FOLLOWUP
146 if (isFollowup && !old) continue
147 const deadlineMs = env.deadlineMs ?? (isBrief(env.type) && !old?.deadlineAt ? DEFAULT_DEADLINE_MS : null)
148 const deadlineFrom = isFollowup ? at : (old?.sentAt ?? at)
149 next[env.req] = {
150 req: env.req,
151 pane: env.target || old?.pane || '',
152 type: env.type,
153 round: env.round ?? old?.round ?? null,
154 ceiling: env.ceiling ?? old?.ceiling ?? null,
155 sentAt: old?.sentAt ?? at,
156 deadlineAt: deadlineMs !== null ? deadlineFrom + deadlineMs : (old?.deadlineAt ?? null),
157 result: old?.result ?? null,
158 }
159 }
160 const kept = Object.values(next)
161 .sort((a, b) => b.sentAt - a.sentAt)
162 .slice(0, MAX_REQUESTS)
163 return Object.fromEntries(kept.map(r => [r.req, r]))
164}
165
166/** The result file of a request: `<home>/.local/state/herdr-orch/<req>/result.md`. */
167export const resultPath = (home: string, req: string): string => `${home.replace(/\/+$/, '')}/${ORCH_DIR}/${req}/result.md`
168
169/**
170 * A result file's status, POLICY.md section 4: the first line exactly
171 * `req: <req>`, the second exactly `status: <status>`, then a non-blank line
172 * (the deliverable or the explanation); '' when it is not valid so, as while
173 * the spoke is still writing it.
174 */
175export function resultStatus(text: string, req: string): string {
176 const [first = '', second = '', ...body] = text.split(/\r?\n/)
177 if (first !== `req: ${req}`) return ''
178 const status = /^status: (\S+)$/.exec(second)?.[1] ?? ''
179 if (!body.some(line => line.trim() !== '')) return ''
180 return RESULT_STATUSES.includes(status) ? status : ''
181}
182
183/** The newest request of each spoke pane (the one the board shows on its row). */
184export function requestsByPane(requests: Readonly<Record<string, OrchRequest>>): Map<string, OrchRequest> {
185 const byPane = new Map<string, OrchRequest>()
186 for (const r of Object.values(requests)) {
187 if (!r.pane) continue
188 const had = byPane.get(r.pane)
189 if (!had || r.sentAt > had.sentAt) byPane.set(r.pane, r)
190 }
191 return byPane
192}
193
194/**
195 * The board rows of a herdr read at `at`: each peer's time in its state from
196 * the alarm's `seen` when it has the pane in that state, else from the last
197 * board when that read is recent (within two ticks; an older one, as after
198 * the board was closed, may have missed changes), else from now.
199 */
200export function boardRows(peers: readonly Peer[], seen: Readonly<Record<string, PeerSeen>> | null, last: Board | null, at: number): BoardRow[] {
201 const recent = last && at - last.at <= 2 * TICK_MS ? last : null
202 return peers.map(p => {
203 const s = seen?.[p.pane]
204 const b = recent?.rows.find(r => r.pane === p.pane)
205 const since = s && s.status === p.status ? s.since : b && b.status === p.status ? b.since : at
206 return { pane: p.pane, agent: p.agent, status: p.status, title: p.title, since }
207 })
208}
209
210/** Blocked peers first, the rest in herdr's order. */
211export const sortedRows = (rows: readonly BoardRow[]): BoardRow[] => [
212 ...rows.filter(r => r.status === 'blocked'),
213 ...rows.filter(r => r.status !== 'blocked'),
214]
215
216/** Each state's glyph and colour on the board; blocked is amber. */
217export const STATE_GLYPH: Record<PeerStatus, string> = { blocked: '▲', working: '●', idle: '○', done: '✓', unknown: '?' }
218export const STATE_COLOR: Record<PeerStatus, string> = {
219 blocked: '#f59e0b',
220 working: '#60a5fa',
221 idle: '#9ca3af',
222 done: '#22c55e',
223 unknown: '#9ca3af',
224}
225
226/** `▲ blocked 4m`: the glyph, the word and the time in the state (left out until a later read sees it so). */
227export const stateText = (r: BoardRow, at: number): string =>
228 `${STATE_GLYPH[r.status]} ${r.status}${r.since < at ? ' ' + durationText(at - r.since) : ''}`
229
230/** `"title"` cut as the toasts cut it; '' when none. */
231export const titleText = (title: string): string => quoted(title).trim()
232
233/** `⏳ 26m left`, `overdue 4m`; '' when no deadline is known. */
234export function deadlineText(r: OrchRequest, at: number): string {
235 if (r.deadlineAt === null) return ''
236 const left = r.deadlineAt - at
237 return left > 0 ? `⏳ ${durationText(left)} left` : `overdue ${durationText(-left)}`
238}
239
240/** `result.md ✓ completed`, `result.md ✗ failed`, `result.md ▲ blocked`, `result.md (no status)`, `no result.md`. */
241export function resultText(result: string | null): string {
242 if (result === null) return 'no result.md'
243 if (result === '') return 'result.md (no status)'
244 return `result.md ${result === 'completed' ? '✓' : result === 'failed' ? '✗' : '▲'} ${result}`
245}
246
247/** The spoke line under a peer: `req apr-alp-r4 · round 4/5 · ⏳ 26m left · no result.md`. */
248export function orchText(r: OrchRequest, at: number): string {
249 const round = r.round !== null ? `round ${r.round}${r.ceiling !== null ? '/' + r.ceiling : ''}` : ''
250 // A result file settles the request: its countdown no longer applies.
251 const deadline = r.result ? '' : deadlineText(r, at)
252 return [`req ${r.req}`, round, deadline, resultText(r.result)].filter(Boolean).join(' · ')
253}
254
255/** The board's head: `3 peers in w1 · 1 blocked · read every 30 s`. */
256export function headText(board: Board | null): string {
257 if (!board) return 'Reading herdr…'
258 const n = board.rows.length
259 const blocked = board.rows.filter(r => r.status === 'blocked').length
260 return [`${n} ${n === 1 ? 'peer' : 'peers'} in ${board.workspace}`, ...(blocked > 0 ? [`${blocked} blocked`] : []), 'read every 30 s'].join(' · ')
261}
262hooks/pane.tsx 86 lines1// The peers board pane (/peer-alarm board): a dim head with framed `r: refresh`
2// and `q: close` buttons, then one row per peer of this herdr workspace (state
3// glyph and word with the time in that state, pane id, agent, title), blocked
4// peers first and amber; under a spoke of this session, its HERDR-ORCH request,
5// round, deadline countdown and result file. Box, Text and Button only, so
6// every surface draws it the same.
7import { atom, read } from 'claude-code'
8import type { EngineInterface, RenderInput } from 'claude-code'
9
10import { STATE_COLOR, headText, orchText, requestsByPane, sortedRows, stateText, titleText } from './orch'
11
12const board = atom({ plugin: 'peers', key: 'board' } as const, null)
13const requests = atom({ plugin: 'peers', key: 'requests' } as const, {})
14
15export type PaneActions = {
16 /** `r`, the twin of `/peer-alarm board`: polls herdr now. */
17 refresh: () => void
18 /** `q`, the twin of `/peer-alarm board close`. */
19 close: () => void
20}
21
22export async function drawPane($: EngineInterface, e: RenderInput<'Pane'>, act: PaneActions) {
23 const b = await read($, board)
24 const byPane = requestsByPane(await read($, requests))
25 const { Box, Text, Button } = $.ui.resolve(e)
26 // The board moves on its reads: times and countdowns are as of the last one.
27 const at = b?.at ?? 0
28 const rows = b ? sortedRows(b.rows) : []
29
30 return (
31 <Box flexDirection="column">
32 <Box flexDirection="row" gap={1}>
33 <Box key="peers-head" flexGrow={1} flexShrink={1}>
34 <Text dimColor wrap="truncate-end">
35 {headText(b)}
36 </Text>
37 </Box>
38 <Box flexShrink={0} flexDirection="row" gap={1}>
39 <Button key="peers-refresh" label="r: refresh" hotkey="r" onPress={() => act.refresh()} />
40 <Button key="peers-close" label="q: close" hotkey="q" onPress={() => act.close()} />
41 </Box>
42 </Box>
43 {b && rows.length === 0 ? <Text dimColor>{`No other agents in herdr workspace ${b.workspace}.`}</Text> : null}
44 {rows.map(r => {
45 const req = byPane.get(r.pane)
46 const isBlocked = r.status === 'blocked'
47 return (
48 <Box key={`peer-${r.pane}`} flexDirection="column">
49 <Box flexDirection="row" gap={1}>
50 <Box key={`state-${r.pane}`} flexShrink={0}>
51 <Text color={STATE_COLOR[r.status]} bold={isBlocked}>
52 {stateText(r, at)}
53 </Text>
54 </Box>
55 <Box key={`pane-${r.pane}`} flexShrink={0}>
56 <Text {...(isBlocked ? { color: STATE_COLOR.blocked } : {})}>
57 {r.pane}
58 </Text>
59 </Box>
60 <Box flexShrink={0}>
61 <Text dimColor={!isBlocked} {...(isBlocked ? { color: STATE_COLOR.blocked } : {})}>
62 {r.agent}
63 </Text>
64 </Box>
65 {r.title ? (
66 <Box key={`title-${r.pane}`} flexGrow={1} flexShrink={1}>
67 <Text wrap="truncate-end" {...(isBlocked ? { color: STATE_COLOR.blocked } : {})}>
68 {titleText(r.title)}
69 </Text>
70 </Box>
71 ) : null}
72 </Box>
73 {req ? (
74 <Box key={`orch-${r.pane}`} paddingLeft={2}>
75 <Text dimColor wrap="truncate-end">
76 {orchText(req, at)}
77 </Text>
78 </Box>
79 ) : null}
80 </Box>
81 )
82 })}
83 </Box>
84 )
85}
86types/index.d.ts 90 lines1// The peers mod's state contract.
2
3/** A herdr agent's state as `herdr agent list` reports it (`agent_status`). */
4export type PeerStatus = 'idle' | 'working' | 'blocked' | 'done' | 'unknown'
5
6/** What this session last saw of one peer pane. */
7export type PeerSeen = {
8 /** The pane's last state. */
9 status: PeerStatus
10 /** When this session first saw the pane in that state, `$.clock.now()` milliseconds. */
11 since: number
12 /** The agent herdr names (`claude`, `codex`). */
13 agent: string
14 /** The pane's terminal title without its spinner (`terminal_title_stripped`); '' when none. */
15 title: string
16}
17
18/** One row of the board pane: a peer as the last board read saw it. */
19export type BoardRow = {
20 pane: string
21 agent: string
22 status: PeerStatus
23 title: string
24 /**
25 * When the pane was first seen in this state, `$.clock.now()` milliseconds:
26 * from the alarm's `seen` when it has the pane so, else from the last board
27 * read, else the time of this read (then no time is drawn yet).
28 */
29 since: number
30}
31
32/** The board pane's data, as of its last read of herdr. */
33export type Board = {
34 /** When herdr was read, `$.clock.now()` milliseconds. */
35 at: number
36 /** The herdr workspace read. */
37 workspace: string
38 /** The peers, in herdr's order (the drawing puts blocked ones first). */
39 rows: BoardRow[]
40}
41
42/**
43 * A HERDR-ORCH request this session took part in, from the envelope headers
44 * (`[HERDR-ORCH v0] req=<id> from=<agent>@<pane> type=<type>`) in its prompts.
45 */
46export type OrchRequest = {
47 /** The request id (`req=`), also the folder under `~/.local/state/herdr-orch/`. */
48 req: string
49 /** The spoke's pane id (`w1:p6`); '' while no envelope named it. */
50 pane: string
51 /** The type of the last envelope seen (`request`, `progress`, `completed`, ...). */
52 type: string
53 /** The round, from `round 4/5`, `round 4 of 5`, `round 4` or a `-r4` req suffix; null when none. */
54 round: number | null
55 /** The round ceiling, from `round 4/5`, `ceiling 5`, `max rounds 5`; null when none. */
56 ceiling: number | null
57 /** When the first envelope of the request was submitted here, `$.clock.now()` milliseconds. */
58 sentAt: number
59 /** When the deadline passes (the brief's `deadline: N minutes`, else POLICY's 30 minutes for a request or handoff); null when unknown. */
60 deadlineAt: number | null
61 /**
62 * The result file as last read while the board was open: null when not read
63 * yet or missing, '' when it is there without a valid `status:` line, else
64 * its status (`completed`, `failed`, `blocked`).
65 */
66 result: string | null
67}
68
69declare module 'claude-code' {
70 interface PluginState {
71 peers: {
72 /**
73 * The peers of this workspace by pane id (this pane left out), as of the
74 * last poll; null until the first poll, which only takes a baseline.
75 */
76 seen: Record<string, PeerSeen> | null
77 /**
78 * True once a peer was seen working in this session and ALL DONE has not
79 * fired since; ALL DONE fires only while it is true, and a peer going
80 * working sets it again.
81 */
82 isArmed: boolean
83 /** The board pane's last read of herdr; null until the board first reads. */
84 board: Board | null
85 /** The HERDR-ORCH requests seen in this session's prompts, by request id. */
86 requests: Record<string, OrchRequest>
87 }
88 }
89}
90