Observe-only herd band and /herd pane: shepherd tracks blocked, in review or stale, sessions waiting on you, and whether headless claude -p runs really…

One line above the prompt and a /herd pane that say, from any session, which parallel tracks are blocked, in review or stale, which sessions wait on you, and whether the unattended claude -p runs really finished. A run can exit 0 with subtype: success while the background subagent it was waiting for got killed; the band turns red for that.
3 tracks: 1 blocked · 1 in review · 1 stale │ 1 waiting on you │ 1 bg working │ Nightly 10-09 ✗ unfinished │ Weekly 10-05 ok
The mod is a view. herd does the reading and the judging: herd snapshot --json joins shepherd's tracks, the live sessions and the run logs its sources file names, and the mod draws what it prints. The run verdicts (unfinished, asked, late and the rest) are described in herd's README, and the sources file in harnesses/herd/presets/.
It is observe-only. Every hook passes its event on unchanged, except /herd itself once the mod has registered it. It has no tool.call or tool.check hook, makes no $.model call and parses no transcript. The one press that reaches a model is Ping, which starts a turn in the session you ping (below). In a session where nothing draws (claude -p, cron, the SDK) it registers nothing, starts no timer and runs nothing until a client attaches, and claude -p "/herd" is passed on as if the mod were not there. mods/CLAUDE.md has the rules it follows.
Every 60 seconds (refresh_seconds), in a live session only: herd snapshot --json --stale-hours <stale_hours>. That takes about 0.4 s here, most of it shepherd status. Each part of the snapshot carries its own error:
tracks ?.runs ?, and an unreadable log as its own unreadable row.herd ? and the pane says why. With herd not installed, it says how to get it: install.sh links it into ~/.local/bin, or set herd_bin.A live session is any session with a surface: a terminal, the desktop app, and also a background session (claude --bg, agent view), which starts on a terminal surface with nobody looking.
Each live session refreshes on its own clock and shares its read in $.store, which every session on the machine reads. When another session's read is less than three quarters of a period old (45 s at the default), a session shows that read instead of running herd again, so the machine runs herd about once a period. A shared read of another schema or other options is read again, never drawn. The read runs on the clock, outside any hook.
h once the band has the focus). Pressing it opens the pane on the tab that needs you: runs with an unacked failure first, then tracks that are blocked, conflicting or failing checks, then sessions waiting on you. The band option turns the band off; the pane stays on /herd./herd [tracks|sessions|runs] opens the pane, and Esc closes it.herd: a ~/.claude/skills/herd/ folder, say, since a personal skill's folder name is its command too. Claude Code then refuses to register the mod's /herd, and the mod passes /herd on, so it runs yours./herd is missing.2 conflicting is two tracks with a conflicting pair.shepherd inbox --json in each repo's main checkout, and Accept runs shepherd accept <id> there. Both run with CLAUDE_CODE_SESSION_ID empty, so shepherd sees the person, not this session's track.shepherd serve --daemon and prints the dashboard URL, with shepherd's port-forward hint for a remote machine.$.session.send to a live session other than this one, and toasts whether it was delivered. The check follows the session id across /clear, /resume and /branch.herd ack <key>…, which marks a failure as seen in herd's own state (~/.local/state/herd/acked.json). The status line clears here at once, and the pane reads again.⚠, and acked before its reason) until a newer log replaces it.$.ui.status) holds the oldest unacked run failure, with a count of the rest.session.receive with origin peer or peer-send-message) shows as a toast: its kind, the sender's name from the engine's <cross-session-message from-name=…> envelope, and the body's first line, such as peer from api-fix: Status check from the person.<agent-message> envelope), a teammate's mailbox message, and a message for one of this session's agents.Every button is the person's press. Nothing runs on a timer except the read above.
It fails open: a failed refresh keeps the last snapshot on screen. Its own failures show in two places, and in the debug log under claude --debug:
herd ? while refreshes fail, and with as of 5m ago once what it shows is more than three refresh periods old./herd not registered.claude plugin install herd@claude-dotfiles
The old fleet@claude-dotfiles is gone from the marketplace: claude plugin uninstall fleet@claude-dotfiles first. For runs to show, herd needs a sources file (~/.config/herd/sources.json; see its presets).
The options live in pluginConfigs["herd@claude-dotfiles"].options in user settings (herd@inline for a --plugin-dir copy), or claude plugin configure herd@claude-dotfiles.
| Option | Default | | | :- | :- | :- | | band | true | Draw the band | | herd_bin | herd | The herd the mod runs: a name looked up on PATH, or a path (~/ is home) | | shepherd_bin | shepherd | The shepherd Inbox, Accept and Open board run | | refresh_seconds | 60 | 15 to 3600 | | stale_hours | 24 | 1 to 720. An active track with no activity for this long, or whose owner ended, is stale |
A number outside its range makes Claude Code refuse to load the mod at all: the band and the status line are gone, and only claude --debug says why (herd: options do not fit plugin.json userConfig: …).
| File | What it holds | | :- | :- | | .claude-plugin/plugin.json | Name, version, the types contract and the options | | hooks/hooks.json | "modules": ["./register.ts"] | | hooks/register.ts | The hooks, with hostOf binding $ once: session.start / session.attach go live (timers, /herd); classic.SessionStart reloads state and the session id after /clear; session.receive toasts; command.run; ui.render for the pane and the band | | hooks/read.ts | herd snapshot, herd ack, and the Inbox, Accept and board presses | | hooks/herd.ts | Pure: the options, the snapshot's shape check, peer envelopes, the band's and the pane's words | | hooks/view.ts | The pane's tree | | types/index.d.ts | The $.state contract and the snapshot's shapes | | tests/ | fixtures.ts (a stub world, and a snapshot as herd prints it); herd.test.ts (the hooks); words.test.ts (the pure half and read.ts); test_contract.py (herd's real output against snapshotOf and the fixture's fields) |
mods/check.sh herd # validate, tests, probe, headless load, tsc
python3 mods/herd/tests/test_contract.py # what herd prints is what the mod draws (Node 22.6+)
harnesses/tools/moddev.sh herd --smoke # headless load, no model turn
harnesses/tools/moddev.sh herd # live: the band shows; type /herd
Not covered:
statusLine), which a mod cannot draw. The band sits beside it.Tested with Claude Code 2.1.296.
hooks/register.ts 518 lines1// herd: one line above the prompt and a /herd pane that say, from any session,
2// which shepherd tracks are blocked, in review or stale, which sessions wait on
3// the person, and whether unattended `claude -p` runs really finished. It draws
4// what `herd snapshot --json` prints; herd does the reading and the judging.
5// It follows mods/CLAUDE.md: inert where nothing draws (claude -p, cron, the
6// SDK), observe-only (every engine event but its own registered /herd is passed
7// on unchanged), no tool.call or tool.check hook, no model call, and every
8// process it starts is either a read on its clock or a press of the person's.
9import { atom, read, update } from 'claude-code'
10import type {
11 EngineInterface,
12 HerdFaults,
13 HerdInboxEntry,
14 HerdRun,
15 HerdSession,
16 HerdSnapshot,
17 HerdTab,
18 On,
19 PluginOptions,
20 SessionSendResult,
21 Timer,
22} from 'claude-code'
23
24import { ackedOf, bandOf, configKeyOf, configOf, fitted, peerToastOf, plural, sharedOf, statusOf, tabOf, toneOf, urgentTab, withHealth } from './herd'
25import type { Config } from './herd'
26import { acceptEntry, ackRuns, boardUrl, errorText, readInbox, readSnapshot } from './read'
27import type { Reader } from './read'
28import { paneOf, styleOf } from './view'
29
30const PANE = 'herd'
31
32const NO_FAULTS: HerdFaults = { refresh: null, share: null, command: null }
33
34// `plugin` and `key` stay string literals and each atom sits in a const:
35// `claude plugin validate` reads them off the source and holds them to
36// types/index.d.ts.
37const snapshotAtom = atom({ plugin: 'herd', key: 'snapshot' } as const, null)
38const ackedAtom = atom({ plugin: 'herd', key: 'acked' } as const, [])
39const tabAtom = atom({ plugin: 'herd', key: 'tab' } as const, 'tracks')
40const inboxAtom = atom({ plugin: 'herd', key: 'inbox' } as const, null)
41const noticeAtom = atom({ plugin: 'herd', key: 'notice' } as const, null)
42const faultsAtom = atom({ plugin: 'herd', key: 'faults' } as const, NO_FAULTS)
43
44/**
45 * What Ping sends: a question for the other session's model, nothing it must
46 * act on. It starts or joins a turn there; the answer stays in that session's
47 * own transcript.
48 */
49export const PING_TEXT =
50 'Status check from the person, sent with the herd pane: say in one line, in your own reply here, what you are doing and what you are waiting for. Do not message the sender back.'
51
52/** A refresh another session stored this recently is shown as it is, not read again. */
53const FRESH_SHARE = 0.75
54
55/** The band is a plain Button: `h: ` before its pieces, and the engine's hint beside it when focused. */
56const BAND_RESERVED_CELLS = 3 + 2
57
58/** The $.store key every session shares its last read under: `{ config, snapshot }`. */
59const SHARED = 'shared'
60
61/**
62 * The engine as `session.start` (or `session.attach`) bound it: the timers and
63 * the presses reach the engine through this, so read.ts and the refresh below
64 * are plain functions over it.
65 */
66type Host = Reader & {
67 storeGet: (key: string) => Promise<unknown>
68 storeSet: (key: string, value: unknown) => Promise<void>
69 status: (text: string | undefined) => void
70 toast: (text: string) => void
71 log: (text: string) => void
72 send: (sessionId: string, text: string) => Promise<SessionSendResult>
73 setSnapshot: (snapshot: HerdSnapshot | null) => Promise<unknown>
74 setAcked: (acked: string[]) => Promise<unknown>
75 setNotice: (notice: string | null) => Promise<unknown>
76 setInbox: (inbox: HerdInboxEntry[] | null) => Promise<unknown>
77 setFaults: (faults: HerdFaults) => Promise<unknown>
78}
79
80/** The module's own state: what a reload may lose. */
81type Live = {
82 cfg: Config
83 key: string
84 isLive: boolean
85 /** `/herd` is this mod's: registered in this session. Until then /herd is passed on to whatever owns it. */
86 isRegistered: boolean
87 host: Host | null
88 home: string | null
89 selfId: string | null
90 timers: Timer[]
91 isReading: boolean
92 isQueued: boolean
93 /** The snapshot last shown, and the acks it was shown with. */
94 last: HerdSnapshot | null
95 shownAt: number | null
96 shownAcked: string | null
97 /** Keys this session acked with `herd ack`: shown as acked even by a read that began before the press. */
98 ackedHere: string[]
99 /** The status line last set: set again only when it changes. */
100 status: string | undefined
101 /** The faults last put in $.state: written again only when they change. */
102 faults: HerdFaults
103}
104
105/**
106 * Binds a Host from `$`. Declared at the top level of this file, each member
107 * spelled `$.noun.method(...)`, so the engine reads what the module calls off
108 * its source.
109 */
110function hostOf($: EngineInterface): Host {
111 return {
112 now: () => $.clock.now(),
113 run: (argv, init) => $.process.run(argv, init),
114 storeGet: key => $.store.get(key),
115 storeSet: (key, value) => $.store.set(key, value),
116 status: text => $.ui.status(text),
117 toast: text => $.ui.toast(text),
118 log: text => $.ui.log(text, { to: 'debug' }),
119 send: (sessionId, text) => $.session.send({ to: { sessionId }, text }),
120 setSnapshot: snapshot => update($, snapshotAtom, () => snapshot),
121 setAcked: acked => update($, ackedAtom, () => acked),
122 setNotice: notice => update($, noticeAtom, () => notice),
123 setInbox: inbox => update($, inboxAtom, () => inbox),
124 setFaults: faults => update($, faultsAtom, () => faults),
125 }
126}
127
128/** Puts a snapshot and its acks on screen: the state the band and pane read, and the status line. */
129async function show(live: Live, host: Host, snapshot: HerdSnapshot | null): Promise<void> {
130 const acked = ackedOf(snapshot, live.ackedHere)
131 const ackedKey = JSON.stringify(acked)
132
133 live.last = snapshot
134 if (snapshot !== null && live.shownAt !== snapshot.at) {
135 await host.setSnapshot(snapshot)
136 live.shownAt = snapshot.at
137 }
138 if (live.shownAcked !== ackedKey) {
139 await host.setAcked(acked)
140 live.shownAcked = ackedKey
141 }
142
143 const status = statusOf(snapshot, new Set(acked))
144
145 if (status !== live.status) {
146 host.status(status)
147 live.status = status
148 }
149}
150
151/** Records the mod's own failures where the pane and the band show them; a write only when they change. */
152async function setFaults(live: Live, host: Host, change: Partial<HerdFaults>): Promise<void> {
153 const next = { ...live.faults, ...change }
154
155 if (JSON.stringify(next) !== JSON.stringify(live.faults)) {
156 live.faults = next
157 await host.setFaults(next)
158 }
159}
160
161/**
162 * One refresh: adopt what another session stored a moment ago, else run herd
163 * again, show it, and store it for them. `force` reads whatever is stored. A
164 * store that refuses the write never hides a fresh read.
165 */
166async function tick(live: Live, force: boolean): Promise<void> {
167 const host = live.host
168
169 if (host === null) {
170 return
171 }
172 if (live.isReading) {
173 live.isQueued = live.isQueued || force
174
175 return
176 }
177
178 live.isReading = true
179
180 try {
181 const now = await host.now()
182 let shareError: string | null = null
183 let stored: HerdSnapshot | null = null
184
185 try {
186 stored = sharedOf(await host.storeGet(SHARED), live.key)
187 } catch (error) {
188 shareError = errorText(error)
189 }
190
191 if (!force && stored !== null && now - stored.at < live.cfg.periodMs * FRESH_SHARE) {
192 await show(live, host, stored)
193 } else {
194 const snapshot = await readSnapshot(host, live.cfg, live.home)
195
196 host.log(`herd: read in ${snapshot.tookMs} ms (${snapshot.tracks.length} tracks, ${snapshot.sessions.length} sessions, ${snapshot.runs.length} runs)`)
197 await show(live, host, snapshot)
198
199 if (shareError === null) {
200 shareError = await host.storeSet(SHARED, { config: live.key, snapshot }).then(
201 () => null,
202 (error: unknown) => errorText(error),
203 )
204 }
205 }
206 if (shareError !== null) {
207 host.log(`herd: snapshot not shared: ${shareError}`)
208 }
209 await setFaults(live, host, { refresh: null, share: shareError })
210 } catch (error) {
211 // Fail open: the band keeps the last snapshot, marked `herd ?`, and the pane says why.
212 host.log(`herd: refresh failed: ${String(error)}`)
213 await setFaults(live, host, { refresh: errorText(error) }).catch(() => undefined)
214 } finally {
215 live.isReading = false
216
217 if (live.isQueued) {
218 live.isQueued = false
219 void tick(live, true)
220 }
221 }
222}
223
224/** Shows the read another session (or this one before a reload) stored, when it was read with these options. */
225async function adoptStored(live: Live, host: Host): Promise<void> {
226 await show(live, host, sharedOf(await host.storeGet(SHARED), live.key))
227}
228
229/** Marks failures as seen with `herd ack`, for every session and the board, then reads again. Says what it acked. */
230async function ack(live: Live, host: Host, runs: readonly HerdRun[]): Promise<string> {
231 const keys = runs.map(r => r.key)
232
233 await ackRuns(host, live.cfg, live.home, keys)
234 live.ackedHere = [...new Set([...live.ackedHere, ...keys])].slice(-500)
235 await show(live, host, live.last)
236 void tick(live, true)
237
238 const [only] = runs
239
240 return runs.length === 1 && only !== undefined ? `acked ${only.source} ${only.name}` : `acked ${plural(runs.length, 'failure')}`
241}
242
243/** Runs one press's work and puts what it said under the pane, a failure included; never rejects. */
244async function noted(host: Host, what: string, work: () => Promise<string>): Promise<void> {
245 let said: string
246
247 try {
248 said = await work()
249 } catch (error) {
250 said = `${what} failed: ${errorText(error)}`
251 host.log(`herd: ${said}`)
252 }
253 await host.setNotice(said).catch(() => undefined)
254}
255
256/**
257 * What the mod does once the session has somewhere to draw. `$` may be passed
258 * to a function at the top level of this file, and nowhere else.
259 */
260async function goLive($: EngineInterface, live: Live): Promise<void> {
261 live.isLive = true
262 live.host = hostOf($)
263 live.home = (await $.env.get('HOME').catch(() => undefined)) ?? null
264 live.selfId = await $.session.id().catch(() => null)
265
266 // The last snapshot shows at once; the band may have drawn already, while
267 // the mod was inert, and a module variable redraws nothing by itself.
268 try {
269 await adoptStored(live, live.host)
270 } catch (error) {
271 $.ui.log(`herd: stored snapshot not read: ${String(error)}`, { to: 'debug' })
272 }
273 $.ui.invalidate('ui.render')
274
275 for (const timer of live.timers) {
276 timer.cancel()
277 }
278 live.timers = [$.clock.after(0, () => void tick(live, false)), $.clock.every(live.cfg.periodMs, () => void tick(live, false))]
279
280 // Last, and caught: register throws for a taken name (a skill or command the
281 // person has called herd), and a hook that throws is skipped whole. /herd
282 // then stays theirs (command.run passes it on); the band still opens the
283 // pane, and the pane's footer says why /herd is missing.
284 try {
285 await $.command.register({ name: 'herd', description: 'Open the herd pane: shepherd tracks, sessions and headless runs', immediate: true })
286 live.isRegistered = true
287 await setFaults(live, live.host, { command: null })
288 } catch (error) {
289 $.ui.log(`herd: /herd not registered: ${String(error)}`, { to: 'debug' })
290 await setFaults(live, live.host, { command: errorText(error) }).catch(() => undefined)
291 }
292}
293
294export function register(on: On, options: PluginOptions) {
295 const cfg = configOf(options)
296 // isLive is false until the session has a surface; until then every hook
297 // only passes its event on. Module state is enough: a reload runs
298 // session.start again.
299 const live: Live = {
300 cfg,
301 key: configKeyOf(cfg),
302 isLive: false,
303 isRegistered: false,
304 host: null,
305 home: null,
306 selfId: null,
307 timers: [],
308 isReading: false,
309 isQueued: false,
310 last: null,
311 shownAt: null,
312 shownAcked: null,
313 ackedHere: [],
314 status: undefined,
315 faults: NO_FAULTS,
316 }
317
318 // A terminal session, a background one (`claude --bg`, agent view: it starts
319 // on a terminal surface with nobody looking) and a desktop one go live here.
320 on('session.start', async ($, e, next) => {
321 if ((await $.session.surfaces()).length === 0) {
322 return next(e)
323 }
324
325 await goLive($, live)
326
327 return next(e)
328 })
329
330 // A session that started with nothing to draw on goes live when a client
331 // attaches. The terminal never raises it.
332 on('session.attach', async ($, e, next) => {
333 if (!live.isLive) {
334 await goLive($, live)
335 }
336
337 return next(e)
338 })
339
340 // /clear, /resume and /branch reset $.state and go on under a new session id,
341 // with no session.start: read the id again (so Ping never offers this session
342 // to itself) and copy the shared snapshot back from $.store. Reads only, so
343 // it may run with nothing attached too.
344 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
345 const result = await next(e)
346
347 if (live.isLive) {
348 try {
349 live.selfId = await $.session.id().catch(() => live.selfId)
350 live.last = sharedOf(await $.store.get(SHARED), live.key) ?? live.last
351 live.shownAt = live.last?.at ?? null
352
353 const acked = ackedOf(live.last, live.ackedHere)
354
355 live.shownAcked = JSON.stringify(acked)
356 // The next refresh pins the status line again if it still has something to say.
357 live.status = undefined
358 await update($, snapshotAtom, () => live.last)
359 await update($, ackedAtom, () => acked)
360 await update($, faultsAtom, () => live.faults)
361 } catch (error) {
362 $.ui.log(`herd: state not restored after ${e.source}: ${String(error)}`, { to: 'debug' })
363 }
364 $.ui.invalidate('ui.render')
365 }
366
367 return result
368 }).catch(($, e, next) => next(e))
369
370 // A message from another session: a toast with its sender and first line,
371 // never more. A teammate's mailbox delivery, a message for one of this
372 // session's agents and a subagent's hand-back are this session's own work,
373 // not news. The text is the sender's; nothing here acts on it.
374 on('session.receive', async ($, e, next) => {
375 const result = await next(e)
376 const origin = e.origin
377
378 if (live.isLive && e.agentId === undefined && (origin.kind === 'peer' || origin.kind === 'peer-send-message') && !('teammate' in origin)) {
379 const toast = peerToastOf(origin.kind, e.text)
380
381 if (toast !== null) {
382 $.ui.toast(toast)
383 }
384 }
385
386 return result
387 }).catch(($, e, next) => next(e))
388
389 // Answers /herd only when it is this mod's, registered in a live session. A
390 // skill or command of the person's called herd (register refused the name),
391 // and `claude -p "/herd"` where nothing draws, get the event passed on: an
392 // answer without next would run no command at all. A gate, so caught.
393 on('command.run', { command: 'herd' }, async ($, e, next) => {
394 if (!live.isLive || !live.isRegistered) {
395 return next(e)
396 }
397
398 const tab = tabOf(e.args)
399
400 if (tab !== null) {
401 await update($, tabAtom, () => tab)
402 }
403
404 const opened = await $.ui.open({ id: PANE, title: 'Herd', focus: true, closeOnEscape: true })
405
406 return opened.isPlaced ? {} : { text: `The herd pane is waiting: ${opened.reason}` }
407 }).catch(($, e, next) => next(e))
408
409 on('ui.render', { component: 'Pane', requestId: 'herd' }, async ($, e) => {
410 const { Box, Text, Button } = $.ui.resolve(e)
411 const snapshot = await read($, snapshotAtom)
412 const acked = new Set(await read($, ackedAtom))
413 const host = live.host
414 // Every press runs through noted: what it said, or why it failed, goes under the pane.
415 const press = (what: string, work: (h: Host) => Promise<string>): void => {
416 if (host !== null) {
417 void noted(host, what, () => work(host))
418 }
419 }
420
421 return paneOf(
422 { Box, Text, Button },
423 {
424 snapshot,
425 acked,
426 tab: await read($, tabAtom),
427 inbox: await read($, inboxAtom),
428 notice: await read($, noticeAtom),
429 faults: await read($, faultsAtom),
430 nowMs: await $.clock.now(),
431 selfId: live.selfId,
432 },
433 {
434 tab: tab => void update($, tabAtom, () => tab).catch(() => undefined),
435 refresh: () => void tick(live, true),
436 close: () => void $.ui.close({ id: PANE }).catch(() => undefined),
437 ack: run => press('ack', h => ack(live, h, [run])),
438 ackAll: () => press('ack', h => ack(live, h, (snapshot?.runs ?? []).filter(r => toneOf(r, acked) === 'fail'))),
439 board: () => press('board', h => boardUrl(h, live.cfg, live.home)),
440 inbox: () =>
441 press('inbox', async h => {
442 const got = await readInbox(h, live.cfg, live.home, snapshot?.repos ?? [])
443
444 await h.setInbox(got.entries)
445
446 return got.errors.length === 0 ? `inbox: ${got.entries.length} for you` : `inbox: ${got.errors.join('; ')}`
447 }),
448 accept: (entry: HerdInboxEntry) =>
449 press('accept', async h => {
450 const said = await acceptEntry(h, live.cfg, live.home, entry)
451 const got = await readInbox(h, live.cfg, live.home, snapshot?.repos ?? [])
452
453 await h.setInbox(got.entries)
454
455 return said
456 }),
457 ping: (session: HerdSession) => {
458 // Checked again at the press: the id moves on /clear, /resume and /branch.
459 if (!session.isLive || session.sessionId === live.selfId) {
460 return
461 }
462 press('ping', async h => {
463 const sent = await h.send(session.sessionId, PING_TEXT)
464 const who = session.name ?? session.sessionId.slice(0, 8)
465
466 h.toast(sent.isDelivered ? `ping delivered to ${who}` : `ping to ${who} not delivered: ${sent.reason}`)
467
468 return sent.isDelivered ? `pinged ${who}: its answer stays in its own transcript` : `ping to ${who}: ${sent.reason}`
469 })
470 },
471 },
472 )
473 })
474
475 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
476 if (!live.isLive || !live.cfg.isBandOn || e.props.hasSurvey) {
477 return next(e)
478 }
479
480 const snapshot = await read($, snapshotAtom)
481 const acked = new Set(await read($, ackedAtom))
482 const faults = await read($, faultsAtom)
483 const band = withHealth(bandOf(snapshot, acked), snapshot, faults.refresh, await $.clock.now(), live.cfg.periodMs)
484 const segments = fitted(band, Math.max(20, e.props.bodyColumns - BAND_RESERVED_CELLS))
485
486 if (segments.length === 0) {
487 return next(e)
488 }
489
490 const { Box, Text, Button } = $.ui.resolve(e)
491 const fallback: HerdTab = await read($, tabAtom)
492 // What the mods after this one draw stays, under this line.
493 const below = await next(e)
494
495 return Box({
496 flexDirection: 'column',
497 children: [
498 Button({
499 key: 'herd-open',
500 label: 'herd',
501 hotkey: 'h',
502 plain: true,
503 onPress: async () => {
504 try {
505 await update($, tabAtom, () => urgentTab(snapshot, acked, fallback))
506 await $.ui.open({ id: PANE, title: 'Herd', focus: true, closeOnEscape: true })
507 } catch (error) {
508 $.ui.log(`herd: pane not opened: ${String(error)}`, { to: 'debug' })
509 }
510 },
511 children: segments.map(s => Text({ ...styleOf(s.tone), wrap: 'truncate-end', children: [s.text] })),
512 }),
513 below,
514 ],
515 })
516 })
517}
518hooks/herd.ts 527 lines1// The herd mod's pure half: the options, the snapshot `herd snapshot --json`
2// prints (checked before it is drawn), and the words the band, the status line
3// and the pane say about it. No `$` and no I/O here: read.ts runs herd, this
4// file decides what to show. herd itself judges the tracks, sessions and runs.
5import type { HerdRun, HerdSession, HerdSnapshot, HerdSource, HerdTab, HerdTone, HerdTrack } from 'claude-code'
6
7// ------------------------------------------------------------------ helpers
8
9const isRecord = (value: unknown): value is Record<string, unknown> =>
10 typeof value === 'object' && value !== null && !Array.isArray(value)
11
12const numberOf = (value: unknown): number | null =>
13 typeof value === 'number' && Number.isFinite(value) ? value : null
14
15export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
16
17/** How long ago, in one short word: 40s, 12m, 5h, 3d. */
18export function agoOf(thenMs: number, nowMs: number): string {
19 const s = Math.max(0, Math.round((nowMs - thenMs) / 1000))
20
21 if (s < 60) {
22 return `${s}s`
23 }
24 if (s < 3600) {
25 return `${Math.floor(s / 60)}m`
26 }
27 if (s < 86_400) {
28 return `${Math.floor(s / 3600)}h`
29 }
30
31 return `${Math.floor(s / 86_400)}d`
32}
33
34const pad2 = (n: number): string => String(n).padStart(2, '0')
35
36/** A log's day as MM-DD, local time. */
37export function dayOf(ms: number): string {
38 const d = new Date(ms)
39
40 return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
41}
42
43/** A log's day and minute as MM-DD HH:MM, local time. */
44export function stampOf(ms: number): string {
45 const d = new Date(ms)
46
47 return `${dayOf(ms)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
48}
49
50/** The first non-blank line of a message, cut to `max` characters. */
51export function firstLineOf(text: string, max = 100): string {
52 const line = (text.split('\n').find(l => l.trim() !== '') ?? '').trim()
53
54 return line.length > max ? `${line.slice(0, max - 1)}…` : line
55}
56
57// ------------------------------------------------------------------ options
58
59export type Config = {
60 isBandOn: boolean
61 /** The herd and shepherd executables: a name looked up on PATH, or a path. */
62 herd: string
63 shepherd: string
64 periodMs: number
65 staleHours: number
66}
67
68const MIN_PERIOD_S = 15
69const DEFAULT_PERIOD_S = 60
70const DEFAULT_STALE_H = 24
71
72const optionText = (value: unknown, fallback: string): string => (typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback)
73
74/**
75 * The plugin's options as the mod reads them, every one defaulted and bounded.
76 * Claude Code refuses to load the mod at all when a number is outside its
77 * plugin.json min/max, so the bounds here only hold for callers that skip that
78 * check (the tests, a smoke script).
79 */
80export function configOf(options: Readonly<Record<string, unknown>>): Config {
81 const period = numberOf(options.refresh_seconds) ?? DEFAULT_PERIOD_S
82 const stale = numberOf(options.stale_hours) ?? DEFAULT_STALE_H
83
84 return {
85 isBandOn: options.band !== false,
86 herd: optionText(options.herd_bin, 'herd'),
87 shepherd: optionText(options.shepherd_bin, 'shepherd'),
88 periodMs: Math.max(MIN_PERIOD_S, period) * 1000,
89 staleHours: Math.max(1, stale),
90 }
91}
92
93/**
94 * The shape `herd snapshot --json` prints (SNAPSHOT_SCHEMA in herd.py). A
95 * snapshot of another schema is not drawn: herd and the mod are out of step.
96 */
97export const SCHEMA = 3
98
99/** What a snapshot was read with: another session's stored read is adopted only when this matches. */
100export function configKeyOf(c: Config): string {
101 return JSON.stringify([SCHEMA, c.herd, c.staleHours])
102}
103
104/** `~` and `~/...` under HOME; anything else as given. */
105export function expandHome(path: string, home: string | null): string {
106 if (home === null || (path !== '~' && !path.startsWith('~/'))) {
107 return path
108 }
109
110 return home.replace(/\/$/, '') + path.slice(1)
111}
112
113/** The tab `/herd <word>` names, or null. */
114export function tabOf(args: string): HerdTab | null {
115 const word = args.trim().toLowerCase()
116
117 return word === 'tracks' || word === 'sessions' || word === 'runs' ? word : null
118}
119
120/** Waiting on the person first, then working, idle, finished, and failed or stopped last. */
121export function sessionRank(s: HerdSession): number {
122 if (s.isBlocked) {
123 return 0
124 }
125 if (s.status === 'busy' || s.state === 'working') {
126 return 1
127 }
128 if (s.isLive) {
129 return 2
130 }
131 if (s.state === 'done') {
132 return 3
133 }
134
135 return 4
136}
137
138// ------------------------------------------------------------------ the band and the status line
139
140export type Segment = { text: string; tone: HerdTone }
141
142/** A failure the person acked reads as a warning: still in the band, gone from the status line. */
143export function toneOf(run: HerdRun, acked: ReadonlySet<string>): HerdTone {
144 return run.tone === 'fail' && acked.has(run.key) ? 'warn' : run.tone
145}
146
147const uniqueLabels = (sources: readonly HerdSource[]): string[] => [...new Set(sources.map(s => s.label))]
148
149/** What the band says, as colored pieces: tracks, sessions waiting, then each run source. */
150export function bandOf(snapshot: HerdSnapshot | null, acked: ReadonlySet<string>): Segment[] {
151 if (snapshot === null) {
152 return [{ text: 'herd: reading…', tone: 'dim' }]
153 }
154
155 const out: Segment[] = []
156 const sep = (): void => {
157 if (out.length > 0) {
158 out.push({ text: ' │ ', tone: 'dim' })
159 }
160 }
161
162 if (snapshot.tracksOn && !snapshot.tracksMissing) {
163 sep()
164
165 if (snapshot.tracksError !== null) {
166 out.push({ text: 'tracks ?', tone: 'warn' })
167 } else {
168 const t = snapshot.tracks
169 const pieces: Segment[] = [
170 { text: `${t.filter(x => x.isBlocked).length} blocked`, tone: 'fail' as const },
171 // Tracks, as the other counts: one conflicting pair is two conflicting tracks.
172 { text: `${t.filter(x => x.conflicts > 0).length} conflicting`, tone: 'fail' as const },
173 { text: `${t.filter(x => x.checks === 'failing').length} failing`, tone: 'fail' as const },
174 { text: `${t.filter(x => x.status === 'merged').length} merged`, tone: 'warn' as const },
175 { text: `${t.filter(x => x.status === 'review').length} in review`, tone: 'busy' as const },
176 { text: `${t.filter(x => x.isStale).length} stale`, tone: 'warn' as const },
177 ].filter(p => !p.text.startsWith('0 '))
178
179 out.push({ text: plural(t.length, 'track') + (pieces.length > 0 ? ': ' : ''), tone: 'dim' })
180 pieces.forEach((p, i) => {
181 if (i > 0) {
182 out.push({ text: ' · ', tone: 'dim' })
183 }
184 out.push(p)
185 })
186 }
187 }
188
189 if (snapshot.sessionsOn && !snapshot.sessionsMissing) {
190 if (snapshot.sessionsError !== null) {
191 sep()
192 out.push({ text: 'sessions ?', tone: 'warn' })
193 } else {
194 const waiting = snapshot.sessions.filter(s => s.isBlocked).length
195 const working = snapshot.sessions.filter(s => s.kind === 'background' && s.state === 'working').length
196
197 if (waiting > 0) {
198 sep()
199 out.push({ text: `${waiting} waiting on you`, tone: 'fail' })
200 }
201 if (working > 0) {
202 sep()
203 out.push({ text: `${working} bg working`, tone: 'busy' })
204 }
205 }
206 }
207
208 for (const source of uniqueLabels(snapshot.sources)) {
209 const runs = snapshot.runs.filter(r => r.source === source)
210 const errors = snapshot.sources.filter(s => s.label === source && s.error !== null)
211
212 sep()
213
214 if (runs.length === 0) {
215 out.push({ text: `${source} ${errors.length > 0 ? '?' : 'none'}`, tone: errors.length > 0 ? 'warn' : 'dim' })
216 continue
217 }
218
219 const newest = Math.max(...runs.map(r => r.mtimeMs))
220 const fails = runs.filter(r => toneOf(r, acked) === 'fail')
221 const warns = runs.filter(r => toneOf(r, acked) === 'warn')
222 const busy = runs.filter(r => r.tone === 'busy')
223 const head = `${source} ${newest > 0 ? dayOf(newest) + ' ' : ''}`
224 const [firstFail] = fails
225 const [firstWarn] = warns
226
227 if (firstFail !== undefined) {
228 out.push({ text: `${head}✗ ${fails.length === 1 ? firstFail.verdict : plural(fails.length, 'failure')}`, tone: 'fail' })
229 } else if (busy.length > 0) {
230 out.push({ text: `${head}${busy.length} running`, tone: 'busy' })
231 } else if (firstWarn !== undefined) {
232 out.push({ text: `${head}⚠ ${warns.length === 1 ? firstWarn.verdict : plural(warns.length, 'warning')}`, tone: 'warn' })
233 } else {
234 out.push({ text: `${head}ok`, tone: 'ok' })
235 }
236 }
237
238 if (snapshot.sourcesError !== null && snapshot.sources.length === 0) {
239 sep()
240 out.push({ text: 'runs ?', tone: 'warn' })
241 }
242
243 return out
244}
245
246/** Snapshots this many refresh periods old are marked in the band: the refreshes have stopped landing. */
247const STALE_PERIODS = 3
248
249/**
250 * The band with what qualifies it in front: `herd ?` while refreshes fail,
251 * `as of 5m ago` once the snapshot is older than a few refresh periods. With
252 * nothing read yet and a failing refresh, `herd ?` alone: the pane says why.
253 */
254export function withHealth(band: readonly Segment[], snapshot: HerdSnapshot | null, refreshFault: string | null, nowMs: number, periodMs: number): Segment[] {
255 if (snapshot === null && refreshFault !== null) {
256 return [{ text: 'herd ?', tone: 'warn' }]
257 }
258
259 const notes: Segment[] = []
260
261 if (refreshFault !== null) {
262 notes.push({ text: 'herd ?', tone: 'warn' })
263 }
264 if (snapshot !== null && nowMs - snapshot.at > STALE_PERIODS * periodMs) {
265 notes.push({ text: `as of ${agoOf(snapshot.at, nowMs)} ago`, tone: 'dim' })
266 }
267
268 return notes.length === 0 ? [...band] : [...notes.flatMap((n, i) => (i === 0 ? [n] : [{ text: ' ', tone: 'dim' as const }, n])), { text: ' │ ', tone: 'dim' }, ...band]
269}
270
271/** The framing line the engine may put above a message from another session. */
272const PEER_PREFIX = /^(?:Another Claude session|A peer session) sent a message[^\n]*:[ \t]*\n?/
273
274/** The envelope a delivery's body sits in; a nested one is escaped with a backslash. */
275const ENVELOPE = /^<\\?(cross-session-message|agent-message|teammate-message)\b([^>]*)>/
276
277/**
278 * What a toast says about a message delivered to this session: its kind, the
279 * sender's name when the envelope has one, and the body's first line. Null for
280 * a hand-back from one of this session's own agents (an `agent-message`
281 * envelope), which is not another session's message.
282 */
283export function peerToastOf(kind: string, text: string): string | null {
284 const body = text.trimStart().replace(PEER_PREFIX, '').trimStart()
285 const envelope = ENVELOPE.exec(body)
286
287 if (envelope?.[1] === 'agent-message') {
288 return null
289 }
290
291 const attributes = envelope?.[2] ?? ''
292 // The display name the sender gave, else its address unless that is a socket path.
293 const name = /\bfrom-name="([^"]+)"/.exec(attributes)?.[1] ?? /\bfrom="([^"]+)"/.exec(attributes)?.[1]
294 const from = name === undefined || name.startsWith('uds:') ? '' : ` from ${firstLineOf(name, 40)}`
295 const inner = envelope === null ? body : body.slice(envelope[0].length).replace(/<\\?\/[\w-]+>\s*$/, '')
296
297 return `${kind}${from}: ${firstLineOf(inner, 100) || '(empty)'}`
298}
299
300/** Cuts the band to `columns`: whole pieces while they fit, then the next one shortened with an ellipsis. */
301export function fitted(segments: readonly Segment[], columns: number): Segment[] {
302 const out: Segment[] = []
303 let left = Math.max(0, Math.floor(columns))
304
305 for (const s of segments) {
306 if (s.text.length <= left) {
307 out.push(s)
308 left -= s.text.length
309 continue
310 }
311 if (left > 1) {
312 out.push({ text: `${s.text.slice(0, left - 1)}…`, tone: s.tone })
313 }
314 break
315 }
316
317 return out
318}
319
320/** The tab the band opens: what needs the person most. */
321export function urgentTab(snapshot: HerdSnapshot | null, acked: ReadonlySet<string>, fallback: HerdTab): HerdTab {
322 if (snapshot === null) {
323 return fallback
324 }
325 if (snapshot.runs.some(r => toneOf(r, acked) === 'fail')) {
326 return 'runs'
327 }
328 if (snapshot.tracks.some(t => t.isBlocked || t.conflicts > 0 || t.checks === 'failing')) {
329 return 'tracks'
330 }
331 if (snapshot.sessions.some(s => s.isBlocked)) {
332 return 'sessions'
333 }
334
335 return fallback
336}
337
338/** The status line: the oldest unacknowledged run failure, and how many more there are; undefined when none. */
339export function statusOf(snapshot: HerdSnapshot | null, acked: ReadonlySet<string>): string | undefined {
340 const fails = (snapshot?.runs ?? []).filter(r => toneOf(r, acked) === 'fail').sort((a, b) => a.mtimeMs - b.mtimeMs)
341 const [f] = fails
342
343 if (f === undefined) {
344 return undefined
345 }
346
347 const where = f.kind === 'late' ? f.source : `${f.source} ${f.name}${f.mtimeMs > 0 ? ` ${dayOf(f.mtimeMs)}` : ''}`
348
349 return `${where}: ${f.why}${fails.length > 1 ? ` (+${fails.length - 1} more, /herd runs)` : ''}`
350}
351
352/** The acks a snapshot carries, and those this session made since it was read: herd keeps them, so a union is never stale. */
353export function ackedOf(snapshot: HerdSnapshot | null, ackedHere: readonly string[]): string[] {
354 return [...new Set([...(snapshot?.acked ?? []), ...ackedHere])]
355}
356
357// ------------------------------------------------------------------ the snapshot's shape
358
359/** A field's kind, as a snapshot is checked: `?` also allows null. */
360type Kind = 'string' | 'string?' | 'number' | 'number?' | 'boolean' | 'strings' | 'record?'
361
362const isKind = (value: unknown, kind: Kind): boolean =>
363 kind === 'strings'
364 ? Array.isArray(value) && value.every(v => typeof v === 'string')
365 : kind === 'record?'
366 ? value === null || isRecord(value)
367 : kind.endsWith('?')
368 ? value === null || typeof value === kind.slice(0, -1)
369 : typeof value === kind
370
371const hasShape = (value: unknown, shape: Readonly<Record<string, Kind>>): boolean =>
372 isRecord(value) && Object.entries(shape).every(([field, kind]) => isKind(value[field], kind))
373
374const listOf = (value: unknown, shape: Readonly<Record<string, Kind>>): boolean => Array.isArray(value) && value.every(v => hasShape(v, shape))
375
376/** Every field the band and the pane read of a snapshot and its rows (types/index.d.ts). */
377const SNAPSHOT_SHAPE: Readonly<Record<string, Kind>> = {
378 at: 'number',
379 tookMs: 'number',
380 host: 'string',
381 tracksOn: 'boolean',
382 tracksError: 'string?',
383 tracksMissing: 'boolean',
384 sessionsOn: 'boolean',
385 sessionsError: 'string?',
386 sessionsMissing: 'boolean',
387 sourcesFile: 'string?',
388 sourcesError: 'string?',
389 acked: 'strings',
390}
391const REPO_SHAPE: Readonly<Record<string, Kind>> = { name: 'string', primary: 'string' }
392const TRACK_SHAPE: Readonly<Record<string, Kind>> = {
393 repo: 'string',
394 name: 'string',
395 status: 'string',
396 goal: 'string?',
397 sessionId: 'string?',
398 owner: 'string?',
399 isLive: 'boolean',
400 reviewId: 'string?',
401 reviewState: 'string?',
402 checks: 'string?',
403 checkedAt: 'number?',
404 reviewUrl: 'string?',
405 lastActivity: 'number?',
406 warnings: 'strings',
407 conflicts: 'number',
408 isBlocked: 'boolean',
409 waitingFor: 'string?',
410 isStale: 'boolean',
411}
412const SESSION_SHAPE: Readonly<Record<string, Kind>> = {
413 sessionId: 'string',
414 shortId: 'string?',
415 name: 'string?',
416 kind: 'string',
417 state: 'string?',
418 status: 'string?',
419 waitingFor: 'string?',
420 startedAt: 'number?',
421 cwd: 'string?',
422 isLive: 'boolean',
423 isBlocked: 'boolean',
424 track: 'string?',
425}
426const RUN_SHAPE: Readonly<Record<string, Kind>> = {
427 key: 'string',
428 source: 'string',
429 kind: 'string',
430 name: 'string',
431 path: 'string',
432 size: 'number',
433 mtimeMs: 'number',
434 verdict: 'string',
435 tone: 'string',
436 why: 'string',
437 detail: 'string',
438 facts: 'record?',
439}
440const SOURCE_SHAPE: Readonly<Record<string, Kind>> = { label: 'string', kind: 'string', glob: 'string', error: 'string?', count: 'number' }
441
442/**
443 * A snapshot as `herd snapshot --json` prints it (or as another session stored
444 * it), or null when it is not one this build draws: another schema, or any
445 * field or row of another shape. Null is never drawn.
446 */
447export function snapshotOf(value: unknown): HerdSnapshot | null {
448 if (!isRecord(value) || value.schema !== SCHEMA || !hasShape(value, SNAPSHOT_SHAPE)) {
449 return null
450 }
451
452 const rows =
453 listOf(value.repos, REPO_SHAPE) &&
454 listOf(value.tracks, TRACK_SHAPE) &&
455 listOf(value.sessions, SESSION_SHAPE) &&
456 listOf(value.runs, RUN_SHAPE) &&
457 listOf(value.sources, SOURCE_SHAPE)
458
459 return rows ? (value as unknown as HerdSnapshot) : null
460}
461
462/** Why a value is not a snapshot this build draws, in one phrase. */
463export function notSnapshotWhy(value: unknown): string {
464 if (!isRecord(value)) {
465 return 'no snapshot'
466 }
467 if (value.schema !== SCHEMA) {
468 return `schema ${String(value.schema)}, not ${SCHEMA}: herd and the mod are out of step (update both from one checkout)`
469 }
470
471 return 'a snapshot of another shape'
472}
473
474/** A stored read another session shared: what it was read with, and the snapshot. */
475export function sharedOf(value: unknown, key: string): HerdSnapshot | null {
476 return isRecord(value) && value.config === key ? snapshotOf(value.snapshot) : null
477}
478
479// ------------------------------------------------------------------ the pane's lines
480
481/** One track's line in the pane: status, review, owner, the first warning. */
482export function trackLineOf(t: HerdTrack, nowMs: number): Segment {
483 const review =
484 t.reviewId === null
485 ? ''
486 : ` · #${t.reviewId} ${t.reviewState ?? '?'}${t.checks === null ? '' : ` ${t.checks}`}${t.checkedAt === null ? '' : ` (checked ${agoOf(t.checkedAt, nowMs)} ago)`}`
487 const owner = t.isBlocked
488 ? ` · waiting on you${t.waitingFor === null ? '' : `: ${t.waitingFor}`}`
489 : t.owner === null
490 ? ' · unclaimed'
491 : ` · ${t.owner}`
492 const conflicts = t.conflicts > 0 ? ` · ${plural(t.conflicts, 'conflict')}` : ''
493 const warning = t.warnings.find(w => !w.startsWith('owner session ended') && w !== 'unclaimed')
494 const tone: HerdTone =
495 t.isBlocked || t.conflicts > 0 || t.checks === 'failing'
496 ? 'fail'
497 : t.isStale || t.status === 'merged'
498 ? 'warn'
499 : t.status === 'review'
500 ? 'busy'
501 : t.status === 'paused'
502 ? 'dim'
503 : 'ok'
504
505 return { text: `${t.name} · ${t.status}${review}${owner}${conflicts}${warning === undefined ? '' : ` · ${warning}`}`, tone }
506}
507
508/** One session's line in the pane. */
509export function sessionLineOf(s: HerdSession, nowMs: number): Segment {
510 const state = s.isBlocked
511 ? `waiting on you${s.waitingFor === null ? '' : `: ${s.waitingFor}`}`
512 : [s.state, s.status].filter((x): x is string => x !== null).join('/') || 'ended'
513 const age = s.startedAt === null ? '' : ` · ${agoOf(s.startedAt, nowMs)}`
514 const tone: HerdTone =
515 s.isBlocked || s.state === 'failed' ? 'fail' : s.status === 'busy' || s.state === 'working' ? 'busy' : s.isLive ? 'ok' : 'dim'
516
517 return { text: `${s.name ?? s.shortId ?? s.sessionId.slice(0, 8)} · ${s.kind} · ${state}${age}${s.track === null ? '' : ` · ${s.track}`}`, tone }
518}
519
520/** One run's line in the pane; `acked` before the reason, where a cut line still shows it. */
521export function runLineOf(r: HerdRun, acked: ReadonlySet<string>): Segment {
522 const when = r.mtimeMs > 0 ? ` ${stampOf(r.mtimeMs)}` : ''
523 const seen = r.tone === 'fail' && acked.has(r.key) ? ' · acked' : ''
524
525 return { text: `${r.kind === 'late' ? 'schedule' : r.name}${when}${seen} · ${r.why}`, tone: toneOf(r, acked) }
526}
527hooks/read.ts 171 lines1// The herd mod's reading half: one refresh is one `herd snapshot --json`, over
2// a narrow Reader that register.ts binds from `$` (and a test binds from
3// fixtures). herd reads shepherd, the sessions and the run logs and judges
4// them; each part of what it prints carries its own error. The presses run
5// here too: Ack through `herd ack`, Inbox, Accept and the board through shepherd.
6import type { HerdInboxEntry, HerdRepo, HerdSnapshot, ProcessRunInit, ProcessRunResult } from 'claude-code'
7
8import { expandHome, firstLineOf, notSnapshotWhy, snapshotOf } from './herd'
9import type { Config } from './herd'
10
11/** What a refresh and the presses need of the engine: the clock and processes. */
12export type Reader = {
13 now: () => Promise<number>
14 run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
15}
16
17/** `herd snapshot` waits up to 20 s on shepherd itself; the rest answer in well under a second. */
18const SNAPSHOT_TIMEOUT_MS = 30_000
19const RUN_TIMEOUT_MS = 15_000
20
21/** Shepherd's inbox and accept act as the person, never as this session's track. */
22const AS_THE_PERSON = { CLAUDE_CODE_SESSION_ID: '' }
23
24const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
25
26/**
27 * An error as one short line, the reason first: the engine words a failed call
28 * `herd: $.process.run(/a/long/path) failed to start: ENOENT: …`, and in a
29 * docked pane the path would push the reason out of sight.
30 */
31export const errorText = (error: unknown): string =>
32 firstLineOf(messageOf(error).replace(/^[\w@.-]+: \$\.[\w.]+(?:\([^)\n]*\))?:?\s*/, ''), 160)
33
34/**
35 * Runs a command that prints JSON and parses it; throws with the command's own
36 * words on failure. A command that cannot start (ENOENT) says how to get one.
37 */
38async function runJson(r: Reader, argv: readonly string[], init: ProcessRunInit = {}): Promise<unknown> {
39 // The command's name, not its path: `herd snapshot`.
40 const what = [(argv[0] ?? '').replace(/^.*\//, ''), ...argv.slice(1, 2)].join(' ')
41 let out: ProcessRunResult
42
43 try {
44 out = await r.run(argv, { timeoutMs: RUN_TIMEOUT_MS, ...init })
45 } catch (error) {
46 // Rejects only when it cannot start or outlives its timeout; classify the whole message, cut it after.
47 const missing = /\bENOENT\b/.test(messageOf(error)) ? ' (not installed here? install.sh links it, or set its _bin option)' : ''
48
49 throw new Error(`${what}: ${errorText(error)}${missing}`)
50 }
51
52 if (out.exitCode !== 0) {
53 throw new Error(`${what} exited ${out.exitCode}: ${firstLineOf(out.stderr || out.stdout, 140) || 'no output'}`)
54 }
55
56 try {
57 return JSON.parse(out.stdout)
58 } catch {
59 throw new Error(`${what} printed no JSON: ${firstLineOf(out.stdout, 100) || 'nothing'}`)
60 }
61}
62
63/** One refresh: what `herd snapshot` prints, checked. Throws when herd cannot run or prints another shape. */
64export async function readSnapshot(r: Reader, cfg: Config, home: string | null): Promise<HerdSnapshot> {
65 const argv = [expandHome(cfg.herd, home), 'snapshot', '--json', '--stale-hours', String(cfg.staleHours)]
66 const value = await runJson(r, argv, { timeoutMs: SNAPSHOT_TIMEOUT_MS })
67 const snapshot = snapshotOf(value)
68
69 if (snapshot === null) {
70 throw new Error(`herd snapshot printed ${notSnapshotWhy(value)}`)
71 }
72
73 return snapshot
74}
75
76/** `herd ack <key>…`: marks run failures as seen for every session and the board. Resolves what herd printed. */
77export async function ackRuns(r: Reader, cfg: Config, home: string | null, keys: readonly string[]): Promise<string> {
78 const out = await r.run([expandHome(cfg.herd, home), 'ack', ...keys], { timeoutMs: RUN_TIMEOUT_MS })
79
80 if (out.exitCode !== 0) {
81 throw new Error(firstLineOf(out.stderr || out.stdout, 160) || `herd ack exited ${out.exitCode}`)
82 }
83
84 return firstLineOf(out.stdout, 160) || `acked ${keys.length}`
85}
86
87// ------------------------------------------------------------------ shepherd presses (the person's only)
88
89const ENTRY_ID = /^[0-9a-f]{4,32}$/
90
91/** Shepherd's unaccepted entries addressed to the person, in every enrolled repo. */
92export async function readInbox(r: Reader, cfg: Config, home: string | null, repos: readonly HerdRepo[]): Promise<{ entries: HerdInboxEntry[]; errors: string[] }> {
93 const entries: HerdInboxEntry[] = []
94 const errors: string[] = []
95
96 for (const repo of repos) {
97 if (repo.primary === '') {
98 continue
99 }
100
101 try {
102 const items = await runJson(r, [expandHome(cfg.shepherd, home), 'inbox', '--json'], { cwd: repo.primary, env: AS_THE_PERSON })
103
104 for (const item of Array.isArray(items) ? items : []) {
105 if (typeof item !== 'object' || item === null) {
106 continue
107 }
108
109 const e = item as Record<string, unknown>
110 const id = typeof e.id === 'string' ? e.id : ''
111
112 if (!ENTRY_ID.test(id)) {
113 continue
114 }
115 entries.push({
116 repo: repo.name,
117 primary: repo.primary,
118 id,
119 at: typeof e.at === 'number' ? e.at * 1000 : null,
120 type: typeof e.type === 'string' ? e.type : '?',
121 from: typeof e.from === 'string' ? e.from : '?',
122 text: typeof e.text === 'string' ? e.text : '',
123 })
124 }
125 } catch (error) {
126 errors.push(`${repo.name}: ${errorText(error)}`)
127 }
128 }
129
130 return { entries, errors }
131}
132
133/** `shepherd accept <id>` in the entry's repo, as the person. Resolves what shepherd printed. */
134export async function acceptEntry(r: Reader, cfg: Config, home: string | null, entry: HerdInboxEntry): Promise<string> {
135 if (!ENTRY_ID.test(entry.id)) {
136 throw new Error(`not a shepherd entry id: ${entry.id}`)
137 }
138
139 const out = await r.run([expandHome(cfg.shepherd, home), 'accept', entry.id], {
140 cwd: entry.primary,
141 env: AS_THE_PERSON,
142 timeoutMs: RUN_TIMEOUT_MS,
143 })
144
145 if (out.exitCode !== 0) {
146 throw new Error(firstLineOf(out.stderr || out.stdout, 160) || `shepherd accept exited ${out.exitCode}`)
147 }
148
149 return firstLineOf(out.stdout, 160) || `accepted ${entry.id}`
150}
151
152/**
153 * `shepherd serve --daemon`: starts the dashboard when it is not running, and
154 * resolves what it printed: the URL line, and the port-forward hint a remote
155 * machine with no browser needs.
156 */
157export async function boardUrl(r: Reader, cfg: Config, home: string | null): Promise<string> {
158 const out = await r.run([expandHome(cfg.shepherd, home), 'serve', '--daemon'], { timeoutMs: 20_000 })
159
160 if (out.exitCode !== 0) {
161 throw new Error(firstLineOf(out.stderr || out.stdout, 160) || `shepherd serve exited ${out.exitCode}`)
162 }
163
164 const lines = out.stdout
165 .split('\n')
166 .map(l => l.trim())
167 .filter(l => l !== '')
168
169 return lines.slice(0, 3).join('\n') || 'shepherd serve printed nothing'
170}
171hooks/view.ts 280 lines1// The herd pane's tree: three tabs (Tracks, Sessions, Runs) over one snapshot,
2// and the buttons the person presses. Plain functions over an element table and
3// callbacks; register.ts hands both in, so nothing here touches `$`.
4import type {
5 BoxProps,
6 ButtonProps,
7 ElementConstructor,
8 HerdFaults,
9 HerdInboxEntry,
10 HerdRun,
11 HerdSession,
12 HerdSnapshot,
13 HerdTab,
14 HerdTone,
15 RenderElement,
16 TextProps,
17} from 'claude-code'
18
19import { agoOf, firstLineOf, plural, runLineOf, sessionLineOf, sessionRank, toneOf, trackLineOf } from './herd'
20import type { Segment } from './herd'
21
22export type Els = {
23 Box: ElementConstructor<BoxProps>
24 Text: ElementConstructor<TextProps>
25 Button: ElementConstructor<ButtonProps>
26}
27
28export type PaneModel = {
29 snapshot: HerdSnapshot | null
30 acked: ReadonlySet<string>
31 tab: HerdTab
32 inbox: HerdInboxEntry[] | null
33 notice: string | null
34 /** The mod's own failures: refresh, share, /herd's registration. */
35 faults: HerdFaults
36 nowMs: number
37 /** This session: no Ping for it. */
38 selfId: string | null
39}
40
41/** What the buttons do; every one is the person's press. */
42export type PaneActions = {
43 tab: (tab: HerdTab) => void
44 refresh: () => void
45 board: () => void
46 inbox: () => void
47 accept: (entry: HerdInboxEntry) => void
48 ping: (session: HerdSession) => void
49 ack: (run: HerdRun) => void
50 ackAll: () => void
51 close: () => void
52}
53
54const TABS: readonly { tab: HerdTab; label: string; hotkey: string }[] = [
55 { tab: 'tracks', label: 'Tracks', hotkey: '1' },
56 { tab: 'sessions', label: 'Sessions', hotkey: '2' },
57 { tab: 'runs', label: 'Runs', hotkey: '3' },
58]
59
60/** At most this many sessions in the tab: the ones that need the person sort first. */
61const MAX_SESSIONS = 40
62
63/** A tone as Text props: the theme's own colors, so it follows the person's theme. */
64export function styleOf(tone: HerdTone): TextProps {
65 switch (tone) {
66 case 'ok':
67 return { color: 'success' }
68 case 'warn':
69 return { color: 'warning' }
70 case 'fail':
71 return { color: 'error' }
72 case 'busy':
73 return { color: 'suggestion' }
74 default:
75 return { dimColor: true }
76 }
77}
78
79const line = (els: Els, s: Segment, indent = 0): RenderElement =>
80 els.Text({ ...styleOf(s.tone), wrap: 'truncate-end', children: [`${' '.repeat(indent)}${s.text}`] })
81
82const dim = (els: Els, text: string, indent = 0): RenderElement =>
83 els.Text({ dimColor: true, wrap: 'truncate-end', children: [`${' '.repeat(indent)}${text}`] })
84
85const row = (els: Els, children: RenderElement[]): RenderElement => els.Box({ flexDirection: 'row', columnGap: 2, children })
86
87const button = (els: Els, key: string, label: string, onPress: () => void, hotkey?: string): RenderElement =>
88 els.Button({ key, label, plain: true, ...(hotkey !== undefined && { hotkey }), onPress: () => onPress() })
89
90function tracksTab(els: Els, m: PaneModel, a: PaneActions): RenderElement[] {
91 const s = m.snapshot
92 const out: RenderElement[] = []
93
94 if (s === null) {
95 return [dim(els, 'reading…')]
96 }
97 if (!s.tracksOn) {
98 return [dim(els, 'Tracks are off: herd snapshot ran with --no-tracks.')]
99 }
100 if (s.tracksMissing) {
101 out.push(dim(els, 'herd found no shepherd to run here: no tracks to show.'))
102 } else if (s.tracksError !== null) {
103 out.push(line(els, { text: s.tracksError, tone: 'warn' }))
104 }
105 if (s.tracks.length === 0 && s.tracksError === null && !s.tracksMissing) {
106 out.push(dim(els, 'No shepherd tracks in any enrolled repo.'))
107 }
108
109 for (const repo of s.repos) {
110 const tracks = s.tracks.filter(t => t.repo === repo.name)
111
112 if (tracks.length === 0) {
113 continue
114 }
115 out.push(els.Text({ bold: true, wrap: 'truncate-end', children: [repo.name] }))
116 for (const t of tracks) {
117 out.push(line(els, trackLineOf(t, m.nowMs), 2))
118 }
119 }
120
121 out.push(els.Text({ children: [' '] }))
122 out.push(row(els, [button(els, 'inbox', m.inbox === null ? 'Inbox' : 'Inbox again', a.inbox, 'i'), button(els, 'board', 'Open board', a.board, 'b')]))
123
124 if (m.inbox !== null) {
125 if (m.inbox.length === 0) {
126 out.push(dim(els, 'Inbox: nothing addressed to you.'))
127 }
128 for (const entry of m.inbox) {
129 const when = entry.at === null ? '' : ` ${agoOf(entry.at, m.nowMs)} ago`
130
131 out.push(
132 row(els, [
133 els.Text({ wrap: 'truncate-end', children: [`${entry.repo} ${entry.id}${when} · ${entry.type} from ${entry.from}: ${firstLineOf(entry.text, 120)}`] }),
134 button(els, `accept-${entry.repo}-${entry.id}`, 'Accept', () => a.accept(entry)),
135 ]),
136 )
137 }
138 }
139
140 return out
141}
142
143function sessionsTab(els: Els, m: PaneModel, a: PaneActions): RenderElement[] {
144 const s = m.snapshot
145
146 if (s === null) {
147 return [dim(els, 'reading…')]
148 }
149 if (!s.sessionsOn) {
150 return [dim(els, 'Sessions are off: herd snapshot ran with --no-sessions.')]
151 }
152
153 const out: RenderElement[] = []
154
155 if (s.sessionsError !== null) {
156 out.push(line(els, { text: s.sessionsError, tone: s.sessionsMissing ? 'dim' : 'warn' }))
157 }
158
159 const sessions = [...s.sessions].sort((x, y) => sessionRank(x) - sessionRank(y) || (y.startedAt ?? 0) - (x.startedAt ?? 0))
160 const waiting = sessions.filter(x => x.isBlocked).length
161 const background = sessions.filter(x => x.kind === 'background')
162
163 out.push(
164 dim(
165 els,
166 `${plural(sessions.length, 'session')} · ${waiting} waiting on you · ${background.length} background (${background.filter(x => x.state === 'working').length} working)`,
167 ),
168 )
169
170 for (const session of sessions.slice(0, MAX_SESSIONS)) {
171 const text = line(els, sessionLineOf(session, m.nowMs))
172 const isSelf = session.sessionId === m.selfId
173
174 out.push(
175 session.isLive && !isSelf
176 ? row(els, [text, button(els, `ping-${session.sessionId}`, 'Ping', () => a.ping(session))])
177 : isSelf
178 ? row(els, [text, dim(els, '(this session)')])
179 : text,
180 )
181 }
182 if (sessions.length > MAX_SESSIONS) {
183 out.push(dim(els, `… ${sessions.length - MAX_SESSIONS} more: herd ls`))
184 }
185
186 return out
187}
188
189function runsTab(els: Els, m: PaneModel, a: PaneActions): RenderElement[] {
190 const s = m.snapshot
191
192 if (s === null) {
193 return [dim(els, 'reading…')]
194 }
195
196 const out: RenderElement[] = []
197
198 if (s.sourcesError !== null) {
199 out.push(line(els, { text: s.sourcesError, tone: 'warn' }))
200 }
201 if (s.sources.length === 0) {
202 out.push(dim(els, 'No run sources: herd reads ~/.config/herd/sources.json or $HERD_SOURCES (harnesses/herd/presets/README.md).'))
203 }
204
205 for (const label of [...new Set(s.sources.map(x => x.label))]) {
206 const sources = s.sources.filter(x => x.label === label)
207
208 out.push(els.Text({ bold: true, wrap: 'truncate-end', children: [label] }))
209
210 for (const source of sources) {
211 if (source.error !== null) {
212 out.push(line(els, { text: `${source.glob}: ${source.error}`, tone: 'warn' }, 2))
213 } else if (source.count === 0) {
214 out.push(dim(els, `${source.glob}: no logs`, 2))
215 }
216 }
217
218 for (const run of s.runs.filter(r => r.source === label)) {
219 const isUnacked = toneOf(run, m.acked) === 'fail'
220 const text = line(els, runLineOf(run, m.acked), 2)
221
222 out.push(isUnacked ? row(els, [text, button(els, `ack-${run.key}`, 'Ack', () => a.ack(run))]) : text)
223 if (run.detail !== '') {
224 out.push(dim(els, run.detail, 4))
225 }
226 }
227 }
228
229 if (s.runs.some(r => toneOf(r, m.acked) === 'fail')) {
230 out.push(els.Text({ children: [' '] }))
231 out.push(button(els, 'ack-all', 'Ack all', a.ackAll, 'a'))
232 }
233
234 return out
235}
236
237/** The whole pane: the tab row, the open tab, then when it was read and the common buttons. */
238export function paneOf(els: Els, m: PaneModel, a: PaneActions): RenderElement {
239 const body = m.tab === 'tracks' ? tracksTab(els, m, a) : m.tab === 'sessions' ? sessionsTab(els, m, a) : runsTab(els, m, a)
240 const read =
241 m.snapshot === null
242 ? 'not read yet'
243 : `read ${agoOf(m.snapshot.at, m.nowMs)} ago in ${(m.snapshot.tookMs / 1000).toFixed(1)}s`
244
245 return els.Box({
246 flexDirection: 'column',
247 children: [
248 els.Box({
249 flexDirection: 'row',
250 columnGap: 3,
251 children: TABS.map(t =>
252 els.Button({
253 key: `tab-${t.tab}`,
254 label: t.label,
255 hotkey: t.hotkey,
256 plain: true,
257 dimColor: m.tab !== t.tab,
258 onPress: () => a.tab(t.tab),
259 }),
260 ),
261 }),
262 els.Text({ children: [' '] }),
263 ...body,
264 els.Text({ children: [' '] }),
265 row(els, [dim(els, read), button(els, 'refresh', 'Refresh', a.refresh, 'r'), button(els, 'close', 'Close', a.close, 'c')]),
266 ...faultLines(m.faults).map(text => line(els, { text, tone: 'warn' })),
267 ...(m.notice === null ? [] : m.notice.split('\n').map(text => dim(els, text))),
268 ],
269 })
270}
271
272/** The mod's own failures, one line each: nothing else shows them outside `--debug`. */
273function faultLines(f: HerdFaults): string[] {
274 return [
275 f.refresh === null ? null : `refresh failed: ${f.refresh}`,
276 f.share === null ? null : `not shared with other sessions: ${f.share}`,
277 f.command === null ? null : `/herd not registered (the band still opens this pane): ${f.command}`,
278 ].filter((x): x is string => x !== null)
279}
280types/index.d.ts 173 lines1// The herd mod's $.state contract, and the snapshot it draws: what
2// `herd snapshot --json` prints (schema 3, harnesses/herd/herd.py). Every value
3// the module reads or writes with atom/read/update is declared under 'herd';
4// the snapshot's shapes are declared beside it so the hooks import them from
5// 'claude-code'. No imports and no `export {}`: `claude plugin validate`
6// refuses a contract that exports.
7declare module 'claude-code' {
8 /** How a line or a run reads: green, yellow, red, in progress, or nothing to say. */
9 type HerdTone = 'ok' | 'warn' | 'fail' | 'busy' | 'dim'
10
11 /** The pane's tabs. */
12 type HerdTab = 'tracks' | 'sessions' | 'runs'
13
14 /** One enrolled repo, as shepherd lists it. */
15 interface HerdRepo {
16 name: string
17 /** The main checkout: where `shepherd inbox` and `accept` run. */
18 primary: string
19 }
20
21 /** One shepherd track, joined by herd to its owner session. */
22 interface HerdTrack {
23 repo: string
24 name: string
25 /** active, review, paused or merged. */
26 status: string
27 goal: string | null
28 sessionId: string | null
29 /** Shepherd's word for the owner session: busy, idle, shell or ended. */
30 owner: string | null
31 isLive: boolean
32 reviewId: string | null
33 reviewState: string | null
34 /** passing, failing, pending, none or unknown. */
35 checks: string | null
36 /** When shepherd last asked the review backend, in ms; only `shepherd serve` refreshes it. */
37 checkedAt: number | null
38 reviewUrl: string | null
39 lastActivity: number | null
40 warnings: string[]
41 /** Pairs with another track whose merge would conflict (the band counts the tracks with any). */
42 conflicts: number
43 /** Its session waits on the person. */
44 isBlocked: boolean
45 waitingFor: string | null
46 /** Active, and nobody works on it: the owner ended, or no activity for stale_hours. */
47 isStale: boolean
48 }
49
50 /** One live session, as `herd ls` lists it. */
51 interface HerdSession {
52 sessionId: string
53 shortId: string | null
54 name: string | null
55 /** interactive or background. */
56 kind: string
57 /** herd's word: blocked, done or working. */
58 state: string | null
59 /** The registry's: busy, waiting, idle or shell. */
60 status: string | null
61 waitingFor: string | null
62 startedAt: number | null
63 cwd: string | null
64 /** Its process is alive, so a message reaches it now. */
65 isLive: boolean
66 isBlocked: boolean
67 /** The shepherd track it owns, when it owns one. */
68 track: string | null
69 }
70
71 /** The counts behind a stream run's verdict; null for text logs and schedules. */
72 interface HerdRunFacts {
73 results: number
74 turns: number
75 cost: number | null
76 denied: number
77 subagents: { spawned: number; completed: number; failed: number; killed: number; stopped: number; pending: number }
78 }
79
80 /** One headless run log (a stream-json log, or a text log's last matching line), judged by herd. */
81 interface HerdRun {
82 /** path@mtimeMs: what `herd ack` keeps; a rewritten log alerts again. */
83 key: string
84 source: string
85 kind: 'stream' | 'line' | 'late'
86 name: string
87 path: string
88 size: number
89 mtimeMs: number
90 /** ok, denied, asked, running, quiet, unfinished, failed, no result, no line, unreadable, late. */
91 verdict: string
92 tone: HerdTone
93 /** One phrase for the band and the status line. */
94 why: string
95 /** The numbers the Runs tab shows under it. */
96 detail: string
97 facts: HerdRunFacts | null
98 }
99
100 /** One run source of herd's sources file and what reading it found. */
101 interface HerdSource {
102 label: string
103 kind: 'stream' | 'line'
104 glob: string
105 error: string | null
106 count: number
107 }
108
109 /** What one `herd snapshot --json` printed; shared in $.store by every session that loads the mod. */
110 interface HerdSnapshot {
111 /** The shape's version (SCHEMA in herd.ts, SNAPSHOT_SCHEMA in herd.py): another one is not drawn. */
112 schema: number
113 at: number
114 /** How long herd took, in ms. */
115 tookMs: number
116 host: string
117 tracksOn: boolean
118 repos: HerdRepo[]
119 tracks: HerdTrack[]
120 tracksError: string | null
121 /** herd found no shepherd to run: the band leaves tracks out. */
122 tracksMissing: boolean
123 sessionsOn: boolean
124 sessions: HerdSession[]
125 sessionsError: string | null
126 sessionsMissing: boolean
127 runs: HerdRun[]
128 sources: HerdSource[]
129 /** The sources file herd read, or null when none is set up. */
130 sourcesFile: string | null
131 sourcesError: string | null
132 /** Keys of the runs above that someone acked with `herd ack`. */
133 acked: string[]
134 }
135
136 /** One unaccepted shepherd log entry addressed to the person. */
137 interface HerdInboxEntry {
138 repo: string
139 primary: string
140 id: string
141 at: number | null
142 type: string
143 from: string
144 text: string
145 }
146
147 /** The mod's own failures, shown in the pane's footer: none of them stops the rest. */
148 interface HerdFaults {
149 /** The last refresh failed (the band shows `herd ?` until one lands). */
150 refresh: string | null
151 /** The last read could not be stored for the other sessions. */
152 share: string | null
153 /** `/herd` could not be registered: a skill or command owns the name. */
154 command: string | null
155 }
156
157 interface PluginState {
158 herd: {
159 /** The last snapshot, this session's or another's; null before the first. */
160 snapshot: HerdSnapshot | null
161 /** Ack keys of the run failures the person has seen. */
162 acked: string[]
163 /** The pane's open tab. */
164 tab: HerdTab
165 /** What Inbox found, null before it is pressed. */
166 inbox: HerdInboxEntry[] | null
167 /** What the last button press said: the board URL, an accept, a ping. */
168 notice: string | null
169 faults: HerdFaults
170 }
171 }
172}
173