SLOPSHOPPER

bd-issue-band

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

bandguardcommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bd-issue-band
› fix the failing auth test and add an audit log call ╭──────────────────────────────────╮ │ bd-issue-band │ ⏺ Read(src/auth.ts) │ Editing with no bd issue claimed │ ⎿ Read 6 lines ╰──────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /issue ⎿ bd-issue-band: No bd issue held by this session. No bd issue claimed · 3 edits this session [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
No bd issue claimed · 3 edits this session [ Hide ]
README

bd-issue-band

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.

  • Claim an issue (bd update <id> --claim, or a Beads MCP claim) and the band shows its id, its title and the minutes since its last comment.
  • After 30 minutes of work with no comment the band turns to a warning and says so once.
  • Edit a file with nothing claimed and it says so.
  • /issue shows the band again. /issue <id> follows an issue claimed in an earlier session.

It only observes. It never blocks a tool call.

Needs

  • Claude Code with mod support (early access; tested on 2.1.291).
  • The 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.

Turning it off

  • The 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.

Working on it

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.

Source 5 files
hooks/register.tsx 215 lines
1import { 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}
215
hooks/lib/bd.ts 33 lines
1export 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}
33
hooks/lib/detect.ts 90 lines
1export 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}
90
hooks/lib/model.ts 139 lines
1import 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}
139
types/index.d.ts 36 lines
1export 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