SLOPSHOPPER

duckboard

One pane that shows every Claude Code session on this computer.

newpanecommandprocesstimer
v0.1.0no licenseupdated 2026-10-23stalladesign/duckboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · duckboard
│ ┃ duckboard ✕ › fix the failing auth test and add an audit log call │ ┃ 5-hour limit 31% [###.......] │ ┃ 0 working, 0 your turn, 0 agents running ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Working 0 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Your turn 0 ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Cold 0 [ Show ] │ ┃ No sessions found. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 0 background runs in the last 6 hours │ ┃ Updated 0s ago ✻ Worked for 42s · done 4:20 PM │ │ › /duckboard │ ⎿ duckboard: duckboard opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · duckboard
5-hour limit 31% [###.......] 0 working, 0 your turn, 0 agents running Working 0 Your turn 0 Cold 0 [ Show ] No sessions found. 0 background runs in the last 6 hours Updated 0s ago
README

duckboard

A Claude Code mod: one pane that shows every Claude Code session on your computer, so you stop checking each one by hand.

It reads your own ~/.claude files (the live-session registry and the session transcripts) and the engine's usage call. Nothing is sent anywhere.

What it shows

  • The 5-hour and weekly limits, with reset times.
  • Sessions in three groups: Working, Your turn (idle, the prompt cache still warm) and Cold (cache gone, or the session closed). Cold is folded; press Show.
  • Per session: what it is doing, running agents, cache time left, context size.
  • Details on a row: the last update, context, cache, running agents, a ticket id, git push state and the newest test run, where known.
  • Compact on an idle session with a warm cache. Compacting another session works only when that session runs duckboard too.

Open the pane with /duckboard. It also opens when a session starts.

Install

At the prompt of a terminal session:

/plugin install duckboard --marketplace stalladesign/duckboard

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install duckboard --marketplace stalladesign/duckboard

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with stalladesign/duckboard, then Add plugin.

After installing, restart your open sessions so every session runs the same version.

Requirements

A Claude Code build that supports mods (hooks modules). Mods are early access; the API may change between releases. Built and tested on 2.1.308.

Options

Set in the config menu (/config) or under pluginConfigs.duckboard in settings:

  • addressName: if a reply line starts with this name and a comma or colon, the board drops it.
  • autoCompact (off by default): compact an idle, warm session after autoCompactIdleMinutes (default 50; must be under the 60 minute cache lifetime) when its context is at least autoCompactMinTokens (default 100000).
Source 8 files
hooks/register.tsx 405 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, CompactState, GitFact, Pending, Row } from '../types'
5import { detailItems, drawDetails, drawFooter, drawGroup, drawRow, drawRule, drawTop, GROUP_LABEL, metaParts, PALETTE, textBar } from './draw'
6import { fmtDur, resetsIn } from './format'
7import { canCompact, compactNote, failed, formatLine, newId, onReply, parseLine, request, showButton, tick, unwrap, type Line } from './compact'
8import { gitFact, log, readTheme, scan } from './scan'
9
10const PANE = 'duckboard'
11const SCAN_MS = 20_000
12const GIT_TTL_MS = 60_000
13// shortcut: remote surfaces report the pane in code-font cells, not pixels. 7.8 px (a 13 px monospace
14// advance) was measured on the desktop app: 147 columns drew a 1,160 px pane. Upgrade if the engine reports pixels.
15const PX_PER_COLUMN = 7.8
16const BUTTONS_PX = 96
17
18const boardA = atom({ plugin: 'duckboard', key: 'board' } as const, null as Board | null)
19const coldOpenA = atom({ plugin: 'duckboard', key: 'coldOpen' } as const, false)
20const openRowsA = atom({ plugin: 'duckboard', key: 'openRows' } as const, [] as string[])
21const themeA = atom({ plugin: 'duckboard', key: 'theme' } as const, 'light' as 'light' | 'dark')
22const compactA = atom({ plugin: 'duckboard', key: 'compact' } as const, {} as Record<string, CompactState>)
23const gitA = atom({ plugin: 'duckboard', key: 'git' } as const, {} as Record<string, GitFact>)
24const pendingA = atom({ plugin: 'duckboard', key: 'pending' } as const, null as Pending | null)
25const turnA = atom({ plugin: 'duckboard', key: 'isTurnRunning' } as const, false)
26
27// Module state: a hot reload starts these over; the next scan fills them again.
28const commits: Record<string, string | null> = {}
29const autoTried = new Set<string>()
30let isScanning = false
31let isRunningPending = false
32
33type Auto = { isOn: boolean; idleMin: number; minTokens: number }
34
35async function refreshGit($: EngineInterface, row: Row, force: boolean) {
36  if (!row.cwd) return
37  const now = await $.clock.now()
38  const had = (await read($, gitA))[row.sessionId]
39  if (!force && had && now - had.at < GIT_TTL_MS) return
40  const { fact, commit } = await gitFact($, row.cwd, now)
41  commits[row.sessionId] = commit
42  await update($, gitA, g => ({ ...g, [row.sessionId]: fact }))
43}
44
45async function runScan($: EngineInterface, addressName: string, auto: Auto) {
46  if (isScanning) return
47  isScanning = true
48  try {
49    const board = await scan($, { addressName, commits })
50    await update($, boardA, () => board)
51    const theme = await readTheme($)
52    if (theme !== (await read($, themeA))) await update($, themeA, () => theme)
53    const open = new Set(await read($, openRowsA))
54    for (const r of board.rows) if (open.has(r.sessionId) && r.group !== 'cold') await refreshGit($, r, false)
55    await autoCompact($, board, auto)
56  } catch (err) {
57    log($, 'scan', err)
58    const at = await $.clock.now()
59    const msg = err instanceof Error ? err.message : String(err)
60    await update($, boardA, b => (b ? { ...b, error: msg } : { scannedAt: at, rows: [], headless6h: 0, limits: [], limitsReadAt: null, error: msg }))
61  } finally {
62    isScanning = false
63  }
64}
65
66async function setCompact($: EngineInterface, sid: string, next: CompactState | null) {
67  await update($, compactA, m => {
68    const out = { ...m }
69    if (next) out[sid] = next
70    else delete out[sid]
71    return out
72  })
73}
74
75async function sendLine($: EngineInterface, sessionId: string, line: Line) {
76  const r = await $.session.send({ to: { sessionId }, text: formatLine(line) })
77  if (!r.isDelivered) log($, `send ${line.kind} to ${sessionId}`, r.reason)
78}
79
80// Compact a row: this session through the engine, another one by a request line it answers.
81async function pressCompact($: EngineInterface, row: Row, surface: string) {
82  const now = await $.clock.now()
83  const id = newId(crypto.getRandomValues(new Uint8Array(6)))
84  const prev = (await read($, compactA))[row.sessionId] ?? null
85  const sent = request(prev, id, now)
86  await setCompact($, row.sessionId, sent)
87  if (row.isThis) return compactSelf($, row.sessionId, sent, id, surface)
88  try {
89    const me = await $.session.id()
90    const r = await $.session.send({ to: { sessionId: row.sessionId }, text: formatLine({ kind: 'request', id, from: me }) })
91    // auto mode can answer "no verdict" and block the send: show the engine's own words
92    if (!r.isDelivered) await setCompact($, row.sessionId, failed(sent, r.reason, await $.clock.now()))
93  } catch (err) {
94    log($, 'send compact request', err)
95    await setCompact($, row.sessionId, failed(sent, err instanceof Error ? err.message : String(err), await $.clock.now()))
96  }
97}
98
99async function compactSelf($: EngineInterface, sid: string, sent: CompactState, id: string, surface: string) {
100  const at = await $.clock.now()
101  await setCompact($, sid, onReply(sent, { kind: 'started', id }, at))
102  await update($, pendingA, () => ({ id, from: null, at }))
103  const fail = async (reason: string) => {
104    await update($, pendingA, () => null)
105    await setCompact($, sid, failed((await read($, compactA))[sid] ?? sent, reason, await $.clock.now()))
106  }
107  try {
108    const r = await $.session.compact()
109    if (r.skip !== undefined) return fail(r.skip)
110    await finishPending($, id, { kind: 'result', id, ok: true, before: r.tokensBefore ?? null, after: r.tokensAfter ?? null })
111  } catch (err) {
112    log($, 'compact this session', err)
113    const msg = err instanceof Error ? err.message : String(err)
114    // On the terminal the engine's refusal is the answer; queueing a /compact would hide it.
115    if (surface === 'terminal') return fail(msg)
116    try {
117      // Headless (the desktop app): $.session.compact is refused, the /compact command is not.
118      await $.command.run({ command: 'compact', args: '' })
119      if ((await read($, pendingA))?.id === id) await fail(`/compact ran but no compaction followed (${msg})`)
120    } catch (err2) {
121      log($, 'run /compact', err2)
122      await fail(err2 instanceof Error ? err2.message : String(err2))
123    }
124  }
125}
126
127// Report the open request once: to the peer that asked, or to this session's own row.
128async function finishPending($: EngineInterface, id: string, line: Line) {
129  const p = await read($, pendingA)
130  if (!p || p.id !== id) return
131  await update($, pendingA, () => null)
132  if (p.from !== null) return sendLine($, p.from, line)
133  const me = await $.session.id()
134  await setCompact($, me, onReply((await read($, compactA))[me] ?? null, line, await $.clock.now()))
135}
136
137// The receiver's side: compact now, or when the running turn ends.
138async function runPending($: EngineInterface) {
139  const p = await read($, pendingA)
140  if (!p || p.from === null || isRunningPending || (await read($, turnA))) return
141  isRunningPending = true
142  try {
143    const r = await $.session.compact()
144    if (r.skip !== undefined) await finishPending($, p.id, { kind: 'result', id: p.id, ok: false, reason: r.skip })
145    else await finishPending($, p.id, { kind: 'result', id: p.id, ok: true, before: r.tokensBefore ?? null, after: r.tokensAfter ?? null })
146  } catch (err) {
147    log($, 'compact on request', err)
148    try {
149      await $.command.run({ command: 'compact', args: '' })
150      const msg = err instanceof Error ? err.message : String(err)
151      await finishPending($, p.id, { kind: 'result', id: p.id, ok: false, reason: `/compact ran but no compaction followed (${msg})` })
152    } catch (err2) {
153      log($, 'run /compact on request', err2)
154      await finishPending($, p.id, { kind: 'result', id: p.id, ok: false, reason: err2 instanceof Error ? err2.message : String(err2) })
155    }
156  } finally {
157    isRunningPending = false
158  }
159}
160
161async function takeRequest($: EngineInterface, l: Extract<Line, { kind: 'request' }>) {
162  const at = await $.clock.now()
163  await update($, pendingA, () => ({ id: l.id, from: l.from, at }))
164  const busy = await read($, turnA)
165  await sendLine($, l.from, { kind: busy ? 'queued' : 'started', id: l.id })
166  if (!busy) $.clock.after(0, () => void runPending($).catch(err => log($, 'compact on request', err)))
167}
168
169async function takeReply($: EngineInterface, l: Line) {
170  const now = await $.clock.now()
171  const all = await read($, compactA)
172  const sid = Object.keys(all).find(k => all[k]!.ids.includes(l.id))
173  if (!sid) return log($, 'compact reply', `no open request has id ${l.id}`)
174  await setCompact($, sid, onReply(all[sid]!, l, now))
175}
176
177async function tickCompact($: EngineInterface) {
178  const now = await $.clock.now()
179  const all = await read($, compactA)
180  for (const [sid, s] of Object.entries(all)) {
181    const t = tick(s, now)
182    if (t !== s) await setCompact($, sid, t)
183  }
184}
185
186async function autoCompact($: EngineInterface, board: Board, auto: Auto) {
187  // Under the 60 minute cache lifetime only: past it there is no warm cache to keep.
188  if (!auto.isOn || auto.idleMin >= 60) return
189  const all = await read($, compactA)
190  for (const row of board.rows) {
191    const key = `${row.sessionId}:${row.lastReplyAt}`
192    if (!canCompact(row) || all[row.sessionId] || autoTried.has(key)) continue
193    if (row.idleSince === null || board.scannedAt - row.idleSince < auto.idleMin * 60_000) continue
194    if ((row.tokens ?? 0) < auto.minTokens) continue
195    autoTried.add(key)
196    await pressCompact($, row, 'auto')
197  }
198}
199
200export const register: Register = (on, options) => {
201  const addressName = String(options.addressName ?? '')
202  const auto: Auto = {
203    isOn: options.autoCompact === true,
204    idleMin: Number(options.autoCompactIdleMinutes ?? 50),
205    minTokens: Number(options.autoCompactMinTokens ?? 100_000),
206  }
207
208  on('session.start', async ($, e, next) => {
209    const started = await next(e)
210    await $.command.register({ name: 'duckboard', description: 'Open the duckboard pane: every Claude Code session on this computer' })
211    $.clock.every(SCAN_MS, () => void runScan($, addressName, auto).catch(err => log($, 'timer scan', err)))
212    // Redraws "Updated Ns ago" between scans.
213    $.clock.every(5000, () => {
214      $.ui.invalidate('ui.render')
215      void tickCompact($).catch(err => log($, 'compact timers', err))
216    })
217    void runScan($, addressName, auto).catch(err => log($, 'first scan', err))
218    void $.ui.open({ id: PANE, title: 'duckboard' }).then(
219      r => { if (!r.isPlaced) log($, 'pane not placed at start', r.reason) },
220      err => log($, 'open pane at start', err),
221    )
222    return started
223  })
224
225  on('command.run', { command: 'duckboard' }, async $ => {
226    const opened = await $.ui.open({ id: PANE, title: 'duckboard' })
227    void runScan($, addressName, auto).catch(err => log($, 'command scan', err))
228    return { text: opened.isPlaced ? 'duckboard opened.' : `duckboard is not placed: ${opened.reason}` }
229  })
230
231  on('session.receive', async ($, e, next) => {
232    // Only a peer session's message, and only an exact protocol line; all else goes on.
233    if (e.origin.kind !== 'peer' || 'teammate' in e.origin || e.agentId !== undefined) return next(e)
234    const l = parseLine(unwrap(e.text))
235    if (!l) return next(e)
236    if (l.kind === 'request') await takeRequest($, l)
237    else await takeReply($, l)
238    return { consumed: `duckboard compact-${l.kind}` }
239  })
240
241  on('session.compact', async ($, e, next) => {
242    const r = await next(e)
243    if (e.agentId !== undefined) return r
244    const p = await read($, pendingA)
245    if (p) {
246      const line: Line = r.skip !== undefined ? { kind: 'result', id: p.id, ok: false, reason: r.skip } : { kind: 'result', id: p.id, ok: true, before: r.tokensBefore ?? null, after: r.tokensAfter ?? null }
247      await finishPending($, p.id, line)
248    }
249    return r
250  })
251
252  on('turn.start', async ($, e, next) => {
253    if (e.agentId === undefined) await update($, turnA, () => true)
254    return next(e)
255  })
256
257  on('turn.complete', async ($, e, next) => {
258    const r = await next(e)
259    if (e.agentId === undefined) {
260      await update($, turnA, () => false)
261      // A request queued mid-turn runs once the turn is over.
262      $.clock.after(0, () => void runPending($).catch(err => log($, 'compact after turn', err)))
263    }
264    return r
265  })
266
267  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
268    const els = $.ui.resolve(e)
269    const { Box, Text, Button } = els
270    const board = await read($, boardA)
271    const coldOpen = await read($, coldOpenA)
272    const openRows = await read($, openRowsA)
273    const git = await read($, gitA)
274    const compact = await read($, compactA)
275    const theme = await read($, themeA)
276    const now = await $.clock.now()
277    if (!board) return <Text dimColor>Reading sessions.</Text>
278
279    const groups = (['working', 'yourturn', 'cold'] as const).map(g => ({ g, rows: board.rows.filter(r => r.group === g) }))
280    const counts = {
281      working: groups[0]!.rows.length,
282      yourturn: groups[1]!.rows.length,
283      agents: board.rows.reduce((n, r) => n + r.agentsRunning, 0),
284    }
285    const toggleCold = () => update($, coldOpenA, v => !v)
286    const toggleRow = async (row: Row) => {
287      const isOpen = (await read($, openRowsA)).includes(row.sessionId)
288      await update($, openRowsA, list => (isOpen ? list.filter(x => x !== row.sessionId) : [...list, row.sessionId]))
289      if (!isOpen) await refreshGit($, row, true)
290    }
291    const ago = now - board.scannedAt
292    const noteFor = (row: Row) => compactNote(compact[row.sessionId] ?? null, row, now)
293    const compactBtn = (row: Row) => {
294      const label = showButton(row, compact[row.sessionId] ?? null)
295      return label && <Button key={`c-${row.sessionId}`} onPress={() => pressCompact($, row, e.surface)}>{label}</Button>
296    }
297
298    // The terminal's table carries an Svg that draws nothing: pick by surface, not by the table.
299    if (e.surface !== 'terminal' && 'Svg' in els && els.Svg) {
300      const Svg = els.Svg
301      const W = Math.max(260, Math.round(e.props.bodyColumns * PX_PER_COLUMN))
302      const RW = W - BUTTONS_PX
303      const hover = { backgroundColor: PALETTE[theme].hover }
304      const top = drawTop({ limits: board.limits, counts, now, width: W, theme, readAt: board.limitsReadAt })
305      const foot = drawFooter({ headless: board.headless6h, agoMs: ago, width: W, theme })
306      const rule = drawRule(W, theme)
307      const ruleBox = (k: string) => <Box key={k}><Svg source={rule.svg} alt="" width={W} height={1} /></Box>
308      return (
309        <Box flexDirection="column">
310          <Box key="top"><Svg source={top.svg} alt={top.alt} width={W} /></Box>
311          {board.error && <Text color="red">Scan failed: {board.error}</Text>}
312          {groups.map(({ g, rows }) => {
313            const isCold = g === 'cold'
314            const head = drawGroup({ label: GROUP_LABEL[g], n: rows.length, width: isCold ? RW : W, theme, rule: !isCold })
315            const show = !isCold || coldOpen
316            return (
317              <Box key={`g-${g}`} flexDirection="column">
318                {isCold ? (
319                  <Box key="gh-cold" flexDirection="column">
320                    {/* bottom-aligned: the label row's 20 px top padding sits above the button */}
321                    <Box flexDirection="row" alignItems="flex-end">
322                      <Svg source={head.svg} alt={head.alt} width={RW} />
323                      <Box flexGrow={1} />
324                      <Box paddingRight="sm"><Button key="cold-toggle" onPress={toggleCold}>{coldOpen ? 'Hide' : 'Show'}</Button></Box>
325                    </Box>
326                    {ruleBox('gh-cold-rule')}
327                  </Box>
328                ) : (
329                  <Box key={`gh-${g}`}><Svg source={head.svg} alt={head.alt} width={W} /></Box>
330                )}
331                {show && rows.map((row, i) => {
332                  const pic = drawRow({ row, now, width: RW, theme, note: noteFor(row) })
333                  const isOpen = openRows.includes(row.sessionId)
334                  const det = isOpen ? drawDetails({ items: detailItems(row, git[row.sessionId] ?? null, now), width: W, theme }) : null
335                  return (
336                    <Box key={`r-${row.sessionId}`} flexDirection="column" hover={hover}>
337                      {i > 0 && ruleBox(`rl-${row.sessionId}`)}
338                      <Box key={`rr-${row.sessionId}`} flexDirection="row" alignItems="flex-start">
339                        <Svg source={pic.svg} alt={pic.alt} width={RW} />
340                        <Box flexGrow={1} />
341                        <Box flexDirection="column" alignItems="flex-end" gap="xs" paddingTop="sm" paddingRight="sm">
342                          <Button key={`d-${row.sessionId}`} onPress={() => toggleRow(row)}>{isOpen ? 'Hide details' : 'Details'}</Button>
343                          {compactBtn(row)}
344                        </Box>
345                      </Box>
346                      {det && <Box key={`x-${row.sessionId}`}><Svg source={det.svg} alt={det.alt} width={W} /></Box>}
347                    </Box>
348                  )
349                })}
350              </Box>
351            )
352          })}
353          {board.rows.length === 0 && <Text dimColor>No sessions found.</Text>}
354          <Box key="foot"><Svg source={foot.svg} alt={foot.alt} width={W} /></Box>
355        </Box>
356      )
357    }
358
359    // Terminal: plain Text rows, bars drawn as text.
360    return (
361      <Box flexDirection="column">
362        {board.limits.map(l => (
363          <Text>{`${l.label} ${l.percent}% ${textBar(l.percent / 100, 10)}${resetsIn(l.resetsAt, now) ? ' ' + resetsIn(l.resetsAt, now) : ''}${board.limitsReadAt != null ? `, read ${fmtDur(now - board.limitsReadAt)} ago` : ''}`}</Text>
364        ))}
365        <Text dimColor>{`${counts.working} working, ${counts.yourturn} your turn, ${counts.agents} agent${counts.agents === 1 ? '' : 's'} running`}</Text>
366        {board.error && <Text color="red">Scan failed: {board.error}</Text>}
367        {groups.map(({ g, rows }) => (
368          <Box key={`g-${g}`} flexDirection="column" marginTop={1}>
369            <Box flexDirection="row" justifyContent="space-between">
370              <Text bold>{`${GROUP_LABEL[g]} ${rows.length}`}</Text>
371              {g === 'cold' && <Button key="cold-toggle" onPress={toggleCold}>{coldOpen ? 'Hide' : 'Show'}</Button>}
372            </Box>
373            {(g !== 'cold' || coldOpen) && rows.map(row => {
374              const isOpen = openRows.includes(row.sessionId)
375              return (
376                <Box key={`r-${row.sessionId}`} flexDirection="column">
377                  <Box flexDirection="row" justifyContent="space-between">
378                    <Text bold>{`* ${row.title}${row.isThis ? '  (this one)' : ''}`}</Text>
379                    <Box flexDirection="row" gap={1}>
380                      <Button key={`d-${row.sessionId}`} onPress={() => toggleRow(row)}>{isOpen ? 'Hide details' : 'Details'}</Button>
381                      {compactBtn(row)}
382                    </Box>
383                  </Box>
384                  {row.what && <Text wrap="truncate-end">{`  ${row.what}`}</Text>}
385                  {noteFor(row) && <Text color={toneColor(noteFor(row)!.tone)} dimColor={noteFor(row)!.tone === 'dim'}>{`  ${noteFor(row)!.text}`}</Text>}
386                  <Text dimColor wrap="truncate-end">{'  ' + metaParts(row, now).map(p => (p.bar === null ? p.text : `${p.text} ${textBar(p.bar, 10)}`)).join(' | ')}</Text>
387                  {isOpen && detailItems(row, git[row.sessionId] ?? null, now).map(it => (
388                    <Text>{`    ${it.k}: ${it.lines.join('; ')}${it.k === 'Context' && it.bar ? ' ' + textBar(it.bar.pct, 10) : ''}`}</Text>
389                  ))}
390                </Box>
391              )
392            })}
393          </Box>
394        ))}
395        {board.rows.length === 0 && <Text dimColor>No sessions found.</Text>}
396        <Text dimColor>{`${board.headless6h} background run${board.headless6h === 1 ? '' : 's'} in the last 6 hours   Updated ${Math.round(ago / 1000)}s ago`}</Text>
397      </Box>
398    )
399  })
400}
401
402function toneColor(tone: string): string | undefined {
403  return tone === 'red' ? 'red' : tone === 'amber' ? 'yellow' : tone === 'ok' ? 'green' : undefined
404}
405
hooks/draw.ts 282 lines
1// Pure: the pictures (SVG strings) and the plain-text parts both surfaces draw.
2// Sizes, padding, rules and colors are design/mockup.html's tokens.
3import type { GitFact, Group, Limit, Row } from '../types'
4import { CACHE_TTL_MS } from './board'
5import { cacheLabel, contextLine, fmtDur, fmtTokens, metaContext, resetsIn } from './format'
6
7export const SPEC = { padX: 12, padY: 10, gap: 4, fsTitle: 13, fsBody: 12, fsMeta: 11, fsCount: 13, lh: 1.35, dot: 8, barH: 4, cacheBarW: 36, rule: 1 }
8
9export const PALETTE = {
10  light: { bg: '#ffffff', text: '#1f1f1f', dim: '#6b6b6b', rule: '#e6e6e6', track: '#ececec', hover: '#f5f5f5', working: '#2f6fdf', yourturn: '#2e9e5b', cold: '#9a9a9a', amber: '#b87d00', red: '#c4453b' },
11  dark: { bg: '#1e1e1e', text: '#e8e8e8', dim: '#9a9a9a', rule: '#333333', track: '#3a3a3a', hover: '#262626', working: '#5b9cff', yourturn: '#4cc27c', cold: '#777777', amber: '#e0a526', red: '#e5675c' },
12}
13export type Theme = keyof typeof PALETTE
14export type Tone = 'text' | 'dim' | 'amber' | 'red' | 'ok'
15export type Pic = { svg: string; alt: string; height: number }
16
17export const GROUP_LABEL: Record<Group, string> = { working: 'Working', yourturn: 'Your turn', cold: 'Cold' }
18const FONT = 'system-ui, -apple-system, Segoe UI, sans-serif'
19const MIN = 60_000
20const LOW_CACHE_MS = 10 * MIN
21const TEXT_X = SPEC.padX + SPEC.dot + 8
22
23// shortcut: per-glyph width classes for a system sans, not real metrics; widened 4% so text never overflows.
24export function textWidth(s: string, size: number, bold = false): number {
25  let em = 0
26  for (const c of s) {
27    if (" .,:;'!|ijl".includes(c)) em += 0.28
28    else if ('frtI()[]-'.includes(c)) em += 0.38
29    else if ('mw'.includes(c)) em += 0.85
30    else if ('MW@'.includes(c)) em += 0.92
31    else if (c >= '0' && c <= '9') em += 0.58
32    else if (c >= 'A' && c <= 'Z') em += 0.66
33    else if (c >= 'a' && c <= 'z') em += 0.54
34    else em += 0.62
35  }
36  return em * size * (bold ? 1.06 : 1) * 1.04
37}
38
39export function fit(s: string, maxPx: number, size: number, bold = false): string {
40  if (textWidth(s, size, bold) <= maxPx) return s
41  const chars = [...s]
42  while (chars.length && textWidth(chars.join('').trimEnd() + '...', size, bold) > maxPx) chars.pop()
43  return chars.length ? chars.join('').trimEnd() + '...' : ''
44}
45
46export function wrap(s: string, maxPx: number, size: number, maxLines: number): string[] {
47  const out: string[] = []
48  let cur = ''
49  const words = s.split(/\s+/).filter(Boolean)
50  for (let i = 0; i < words.length; i++) {
51    const next = cur ? cur + ' ' + words[i] : words[i]!
52    if (textWidth(next, size) <= maxPx || !cur) {
53      cur = next
54      continue
55    }
56    out.push(cur)
57    cur = words[i]!
58    if (out.length === maxLines - 1) {
59      out.push(fit(words.slice(i).join(' '), maxPx, size))
60      return out
61    }
62  }
63  if (cur) out.push(fit(cur, maxPx, size))
64  return out.slice(0, maxLines)
65}
66
67const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
68const r1 = (n: number) => Math.round(n * 10) / 10
69const lineH = (size: number) => size * SPEC.lh
70const baseline = (top: number, size: number) => r1(top + (lineH(size) - size) / 2 + size * 0.8)
71
72function color(theme: Theme, tone: Tone): string {
73  const p = PALETTE[theme]
74  return tone === 'ok' ? p.yourturn : tone === 'text' ? p.text : p[tone]
75}
76
77function text(x: number, y: number, s: string, size: number, fill: string, o: { bold?: boolean; anchor?: 'end' } = {}): string {
78  return `<text x="${r1(x)}" y="${y}" font-size="${size}"${o.bold ? ' font-weight="600"' : ''}${o.anchor ? ` text-anchor="${o.anchor}"` : ''} fill="${fill}">${esc(s)}</text>`
79}
80
81function bar(x: number, y: number, w: number, pct: number, fill: string, track: string): string {
82  const p = Math.max(0, Math.min(1, pct))
83  return `<rect x="${r1(x)}" y="${r1(y)}" width="${r1(w)}" height="${SPEC.barH}" rx="2" fill="${track}"/>` +
84    (p > 0 ? `<rect x="${r1(x)}" y="${r1(y)}" width="${r1(Math.max(2, w * p))}" height="${SPEC.barH}" rx="2" fill="${fill}"/>` : '')
85}
86
87const svg = (width: number, height: number, body: string) =>
88  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${r1(height)}" viewBox="0 0 ${width} ${r1(height)}" font-family="${FONT}">${body}</svg>`
89
90export const drawRule = (width: number, theme: Theme): Pic => ({ svg: svg(width, 1, ruleLine(width, 0, theme)), alt: '', height: 1 })
91
92const ruleLine = (width: number, y: number, theme: Theme) => `<rect x="0" y="${y}" width="${width}" height="${SPEC.rule}" fill="${PALETTE[theme].rule}"/>`
93
94export type MetaPart = { text: string; tone: Tone; bar: number | null }
95
96export const cacheLeft = (row: Row, now: number) => (row.lastReplyAt === null ? null : CACHE_TTL_MS - (now - row.lastReplyAt))
97
98// The meta line, shared by the picture and the terminal rows.
99export function metaParts(row: Row, now: number): MetaPart[] {
100  const parts: MetaPart[] = []
101  const ctx = metaContext(row.tokens, row.window)
102  if (row.group === 'cold') {
103    if (row.idleSince !== null) parts.push({ text: `Idle ${fmtDur(now - row.idleSince)}`, tone: 'dim', bar: null })
104  } else {
105    const n = row.agentsRunning
106    parts.push({ text: n === 0 ? 'No agents' : `${n} agent${n === 1 ? '' : 's'} running`, tone: 'dim', bar: null })
107    const left = cacheLeft(row, now)
108    const label = cacheLabel(left)
109    if (label && left !== null) parts.push({ text: label, tone: left < LOW_CACHE_MS ? 'amber' : 'dim', bar: left / CACHE_TTL_MS })
110    else parts.push({ text: `Cold: next turn rewrites ${row.tokens === null ? 'the context' : fmtTokens(row.tokens)}`, tone: 'amber', bar: null })
111  }
112  if (ctx) parts.push({ text: ctx, tone: 'dim', bar: null })
113  return parts
114}
115
116export type Note = { text: string; tone: Tone }
117
118export function drawRow(o: { row: Row; now: number; width: number; theme: Theme; note: Note | null }): Pic {
119  const { row, now, width, theme } = o
120  const p = PALETTE[theme]
121  const avail = width - TEXT_X - SPEC.padX
122  let y = SPEC.padY
123  let body = ''
124  const dotY = r1(y + lineH(SPEC.fsTitle) / 2)
125  body += `<circle cx="${SPEC.padX + SPEC.dot / 2}" cy="${dotY}" r="${SPEC.dot / 2}" fill="${p[row.group]}"/>`
126  const tag = row.isThis ? 'this one' : ''
127  const tagW = tag ? textWidth(tag, SPEC.fsMeta) + 6 : 0
128  const title = fit(row.title, avail - tagW, SPEC.fsTitle, true)
129  body += text(TEXT_X, baseline(y, SPEC.fsTitle), title, SPEC.fsTitle, p.text, { bold: true })
130  if (tag) body += text(TEXT_X + textWidth(title, SPEC.fsTitle, true) + 6, baseline(y, SPEC.fsTitle), tag, SPEC.fsMeta, p.dim)
131  y += lineH(SPEC.fsTitle) + SPEC.gap
132  if (row.what) {
133    body += text(TEXT_X, baseline(y, SPEC.fsBody), fit(row.what, avail, SPEC.fsBody), SPEC.fsBody, p.text)
134    y += lineH(SPEC.fsBody) + SPEC.gap
135  }
136  const parts = metaParts(row, now)
137  let x = TEXT_X
138  const by = baseline(y, SPEC.fsMeta)
139  parts.forEach((part, i) => {
140    if (i > 0) {
141      body += text(x + 8, by, '|', SPEC.fsMeta, p.rule)
142      x += 8 + textWidth('|', SPEC.fsMeta) + 8
143    }
144    if (part.bar !== null) {
145      body += bar(x, y + (lineH(SPEC.fsMeta) - SPEC.barH) / 2, SPEC.cacheBarW, part.bar, part.tone === 'amber' ? p.amber : p.yourturn, p.track)
146      x += SPEC.cacheBarW + 8
147    }
148    const t = fit(part.text, Math.max(0, TEXT_X + avail - x), SPEC.fsMeta)
149    body += text(x, by, t, SPEC.fsMeta, color(theme, part.tone))
150    x += textWidth(t, SPEC.fsMeta)
151  })
152  y += lineH(SPEC.fsMeta)
153  if (o.note) {
154    y += SPEC.gap
155    for (const l of wrap(o.note.text, avail, SPEC.fsMeta, 2)) {
156      body += text(TEXT_X, baseline(y, SPEC.fsMeta), l, SPEC.fsMeta, color(theme, o.note.tone))
157      y += lineH(SPEC.fsMeta)
158    }
159  }
160  y += SPEC.padY
161  const alt = [GROUP_LABEL[row.group] + '.', row.title + (row.isThis ? ' (this one).' : '.'), row.what ?? '', parts.map(x => x.text).join('. ') + '.', o.note?.text ?? '']
162    .filter(Boolean)
163    .join(' ')
164  return { svg: svg(width, y, body), alt, height: y }
165}
166
167// Figma 1:1194 (Section Label): 20 top, 4 bottom, a 1px rule along the bottom (drawn here, or beside a button by drawRule).
168const LABEL_TOP = 20
169
170export function drawGroup(o: { label: string; n: number; width: number; theme: Theme; rule: boolean }): Pic {
171  const p = PALETTE[o.theme]
172  const h = LABEL_TOP + lineH(SPEC.fsMeta) + 4 + (o.rule ? SPEC.rule : 0)
173  const by = baseline(LABEL_TOP, SPEC.fsMeta)
174  const body = text(SPEC.padX, by, o.label, SPEC.fsMeta, p.dim, { bold: true }) +
175    text(SPEC.padX + textWidth(o.label, SPEC.fsMeta, true) + 4, by, String(o.n), SPEC.fsMeta, p.dim) +
176    (o.rule ? ruleLine(o.width, r1(h - SPEC.rule), o.theme) : '')
177  return { svg: svg(o.width, h, body), alt: `${o.label}, ${o.n} session${o.n === 1 ? '' : 's'}`, height: h }
178}
179
180// Figma 1:721: the counts first (13px, numbers semibold), 12 below them, then each limit 8 apart, 24 at the bottom.
181export function drawTop(o: { limits: Limit[]; counts: { working: number; yourturn: number; agents: number }; now: number; width: number; theme: Theme; readAt?: number | null }): Pic {
182  const p = PALETTE[o.theme]
183  let y = SPEC.padY
184  let body = ''
185  const parts: [number, string][] = [[o.counts.working, 'working'], [o.counts.yourturn, 'your turn'], [o.counts.agents, `agent${o.counts.agents === 1 ? '' : 's'} running`]]
186  const cy = r1(y + (16 - SPEC.fsCount) / 2 + SPEC.fsCount * 0.8)
187  let x = SPEC.padX
188  parts.forEach(([n, label], i) => {
189    if (i > 0) {
190      body += text(x, cy, '|', SPEC.fsCount, p.rule)
191      x += textWidth('|', SPEC.fsCount) + 10
192    }
193    body += text(x, cy, String(n), SPEC.fsCount, p.text, { bold: true })
194    x += textWidth(String(n), SPEC.fsCount, true) + 6
195    body += text(x, cy, label, SPEC.fsCount, p.text)
196    x += textWidth(label, SPEC.fsCount) + 10
197  })
198  y += 16 + (o.limits.length ? 12 : 0)
199  const sum = parts.map(([n, label]) => `${n} ${label}`).join(', ')
200  const alt: string[] = [sum + '.']
201  for (const l of o.limits) {
202    y += 8
203    const left = `${l.label} ${l.percent}%`
204    const resets = resetsIn(l.resetsAt, o.now)
205    const right = resets && o.readAt != null ? `${resets}, read ${fmtDur(o.now - o.readAt)} ago` : resets
206    body += text(SPEC.padX, baseline(y, SPEC.fsMeta), left, SPEC.fsMeta, p.text)
207    if (right) body += text(o.width - SPEC.padX, baseline(y, SPEC.fsMeta), right, SPEC.fsMeta, p.dim, { anchor: 'end' })
208    y += lineH(SPEC.fsMeta) + 3
209    body += bar(SPEC.padX, y, o.width - 2 * SPEC.padX, l.percent / 100, p.dim, p.track)
210    y += SPEC.barH
211    alt.push(right ? `${left}, ${right}.` : `${left}.`)
212  }
213  y += 24
214  return { svg: svg(o.width, y, body), alt: alt.join(' '), height: y }
215}
216
217export type DetailItem = { k: string; lines: string[]; bar: { pct: number; tone: Tone } | null }
218
219export function detailItems(row: Row, git: GitFact | null, now: number): DetailItem[] {
220  const items: DetailItem[] = []
221  items.push({ k: 'Last update', lines: row.lastTwo.length ? row.lastTwo : ['unknown'], bar: null })
222  items.push({ k: 'Context', lines: [contextLine(row.tokens, row.window)], bar: row.tokens !== null && row.window ? { pct: row.tokens / row.window, tone: 'dim' } : null })
223  const left = cacheLeft(row, now)
224  if (left !== null && left > 0) {
225    items.push({ k: 'Cache', lines: [left < MIN ? 'Under 1 min left' : `${Math.floor(left / MIN)} min left`], bar: { pct: left / CACHE_TTL_MS, tone: left < LOW_CACHE_MS ? 'amber' : 'ok' } })
226  } else {
227    items.push({ k: 'Cache', lines: ['Cold. The next turn rewrites the context.'], bar: null })
228  }
229  const agents = row.agents.map(a => [a.name, a.model, a.startedAt === null ? null : fmtDur(now - a.startedAt)].filter(Boolean).join(', '))
230  const done = row.agentsDoneHour
231  if (agents.length || done) agents.push(`${done} agent${done === 1 ? '' : 's'} done in the last hour`)
232  items.push({ k: 'Agents', lines: agents.length ? agents : ['No agents in the last hour'], bar: null })
233  if (row.ticket) items.push({ k: 'Ticket', lines: [row.ticket], bar: null })
234  if (git?.text) items.push({ k: 'Git', lines: [git.text], bar: null })
235  if (row.test) {
236    const t = row.test
237    items.push({ k: 'Tests', lines: [t.kind === 'running' ? 'Testing' : t.kind === 'failed' ? 'Tests failed' : `Tested ${fmtDur(now - t.at)} ago`], bar: null })
238  }
239  return items
240}
241
242export function drawDetails(o: { items: DetailItem[]; width: number; theme: Theme }): Pic {
243  const p = PALETTE[o.theme]
244  const kx = TEXT_X
245  const vx = TEXT_X + 76 + 8
246  const vw = o.width - vx - SPEC.padX
247  let y = 0
248  let body = ''
249  for (const it of o.items) {
250    body += text(kx, baseline(y, SPEC.fsBody), it.k, SPEC.fsMeta, p.dim)
251    const lines = it.lines.flatMap(l => wrap(l, vw, SPEC.fsBody, it.k === 'Last update' ? 2 : 1))
252    for (const l of lines) {
253      body += text(vx, baseline(y, SPEC.fsBody), l, SPEC.fsBody, p.text)
254      y += lineH(SPEC.fsBody)
255    }
256    if (it.bar) {
257      body += bar(vx, y + 3, vw, it.bar.pct, color(o.theme, it.bar.tone), p.track)
258      y += 3 + SPEC.barH
259    }
260    y += 6
261  }
262  y += SPEC.padY - 6
263  const alt = o.items.map(i => `${i.k}: ${i.lines.join('; ')}.`).join(' ')
264  return { svg: svg(o.width, y, body), alt, height: y }
265}
266
267export function drawFooter(o: { headless: number; agoMs: number; width: number; theme: Theme }): Pic {
268  const p = PALETTE[o.theme]
269  const left = `${o.headless} background run${o.headless === 1 ? '' : 's'} in the last 6 hours`
270  const right = `Updated ${Math.max(0, Math.round(o.agoMs / 1000))}s ago`
271  const h = 8 + lineH(SPEC.fsMeta) + 8
272  const by = baseline(8, SPEC.fsMeta)
273  const body = ruleLine(o.width, 0, o.theme) + text(SPEC.padX, by, fit(left, o.width - 2 * SPEC.padX - textWidth(right, SPEC.fsMeta) - 8, SPEC.fsMeta), SPEC.fsMeta, p.dim) +
274    text(o.width - SPEC.padX, by, right, SPEC.fsMeta, p.dim, { anchor: 'end' })
275  return { svg: svg(o.width, h, body), alt: `${left}. ${right}.`, height: h }
276}
277
278export function textBar(pct: number, cells: number): string {
279  const n = Math.max(0, Math.min(cells, Math.round(pct * cells)))
280  return '[' + '#'.repeat(n) + '.'.repeat(cells - n) + ']'
281}
282
hooks/format.ts 44 lines
1// Pure: the words and numbers the board shows.
2const MIN = 60_000
3
4export function fmtTokens(n: number): string {
5  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
6  if (n >= 1000) return `${Math.round(n / 1000)}k`
7  return String(n)
8}
9
10export function fmtDur(ms: number): string {
11  if (ms < MIN) return '<1m'
12  const m = Math.floor(ms / MIN)
13  if (m < 60) return `${m}m`
14  const h = Math.floor(m / 60)
15  if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`
16  if (h < 48) return `${h}h`
17  return `${Math.floor(h / 24)}d`
18}
19
20export function cacheLabel(leftMs: number | null): string | null {
21  if (leftMs === null || leftMs <= 0) return null
22  return leftMs < MIN ? 'Cache <1m' : `Cache ${Math.floor(leftMs / MIN)}m`
23}
24
25export const percentOf = (tokens: number, window: number) => Math.round((tokens / window) * 100)
26
27export function contextLine(tokens: number | null, window: number | null): string {
28  if (tokens === null) return 'unknown'
29  if (window === null) return `${fmtTokens(tokens)} tokens`
30  return `${fmtTokens(tokens)} of ${fmtTokens(window)} tokens (${percentOf(tokens, window)}%)`
31}
32
33// The short form on the meta line: a percent when the window is known, else the count.
34export function metaContext(tokens: number | null, window: number | null): string | null {
35  if (tokens === null) return null
36  return window === null ? `${fmtTokens(tokens)} tokens` : `${percentOf(tokens, window)}%`
37}
38
39export function resetsIn(iso: string | null, now: number): string | null {
40  if (!iso) return null
41  const at = Date.parse(iso)
42  return Number.isNaN(at) ? null : `resets in ${fmtDur(Math.max(0, at - now))}`
43}
44
hooks/compact.ts 118 lines
1// Pure: the compact protocol lines and the state machine one row's compact moves through.
2import type { CompactState, Row } from '../types'
3import { CACHE_TTL_MS } from './board'
4import type { Note } from './draw'
5import { fmtTokens } from './format'
6
7const SENT_MS = 30_000
8const RUN_MS = 10 * 60_000
9const DONE_MS = 10 * 60_000
10const LOW_MS = 10 * 60_000
11
12export type Line =
13  | { kind: 'request'; id: string; from: string }
14  | { kind: 'started'; id: string }
15  | { kind: 'queued'; id: string }
16  | { kind: 'result'; id: string; ok: true; before: number | null; after: number | null }
17  | { kind: 'result'; id: string; ok: false; reason: string }
18
19const ID = '[0-9a-f]{12}'
20const SESSION = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
21const N = '(\\d+|unknown)'
22const REQUEST = new RegExp(`^duckboard compact-request (${ID}) from (${SESSION})$`)
23const STEP = new RegExp(`^duckboard compact-(started|queued) (${ID})$`)
24const OK = new RegExp(`^duckboard compact-result (${ID}) ok ${N} ${N}$`)
25const FAIL = new RegExp(`^duckboard compact-result (${ID}) fail ([^\\n]+)$`)
26
27const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
28const num = (s: string | undefined) => (s === undefined || s === 'unknown' ? null : Number(s))
29
30export function formatLine(l: Line): string {
31  if (l.kind === 'request') return `duckboard compact-request ${l.id} from ${l.from}`
32  if (l.kind === 'started' || l.kind === 'queued') return `duckboard compact-${l.kind} ${l.id}`
33  if (l.ok) return `duckboard compact-result ${l.id} ok ${l.before ?? 'unknown'} ${l.after ?? 'unknown'}`
34  return `duckboard compact-result ${l.id} fail ${oneLine(l.reason) || 'no reason given'}`
35}
36
37export function parseLine(text: string): Line | null {
38  let m = REQUEST.exec(text)
39  if (m) return { kind: 'request', id: m[1]!, from: m[2]! }
40  m = STEP.exec(text)
41  if (m) return m[1] === 'started' ? { kind: 'started', id: m[2]! } : { kind: 'queued', id: m[2]! }
42  m = OK.exec(text)
43  if (m) return { kind: 'result', id: m[1]!, ok: true, before: num(m[2]), after: num(m[3]) }
44  m = FAIL.exec(text)
45  if (m) return { kind: 'result', id: m[1]!, ok: false, reason: m[2]! }
46  return null
47}
48
49// A peer's message may come inside one envelope tag; the protocol line must be all that is inside.
50export function unwrap(text: string): string {
51  const t = text.trim()
52  const m = /^<([a-zA-Z][\w-]*)(?:\s[^>]*)?>([\s\S]*)<\/\1>$/.exec(t)
53  return m ? m[2]!.trim() : t
54}
55
56export function newId(bytes: Uint8Array): string {
57  return [...bytes.slice(0, 6)].map(b => b.toString(16).padStart(2, '0')).join('')
58}
59
60export const canCompact = (row: Row) => row.isLive && row.isIdle && row.group === 'yourturn'
61
62export function request(prev: CompactState | null, id: string, now: number): CompactState {
63  const ids = prev && (prev.kind === 'noanswer' || prev.kind === 'failed') ? [...new Set([...prev.ids, id])] : [id]
64  return { kind: 'sent', ids, at: now, reason: null, before: null, after: null }
65}
66
67export function failed(prev: CompactState | null, reason: string, now: number): CompactState {
68  return { kind: 'failed', ids: prev?.ids ?? [], at: now, reason, before: null, after: null }
69}
70
71export function onReply(s: CompactState | null, l: Line, now: number): CompactState | null {
72  if (!s || l.kind === 'request' || !s.ids.includes(l.id)) return s
73  if (l.kind === 'started') return { ...s, kind: 'compacting', at: now }
74  if (l.kind === 'queued') return { ...s, kind: 'busy', at: now }
75  if (l.ok) return { kind: 'done', ids: [], at: now, reason: null, before: l.before, after: l.after }
76  return { ...s, kind: 'failed', at: now, reason: l.reason }
77}
78
79export function tick(s: CompactState | null, now: number): CompactState | null {
80  if (!s) return s
81  if (s.kind === 'sent' && now - s.at > SENT_MS) return { ...s, kind: 'noanswer', reason: 'No answer in 30 seconds.' }
82  if ((s.kind === 'compacting' || s.kind === 'busy') && now - s.at > RUN_MS) return { ...s, kind: 'noanswer', reason: 'No result in 10 minutes.' }
83  if (s.kind === 'done' && now - s.at > DONE_MS) return null
84  return s
85}
86
87export function compactNote(s: CompactState | null, row: Row, now: number): Note | null {
88  if (!s) {
89    const left = row.lastReplyAt === null ? null : CACHE_TTL_MS - (now - row.lastReplyAt)
90    if (canCompact(row) && left !== null && left > 0 && left < LOW_MS) return { text: `Cache ends in ${Math.max(1, Math.floor(left / 60_000))} min`, tone: 'amber' }
91    return null
92  }
93  switch (s.kind) {
94    case 'sent':
95      return { text: 'Request sent. Waiting for the session to start it.', tone: 'dim' }
96    case 'compacting':
97      return { text: 'Compacting. Do not send this session a message.', tone: 'dim' }
98    case 'busy':
99      return { text: 'Busy. It compacts when this turn ends.', tone: 'dim' }
100    case 'done': {
101      // The after count: the engine's, else the next scan's reading once the session wrote again.
102      const after = s.after ?? (row.lastReplyAt !== null && row.lastReplyAt > s.at ? row.tokens : null)
103      const before = s.before === null ? 'unknown' : fmtTokens(s.before)
104      return { text: `Compacted just now: ${before} to ${after === null ? 'unknown' : fmtTokens(after)} tokens. Cache is fresh.`, tone: 'ok' }
105    }
106    case 'noanswer':
107      return { text: s.reason ?? 'No answer.', tone: 'red' }
108    case 'failed':
109      return { text: `Failed: ${s.reason ?? 'no reason given'}`, tone: 'red' }
110  }
111}
112
113export function showButton(row: Row, s: CompactState | null): 'Compact' | 'Retry' | null {
114  if (!canCompact(row)) return null
115  if (!s || s.kind === 'done') return s ? null : 'Compact'
116  return s.kind === 'noanswer' || s.kind === 'failed' ? 'Retry' : null
117}
118
hooks/scan.ts 238 lines
1// Engine IO: reads the registry, transcripts, agents, usage and git through `$`.
2import type { EngineInterface } from 'claude-code'
3
4import type { Agent, Board, GitFact } from '../types'
5import { buildRows, liveSessions, parsePs, pickFiles, pickLimits, toAgent, toLimits, type AgentMeta, type RegEntry, type TFile } from './board'
6import { parseTranscript, testFromOutput, type Facts } from './transcript'
7
8const WHOLE_MAX = 3.5 * 1024 * 1024
9const MIN = 60_000
10const HOUR = 60 * MIN
11
12export function log($: EngineInterface, what: string, err: unknown) {
13  $.ui.log(`duckboard: ${what}: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
14}
15
16// Module cache by file time; a hot reload empties it and the next scan fills it again.
17const factsCache = new Map<string, { mtimeMs: number; size: number; facts: Facts }>()
18const metaCache = new Map<string, AgentMeta>()
19
20export async function home($: EngineInterface): Promise<{ dir: string; isWindows: boolean }> {
21  const h = await $.env.get('HOME')
22  if (h) return { dir: h, isWindows: false }
23  const u = await $.env.get('USERPROFILE')
24  if (u) return { dir: u, isWindows: true }
25  throw new Error('neither HOME nor USERPROFILE is set')
26}
27
28async function readFacts($: EngineInterface, f: { path: string; mtimeMs: number; size: number }): Promise<Facts | null> {
29  const hit = factsCache.get(f.path)
30  if (hit && hit.mtimeMs === f.mtimeMs && hit.size === f.size) return hit.facts
31  try {
32    let text: string
33    if (f.size <= WHOLE_MAX) {
34      text = String(await $.fs.read(f.path))
35    } else {
36      // A live file grows between listing and reading, and $.fs.read rejects over 4 MiB: read its tail.
37      const r = await $.process.run(['tail', '-n', '600', f.path])
38      if (r.exitCode !== 0) throw new Error(`tail exited ${r.exitCode}: ${r.stderr.trim()}`)
39      text = r.stdout
40    }
41    const facts = parseTranscript(text)
42    factsCache.set(f.path, { mtimeMs: f.mtimeMs, size: f.size, facts })
43    return facts
44  } catch (err) {
45    log($, `read ${f.path}`, err)
46    return null
47  }
48}
49
50async function readRegistry($: EngineInterface, dir: string): Promise<RegEntry[]> {
51  const out: RegEntry[] = []
52  let names: string[] = []
53  try {
54    names = (await $.fs.list(dir)).filter(e => e.kind === 'file' && /^\d+\.json$/.test(e.name)).map(e => e.name)
55  } catch (err) {
56    log($, `list ${dir}`, err)
57    return out
58  }
59  for (const n of names) {
60    try {
61      const d = JSON.parse(String(await $.fs.read(`${dir}/${n}`)))
62      if (typeof d.pid !== 'number' || typeof d.sessionId !== 'string') continue
63      const s = (k: string) => (typeof d[k] === 'string' ? (d[k] as string) : null)
64      out.push({
65        pid: d.pid, sessionId: d.sessionId, cwd: s('cwd'), kind: s('kind'), entrypoint: s('entrypoint'), name: s('name'),
66        nameSource: s('nameSource'), status: s('status'), updatedAt: Number(d.updatedAt) || 0, statusUpdatedAt: Number(d.statusUpdatedAt) || 0, procStart: s('procStart'),
67      })
68    } catch (err) {
69      // An entry being rewritten reads as broken JSON; the next scan reads it again.
70      log($, `registry ${n}`, err)
71    }
72  }
73  return out
74}
75
76async function livePids($: EngineInterface, pids: number[], isWindows: boolean): Promise<Map<number, string>> {
77  if (pids.length === 0) return new Map()
78  if (isWindows) {
79    // shortcut: tasklist gives no start time, so a reused pid on Windows still reads as live.
80    const r = await $.process.run(['tasklist', '/FO', 'CSV', '/NH'])
81    const alive = new Set([...r.stdout.matchAll(/^"[^"]*","(\d+)"/gm)].map(m => Number(m[1])))
82    return new Map(pids.filter(p => alive.has(p)).map(p => [p, '']))
83  }
84  // One ps call; exit 1 just means some pid is gone.
85  const r = await $.process.run(['ps', '-o', 'pid=,lstart=', '-p', pids.join(',')], { env: { TZ: 'UTC' } })
86  return parsePs(r.stdout)
87}
88
89async function listTranscripts($: EngineInterface, projects: string): Promise<(TFile & { dir: string })[]> {
90  const out: (TFile & { dir: string })[] = []
91  let dirs: string[] = []
92  try {
93    dirs = (await $.fs.list(projects)).filter(e => e.kind === 'dir').map(e => e.name)
94  } catch (err) {
95    log($, `list ${projects}`, err)
96    return out
97  }
98  for (const d of dirs) {
99    const dir = `${projects}/${d}`
100    try {
101      for (const e of await $.fs.list(dir)) {
102        if (e.kind === 'file' && e.name.endsWith('.jsonl')) out.push({ sessionId: e.name.slice(0, -6), path: `${dir}/${e.name}`, mtimeMs: e.mtimeMs, size: e.size, dir })
103      }
104    } catch (err) {
105      log($, `list ${dir}`, err)
106    }
107  }
108  return out
109}
110
111async function readAgents($: EngineInterface, sessionDir: string, now: number): Promise<Agent[]> {
112  const dir = `${sessionDir}/subagents`
113  let entries: Awaited<ReturnType<EngineInterface['fs']['list']>> = []
114  try {
115    if (!(await $.fs.exists(dir))) return []
116    entries = await $.fs.list(dir)
117  } catch (err) {
118    log($, `list ${dir}`, err)
119    return []
120  }
121  const agents: Agent[] = []
122  for (const e of entries) {
123    const m = /^agent-(.+)\.jsonl$/.exec(e.name)
124    if (!m?.[1] || e.kind !== 'file' || now - e.mtimeMs > HOUR) continue
125    const metaPath = `${dir}/agent-${m[1]}.meta.json`
126    if (!metaCache.has(metaPath)) {
127      try {
128        metaCache.set(metaPath, (await $.fs.exists(metaPath)) ? JSON.parse(String(await $.fs.read(metaPath))) : null)
129      } catch (err) {
130        log($, `meta ${metaPath}`, err)
131        metaCache.set(metaPath, null)
132      }
133    }
134    const facts = await readFacts($, { path: `${dir}/${e.name}`, mtimeMs: e.mtimeMs, size: e.size })
135    agents.push(toAgent({ id: m[1], mtimeMs: e.mtimeMs, meta: metaCache.get(metaPath) ?? null, facts }, now))
136  }
137  return agents
138}
139
140// A background test run: its output file decides passed or failed once it stops growing.
141async function settleTest($: EngineInterface, f: Facts, now: number): Promise<void> {
142  if (f.test?.kind !== 'running' || !f.testOutputPath) return
143  try {
144    if (!(await $.fs.exists(f.testOutputPath))) return
145    const st = await $.fs.stat(f.testOutputPath)
146    const r = await $.process.run(['tail', '-n', '200', f.testOutputPath])
147    const verdict = testFromOutput(r.stdout)
148    if (verdict) f.test = { kind: verdict, at: st.mtimeMs }
149    else if (now - st.mtimeMs > MIN) f.test = null
150  } catch (err) {
151    log($, `test output ${f.testOutputPath}`, err)
152  }
153}
154
155export async function scan($: EngineInterface, opts: { addressName: string; commits: Record<string, string | null> }): Promise<Board> {
156  const now = await $.clock.now()
157  const { dir: h, isWindows } = await home($)
158  const reg = await readRegistry($, `${h}/.claude/sessions`)
159  let ps = new Map<number, string>()
160  try {
161    ps = await livePids($, [...new Set(reg.map(r => r.pid))], isWindows)
162  } catch (err) {
163    log($, 'process list', err)
164  }
165  const live = liveSessions(reg, ps)
166  const files = await listTranscripts($, `${h}/.claude/projects`)
167  const { recent, old } = pickFiles(files, new Set(live.keys()), now)
168  const sessions: { facts: Facts; agents: Agent[]; commit?: string | null }[] = []
169  for (const f of recent) {
170    const facts = await readFacts($, f)
171    if (!facts) continue
172    facts.sessionId ??= f.sessionId
173    await settleTest($, facts, now)
174    const agents = await readAgents($, `${f.dir}/${f.sessionId}`, now)
175    sessions.push({ facts, agents, commit: opts.commits[f.sessionId] ?? null })
176  }
177  // Old ones: read once, cached by file time, never scanned for agents.
178  for (const f of old) {
179    const facts = await readFacts($, f)
180    if (!facts) continue
181    facts.sessionId ??= f.sessionId
182    sessions.push({ facts, agents: [] })
183  }
184  let thisUsage: { tokens: number | null; window: number | null } | null = null
185  let limits: Board['limits'] = []
186  let limitsReadAt: number | null = null
187  try {
188    const u = await $.session.usage()
189    thisUsage = { tokens: u.context.tokens ?? null, window: u.context.window || null }
190    limits = toLimits(u.rateLimits)
191  } catch (err) {
192    log($, 'session usage', err)
193  }
194  // A session that has sent no request yet has no reading: show the last real one any session saved.
195  try {
196    const picked = pickLimits(limits, await $.store.get('limits'), now)
197    limits = picked.limits
198    limitsReadAt = picked.readAt
199    if (picked.save) await $.store.set('limits', picked.save)
200  } catch (err) {
201    log($, 'saved limits', err)
202  }
203  const thisId = await $.session.id()
204  const { rows, headless6h } = buildRows({ now, thisId, live, addressName: opts.addressName, thisUsage, sessions })
205  return { scannedAt: now, rows, headless6h, limits, limitsReadAt, error: null }
206}
207
208// Git facts for one folder: "Pushed" or "N not pushed", and the newest commit subject.
209export async function gitFact($: EngineInterface, cwd: string, now: number): Promise<{ fact: GitFact; commit: string | null }> {
210  let text: string | null = null
211  let commit: string | null = null
212  try {
213    const r = await $.process.run(['git', '-C', cwd, 'rev-list', '--count', '@{u}..HEAD'], { timeoutMs: 10_000 })
214    if (r.exitCode === 0) {
215      const n = Number(r.stdout.trim())
216      text = n === 0 ? 'Pushed' : `${n} not pushed`
217    } else {
218      log($, `git rev-list in ${cwd}`, r.stderr.trim() || `exit ${r.exitCode}`)
219    }
220    const c = await $.process.run(['git', '-C', cwd, 'log', '-1', '--format=%s'], { timeoutMs: 10_000 })
221    if (c.exitCode === 0) commit = c.stdout.trim() || null
222  } catch (err) {
223    log($, `git in ${cwd}`, err)
224  }
225  return { fact: { text, at: now }, commit }
226}
227
228export async function readTheme($: EngineInterface): Promise<'light' | 'dark'> {
229  try {
230    const row = (await $.config.list()).find(r => r.key === 'theme')
231    // shortcut: "auto" (follow the system) reads as light; the module cannot see the system theme.
232    return typeof row?.value === 'string' && row.value.includes('dark') ? 'dark' : 'light'
233  } catch (err) {
234    log($, 'theme', err)
235    return 'light'
236  }
237}
238
hooks/board.ts 165 lines
1// Pure: which sessions are on the board, in which group, with which facts.
2import type { Agent, Group, Limit, Row } from '../types'
3import { findTicket, firstPlainLine, plainLines, type Facts } from './transcript'
4
5// Measured on this machine (2026-10-23): every cache write is the 1-hour tier
6// (ephemeral_5m always 0); a 58.1 min gap still read 272,686 cached tokens, a
7// 120.4 min gap rewrote 186,839. So the cache lifetime is 60 minutes.
8export const CACHE_TTL_MS = 60 * 60_000
9
10const MIN = 60_000
11const HOUR = 60 * MIN
12const RECENT_MS = 6 * HOUR
13const OLD_MS = 7 * 24 * HOUR
14
15export type RegEntry = {
16  pid: number
17  sessionId: string
18  cwd: string | null
19  kind: string | null
20  entrypoint: string | null
21  name: string | null
22  nameSource: string | null
23  status: string | null
24  updatedAt: number
25  statusUpdatedAt: number
26  procStart: string | null
27}
28
29export type TFile = { sessionId: string; path: string; mtimeMs: number; size: number }
30
31// `ps -o pid=,lstart=` lines, run with TZ=UTC to match the registry's procStart.
32export function parsePs(stdout: string): Map<number, string> {
33  const out = new Map<number, string>()
34  for (const l of stdout.split('\n')) {
35    const m = /^\s*(\d+)\s+(.+?)\s*$/.exec(l)
36    if (m?.[1] && m[2]) out.set(Number(m[1]), m[2])
37  }
38  return out
39}
40
41const sameStart = (a: string, b: string) => a.replace(/\s+/g, ' ') === b.replace(/\s+/g, ' ')
42
43// Live = the pid runs and is the same process that wrote the entry; newest entry per session wins.
44export function liveSessions(entries: RegEntry[], ps: Map<number, string>): Map<string, RegEntry> {
45  const out = new Map<string, RegEntry>()
46  for (const r of entries) {
47    const start = ps.get(r.pid)
48    if (start === undefined) continue
49    if (r.procStart && !sameStart(r.procStart, start)) continue
50    const had = out.get(r.sessionId)
51    if (!had || r.updatedAt > had.updatedAt) out.set(r.sessionId, r)
52  }
53  return out
54}
55
56export function pickFiles<F extends TFile>(files: F[], live: Set<string>, now: number): { recent: F[]; old: F[] } {
57  const byNew = [...files].sort((a, b) => b.mtimeMs - a.mtimeMs)
58  const liveFiles = byNew.filter(f => live.has(f.sessionId))
59  const rest = byNew.filter(f => !live.has(f.sessionId))
60  const recent = rest.filter(f => now - f.mtimeMs <= RECENT_MS).slice(0, 40)
61  const old = rest.filter(f => now - f.mtimeMs > RECENT_MS && now - f.mtimeMs <= OLD_MS).slice(0, 60)
62  return { recent: [...liveFiles, ...recent], old }
63}
64
65// shortcut: headless is read from the registry kind or an `sdk*` entrypoint; upgrade if the engine names more kinds.
66export const isHeadless = (f: Facts, r: RegEntry | undefined) =>
67  (r?.kind != null && r.kind !== 'interactive') || (f.entrypoint?.startsWith('sdk') ?? false)
68
69export type AgentMeta = { description?: string; agentType?: string; model?: string } | null
70
71export function toAgent(a: { id: string; mtimeMs: number; meta: AgentMeta; facts: Facts | null }, now: number): Agent {
72  const recentWrite = now - a.mtimeMs < MIN
73  const openCall = a.facts?.openToolAt != null && now - a.facts.openToolAt < HOUR
74  return {
75    id: a.id,
76    name: a.meta?.description || a.meta?.agentType || a.id,
77    model: a.meta?.model ?? a.facts?.model ?? null,
78    startedAt: a.facts?.firstAt ?? null,
79    isRunning: recentWrite || openCall,
80    lastWriteAt: a.mtimeMs,
81  }
82}
83
84const LIMIT_LABEL: Record<string, string> = { five_hour: '5-hour limit', seven_day: 'Weekly' }
85
86export function toLimits(rl: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Limit[] {
87  return rl.map(l => ({ label: LIMIT_LABEL[l.kind] ?? l.kind, percent: l.percentUsed, resetsAt: l.resetsAt ?? null }))
88}
89
90export type LimitsReading = { limits: Limit[]; at: number }
91
92// This session's reading when it has one (and save it); else the last saved one, each window only until it resets.
93export function pickLimits(fresh: Limit[], stored: unknown, now: number): { limits: Limit[]; readAt: number | null; save: LimitsReading | null } {
94  if (fresh.length) return { limits: fresh, readAt: null, save: { limits: fresh, at: now } }
95  const s = stored as Partial<LimitsReading> | null
96  if (!s || typeof s !== 'object' || !Array.isArray(s.limits) || typeof s.at !== 'number') return { limits: [], readAt: null, save: null }
97  const live = s.limits.filter(
98    (l): l is Limit => typeof l?.label === 'string' && typeof l.percent === 'number' && typeof l.resetsAt === 'string' && Date.parse(l.resetsAt) > now,
99  )
100  return { limits: live, readAt: live.length ? s.at : null, save: null }
101}
102
103const basename = (p: string | null) => (p ? p.split(/[\\/]/).filter(Boolean).pop() ?? null : null)
104
105export type BuildInput = {
106  now: number
107  thisId: string
108  live: Map<string, RegEntry>
109  addressName: string
110  thisUsage: { tokens: number | null; window: number | null } | null
111  sessions: { facts: Facts; agents: Agent[]; commit?: string | null }[]
112}
113
114const ORDER: Record<Group, number> = { working: 0, yourturn: 1, cold: 2 }
115
116export function buildRows(input: BuildInput): { rows: Row[]; headless6h: number } {
117  const { now, thisId, live, addressName, thisUsage } = input
118  const rows: Row[] = []
119  let headless6h = 0
120  for (const { facts: f, agents, commit } of input.sessions) {
121    const sid = f.sessionId
122    if (!sid) continue
123    const r = live.get(sid)
124    if (isHeadless(f, r)) {
125      if (f.lastAt !== null && now - f.lastAt <= RECENT_MS) headless6h++
126      continue
127    }
128    const isThis = sid === thisId
129    const running = agents.filter(a => a.isRunning)
130    const cacheLeft = f.lastReplyAt === null ? null : CACHE_TTL_MS - (now - f.lastReplyAt)
131    const isWarm = cacheLeft !== null && cacheLeft > 0
132    const isIdle = r?.status !== 'busy'
133    // Idle, warm but the last row not Claude's (an interrupted turn) also waits on the person: Your turn.
134    const group: Group = !r ? 'cold' : !isIdle || running.length > 0 ? 'working' : isWarm ? 'yourturn' : 'cold'
135    const tokens = (isThis ? thisUsage?.tokens : null) ?? f.tokens
136    const window =
137      (isThis ? thisUsage?.window : null) ??
138      (f.model?.endsWith('[1m]') || (tokens ?? 0) > 200_000 ? 1_000_000 : null)
139    const text = f.lastText ?? ''
140    rows.push({
141      sessionId: sid,
142      title: (r?.nameSource === 'user' && r.name) || f.title || basename(f.cwd ?? r?.cwd ?? null) || 'unknown',
143      isThis,
144      group,
145      isLive: !!r,
146      isIdle,
147      cwd: f.cwd ?? r?.cwd ?? null,
148      branch: f.branch,
149      what: firstPlainLine(text, addressName),
150      lastTwo: plainLines(text, addressName, 2),
151      tokens,
152      window,
153      lastReplyAt: f.lastReplyAt,
154      idleSince: r?.status === 'idle' && r.statusUpdatedAt ? r.statusUpdatedAt : f.lastAt,
155      agents: running.slice(0, 5),
156      agentsRunning: running.length,
157      agentsDoneHour: agents.filter(a => !a.isRunning && now - a.lastWriteAt < HOUR).length,
158      ticket: findTicket(f.branch, commit ?? null, f.prompts),
159      test: f.test,
160    })
161  }
162  rows.sort((a, b) => ORDER[a.group] - ORDER[b.group] || (b.idleSince ?? 0) - (a.idleSince ?? 0))
163  return { rows, headless6h }
164}
165
hooks/transcript.ts 182 lines
1// Pure: reads a session transcript (.jsonl text) into the facts the board needs.
2import type { TestFact } from '../types'
3
4export type Facts = {
5  sessionId: string | null
6  title: string | null
7  cwd: string | null
8  branch: string | null
9  entrypoint: string | null
10  model: string | null
11  tokens: number | null
12  lastReplyAt: number | null
13  firstAt: number | null
14  lastAt: number | null
15  lastText: string | null
16  isLastClaudes: boolean
17  openToolAt: number | null
18  prompts: string[]
19  test: TestFact | null
20  testOutputPath: string | null
21  badLines: number
22}
23
24type Block = { type?: string; text?: string; id?: string; name?: string; input?: Record<string, unknown>; tool_use_id?: string; content?: unknown; is_error?: boolean }
25
26const TEST_CMD = /\b(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?test\b|\bvitest\b|\bjest\b|\bpytest\b|\bgo test\b|\bcargo test\b|\bplugin test\b|\bplaywright test\b/
27const FAILED = /\b[1-9]\d* (?:fail|failed|failing|errors?)\b|^\s*FAIL\b|\bTests? failed\b/im
28const PASSED = /\b\d+ pass(?:ed)?\b|\bAll tests passed\b|^ok\b/im
29const BG_PATH = /Output is being written to: (\S+)/
30
31const blockText = (c: unknown): string =>
32  typeof c === 'string' ? c : Array.isArray(c) ? c.map(b => (typeof b === 'string' ? b : (b as Block)?.text ?? '')).join('\n') : ''
33
34export function testFromOutput(text: string): 'passed' | 'failed' | null {
35  if (FAILED.test(text)) return 'failed'
36  if (PASSED.test(text)) return 'passed'
37  return null
38}
39
40export function parseTranscript(text: string): Facts {
41  const f: Facts = {
42    sessionId: null, title: null, cwd: null, branch: null, entrypoint: null, model: null, tokens: null,
43    lastReplyAt: null, firstAt: null, lastAt: null, lastText: null, isLastClaudes: false, openToolAt: null, prompts: [],
44    test: null, testOutputPath: null, badLines: 0,
45  }
46  const open = new Map<string, number>()
47  let testId: string | null = null
48  for (const raw of text.split('\n')) {
49    if (!raw.trim()) continue
50    let d: Record<string, any>
51    try {
52      d = JSON.parse(raw)
53    } catch {
54      // A tail read starts mid-line, and a live file can end mid-line; count it and go on.
55      f.badLines++
56      continue
57    }
58    if (d.type === 'custom-title' && typeof d.customTitle === 'string') f.title = d.customTitle
59    if (d.isSidechain) continue
60    if (typeof d.sessionId === 'string') f.sessionId ??= d.sessionId
61    if (typeof d.cwd === 'string') f.cwd = d.cwd
62    if (typeof d.gitBranch === 'string') f.branch = d.gitBranch
63    if (typeof d.entrypoint === 'string') f.entrypoint ??= d.entrypoint
64    const at = typeof d.timestamp === 'string' ? Date.parse(d.timestamp) : NaN
65    if (!Number.isNaN(at)) {
66      f.firstAt ??= at
67      f.lastAt = at
68    }
69    const m = d.message
70    if (!m || (d.type !== 'user' && d.type !== 'assistant')) continue
71    const content: Block[] = Array.isArray(m.content) ? m.content : typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : []
72
73    if (d.type === 'assistant') {
74      if (m.model === '<synthetic>') continue
75      const u = m.usage
76      if (u) {
77        f.tokens = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
78        if (!Number.isNaN(at)) f.lastReplyAt = at
79      }
80      if (typeof m.model === 'string') f.model = m.model
81      let hasTool = false
82      for (const b of content) {
83        if (b.type === 'text' && b.text?.trim()) f.lastText = b.text
84        if (b.type === 'tool_use' && b.id) {
85          hasTool = true
86          open.set(b.id, at)
87          const cmd = typeof b.input?.command === 'string' ? b.input.command : ''
88          if (b.name === 'Bash' && TEST_CMD.test(cmd)) {
89            testId = b.id
90            f.test = { kind: 'running', at }
91            f.testOutputPath = null
92          }
93        }
94      }
95      f.isLastClaudes = !hasTool
96      continue
97    }
98
99    // user row: a prompt or tool results
100    const results = content.filter(b => b.type === 'tool_result')
101    for (const r of results) {
102      if (r.tool_use_id) open.delete(r.tool_use_id)
103      if (r.tool_use_id && r.tool_use_id === testId) {
104        const out = blockText(r.content)
105        const bg = BG_PATH.exec(out)
106        if (bg?.[1] && /running in background/i.test(out)) {
107          f.testOutputPath = bg[1]
108        } else {
109          // The output's own verdict first: a later command in the same call can set the exit code.
110          f.test = { kind: testFromOutput(out) ?? (r.is_error ? 'failed' : 'passed'), at }
111        }
112      }
113    }
114    if (results.length === 0 && !d.isMeta) {
115      const t = blockText(m.content).trim()
116      if (t && !t.startsWith('<')) {
117        f.prompts.push(t)
118        if (f.prompts.length > 20) f.prompts.shift()
119        f.isLastClaudes = false
120      }
121    }
122  }
123  for (const at of open.values()) f.openToolAt = Math.max(f.openToolAt ?? 0, at)
124  return f
125}
126
127const stripInline = (s: string) =>
128  s
129    .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
130    .replace(/`([^`]*)`/g, '$1')
131    .replace(/(\*\*|__)(.*?)\1/g, '$2')
132    .replace(/(^|\s)[*_]([^*_]+)[*_](?=\s|$|[.,;:!?])/g, '$1$2')
133    .replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)/, '')
134    .trim()
135
136function dropAddress(s: string, name: string): string {
137  if (!name) return s
138  const lower = s.toLowerCase()
139  const n = name.toLowerCase()
140  if (lower.startsWith(n) && (s[n.length] === ',' || s[n.length] === ':')) return s.slice(n.length + 1).trim()
141  return s
142}
143
144// The plain lines of a reply: fences, table rows and rules skipped, markdown stripped.
145export function plainLines(text: string, addressName: string, max: number): string[] {
146  const out: string[] = []
147  for (const raw of text.split('\n')) {
148    const t = raw.trim()
149    if (!t || t.startsWith('```') || t.startsWith('|') || /^[-*_]{3,}$/.test(t)) continue
150    const s = dropAddress(stripInline(t), addressName)
151    if (s) out.push(s)
152    if (out.length >= max) break
153  }
154  return out
155}
156
157export function firstPlainLine(text: string, addressName: string): string | null {
158  return plainLines(text, addressName, 1)[0] ?? null
159}
160
161const NOT_TICKETS = /^(?:UTF|ISO|SHA|RFC|CVE|HTTP|TLS|SSL|MD|AES|ES|GPT|WCAG|PEP|ECMA|X)-/
162const TICKET = /\b([A-Z][A-Z0-9]{1,9}-\d{1,6})\b/g
163const BRANCH_TICKET = /(?:^|[/_-])([a-z][a-z0-9]{1,9}-\d{1,6})(?=$|[/_-])/i
164
165function firstTicket(s: string): string | null {
166  for (const m of s.matchAll(TICKET)) if (m[1] && !NOT_TICKETS.test(m[1])) return m[1]
167  return null
168}
169
170// Branch name first, then the newest commit subject, then prompts newest first.
171export function findTicket(branch: string | null, commit: string | null, prompts: string[]): string | null {
172  const b = branch ? BRANCH_TICKET.exec(branch)?.[1]?.toUpperCase() : undefined
173  if (b && !NOT_TICKETS.test(b)) return b
174  const c = commit ? firstTicket(commit) : null
175  if (c) return c
176  for (const p of [...prompts].reverse()) {
177    const t = firstTicket(p)
178    if (t) return t
179  }
180  return null
181}
182
types/index.d.ts 78 lines
1export type Group = 'working' | 'yourturn' | 'cold'
2
3export type Agent = {
4  id: string
5  name: string
6  model: string | null
7  startedAt: number | null
8  isRunning: boolean
9  lastWriteAt: number
10}
11
12export type TestFact = { kind: 'passed' | 'failed' | 'running'; at: number }
13
14export type Row = {
15  sessionId: string
16  title: string
17  isThis: boolean
18  group: Group
19  isLive: boolean
20  isIdle: boolean
21  cwd: string | null
22  branch: string | null
23  what: string | null
24  lastTwo: string[]
25  tokens: number | null
26  window: number | null
27  lastReplyAt: number | null
28  idleSince: number | null
29  agents: Agent[]
30  agentsRunning: number
31  agentsDoneHour: number
32  ticket: string | null
33  test: TestFact | null
34}
35
36export type Limit = { label: string; percent: number; resetsAt: string | null }
37
38export type Board = {
39  scannedAt: number
40  rows: Row[]
41  headless6h: number
42  limits: Limit[]
43  // When the limits were read, if not by this session's own last response; null when they are this session's.
44  limitsReadAt: number | null
45  error: string | null
46}
47
48export type GitFact = { text: string | null; at: number }
49
50export type CompactKind = 'sent' | 'compacting' | 'busy' | 'done' | 'noanswer' | 'failed'
51
52export type CompactState = {
53  kind: CompactKind
54  ids: string[]
55  at: number
56  reason: string | null
57  before: number | null
58  after: number | null
59}
60
61// A request this session took and has not answered yet.
62export type Pending = { id: string; from: string | null; at: number }
63
64declare module 'claude-code' {
65  interface PluginState {
66    duckboard: {
67      board: Board | null
68      coldOpen: boolean
69      openRows: string[]
70      compact: Record<string, CompactState>
71      theme: 'light' | 'dark'
72      git: Record<string, GitFact>
73      pending: Pending | null
74      isTurnRunning: boolean
75    }
76  }
77}
78