SLOPSHOPPER

peers

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…

newpanecommandtoastpromptprocess
v0.2.0MITupdated 2026-10-20ZH-L1N/agent-window-mods/peers
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · peers
│ ┃ peers ✕ › fix the failing auth test and add an audit log call │ ┃ Reading herdr… [ r: refresh ] [ q: close ] │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /peer-alarm │ ⎿ peers: The peer alarm needs a herdr pane (HERDR_ENV=1 with a wor │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · peers
Reading herdr… [ r: refresh ] [ q: close ]
README

peers

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

How it works

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: a peer whose state becomes blocked (or a new pane that appears blocked). One toast per change, not one per poll.
  • ✓ finished: a peer that goes from working to idle or done.
  • ★ ALL DONE · N peers idle: every peer is 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.

Board (0.2.0)

/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
  • One row per peer of this workspace: the state's glyph and word (▲ 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.
  • While the pane is open, the 30 s tick reads herdr even when the alarm is off (/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.
  • Keys while the pane has the focus (ctrl+x tab): r polls now (the twin is /peer-alarm board again), q closes it (/peer-alarm board close).

Which peers are this session's spokes

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

  • Spoke pane: 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.
  • This pane is the spoke: a 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: 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: 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.

Sound

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.

Command

/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.

Options

OptionValuesDefault
soundoff, onoff

Install

From the local marketplace folder (~/.claude/mods):

claude plugin install peers@zehui-mods --scope user

Notes

  • No network and no model: the mod runs herdr agent list and nothing else, and reads only ~/.local/state/herdr-orch/<req>/result.md files.
  • Only the current herdr workspace is watched; cross-workspace watching is out of scope (hand off to the other workspace instead).
  • English only. No band row; one pane, peers.
Source 5 files
hooks/register.tsx 337 lines
1// 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}
337
hooks/kit.ts 140 lines
1// 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'
140
hooks/orch.ts 262 lines
1// 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}
262
hooks/pane.tsx 86 lines
1// 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}
86
types/index.d.ts 90 lines
1// 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