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

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.
Open the pane with /duckboard. It also opens when a session starts.
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.
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.
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).hooks/register.tsx 405 lines1import { 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}
405hooks/draw.ts 282 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
282hooks/format.ts 44 lines1// 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}
44hooks/compact.ts 118 lines1// 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}
118hooks/scan.ts 238 lines1// 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}
238hooks/board.ts 165 lines1// 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}
165hooks/transcript.ts 182 lines1// 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}
182types/index.d.ts 78 lines1export 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