Live Beadhive status in a Claude Code pane: in-flight beads, per-agent liveness, molecule progress

A Claude Code plugin marketplace of mods that change Claude Code's own interface. One repo, one marketplace file, one folder per mod under plugins/.
| Mod | What it does |
|---|---|
hivewatch | Live Beadhive status in a pane: in-flight beads, per-agent liveness, molecule progress. |
/plugin marketplace add SimplicityGuy/claude-mods
/plugin install hivewatch@claude-mods
Run /hivewatch to open the pane (/hivewatch refresh to poll now, /hivewatch off to stop polling). The status line also shows hive: 3 live · 1 stale.
hivewatch · 12 in flight (8 live · 4 stale) · updated 3s ago
dispatched ████████████░░░░░░░░░░░░ 17/21 steps · 2 molecules
phaze-oprdu ████████████░░░░ 12/19 (2 running) Remote tracklist providers
agents
● dev/mixesdb-provider phaze-ttw7n 4h dirty MixesDB provider: REST search…
○ dev/rank phaze-e5yyq 8d none Measure similar-set ranking…
● means the agent's lease on the bead is still valid, ○ means the bead is in_progress but the lease has expired (the agent stopped heartbeating), · means no lease was ever granted.bh work readiness --json for each in-progress epic (done steps over total steps).dirty, none) comes from bh worktree status --json.| Setting | Default | |
|---|---|---|
hives | empty | Comma-separated hive ids, for example github/SimplicityGuy/phaze. Empty watches the hive of the session's working directory. |
refreshSeconds | 20 | Poll interval; each poll runs a few bh commands per hive, so keep it at 10 or more. |
bd records an assignee label such as dev/rank, not the Claude Code session or agent name, so the pane cannot yet join a bead to the live agent session.Install just and the Claude Code CLI (CI pins 2.1.296), then:
just check # validate + test every plugin under plugins/ (what CI runs)
just validate # claude plugin validate
just test # claude plugin test
just typecheck # strict tsc, see below
claude --plugin-dir plugins/hivewatch # try it in a session
CI (.github/workflows/ci.yml) runs just check on every pull request and on pushes to main. A new directory under plugins/ is picked up automatically.
just typecheck needs the engine's type declarations, which Claude Code writes into <plugin>/.claude-plugin/types/ (and a tsconfig.json beside the plugin) when a session loads the mod. They are generated per machine and not committed, and no headless command writes them, so the type-check runs locally after loading the mod once and is not yet part of CI.
MIT licensed.
hooks/register.tsx 216 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import {
6 agentRows,
7 bar,
8 fmtAge,
9 moleculeRow,
10 parseBeads,
11 parseSteps,
12 parseWorktrees,
13 summary,
14 trunc,
15} from './model'
16import type { RawBead, RawWorktree } from './model'
17import type { MoleculeRow } from '../types'
18
19const PANE = 'hivewatch'
20const MAX_MOLECULES = 6
21
22const snapshot = atom({ plugin: 'hivewatch', key: 'snapshot' } as const, null)
23
24type BhResult = { stdout: string; error: string | null }
25
26const bh = async ($: EngineInterface, args: string[]): Promise<BhResult> => {
27 try {
28 const ran = await $.process.run(['bh', ...args], { timeoutMs: 60_000 })
29
30 return ran.exitCode === 0
31 ? { stdout: ran.stdout, error: null }
32 : { stdout: '', error: `bh ${args.slice(0, 2).join(' ')} exited ${ran.exitCode}` }
33 } catch (err) {
34 return { stdout: '', error: `bh ${args.slice(0, 2).join(' ')}: ${String(err)}` }
35 }
36}
37
38const collect = async ($: EngineInterface, hives: string[]): Promise<Snapshot> => {
39 const now = await $.clock.now()
40 const beads: RawBead[] = []
41 const worktrees: RawWorktree[] = []
42 const molecules: MoleculeRow[] = []
43 const errors: string[] = []
44
45 for (const hive of hives.length > 0 ? hives : [null]) {
46 const scope = hive === null ? [] : ['--hive', hive]
47 const list = await bh($, ['work', 'list', ...scope, '--status', 'in_progress', '--json'])
48
49 if (list.error !== null) {
50 errors.push(list.error)
51 continue
52 }
53
54 const inflight = parseBeads(list.stdout)
55 beads.push(...inflight)
56
57 const wt = await bh($, ['worktree', 'status', ...scope, '--json'])
58
59 if (wt.error === null) {
60 worktrees.push(...parseWorktrees(wt.stdout))
61 }
62
63 for (const epic of inflight.filter(b => b.issue_type === 'epic').slice(0, MAX_MOLECULES)) {
64 const steps = await bh($, ['work', 'readiness', epic.id, ...scope, '--json'])
65
66 if (steps.error === null) {
67 molecules.push(moleculeRow(epic, parseSteps(steps.stdout)))
68 } else {
69 errors.push(steps.error)
70 }
71 }
72 }
73
74 return { at: now, agents: agentRows(beads, worktrees, now), molecules, error: errors.join('; ') || null }
75}
76
77const HEALTH_MARK = { live: '●', stale: '○', unleased: '·' } as const
78const HEALTH_COLOR = { live: 'green', stale: 'red', unleased: 'yellow' } as const
79
80type Config = { hives: string[]; refreshMs: number }
81
82// Timers die with the module on a reload, so these may live outside $.state.
83let timer: { cancel: () => void } | null = null
84let isBusy = false
85
86const refresh = async ($: EngineInterface, config: Config): Promise<void> => {
87 if (isBusy) {
88 return
89 }
90
91 isBusy = true
92
93 try {
94 const snap = await collect($, config.hives)
95 await update($, snapshot, () => snap)
96 $.ui.status(
97 snap.error !== null && snap.agents.length === 0
98 ? 'hivewatch: bh unavailable'
99 : `hive: ${summary(snap.agents)}`,
100 )
101 } finally {
102 isBusy = false
103 }
104}
105
106const start = ($: EngineInterface, config: Config): void => {
107 if (timer === null) {
108 timer = $.clock.every(config.refreshMs, () => void refresh($, config))
109 }
110
111 void refresh($, config)
112}
113
114const stop = ($: EngineInterface): void => {
115 timer?.cancel()
116 timer = null
117 $.ui.status(undefined)
118}
119
120export const register: Register = (on, options) => {
121 const opts = options as Record<string, unknown>
122 const config: Config = {
123 hives: String(opts.hives ?? '')
124 .split(',')
125 .map(h => h.trim())
126 .filter(h => h !== ''),
127 refreshMs: Math.max(10, Number(opts.refreshSeconds ?? 20)) * 1000,
128 }
129
130 on('session.start', async ($, e, next) => {
131 await $.command.register({
132 name: 'hivewatch',
133 description: 'Live Beadhive status: in-flight beads, agent liveness, molecule progress (off | refresh)',
134 })
135
136 return next(e)
137 })
138
139 on('command.run', { command: 'hivewatch' }, async ($, e) => {
140 const arg = e.args.trim()
141
142 if (arg === 'off') {
143 stop($)
144
145 return { text: 'hivewatch polling stopped.' }
146 }
147
148 start($, config)
149 await $.ui.open({ id: PANE, title: 'Hive' })
150
151 return { text: arg === 'refresh' ? 'hivewatch refreshed.' : 'hivewatch pane opened.' }
152 })
153
154 on('session.end', async ($, e, next) => {
155 stop($)
156
157 return next(e)
158 })
159
160 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
161 const { Box, Text } = $.ui.resolve(e)
162 const snap = await read($, snapshot)
163 const columns = e.props.bodyColumns ?? e.viewport?.columns ?? 80
164
165 if (snap === null) {
166 return <Text dimColor>Polling bh…</Text>
167 }
168
169 const now = await $.clock.now()
170 const barWidth = Math.max(8, Math.min(24, Math.floor(columns / 4)))
171 const done = snap.molecules.reduce((n, m) => n + m.done, 0)
172 const total = snap.molecules.reduce((n, m) => n + m.total, 0)
173 const titleRoom = Math.max(10, columns - 24 - 14 - 6 - 7 - 4)
174
175 return (
176 <Box flexDirection="column">
177 <Text bold>
178 hivewatch · {snap.agents.length} in flight ({summary(snap.agents)}) ·{' '}
179 <Text dimColor>updated {fmtAge(Math.round((now - snap.at) / 1000))} ago</Text>
180 </Text>
181
182 {snap.error !== null && <Text color="red">{trunc(snap.error, columns)}</Text>}
183
184 <Text>
185 dispatched {bar(done, total, barWidth)} {done}/{total} steps ·{' '}
186 {snap.molecules.length} {snap.molecules.length === 1 ? 'molecule' : 'molecules'}
187 </Text>
188
189 {snap.molecules.map(m => (
190 <Text>
191 {' '}
192 {trunc(m.id, 14).padEnd(14)} {bar(m.done, m.total, barWidth)} {m.done}/{m.total}
193 {m.running > 0 ? ` (${m.running} running)` : ''}{' '}
194 <Text dimColor>{trunc(m.title, Math.max(8, columns - barWidth - 40))}</Text>
195 </Text>
196 ))}
197
198 <Text> </Text>
199 <Text bold>agents</Text>
200 {snap.agents.length === 0 && <Text dimColor> nothing in flight.</Text>}
201
202 {snap.agents.map(a => (
203 <Text>
204 <Text color={HEALTH_COLOR[a.health]}>{HEALTH_MARK[a.health]}</Text>{' '}
205 {trunc(a.agent, 24).padEnd(24)} {trunc(a.bead, 14).padEnd(14)}{' '}
206 {fmtAge(a.heartbeatAgeSec).padStart(4)} {a.worktree.padEnd(6)}{' '}
207 <Text dimColor>{trunc(a.title, titleRoom)}</Text>
208 </Text>
209 ))}
210
211 <Text dimColor>● live lease ○ stale (lease expired) · no lease</Text>
212 </Box>
213 )
214 })
215}
216hooks/model.ts 156 lines1import type { AgentRow, Health, MoleculeRow, WorktreeState } from '../types'
2
3export type RawBead = {
4 id: string
5 title: string
6 status: string
7 assignee: string | null
8 issue_type: string
9 heartbeat_at?: string | null
10 lease_expires_at?: string | null
11}
12
13export type RawStep = { id: string; status: string; readiness: string }
14
15export type RawWorktree = { bead_id: string | null; classification: string; dirty: boolean }
16
17// bh can print log lines before its JSON, so parse from the first line that
18// opens a value.
19export const parseJson = (stdout: string): unknown => {
20 const lines = stdout.split('\n')
21 const start = lines.findIndex(line => /^[[{]/.test(line))
22
23 if (start < 0) {
24 return null
25 }
26
27 try {
28 return JSON.parse(lines.slice(start).join('\n'))
29 } catch {
30 return null
31 }
32}
33
34const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
35
36export const parseBeads = (stdout: string): RawBead[] => {
37 const v = parseJson(stdout)
38
39 return Array.isArray(v)
40 ? v.filter((b): b is RawBead => isRecord(b) && typeof b.id === 'string')
41 : []
42}
43
44export const parseSteps = (stdout: string): RawStep[] => {
45 const v = parseJson(stdout)
46 const steps = isRecord(v) ? v.steps : null
47
48 return Array.isArray(steps)
49 ? steps.filter((s): s is RawStep => isRecord(s) && typeof s.id === 'string')
50 : []
51}
52
53export const parseWorktrees = (stdout: string): RawWorktree[] => {
54 const v = parseJson(stdout)
55
56 return Array.isArray(v) ? v.filter((w): w is RawWorktree => isRecord(w)) : []
57}
58
59// A lease that has not run out means the agent is holding the bead; an
60// expired one means it stopped heartbeating (the bead is still in_progress).
61export const healthOf = (bead: RawBead, nowMs: number): Health => {
62 const lease = Date.parse(bead.lease_expires_at ?? '')
63
64 if (Number.isNaN(lease)) {
65 return 'unleased'
66 }
67
68 return lease > nowMs ? 'live' : 'stale'
69}
70
71export const ageSec = (iso: string | null | undefined, nowMs: number): number | null => {
72 const t = Date.parse(iso ?? '')
73
74 return Number.isNaN(t) ? null : Math.max(0, Math.round((nowMs - t) / 1000))
75}
76
77export const worktreeOf = (beadId: string, worktrees: RawWorktree[]): WorktreeState => {
78 const wt = worktrees.find(w => w.bead_id === beadId)
79
80 if (!wt) {
81 return 'none'
82 }
83
84 return wt.dirty ? 'dirty' : 'active'
85}
86
87const HEALTH_ORDER: Record<Health, number> = { live: 0, unleased: 1, stale: 2 }
88
89export const agentRows = (
90 beads: RawBead[],
91 worktrees: RawWorktree[],
92 nowMs: number,
93): AgentRow[] =>
94 beads
95 .map(
96 (b): AgentRow => ({
97 agent: b.assignee ?? '(unassigned)',
98 bead: b.id,
99 title: b.title,
100 kind: b.issue_type,
101 health: healthOf(b, nowMs),
102 heartbeatAgeSec: ageSec(b.heartbeat_at, nowMs),
103 worktree: worktreeOf(b.id, worktrees),
104 }),
105 )
106 .sort(
107 (a, b) =>
108 HEALTH_ORDER[a.health] - HEALTH_ORDER[b.health] ||
109 (a.heartbeatAgeSec ?? Infinity) - (b.heartbeatAgeSec ?? Infinity),
110 )
111
112export const moleculeRow = (epic: RawBead, steps: RawStep[]): MoleculeRow => ({
113 id: epic.id,
114 title: epic.title,
115 total: steps.length,
116 done: steps.filter(s => s.readiness === 'done').length,
117 running: steps.filter(s => s.status === 'in_progress').length,
118})
119
120export const bar = (done: number, total: number, width: number): string => {
121 const w = Math.max(1, width)
122 const filled = total > 0 ? Math.min(w, Math.round((done / total) * w)) : 0
123
124 return '█'.repeat(filled) + '░'.repeat(w - filled)
125}
126
127export const fmtAge = (sec: number | null): string => {
128 if (sec === null) {
129 return '-'
130 }
131
132 if (sec < 90) {
133 return `${sec}s`
134 }
135
136 if (sec < 90 * 60) {
137 return `${Math.round(sec / 60)}m`
138 }
139
140 if (sec < 48 * 3600) {
141 return `${Math.round(sec / 3600)}h`
142 }
143
144 return `${Math.round(sec / 86400)}d`
145}
146
147export const trunc = (s: string, n: number): string =>
148 s.length <= n ? s : `${s.slice(0, Math.max(0, n - 1))}…`
149
150export const summary = (agents: AgentRow[]): string => {
151 const live = agents.filter(a => a.health === 'live').length
152 const stale = agents.filter(a => a.health === 'stale').length
153
154 return `${live} live${stale > 0 ? ` · ${stale} stale` : ''}`
155}
156types/index.d.ts 36 lines1export type Health = 'live' | 'stale' | 'unleased'
2
3export type WorktreeState = 'dirty' | 'active' | 'none'
4
5export type AgentRow = {
6 agent: string
7 bead: string
8 title: string
9 kind: string
10 health: Health
11 // Seconds since the bead's last heartbeat; null when it never beat.
12 heartbeatAgeSec: number | null
13 worktree: WorktreeState
14}
15
16export type MoleculeRow = {
17 id: string
18 title: string
19 done: number
20 total: number
21 running: number
22}
23
24export type Snapshot = {
25 at: number
26 agents: AgentRow[]
27 molecules: MoleculeRow[]
28 error: string | null
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 hivewatch: { snapshot: Snapshot | null }
34 }
35}
36