One global ledger of the todos, parked work, proposals and discussions you and Claude leave behind, across every project, in a ToDo's sidebar

A Claude Code mod that keeps one global ledger of the work you and Claude leave behind: todos, parked or deferred items, proposals and discussions, from every project and session. It lives in a ToDo's sidebar in the desktop app and the terminal.
/todo add.Works on Windows, macOS and Linux. Requires Claude Code v2.1.287 or later (mods support).
In Claude Code:
/plugin marketplace add SamAct/todo-ledger
/plugin install todo-ledger@todo-ledger
Then start a new session (or run /reload-plugins). A ☰ ToDo's button appears in the prompt footer.
☰ ToDo's N: opens the sidebar. N is this project's open items.This project ▾ switches to all projects; tabs Agent / Human / Done; items grouped by what they need (Needs decision, To do, Discussion, Parked). Click an item to open its card.s start, d done, x drop, n note, a approve.Commands:
| Command | Does | ||
|---|---|---|---|
/todos | Toggle the sidebar | ||
/todos list [all] | Print open items (this project, or all) | ||
/todos refresh | Reload items written by other sessions | ||
| `/todo add [-d\ | -p\ | -n] <text>` | Add a todo (-d deferred, -p proposal, -n discussion) |
/todo done <id> | Mark an item done | ||
/todo delete <id> | Delete a dropped item forever |
Claude gets four tools: park_item, list_items, get_item and update_item. It keeps the ledger current by itself, and agents can only drop items, never delete them.
One JSON file per item in ~/.claude/todo-ledger/items/ (%USERPROFILE%\.claude\todo-ledger\items\ on Windows). Every Claude Code session on the machine shares it; open panes pick up changes within about 10 seconds. Nothing leaves your machine except the end-of-turn scan, which sends the last reply to Claude Haiku through your own Claude Code session.
claude plugin validate . --strict
claude plugin test .
claude --plugin-dir .
MIT
hooks/register.ts 978 lines1import { formatItem, formatList, parseTodoCommand, parseTodosArgs } from '../src/commands.ts'
2import { SCAN_SYSTEM, buildScanPrompt, parseScanReply, parseScanUpdates, shouldScan } from '../src/scan.ts'
3import type { ScanUpdate } from '../src/scan.ts'
4import { KINDS, cleanTitle, clip, dedupeKey, wrapLines, isOpen, lastSessionTitle, makeItem, parseItem, randomHex4, sessionLabel, similarTitle } from '../src/item.ts'
5import type { Item, Kind, Origin, Source, Status } from '../src/item.ts'
6import { fallbackArgv, startOptions, startPrompt, terminalArgv, terminalTask } from '../src/start.ts'
7import type { StartChoice } from '../src/start.ts'
8import { TABS, age, bandLabel, displayOrder, firstView, groupItems, itemsForTab, tabOf } from '../src/view.ts'
9import type { Tab } from '../src/view.ts'
10
11// Every function below that takes $ must stay top-level in this file: the validator
12// rejects passing $ into functions imported from other files.
13
14let cache: Item[] = []
15const reportedBad = new Set<string>()
16let parkedThisTurn = false
17// True while the main loop is answering; a prompt sent then waits for the reply to end.
18let replying = false
19let lastUserText = ''
20let deletingId: string | null = null
21const STATUS_VALUES = ['open', 'in_progress', 'done', 'dropped']
22// What the card shows under an item's title, asked of every agent that adds one.
23const DETAIL_HINT = 'Optional but recommended: 2 to 4 short sentences in plain English, no jargon: what this task is ' +
24 'and why it is proposed or parked. Shown to the user in the item\'s card.'
25
26const PANE = 'todo-ledger'
27// The dock width asked for on open; a width the person dragged wins (the engine has no minimum).
28const PANE_COLUMNS = 72
29let paneOpen = false
30let tab: Tab = 'agent'
31let allProjects = false
32let expandedId: string | null = null
33let notingId: string | null = null
34let startingId: string | null = null
35let allNotesId: string | null = null
36// The list draws its first LIST_LIMIT rows (in display order) until the person asks for all:
37// fewer elements per draw keeps presses responsive on a long ledger.
38const LIST_LIMIT = 40
39let showAllRows = false
40let cwd = ''
41// One accent colour for what needs attention (the open card, the decision mark,
42// started items); everything else is hierarchy by weight, dimming and spacing.
43// A theme key, so light and dark themes both fit.
44const ACCENT = 'suggestion'
45// Faint tints for a row's project, session and age.
46const META = { project: '#5b8fd9', session: '#a57fd6', age: '#4fa88a' }
47// The same hues at low alpha, as pill backgrounds on the desktop. A coloured area
48// behind a plain Button keeps it pressable (a Button's own text takes no colour).
49const META_BG = { project: '#5b8fd92e', session: '#a57fd62e', age: '#4fa88a2e' }
50const CLOSED_MEMORY_MS = 30 * 86_400_000
51let lastLoadAt = 0
52const REFRESH_MS = 5000
53const SYNC_MS = 10_000
54let folderSig = ''
55// TodoWrite entries (by dedupe key) already linked to a ledger item in this session.
56const linked = new Map<string, string>()
57
58// The machine the session runs on: Windows uses USERPROFILE, '\' and cmd; macOS and
59// Linux use HOME, '/' and POSIX tools. Read once, from the environment.
60let host: { win: boolean; sep: string; home: string } | null = null
61
62async function hostOf($: any): Promise<{ win: boolean; sep: string; home: string }> {
63 if (host) return host
64 const win = (await $.env.get('OS')) === 'Windows_NT'
65 const home = (win ? await $.env.get('USERPROFILE') : undefined) || (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
66 if (!home) throw new Error('Neither HOME nor USERPROFILE is set, so the ledger folder cannot be found')
67 host = { win, sep: win ? '\\' : '/', home }
68 return host
69}
70
71// Moves a file over another, replacing it (how a save lands in one step).
72async function moveFile($: any, from: string, to: string): Promise<{ exitCode: number; stderr: string }> {
73 const h = await hostOf($)
74 return $.process.run(h.win ? ['cmd', '/c', 'move', '/y', from, to] : ['mv', '-f', from, to])
75}
76
77async function removeFile($: any, path: string): Promise<{ exitCode: number; stderr: string }> {
78 const h = await hostOf($)
79 return $.process.run(h.win ? ['cmd', '/c', 'del', '/q', path] : ['rm', '-f', path])
80}
81
82async function ledgerDir($: any): Promise<string> {
83 const h = await hostOf($)
84 return [h.home, '.claude', 'todo-ledger'].join(h.sep)
85}
86
87async function sep($: any): Promise<string> {
88 return (await hostOf($)).sep
89}
90
91async function itemsDir($: any): Promise<string> {
92 return (await ledgerDir($)) + (await sep($)) + 'items'
93}
94
95async function loadAll($: any): Promise<Item[]> {
96 const dir = await itemsDir($)
97 lastLoadAt = await $.clock.now()
98 if (!(await $.fs.exists(dir))) { cache = []; return cache }
99 const entries = await $.fs.list(dir)
100 const items: Item[] = []
101 for (const entry of entries) {
102 if (!entry.name.endsWith('.json')) continue
103 // A read can reject (over 4 MiB, permission, removed since the list); treat it like bad JSON.
104 let text: string | null
105 try { text = await $.fs.read(dir + (await sep($)) + entry.name) } catch { text = null }
106 const item = text === null ? null : parseItem(text)
107 if (item) items.push(item)
108 else if (!reportedBad.has(entry.name)) {
109 reportedBad.add(entry.name)
110 $.ui.log('skipped unreadable item file ' + entry.name)
111 }
112 }
113 cache = items
114 folderSig = signatureOf(entries)
115 return items
116}
117
118// What the items folder looks like on disk: changes when any session adds,
119// edits (size or mtime) or removes an item file.
120function signatureOf(entries: { name: string; size?: number; mtimeMs?: number }[]): string {
121 return entries
122 .filter((x) => x.name.endsWith('.json'))
123 .map((x) => x.name + ':' + (x.size ?? 0) + ':' + (x.mtimeMs ?? 0))
124 .sort()
125 .join('|')
126}
127
128// Background check: one directory listing; reload and redraw only when another
129// session (CLI or desktop) changed the folder since our last load.
130async function syncIfChanged($: any): Promise<void> {
131 try {
132 const dir = await itemsDir($)
133 const entries = (await $.fs.exists(dir)) ? await $.fs.list(dir) : []
134 if (signatureOf(entries) === folderSig) return
135 await loadAll($)
136 $.ui.invalidate('ui.render')
137 } catch { /* a failed check waits for the next tick */ }
138}
139
140// The pane's Refresh button and `/todos refresh`.
141async function forceRefresh($: any): Promise<number> {
142 await loadAll($)
143 $.ui.invalidate('ui.render')
144 return cache.length
145}
146
147async function saveItem($: any, item: Item): Promise<void> {
148 // $.fs.write creates missing parent directories, so no separate mkdir step.
149 const dir = await itemsDir($)
150 const path = dir + (await sep($)) + item.id + '.json'
151 await $.fs.write(path + '.tmp', JSON.stringify(item, null, 2))
152 const r = await moveFile($, path + '.tmp', path)
153 if (r.exitCode !== 0) throw new Error('could not save ' + item.id + ': ' + r.stderr.trim())
154 cache = [...cache.filter((i) => i.id !== item.id), item]
155 $.ui.invalidate('ui.render')
156}
157
158// The session's name (its tab title). The mods API has no call for it, so it is
159// read from the transcript's `custom-title` lines; null for an unnamed session.
160let sessionName: string | null = null
161let transcriptPath: string | null = null
162
163async function lookupSessionName($: any): Promise<string | null> {
164 try {
165 if (!transcriptPath) {
166 const id = await $.session.id()
167 const h = await hostOf($)
168 const projects = [h.home, '.claude', 'projects'].join(h.sep)
169 const found = await $.process.run(h.win
170 ? ['cmd', '/c', 'dir', '/s', '/b', projects + h.sep + id + '.jsonl']
171 : ['find', projects, '-name', id + '.jsonl'])
172 const first = found.exitCode === 0 ? found.stdout.split(/\r?\n/).find((l: string) => l.trim()) : undefined
173 if (!first) return sessionName
174 transcriptPath = first.trim()
175 }
176 // findstr / grep print only lines that begin with a title record, so a large
177 // transcript is never read whole (`.` matches the quotes in the regex).
178 const r = await $.process.run((await hostOf($)).win
179 ? ['findstr', '/r', '/b', '/c:{.type.:.custom-title.', transcriptPath]
180 : ['grep', '^{.type.:.custom-title.', transcriptPath])
181 sessionName = lastSessionTitle(r.stdout) ?? sessionName
182 } catch { /* keep the last known name */ }
183 return sessionName
184}
185
186async function capture(
187 $: any,
188 f: { title: string; kind: Kind; origin: Origin; source: Source; body?: string },
189): Promise<{ item: Item; isNew: boolean }> {
190 const project = await $.session.cwd()
191 const items = await loadAll($)
192 const key = dedupeKey(f.title, project)
193 const now = await $.clock.now()
194 const at = new Date(now).toISOString()
195 // Same item when the title matches exactly, or is a rewording of an open item in this project.
196 const existing = items.find((i) => isOpen(i) && dedupeKey(i.title, i.project) === key) ??
197 items.find((i) => isOpen(i) && i.project.toLowerCase() === project.toLowerCase() && similarTitle(i.title, f.title))
198 if (existing) {
199 // The scan re-reading a known item says nothing new; a note per turn was only noise.
200 if (f.source === 'scan' || f.source === 'todowrite') return { item: existing, isNew: false }
201 const reworded = dedupeKey(existing.title, existing.project) !== key ? ' as "' + cleanTitle(f.title) + '"' : ''
202 const text = 'Captured again (' + f.source + ')' + reworded + (f.body ? ': ' + f.body : '')
203 if (existing.notes.at(-1)?.text === text) return { item: existing, isNew: false }
204 const updated = { ...existing, updatedAt: at, notes: [...existing.notes, { by: f.origin, text, at }] }
205 await saveItem($, updated)
206 return { item: updated, isNew: false }
207 }
208 // The scan must not bring back what was just finished or turned down when a
209 // later reply mentions it again; a person or an agent asking on purpose still can.
210 if (f.source === 'scan') {
211 const closed = items.find((i) => !isOpen(i) && i.project.toLowerCase() === project.toLowerCase() &&
212 now - Date.parse(i.updatedAt) < CLOSED_MEMORY_MS && (dedupeKey(i.title, i.project) === key || similarTitle(i.title, f.title)))
213 if (closed) return { item: closed, isNew: false }
214 }
215 const sessionId = await $.session.id()
216 const name = sessionName ?? (await lookupSessionName($))
217 const dir = await itemsDir($)
218 // Ids are second-resolution plus 4 hex chars and `move /y` overwrites, so check before the first write.
219 let item = makeItem({ ...f, title: cleanTitle(f.title), project, sessionId, sessionName: name ?? undefined, nowMs: now, hex4: randomHex4() })
220 for (let tries = 0; tries < 5 && (await $.fs.exists(dir + (await sep($)) + item.id + '.json')); tries++) {
221 item = makeItem({ ...f, title: cleanTitle(f.title), project, sessionId, sessionName: name ?? undefined, nowMs: now, hex4: randomHex4() })
222 }
223 await saveItem($, item)
224 return { item, isNew: true }
225}
226
227// Called while drawing: one folder listing at most every few seconds, and a full
228// reload only when the folder changed (reading every item file per draw was slow).
229async function refreshIfStale($: any): Promise<void> {
230 try {
231 const now = await $.clock.now()
232 if (now - lastLoadAt < REFRESH_MS) return
233 lastLoadAt = now
234 await syncIfChanged($)
235 } catch { /* keep the old cache; the next command or tool reports the error */ }
236}
237
238async function setStatus($: any, id: string, status: Status, note?: string): Promise<Item | null> {
239 const items = await loadAll($)
240 const found = items.filter((i) => i.id.startsWith(id))
241 if (found.length !== 1) return null
242 const at = new Date(await $.clock.now()).toISOString()
243 const notes = note ? [...found[0].notes, { by: 'agent' as Origin, text: note, at }] : found[0].notes
244 const updated = { ...found[0], status, updatedAt: at, notes }
245 await saveItem($, updated)
246 return updated
247}
248
249async function mirrorTodos($: any, todos: { content: string; status: string }[]): Promise<void> {
250 const project = await $.session.cwd()
251 const sessionId = await $.session.id()
252 for (const todo of todos) {
253 if (typeof todo?.content !== 'string' || !todo.content.trim()) continue
254 const items = await loadAll($)
255 const key = dedupeKey(todo.content, project)
256 const sameKey = (i: Item) => dedupeKey(i.title, i.project) === key
257 // Link to this session's earlier mirror, else to any open item with the same key and project
258 // (parked, scanned or manual), so repeated TodoWrite calls never add "Captured again" notes.
259 const linkedId = linked.get(key)
260 const mine =
261 (linkedId ? items.find((i) => i.id === linkedId) : undefined) ??
262 items.find((i) => i.source === 'todowrite' && i.sessionId === sessionId && sameKey(i)) ??
263 items.find((i) => isOpen(i) && sameKey(i))
264 const status: Status = todo.status === 'completed' ? 'done' : todo.status === 'in_progress' ? 'in_progress' : 'open'
265 if (mine) {
266 linked.set(key, mine.id)
267 if (mine.status !== status && isOpen(mine)) {
268 await saveItem($, { ...mine, status, updatedAt: new Date(await $.clock.now()).toISOString() })
269 }
270 continue
271 }
272 if (status === 'done') continue
273 const r = await capture($, { title: todo.content, kind: 'todo', origin: 'agent', source: 'todowrite' })
274 linked.set(key, r.item.id)
275 if (r.isNew && status !== 'open') await saveItem($, { ...r.item, status })
276 }
277 // TodoWrite sends the whole list each time: an entry it no longer carries was
278 // removed from the session's plan, so its mirror is dropped rather than left open forever.
279 const keep = new Set(todos.filter((t) => typeof t?.content === 'string').map((t) => dedupeKey(t.content, project)))
280 const at = new Date(await $.clock.now()).toISOString()
281 for (const i of await loadAll($)) {
282 if (i.source !== 'todowrite' || i.sessionId !== sessionId || !isOpen(i) || keep.has(dedupeKey(i.title, i.project))) continue
283 await saveItem($, { ...i, status: 'dropped', updatedAt: at, notes: [...i.notes, { by: 'agent' as Origin, text: 'removed from the session\'s task list', at }] })
284 }
285}
286
287// What the scan concluded about an open item, applied with a note saying why.
288async function applyUpdate($: any, item: Item, u: ScanUpdate): Promise<void> {
289 const at = new Date(await $.clock.now()).toISOString()
290 const note = (text: string) => [...item.notes, { by: 'agent' as Origin, text, at }]
291 if (u.action === 'approve') {
292 await saveItem($, { ...item, kind: item.kind === 'proposal' ? 'todo' : item.kind, updatedAt: at, notes: note('approved by the user (seen by the end-of-turn scan)') })
293 } else if (u.action === 'reject') {
294 await saveItem($, { ...item, status: 'dropped', updatedAt: at, notes: note('rejected by the user (seen by the end-of-turn scan)') })
295 } else if (u.action === 'done') {
296 await saveItem($, { ...item, status: 'done', updatedAt: at, notes: note('done (seen by the end-of-turn scan)') })
297 } else {
298 await saveItem($, { ...item, status: 'dropped', updatedAt: at, notes: note('duplicate of ' + u.of + ' (seen by the end-of-turn scan)') })
299 }
300}
301
302async function runScan($: any, answer: string, userText: string): Promise<void> {
303 try {
304 const project = (await $.session.cwd()).toLowerCase()
305 const open = (await loadAll($)).filter((i) => isOpen(i) && i.project.toLowerCase() === project)
306 const r = await $.model.complete({
307 model: 'haiku',
308 system: SCAN_SYSTEM,
309 prompt: buildScanPrompt(answer, open.map((i) => ({ id: i.id, title: i.title, kind: i.kind })), userText),
310 maxTokens: 1200,
311 timeoutMs: 20000,
312 })
313 if (!r.isAnswered) { $.ui.log('scan skipped: ' + r.reason); return }
314 const found = parseScanReply(r.text)
315 if (!found) { $.ui.log('scan skipped: reply was not JSON'); return }
316 // Updates first, against the items the prompt listed, so new captures cannot be touched.
317 for (const u of parseScanUpdates(r.text, open.map((i) => i.id))) {
318 const item = open.find((i) => i.id === u.id)
319 if (item) await applyUpdate($, item, u)
320 }
321 for (const f of found) await capture($, { title: f.title, kind: f.kind, origin: 'agent', source: 'scan', body: f.detail })
322 } catch (err: any) {
323 $.ui.log('scan skipped: ' + err.message)
324 }
325}
326
327async function togglePane($: any): Promise<void> {
328 if (paneOpen) {
329 paneOpen = false
330 await $.ui.close({ id: PANE })
331 return
332 }
333 try { await loadAll($) } catch (err: any) { $.ui.toast('Ledger not loaded: ' + err.message) }
334 // Open on something to look at: a tab with items, and every project when this one has none.
335 let here = cwd
336 try { here = await $.session.cwd() } catch { /* keep the session.start value */ }
337 const view = firstView(cache, here, tab, allProjects)
338 tab = view.tab
339 allProjects = view.allProjects
340 const r = await $.ui.open({ id: PANE, title: "ToDo's", focus: true, closeOnEscape: true, columns: PANE_COLUMNS })
341 // Only a placed pane counts as open; otherwise the next press must try to open it again.
342 paneOpen = r?.isPlaced === true
343 if (!paneOpen) $.ui.toast("Could not open the ToDo's pane: " + (r?.reason ?? 'unknown reason'))
344 $.ui.invalidate('ui.render')
345}
346
347async function safeToggle($: any): Promise<void> {
348 try { await togglePane($) } catch (err: any) { $.ui.toast("ToDo's pane failed: " + err.message) }
349}
350
351async function markStatus($: any, id: string, status: Status): Promise<void> {
352 try { await setStatus($, id, status) } catch (err: any) { $.ui.toast('ToDo not saved: ' + err.message) }
353}
354
355async function addNote($: any, id: string, text: string): Promise<void> {
356 const item = cache.find((i) => i.id === id)
357 if (!item || !text.trim()) return
358 const at = new Date(await $.clock.now()).toISOString()
359 try {
360 await saveItem($, { ...item, updatedAt: at, notes: [...item.notes, { by: 'human' as Origin, text: text.trim(), at }] })
361 } catch (err: any) {
362 $.ui.toast('Note not saved: ' + err.message)
363 }
364}
365
366async function copyText($: any, text: string, okMessage: string, failPrefix: string): Promise<void> {
367 const r = await $.ui.copy({ text })
368 $.ui.toast(r?.isCopied ? okMessage : failPrefix + ' (' + (r?.reason ?? 'unknown') + ')')
369}
370
371async function doStart($: any, item: Item, choice: StartChoice): Promise<void> {
372 const text = startPrompt(item, cwd || (await $.session.cwd()))
373 try {
374 if (choice === 'send') {
375 await saveItem($, { ...item, status: 'in_progress', updatedAt: new Date(await $.clock.now()).toISOString() })
376 // A plugin's prompt waits until Claude is idle, so say so at once instead of
377 // looking like nothing happened while a reply is still running.
378 $.ui.toast(replying ? 'Queued: starts when the current reply finishes' : 'Sending: ' + clip(item.title, 40))
379 let dropped: string | null = null
380 try {
381 const r = await $.prompt.submit({ text, asUser: true })
382 if (r && typeof r.drop === 'string') dropped = r.drop
383 } catch (err: any) { dropped = err.message }
384 if (dropped !== null) {
385 await saveItem($, { ...item, updatedAt: new Date(await $.clock.now()).toISOString() })
386 $.ui.toast('Prompt not sent: ' + dropped)
387 }
388 } else if (choice === 'draft') {
389 const r = await $.prompt.fill({ text })
390 if (!r.isFilled) await copyText($, text, 'Could not fill the prompt; copied instead', 'Could not fill or copy the prompt')
391 } else if (choice === 'copy') {
392 await copyText($, text, 'Prompt copied', 'Prompt not copied')
393 } else {
394 // The full prompt goes in a file; the command line carries a one-line pointer to it.
395 const s = await sep($)
396 const file = (await ledgerDir($)) + s + 'start' + s + item.id + '.md'
397 await $.fs.write(file, text)
398 const task = terminalTask(item.id, file)
399 let ok = false
400 // Opening a new terminal window is wired for Windows (wt, else cmd start); elsewhere the prompt is copied.
401 if (!(await hostOf($)).win) {
402 await copyText($, text, 'Prompt copied: start claude in a new terminal and paste it', 'Prompt not copied')
403 return
404 }
405 try { ok = (await $.process.run(terminalArgv(item.project, task))).exitCode === 0 } catch { ok = false }
406 if (!ok) {
407 try { ok = (await $.process.run(fallbackArgv(item.project, task))).exitCode === 0 } catch { ok = false }
408 }
409 if (!ok) await copyText($, text, 'Could not open a terminal; prompt copied instead', 'Could not open a terminal or copy the prompt')
410 }
411 } catch (err: any) {
412 $.ui.toast('Start failed: ' + err.message)
413 }
414}
415
416// The Start step: one button per way to start, in place of the card's actions, so
417// a choice is a single press (a picker needed focus, arrows and Enter). The first
418// is the main one; Cancel is plain and dim so it never competes.
419function startChooser($: any, e: any, item: Item) {
420 const { Button } = $.ui.resolve(e)
421 const redraw = () => $.ui.invalidate('ui.render')
422 return [
423 ...startOptions(e.surface === 'desktop' ? 'desktop' : 'terminal').map((o, k) =>
424 Button({ key: 'start-' + o.value + '-' + item.id, label: o.label, hotkey: o.hotkey,
425 ...(k === 0 ? { variant: 'primary' as const, autoFocus: true as const } : { plain: true as const }),
426 onPress: async () => { startingId = null; await doStart($, item, o.value as StartChoice); redraw() } })),
427 Button({ key: 'start-cancel-' + item.id, label: 'Cancel', plain: true, dimColor: true, onPress: () => { startingId = null; redraw() } }),
428 ]
429}
430
431// Permanent deletion: only for items already dropped, only by the person (pane or
432// /todo delete). Agents' tools can drop but never delete.
433async function deleteItem($: any, idPrefix: string): Promise<{ ok: true; title: string } | { ok: false; message: string }> {
434 const items = await loadAll($)
435 const found = items.filter((i) => i.id.startsWith(idPrefix))
436 if (found.length !== 1) return { ok: false, message: 'No single item matches id ' + idPrefix }
437 const item = found[0]
438 if (item.status !== 'dropped') return { ok: false, message: 'Only dropped items can be deleted. Drop it first: ' + item.title }
439 if (!/^[A-Za-z0-9_-]+$/.test(item.id)) return { ok: false, message: 'Refused: unexpected characters in id ' + item.id }
440 const path = (await itemsDir($)) + (await sep($)) + item.id + '.json'
441 const r = await removeFile($, path)
442 if (r.exitCode !== 0 || (await $.fs.exists(path))) return { ok: false, message: 'Could not delete ' + item.title + ': ' + (r.stderr.trim() || 'file still there') }
443 cache = cache.filter((i) => i.id !== item.id)
444 $.ui.invalidate('ui.render')
445 return { ok: true, title: item.title }
446}
447
448async function runTodo($: any, args: string): Promise<{ text: string }> {
449 const cmd = parseTodoCommand(args)
450 if (cmd.action === 'error') return { text: cmd.message }
451 try {
452 if (cmd.action === 'done') {
453 const item = await setStatus($, cmd.idPrefix, 'done')
454 return { text: item ? 'Done: ' + item.title : 'No single item matches id ' + cmd.idPrefix }
455 }
456 if (cmd.action === 'delete') {
457 const r = await deleteItem($, cmd.idPrefix)
458 return { text: r.ok ? 'Deleted forever: ' + r.title : r.message }
459 }
460 const r = await capture($, { title: cmd.text, kind: cmd.kind, origin: 'human', source: 'manual' })
461 return { text: (r.isNew ? 'Added ' : 'Already listed, noted: ') + r.item.title }
462 } catch (err: any) {
463 $.ui.toast('ToDo not saved: ' + err.message)
464 return { text: 'Not saved: ' + err.message }
465 }
466}
467
468async function runTodos($: any, args: string): Promise<{ text?: string }> {
469 const cmd = parseTodosArgs(args)
470 if (cmd.action === 'list') {
471 try { return { text: formatList(await loadAll($), await $.session.cwd(), cmd.all) } }
472 catch (err: any) { return { text: 'Not loaded: ' + err.message } }
473 }
474 if (cmd.action === 'refresh') {
475 try {
476 await forceRefresh($)
477 return { text: 'Ledger refreshed: ' + cache.filter(isOpen).length + ' open.' }
478 } catch (err: any) { return { text: 'Not loaded: ' + err.message } }
479 }
480 await safeToggle($)
481 return {}
482}
483
484// Registers a command under its preferred name, or under the fallback name if that one is refused.
485async function registerCommand($: any, spec: any, fallback: string): Promise<void> {
486 try {
487 await $.command.register(spec)
488 } catch (err: any) {
489 $.ui.log('/' + spec.name + ' not registered (' + err.message + '); using /' + fallback)
490 try { await $.command.register({ ...spec, name: fallback }) }
491 catch (err2: any) { $.ui.log('/' + fallback + ' not registered either: ' + err2.message) }
492 }
493}
494
495async function registerTool($: any, spec: any): Promise<void> {
496 try { await $.tool.register(spec) } catch (err: any) { $.ui.log('tool ' + spec.name + ' not registered: ' + err.message) }
497}
498
499export function register(on) {
500 on('session.start', async ($, e, next) => {
501 try { cwd = await $.session.cwd() } catch { /* keep '' until a render reads it */ }
502 // Picks up items other sessions write; the timer stops when the module reloads.
503 $.clock.every(SYNC_MS, () => syncIfChanged($))
504 $.clock.after(0, () => lookupSessionName($))
505 await registerTool($, {
506 name: 'park_item',
507 description:
508 'Record an item in the user\'s global todo ledger. Call this, without asking first, whenever you ' +
509 'defer, park, postpone or put something out of scope, or propose work the user has not asked for. ' +
510 'If it may already be recorded, call list_items first and update that item instead of adding a second one. ' +
511 'kind: todo | deferred | proposal | discussion. Keep title to one line.',
512 inputSchema: {
513 type: 'object',
514 properties: {
515 title: { type: 'string' },
516 kind: { type: 'string', enum: ['todo', 'deferred', 'proposal', 'discussion'] },
517 body: { type: 'string', description: DETAIL_HINT },
518 },
519 required: ['title', 'kind'],
520 },
521 })
522 await registerTool($, {
523 name: 'list_items',
524 description:
525 'List open items in the user\'s todo ledger (their todos, parked work, proposals and discussions across ' +
526 'all projects and sessions). Use it whenever the user mentions todos, the ledger, parked or deferred work, ' +
527 'or asks what is pending. scope "project" (default) or "all". Each line gives the id to use with get_item and update_item.',
528 inputSchema: { type: 'object', properties: { scope: { type: 'string', enum: ['project', 'all'] } } },
529 })
530 await registerTool($, {
531 name: 'get_item',
532 description: 'Read one ledger item in full: title, details, project, session, status and its notes thread. id may be a unique prefix.',
533 inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
534 })
535 await registerTool($, {
536 name: 'update_item',
537 description:
538 'Update a ledger item: status (open, in_progress, done, dropped), title, kind, details, and/or add a note. ' +
539 'Keep the ledger current without being asked: as soon as the user approves a proposal set kind "todo"; when ' +
540 'they reject or drop it set status "dropped"; when an item\'s work is finished set status "done"; when two ' +
541 'items are the same, drop one with a note naming the other. ' +
542 'To remove an item, set status "dropped" (it moves to Done and can be reopened). id may be a unique prefix.',
543 inputSchema: {
544 type: 'object',
545 properties: {
546 id: { type: 'string' },
547 status: { type: 'string', enum: STATUS_VALUES },
548 title: { type: 'string' },
549 kind: { type: 'string', enum: ['todo', 'deferred', 'proposal', 'discussion'] },
550 body: { type: 'string', description: 'Replaces the item\'s details. ' + DETAIL_HINT },
551 note: { type: 'string', description: 'Appended to the notes thread' },
552 },
553 required: ['id'],
554 },
555 })
556 await registerCommand($, { name: 'todos', description: "Toggle the ToDo's pane", argumentHint: '[list [all] | refresh]', immediate: true }, 'ledger')
557 await registerCommand($, { name: 'todo', description: 'Add or close a ledger item', argumentHint: 'add [-d|-p|-n] <text> | done <id>' }, 'ledger-add')
558 // Warm the cache; a failure here must not block the session.
559 // The next loadAll (any command, tool or render) retries.
560 try { await loadAll($) } catch { cache = [] }
561 return next(e)
562 })
563
564 // After a press redraws the pane, the desktop loses the pane's focus ring, and the
565 // next click is spent taking it back (traced 2026-10-07: one press logged per two
566 // clicks). Putting the ring on the element the next press will want keeps every
567 // click live: the open card's Close, or the row title once the card closes.
568 on('ui.press', async ($, e, next) => {
569 const r = await next(e)
570 if (e.plugin !== 'todo-ledger' || e.requestId !== PANE) return r
571 const target = expandedId ? 'close-' + expandedId : e.element.replace(/^close-/, 'title-')
572 try { await $.ui.focus({ requestId: PANE, key: target }) } catch { /* a denied move leaves focus as it was */ }
573 return r
574 })
575
576 on('ui.close', async ($, e, next) => {
577 if (e.id === PANE) paneOpen = false
578 return next(e)
579 })
580
581 // The button lives in the prompt footer (the row with the permission-mode label),
582 // on the same line as the engine's own mode labels.
583 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
584 const theirs = await next(e)
585 await refreshIfStale($)
586 let here = cwd
587 try { here = await $.session.cwd() } catch { /* keep the session.start value */ }
588 const label = bandLabel(cache, here)
589 const { Box, Button } = $.ui.resolve(e)
590 const mine = Button({ key: 'ledger-toggle', label, plain: true, onPress: () => safeToggle($) })
591 return Box({ key: 'ledger-footer', flexDirection: 'row', columnGap: 2, alignItems: 'center',
592 children: theirs ? [mine, theirs] : [mine] })
593 })
594
595 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
596 if (e.requestId !== PANE) return next(e)
597 try {
598 const { Box, Text, Button, Input } = $.ui.resolve(e)
599 // Mobile's element table has no Input, so the note composer is skipped there.
600 const canInput = e.surface !== 'mobile'
601 const redraw = () => $.ui.invalidate('ui.render')
602 const now = await $.clock.now()
603 await refreshIfStale($)
604 // Read the cwd per render so the filter follows /cd and matches what capture stores.
605 let here = cwd
606 try { here = await $.session.cwd() } catch { /* keep the session.start value */ }
607 const rows = itemsForTab(cache, tab, allProjects ? null : here)
608 const ordered = displayOrder(rows, tab)
609 const head = ordered.slice(0, LIST_LIMIT)
610 const picked = ordered.find((x) => x.id === expandedId)
611 const shown = showAllRows ? ordered : picked && !head.includes(picked) ? [...head, picked] : head
612
613 const scoped = cache.filter((i) => allProjects || i.project.toLowerCase() === here.toLowerCase())
614 const count = (t: Tab) => scoped.filter((i) => tabOf(i) === t).length
615 const select = (id: string | null) => { expandedId = id; startingId = null; notingId = null; deletingId = null; redraw() }
616
617 // Closing an item from its card opens the next one, so a run of items is
618 // triaged with one press each instead of select, act, select again.
619 const closeItem = async (i: Item, status: Status, kindChange?: Kind) => {
620 const at = ordered.findIndex((x) => x.id === i.id)
621 const following = ordered.slice(at + 1).concat(ordered.slice(0, Math.max(0, at))).find((x) => x.id !== i.id)
622 try {
623 if (kindChange) {
624 const stamp = new Date(await $.clock.now()).toISOString()
625 await saveItem($, { ...i, kind: kindChange, updatedAt: stamp, notes: [...i.notes, { by: 'human' as Origin, text: 'approved', at: stamp }] })
626 } else await setStatus($, i.id, status)
627 } catch (err: any) { $.ui.toast('ToDo not saved: ' + err.message); return }
628 if (kindChange) { redraw(); return }
629 // Closed items are kept: say where they went.
630 $.ui.toast((status === 'dropped' ? 'Dropped, moved to Done: ' : 'Done: ') + clip(i.title, 40))
631 select(expandedId === i.id ? (following?.id ?? null) : expandedId)
632 }
633
634 // Row 1: where the list comes from, with a dim refresh at the far end. The open
635 // count is not repeated here: the tabs carry it.
636 const scopeRow = Box({
637 flexDirection: 'row',
638 justifyContent: 'space-between',
639 children: [
640 Button({ key: 'scope', plain: true, hotkey: 'p',
641 label: (allProjects ? 'All projects' : 'This project') + ' ▾',
642 onPress: () => { allProjects = !allProjects; showAllRows = false; select(null) } }),
643 Button({ key: 'refresh', plain: true, dimColor: true, hotkey: 'r', label: '↻',
644 onPress: async () => {
645 try { await forceRefresh($) } catch (err: any) { $.ui.toast('Refresh failed: ' + err.message) }
646 } }),
647 ],
648 })
649
650 // Row 2: who started it; the chosen tab at full strength, the others dim.
651 const tabRow = Box({
652 flexDirection: 'row',
653 columnGap: 3,
654 marginBottom: 1,
655 children: TABS.map((t) =>
656 Button({ key: 'tab-' + t.id, plain: true, hotkey: t.hotkey, dimColor: tab !== t.id,
657 label: t.label + ' ' + count(t.id),
658 onPress: () => { tab = t.id; showAllRows = false; select(null) } })),
659 })
660
661 // The card's actions in one fixed order, the first the primary. Proposals are
662 // decided (Approve / Reject) rather than worked, so they lead with that. While a
663 // note is being typed the Note button turns into Cancel and letter keys rest.
664 const actionsFor = (i: Item) => {
665 if (startingId === i.id) return startChooser($, e, i)
666 const noting = notingId === i.id
667 const hk = (k: string) => (notingId === null ? { hotkey: k } : {})
668 if (tab === 'done') {
669 const reopen = Button({ key: 'reopen-' + i.id, label: 'Reopen', variant: 'primary', ...hk('o'), onPress: () => markStatus($, i.id, 'open') })
670 if (i.status !== 'dropped') return [reopen]
671 // Two-step: the first press arms it; only Confirm deletes the file. Neither is
672 // primary: nothing should invite an irreversible press.
673 if (deletingId === i.id) {
674 return [
675 Button({ key: 'delete-confirm-' + i.id, label: 'Confirm delete', onPress: async () => {
676 deletingId = null
677 const r = await deleteItem($, i.id)
678 if (!r.ok) $.ui.toast(r.message)
679 else { expandedId = null; $.ui.toast('Deleted forever: ' + r.title) }
680 redraw()
681 } }),
682 Button({ key: 'delete-cancel-' + i.id, label: 'Cancel', plain: true, dimColor: true, onPress: () => { deletingId = null; redraw() } }),
683 ]
684 }
685 return [reopen, Button({ key: 'delete-' + i.id, label: 'Delete forever', dimColor: true, onPress: () => { deletingId = i.id; redraw() } })]
686 }
687 const start = Button({ key: 'start-' + i.id, label: 'Start', ...hk('s'),
688 ...(i.kind === 'proposal' ? {} : { variant: 'primary' as const }),
689 onPress: () => { startingId = i.id; notingId = null; redraw() } })
690 const note = !canInput ? []
691 : noting
692 ? [Button({ key: 'note-cancel-' + i.id, label: 'Cancel', plain: true, dimColor: true, onPress: () => { notingId = null; redraw() } })]
693 : [Button({ key: 'note-' + i.id, label: 'Note', ...hk('n'), onPress: () => { notingId = i.id; startingId = null; redraw() } })]
694 if (i.kind === 'proposal') {
695 return [
696 Button({ key: 'approve-' + i.id, label: 'Approve', variant: 'primary', ...hk('a'), onPress: () => closeItem(i, i.status, 'todo') }),
697 Button({ key: 'drop-' + i.id, label: 'Reject', ...hk('x'), onPress: () => closeItem(i, 'dropped') }),
698 start,
699 ...note,
700 ]
701 }
702 return [
703 start,
704 Button({ key: 'done-' + i.id, label: 'Done', ...hk('d'), onPress: () => closeItem(i, 'done') }),
705 Button({ key: 'drop-' + i.id, label: 'Drop', ...hk('x'), onPress: () => closeItem(i, 'dropped') }),
706 ...note,
707 ]
708 }
709
710 // Row width in title characters. The desktop draws a proportional font, about
711 // 1.4 characters per cell, so text wraps later there to use the pane's real width.
712 const cols = Math.max(12, (e.props.bodyColumns ?? 40) - 1)
713 const wide = e.surface === 'desktop' ? 1.4 : 1
714 // Row chrome: mark and gap (2), a spare column.
715 const titleCols = Math.max(16, Math.floor((cols - 3) * wide))
716
717 const projectOf = (i: Item) => i.project.split(/[\\/]/).filter(Boolean).pop() ?? i.project
718 // Where and when, each part in its own faint tint (a mid-tone drawn dim, readable
719 // on light and dark). The project is named only when the list spans projects:
720 // inside one project it would be the same word on every row. Closed items show
721 // how long ago they closed, which is also the Done tab's order.
722 // On the desktop each part is a pill tinted at low alpha; given `open`, the
723 // pills are buttons, so the whole row opens the card. The terminal cannot draw
724 // alpha, so there the parts stay faintly tinted text.
725 const pills = e.surface === 'desktop'
726 const metaLine = (i: Item, open?: () => void) => {
727 const room = Math.max(12, titleCols - 8)
728 const part = (k: 'project' | 'session' | 'age', text: string) => {
729 if (!pills) return Text({ color: META[k], dimColor: true, wrap: 'truncate-end', children: [text] })
730 const inner = open
731 ? Button({ key: 'meta-' + k + '-' + i.id, plain: true, label: text, onPress: open })
732 : Text({ children: [text] })
733 return Box({ backgroundColor: META_BG[k], paddingX: 1, children: [inner] })
734 }
735 const dot = Text({ dimColor: true, children: ['·'] })
736 const when = part('age', age(now, tab === 'done' ? i.updatedAt : i.createdAt))
737 const session = part('session', clip(sessionLabel(i), allProjects ? Math.floor(room / 2) : room))
738 const parts = allProjects ? [part('project', clip(projectOf(i), Math.ceil(room / 2))), session, when] : [session, when]
739 // Pills sit apart on their own; text parts need the dot between them.
740 const children = pills ? parts : parts.flatMap((x, k) => (k ? [dot, x] : [x]))
741 return Box({ key: 'meta-' + i.id, flexDirection: 'row', columnGap: 1, children })
742 }
743 // A started item shows ▶ in place of its group mark.
744 const markOf = (i: Item, mark: string, color?: string) =>
745 i.status === 'in_progress' ? Text({ color: ACCENT, children: ['▶'] }) : Text({ color, dimColor: !color, children: [mark] })
746
747 // Scanning: a mark, the title on up to two lines (both open the card), the meta
748 // line. Rows are set apart by one blank line, none after a group's last; no
749 // borders, no rules.
750 const compactRow = (i: Item, mark: string, color: string | undefined, last: boolean) => {
751 const open = () => select(i.id)
752 const lines = wrapLines(i.title, titleCols, 2)
753 return Box({
754 key: 'row-' + i.id,
755 flexDirection: 'row',
756 columnGap: 1,
757 alignItems: 'flex-start',
758 ...(last ? {} : { marginBottom: 1 }),
759 children: [
760 markOf(i, mark, color),
761 Box({
762 flexDirection: 'column',
763 flexGrow: 1,
764 flexShrink: 1,
765 children: [
766 Button({ key: 'title-' + i.id, plain: true, label: lines[0] ?? '', onPress: open }),
767 ...(lines[1] ? [Button({ key: 'title2-' + i.id, plain: true, label: lines[1], onPress: open })] : []),
768 metaLine(i, open),
769 ],
770 }),
771 ],
772 })
773 }
774
775 // One note: who and when on a dim line, the text under it; no blank between notes.
776 const noteView = (n: { by: string; text: string; at: string }, k: number, id: string) =>
777 Box({
778 key: 'note-' + k + '-' + id,
779 flexDirection: 'column',
780 children: [
781 Text({ dimColor: true, children: [(n.by === 'human' ? 'You' : 'Claude') + ' · ' + age(now, n.at)] }),
782 Text({ wrap: 'wrap', children: [n.text] }),
783 ],
784 })
785
786 // Acting: the picked item opens into a card outlined in the accent. Inside, by
787 // weight: the title bold, the same meta line as a row, details, the latest notes
788 // (older ones one press away), the composer when open, then the actions. The kind
789 // is not repeated: the card sits under its group heading.
790 const selectedCard = (i: Item, mark: string, color?: string) => {
791 const noting = notingId === i.id && canInput
792 const showAll = allNotesId === i.id
793 const hidden = showAll ? 0 : Math.max(0, i.notes.length - 3)
794 return Box({
795 key: 'row-' + i.id,
796 flexDirection: 'column',
797 borderStyle: 'round',
798 borderColor: ACCENT,
799 paddingX: 1,
800 marginBottom: 1,
801 children: [
802 Box({
803 flexDirection: 'row',
804 columnGap: 1,
805 alignItems: 'flex-start',
806 children: [
807 markOf(i, mark, color),
808 Box({
809 flexDirection: 'column',
810 flexGrow: 1,
811 flexShrink: 1,
812 children: [
813 Text({ bold: true, wrap: 'wrap', children: [i.title] }),
814 metaLine(i),
815 ],
816 }),
817 Button({ key: 'close-' + i.id, plain: true, dimColor: true, label: 'Close', onPress: () => select(null) }),
818 ],
819 }),
820 ...(i.body ? [Box({ marginTop: 1, children: [Text({ wrap: 'wrap', children: [i.body] })] })] : []),
821 ...(i.notes.length
822 ? [Box({
823 flexDirection: 'column',
824 marginTop: 1,
825 children: [
826 hidden
827 ? Button({ key: 'notes-more-' + i.id, plain: true, dimColor: true, label: 'Show ' + hidden + ' earlier',
828 onPress: () => { allNotesId = i.id; redraw() } })
829 : Text({ dimColor: true, children: ['Notes'] }),
830 ...i.notes.slice(hidden).map((n, k) => noteView(n, k + hidden, i.id)),
831 ],
832 })]
833 : []),
834 ...(noting
835 ? [Box({ marginTop: 1, width: '100%', minHeight: 3, borderStyle: 'round', borderDimColor: true, paddingX: 1, children: [
836 Input({ key: 'note-input-' + i.id, value: '', placeholder: 'Write a note, then press Enter', submitLabel: 'add', autoFocus: true,
837 onSubmit: async (v: string) => { notingId = null; await addNote($, i.id, v); redraw() } }),
838 ] })]
839 : []),
840 Box({ flexDirection: 'row', columnGap: 1, flexWrap: 'wrap', marginTop: 1, children: actionsFor(i) }),
841 ],
842 })
843 }
844
845 const itemView = (i: Item, mark: string, color: string | undefined, last: boolean) =>
846 expandedId === i.id ? selectedCard(i, mark, color) : compactRow(i, mark, color, last)
847
848 // Group headings are secondary: dim, no count (the rows under them are the count).
849 const body = rows.length === 0
850 ? [Text({ dimColor: true, wrap: 'wrap', children: [
851 tab === 'done'
852 ? 'Nothing closed yet.'
853 : 'Nothing open. Claude adds what it parks or proposes; add your own with /todo add <text>.',
854 ] })]
855 : tab === 'done'
856 ? shown.map((i, k) => itemView(i, i.status === 'dropped' ? '✕' : '✓', undefined, k === shown.length - 1))
857 : groupItems(shown).flatMap((g, gi) => [
858 Box({ ...(gi === 0 ? {} : { marginTop: 1 }), children: [Text({ dimColor: true, children: [g.label] })] }),
859 ...g.items.map((i, k) => itemView(i, g.mark, g.color, k === g.items.length - 1)),
860 ])
861 const more = ordered.length - shown.length
862 if (more > 0) {
863 body.push(Box({ marginTop: 1, children: [Button({ key: 'show-all-rows', plain: true, dimColor: true,
864 label: 'Show ' + more + ' more', onPress: () => { showAllRows = true; redraw() } })] }))
865 }
866
867 return Box({
868 flexDirection: 'column',
869 children: [scopeRow, tabRow, ...body],
870 })
871 } catch (err: any) {
872 // A failed draw shows its reason instead of a blank pane.
873 $.ui.log('pane draw failed: ' + (err?.stack ?? err?.message ?? String(err)))
874 const { Text } = $.ui.resolve(e)
875 return Text({ wrap: 'wrap', children: ["ToDo's could not draw: " + (err?.message ?? String(err))] })
876 }
877 })
878
879 on('turn.start', async ($, e, next) => {
880 parkedThisTurn = false
881 if (e.agentId === undefined) replying = true
882 // The scan reads what the user said this turn to see which items they decided.
883 if (e.agentId === undefined && typeof e.text === 'string') lastUserText = e.text
884 // Picks up a /rename since the last turn, without holding the turn.
885 $.clock.after(0, () => lookupSessionName($))
886 // Other sessions may have written items; the next render reloads.
887 lastLoadAt = 0
888 return next(e)
889 })
890
891 on('turn.complete', async ($, e, next) => {
892 const result = await next(e)
893 if (e.agentId === undefined) replying = false
894 // Subagent turns and non-answer turns are not scanned.
895 if (e.agentId === undefined && e.reason === 'answer' && shouldScan(e.answer, parkedThisTurn)) {
896 const answer = e.answer
897 const userText = lastUserText
898 $.clock.after(0, () => runScan($, answer, userText))
899 }
900 return result
901 })
902
903 on('tool.call', { tool: 'mcp__todo-ledger__park_item' }, async ($, e) => {
904 if (!KINDS.includes(e.kind)) return { result: 'Not saved: kind must be one of ' + KINDS.join(', ') }
905 if (typeof e.title !== 'string' || !e.title.trim()) return { result: 'Not saved: title is required' }
906 try {
907 const r = await capture($, { title: e.title, kind: e.kind, origin: 'agent', source: 'tool', body: e.body })
908 parkedThisTurn = true
909 return { result: (r.isNew ? 'Parked ' : 'Already in ledger, added note to ') + r.item.id + ': ' + r.item.title }
910 } catch (err: any) {
911 $.ui.toast('ToDo not saved: ' + err.message)
912 return { result: 'Not saved: ' + err.message }
913 }
914 })
915
916 on('tool.call', { tool: 'mcp__todo-ledger__list_items' }, async ($, e) => {
917 try { return { result: formatList(await loadAll($), await $.session.cwd(), e.scope === 'all') } }
918 catch (err: any) { return { result: 'Not loaded: ' + err.message } }
919 })
920
921 on('tool.call', { tool: 'mcp__todo-ledger__get_item' }, async ($, e) => {
922 try {
923 const match = (await loadAll($)).filter((i) => typeof e.id === 'string' && e.id && i.id.startsWith(e.id))
924 return { result: match.length === 1 ? formatItem(match[0]) : 'No single item matches id ' + e.id }
925 } catch (err: any) { return { result: 'Not loaded: ' + err.message } }
926 })
927
928 on('tool.call', { tool: 'mcp__todo-ledger__update_item' }, async ($, e) => {
929 try {
930 const items = await loadAll($)
931 const match = items.filter((i) => typeof e.id === 'string' && i.id.startsWith(e.id))
932 if (match.length !== 1) return { result: 'No single item matches id ' + e.id }
933 if (e.kind !== undefined && !KINDS.includes(e.kind)) return { result: 'Not saved: kind must be one of ' + KINDS.join(', ') }
934 if (e.title !== undefined && (typeof e.title !== 'string' || !e.title.trim())) return { result: 'Not saved: title cannot be empty' }
935 // A wrong status used to be ignored while the reply still said "updated".
936 if (e.status !== undefined && !STATUS_VALUES.includes(e.status)) return { result: 'Not saved: status must be one of ' + STATUS_VALUES.join(', ') }
937 const item = match[0]
938 const at = new Date(await $.clock.now()).toISOString()
939 const edits = {
940 ...(e.title !== undefined ? { title: cleanTitle(e.title) } : {}),
941 ...(e.kind !== undefined ? { kind: e.kind as Kind } : {}),
942 ...(typeof e.body === 'string' ? { body: e.body } : {}),
943 }
944 const note = typeof e.note === 'string' && e.note.trim() ? e.note.trim() : ''
945 // One write for every change, so another session never sees half an update.
946 const final: Item = {
947 ...item, ...edits, status: (e.status as Status | undefined) ?? item.status, updatedAt: at,
948 notes: note ? [...item.notes, { by: 'agent' as Origin, text: note, at }] : item.notes,
949 }
950 await saveItem($, final)
951 const changed = ['status ' + final.status, ...Object.keys(edits), ...(note ? ['note added'] : [])]
952 return { result: 'Item ' + final.id + ' updated: ' + changed.join(', ') }
953 } catch (err: any) {
954 $.ui.toast('ToDo not saved: ' + err.message)
955 return { result: 'Not saved: ' + err.message }
956 }
957 })
958
959 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
960 const result = await next(e)
961 // Subagents' and workflow agents' working lists are theirs, not the person's ledger.
962 if (e.agentId !== undefined) return result
963 try {
964 await mirrorTodos($, Array.isArray(e.todos) ? e.todos : [])
965 } catch (err: any) {
966 $.ui.log('TodoWrite mirror failed: ' + err.message)
967 }
968 return result
969 })
970
971 on('command.run', { command: 'todo' }, async ($, e) => runTodo($, e.args))
972 on('command.run', { command: 'todos' }, async ($, e) => runTodos($, e.args))
973 // Fallback names, used only when /todo or /todos could not be registered.
974 on('command.run', { command: 'ledger-add' }, async ($, e) => runTodo($, e.args))
975 on('command.run', { command: 'ledger' }, async ($, e) => runTodos($, e.args))
976}
977
978src/commands.ts 56 lines1import { isOpen, sessionLabel } from './item.ts'
2import type { Item, Kind } from './item.ts'
3
4export type TodoCommand =
5 | { action: 'add'; kind: Kind; text: string }
6 | { action: 'done'; idPrefix: string }
7 | { action: 'delete'; idPrefix: string }
8 | { action: 'error'; message: string }
9
10const FLAGS: Record<string, Kind> = { '-d': 'deferred', '-p': 'proposal', '-n': 'discussion' }
11const USAGE = 'Usage: /todo add [-d|-p|-n] <text> · /todo done <id> · /todo delete <id> (dropped items only)'
12
13export function parseTodoCommand(args: string): TodoCommand {
14 const parts = args.trim().split(/\s+/).filter(Boolean)
15 if (parts[0] === 'done' && parts[1]) return { action: 'done', idPrefix: parts[1] }
16 if (parts[0] === 'delete' && parts[1]) return { action: 'delete', idPrefix: parts[1] }
17 if (parts[0] !== 'add') return { action: 'error', message: USAGE }
18 let kind: Kind = 'todo'
19 let rest = parts.slice(1)
20 if (rest[0] && FLAGS[rest[0]]) { kind = FLAGS[rest[0]]; rest = rest.slice(1) }
21 if (rest.length === 0) return { action: 'error', message: USAGE }
22 return { action: 'add', kind, text: rest.join(' ') }
23}
24
25export function parseTodosArgs(args: string): { action: 'toggle' } | { action: 'refresh' } | { action: 'list'; all: boolean } {
26 const parts = args.trim().split(/\s+/).filter(Boolean)
27 if (parts[0] === 'list') return { action: 'list', all: parts[1] === 'all' }
28 if (parts[0] === 'refresh') return { action: 'refresh' }
29 return { action: 'toggle' }
30}
31
32export function formatList(items: Item[], project: string, all: boolean): string {
33 const shown = items
34 .filter(isOpen)
35 .filter((i) => all || i.project.toLowerCase() === project.toLowerCase())
36 .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
37 if (shown.length === 0) return all ? 'No open ledger items.' : 'No open ledger items for this project.'
38 return shown
39 .map((i) => `- [${i.id}] (${i.kind}, ${i.origin}, ${i.status}) ${i.title} · session ${sessionLabel(i)}` + (all ? ` · ${i.project}` : ''))
40 .join('\n')
41}
42
43// One item in full, for the get_item tool: everything an agent needs to act on it.
44export function formatItem(i: Item): string {
45 const lines = [
46 `[${i.id}] ${i.title}`,
47 `kind: ${i.kind} · status: ${i.status} · started by: ${i.origin} (${i.source})`,
48 `project: ${i.project}`,
49 `session: ${sessionLabel(i)} (${i.sessionId})`,
50 `created: ${i.createdAt} · updated: ${i.updatedAt}`,
51 ]
52 if (i.body) lines.push('', i.body)
53 if (i.notes.length) lines.push('', 'Notes:', ...i.notes.map((n) => `- ${n.by === 'human' ? 'user' : 'agent'} (${n.at}): ${n.text}`))
54 return lines.join('\n')
55}
56src/scan.ts 64 lines1export type ScanKind = 'todo' | 'deferred' | 'proposal'
2const SCAN_KINDS: ScanKind[] = ['todo', 'deferred', 'proposal']
3
4export const SCAN_SYSTEM =
5 'You keep a todo ledger current from one turn of a conversation between a USER and an AI coding assistant (the REPLY).\n' +
6 '1. items: things the assistant explicitly deferred, parked, left for later, put out of scope, or proposed without being ' +
7 'asked. Not work completed in the reply. Not anything already in KNOWN, even reworded.\n' +
8 '2. updates: KNOWN items this turn changed. "approve": the USER agreed to a proposal. "reject": the USER declined it. ' +
9 '"done": the REPLY shows its work was completed. "duplicate": it is the same thing as another KNOWN item (give "of": that id). ' +
10 'Use only ids from KNOWN. Only when the USER message or the REPLY says so clearly; when unsure, leave it out.\n' +
11 'Give each item a "detail": 2 to 4 short sentences in plain English, no jargon, saying what the task is and why it ' +
12 'was deferred or proposed.\n' +
13 'Reply with JSON only: {"items":[{"title":"one line","kind":"todo|deferred|proposal","detail":"..."}],' +
14 '"updates":[{"id":"…","action":"approve|reject|done|duplicate","of":"…"}]}. Use empty arrays when there is nothing.'
15
16export function shouldScan(answer: unknown, parked: boolean): boolean {
17 return typeof answer === 'string' && answer.length >= 400 && !parked
18}
19
20export type KnownItem = { id: string; title: string; kind: string }
21
22export function buildScanPrompt(answer: string, known: (string | KnownItem)[], userText?: string): string {
23 const lines = known.length
24 ? known.map((k) => (typeof k === 'string' ? '- ' + k : `- [${k.id}] (${k.kind}) ${k.title}`)).join('\n')
25 : '(none)'
26 const user = userText && userText.trim() ? 'USER:\n' + userText.trim().slice(-2000) + '\n\n' : ''
27 return 'KNOWN:\n' + lines + '\n\n' + user + 'REPLY:\n' + answer.slice(-6000)
28}
29
30export type ScanUpdate = { id: string; action: 'approve' | 'reject' | 'done' } | { id: string; action: 'duplicate'; of: string }
31const ACTIONS = ['approve', 'reject', 'done', 'duplicate']
32
33// Updates the model proposed, kept only when they name ids the prompt listed.
34export function parseScanUpdates(text: string, knownIds: string[]): ScanUpdate[] {
35 const start = text.indexOf('{')
36 const end = text.lastIndexOf('}')
37 if (start < 0 || end <= start) return []
38 let v: any
39 try { v = JSON.parse(text.slice(start, end + 1)) } catch { return [] }
40 if (!v || !Array.isArray(v.updates)) return []
41 const known = new Set(knownIds)
42 const out: ScanUpdate[] = []
43 for (const u of v.updates) {
44 if (!u || !known.has(u.id) || !ACTIONS.includes(u.action)) continue
45 if (u.action === 'duplicate') {
46 if (typeof u.of === 'string' && known.has(u.of) && u.of !== u.id) out.push({ id: u.id, action: 'duplicate', of: u.of })
47 } else out.push({ id: u.id, action: u.action })
48 }
49 return out
50}
51
52export function parseScanReply(text: string): { title: string; kind: ScanKind; detail?: string }[] | null {
53 const start = text.indexOf('{')
54 const end = text.lastIndexOf('}')
55 if (start < 0 || end <= start) return null
56 let v: any
57 try { v = JSON.parse(text.slice(start, end + 1)) } catch { return null }
58 if (!v || !Array.isArray(v.items)) return null
59 return v.items
60 .filter((i: any) => i && typeof i.title === 'string' && i.title.trim() && SCAN_KINDS.includes(i.kind))
61 .map((i: any) => ({ title: i.title.trim(), kind: i.kind,
62 ...(typeof i.detail === 'string' && i.detail.trim() ? { detail: i.detail.trim().slice(0, 600) } : {}) }))
63}
64src/item.ts 163 lines1export type Kind = 'todo' | 'deferred' | 'proposal' | 'discussion'
2export type Origin = 'agent' | 'human'
3export type Status = 'open' | 'in_progress' | 'done' | 'dropped'
4export type Source = 'tool' | 'todowrite' | 'scan' | 'manual'
5
6export interface Note { by: Origin; text: string; at: string }
7
8export interface Item {
9 id: string
10 title: string
11 body: string
12 kind: Kind
13 origin: Origin
14 status: Status
15 project: string
16 sessionId: string
17 // The session's name as its tab shows it (/rename), when it had one at capture.
18 sessionName?: string
19 source: Source
20 createdAt: string
21 updatedAt: string
22 notes: Note[]
23}
24
25export interface NewItemFields {
26 title: string
27 kind: Kind
28 origin: Origin
29 source: Source
30 project: string
31 sessionId: string
32 sessionName?: string
33 body?: string
34 nowMs: number
35 hex4: string
36}
37
38export const KINDS: Kind[] = ['todo', 'deferred', 'proposal', 'discussion']
39const STATUSES: Status[] = ['open', 'in_progress', 'done', 'dropped']
40
41const pad = (n: number) => String(n).padStart(2, '0')
42
43export function newId(nowMs: number, hex4: string): string {
44 const d = new Date(nowMs)
45 const date = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`
46 const time = `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
47 return `${date}-${time}-${hex4}`
48}
49
50export function randomHex4(): string {
51 const b = new Uint8Array(2)
52 crypto.getRandomValues(b)
53 return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('')
54}
55
56export function cleanTitle(s: string): string {
57 const one = s.replace(/\s+/g, ' ').trim()
58 return one.length > 200 ? one.slice(0, 197) + '...' : one
59}
60
61export function dedupeKey(title: string, project: string): string {
62 return cleanTitle(title).toLowerCase() + '|' + project.toLowerCase()
63}
64
65export function isOpen(item: Item): boolean {
66 return item.status === 'open' || item.status === 'in_progress'
67}
68
69export function makeItem(f: NewItemFields): Item {
70 const at = new Date(f.nowMs).toISOString()
71 return {
72 id: newId(f.nowMs, f.hex4),
73 title: cleanTitle(f.title),
74 body: f.body ?? '',
75 kind: f.kind,
76 origin: f.origin,
77 status: 'open',
78 project: f.project,
79 sessionId: f.sessionId,
80 ...(f.sessionName ? { sessionName: f.sessionName } : {}),
81 source: f.source,
82 createdAt: at,
83 updatedAt: at,
84 notes: [],
85 }
86}
87
88export function parseItem(text: string): Item | null {
89 let v: any
90 try { v = JSON.parse(text) } catch { return null }
91 if (!v || typeof v !== 'object') return null
92 const strings = ['id', 'title', 'project', 'sessionId', 'createdAt', 'updatedAt']
93 if (!strings.every((k) => typeof v[k] === 'string')) return null
94 if (!KINDS.includes(v.kind) || !STATUSES.includes(v.status)) return null
95 if (v.origin !== 'agent' && v.origin !== 'human') return null
96 return { ...v, body: typeof v.body === 'string' ? v.body : '', notes: Array.isArray(v.notes) ? v.notes : [] }
97}
98
99// Cut text to at most `max` characters, ending in an ellipsis when cut.
100export function clip(s: string, max: number): string {
101 return s.length <= max ? s : s.slice(0, Math.max(1, max - 1)) + '…'
102}
103
104// Wrap text at word boundaries into at most `lines` lines of `width`; the last
105// line ends in an ellipsis when text is left over.
106export function wrapLines(s: string, width: number, lines: number): string[] {
107 const w = Math.max(4, width)
108 const out: string[] = []
109 let rest = s.replace(/\s+/g, ' ').trim()
110 while (rest && out.length < lines) {
111 if (rest.length <= w) { out.push(rest); rest = ''; break }
112 if (out.length === lines - 1) { out.push(clip(rest, w)); rest = ''; break }
113 const cut = rest.lastIndexOf(' ', w)
114 const at = cut > w / 3 ? cut : w
115 out.push(rest.slice(0, at).trimEnd())
116 rest = rest.slice(at).trimStart()
117 }
118 return out
119}
120
121// How the pane names the session an item came from: its name, else the id's first block.
122export function sessionLabel(item: Item): string {
123 return item.sessionName?.trim() || item.sessionId.split('-')[0]
124}
125
126// The newest session name in transcript lines (`{"type":"custom-title","customTitle":...}`).
127export function lastSessionTitle(lines: string): string | null {
128 let found: string | null = null
129 for (const line of lines.split(/\r?\n/)) {
130 if (!line.includes('"custom-title"')) continue
131 try {
132 const v = JSON.parse(line)
133 if (v?.type === 'custom-title' && typeof v.customTitle === 'string' && v.customTitle.trim()) found = v.customTitle.trim()
134 } catch { /* a partial line; skip it */ }
135 }
136 return found
137}
138// Words that carry no meaning for "is this the same item": articles, joiners and
139// the verbs titles start with ("add", "implement", "build"...).
140const STOP = new Set(['the', 'an', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'from', 'with', 'by', 'at', 'as', 'is', 'are',
141 'be', 'it', 'its', 'this', 'that', 'into', 'per', 'via', 'then', 'add', 'implement', 'build', 'make', 'create', 'support',
142 'handle', 'extract', 'display', 'show', 'use'])
143
144function titleWords(s: string): Set<string> {
145 const words = s.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/)
146 .filter((w) => w.length >= 2 && !STOP.has(w))
147 .map((w) => (w.length > 3 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w))
148 return new Set(words)
149}
150
151// Two titles name the same item when at least 70% of the shorter one's meaningful
152// words appear in the other. Titles under 3 meaningful words never match this way
153// ("Fix login" / "Fix logout"); exact duplicates are caught by dedupeKey instead.
154export function similarTitle(a: string, b: string): boolean {
155 const A = titleWords(a)
156 const B = titleWords(b)
157 const small = Math.min(A.size, B.size)
158 if (small < 3) return false
159 let shared = 0
160 for (const w of A) if (B.has(w)) shared++
161 return shared / small >= 0.7
162}
163src/start.ts 58 lines1import type { Item } from './item.ts'
2
3export type StartChoice = 'send' | 'draft' | 'terminal' | 'copy'
4
5export interface StartOption { value: StartChoice; label: string; hotkey: string }
6
7// The ways to start an item, drawn as one button each (one press, no picker).
8// The first is the main one. Terminals can open a new window; the desktop copies instead.
9export function startOptions(surface: 'terminal' | 'desktop'): StartOption[] {
10 const common: StartOption[] = [
11 { value: 'send', label: 'Send', hotkey: 's' },
12 { value: 'draft', label: 'Draft', hotkey: 'd' },
13 ]
14 return surface === 'terminal'
15 ? [...common, { value: 'terminal', label: 'Terminal', hotkey: 't' }]
16 : [...common, { value: 'copy', label: 'Copy', hotkey: 'c' }]
17}
18
19export function startPrompt(item: Item, cwd: string): string {
20 const lines = [
21 `Work on this ledger item (id ${item.id}, ${item.kind}, from project ${item.project}):`,
22 item.title,
23 ]
24 if (item.body) lines.push(item.body)
25 if (item.notes.length) lines.push('Notes:', ...item.notes.map((n) => `- ${n.by}: ${n.text}`))
26 if (item.project.toLowerCase() !== cwd.toLowerCase()) {
27 lines.push(`Note: this item came from a different project (${item.project}); this session is in ${cwd}.`)
28 }
29 lines.push(`When finished, call update_item with id ${item.id} and status done.`)
30 return lines.join('\n')
31}
32
33// The full prompt never goes on a command line: Windows Terminal splits its own command line on ';'
34// (even inside one argument) and a newline ends a cmd command line. The new session gets a short
35// one-line task that points at a file holding the full prompt.
36export function terminalTask(id: string, file: string): string {
37 return `Work on todo-ledger item ${id}. Read the file ${file} and follow the instructions in it.`
38}
39
40function oneLine(s: string): string {
41 return s.replace(/[\r\n]+/g, ' ')
42}
43
44// wt documents '\;' as the escape for a literal ';' in its arguments.
45function wtEscape(s: string): string {
46 return oneLine(s).replace(/;/g, '\\;')
47}
48
49export function terminalArgv(project: string, task: string): string[] {
50 return ['wt', '-d', wtEscape(project), 'claude', wtEscape(task)]
51}
52
53// Fallback without Windows Terminal: `start` opens a new console in the project directory, where
54// `cmd /k` runs claude. No '&&' element: the outer `cmd /c` would run what follows it itself.
55export function fallbackArgv(project: string, task: string): string[] {
56 return ['cmd', '/c', 'start', '', '/d', oneLine(project), 'cmd', '/k', 'claude', oneLine(task)]
57}
58src/view.ts 74 lines1import { isOpen } from './item.ts'
2import type { Item, Kind } from './item.ts'
3
4export type Tab = 'agent' | 'human' | 'done'
5
6export const TABS: { id: Tab; label: string; hotkey: string }[] = [
7 { id: 'agent', label: 'Agent', hotkey: '1' },
8 { id: 'human', label: 'Human', hotkey: '2' },
9 { id: 'done', label: 'Done', hotkey: '3' },
10]
11
12// Tabs split by who started the item; the kind shows as a tag on the row instead.
13export function tabOf(item: Item): Tab {
14 if (!isOpen(item)) return 'done'
15 return item.origin === 'agent' ? 'agent' : 'human'
16}
17
18// Groups inside an open tab, ordered by what the item needs from the person:
19// a decision first, then work, then talk, then what was put off.
20export const GROUPS: { kind: Kind; label: string; mark: string; color?: string }[] = [
21 { kind: 'proposal', label: 'Needs decision', mark: '◆', color: 'suggestion' },
22 { kind: 'todo', label: 'To do', mark: '•' },
23 { kind: 'discussion', label: 'Discussion', mark: '◇' },
24 { kind: 'deferred', label: 'Parked', mark: '◇' },
25]
26
27export function groupItems(items: Item[]): { kind: Kind; label: string; mark: string; color?: string; items: Item[] }[] {
28 return GROUPS.map((g) => ({ ...g, items: items.filter((i) => i.kind === g.kind) })).filter((g) => g.items.length > 0)
29}
30
31export function itemsForTab(items: Item[], tab: Tab, project: string | null): Item[] {
32 const p = project?.toLowerCase() ?? null
33 const sortKey = (i: Item) => (tab === 'done' ? i.updatedAt : i.createdAt)
34 return items
35 .filter((i) => tabOf(i) === tab)
36 .filter((i) => p === null || i.project.toLowerCase() === p)
37 .sort((a, b) => sortKey(b).localeCompare(sortKey(a)))
38 .slice(0, tab === 'done' ? 50 : 100)
39}
40
41const inProject = (i: Item, project: string | null) => project === null || i.project.toLowerCase() === project.toLowerCase()
42
43// The footer button. It counts what the pane first shows (this project's open
44// items) and is always drawn, so the pane (Done tab included) stays reachable.
45// Compact: it shares the footer row with the mode labels.
46export function bandLabel(items: Item[], project: string | null = null): string {
47 const open = items.filter((i) => isOpen(i) && inProject(i, project))
48 return "☰ ToDo's" + (open.length ? ' ' + open.length : '')
49}
50
51// Where the pane opens: this project unless it has nothing open while others do,
52// and the open tab with items (Agent first) rather than an empty one.
53export function firstView(items: Item[], project: string, tab: Tab, allProjects: boolean): { tab: Tab; allProjects: boolean } {
54 const open = items.filter(isOpen)
55 const all = allProjects || (!open.some((i) => inProject(i, project)) && open.length > 0)
56 const scoped = open.filter((i) => all || inProject(i, project))
57 const has = (t: Tab) => scoped.some((i) => tabOf(i) === t)
58 if (tab !== 'done' && has(tab)) return { tab, allProjects: all }
59 return { tab: has('agent') ? 'agent' : has('human') ? 'human' : tab, allProjects: all }
60}
61
62// The list in the order the pane draws it: groups in GROUPS order inside an open tab.
63export function displayOrder(rows: Item[], tab: Tab): Item[] {
64 return tab === 'done' ? rows : groupItems(rows).flatMap((g) => g.items)
65}
66
67export function age(nowMs: number, iso: string): string {
68 const s = Math.max(0, (nowMs - Date.parse(iso)) / 1000)
69 if (s < 60) return 'now'
70 if (s < 3600) return Math.floor(s / 60) + 'm'
71 if (s < 86400) return Math.floor(s / 3600) + 'h'
72 return Math.floor(s / 86400) + 'd'
73}
74