SLOPSHOPPER

cc-board

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-21timjamwoo/claude-mods/cc-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-board
│ ┃ cc-board ✕ › fix the failing auth test and add an audit log call │ ┃ app · 0 open │ ┃ OK ● cc-board: cc-board: checklist restored from transcript │ ┃ No tasks yet. /manual <text> adds a step of ⏺ Read(src/auth.ts) │ ┃ your own. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /manual │ ⎿ cc-board: Usage: /manual <step you will do by hand> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cc-board
app · 0 open OK No tasks yet. /manual <text> adds a step of your own.
README

cc-board

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).

What you get

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.

Share the board across machines and cloud sessions

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.

Install for development

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.

Install from the marketplace

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.

Check it

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.

Files

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

What it does not do

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.

Source 5 files
hooks/register.tsx 1116 lines
1// 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}
1116
hooks/checklist.ts 397 lines
1// 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}
397
hooks/board.ts 205 lines
1// 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}
205
hooks/shared.ts 67 lines
1// 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}
67
types/index.d.ts 63 lines
1// 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