Per-session checklist (todos + manual steps you tick off) and a live board of every Claude Code session on this machine.

A Claude Code mod for people who run several sessions at once. It keeps a per-session checklist you can tick off, tells Claude what you ticked, and shows a live board of every session on the machine above the prompt.
Requires Claude Code 2.1.287 or later (built and tested on 2.1.304).
A checklist pane for this session. Claude's TodoWrite items appear as rows. Manual steps you have to do yourself join them, from /manual <text> or from any line in Claude's reply that starts with MANUAL:. Each row is a button: click it in the Desktop Code tab or in a fullscreen terminal, or Tab to it and press Enter. /done <id> ticks an item from the keyboard. Done items are hidden; a show N done button at the foot of the list, or /board done, brings them back.
Handled items tick themselves. Claude's own tasks complete through the Task tools. A manual step or loose end is ticked when a later exchange shows the work done, by Claude or by you. Only items that share words with the exchange are put to Haiku, so most turns cost nothing. A task Claude takes on that reads like an open loose end adopts it, so the task finishing finishes the item. Turn the Haiku check off with the autoDone option.
Loose ends get surfaced. The real point of the checklist is the small things said in passing and then forgotten: "you'll need to rotate the key later", "I've skipped the store listing for now", "oh, and remind me to bump the version". After a completed turn whose prompt or reply contains a cue phrase like those, Haiku reads that one exchange and lists the loose ends, which join the checklist tagged (loose end). Nothing already on the list is added twice, and Claude is told they were noted for you, not assigned to it. The first time the mod meets a session it scans the whole conversation for loose ends mentioned before it loaded, and /board scan does so on demand. Turn all of this off with the followUps option.
Claude knows what you ticked. Every change is sent to Claude as one short hidden note, only when something changed, and only the delta. If a later TodoWrite would reopen an item you ticked, the item stays done and the note says so, so Claude stops reminding you and does not add it back.
A live board of all sessions. Each session writes ~/.claude/board/<session-id>.json (atomically, temp file plus rename). Every session polls that folder every two seconds and draws one line per live session at the top of the pane: the session's title as the Desktop app lists it (its repo name until it has one), a one-sentence summary, open item count. The current session is marked with ▶. Sessions with no update for 24 hours are dimmed as stale. Nothing here needs a Claude turn. The band option draws the same lines above the prompt as well, for a terminal too narrow to place the pane; it is off by default.
Restarts, resumes and older sessions. A session keeps its id across a restart or claude --resume, so at startup the mod reads its own board file back, ticks included. A session that predates the mod has no file, so the mod reads the transcript instead and seeds the checklist from the last TodoWrite call and any MANUAL: lines Claude wrote. Neither sends Claude a note, since Claude already knows all of it.
Click a session to switch to it. Each session line is a button. A session records the terminal it runs in, and a click asks Terminal.app, then iTerm2, to bring that tab to the front. macOS asks once to let your terminal control them. A cloud session records its claude.ai/code page, and a click opens that page with the system opener, so it lands in the browser or the Desktop app, whichever handles the link. A session the click cannot reach any other way, such as one in the Desktop app on another Mac, gets its resume command copied to the clipboard instead, with a toast saying so.
/board scan reads the whole conversation for loose ends, newest first, in chunks, with at most eight Haiku calls, which is what to run on a long chat that predates the mod or has things lingering from earlier. The same scan runs by itself the first time the mod meets a session.
/board prints the full board: every session, its open items, and the claude --resume command or the claude.ai page to get back to it. /board prune asks, then removes the files of stale or ended sessions.
The summary comes from the in-progress task, then the first pending task, then the first line of your last prompt. Only when the todo list is empty does the mod ask Haiku for one sentence, at most once every five minutes. Turn that off with the haikuSummary option in /config.
By default the board is the local folder, so each machine sees only its own sessions. To see every session on one board, including cloud sessions, give the mod a Supabase project with a cc_board table. Each session then also upserts its JSON document as that table's row for its session id, and polls the table every fifteen seconds. A GitHub gist would have been simpler, but cloud sessions cannot reach the gist API, so a table it is.
Create a free Supabase project of your own and run this once in its SQL editor. Row-level security is on with no policies, so the public publishable key can neither read nor write the table; the mod uses a secret key instead.
create table if not exists public.cc_board (
session_id text primary key,
payload jsonb not null,
updated_at timestamptz not null default now()
);
alter table public.cc_board enable row level security;
Then copy a secret key from the project's API settings, the service_role key or a new secret key. On your Mac, enter the project URL and that key through the mod's options dialog, not by editing settings.json: run /plugin, open the Installed tab, select cc-board and choose Configure options (a marketplace install shows the same dialog as it installs). The URL lands in ~/.claude/settings.json under pluginConfigs, and the key, which the manifest marks sensitive, goes to the macOS Keychain. A key written into settings.json by hand is dropped the next time Claude Code saves that file, because sensitive options never live there.
If the dialog is not offered for the way you loaded the mod, set both values as environment variables instead: CC_BOARD_URL and CC_BOARD_KEY under env in ~/.claude/settings.json, or in your shell profile. Claude Code leaves the env block alone when it saves settings. That keeps the key in a plain file, so prefer the dialog where it is available.
In a cloud environment, set CC_BOARD_URL and CC_BOARD_KEY as environment variables on the environment and allow the project's host in its network settings (Custom access with yourproject.supabase.co listed, or Full). Load the mod there with CLAUDE_CODE_PLUGIN_DIRS after a setup script clones this repository into the container. Anyone who can use that environment can read its variables, so keep the project to this one table.
Each session is tagged with where it runs, cloud or the Mac's host name, and the pane shows the tag for sessions on other machines. /board prune removes stale sessions from the table as well as the local folder. If the table cannot be reached, the mod shows one toast, logs the reason, and carries on with the local board.
Run the plugin straight from this folder for one session:
claude --plugin-dir ~/src/claude-mods/cc-board
Saving a file in the folder reloads the mod in that session. To have the Desktop app's local Code sessions load it too, name the folder in your settings instead, because the app takes no flag. Add this to the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/src/claude-mods/cc-board"
}
}
Open two sessions in two repos. Each one gets its own checklist and both panes list both sessions within a couple of seconds.
This repository doubles as a marketplace: .claude-plugin/marketplace.json at its root lists the mod. Once it is pushed to GitHub, anyone with access installs it with one line at the Claude Code prompt:
/plugin install cc-board --marketplace timjamwoo/claude-mods
Or from the shell:
claude plugin marketplace add timjamwoo/claude-mods
claude plugin install cc-board@claude-mods
Answer y to add the marketplace and pick the user scope so every project gets it. In the Desktop app, click + next to the prompt box, then Plugins, Add marketplace with timjamwoo/claude-mods, then Add plugin. Run /reload-plugins in any session that was already open.
Installing from a local clone works the same way with the clone's path in place of timjamwoo/claude-mods, and the mod is then read from that folder, so edits only need /reload-plugins.
claude plugin validate cc-board
claude plugin test cc-board
validate lists every event the mod hooks and every call it makes. test runs the files under tests/: the board file round trip and malformed files, MANUAL: parsing, keeping ticked items done through a TodoWrite update, the note sent to Claude, the pane and the band on the terminal and desktop surfaces, and both restore paths at startup.
hooks/register.tsx wires the events to the engine: commands, the pane, the band, the poll, the board file writes.hooks/checklist.ts is the checklist logic, pure functions only.hooks/board.ts is the board file shape, the stale rule, the merge of local and shared sessions, and the text the pane and /board print.hooks/shared.ts builds the table requests and reads the rows back.types/index.d.ts declares the values kept in $.state.tests/ holds the tests claude plugin test runs.It never answers or changes a tool call, and the only text it adds to a request is the checklist note. It makes four kinds of network call, each with its own switch: the Haiku summary (haikuSummary), the Haiku loose-end scan of an exchange that carries a cue phrase (followUps), the Haiku check of whether an exchange handled an open item (autoDone), all three through your own Claude Code credentials, and the table reads and writes once a shared board is configured (boardUrl). With all four off it touches nothing outside the machine. A missing board folder or a malformed board file is logged to the debug log (claude --debug) and skipped.
There is no way for a mod to set the terminal tab title, and Claude Code sets its own, so that part of the original idea is left out.
hooks/register.tsx 1116 lines1// cc-board: wiring only. The rules live in checklist.ts and board.ts.
2//
3// What this module does:
4// - keeps this session's checklist (TodoWrite items + manual steps) in $.state
5// - writes it to ~/.claude/board/<session-id>.json after every change
6// - polls that folder and draws every live session in the band above the prompt
7// - optionally mirrors the same document to a Supabase table (one row per
8// session), so sessions on other machines and in the cloud share one board
9// - draws the checklist as buttons in a pane, and tells Claude what you ticked
10//
11// Every helper that takes `$` is a top-level function: `claude plugin validate`
12// follows `$` only into those.
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, PluginOptions, Register, RenderSurface, Timer } from 'claude-code'
15
16import type { BoardSession, Checklist, ChecklistItem } from '../types'
17import {
18 EMPTY_CHECKLIST,
19 addFollowUp,
20 addManual,
21 applyTaskEvent,
22 candidatesHandled,
23 cueExcerpts,
24 describeChanges,
25 hasLooseEndCue,
26 markHandled,
27 mergeTodos,
28 openItems,
29 parseHandledIds,
30 parseItemId,
31 parseFollowUpLines,
32 parseManualLines,
33 reconcile,
34 seedFromTranscript,
35 setTicked,
36 type Change,
37 type TaskEvent,
38 type Todo,
39} from './checklist'
40import {
41 basename,
42 boardLine,
43 cheapSummary,
44 cleanPrompt,
45 clip,
46 displayName,
47 fileFor,
48 fullBoardText,
49 isLive,
50 isStale,
51 mergeSessions,
52 parse,
53 serialize,
54 sortSessions,
55 sourceTag,
56 STALE_AFTER_MS,
57} from './board'
58import { deleteBeforeUrl, deleteUrl, parseRows, readUrl, sharedHeaders, tableUrl, upsertBody, upsertHeaders } from './shared'
59
60const PANE = 'cc-board'
61const POLL_MS = 2000
62const REMOTE_POLL_MS = 15000
63const REMOTE_PUSH_MS = 2000
64const FLUSH_MS = 300
65const HAIKU_GAP_MS = 5 * 60 * 1000
66
67const checklist = atom({ plugin: 'cc-board', key: 'checklist' } as const, EMPTY_CHECKLIST)
68const summary = atom({ plugin: 'cc-board', key: 'summary' } as const, '')
69const board = atom({ plugin: 'cc-board', key: 'board' } as const, [])
70// Whether the pane lists completed items too. Off by default, so a long session stays short.
71const showDone = atom({ plugin: 'cc-board', key: 'showDone' } as const, false)
72
73// ---- Module state (a hot reload resets it; session.start refills it) ------
74
75let useHaiku = true
76let useFollowUps = true
77let useAutoDone = true
78// Whether the sessions are also drawn in the band above the prompt.
79let showBand = false
80let pluginOptions: PluginOptions = {}
81
82let sessionId = ''
83let cwd = ''
84let project = ''
85// The session's title, as the Desktop app lists it; '' until a classic hook carries one.
86let title = ''
87let machine = ''
88let tty = ''
89// A cloud session's claude.ai/code page, '' elsewhere.
90let sessionUrl = ''
91let boardDir = ''
92
93// The shared table, when configured.
94let boardUrl = ''
95let boardKey = ''
96let remoteFailures = 0
97let pushTimer: Timer | undefined
98let pendingPush: BoardSession | undefined
99let lastPushed = ''
100
101// What the summary is derived from.
102let lastPrompt = ''
103let lastAnswer = ''
104let haikuText = ''
105let haikuPrompt = ''
106let haikuAt = 0
107
108// Changes not yet told to Claude, flushed as one note after a short pause.
109let pending: Change[] = []
110let flushTimer: Timer | undefined
111
112// The board files as last read: name -> mtime, and name -> parsed session.
113const seenMtime = new Map<string, number>()
114const sessions = new Map<string, BoardSession>()
115// The shared table's sessions as last read, by session id.
116const remoteSessions = new Map<string, BoardSession>()
117let boardSignature = ''
118
119function isRemoteConfigured(): boolean {
120 return boardUrl !== '' && boardKey !== ''
121}
122
123function log($: EngineInterface, text: string): void {
124 $.ui.log(`cc-board: ${text}`, { to: 'debug' })
125}
126
127// ---- Board file -----------------------------------------------------------
128
129async function writeBoardFile($: EngineInterface, endedAt?: string): Promise<void> {
130 if (boardDir === '' || sessionId === '') return
131 try {
132 const now = await $.clock.now()
133 const list = await read($, checklist)
134 const snapshot: BoardSession = {
135 sessionId,
136 project,
137 cwd,
138 summary: await read($, summary),
139 items: list.items,
140 updatedAt: new Date(now).toISOString(),
141 }
142 if (title !== '') snapshot.title = title
143 if (endedAt !== undefined) snapshot.endedAt = endedAt
144 if (machine !== '') snapshot.machine = machine
145 if (tty !== '') snapshot.tty = tty
146 if (sessionUrl !== '') snapshot.url = sessionUrl
147
148 // $.fs.write is not atomic, so write a temp file and rename it into place.
149 const target = fileFor(boardDir, sessionId)
150 const temp = `${target}.${now}.tmp`
151 await $.fs.write(temp, serialize(snapshot))
152 const moved = await $.process.run(['mv', '-f', temp, target], { timeoutMs: 5000 })
153 if (moved.exitCode !== 0) log($, `mv failed: ${moved.stderr}`)
154 await refreshBoard($)
155 schedulePush($, snapshot, endedAt !== undefined)
156 } catch (error) {
157 log($, `board write failed: ${String(error)}`)
158 }
159}
160
161async function refreshBoard($: EngineInterface): Promise<void> {
162 if (boardDir === '') return
163 let entries
164 try {
165 entries = await $.fs.list(boardDir)
166 } catch {
167 return // No board folder yet: nothing to show.
168 }
169
170 let changed = false
171 const present = new Set<string>()
172 for (const entry of entries) {
173 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
174 present.add(entry.name)
175 if (seenMtime.get(entry.name) === entry.mtimeMs) continue
176 seenMtime.set(entry.name, entry.mtimeMs)
177 try {
178 const session = parse(await $.fs.read(`${boardDir}/${entry.name}`))
179 if (session === undefined) {
180 log($, `skipped malformed board file ${entry.name}`)
181 sessions.delete(entry.name)
182 } else {
183 sessions.set(entry.name, session)
184 }
185 changed = true
186 } catch (error) {
187 log($, `could not read ${entry.name}: ${String(error)}`)
188 }
189 }
190 for (const name of [...sessions.keys()]) {
191 if (present.has(name)) continue
192 sessions.delete(name)
193 seenMtime.delete(name)
194 changed = true
195 }
196
197 await publishBoard($, changed)
198}
199
200/** Puts the merged board (local files + gist) into state when it differs from what is drawn. */
201async function publishBoard($: EngineInterface, changed: boolean): Promise<void> {
202 const merged = mergeSessions([...sessions.values()], [...remoteSessions.values()])
203 // A session also changes when it crosses the 24h stale line with no write.
204 const now = await $.clock.now()
205 const signature = merged.map(s => `${s.sessionId}:${s.updatedAt}:${isLive(s, now)}`).join('|')
206 if (signature !== boardSignature) {
207 boardSignature = signature
208 changed = true
209 }
210 if (changed) await update($, board, () => merged)
211}
212
213// ---- Shared board in a Supabase table --------------------------------------
214
215async function configureRemote($: EngineInterface): Promise<void> {
216 const option = (name: string) => {
217 const value = pluginOptions[name]
218 return typeof value === 'string' ? value.trim() : ''
219 }
220 boardUrl = option('boardUrl') || ((await $.env.get('CC_BOARD_URL')) ?? '').trim()
221 boardKey = option('boardKey') || ((await $.env.get('CC_BOARD_KEY')) ?? '').trim()
222 if (boardUrl !== '' && boardKey === '') log($, 'boardUrl is set but no key: the board stays local')
223}
224
225/** The terminal this session runs in, as /dev/ttysNNN, or '' when it has none (the desktop app, a cloud session). */
226async function detectTty($: EngineInterface): Promise<string> {
227 try {
228 const found = await $.process.run(['sh', '-c', 'ps -o tty= -p $PPID'], { timeoutMs: 3000 })
229 const name = found.stdout.trim()
230 if (found.exitCode !== 0 || name === '' || name === '?' || name === '??') return ''
231 return name.startsWith('/dev/') ? name : `/dev/${name}`
232 } catch {
233 return ''
234 }
235}
236
237/**
238 * Brings the terminal tab of a session on this machine to the front, through
239 * Terminal.app then iTerm2. Resolves false when neither holds that tty.
240 */
241async function raiseTerminal($: EngineInterface, target: string): Promise<boolean> {
242 const scripts = [
243 `tell application "Terminal"
244 repeat with w in windows
245 repeat with t in tabs of w
246 if tty of t is "${target}" then
247 set selected tab of w to t
248 set index of w to 1
249 activate
250 return "ok"
251 end if
252 end repeat
253 end repeat
254 end tell
255 return "none"`,
256 `tell application "iTerm2"
257 repeat with w in windows
258 repeat with t in tabs of w
259 repeat with s in sessions of t
260 if tty of s is "${target}" then
261 select t
262 select s
263 activate
264 return "ok"
265 end if
266 end repeat
267 end repeat
268 end repeat
269 end tell
270 return "none"`,
271 ]
272 for (const script of scripts) {
273 try {
274 const ran = await $.process.run(['osascript', '-e', script], { timeoutMs: 8000 })
275 if (ran.exitCode === 0 && ran.stdout.trim() === 'ok') return true
276 } catch (error) {
277 log($, `osascript failed: ${String(error)}`)
278 }
279 }
280 return false
281}
282
283/** Hands a URL to the system opener, so a claude.ai/code page lands in the browser or the Desktop app. */
284async function openUrl($: EngineInterface, url: string): Promise<boolean> {
285 for (const opener of ['open', 'xdg-open']) {
286 try {
287 const ran = await $.process.run([opener, url], { timeoutMs: 8000 })
288 if (ran.exitCode === 0) return true
289 } catch (error) {
290 log($, `${opener} failed: ${String(error)}`)
291 }
292 }
293 return false
294}
295
296/** What a click on a board line does: raise that session's terminal, open its page, or copy how to get back to it. */
297async function switchToSession($: EngineInterface, target: BoardSession, surface: RenderSurface): Promise<void> {
298 if (target.sessionId === sessionId) {
299 $.ui.toast('that is this session')
300 return
301 }
302 if (target.tty !== undefined && target.machine === machine && (await raiseTerminal($, target.tty))) return
303 if (target.url !== undefined && (await openUrl($, target.url))) {
304 $.ui.toast(`opened ${displayName(target)} in your browser`)
305 return
306 }
307 const command = `cd ${target.cwd} && claude --resume ${target.sessionId}`
308 const copied = await $.ui.copy({ text: command, surface }).catch(() => ({ isCopied: false as const, reason: 'failed' }))
309 const where = target.machine !== undefined && target.machine !== machine ? ` (it runs on ${target.machine})` : ''
310 $.ui.toast(copied.isCopied ? `resume command copied${where}` : `resume with: ${command}`)
311}
312
313/** A cloud session's claude.ai/code page, from the id the cloud sets in the environment; '' for a local session. */
314async function detectSessionUrl($: EngineInterface): Promise<string> {
315 const remoteId = ((await $.env.get('CLAUDE_CODE_REMOTE_SESSION_ID')) ?? '').trim()
316 const match = /^cse_([A-Za-z0-9]+)$/.exec(remoteId)
317 return match === null ? '' : `https://claude.ai/code/session_${match[1]}`
318}
319
320/** A short label for where this session runs: "cloud", or the host name. */
321async function detectMachine($: EngineInterface): Promise<string> {
322 const named = ((await $.env.get('CC_BOARD_MACHINE')) ?? '').trim()
323 if (named !== '') return named
324 if ((await $.env.get('CLAUDE_CODE_REMOTE_SDK_URL')) !== undefined) return 'cloud'
325 try {
326 const host = await $.process.run(['hostname', '-s'], { timeoutMs: 3000 })
327 return host.exitCode === 0 ? host.stdout.trim() : 'local'
328 } catch {
329 return 'local'
330 }
331}
332
333function remoteFailed($: EngineInterface, what: string, status: number | string): void {
334 remoteFailures += 1
335 log($, `shared board ${what} failed: ${status}`)
336 if (remoteFailures === 1) $.ui.toast(`shared board: ${what} failed (${status}), see the debug log`)
337}
338
339/** Writes this session's row, a little after the last local write, and only when it changed. */
340function schedulePush($: EngineInterface, snapshot: BoardSession, atOnce: boolean): void {
341 if (!isRemoteConfigured()) return
342 pendingPush = snapshot
343 pushTimer?.cancel()
344 if (atOnce) {
345 void pushRemote($)
346 } else {
347 pushTimer = $.clock.after(REMOTE_PUSH_MS, () => pushRemote($))
348 }
349}
350
351async function pushRemote($: EngineInterface): Promise<void> {
352 if (!isRemoteConfigured() || pendingPush === undefined) return
353 const body = upsertBody(pendingPush)
354 if (body === lastPushed) return
355 try {
356 const response = await $.http.fetch(tableUrl(boardUrl), { method: 'POST', headers: upsertHeaders(boardKey), body })
357 if (response.ok) {
358 lastPushed = body
359 remoteFailures = 0
360 } else {
361 remoteFailed($, 'write', response.status)
362 }
363 } catch (error) {
364 remoteFailed($, 'write', String(error))
365 }
366}
367
368async function pullRemote($: EngineInterface): Promise<void> {
369 if (!isRemoteConfigured()) return
370 try {
371 const response = await $.http.fetch(readUrl(boardUrl), { headers: sharedHeaders(boardKey) })
372 if (!response.ok) {
373 remoteFailed($, 'read', response.status)
374 return
375 }
376 remoteFailures = 0
377 remoteSessions.clear()
378 for (const session of parseRows(response.text)) remoteSessions.set(session.sessionId, session)
379 await publishBoard($, true)
380 } catch (error) {
381 remoteFailed($, 'read', String(error))
382 }
383}
384
385/**
386 * Deletes the named sessions' rows, and every row older than the stale line,
387 * which reaches rows a capped read never returned. False when a request failed.
388 */
389async function deleteRemote($: EngineInterface, sessionIds: readonly string[], beforeIso: string): Promise<boolean> {
390 if (!isRemoteConfigured()) return true
391 const urls = [deleteBeforeUrl(boardUrl, beforeIso)]
392 if (sessionIds.length > 0) urls.push(deleteUrl(boardUrl, sessionIds))
393 try {
394 for (const url of urls) {
395 const response = await $.http.fetch(url, { method: 'DELETE', headers: sharedHeaders(boardKey) })
396 if (!response.ok) {
397 remoteFailed($, 'prune', response.status)
398 return false
399 }
400 }
401 for (const id of sessionIds) remoteSessions.delete(id)
402 return true
403 } catch (error) {
404 remoteFailed($, 'prune', String(error))
405 return false
406 }
407}
408
409// ---- Telling Claude -------------------------------------------------------
410
411function queueChange($: EngineInterface, change: Change): void {
412 pending.push(change)
413 flushTimer?.cancel()
414 flushTimer = $.clock.after(FLUSH_MS, () => flushChanges($))
415}
416
417async function flushChanges($: EngineInterface): Promise<void> {
418 if (pending.length === 0) return
419 const text = describeChanges(pending)
420 pending = []
421 try {
422 const result = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
423 if (result.deny !== undefined) log($, `note refused: ${result.deny}`)
424 } catch (error) {
425 log($, `note failed: ${String(error)}`)
426 }
427}
428
429// ---- Summary --------------------------------------------------------------
430
431/**
432 * The summary: an in-progress or pending task wins. Otherwise, with Haiku on,
433 * the last Haiku sentence stays until a new one is made (at most one call per
434 * HAIKU_GAP_MS, and only when the prompt changed); the raw prompt shows only
435 * before the first call. With Haiku off, the prompt's first line is used.
436 */
437async function refreshSummary($: EngineInterface, mayCallModel: boolean): Promise<void> {
438 const list = await read($, checklist)
439 const cheap = cheapSummary(list.items, lastPrompt)
440 let text = cheap.text
441
442 if (!cheap.fromTask && useHaiku) {
443 if (mayCallModel && lastPrompt !== '' && haikuPrompt !== lastPrompt) {
444 const now = await $.clock.now()
445 if (now - haikuAt > HAIKU_GAP_MS) {
446 haikuAt = now
447 haikuPrompt = lastPrompt
448 const fresh = await askHaiku($)
449 if (fresh !== '') haikuText = fresh
450 }
451 }
452 if (haikuText !== '') text = haikuText
453 }
454 await update($, summary, () => text)
455}
456
457async function applyTask($: EngineInterface, event: TaskEvent): Promise<void> {
458 let kept: ChecklistItem | undefined
459 await update($, checklist, (list: Checklist) => {
460 const result = applyTaskEvent(list, event)
461 kept = result.keptDone
462 return result.list
463 })
464 if (kept !== undefined) queueChange($, { kind: 'kept-done', item: kept })
465 await refreshSummary($, false)
466 await writeBoardFile($)
467}
468
469async function askHaiku($: EngineInterface): Promise<string> {
470 try {
471 const reply = await $.model.complete({
472 model: 'haiku',
473 system:
474 'You write a one-line status for a board of coding sessions. Answer with one plain phrase of at most twelve words that says what the session is working on, starting with a verb ending in -ing, such as "Fixing the login crash on iOS". No quotes, no preamble, no mention of the user or the assistant. The excerpts may be cut short; never remark on that.',
475 prompt: `Latest request:\n${clip(lastPrompt, 1500)}\n\nEnd of the latest reply:\n${tail(lastAnswer, 1500)}`,
476 maxTokens: 60,
477 effort: 'low',
478 timeoutMs: 8000,
479 })
480 if (!reply.isAnswered) {
481 log($, `haiku summary skipped: ${reply.reason}`)
482 return ''
483 }
484 return clip(reply.text, 100)
485 } catch (error) {
486 log($, `haiku summary failed: ${String(error)}`)
487 return ''
488 }
489}
490
491// ---- Checklist edits ------------------------------------------------------
492
493async function toggleItem($: EngineInterface, id: number): Promise<void> {
494 let change: Change | undefined
495 await update($, checklist, (list: Checklist) => {
496 const found = list.items.find(item => item.id === id)
497 if (found === undefined) return list
498 const ticked = found.status !== 'completed'
499 const result = setTicked(list, id, ticked)
500 if (result.item !== undefined) change = { kind: ticked ? 'ticked' : 'unticked', item: result.item }
501 return result.list
502 })
503 if (change === undefined) return
504 queueChange($, change)
505 await refreshSummary($, false)
506 await writeBoardFile($)
507}
508
509async function applyTodos($: EngineInterface, todos: readonly Todo[]): Promise<void> {
510 let kept: ChecklistItem[] = []
511 await update($, checklist, (list: Checklist) => {
512 const result = mergeTodos(list, todos)
513 kept = result.keptDone
514 return result.list
515 })
516 for (const item of kept) queueChange($, { kind: 'kept-done', item })
517 await refreshSummary($, false)
518 await writeBoardFile($)
519}
520
521async function addManualStep($: EngineInterface, text: string): Promise<ChecklistItem | undefined> {
522 let added: ChecklistItem | undefined
523 await update($, checklist, (list: Checklist) => {
524 const result = addManual(list, text)
525 added = result.added ? result.item : undefined
526 return result.list
527 })
528 if (added !== undefined) queueChange($, { kind: 'manual-added', item: added })
529 return added
530}
531
532async function addManualLines($: EngineInterface, text: string): Promise<number> {
533 let count = 0
534 for (const line of parseManualLines(text)) {
535 const added = await addManualStep($, line)
536 if (added === undefined) continue
537 count += 1
538 $.ui.toast(`manual step #${added.id}: ${clip(added.text, 60)}`)
539 }
540 if (count > 0) await writeBoardFile($)
541 return count
542}
543
544// ---- Loose ends -------------------------------------------------------------
545
546/**
547 * Asks Haiku which loose ends `text` mentions (things to do later, by hand,
548 * or not yet done), skipping what the checklist already holds. Resolves the
549 * new lines, or none.
550 */
551async function extractFollowUps($: EngineInterface, text: string): Promise<string[]> {
552 const list = await read($, checklist)
553 const open = list.items.filter(item => item.status !== 'completed').map(item => `- ${item.text}`).join('\n')
554 try {
555 const reply = await $.model.complete({
556 model: 'haiku',
557 system:
558 'You keep a checklist for a developer so small things said in passing are not forgotten. Read the exchange and list only concrete actions a person still has to do: something to do by hand or outside this conversation (a portal, a key, a certificate, a store listing, a release, a config), something deferred or skipped for now, or something asked for and not done. Skip what the assistant says it will do next, skip advice and hypotheticals, skip anything finished, and skip anything already on the checklist. One per line, imperative, at most twelve words, at most three lines, no numbering, no commentary. If there is nothing, reply NONE.',
559 prompt: `Already on the checklist:\n${open === '' ? '(nothing)' : open}\n\nExchange:\n${cueExcerpts(text)}`,
560 maxTokens: 160,
561 effort: 'low',
562 timeoutMs: 10000,
563 })
564 if (!reply.isAnswered) {
565 log($, `loose-end scan skipped: ${reply.reason}`)
566 return []
567 }
568 return parseFollowUpLines(reply.text)
569 } catch (error) {
570 log($, `loose-end scan failed: ${String(error)}`)
571 return []
572 }
573}
574
575const SCAN_CHUNK_MESSAGES = 16
576const SCAN_MAX_CALLS = 8
577
578/**
579 * Reads a whole conversation for loose ends, newest first, in chunks of
580 * messages, with at most SCAN_MAX_CALLS model calls. Chunks with no cue
581 * phrase cost nothing. Resolves how many loose ends were new.
582 */
583async function scanTranscript($: EngineInterface, messages: readonly { role: 'user' | 'assistant'; text: string }[]): Promise<number> {
584 if (!useFollowUps) return 0
585 let found = 0
586 let calls = 0
587 for (let end = messages.length; end > 0 && calls < SCAN_MAX_CALLS; end -= SCAN_CHUNK_MESSAGES) {
588 const chunk = messages.slice(Math.max(0, end - SCAN_CHUNK_MESSAGES), end)
589 const text = chunk.map(message => `${message.role === 'user' ? 'User' : 'Claude'}: ${cleanPrompt(message.text)}`).join('\n\n')
590 if (!hasLooseEndCue(text)) continue
591 calls += 1
592 found += await surfaceFollowUps($, text)
593 }
594 if (found > 0) await writeBoardFile($)
595 return found
596}
597
598/**
599 * Ticks off the manual steps and loose ends that an exchange shows were done,
600 * whoever did them. Only items sharing words with the exchange are put to
601 * Haiku, so most turns cost nothing. Resolves how many were marked done.
602 */
603async function tickHandled($: EngineInterface, text: string): Promise<number> {
604 if (!useAutoDone) return 0
605 const list = await read($, checklist)
606 const candidates = candidatesHandled(list.items, text)
607 if (candidates.length === 0) return 0
608 let ids: number[] = []
609 try {
610 const reply = await $.model.complete({
611 model: 'haiku',
612 system:
613 'You keep a checklist for a developer. Read the exchange and decide which checklist items it shows as finished: the assistant did the work in this exchange, or the user says they did it. An item that is only mentioned, planned, started, or advised about is not finished. Answer with the numbers of the finished items, one per line, or NONE.',
614 prompt: `Checklist:\n${candidates.map(item => `#${item.id} ${item.text}`).join('\n')}\n\nExchange:\n${clip(text, 6000)}`,
615 maxTokens: 60,
616 effort: 'low',
617 timeoutMs: 8000,
618 })
619 if (!reply.isAnswered) {
620 log($, `handled check skipped: ${reply.reason}`)
621 return 0
622 }
623 ids = parseHandledIds(reply.text, candidates)
624 } catch (error) {
625 log($, `handled check failed: ${String(error)}`)
626 return 0
627 }
628 if (ids.length === 0) return 0
629 let done: ChecklistItem[] = []
630 await update($, checklist, (list: Checklist) => {
631 const result = markHandled(list, ids)
632 done = result.items
633 return result.list
634 })
635 for (const item of done) {
636 queueChange($, { kind: 'auto-done', item })
637 $.ui.toast(`#${item.id} done: ${clip(item.text, 60)}`)
638 }
639 return done.length
640}
641
642/** Adds the loose ends found in `text` to the checklist and tells Claude. Resolves how many were new. */
643async function surfaceFollowUps($: EngineInterface, text: string): Promise<number> {
644 if (!useFollowUps || !hasLooseEndCue(text)) return 0
645 let count = 0
646 for (const line of await extractFollowUps($, text)) {
647 let added: ChecklistItem | undefined
648 await update($, checklist, (list: Checklist) => {
649 const result = addFollowUp(list, line)
650 added = result.added ? result.item : undefined
651 return result.list
652 })
653 if (added === undefined) continue
654 count += 1
655 queueChange($, { kind: 'followup-added', item: added })
656 $.ui.toast(`loose end #${added.id}: ${clip(added.text, 60)}`)
657 }
658 return count
659}
660
661async function bindSession($: EngineInterface): Promise<void> {
662 sessionId = await $.session.id()
663 const repo = await $.session.repo().catch(() => null)
664 project = basename(repo?.root ?? cwd)
665 title = ''
666 pending = []
667 lastPrompt = ''
668 lastAnswer = ''
669 haikuText = ''
670 haikuPrompt = ''
671 haikuAt = 0
672}
673
674/**
675 * Fills the checklist at startup. A resumed or restarted session keeps its
676 * id, so its own board file comes back first, ticks included. With no file
677 * (a session that predates the mod), the transcript's last TodoWrite and any
678 * MANUAL: lines seed it. Claude already knows all of this, so nothing is queued.
679 */
680async function restoreChecklist($: EngineInterface): Promise<'file' | 'transcript' | 'none'> {
681 if (boardDir !== '' && sessionId !== '') {
682 try {
683 const path = fileFor(boardDir, sessionId)
684 if (await $.fs.exists(path)) {
685 const saved = parse(await $.fs.read(path))
686 if (saved !== undefined) {
687 // The file wins (ticks, manual steps, summary), topped up with any task
688 // the transcript knows and the file does not, as after an upgrade.
689 const nextId = Math.max(0, ...saved.items.map(item => item.id)) + 1
690 const replayed = await replayTranscript($)
691 const list = reconcile({ items: saved.items, nextId }, replayed.list)
692 await update($, checklist, () => list)
693 await update($, summary, () => saved.summary)
694 if (lastPrompt === '') lastPrompt = replayed.lastPrompt
695 return 'file'
696 }
697 }
698 } catch (error) {
699 log($, `restore from board file failed: ${String(error)}`)
700 }
701 }
702
703 try {
704 const messages = await $.session.messages()
705 if ('deny' in messages) return 'none'
706 const list = seedFromTranscript(messages)
707 const lastUser = [...messages].reverse().find(message => message.role === 'user' && cleanPrompt(message.text) !== '')
708 lastPrompt = cleanPrompt(lastUser?.text ?? '')
709 if (list.items.length === 0 && lastPrompt === '') return 'none'
710 await update($, checklist, () => list)
711 await refreshSummary($, false)
712 // Loose ends mentioned before the mod loaded, anywhere in the conversation.
713 await scanTranscript($, messages)
714 return 'transcript'
715 } catch (error) {
716 log($, `seed from transcript failed: ${String(error)}`)
717 return 'none'
718 }
719}
720
721/** The checklist the transcript implies and the last prompt a person typed, or empty when it cannot be read. */
722async function replayTranscript($: EngineInterface): Promise<{ list: Checklist; lastPrompt: string }> {
723 try {
724 const messages = await $.session.messages()
725 if ('deny' in messages) return { list: EMPTY_CHECKLIST, lastPrompt: '' }
726 const lastUser = [...messages].reverse().find(message => message.role === 'user' && cleanPrompt(message.text) !== '')
727 return { list: seedFromTranscript(messages), lastPrompt: cleanPrompt(lastUser?.text ?? '') }
728 } catch (error) {
729 log($, `transcript replay failed: ${String(error)}`)
730 return { list: EMPTY_CHECKLIST, lastPrompt: '' }
731 }
732}
733
734async function registerCommands($: EngineInterface): Promise<void> {
735 await $.command.register({
736 name: 'manual',
737 description: 'Add a manual step to this session’s checklist (no Claude turn)',
738 argumentHint: '<text>',
739 immediate: true,
740 })
741 await $.command.register({
742 name: 'done',
743 description: 'Tick a checklist item done by its number (no Claude turn)',
744 argumentHint: '<id>',
745 immediate: true,
746 })
747 await $.command.register({
748 name: 'board',
749 description: 'Print the board of all sessions; “prune” removes stale ones, “scan” reads this chat for loose ends',
750 argumentHint: '[prune|scan]',
751 immediate: true,
752 })
753}
754
755async function pruneBoard($: EngineInterface): Promise<string> {
756 await pullRemote($)
757 const now = await $.clock.now()
758 const merged = mergeSessions([...sessions.values()], [...remoteSessions.values()])
759 const stale = merged.filter(
760 session => session.sessionId !== sessionId && (session.endedAt !== undefined || isStale(session, now)),
761 )
762 if (stale.length === 0 && !isRemoteConfigured()) return 'cc-board: nothing to prune.'
763 // With a shared board, rows older than the stale line may sit behind the read's cap, unseen.
764 const question =
765 stale.length > 0
766 ? `Remove ${stale.length} stale or ended session(s) from the board?`
767 : 'Nothing stale is listed. Sweep the shared board for rows older than 24 hours anyway?'
768 const answer = await $.ui.ask(question, [stale.length > 0 ? 'Remove' : 'Sweep', 'Keep']).catch(() => 'Keep')
769 if (answer === 'Keep') return 'cc-board: kept everything.'
770 const before = new Date(now - STALE_AFTER_MS).toISOString()
771 if (stale.length === 0) {
772 const swept = await deleteRemote($, [], before)
773 await refreshBoard($)
774 return swept ? 'cc-board: swept the shared board of rows older than 24 hours.' : 'cc-board: the sweep failed, see the debug log.'
775 }
776
777 const localPaths = stale
778 .map(session => `${session.sessionId}.json`)
779 .filter(name => sessions.has(name))
780 .map(name => `${boardDir}/${name}`)
781 if (localPaths.length > 0) {
782 const removed = await $.process.run(['rm', '-f', ...localPaths], { timeoutMs: 5000 })
783 if (removed.exitCode !== 0) return `cc-board: rm failed: ${removed.stderr}`
784 }
785 const remoteIds = stale.map(session => session.sessionId).filter(id => remoteSessions.has(id))
786 const remoteOk = await deleteRemote($, remoteIds, before)
787 await refreshBoard($)
788 const where = isRemoteConfigured() ? ` (${remoteIds.length} from the shared board${remoteOk ? '' : ', which failed'})` : ''
789 return `cc-board: removed ${stale.length} session(s)${where}.`
790}
791
792/** The last `max` characters of a text on one line, with a leading ellipsis when cut. */
793function tail(text: string, max: number): string {
794 const oneLine = text.replace(/\s+/g, ' ').trim()
795 return oneLine.length <= max ? oneLine : `…${oneLine.slice(oneLine.length - max + 1)}`
796}
797
798function itemMark(item: ChecklistItem): string {
799 if (item.status === 'completed') return '[x]'
800 if (item.status === 'in_progress') return '[>]'
801 return '[ ]'
802}
803
804function itemLabel(item: ChecklistItem): string {
805 return `${itemMark(item)} #${item.id} ${item.text}${sourceTag(item)}`
806}
807
808// ---- Hooks ----------------------------------------------------------------
809
810/** Records a changed session title and rewrites the board file so the other sessions see it. */
811async function noteTitle($: EngineInterface, fresh: string | undefined): Promise<void> {
812 const next = (fresh ?? '').trim()
813 if (next === title) return
814 title = next
815 await writeBoardFile($)
816}
817
818/** The sessions the pane and the band list: every one not ended, this one first. */
819async function liveSessions($: EngineInterface): Promise<{ rows: BoardSession[]; now: number }> {
820 const all = await read($, board)
821 const now = await $.clock.now()
822 return { rows: sortSessions(all, sessionId).filter(session => session.endedAt === undefined), now }
823}
824
825export const register: Register = (on, options) => {
826 useHaiku = options.haikuSummary !== false
827 useFollowUps = options.followUps !== false
828 useAutoDone = options.autoDone !== false
829 showBand = options.band === true
830 pluginOptions = options
831
832 on('session.start', async ($, e, next) => {
833 cwd = e.cwd
834 try {
835 await bindSession($)
836 const home = await $.env.get('HOME')
837 boardDir = home === undefined || home === '' ? '' : `${home}/.claude/board`
838 if (boardDir === '') log($, 'HOME is unset, so no board file is written')
839 machine = await detectMachine($)
840 tty = await detectTty($)
841 sessionUrl = await detectSessionUrl($)
842 await configureRemote($)
843 } catch (error) {
844 log($, `start failed: ${String(error)}`)
845 }
846 try {
847 await registerCommands($)
848 } catch (error) {
849 log($, `command registration failed: ${String(error)}`)
850 }
851
852 void $.ui.open({ id: PANE, title: 'cc-board' })
853 $.clock.every(POLL_MS, () => refreshBoard($))
854 if (isRemoteConfigured()) {
855 $.clock.every(REMOTE_POLL_MS, () => pullRemote($))
856 void pullRemote($)
857 }
858 const restored = await restoreChecklist($)
859 if (restored !== 'none') log($, `checklist restored from ${restored}`)
860 await writeBoardFile($)
861 return next(e)
862 })
863
864 // /clear, /resume and /branch start a new session id without a session.start, so the session is
865 // rebound first; only then is the title the event carries written, under the right session id.
866 on('classic.SessionStart', async ($, e, next) => {
867 try {
868 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
869 cwd = await $.session.cwd()
870 await bindSession($)
871 if (e.source !== 'clear') await restoreChecklist($)
872 }
873 title = (e.session_title ?? '').trim()
874 await writeBoardFile($)
875 } catch (error) {
876 log($, `rebind failed: ${String(error)}`)
877 }
878 return next(e)
879 })
880
881 // The title rides on every prompt too, so a /rename shows on the next one.
882 on('classic.UserPromptSubmit', async ($, e, next) => {
883 await noteTitle($, e.session_title)
884 return next(e)
885 })
886
887 on('session.end', async ($, e, next) => {
888 const now = await $.clock.now()
889 await writeBoardFile($, new Date(now).toISOString())
890 return next(e)
891 })
892
893 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
894 if (e.agentId === undefined) {
895 try {
896 await applyTodos($, e.todos)
897 } catch (error) {
898 log($, `TodoWrite merge failed: ${String(error)}`)
899 }
900 }
901 return next(e)
902 })
903
904 // The Task tools, which newer builds use in place of TodoWrite. The call runs
905 // first, so a created task's id is known and a refused update changes nothing.
906 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
907 const ran = await next(e)
908 if (e.agentId === undefined && ran.deny === undefined) {
909 try {
910 const task = (ran.result as { task?: { id?: unknown } } | undefined)?.task
911 if (typeof task?.id === 'string') await applyTask($, { kind: 'created', taskId: task.id, subject: e.subject })
912 } catch (error) {
913 log($, `TaskCreate failed: ${String(error)}`)
914 }
915 }
916 return ran
917 })
918
919 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
920 const ran = await next(e)
921 if (e.agentId === undefined && ran.deny === undefined) {
922 try {
923 const ok = (ran.result as { success?: unknown } | undefined)?.success !== false
924 if (ok) {
925 const event: TaskEvent = { kind: 'updated', taskId: e.taskId }
926 if (e.status !== undefined) event.status = e.status
927 if (e.subject !== undefined) event.subject = e.subject
928 await applyTask($, event)
929 }
930 } catch (error) {
931 log($, `TaskUpdate failed: ${String(error)}`)
932 }
933 }
934 return ran
935 })
936
937 // Lines Claude writes that start with "MANUAL:" become manual steps.
938 on('session.append', { door: 'response' }, async ($, e, next) => {
939 if (e.agentId === undefined) {
940 try {
941 const text = e.message.content
942 .map(block => ('text' in block && typeof block.text === 'string' ? block.text : ''))
943 .join('\n')
944 await addManualLines($, text)
945 } catch (error) {
946 log($, `MANUAL scan failed: ${String(error)}`)
947 }
948 }
949 return next(e)
950 })
951
952 // Only what a person typed feeds the summary: notifications, scheduled
953 // triggers and other sessions' messages are not what this session works on.
954 on('prompt.submit', async ($, e, next) => {
955 const kind = e.origin.kind
956 const isTyped = e.isMeta !== true && e.command === undefined && (kind === 'composer' || kind === 'bridge' || kind === 'sdk')
957 if (isTyped) {
958 // A typed prompt that is nothing but markup is still the person's request.
959 const typed = cleanPrompt(e.text) || (e.text.split('\n').find(line => line.trim() !== '') ?? '').trim()
960 if (typed !== '') lastPrompt = typed
961 }
962 return next(e)
963 }).catch(($, e, next) => next(e))
964
965 on('turn.start', async ($, e, next) => {
966 if (e.agentId === undefined) {
967 await refreshSummary($, false)
968 await writeBoardFile($)
969 }
970 return next(e)
971 })
972
973 on('turn.complete', async ($, e, next) => {
974 if (e.agentId === undefined) {
975 lastAnswer = e.answer
976 await refreshSummary($, true)
977 if (e.reason === 'answer' && e.answer.trim() !== '') {
978 const exchange = `User: ${clip(lastPrompt, 2000)}\n\nClaude: ${e.answer}`
979 await tickHandled($, exchange)
980 await surfaceFollowUps($, exchange)
981 }
982 await writeBoardFile($)
983 }
984 return next(e)
985 })
986
987 on('command.run', { command: 'manual' }, async ($, e) => {
988 const text = e.args.trim()
989 if (text === '') return { text: 'Usage: /manual <step you will do by hand>' }
990 const added = await addManualStep($, text)
991 if (added === undefined) {
992 $.ui.toast('that step is already on the checklist')
993 } else {
994 $.ui.toast(`added manual step #${added.id}`)
995 await writeBoardFile($)
996 }
997 void $.ui.open({ id: PANE, title: 'cc-board' })
998 return {}
999 })
1000
1001 on('command.run', { command: 'done' }, async ($, e) => {
1002 const id = parseItemId(e.args)
1003 if (id === undefined) return { text: 'Usage: /done <id> (the number shown in the pane)' }
1004 const list = await read($, checklist)
1005 const item = list.items.find(one => one.id === id)
1006 if (item === undefined) return { text: `cc-board: no item #${id}` }
1007 if (item.status !== 'completed') await toggleItem($, id)
1008 $.ui.toast(`#${id} done`)
1009 return {}
1010 })
1011
1012 on('command.run', { command: 'board' }, async ($, e) => {
1013 await refreshBoard($)
1014 if (e.args.trim() === 'prune') return { text: await pruneBoard($) }
1015 if (e.args.trim() === 'done') {
1016 const shown = await update($, showDone, (value: boolean) => !value)
1017 void $.ui.open({ id: PANE, title: 'cc-board' })
1018 return { text: shown ? 'cc-board: completed items shown.' : 'cc-board: completed items hidden.' }
1019 }
1020 if (e.args.trim() === 'scan') {
1021 if (!useFollowUps) return { text: 'cc-board: loose-end scans are off (followUps).' }
1022 try {
1023 const messages = await $.session.messages()
1024 if ('deny' in messages) return { text: 'cc-board: the transcript could not be read.' }
1025 const found = await scanTranscript($, messages)
1026 void $.ui.open({ id: PANE, title: 'cc-board' })
1027 return { text: found === 0 ? 'cc-board: no new loose ends in this chat.' : `cc-board: ${found} loose end(s) added from this chat.` }
1028 } catch (error) {
1029 return { text: `cc-board: scan failed: ${String(error)}` }
1030 }
1031 }
1032 void $.ui.open({ id: PANE, title: 'cc-board' })
1033 const now = await $.clock.now()
1034 const merged = mergeSessions([...sessions.values()], [...remoteSessions.values()])
1035 return { text: fullBoardText(merged, sessionId, now, machine) }
1036 })
1037
1038 // ---- Drawing ------------------------------------------------------------
1039
1040 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1041 const { Box, Text, Button } = $.ui.resolve(e)
1042 const list = await read($, checklist)
1043 const current = await read($, summary)
1044 const open = openItems(list.items).length
1045 const doneCount = list.items.length - open
1046 const showingDone = await read($, showDone)
1047 const shown = showingDone ? list.items : openItems(list.items)
1048 const { rows, now } = await liveSessions($)
1049
1050 return (
1051 <Box flexDirection="column">
1052 {rows.map(session => (
1053 <Button
1054 key={`session-${session.sessionId}`}
1055 plain
1056 dimColor={isStale(session, now)}
1057 label={boardLine(session, session.sessionId === sessionId, now, machine)}
1058 onPress={press => void switchToSession($, session, press.surface)}
1059 />
1060 ))}
1061 {rows.length > 0 && <Text> </Text>}
1062 <Text bold>
1063 {title === '' ? project : title} · {open} open
1064 </Text>
1065 {current !== '' && (
1066 <Text dimColor wrap="truncate-end">
1067 {current}
1068 </Text>
1069 )}
1070 {list.items.length === 0 && <Text dimColor>No tasks yet. /manual {'<text>'} adds a step of your own.</Text>}
1071 {shown.map(item => (
1072 <Button
1073 key={`item-${item.id}`}
1074 plain
1075 dimColor={item.status === 'completed'}
1076 label={itemLabel(item)}
1077 onPress={() => void toggleItem($, item.id)}
1078 />
1079 ))}
1080 {doneCount > 0 && (
1081 <Button
1082 key="toggle-done"
1083 plain
1084 dimColor
1085 label={showingDone ? `hide ${doneCount} done` : `show ${doneCount} done`}
1086 onPress={() => void update($, showDone, (value: boolean) => !value)}
1087 />
1088 )}
1089 {list.items.length > 0 && <Text dimColor>Click an item to tick it, or /done {'<id>'}.</Text>}
1090 </Box>
1091 )
1092 })
1093
1094 // The band is off unless asked for: the pane lists the sessions, and the band takes rows from every prompt.
1095 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1096 if (!showBand || e.props.hasSurvey) return next(e)
1097 const { rows, now } = await liveSessions($)
1098 if (rows.length === 0) return next(e)
1099
1100 const { Box, Button } = $.ui.resolve(e)
1101 return (
1102 <Box flexDirection="column">
1103 {rows.map(session => (
1104 <Button
1105 key={`session-${session.sessionId}`}
1106 plain
1107 dimColor={isStale(session, now)}
1108 label={boardLine(session, session.sessionId === sessionId, now, machine)}
1109 onPress={press => void switchToSession($, session, press.surface)}
1110 />
1111 ))}
1112 </Box>
1113 )
1114 })
1115}
1116hooks/checklist.ts 397 lines1// Pure checklist logic: no engine access, so it is easy to test.
2import type { Checklist, ChecklistItem, ItemStatus } from '../types'
3
4/** One entry of a TodoWrite call, as the tool receives it. */
5export type Todo = {
6 content: string
7 status: ItemStatus
8 activeForm?: string
9}
10
11/** A change worth telling Claude about. */
12export type Change =
13 | { kind: 'ticked'; item: ChecklistItem }
14 | { kind: 'unticked'; item: ChecklistItem }
15 | { kind: 'manual-added'; item: ChecklistItem }
16 | { kind: 'followup-added'; item: ChecklistItem }
17 | { kind: 'kept-done'; item: ChecklistItem }
18 | { kind: 'auto-done'; item: ChecklistItem }
19
20export const EMPTY_CHECKLIST: Checklist = { items: [], nextId: 1 }
21
22/** What the Task tools tell the checklist: a task was made, or one changed. */
23export type TaskEvent =
24 | { kind: 'created'; taskId: string; subject: string }
25 | { kind: 'updated'; taskId: string; status?: ItemStatus | 'deleted'; subject?: string }
26
27/**
28 * Applies one TaskCreate or TaskUpdate to the checklist. A task the person
29 * ticked stays completed when an update reopens it, reported in `keptDone`.
30 * An update for a task the checklist never saw is ignored.
31 */
32export function applyTaskEvent(list: Checklist, event: TaskEvent): { list: Checklist; item?: ChecklistItem; keptDone?: ChecklistItem } {
33 if (event.kind === 'created') {
34 const subject = event.subject.trim()
35 const known = list.items.find(item => item.taskId === event.taskId)
36 if (known !== undefined) return { list, item: known }
37 const sameText = list.items.find(item => item.source === 'todo' && item.taskId === undefined && item.text === subject)
38 // A task Claude takes on that reads like an open manual step or loose end adopts that item: when
39 // the task completes, the item does too.
40 const adopted = sameText ?? list.items.find(item => item.source !== 'todo' && item.taskId === undefined && item.status !== 'completed' && isSimilar(item.text, subject))
41 if (adopted !== undefined) {
42 const item = { ...adopted, taskId: event.taskId }
43 return { list: { ...list, items: list.items.map(one => (one === adopted ? item : one)) }, item }
44 }
45 const item: ChecklistItem = { id: list.nextId, text: subject, source: 'todo', status: 'pending', ticked: false, taskId: event.taskId }
46 return { list: { items: [...list.items, item], nextId: list.nextId + 1 }, item }
47 }
48
49 const found = list.items.find(item => item.taskId === event.taskId)
50 if (found === undefined) return { list }
51 if (event.status === 'deleted') {
52 // A deleted task takes its own item with it; an adopted manual step or loose end only loses the link.
53 if (found.source === 'todo') return { list: { ...list, items: list.items.filter(item => item !== found) }, item: found }
54 const { taskId: _dropped, ...detached } = found
55 return { list: { ...list, items: list.items.map(item => (item === found ? detached : item)) }, item: detached }
56 }
57
58 let item: ChecklistItem = { ...found }
59 if (event.subject !== undefined && event.subject.trim() !== '') item.text = event.subject.trim()
60 let keptDone: ChecklistItem | undefined
61 if (event.status !== undefined) {
62 if (found.ticked && event.status !== 'completed') keptDone = found
63 else item.status = event.status
64 }
65 return { list: { ...list, items: list.items.map(one => (one === found ? item : one)) }, item, keptDone }
66}
67
68/**
69 * Applies a TodoWrite call to the checklist.
70 *
71 * TodoWrite always carries the whole todo list, so todo items not in the new
72 * list are dropped. Manual items are never touched. An item the person ticked
73 * stays completed even when the new list says otherwise; those are returned
74 * in `keptDone` so the caller can tell Claude.
75 */
76export function mergeTodos(
77 list: Checklist,
78 todos: readonly Todo[],
79): { list: Checklist; keptDone: ChecklistItem[] } {
80 const oldTodos = list.items.filter(item => item.source === 'todo')
81 const manual = list.items.filter(item => item.source !== 'todo')
82 const merged: ChecklistItem[] = []
83 const keptDone: ChecklistItem[] = []
84 let nextId = list.nextId
85
86 for (const todo of todos) {
87 const text = todo.content.trim()
88 if (text === '' || merged.some(item => item.text === text)) continue
89
90 const existing = oldTodos.find(item => item.text === text)
91 if (existing === undefined) {
92 merged.push({ id: nextId, text, source: 'todo', status: todo.status, ticked: false })
93 nextId += 1
94 } else if (existing.ticked && todo.status !== 'completed') {
95 merged.push({ ...existing, status: 'completed' })
96 keptDone.push(existing)
97 } else {
98 merged.push({ ...existing, status: todo.status })
99 }
100 }
101
102 return { list: { items: [...merged, ...manual], nextId }, keptDone }
103}
104
105/** Adds a manual step unless one with the same text already exists. */
106export function addManual(list: Checklist, text: string): { list: Checklist; item: ChecklistItem; added: boolean } {
107 return addItem(list, text, 'manual')
108}
109
110/** Adds a loose end surfaced from the conversation, unless an item already says the same thing. */
111export function addFollowUp(list: Checklist, text: string): { list: Checklist; item: ChecklistItem; added: boolean } {
112 return addItem(list, text, 'followup')
113}
114
115/** Lower case, no punctuation, one space between words: how two items are judged the same. */
116export function normalizeText(text: string): string {
117 return text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
118}
119
120function addItem(list: Checklist, text: string, source: 'manual' | 'followup'): { list: Checklist; item: ChecklistItem; added: boolean } {
121 const trimmed = text.trim()
122 const key = normalizeText(trimmed)
123 const existing = list.items.find(item => normalizeText(item.text) === key)
124 if (existing !== undefined) return { list, item: existing, added: false }
125
126 const item: ChecklistItem = { id: list.nextId, text: trimmed, source, status: 'pending', ticked: false }
127 return { list: { items: [...list.items, item], nextId: list.nextId + 1 }, item, added: true }
128}
129
130const STOP_WORDS = new Set([
131 'the', 'and', 'for', 'with', 'from', 'that', 'this', 'then', 'than', 'into', 'onto', 'your', 'their', 'once', 'when',
132 'after', 'before', 'later', 'also', 'make', 'sure', 'need', 'needs', 'have', 'will', 'should', 'still', 'done', 'again',
133])
134
135/** The words of `text` worth matching on: four letters or more, lower case, no stop words, no repeats. */
136export function keywords(text: string): string[] {
137 const found = new Set<string>()
138 for (const word of normalizeText(text).split(' ')) {
139 if (word.length >= 4 && !STOP_WORDS.has(word)) found.add(word)
140 }
141 return [...found]
142}
143
144/**
145 * True when one text says the same as the other with extra words: every keyword
146 * of the shorter is in the longer, and there are at least two. A differing
147 * word on either side ("staging" against "production") keeps them apart.
148 */
149export function isSimilar(a: string, b: string): boolean {
150 const left = keywords(a)
151 const right = keywords(b)
152 const [shorter, longer] = left.length <= right.length ? [left, new Set(right)] : [right, new Set(left)]
153 return shorter.length >= 2 && shorter.every(word => longer.has(word))
154}
155
156/**
157 * The open manual steps and loose ends that `text` might have handled: those
158 * sharing at least two keywords with it. The cheap gate before asking Haiku.
159 */
160export function candidatesHandled(items: readonly ChecklistItem[], text: string): ChecklistItem[] {
161 const words = new Set(keywords(text))
162 return items.filter(item => {
163 if (item.source === 'todo' || item.status === 'completed') return false
164 return keywords(item.text).filter(word => words.has(word)).length >= 2
165 })
166}
167
168/** The ids a "which items were handled" reply names, limited to the candidates it was asked about. */
169export function parseHandledIds(reply: string, candidates: readonly ChecklistItem[]): number[] {
170 const allowed = new Set(candidates.map(item => item.id))
171 const ids: number[] = []
172 for (const line of reply.split('\n')) {
173 const match = /^\s*(?:[-*]\s*)?#?(\d+)\b/.exec(line)
174 if (match === null) continue
175 const id = Number(match[1])
176 if (allowed.has(id) && !ids.includes(id)) ids.push(id)
177 }
178 return ids
179}
180
181/** Marks the named items completed because the work was seen done, leaving the person's own ticks alone. */
182export function markHandled(list: Checklist, ids: readonly number[]): { list: Checklist; items: ChecklistItem[] } {
183 const done: ChecklistItem[] = []
184 const items = list.items.map(item => {
185 if (!ids.includes(item.id) || item.status === 'completed') return item
186 const handled: ChecklistItem = { ...item, status: 'completed' }
187 done.push(handled)
188 return handled
189 })
190 return { list: { ...list, items }, items: done }
191}
192
193const LOOSE_END_CUE =
194 /\b(later|remember|remind|don'?t forget|manual(?:ly)?|by hand|follow[ -]?ups?|out of scope|for now|not yet|still (?:need|have|to)|(?:you|we)(?:'ll| will| would| should| need| have)? (?:need|have|want) to|todo|to-do|make sure|before (?:you |we )?(?:ship|release|merge|deploy)|eventually|at some point|next steps?|after (?:this|that)|once (?:you|we)|when you get a chance|left (?:out|as is)|skipp?ed|deferred|temporar(?:y|ily)|workaround|not (?:yet )?(?:done|handled|covered|implemented))\b/i
195
196/** Whether an exchange is worth reading for loose ends. Cheap, so it runs on every turn. */
197export function hasLooseEndCue(text: string): boolean {
198 return LOOSE_END_CUE.test(text)
199}
200
201/**
202 * What a scan reads of a long exchange: the whole text when it fits, else
203 * the passages around every cue, with `radius` characters of context each
204 * side, merged where they overlap. Past `max`, the latest passages are kept.
205 */
206export function cueExcerpts(text: string, max = 6000, radius = 600): string {
207 if (text.length <= max) return text
208 const windows: [number, number][] = []
209 for (const match of text.matchAll(new RegExp(LOOSE_END_CUE.source, 'gi'))) {
210 const start = Math.max(0, match.index - radius)
211 const end = Math.min(text.length, match.index + match[0].length + radius)
212 const last = windows[windows.length - 1]
213 if (last !== undefined && start <= last[1]) last[1] = Math.max(last[1], end)
214 else windows.push([start, end])
215 }
216 const joined = windows.map(([start, end]) => text.slice(start, end)).join('\n…\n')
217 return joined.length <= max ? joined : joined.slice(joined.length - max)
218}
219
220/**
221 * The loose ends a model listed, one per line: bullets and numbering
222 * stripped, NONE and empty lines dropped, repeats and over-long lines removed.
223 */
224export function parseFollowUpLines(reply: string, max = 3): string[] {
225 const lines: string[] = []
226 for (const raw of reply.split('\n')) {
227 const line = raw.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').trim()
228 if (line === '' || /^none\.?$/i.test(line) || line.length > 120) continue
229 if (lines.some(one => normalizeText(one) === normalizeText(line))) continue
230 lines.push(line)
231 if (lines.length === max) break
232 }
233 return lines
234}
235
236/** Ticks an item done, or unticks it back to pending. */
237export function setTicked(
238 list: Checklist,
239 id: number,
240 ticked: boolean,
241): { list: Checklist; item: ChecklistItem | undefined } {
242 const found = list.items.find(item => item.id === id)
243 if (found === undefined) return { list, item: undefined }
244
245 const item: ChecklistItem = ticked
246 ? { ...found, status: 'completed', ticked: true }
247 : { ...found, status: 'pending', ticked: false }
248 const items = list.items.map(one => (one.id === id ? item : one))
249 return { list: { ...list, items }, item }
250}
251
252const MANUAL_LINE = /^\s*(?:[-*>]\s*|\d+[.)]\s*)?(?:\*\*)?MANUAL:(?:\*\*)?\s*(.+?)\s*$/
253
254/** The texts of the lines in `text` that start with "MANUAL:", without repeats. */
255export function parseManualLines(text: string): string[] {
256 const found: string[] = []
257 for (const line of text.split('\n')) {
258 const match = MANUAL_LINE.exec(line)
259 if (match === null) continue
260 const step = (match[1] ?? '').replace(/\*\*$/, '').trim()
261 if (step !== '' && !found.includes(step)) found.push(step)
262 }
263 return found
264}
265
266export function openItems(items: readonly ChecklistItem[]): ChecklistItem[] {
267 return items.filter(item => item.status !== 'completed')
268}
269
270/** Parses the argument of /done: "3", "#3" or "3 " all mean item 3. */
271export function parseItemId(args: string): number | undefined {
272 const match = /^#?(\d+)$/.exec(args.trim())
273 return match === null ? undefined : Number(match[1])
274}
275
276function quote(items: readonly ChecklistItem[]): string {
277 return items.map(item => `#${item.id} "${item.text}"`).join(', ')
278}
279
280/**
281 * One compact note for Claude about what changed since the last note. Only
282 * the kinds of change that happened are mentioned.
283 */
284export function describeChanges(changes: readonly Change[]): string {
285 const of = (kind: Change['kind']) => changes.filter(change => change.kind === kind).map(change => change.item)
286 const ticked = of('ticked')
287 const unticked = of('unticked')
288 const manual = of('manual-added')
289 const followUps = of('followup-added')
290 const kept = of('kept-done')
291 const auto = of('auto-done')
292
293 const parts: string[] = []
294 if (ticked.length > 0) parts.push(`The user ticked these done: ${quote(ticked)}.`)
295 if (auto.length > 0) parts.push(`Marked done because this exchange handled them: ${quote(auto)}.`)
296 if (kept.length > 0) {
297 parts.push(`These stay done because the user ticked them, although your TodoWrite reopened them: ${quote(kept)}.`)
298 }
299 if (unticked.length > 0) parts.push(`The user reopened: ${quote(unticked)}.`)
300 if (manual.length > 0) parts.push(`New manual steps the user will do by hand (not you): ${quote(manual)}.`)
301 if (followUps.length > 0) {
302 parts.push(`Loose ends noted on the user's checklist so they are not forgotten (no action from you unless asked): ${quote(followUps)}.`)
303 }
304 if (ticked.length > 0 || kept.length > 0 || auto.length > 0) {
305 parts.push('Do not remind the user about done items or add them back to TodoWrite.')
306 }
307 return `[cc-board] Checklist update. ${parts.join(' ')}`
308}
309
310/** The part of a transcript message the seed needs; `$.session.messages()` rows fit it. */
311export type TranscriptMessage = {
312 role: 'user' | 'assistant'
313 text: string
314 toolUses: readonly { tool: string; input: Record<string, unknown>; result?: unknown; agentId?: string }[]
315}
316
317function parseTodo(value: unknown): Todo | undefined {
318 if (typeof value !== 'object' || value === null) return undefined
319 const { content, status } = value as Record<string, unknown>
320 if (typeof content !== 'string') return undefined
321 if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') return undefined
322 return { content, status }
323}
324
325function updateFailed(result: unknown): boolean {
326 return typeof result === 'object' && result !== null && (result as { success?: unknown }).success === false
327}
328
329/**
330 * Brings a saved checklist up to date with what the transcript says: a task
331 * the transcript knows and the saved list lacks is added, a saved task that
332 * matches one by id or text keeps its tick and gains the id. Nothing saved is
333 * removed, so manual steps and ticks survive.
334 */
335export function reconcile(saved: Checklist, replayed: Checklist): Checklist {
336 let list = saved
337 for (const item of replayed.items) {
338 const key = normalizeText(item.text)
339 const known =
340 (item.taskId !== undefined ? list.items.find(one => one.taskId === item.taskId) : undefined) ??
341 list.items.find(one => (item.source === 'todo' ? one.source === 'todo' && one.text === item.text : normalizeText(one.text) === key))
342 if (known === undefined) {
343 list = { items: [...list.items, { ...item, id: list.nextId }], nextId: list.nextId + 1 }
344 continue
345 }
346 let updated = known
347 if (updated.taskId === undefined && item.taskId !== undefined) updated = { ...updated, taskId: item.taskId }
348 // A task the transcript shows finished finishes the item it adopted, whatever the file said.
349 if (item.taskId !== undefined && item.status === 'completed' && updated.status !== 'completed') updated = { ...updated, status: 'completed' }
350 if (updated !== known) list = { ...list, items: list.items.map(one => (one === known ? updated : one)) }
351 }
352 return list
353}
354
355function createdTaskId(result: unknown): string | undefined {
356 if (typeof result !== 'object' || result === null) return undefined
357 const task = (result as { task?: unknown }).task
358 if (typeof task !== 'object' || task === null) return undefined
359 const id = (task as { id?: unknown }).id
360 return typeof id === 'string' ? id : undefined
361}
362
363/**
364 * Rebuilds the checklist a session that predates the mod would have had:
365 * the last TodoWrite on the main loop, every TaskCreate and TaskUpdate in
366 * order, and every MANUAL: line Claude wrote. Subagents' calls are ignored.
367 */
368export function seedFromTranscript(messages: readonly TranscriptMessage[]): Checklist {
369 let list = EMPTY_CHECKLIST
370
371 for (const message of messages) {
372 if (message.role !== 'assistant') continue
373 for (const use of message.toolUses) {
374 if (use.agentId !== undefined) continue
375 if (use.tool === 'TodoWrite') {
376 const raw = use.input.todos
377 if (Array.isArray(raw)) list = mergeTodos(list, raw.map(parseTodo).filter((todo): todo is Todo => todo !== undefined)).list
378 } else if (use.tool === 'TaskCreate') {
379 const taskId = createdTaskId(use.result)
380 const subject = use.input.subject
381 if (taskId !== undefined && typeof subject === 'string') list = applyTaskEvent(list, { kind: 'created', taskId, subject }).list
382 } else if (use.tool === 'TaskUpdate') {
383 if (updateFailed(use.result)) continue
384 const { taskId, status, subject } = use.input
385 if (typeof taskId !== 'string') continue
386 const event: TaskEvent = { kind: 'updated', taskId }
387 if (status === 'pending' || status === 'in_progress' || status === 'completed' || status === 'deleted') event.status = status
388 if (typeof subject === 'string') event.subject = subject
389 list = applyTaskEvent(list, event).list
390 }
391 }
392 // Message by message, so a MANUAL: step written before Claude takes it on as a task is adopted by that task.
393 for (const line of parseManualLines(message.text)) list = addManual(list, line).list
394 }
395 return list
396}
397hooks/board.ts 205 lines1// Pure board logic: the file shape, the stale rule and the text the band and
2// /board print. No engine access.
3import type { BoardSession, ChecklistItem, ItemStatus } from '../types'
4import { openItems } from './checklist'
5
6export const STALE_AFTER_MS = 24 * 60 * 60 * 1000
7
8export function fileFor(dir: string, sessionId: string): string {
9 return `${dir}/${sessionId}.json`
10}
11
12export function basename(path: string): string {
13 const parts = path.replace(/[\\/]+$/, '').split(/[\\/]/)
14 return parts[parts.length - 1] || path
15}
16
17export function clip(text: string, max: number): string {
18 const oneLine = text.replace(/\s+/g, ' ').trim()
19 return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`
20}
21
22export function serialize(session: BoardSession): string {
23 return JSON.stringify(session, null, 2) + '\n'
24}
25
26const STATUSES: readonly ItemStatus[] = ['pending', 'in_progress', 'completed']
27
28function isRecord(value: unknown): value is Record<string, unknown> {
29 return typeof value === 'object' && value !== null && !Array.isArray(value)
30}
31
32function parseItem(value: unknown): ChecklistItem | undefined {
33 if (!isRecord(value)) return undefined
34 const { id, text, source, status, ticked, taskId } = value
35 if (typeof id !== 'number' || typeof text !== 'string') return undefined
36 if (source !== 'todo' && source !== 'manual' && source !== 'followup') return undefined
37 if (!STATUSES.includes(status as ItemStatus)) return undefined
38 const item: ChecklistItem = { id, text, source, status: status as ItemStatus, ticked: ticked === true }
39 if (typeof taskId === 'string' && taskId !== '') item.taskId = taskId
40 return item
41}
42
43/**
44 * Reads one board file. Returns undefined for anything that is not a board
45 * session, so a malformed file is skipped instead of breaking the board.
46 */
47export function parse(text: string): BoardSession | undefined {
48 let raw: unknown
49 try {
50 raw = JSON.parse(text)
51 } catch {
52 return undefined
53 }
54 return parseValue(raw)
55}
56
57/** As `parse`, for a value already decoded from JSON. */
58export function parseValue(raw: unknown): BoardSession | undefined {
59 if (!isRecord(raw)) return undefined
60 const { sessionId, project, title, cwd, summary, items, updatedAt, endedAt, machine, tty, url } = raw
61 if (typeof sessionId !== 'string' || sessionId === '') return undefined
62 if (typeof project !== 'string' || typeof cwd !== 'string') return undefined
63 if (typeof updatedAt !== 'string' || Number.isNaN(Date.parse(updatedAt))) return undefined
64
65 const session: BoardSession = {
66 sessionId,
67 project,
68 cwd,
69 summary: typeof summary === 'string' ? summary : '',
70 items: Array.isArray(items) ? items.map(parseItem).filter((item): item is ChecklistItem => item !== undefined) : [],
71 updatedAt,
72 }
73 if (typeof title === 'string' && title.trim() !== '') session.title = title.trim()
74 if (typeof endedAt === 'string') session.endedAt = endedAt
75 if (typeof machine === 'string' && machine !== '') session.machine = machine
76 if (typeof tty === 'string' && tty.startsWith('/dev/')) session.tty = tty
77 if (typeof url === 'string' && url.startsWith('https://')) session.url = url
78 return session
79}
80
81/**
82 * Local files and the shared gist, one entry per session id: when both hold
83 * a session, the newer `updatedAt` wins.
84 */
85export function mergeSessions(local: readonly BoardSession[], remote: readonly BoardSession[]): BoardSession[] {
86 const byId = new Map<string, BoardSession>()
87 for (const session of [...remote, ...local]) {
88 const known = byId.get(session.sessionId)
89 if (known === undefined || Date.parse(session.updatedAt) >= Date.parse(known.updatedAt)) byId.set(session.sessionId, session)
90 }
91 return [...byId.values()]
92}
93
94export function isStale(session: BoardSession, nowMs: number): boolean {
95 const updated = Date.parse(session.updatedAt)
96 return Number.isNaN(updated) || nowMs - updated > STALE_AFTER_MS
97}
98
99/** A session that still shows on the live board: not ended, not stale. */
100export function isLive(session: BoardSession, nowMs: number): boolean {
101 return session.endedAt === undefined && !isStale(session, nowMs)
102}
103
104/**
105 * A prompt as a person wrote it: the tagged blocks Claude Code injects
106 * (`<task-notification>`, `<artifact-view-context ...>` and the like) and
107 * any line that is only a tag are dropped.
108 */
109export function cleanPrompt(text: string): string {
110 const withoutBlocks = text.replace(/<([a-zA-Z][\w-]*)\b[^>]*>[\s\S]*?<\/\1\s*>/g, '\n')
111 return withoutBlocks
112 .split('\n')
113 .map(line => line.trim())
114 .filter(line => line !== '' && !/^<[^>]*>$/.test(line))
115 .join('\n')
116}
117
118/**
119 * The cheap summary: the in-progress task first, then the first pending one,
120 * then the start of the last prompt. `fromTask` says whether the task list
121 * gave an answer, so the caller knows when a model call might help.
122 */
123export function cheapSummary(
124 items: readonly ChecklistItem[],
125 lastPrompt: string,
126): { text: string; fromTask: boolean } {
127 const tasks = items.filter(item => item.source === 'todo')
128 const active = tasks.find(item => item.status === 'in_progress') ?? tasks.find(item => item.status === 'pending')
129 if (active !== undefined) return { text: clip(active.text, 100), fromTask: true }
130 const firstLine = cleanPrompt(lastPrompt).split('\n')[0] ?? ''
131 return { text: clip(firstLine, 100), fromTask: false }
132}
133
134function age(session: BoardSession, nowMs: number): string {
135 const ms = Math.max(0, nowMs - Date.parse(session.updatedAt))
136 const minutes = Math.round(ms / 60000)
137 if (minutes < 60) return `${minutes}m`
138 const hours = Math.round(minutes / 60)
139 if (hours < 48) return `${hours}h`
140 return `${Math.round(hours / 24)}d`
141}
142
143/** What a session is called on the board: its title when it has one, else its project. */
144export function displayName(session: Pick<BoardSession, 'project' | 'title'>): string {
145 return session.title ?? session.project
146}
147
148/**
149 * One board line: a marker, the session's name, the summary and the open
150 * count. A session on another machine than `homeMachine` is tagged with its machine.
151 */
152export function boardLine(session: BoardSession, isCurrent: boolean, nowMs: number, homeMachine?: string): string {
153 const open = openItems(session.items).length
154 const marker = isCurrent ? '▶' : ' '
155 const summary = session.summary === '' ? '—' : session.summary
156 const where = session.machine !== undefined && session.machine !== homeMachine ? ` · @${session.machine}` : ''
157 const stale = isStale(session, nowMs) ? ` · stale ${age(session, nowMs)}` : ''
158 return `${marker} ${displayName(session)} · ${summary} · ${open} open${where}${stale}`
159}
160
161/** Current session first, then the rest by most recent update. */
162export function sortSessions(sessions: readonly BoardSession[], currentId: string): BoardSession[] {
163 return [...sessions].sort((a, b) => {
164 if (a.sessionId === currentId) return -1
165 if (b.sessionId === currentId) return 1
166 return Date.parse(b.updatedAt) - Date.parse(a.updatedAt)
167 })
168}
169
170/** The tag drawn after an item that is not one of Claude's tasks. */
171export function sourceTag(item: ChecklistItem): string {
172 if (item.source === 'manual') return ' (manual)'
173 if (item.source === 'followup') return ' (loose end)'
174 return ''
175}
176
177function mark(item: ChecklistItem): string {
178 if (item.status === 'completed') return '[x]'
179 if (item.status === 'in_progress') return '[>]'
180 return '[ ]'
181}
182
183/** What /board prints: every session, its open items and how to get back to it. */
184export function fullBoardText(sessions: readonly BoardSession[], currentId: string, nowMs: number, homeMachine?: string): string {
185 if (sessions.length === 0) return 'cc-board: no sessions on the board yet.'
186 const lines: string[] = [`cc-board · ${sessions.length} session(s)`, '']
187 for (const session of sortSessions(sessions, currentId)) {
188 const flags: string[] = []
189 if (session.sessionId === currentId) flags.push('this session')
190 if (session.endedAt !== undefined) flags.push('ended')
191 else if (isStale(session, nowMs)) flags.push(`stale, ${age(session, nowMs)}`)
192 const suffix = flags.length > 0 ? ` (${flags.join(', ')})` : ''
193 lines.push(boardLine(session, session.sessionId === currentId, nowMs, homeMachine).trimStart() + suffix)
194 for (const item of openItems(session.items)) {
195 lines.push(` ${mark(item)} #${item.id} ${item.text}${sourceTag(item)}`)
196 }
197 lines.push(` cwd: ${session.cwd}`)
198 const elsewhere = session.machine !== undefined && session.machine !== homeMachine ? ` (on ${session.machine})` : ''
199 if (session.url !== undefined) lines.push(` open: ${session.url}`)
200 else lines.push(` resume: cd ${session.cwd} && claude --resume ${session.sessionId}${elsewhere}`)
201 lines.push('')
202 }
203 return lines.join('\n').trimEnd()
204}
205hooks/shared.ts 67 lines1// Pure helpers for the shared board in a Supabase table: one row per session
2// in `cc_board`, reached through the project's REST API. No engine access.
3import type { BoardSession } from '../types'
4import { parseValue } from './board'
5
6export const TABLE = 'cc_board'
7
8export function tableUrl(projectUrl: string): string {
9 return `${projectUrl.replace(/\/+$/, '')}/rest/v1/${TABLE}`
10}
11
12/** How many sessions one read brings back, newest first. Older rows are stale by then and prune removes them by time. */
13export const READ_LIMIT = 500
14
15/** The query that reads the newest rows' documents. */
16export function readUrl(projectUrl: string): string {
17 return `${tableUrl(projectUrl)}?select=payload&order=updated_at.desc&limit=${READ_LIMIT}`
18}
19
20/** The query that deletes every row last updated before `beforeIso`, whether a read returned it or not. */
21export function deleteBeforeUrl(projectUrl: string, beforeIso: string): string {
22 return `${tableUrl(projectUrl)}?updated_at=lt.${encodeURIComponent(beforeIso)}`
23}
24
25/** The query that deletes the named sessions' rows. */
26export function deleteUrl(projectUrl: string, sessionIds: readonly string[]): string {
27 const list = sessionIds.map(id => `"${id.replace(/"/g, '')}"`).join(',')
28 return `${tableUrl(projectUrl)}?session_id=in.(${encodeURIComponent(list)})`
29}
30
31export function sharedHeaders(key: string): Record<string, string> {
32 return {
33 apikey: key,
34 authorization: `Bearer ${key}`,
35 'content-type': 'application/json',
36 accept: 'application/json',
37 }
38}
39
40/** Headers for an upsert: a row that exists is replaced, nothing is echoed back. */
41export function upsertHeaders(key: string): Record<string, string> {
42 return { ...sharedHeaders(key), prefer: 'resolution=merge-duplicates,return=minimal' }
43}
44
45/** The body of an upsert of this session's row. */
46export function upsertBody(session: BoardSession): string {
47 return JSON.stringify([{ session_id: session.sessionId, payload: session, updated_at: session.updatedAt }])
48}
49
50/** The sessions a read answered. A row whose payload is not a board session is skipped. */
51export function parseRows(text: string): BoardSession[] {
52 let raw: unknown
53 try {
54 raw = JSON.parse(text)
55 } catch {
56 return []
57 }
58 if (!Array.isArray(raw)) return []
59 const sessions: BoardSession[] = []
60 for (const row of raw) {
61 if (typeof row !== 'object' || row === null) continue
62 const session = parseValue((row as { payload?: unknown }).payload)
63 if (session !== undefined) sessions.push(session)
64 }
65 return sessions
66}
67types/index.d.ts 63 lines1// The mod's type contract. `claude plugin validate` checks every `$.state`
2// value the hooks module names against `PluginState` below, and the hooks
3// module imports these types from '../types'.
4
5/** Where a checklist item came from: Claude's task list, a /manual or MANUAL: step, or a loose end surfaced from the conversation. */
6export type ItemSource = 'todo' | 'manual' | 'followup'
7
8/** The same three states TodoWrite uses. */
9export type ItemStatus = 'pending' | 'in_progress' | 'completed'
10
11/** One line of this session's checklist. */
12export type ChecklistItem = {
13 /** Short number shown in the pane and typed after /done. Stable for the session. */
14 id: number
15 text: string
16 source: ItemSource
17 status: ItemStatus
18 /** True once the person ticked it themselves. A ticked item stays done. */
19 ticked: boolean
20 /** The id Claude Code gave the task, for items that came from TaskCreate. */
21 taskId?: string
22}
23
24/** The checklist plus the counter that hands out ids. */
25export type Checklist = {
26 items: ChecklistItem[]
27 nextId: number
28}
29
30/** What one session writes to ~/.claude/board/<session-id>.json. */
31export type BoardSession = {
32 sessionId: string
33 project: string
34 /** The session's own title, as the Desktop app's session list shows it, when it has one. */
35 title?: string
36 cwd: string
37 /** One sentence on what the session is handling right now. */
38 summary: string
39 items: ChecklistItem[]
40 /** ISO 8601 time of the last write. */
41 updatedAt: string
42 /** ISO 8601 time the session ended, once it has. */
43 endedAt?: string
44 /** Where the session runs: a short host name, or "cloud". */
45 machine?: string
46 /** The terminal the session runs in, such as /dev/ttys003, when it has one. */
47 tty?: string
48 /** Where a cloud session opens in a browser or the Desktop app: its claude.ai/code page. */
49 url?: string
50}
51
52declare module 'claude-code' {
53 interface PluginState {
54 'cc-board': {
55 checklist: Checklist
56 summary: string
57 board: BoardSession[]
58 /** Whether the pane lists completed items as well as open ones. */
59 showDone: boolean
60 }
61 }
62}
63