SLOPSHOPPER

herd

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…

newpanebandcommandtoaststatus
v0.2.0no licenseupdated 2026-10-13SearchingAlpha/claude-dotfiles/mods/herd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · herd
│ ┃ Herd ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Tracks 2: Sessions 3: Runs │ ┃ ⏺ Read(src/auth.ts) │ ┃ reading… ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ not read yet r: Refresh c: Close ⎿ 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 │ │ › /herd │ │ h: herd ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
h: herd ⟨Claude Code's own drawing⟩
Pane · Herd
1: Tracks 2: Sessions 3: Runs reading… not read yet r: Refresh c: Close
README

herd

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.

What it runs

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:

  • When herd finds no shepherd, the band leaves tracks out. When shepherd fails, the band says tracks ?.
  • A sources file that doesn't parse shows as runs ?, and an unreadable log as its own unreadable row.
  • When herd itself can't run, the band says 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 snapshot of another schema is not drawn. herd and the mod come from one checkout, so that means one of them is stale.

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.

The band and the pane

  • The band is one button (hotkey 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.
  • A skill or command of yours may already be called 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.
  • The band still opens the pane, and the pane's footer says why /herd is missing.
  • Tracks groups tracks by repo. The band's counts are tracks: 2 conflicting is two tracks with a conflicting pair.
  • Inbox runs 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.
  • Open board runs shepherd serve --daemon and prints the dashboard URL, with shepherd's port-forward hint for a remote machine.
  • Sessions sorts sessions by what needs you.
  • Ping sends a fixed status request with $.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.
  • The request lands as a message in that session and starts or joins a turn there, on your usage. It asks for a one-line answer in that session's own transcript, not a message back.
  • Runs groups runs by source label, each with herd's numbers under it.
  • Ack (or Ack all) runs 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.
  • Every other session and shepherd's board see the ack at their next read. The band and the pane show an acked failure as a warning (⚠, and acked before its reason) until a newer log replaces it.
  • An Ack pressed while a refresh is reading stays acked when that refresh lands. A rewritten log alerts again, because the ack key is the log's path and mtime.
  • The status line under the prompt ($.ui.status) holds the oldest unacked run failure, with a count of the rest.
  • A message from another session (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.
  • Not toasted: a hand-back from one of this session's own subagents (an <agent-message> envelope), a teammate's mailbox message, and a message for one of this session's agents.
  • The mod never acts on a message, its sender or its text.

Every button is the person's press. Nothing runs on a timer except the read above.

When the mod itself fails

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:

  • The band starts with herd ? while refreshes fail, and with as of 5m ago once what it shows is more than three refresh periods old.
  • The pane's footer names each failure:
  • the refresh that failed;
  • a read that could not be shared with the other sessions (it still shows here);
  • /herd not registered.

Install and configure

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

Files

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

Check and try it

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:

  • The status line setting (statusLine), which a mod cannot draw. The band sits beside it.
  • The full per-card board: Open board goes to shepherd's dashboard for that.
  • Shepherd's own log lines. The board has them.

Tested with Claude Code 2.1.296.

Source 5 files
hooks/register.ts 518 lines
1// 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}
518
hooks/herd.ts 527 lines
1// 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}
527
hooks/read.ts 171 lines
1// 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}
171
hooks/view.ts 280 lines
1// 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}
280
types/index.d.ts 173 lines
1// 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