A band above the prompt showing the bd (beads) issue this session holds and how long since its last comment

A Claude Code mod: a one-line band above the prompt showing the bd (beads) issue this session holds and how long since anyone commented on it.
bd update <id> --claim, or a Beads MCP claim) and the band shows its id, its title and the minutes since its last comment./issue shows the band again. /issue <id> follows an issue claimed in an earlier session.It only observes. It never blocks a tool call.
bd CLI on PATH, in a project that has a bd tracker. Where bd where finds no tracker the mod is dormant: no band and no nudge. A claim it sees wakes it.Issue band option in /config.DMOKONG_MODS=0 in the environment silences every mod from this marketplace for that process.claude plugin disable bd-issue-band@dmokong-plugins.Headless runs (claude -p) are always ignored.
claude plugin validate plugins/bd-issue-band claude plugin test plugins/bd-issue-band claude --plugin-dir plugins/bd-issue-band
If the plugin is also installed, --plugin-dir overrides the installed copy for that session.
hooks/register.tsx 215 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { parseLastComment, parseShow } from './lib/bd'
5import type { IssueInfo } from './lib/bd'
6import { detect, isExempt } from './lib/detect'
7import { EMPTY, applyEvent, applyProbe, applyRefresh, bandModel, fit, staleMark, takeStaleToast, touch } from './lib/model'
8
9const band = atom({ plugin: 'bd-issue-band', key: 'band' } as const, EMPTY)
10const BD_TIMEOUT_MS = 8_000
11const TICK_MS = 60_000
12const REFRESH_MS = 5 * 60_000
13
14/** What bd says about one issue; `info` is null when it could not be read. Never throws. */
15async function readIssue($: EngineInterface, id: string): Promise<{ info: IssueInfo | null; last: number | null }> {
16 try {
17 const shown = await $.process.run(['bd', 'show', id, '--json'], { timeoutMs: BD_TIMEOUT_MS })
18 const info = shown.exitCode === 0 ? parseShow(shown.stdout) : null
19 if (info === null) return { info: null, last: null }
20 const listed = await $.process.run(['bd', 'comments', id, '--json'], { timeoutMs: BD_TIMEOUT_MS })
21 return { info, last: listed.exitCode === 0 ? parseLastComment(listed.stdout) : null }
22 } catch {
23 return { info: null, last: null }
24 }
25}
26
27/** Reads one issue from bd and folds it into the state. Never throws. */
28async function refresh($: EngineInterface, id: string): Promise<void> {
29 try {
30 const { info, last } = await readIssue($, id)
31 await update($, band, state => applyRefresh(state, id, info, last))
32 } catch {
33 // The state write failed; the band keeps what it showed.
34 }
35}
36
37/** Asks bd once whether a tracker resolves from the session's directory. Never throws. */
38async function probe($: EngineInterface): Promise<void> {
39 let isTracked = false
40 try {
41 const where = await $.process.run(['bd', 'where'], { timeoutMs: BD_TIMEOUT_MS })
42 isTracked = where.exitCode === 0
43 } catch {
44 // No bd on PATH, or it did not answer in time: the same as no tracker.
45 }
46 await update($, band, state => applyProbe(state, isTracked)).catch(() => {})
47}
48
49let home = ''
50
51/**
52 * What the mod does after a tool call has run. Never throws into the call path.
53 * Declared at the top of the file: the engine only follows `$` into top-level functions.
54 */
55async function observe($: EngineInterface, call: Record<string, unknown>): Promise<void> {
56 const now = await $.clock.now()
57 const event = detect(call)
58 if (event === null) {
59 await update($, band, state => touch(state, now))
60 return
61 }
62 if (event.kind === 'edit') {
63 if (home === '') home = (await $.env.get('HOME')) ?? ''
64 if (isExempt(event.path, home)) return
65 const before = await read($, band)
66 if (before.isTracked === false) return
67 await update($, band, state => touch(applyEvent(state, event, now), now))
68 if (Object.keys(before.issues).length === 0 && before.edits === 0) {
69 $.ui.toast('Editing with no bd issue claimed')
70 }
71 return
72 }
73 await update($, band, state => applyEvent(state, event, now))
74 if (event.kind === 'close') {
75 $.ui.toast(`${event.ids.join(', ')} closed`)
76 return
77 }
78 for (const id of event.ids) void refresh($, id)
79}
80
81export const register: Register = (on, options) => {
82 let isOff = options.enabled === 'off'
83
84 on('session.start', async ($, e, next) => {
85 if ((await $.env.get('DMOKONG_MODS')) === '0') isOff = true
86 // A headless run (claude -p) has nobody to draw for: nothing is observed, no timers, no command.
87 if (!e.isInteractive) isOff = true
88 if (isOff) return next(e)
89
90 await $.command.register({
91 name: 'issue',
92 description: 'Show the bd issue band, or adopt an issue: /issue app-abc',
93 })
94 // Not awaited: start-up never waits for bd. Until it answers the band behaves as before.
95 void probe($)
96
97 $.clock.every(TICK_MS, () => {
98 void (async () => {
99 const now = await $.clock.now()
100 let announced: { id: string; minutes: number } | null = null
101 await update($, band, state => {
102 const taken = takeStaleToast(state, now)
103 const held = taken.id === null ? undefined : state.issues[taken.id]
104 announced = taken.id === null || held === undefined
105 ? null
106 : { id: taken.id, minutes: Math.floor((now - staleMark(held)) / 60_000) }
107 return { ...taken.state, tick: Math.floor(now / TICK_MS) }
108 })
109 if (announced !== null) {
110 const { id, minutes } = announced
111 $.ui.toast(`${id}: ${minutes} min with no comment`)
112 }
113 })().catch(() => {})
114 })
115
116 $.clock.every(REFRESH_MS, () => {
117 void (async () => {
118 const { focus } = await read($, band)
119 if (focus !== null) await refresh($, focus)
120 })().catch(() => {})
121 })
122
123 return next(e)
124 })
125
126 on('tool.call', async ($, e, next) => {
127 const answer = await next(e)
128 if (isOff || answer.deny !== undefined || answer.isError === true) return answer
129 try {
130 await observe($, e as unknown as Record<string, unknown>)
131 } catch {
132 // Observation is best effort; the tool's answer is what matters.
133 }
134 return answer
135 })
136
137 on('command.run', { command: 'issue' }, async ($, e) => {
138 const id = e.args.trim()
139 if (id === '') {
140 await update($, band, state => ({ ...state, isHidden: false }))
141 const state = await read($, band)
142 const held = Object.keys(state.issues)
143 if (held.length === 0 && state.isTracked === false) {
144 return { text: 'No bd tracker found from this directory; the band is dormant.' }
145 }
146 return { text: held.length === 0 ? 'No bd issue held by this session.' : `Holding ${held.join(', ')}.` }
147 }
148 if (!/^[a-z][a-z0-9]*-[a-z0-9]+(\.[0-9]+)*$/.test(id)) {
149 return { text: `"${id}" is not a bd issue id.` }
150 }
151 // Read first: an id bd cannot read, or one that is closed, never reaches the band.
152 const { info, last } = await readIssue($, id)
153 if (info === null) return { text: `Could not read ${id} from bd; the band is unchanged.` }
154 if (info.status === 'closed') return { text: `${id} is closed.` }
155 const now = await $.clock.now()
156 await update($, band, state => {
157 const adopted = applyRefresh(applyEvent(state, { kind: 'claim', ids: [id] }, now), id, info, last)
158 const held = adopted.issues[id]
159 if (held === undefined || held.lastCommentAt === null) return adopted
160 // An adopted issue was claimed in an earlier session, so no later than its last comment:
161 // without this the band would read 0m ago from the moment of adoption.
162 const claimedAt = Math.min(held.claimedAt, held.lastCommentAt)
163 return { ...adopted, issues: { ...adopted.issues, [id]: { ...held, claimedAt } } }
164 })
165 return { text: `Band now follows ${id}.` }
166 })
167
168 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
169 if (isOff || e.props.hasSurvey) return next(e)
170 const state = await read($, band)
171 const model = bandModel(state, await $.clock.now())
172 if (state.isHidden || model.kind === 'none') return next(e)
173
174 const { Box, Button, Text } = $.ui.resolve(e)
175 const hide = <Button key="hide" label="Hide" onPress={() => update($, band, s => ({ ...s, isHidden: true }))} />
176
177 if (model.kind === 'unclaimed') {
178 return (
179 <Box>
180 <Text key="line" color="warning">
181 No bd issue claimed · {model.edits} {model.edits === 1 ? 'edit' : 'edits'} this session{' '}
182 </Text>
183 {hide}
184 </Box>
185 )
186 }
187
188 const since = !model.hasComment
189 ? `no comment yet · claimed ${model.minutes}m ago`
190 : model.hasCommentSinceClaim
191 ? `last comment ${model.minutes}m ago`
192 : `claimed ${model.minutes}m ago · no comment since`
193 const tail = ` · ${since}${model.more > 0 ? ` · +${model.more} more` : ''} `
194 const room = (e.props.bodyColumns ?? 80) - model.id.length - tail.length - 30
195 const title = model.title === '' ? '' : ` · ${fit(model.title, Math.max(8, room))}`
196 return (
197 <Box>
198 <Text key="line" color={model.isStale ? 'warning' : undefined} dimColor={!model.isStale}>
199 {model.id}
200 {title}
201 {tail}
202 </Text>
203 {model.error !== null && <Text key="error" dimColor>{model.error} </Text>}
204 <Button
205 key="comment"
206 label="Comment"
207 onPress={() => $.prompt.fill({ text: `Post a bd comment on ${model.id}: `, mode: 'insert' })}
208 />
209 <Text> </Text>
210 {hide}
211 </Box>
212 )
213 })
214}
215hooks/lib/bd.ts 33 lines1export type IssueInfo = { title: string; status: string }
2
3function parseArray(stdout: string): unknown[] | null {
4 try {
5 const parsed: unknown = JSON.parse(stdout)
6 return Array.isArray(parsed) ? parsed : null
7 } catch {
8 return null
9 }
10}
11
12function isRecord(value: unknown): value is Record<string, unknown> {
13 return typeof value === 'object' && value !== null
14}
15
16export function parseShow(stdout: string): IssueInfo | null {
17 const first = parseArray(stdout)?.[0]
18 if (!isRecord(first) || typeof first.title !== 'string') return null
19 return { title: first.title, status: typeof first.status === 'string' ? first.status : 'unknown' }
20}
21
22export function parseLastComment(stdout: string): number | null {
23 const rows = parseArray(stdout)
24 if (rows === null) return null
25 let newest: number | null = null
26 for (const row of rows) {
27 if (!isRecord(row)) continue
28 const at = Date.parse(String(row.created_at))
29 if (!Number.isNaN(at) && (newest === null || at > newest)) newest = at
30 }
31 return newest
32}
33hooks/lib/detect.ts 90 lines1export type BdEvent =
2 | { kind: 'claim'; ids: string[] }
3 | { kind: 'comment'; ids: string[] }
4 | { kind: 'close'; ids: string[] }
5 | { kind: 'edit'; path: string }
6
7const ISSUE_ID = /^[a-z][a-z0-9]*-[a-z0-9]+(\.[0-9]+)*$/
8/** `mcp__<server>__<action>`: the server name is everything up to the last double underscore. */
9const MCP_TOOL = /^mcp__(.+)__([a-z_]+)$/
10
11function isMcpKind(action: string | undefined): action is 'claim' | 'comment' | 'close' {
12 return action === 'claim' || action === 'comment' || action === 'close'
13}
14
15/** The tokens after `bd` and its global flags, or null when the segment is not a bd call. */
16function bdArgs(segment: string): string[] | null {
17 const tokens = segment.trim().split(/\s+/)
18 const at = tokens.indexOf('bd')
19 if (at === -1) return null
20 // Only env assignments may precede `bd` (FOO=1 bd ...); anything else is another command.
21 if (!tokens.slice(0, at).every(token => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token))) return null
22 const rest = tokens.slice(at + 1)
23 let i = 0
24 for (let flag = rest[i]; flag !== undefined && flag.startsWith('-'); flag = rest[i]) {
25 // `--actor exampleapp` takes a value; `--readonly` and `--actor=x` do not.
26 i += flag === '--actor' || flag === '--db' ? 2 : 1
27 }
28 return rest.slice(i)
29}
30
31function fromSegment(segment: string): BdEvent | null {
32 const args = bdArgs(segment)
33 if (args === null || args.length === 0) return null
34 const [sub, ...rest] = args
35 if (sub === 'update') {
36 const id = rest.find(token => ISSUE_ID.test(token))
37 return id !== undefined && rest.includes('--claim') ? { kind: 'claim', ids: [id] } : null
38 }
39 if (sub === 'comment') {
40 return rest[0] !== undefined && ISSUE_ID.test(rest[0]) ? { kind: 'comment', ids: [rest[0]] } : null
41 }
42 if (sub === 'comments') {
43 return rest[0] === 'add' && rest[1] !== undefined && ISSUE_ID.test(rest[1])
44 ? { kind: 'comment', ids: [rest[1]] }
45 : null
46 }
47 if (sub === 'close') {
48 const ids: string[] = []
49 for (const token of rest) {
50 if (token.startsWith('-')) break
51 if (ISSUE_ID.test(token)) ids.push(token)
52 }
53 return ids.length > 0 ? { kind: 'close', ids } : null
54 }
55 return null
56}
57
58export function detect(call: Record<string, unknown>): BdEvent | null {
59 const tool = String(call.tool)
60 if (tool === 'Bash' && typeof call.command === 'string') {
61 for (const segment of call.command.split(/&&|\|\||;|\||\n/)) {
62 const found = fromSegment(segment)
63 if (found !== null) return found
64 }
65 return null
66 }
67 // Any MCP server whose name says beads: connectors and plugins name theirs differently.
68 const mcp = MCP_TOOL.exec(tool)
69 const server = mcp?.[1]
70 const action = mcp?.[2]
71 if (server !== undefined && /beads/i.test(server) && isMcpKind(action) && typeof call.issue_id === 'string') {
72 return { kind: action, ids: [call.issue_id] }
73 }
74 if ((tool === 'Edit' || tool === 'Write') && typeof call.file_path === 'string') {
75 return { kind: 'edit', path: call.file_path }
76 }
77 if (tool === 'NotebookEdit' && typeof call.notebook_path === 'string') {
78 return { kind: 'edit', path: call.notebook_path }
79 }
80 return null
81}
82
83export function isExempt(path: string, home: string): boolean {
84 return (
85 path.startsWith('/tmp/') ||
86 path.startsWith('/private/tmp/') ||
87 (home !== '' && path.startsWith(`${home}/.claude/`))
88 )
89}
90hooks/lib/model.ts 139 lines1import type { BandState, Issue } from '../../types'
2import type { IssueInfo } from './bd'
3import type { BdEvent } from './detect'
4
5export const STALE_MS = 30 * 60_000
6const TOUCH_EVERY_MS = 60_000
7
8export const EMPTY: BandState = {
9 issues: {},
10 focus: null,
11 edits: 0,
12 isHidden: false,
13 error: null,
14 tick: 0,
15 isTracked: null,
16}
17
18export type BandModel =
19 | { kind: 'none' }
20 | { kind: 'unclaimed'; edits: number }
21 | {
22 kind: 'issue'
23 id: string
24 title: string
25 minutes: number
26 isStale: boolean
27 more: number
28 error: string | null
29 /** Whether anyone is known to have commented; `minutes` runs from the claim when not. */
30 hasComment: boolean
31 /** Whether a comment landed after this session's claim; when not, `minutes` runs from the claim. */
32 hasCommentSinceClaim: boolean
33 }
34
35function fresh(id: string, now: number): Issue {
36 return { id, title: '', claimedAt: now, lastCommentAt: null, lastActivityAt: now, toastedMark: null }
37}
38
39function without(state: BandState, ids: readonly string[]): BandState {
40 const issues = { ...state.issues }
41 for (const id of ids) delete issues[id]
42 const left = Object.keys(issues)
43 const focus = state.focus !== null && issues[state.focus] !== undefined ? state.focus : left[left.length - 1] ?? null
44 return { ...state, issues, focus }
45}
46
47export function applyEvent(state: BandState, event: BdEvent, now: number): BandState {
48 if (event.kind === 'edit') {
49 // Where bd is not in use an edit is nobody's unclaimed work.
50 if (state.isTracked === false) return state
51 return Object.keys(state.issues).length === 0 ? { ...state, edits: state.edits + 1 } : state
52 }
53 // A bd command that went through proves a tracker, whatever the probe said.
54 if (event.kind === 'close') return { ...without(state, event.ids), isTracked: true }
55 const issues = { ...state.issues }
56 for (const id of event.ids) {
57 const held = issues[id] ?? fresh(id, now)
58 issues[id] = event.kind === 'comment' ? { ...held, lastCommentAt: now, toastedMark: null } : held
59 }
60 return { ...state, issues, focus: event.ids[event.ids.length - 1] ?? null, edits: 0, isHidden: false, isTracked: true }
61}
62
63export function applyRefresh(
64 state: BandState,
65 id: string,
66 info: IssueInfo | null,
67 lastCommentAt: number | null,
68): BandState {
69 const held = state.issues[id]
70 if (held === undefined) return state
71 if (info === null) return { ...state, error: 'bd unavailable' }
72 if (info.status === 'closed') return { ...without(state, [id]), error: null }
73 const newest =
74 lastCommentAt === null ? held.lastCommentAt : Math.max(lastCommentAt, held.lastCommentAt ?? 0)
75 return {
76 ...state,
77 error: null,
78 issues: { ...state.issues, [id]: { ...held, title: info.title, lastCommentAt: newest } },
79 }
80}
81
82/** The start-up probe's answer. A claim seen first already proved a tracker, so false never overrides true. */
83export function applyProbe(state: BandState, isTracked: boolean): BandState {
84 if (isTracked || state.isTracked === true) return { ...state, isTracked: true }
85 return { ...state, isTracked: false, edits: 0 }
86}
87
88/** Records that the session did something while holding the focus issue. */
89export function touch(state: BandState, now: number): BandState {
90 const held = state.focus === null ? undefined : state.issues[state.focus]
91 if (held === undefined || now - held.lastActivityAt < TOUCH_EVERY_MS) return state
92 return { ...state, issues: { ...state.issues, [held.id]: { ...held, lastActivityAt: now } } }
93}
94
95/** The moment staleness is measured from: the later of the claim and the last comment. */
96export function staleMark(issue: Issue): number {
97 return Math.max(issue.claimedAt, issue.lastCommentAt ?? 0)
98}
99
100export function isStale(issue: Issue, now: number): boolean {
101 const mark = staleMark(issue)
102 return now - mark >= STALE_MS && issue.lastActivityAt > mark
103}
104
105/** The focus issue's id, once per stale episode; the state remembers it was announced. */
106export function takeStaleToast(state: BandState, now: number): { state: BandState; id: string | null } {
107 const held = state.focus === null ? undefined : state.issues[state.focus]
108 if (held === undefined || !isStale(held, now)) return { state, id: null }
109 const mark = staleMark(held)
110 if (held.toastedMark === mark) return { state, id: null }
111 return {
112 id: held.id,
113 state: { ...state, issues: { ...state.issues, [held.id]: { ...held, toastedMark: mark } } },
114 }
115}
116
117export function bandModel(state: BandState, now: number): BandModel {
118 const held = state.focus === null ? undefined : state.issues[state.focus]
119 if (held === undefined) {
120 return state.edits > 0 ? { kind: 'unclaimed', edits: state.edits } : { kind: 'none' }
121 }
122 return {
123 kind: 'issue',
124 id: held.id,
125 title: held.title,
126 minutes: Math.max(0, Math.floor((now - staleMark(held)) / 60_000)),
127 isStale: isStale(held, now),
128 more: Object.keys(state.issues).length - 1,
129 error: state.error,
130 hasComment: held.lastCommentAt !== null,
131 hasCommentSinceClaim: held.lastCommentAt !== null && held.lastCommentAt >= held.claimedAt,
132 }
133}
134
135export function fit(text: string, width: number): string {
136 if (width <= 0) return ''
137 return text.length <= width ? text : `${text.slice(0, width - 1)}…`
138}
139types/index.d.ts 36 lines1export type Issue = {
2 id: string
3 title: string
4 /** When this session claimed or adopted it, ms since epoch. */
5 claimedAt: number
6 /** Newest comment by anyone, ms since epoch; null when none is known. */
7 lastCommentAt: number | null
8 /** Last tool call this session ran while holding it, ms since epoch. */
9 lastActivityAt: number
10 /** The staleness mark a toast was already shown for; null when none. */
11 toastedMark: number | null
12}
13
14export type BandState = {
15 issues: Record<string, Issue>
16 focus: string | null
17 /** Edits made while holding no issue. */
18 edits: number
19 isHidden: boolean
20 /** A dim one-line reason when bd could not be read; null otherwise. */
21 error: string | null
22 /** Minute counter; written by the redraw timer so the band re-renders. */
23 tick: number
24 /**
25 * Whether a bd tracker resolves from the session's directory. Null until the start-up probe
26 * answers; while null the band behaves as if there is one.
27 */
28 isTracked: boolean | null
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'bd-issue-band': { band: BandState }
34 }
35}
36