SLOPSHOPPER

puppy-todo

one quiet to-do pane for every conversation: what this one is for, questions waiting on you, PRs to merge, and what's left

newpaneguardcommandtoolprocess
v0.4.0MITupdated 2026-10-26nonbiri1004/puppy-mods/puppy-todo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · puppy-todo
│ ┃ todo ✕ › fix the failing auth test and add an audit log call │ ┃ ^ᴥ^ all clear [ refresh ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ fix the failing auth test app │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ to-do 0 open │ │ ┃ │ add a to-do… ⏎ add │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ │ nothing to do. the puppy naps. │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ ┃ › /todo │ ┃ looked at 08:53 ⎿ puppy-todo: the to-do pane is open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · todo
^ᴥ^ all clear [ refresh ] ╭──────────────────────────────────────────────────────────╮ │ fix the failing auth test app │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ to-do 0 open │ │ add a to-do… ⏎ add │ │ │ │ nothing to do. the puppy naps. │ ╰──────────────────────────────────────────────────────────╯ looked at 08:53
README

puppy-todo

a to-do pane beside the chat, shared by every conversation. when you come back to a conversation it tells you what this one was doing, what other conversations asked you, and what is left.

•ᴥ• 3 to-do · 1 asked you                 refresh
╭─────────────────────────────────────────────╮
│ notes app                            my-app │
│ last: the list works; offline sync is next. │
╰─────────────────────────────────────────────╯
╭─────────────────────────────────────────────╮
│ asked you                         1 waiting │
│ games chat · 3h                             │
│   make the office prototype first?          │
╰─────────────────────────────────────────────╯
╭─────────────────────────────────────────────╮
│ to-do                                3 open │
│ [add a to-do…]                              │
│                                             │
│ this conversation                           │
│ ☐ test offline sync                         │
│                                             │
│ my-app                                      │
│ ☐ write the retry docs                      │
│ ↗ merge the notes PR · waiting for merge    │
╰─────────────────────────────────────────────╯
▸ done 8
▸ tidy up: 32 of 80 conversations have nothing open
looked at 14:05

it looks like the other puppy mods: one card per part (style).

it opens by itself when a session starts. /todo opens it again; /todo <text> adds a to-do for this conversation.

what you do

  • add: the field at the top of the list, or /todo <text>.
  • tick ☐ when it's done. tick ☑ under done to open it again.
  • ✕ to delete something you won't do. on the desktop it shows when the pointer is on the row.
  • tidy up: when the sidebar gets long, unfold the last row. it lists every open conversation with what is still open in it and its last recap, so you can archive from the sidebar without opening each one.

that's all. everything else happens by itself.

what happens by itself

  • asked you: the last question another conversation asked in its wrap-up box, while you haven't typed anything after it. questions fade after two days.
  • PR items (↗) have no tick: they turn done when GitHub says the PR merged, and show ✕ under done if it closed without merging.
  • claude keeps the list: with the mod installed, claude has todo_add, todo_done and todo_list, and a line in your CLAUDE.md asks it to add what is left at the end of a conversation and tick what it finished.
  • this conversation first: the to-dos written here, by this session or an earlier one of the same conversation, come before the rest.

the shared list

one small json file per item in ~/.claude/todo/items/, so conversations writing at the same time don't overwrite each other's items. the file name is the id.

{
  "schemaVersion": 2,
  "id": "20261025-1210-m8p2",
  "text": "call the vet about the shots",
  "project": "home",
  "kind": "todo",
  "status": "open",
  "session": { "id": "…", "title": "notes app", "cwd": "…" },
  "by": "claude",
  "createdAt": "2026-10-25T04:10:00.000Z",
  "completion": { "mode": "declared" }
}
  • ticking sets status: "done", doneAt, and declaration (who said it: person for a tick in the pane; recordedBy: "claude" when you said it in a conversation).
  • a PR item has "source": { "type": "github-pr", "host": "github.com", "repo": "you/my-app", "number": 78 } and "completion": { "mode": "source", "condition": "merged" }. github decides; status in the file is ignored. a plain link decides nothing.
  • delete sets deletedAt and deletedBy. the file stays; the item is never shown again.
  • files from older versions still work. unknown fields are kept.

without the mod (codex, a script, you), write a file in this shape.

a line for your CLAUDE.md:

before ending a conversation, add what is still left with todo_add (pass pr when a PR merging finishes it), or write a file to ~/.claude/todo/items/. tick with todo_done only when the person said it is done (by: person) or you did it and checked (by: claude).

limits

  • a mod sees only its own session. other conversations are read from their transcript files, whose format is the app's and may change.
  • "nothing open found" in tidy-up is a hint, not a promise: a question asked in plain text, or work never written down, isn't seen. archiving stays in the app's sidebar, since it also removes the conversation's worktree.
  • a conversation is matched to its transcript by the time of its last activity, else by its title. forks with the same title, when the time doesn't tell them apart, may show each other's last turn.
  • "asked you" only knows questions from the app's wrap-up box.
  • the app's conversation list is read in pages of 150, 80 or 40; the pane says when it shows only the newest ones.
  • the list lives on this computer; a second computer has its own ~/.claude/todo/.
  • PR items need the gh cli, signed in.

install

/plugin install puppy-todo --marketplace nonbiri1004/puppy-mods

license

MIT

Source 4 files
hooks/register.tsx 790 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Convo, Item, PrSource, PrState, Session, Talk, View, WorktreeState } from '../types'
5import { folderFiles, isWorktree, matchByTime, parseSessions, parseWorktree, pickByTitle, sortConvos, verdictOf } from './convos'
6import {
7  FACES,
8  ago,
9  byProject,
10  clip,
11  clockTime,
12  declareDone,
13  deleteItem,
14  isHere,
15  isOpenItem,
16  mood,
17  newId,
18  newestTalks,
19  outcomeOf,
20  parseItem,
21  parsePrState,
22  parsePrUrl,
23  parseScan,
24  prAnswer,
25  prKey,
26  prUrl,
27  projectOf,
28  recentAsks,
29  reopen,
30  saying,
31  shown,
32  sourceOf,
33} from './todo'
34
35const PANE = 'puppy-todo'
36// conversations older than this, or past this many, are left out of "asked you"
37const TALK_DAYS = 14
38const TALK_MAX = 40
39// how often the pane looks again (the shared list, transcripts); gh only every GH_EVERY
40const TICK = 60_000
41const GH_EVERY = 5 * 60_000
42
43const EMPTY: View = {
44  here: { id: '', title: '', project: '', cwd: '' },
45  talks: [],
46  items: [],
47  prStates: {},
48  convos: null,
49  isShowingDone: false,
50  isShowingConvos: false,
51  at: null,
52  notes: [],
53}
54const view = atom({ plugin: 'puppy-todo', key: 'view' } as const, EMPTY)
55
56// per transcript: what it said, read again only when the file changes (a reload starts over)
57const talkCache = new Map<string, { mtimeMs: number; talk: Talk }>()
58let ghAt = 0
59// the gh that ran last time
60let ghBin: string | null = null
61let ticking = false
62
63// reads the end of each transcript and prints only what parseScan needs (X for a file it can't read): titles, PR links,
64// wrap-up rows (recap + question), "U" for each typed prompt (not the notes the app slips in,
65// which start with "<"), and the last working folder
66const SCAN = `for f in "$@"; do
67printf '\\036%s\\n' "$f"
68if [ ! -r "$f" ]; then printf 'X\\n'; continue; fi
69tail -c 1500000 "$f" | LC_ALL=C grep -a -F -e '"type":"custom-title"' -e '"type":"pr-link"' -e '"name":"mcp__ccd_turn__wrap_up","input"' -e '"message":{"role":"user","content":"' | LC_ALL=C grep -a -v '"type":"api-request' | LC_ALL=C awk '/"name":"mcp__ccd_turn__wrap_up","input"/ {print; next} /"message":\\{"role":"user","content":"</ {next} /"message":\\{"role":"user","content":"/ {print "U"; next} {print}'
70c=$(tail -c 30000 "$f" | LC_ALL=C grep -a -o '"cwd":"[^"]*"' | tail -1)
71printf 'C:%s\\n' "$c"
72done`
73const LIST = `find "$1" -maxdepth 2 -name '*.jsonl' -mtime -${TALK_DAYS} -not -path '*scratch-workspaces*' -print0 | xargs -0 stat -f '%Fm %N'`
74
75async function home($: EngineInterface): Promise<string> {
76  return (await $.env.get('HOME')) ?? ''
77}
78
79async function todoDir($: EngineInterface, part: 'items'): Promise<string> {
80  return `${await home($)}/.claude/todo/${part}`
81}
82
83async function readJsonDir<T>($: EngineInterface, dir: string, parse: (text: string, name: string) => T | null): Promise<T[]> {
84  if (!(await $.fs.exists(dir))) {
85    return []
86  }
87  const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.json'))
88  const read: (T | null)[] = await Promise.all(
89    files.map(f =>
90      $.fs
91        .read(`${dir}/${f.name}`)
92        .then(text => parse(text, f.name))
93        .catch(() => null),
94    ),
95  )
96
97  return read.filter((x): x is T => x !== null)
98}
99
100async function readItems($: EngineInterface): Promise<Item[]> {
101  return readJsonDir($, await todoDir($, 'items'), parseItem)
102}
103
104async function writeItem($: EngineInterface, item: Item): Promise<void> {
105  await $.fs.write(`${await todoDir($, 'items')}/${item.id}.json`, `${JSON.stringify(item, null, 2)}\n`)
106}
107
108async function projectHere($: EngineInterface): Promise<string> {
109  const repo = await $.session.repo()
110  const fromRemote = repo?.remote?.replace(/\.git$/, '').split(/[/:]/).pop()
111
112  return fromRemote || projectOf(repo?.root ?? (await $.session.cwd()))
113}
114
115type TranscriptFile = { path: string; mtimeMs: number }
116
117// every transcript changed in the last TALK_DAYS days, newest first
118async function listTranscripts($: EngineInterface): Promise<TranscriptFile[]> {
119  const listed = await $.process.run(['sh', '-c', LIST, 'sh', `${await home($)}/.claude/projects`])
120
121  return listed.stdout
122    .split('\n')
123    .map(line => {
124      const cut = line.indexOf(' ')
125
126      return { mtimeMs: Number(line.slice(0, cut)) * 1000, path: line.slice(cut + 1) }
127    })
128    .filter(f => f.path.endsWith('.jsonl') && Number.isFinite(f.mtimeMs))
129    .sort((a, b) => b.mtimeMs - a.mtimeMs)
130}
131
132// what each transcript says, read again only when the file changed
133async function scanTalks($: EngineInterface, files: readonly TranscriptFile[]): Promise<Map<string, Talk>> {
134  const stale = files.filter(f => talkCache.get(f.path)?.mtimeMs !== f.mtimeMs)
135  if (stale.length > 0) {
136    const mtimes = new Map(stale.map(f => [f.path, f.mtimeMs]))
137    const scanned = await $.process.run(['sh', '-c', SCAN, 'sh', ...stale.map(f => f.path)], { timeoutMs: 120_000 })
138    const talks = parseScan(scanned.stdout, mtimes)
139    stale.forEach((f, i) => {
140      const talk = talks[i]
141      // a file that couldn't be read is read again next time, and until then is no transcript
142      if (talk && !talk.isUnread) {
143        talkCache.set(f.path, { mtimeMs: f.mtimeMs, talk })
144      } else {
145        talkCache.delete(f.path)
146      }
147    })
148  }
149  const out = new Map<string, Talk>()
150  for (const f of files) {
151    const talk = talkCache.get(f.path)?.talk
152    if (talk) {
153      out.set(f.path, talk)
154    }
155  }
156
157  return out
158}
159
160// the app's open (not archived) conversations; null away from the desktop app. a big list comes back
161// as something other than JSON (the app caps what one call returns), so ask for fewer until it parses
162const SESSION_LIMITS = [150, 80, 40]
163
164async function readSessions($: EngineInterface): Promise<{ list: Session[]; limit: number } | null> {
165  for (const limit of SESSION_LIMITS) {
166    try {
167      const got = await $.mcp.call('ccd_session_mgmt', 'list_sessions', { limit })
168      const block = got.content[0] as { type?: string; text?: string } | undefined
169      if (got.isError || block?.type !== 'text' || !block.text) {
170        return null
171      }
172      const list = parseSessions(block.text)
173      if (list.length > 0 || block.text.trim() === '[]') {
174        return { list, limit }
175      }
176    } catch {
177      return null
178    }
179  }
180
181  return null
182}
183
184// "asked you" shows a question only from a conversation that is still open in the app (matched
185// by title: transcripts don't carry the app's id); away from the app, every conversation counts
186function isLiveTitle(sessions: readonly Session[] | null, title: string): boolean {
187  return sessions === null || sessions.some(s => s.title === title)
188}
189
190// a worktree's uncommitted changes and commits on no remote; null when it isn't a worktree or is gone
191async function readWorktree($: EngineInterface, cwd: string): Promise<WorktreeState | null> {
192  if (!isWorktree(cwd) || !(await $.fs.exists(cwd))) {
193    return null
194  }
195  try {
196    const [status, unpushed] = await Promise.all([
197      $.process.run(['git', '-C', cwd, 'status', '--porcelain']),
198      $.process.run(['git', '-C', cwd, 'rev-list', '--count', 'HEAD', '--not', '--remotes']),
199    ])
200
201    return parseWorktree(status, unpushed)
202  } catch {
203    return { state: 'unknown' }
204  }
205}
206
207// one row per conversation the app has open, with what is still open in it
208async function readConvos(
209  $: EngineInterface,
210  c: { sessions: readonly Session[]; files: readonly TranscriptFile[]; items: readonly Item[]; prStates: Record<string, PrState>; here: View['here'] },
211): Promise<Convo[]> {
212  const projects = `${await home($)}/.claude/projects`
213  const live = c.sessions.filter(s => !s.isArchived)
214  const paths = matchByTime(live, c.files, projects)
215  // no file at the moment of the last activity: match the rest by title, leaving out files another
216  // conversation already matched
217  let unmatched = live.filter(s => paths.get(s.id) === null)
218  const claimed = new Set([...paths.values()].filter((p): p is string => p !== null))
219  const wanted = new Set([...claimed, ...unmatched.flatMap(s => folderFiles(s, c.files, projects).map(f => f.path))])
220  const talks = await scanTalks(
221    $,
222    c.files.filter(f => wanted.has(f.path)),
223  )
224  // a file matched by time whose titles don't include the conversation's is someone else's
225  for (const s of live) {
226    const path = paths.get(s.id)
227    const titles = path ? talks.get(path)?.titles : undefined
228    if (path && titles && titles.length > 0 && !titles.includes(s.title)) {
229      paths.set(s.id, null)
230      claimed.delete(path)
231      unmatched = [...unmatched, s]
232    }
233  }
234  // the folders of conversations sent back here may not have been read yet
235  const more = c.files.filter(f => !talks.has(f.path) && unmatched.some(s => folderFiles(s, [f], projects).length > 0))
236  if (more.length > 0) {
237    for (const [path, talk] of await scanTalks($, more)) {
238      talks.set(path, talk)
239    }
240  }
241  for (const s of unmatched) {
242    const candidates = folderFiles(s, c.files, projects).map(f => ({ ...f, titles: talks.get(f.path)?.titles ?? [] }))
243    const path = pickByTitle(s, candidates, claimed)
244    paths.set(s.id, path)
245    if (path) {
246      claimed.add(path)
247    }
248  }
249
250  return Promise.all(
251    live.map(async session => {
252      const path = paths.get(session.id)
253      const talk = path ? (talks.get(path) ?? null) : null
254      const worktree = await readWorktree($, session.cwd)
255      const isThis = session.title === c.here.title && session.cwd === c.here.cwd && talk?.id === c.here.id
256
257      return {
258        session,
259        ...(talk?.recap ? { recap: talk.recap } : {}),
260        verdict: verdictOf({
261          session,
262          talk,
263          items: c.items,
264          isOpenItem: i => isOpenItem(i, c.prStates),
265          worktree,
266          isHere: isThis || (path !== null && path !== undefined && path.endsWith(`/${c.here.id}.jsonl`)),
267        }),
268      }
269    }),
270  )
271}
272
273// runs gh from where it is: the app may not have homebrew on its PATH
274async function gh($: EngineInterface, args: readonly string[]): Promise<{ ok: boolean; out: string } | null> {
275  for (const bin of ghBin ? [ghBin] : ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh']) {
276    try {
277      const ran = await $.process.run([bin, ...args])
278      ghBin = bin
279
280      return { ok: ran.exitCode === 0, out: ran.exitCode === 0 ? ran.stdout : ran.stderr }
281    } catch {
282      // not at this path; try the next
283    }
284  }
285
286  return null
287}
288
289// asks github about each PR an item waits on, one by one: a PR missing from the open-PR search
290// proves nothing (past the limit, or someone else's), so every tracked PR is asked by its URL
291async function readPrStates($: EngineInterface, items: readonly Item[]): Promise<Record<string, PrState>> {
292  const sources = new Map<string, PrSource>()
293  for (const i of items) {
294    const s = sourceOf(i)
295    if (s) {
296      sources.set(prKey(s), s)
297    }
298  }
299  const got = await Promise.all(
300    [...sources.entries()].map(async ([key, s]) => {
301      const ran = await gh($, ['pr', 'view', prUrl(s), '--json', 'state,mergedAt,closedAt'])
302      const state = ran?.ok ? parsePrState(ran.out) : null
303
304      return state ? ([key, state] as const) : null
305    }),
306  )
307
308  return Object.fromEntries(got.filter(x => x !== null))
309}
310
311
312// reads every source again; github only when asked or every few minutes
313async function refresh($: EngineInterface, isFull = false): Promise<void> {
314  const now = await $.clock.now()
315  const ownId = await $.session.id()
316  const notes: string[] = []
317  const [items, files, got, project, cwd] = await Promise.all([
318    readItems($).catch(() => {
319      notes.push('could not read ~/.claude/todo/items')
320
321      return [] as Item[]
322    }),
323    listTranscripts($).catch(() => {
324      notes.push('could not read the transcripts')
325
326      return [] as TranscriptFile[]
327    }),
328    readSessions($),
329    projectHere($),
330    $.session.cwd(),
331  ])
332  const sessions = got?.list ?? null
333  if (got && got.list.length >= got.limit) {
334    notes.push(`showing the newest ${got.limit} conversations`)
335  }
336  const recent = files.filter((f, i) => i < TALK_MAX || f.path.endsWith(`/${ownId}.jsonl`))
337  const talks = [...(await scanTalks($, recent).catch(() => new Map<string, Talk>())).values()]
338  const was = await read($, view)
339  let { prStates } = was
340  const missing = items.filter(i => sourceOf(i) && !prStates[prKey(sourceOf(i) as PrSource)])
341  if (isFull || now - ghAt > GH_EVERY) {
342    ghAt = now
343    prStates = await readPrStates($, items)
344  } else if (missing.length > 0) {
345    // a new PR item: check it now rather than in five minutes
346    prStates = { ...prStates, ...(await readPrStates($, missing)) }
347  }
348  const own = talks.find(t => t.id === ownId)
349  const others = newestTalks(talks).filter(
350    t => t.id !== ownId && t.title !== '' && !(own && t.title === own.title && t.cwd === own.cwd) && isLiveTitle(sessions, t.title),
351  )
352  const here = { id: ownId, title: own?.title || (await firstPrompt($)) || '', project, cwd, recap: own?.recap }
353  const convos = sessions
354    ? await readConvos($, { sessions, files, items, prStates, here }).catch(() => {
355        notes.push('could not read the conversations')
356
357        return null
358      })
359    : null
360  await update($, view, v => ({ ...v, here, talks: others, convos, items, prStates, at: now, notes }))
361}
362
363// what the person first asked here, as a title when the app gave the conversation none
364async function firstPrompt($: EngineInterface): Promise<string | undefined> {
365  const messages = await $.session.messages()
366  const first = messages.find(m => m.role === 'user' && m.text.trim() !== '' && !m.text.startsWith('<'))
367
368  return first ? clip(first.text, 40) : undefined
369}
370
371function startTicking($: EngineInterface): void {
372  if (ticking) {
373    return
374  }
375  ticking = true
376  $.clock.every(TICK, () => {
377    void refresh($).catch(() => undefined)
378  })
379}
380
381async function add($: EngineInterface, text: string, extra: { project?: string; link?: string; pr?: PrSource; by?: string } = {}): Promise<Item> {
382  const now = await $.clock.now()
383  const v = await read($, view)
384  const item: Item = {
385    schemaVersion: 2,
386    id: newId(now),
387    text: text.trim(),
388    project: extra.project || v.here.project || projectOf(await $.session.cwd()),
389    kind: 'todo',
390    status: 'open',
391    session: { id: await $.session.id(), title: v.here.title || undefined, cwd: await $.session.cwd() },
392    ...(extra.pr ? { link: prUrl(extra.pr) } : extra.link ? { link: extra.link } : {}),
393    by: extra.by ?? 'claude',
394    createdAt: new Date(now).toISOString(),
395    ...(extra.pr
396      ? { source: extra.pr, completion: { mode: 'source', condition: 'merged' } as const }
397      : { completion: { mode: 'declared' } as const }),
398  }
399  await writeItem($, item)
400  const prStates = extra.pr ? { ...v.prStates, ...(await readPrStates($, [item])) } : v.prStates
401  await update($, view, was => ({ ...was, items: [...was.items, item], prStates }))
402
403  return item
404}
405
406
407// records that someone says an item is done (or open again). a PR item can't be declared:
408// github is asked instead, and the answer comes back
409async function declare(
410  $: EngineInterface,
411  id: string,
412  how: { isDone: boolean; by: string; recordedBy?: string; note?: string },
413): Promise<{ item: Item; said: string } | null> {
414  const item = (await readItems($)).find(i => i.id === id)
415  if (!item) {
416    return null
417  }
418  if (sourceOf(item) || item.completion?.mode === 'source') {
419    const fresh = await readPrStates($, [item])
420    const answer = prAnswer(item, fresh, (await read($, view)).prStates)
421    await update($, view, was => ({ ...was, prStates: { ...was.prStates, ...answer.states } }))
422
423    return { item, said: answer.said }
424  }
425  const at = new Date(await $.clock.now()).toISOString()
426  const next = how.isDone ? declareDone(item, { by: how.by, recordedBy: how.recordedBy, note: how.note, at }) : reopen(item, at)
427  await writeItem($, next)
428  await update($, view, was => ({ ...was, items: was.items.map(i => (i.id === id ? next : i)) }))
429
430  return { item: next, said: next.status === 'done' ? `done (${how.by} said so)` : 'open again' }
431}
432
433
434// a tick or ✕ in the pane: the person did it, so the person is who said so
435async function press($: EngineInterface, id: string, what: 'done' | 'open' | 'delete'): Promise<void> {
436  const item = (await readItems($)).find(i => i.id === id)
437  if (!item || sourceOf(item)) {
438    return
439  }
440  const at = new Date(await $.clock.now()).toISOString()
441  const next = what === 'done' ? declareDone(item, { by: 'person', at }) : what === 'open' ? reopen(item, at) : deleteItem(item, { by: 'person', at })
442  await writeItem($, next)
443  await update($, view, was => ({ ...was, items: was.items.map(i => (i.id === id ? next : i)) }))
444}
445
446function line(i: Item, prStates: Readonly<Record<string, PrState>>): string {
447  const o = outcomeOf(i, prStates)
448  const from = i.session?.title ? ` · from ${i.session.title}` : ''
449  const state = o.state === 'open' ? '' : ` {${o.state}${o.why ? `: ${o.why}` : ''}}`
450
451  return `${i.id} ${i.project}: ${i.text}${i.link ? ` (${i.link})` : ''}${state}${from}`
452}
453
454const field = (e: unknown, name: string): unknown => (e as Record<string, unknown>)[name]
455const text = (e: unknown, name: string): string | undefined => {
456  const v = field(e, name)
457
458  return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
459}
460
461export const register: Register = on => {
462  on('session.start', async ($, e, next) => {
463    await $.command.register({
464      name: 'todo',
465      description: 'open the to-do pane; /todo <text> adds one for this conversation',
466      argumentHint: '[text]',
467    })
468    await $.tool.register({
469      name: 'todo_add',
470      description:
471        'Add a to-do to the shared list every conversation sees (~/.claude/todo/items): something the person asked for ' +
472        'that is still left after this turn, one short sentence. Check todo_list first and do not add the same thing twice. ' +
473        'Not every work step, not optional ideas, not "shall I go on". When it is done exactly when a GitHub PR merges, ' +
474        'pass the PR as `pr`: GitHub then ticks it. This conversation and project are filled in.',
475      inputSchema: {
476        type: 'object',
477        properties: {
478          text: { type: 'string', description: 'what is left, one short sentence' },
479          project: { type: 'string', description: 'repo name; defaults to this conversation’s repo' },
480          pr: { type: 'string', description: 'https://github.com/<owner>/<repo>/pull/<n>; the item is done when it merges' },
481          link: { type: 'string', description: 'a PR, issue or doc URL to show; it decides nothing' },
482        },
483        required: ['text'],
484      },
485      isDeferred: false,
486    })
487    await $.tool.register({
488      name: 'todo_done',
489      description:
490        'Tick a shared to-do as done, or open it again with reopen: true. Only when the person said it is done ' +
491        '(by: "person", their words in note) or you did it yourself and checked the result (by: "claude"). Never because ' +
492        'it looks likely. Items tied to a PR are ticked by GitHub; this only returns what GitHub says.',
493      inputSchema: {
494        type: 'object',
495        properties: {
496          id: { type: 'string' },
497          by: { type: 'string', enum: ['person', 'claude'] },
498          note: { type: 'string', description: 'why it is done, in a few words' },
499          reopen: { type: 'boolean' },
500        },
501        required: ['id', 'by'],
502      },
503      isDeferred: false,
504    })
505    await $.tool.register({
506      name: 'todo_list',
507      description: 'List the open shared to-dos with their ids, optionally for one project.',
508      inputSchema: { type: 'object', properties: { project: { type: 'string' } } },
509      isDeferred: false,
510    })
511    const started = await next(e)
512    void $.ui.open({ id: PANE, title: 'todo' })
513    void refresh($, true).catch(() => undefined)
514    startTicking($)
515
516    return started
517  })
518
519  on('turn.complete', async ($, e, next) => {
520    const done = await next(e)
521    void refresh($).catch(() => undefined)
522
523    return done
524  })
525
526  on('tool.call', { tool: 'mcp__puppy-todo__todo_add' }, async ($, e) => {
527    try {
528      const said = text(e, 'text')
529      if (!said) {
530        return { deny: 'text is required' }
531      }
532      const prText = text(e, 'pr')
533      const pr = prText ? parsePrUrl(prText) : null
534      if (prText && !pr) {
535        return { deny: 'pr must look like https://github.com/<owner>/<repo>/pull/<n>' }
536      }
537      const item = await add($, said, { project: text(e, 'project'), link: text(e, 'link'), pr: pr ?? undefined })
538
539      return { result: `added ${line(item, (await read($, view)).prStates)}` }
540    } catch (err) {
541      return { deny: `could not add: ${String(err)}` }
542    }
543  })
544
545  on('tool.call', { tool: 'mcp__puppy-todo__todo_done' }, async ($, e) => {
546    try {
547      const id = text(e, 'id')
548      const by = text(e, 'by')
549      if (!id || (by !== 'person' && by !== 'claude')) {
550        return { deny: 'id and by (person or claude) are required' }
551      }
552      const got = await declare($, id, {
553        isDone: field(e, 'reopen') !== true,
554        by,
555        // the person said it in a conversation; Claude wrote it down
556        recordedBy: by === 'person' ? 'claude' : undefined,
557        note: text(e, 'note'),
558      })
559
560      return got ? { result: `${got.said}: ${line(got.item, (await read($, view)).prStates)}` } : { deny: `no item with id ${id}` }
561    } catch (err) {
562      return { deny: `could not record it: ${String(err)}` }
563    }
564  })
565
566  on('tool.call', { tool: 'mcp__puppy-todo__todo_list' }, async ($, e) => {
567    try {
568      const project = text(e, 'project')
569      const items = (await readItems($)).filter(i => !project || i.project === project)
570      const prStates = await readPrStates($, items)
571      const open = shown(items, prStates, 0).filter(i => isOpenItem(i, prStates))
572
573      return { result: open.length === 0 ? 'nothing open' : open.map(i => line(i, prStates)).join('\n') }
574    } catch (err) {
575      return { deny: `could not read the list: ${String(err)}` }
576    }
577  })
578
579  on('command.run', { command: 'todo' }, async ($, e) => {
580    await $.ui.open({ id: PANE, title: 'todo' })
581    if (e.args.trim() === '') {
582      void refresh($, true).catch(() => undefined)
583
584      return { text: 'the to-do pane is open.' }
585    }
586    const item = await add($, e.args, { by: 'person' })
587
588    return { text: `added: ${item.text}` }
589  })
590
591  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
592    const table = $.ui.resolve(e)
593    const { Box, Text, Button, Link } = table
594    // the phone app draws no field yet
595    const Input = 'Input' in table ? table.Input : undefined
596    const v = await read($, view)
597    if (v.at === null) {
598      return <Text dimColor>{FACES.calm} sniffing around…</Text>
599    }
600    const now = v.at
601    const width = Math.max(30, (e.viewport?.columns ?? 60) - 4)
602
603    const all = shown(v.items, v.prStates, now)
604    const open = all.filter(i => isOpenItem(i, v.prStates))
605    const done = all.filter(i => !isOpenItem(i, v.prStates))
606    const mine = open.filter(i => isHere(i, v.here))
607    const others = open.filter(i => !isHere(i, v.here))
608    const asks = recentAsks(v.talks, now)
609    const clear = (v.convos ?? []).filter(c => c.verdict.state === 'clear')
610
611    // inside a card: the border and padding take 4 columns (STYLE.md)
612    const inner = width - 4
613    // the terminal cuts by columns; a graphical surface measures the words and cuts at the edge itself
614    const isGraphical = e.surface !== 'terminal'
615    const fit = (text: string, max: number) => clip(text, isGraphical ? 300 : max)
616    // a card: a rounded dim border, the name on the left, quiet facts on the right
617    const card = (key: string, name: string, right: RenderChildren, body: RenderChildren) => (
618      <Box key={`card-${key}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} gap="xs">
619        <Box flexDirection="row" justifyContent="space-between" gap={1}>
620          <Box flexShrink={1} minWidth={0}>
621            <Text weight="semibold" wrap="truncate-end">{name}</Text>
622          </Box>
623          <Text dimColor>{right}</Text>
624        </Box>
625        {body}
626      </Box>
627    )
628    // the ✕ shows when the pointer is on the row (the desktop); with no pointer it always shows
629    const hasPointer = e.surface === 'desktop'
630    // an open to-do: a tick, its words, and a ✕ at the far right. a PR item has no tick or ✕: github ticks it
631    const row = (i: Item) => {
632      const isPr = sourceOf(i) !== null
633      const o = outcomeOf(i, v.prStates)
634      const scope = `row-${i.id}`
635
636      return (
637        <Box key={`i-${i.id}`} flexDirection="row" gap={1} alignItems="center" hover={{ scope }}>
638          {/* one width for the tick and the arrow, so the words line up */}
639          <Box width={3} justifyContent="center">
640            {isPr ? <Text dimColor>↗</Text> : <Button key={`tick-${i.id}`} label="☐" plain onPress={() => press($, i.id, 'done')} />}
641          </Box>
642          <Box flexGrow={1} flexShrink={1} minWidth={0}>
643            <Text wrap="truncate-end">{fit(i.text, inner - 10)}</Text>
644          </Box>
645          {isPr ? <Text dimColor>{o.state === 'unknown' ? 'not checked yet' : 'waiting for merge'}</Text> : null}
646          {i.link?.startsWith('https://') ? <Link href={i.link} label="link" /> : null}
647          {isPr ? null : (
648            <Box {...(hasPointer ? { display: 'none', hover: { scope, display: 'flex' } } : {})}>
649              <Button key={`del-${i.id}`} label="✕" plain dimColor onPress={() => press($, i.id, 'delete')} />
650            </Box>
651          )}
652        </Box>
653      )
654    }
655    const group = (key: string, label: string, list: readonly Item[]) => (
656      <Box key={key} flexDirection="column">
657        <Text dimColor>{label}</Text>
658        {list.map(i => row(i))}
659      </Box>
660    )
661    const ASKS_SHOWN = 3
662
663    return (
664      <Box flexDirection="column" gap={1}>
665        <Box flexDirection="row" justifyContent="space-between" gap={1}>
666          <Box flexShrink={1} minWidth={0}>
667            <Text>
668              {FACES[mood(asks.length, open.length)]}
669              <Text dimColor> {saying(asks.length, open.length)}</Text>
670            </Text>
671          </Box>
672          <Button key="refresh" label="refresh" dimColor onPress={() => refresh($, true)} />
673        </Box>
674
675        {card(
676          'here',
677          fit(v.here.title || 'this conversation', inner - v.here.project.length - 4),
678          v.here.project,
679          v.here.recap ? <Text dimColor wrap="truncate-end">last: {fit(v.here.recap, inner - 6)}</Text> : null,
680        )}
681
682        {asks.length > 0 &&
683          card(
684            'asks',
685            'asked you',
686            <Text color="warning">{asks.length} waiting</Text>,
687            <Box flexDirection="column">
688              {asks.slice(0, ASKS_SHOWN).map(t => (
689                <Box key={`ask-${t.id}`} flexDirection="column">
690                  <Text wrap="truncate-end">
691                    {fit(t.title, inner - 16)}
692                    <Text dimColor> · {ago(now, t.mtimeMs)}</Text>
693                  </Text>
694                  <Text dimColor wrap="truncate-end">  {fit(t.ask ?? '', inner - 2)}</Text>
695                </Box>
696              ))}
697              {asks.length > ASKS_SHOWN ? <Text dimColor>and {asks.length - ASKS_SHOWN} more</Text> : null}
698            </Box>,
699          )}
700
701        {card(
702          'todo',
703          'to-do',
704          `${open.length} open`,
705          <Box flexDirection="column" gap={1}>
706            {Input ? (
707              <Input
708                key="add"
709                placeholder="add a to-do…"
710                submitLabel="add"
711                onSubmit={said => {
712                  if (said.trim() !== '') {
713                    void add($, said, { by: 'person' })
714                  }
715                }}
716              />
717            ) : null}
718            {mine.length > 0 ? group('here', 'this conversation', mine) : null}
719            {byProject(others).map(([project, list]) => group(`p-${project}`, project, list))}
720            {open.length === 0 ? <Text dimColor>nothing to do. the puppy naps.</Text> : null}
721          </Box>,
722        )}
723
724        {done.length > 0 && (
725          <Box flexDirection="column">
726            <Button
727              key="show-done"
728              label={`${v.isShowingDone ? '▾' : '▸'} done ${done.length}`}
729              dimColor
730              onPress={() => update($, view, was => ({ ...was, isShowingDone: !was.isShowingDone }))}
731            />
732            {v.isShowingDone
733              ? done.map(i => (
734                  <Box key={`d-${i.id}`} flexDirection="row" gap={1}>
735                    {sourceOf(i) ? (
736                      <Text dimColor>{outcomeOf(i, v.prStates).state === 'canceled' ? '✕' : '✓'}</Text>
737                    ) : (
738                      <Button key={`untick-${i.id}`} label="☑" dimColor onPress={() => press($, i.id, 'open')} />
739                    )}
740                    <Text dimColor strikethrough>
741                      {fit(i.text, width - 8)}
742                    </Text>
743                  </Box>
744                ))
745              : null}
746          </Box>
747        )}
748
749        {v.convos !== null && (
750          <Box flexDirection="column">
751            <Button
752              key="show-convos"
753              label={`${v.isShowingConvos ? '▾' : '▸'} tidy up: ${clear.length} of ${v.convos.length} conversations have nothing open`}
754              dimColor
755              onPress={() => update($, view, was => ({ ...was, isShowingConvos: !was.isShowingConvos }))}
756            />
757            {v.isShowingConvos ? (
758              <Box flexDirection="column">
759                <Text dimColor>nothing open found is a hint, not a promise. archive from the sidebar.</Text>
760                {sortConvos(v.convos).map(c => (
761                  <Box key={`c-${c.session.id}`} flexDirection="column">
762                    <Text dimColor={c.verdict.state !== 'clear'}>
763                      {{ clear: '✓', open: '●', unknown: '?' }[c.verdict.state]} {fit(c.session.title, width - 24)}
764                      <Text dimColor>
765                        {' '}
766                        · {ago(now, c.session.lastActivityMs)}
767                        {c.session.isPinned ? ' · pinned' : ''}
768                      </Text>
769                    </Text>
770                    <Text dimColor>
771                      {'  '}
772                      {fit(c.verdict.reasons.length > 0 ? c.verdict.reasons.join(' · ') : 'nothing open found', width * 2)}
773                    </Text>
774                    {c.recap ? <Text dimColor>  last: {fit(c.recap, width * 2)}</Text> : null}
775                  </Box>
776                ))}
777              </Box>
778            ) : null}
779          </Box>
780        )}
781
782        <Text dimColor>
783          looked at {clockTime(now)}
784          {v.notes.length > 0 ? ` · ${v.notes.join(' · ')}` : ''}
785        </Text>
786      </Box>
787    )
788  })
789}
790
hooks/convos.ts 176 lines
1// the conversation list: which of the app's conversations can be archived. no $ here; tests import it
2import type { Convo, Item, Session, Talk, Verdict, WorktreeState } from '../types'
3
4import { isHere } from './todo'
5
6// a transcript's folder under ~/.claude/projects: the working folder with every other character as -
7export function projectDir(cwd: string): string {
8  return cwd.replace(/[^A-Za-z0-9]/g, '-')
9}
10
11// `list_sessions` as the app returns it; rows missing an id, a title or a folder are left out
12export function parseSessions(text: string): Session[] {
13  try {
14    const rows = JSON.parse(text) as Record<string, unknown>[]
15
16    return rows.flatMap(r => {
17      if (typeof r.sessionId !== 'string' || typeof r.title !== 'string' || typeof r.cwd !== 'string') {
18        return []
19      }
20      const at = typeof r.lastActivityAt === 'string' ? Date.parse(r.lastActivityAt) : NaN
21
22      return [
23        {
24          id: r.sessionId,
25          title: r.title,
26          cwd: r.cwd,
27          isArchived: r.isArchived === true,
28          isPinned: r.pinned === true,
29          lastActivityMs: Number.isFinite(at) ? at : 0,
30          ...(typeof r.prNumber === 'number' ? { prNumber: r.prNumber } : {}),
31          ...(typeof r.prState === 'string' ? { prState: r.prState } : {}),
32        },
33      ]
34    })
35  } catch {
36    return []
37  }
38}
39
40// which transcript is this conversation's latest: the app writes the transcript as it works, so the
41// file in the conversation's folder changed at its last activity. a resumed conversation has several
42// transcripts and forks share a title, so the time is what tells them apart
43export const SAME_MOMENT_MS = 5_000
44
45export function pickTranscript(s: Session, files: readonly { path: string; mtimeMs: number }[], projects: string): string | null {
46  const mine = folderFiles(s, files, projects)
47  let best: { path: string; gap: number } | null = null
48  for (const f of mine) {
49    const gap = Math.abs(f.mtimeMs - s.lastActivityMs)
50    if (!best || gap < best.gap) {
51      best = { path: f.path, gap }
52    }
53  }
54  // the file named after the app's id is only the first transcript: a reopened conversation goes on
55  // in a newer one, so without a match in time the title match (newest first) decides
56  return best && best.gap <= SAME_MOMENT_MS ? best.path : null
57}
58
59// when no transcript changed at the last activity (the app moves that time when a conversation is
60// pinned or renamed, and writes to a transcript when it opens it again), the newest transcript in its
61// folder that ever had its title, among those no other conversation already matched by time.
62// a resumed conversation has several such files; the newest is its latest
63export function pickByTitle(
64  s: Session,
65  candidates: readonly { path: string; mtimeMs: number; titles: readonly string[] }[],
66  claimed: ReadonlySet<string>,
67): string | null {
68  const same = candidates.filter(c => !claimed.has(c.path) && c.titles.includes(s.title)).sort((a, b) => b.mtimeMs - a.mtimeMs)
69
70  return same[0]?.path ?? null
71}
72
73// the transcripts in a conversation's folder
74export function folderFiles<T extends { path: string }>(s: Session, files: readonly T[], projects: string): T[] {
75  const dir = `${projects}/${projectDir(s.cwd)}/`
76
77  return files.filter(f => f.path.startsWith(dir) && !f.path.slice(dir.length).includes('/'))
78}
79
80// every conversation's transcript by time, each file to one conversation at most: the closest
81// pairs first, so two conversations active a moment apart in one folder never share a transcript
82export function matchByTime(sessions: readonly Session[], files: readonly { path: string; mtimeMs: number }[], projects: string): Map<string, string | null> {
83  const pairs = sessions.flatMap(s =>
84    folderFiles(s, files, projects)
85      .map(f => ({ id: s.id, path: f.path, gap: Math.abs(f.mtimeMs - s.lastActivityMs) }))
86      .filter(p => p.gap <= SAME_MOMENT_MS),
87  )
88  pairs.sort((a, b) => a.gap - b.gap)
89  const out = new Map<string, string | null>(sessions.map(s => [s.id, null]))
90  const taken = new Set<string>()
91  for (const p of pairs) {
92    if (out.get(p.id) === null && !taken.has(p.path)) {
93      out.set(p.id, p.path)
94      taken.add(p.path)
95    }
96  }
97
98  return out
99}
100
101// `git status --porcelain` lines, and the number of commits on no remote
102export function parseWorktree(status: { exitCode: number; stdout: string }, unpushed: { exitCode: number; stdout: string }): WorktreeState {
103  if (status.exitCode !== 0 || unpushed.exitCode !== 0) {
104    return { state: 'unknown' }
105  }
106  const n = Number(unpushed.stdout.trim())
107
108  return {
109    state: 'read',
110    changed: status.stdout.split('\n').filter(l => l.trim() !== '').length,
111    unpushed: Number.isFinite(n) ? n : 0,
112  }
113}
114
115export function isWorktree(cwd: string): boolean {
116  return /\/\.claude\/worktrees\/[^/]+\/?$/.test(cwd)
117}
118
119// what is still open in a conversation, as facts. no open thing found is "clear", which only says
120// nothing was found, not that it is finished; anything that can't be read makes it unknown
121export function verdictOf(c: {
122  session: Session
123  talk: Talk | null
124  items: readonly Item[]
125  isOpenItem: (i: Item) => boolean
126  worktree: WorktreeState | null
127  isHere: boolean
128}): Verdict {
129  const reasons: string[] = []
130  if (c.isHere) {
131    return { state: 'open', reasons: ['this conversation'] }
132  }
133  if (c.talk?.ask) {
134    reasons.push(`asked you: ${c.talk.ask}`)
135  }
136  if (c.talk) {
137    // any title the conversation had: a to-do written before a rename is still its own
138    const titles = new Set([c.session.title, ...c.talk.titles])
139    const open = c.items.filter(
140      i => c.isOpenItem(i) && [...titles].some(title => isHere(i, { id: c.talk!.id, title, cwd: c.session.cwd })),
141    )
142    if (open.length > 0) {
143      reasons.push(`${open.length} open to-do${open.length > 1 ? 's' : ''}`)
144    }
145  }
146  if (c.session.prState === 'OPEN') {
147    reasons.push(`PR #${c.session.prNumber ?? '?'} not merged`)
148  }
149  if (c.worktree?.state === 'read') {
150    if (c.worktree.changed > 0) {
151      reasons.push(`${c.worktree.changed} uncommitted change${c.worktree.changed > 1 ? 's' : ''}`)
152    }
153    if (c.worktree.unpushed > 0) {
154      reasons.push(`${c.worktree.unpushed} commit${c.worktree.unpushed > 1 ? 's' : ''} not pushed`)
155    }
156  }
157  if (reasons.length > 0) {
158    return { state: 'open', reasons }
159  }
160  if (!c.talk) {
161    return { state: 'unknown', reasons: ['could not find its last turn'] }
162  }
163  if (c.worktree?.state === 'unknown') {
164    return { state: 'unknown', reasons: ['could not check its folder'] }
165  }
166
167  return { state: 'clear', reasons: [] }
168}
169
170// clear ones first, oldest first, so tidying starts with the stalest; then open; then unknown
171export function sortConvos(list: readonly Convo[]): Convo[] {
172  const rank = { clear: 0, open: 1, unknown: 2 }
173
174  return [...list].sort((a, b) => rank[a.verdict.state] - rank[b.verdict.state] || a.session.lastActivityMs - b.session.lastActivityMs)
175}
176
hooks/todo.ts 394 lines
1// the parts with no $: ids, parsing transcripts and items, sorting. tests import these.
2import type { Item, Kind, Outcome, PrSource, PrState, Talk } from '../types'
3
4export const KINDS: readonly Kind[] = ['todo', 'ask', 'later']
5// the puppy at the top of the pane, no headphones (a to-do isn't music, so no orange either):
6// hello while another conversation asked you something, happy when nothing is open, calm otherwise
7export type Mood = 'calm' | 'hello' | 'happy'
8export const FACES: Record<Mood, string> = { calm: '•ᴥ•', hello: '°ᴥ°', happy: '^ᴥ^' }
9
10export function mood(asks: number, open: number): Mood {
11  if (asks > 0) {
12    return 'hello'
13  }
14
15  return open === 0 ? 'happy' : 'calm'
16}
17
18// the words beside the face
19export function saying(asks: number, open: number): string {
20  if (asks === 0 && open === 0) {
21    return 'all clear'
22  }
23  const parts = [`${open} to-do`]
24  if (asks > 0) {
25    parts.push(`${asks} asked you`)
26  }
27
28  return parts.join(' · ')
29}
30
31// a question from another conversation shows this long after it was asked, then fades by itself
32export const ASK_DAYS = 2
33
34export function recentAsks(talks: readonly Talk[], now: number): Talk[] {
35  return talks.filter(t => t.ask && now - t.mtimeMs < ASK_DAYS * DAY).sort((a, b) => b.mtimeMs - a.mtimeMs)
36}
37
38// done items stay visible this long, then only the file remains
39export const DONE_DAYS = 3
40const DAY = 86_400_000
41
42// 20261024-1432-k3f9: sorts by time, short enough to type
43export function newId(now: number, rand: () => number = Math.random): string {
44  const d = new Date(now)
45  const two = (n: number) => String(n).padStart(2, '0')
46  const stamp = `${d.getFullYear()}${two(d.getMonth() + 1)}${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}`
47  const tail = Math.floor(rand() * 36 ** 4).toString(36).padStart(4, '0')
48
49  return `${stamp}-${tail}`
50}
51
52// a repo's name from its working folder: worktrees count as their repo
53export function projectOf(cwd: string): string {
54  const repo = cwd.replace(/\/\.claude\/worktrees\/[^/]+\/?$/, '').replace(/\/+$/, '')
55
56  return repo.slice(repo.lastIndexOf('/') + 1) || repo
57}
58
59// a file someone (or another AI) wrote by hand: keep what is usable, drop the rest
60export function parseItem(text: string): Item | null {
61  let raw: unknown
62  try {
63    raw = JSON.parse(text)
64  } catch {
65    return null
66  }
67  if (typeof raw !== 'object' || raw === null) {
68    return null
69  }
70  const o = raw as Record<string, unknown>
71  if (typeof o.id !== 'string' || typeof o.text !== 'string' || o.text.trim() === '') {
72    return null
73  }
74  const kind = KINDS.includes(o.kind as Kind) ? (o.kind as Kind) : 'todo'
75  // a null or a non-object in these is read as not written; an object with a strange mode
76  // stays, and shows as "not understood"
77  const isObject = (v: unknown) => typeof v === 'object' && v !== null && !Array.isArray(v)
78  const { completion, source, declaration, ...known } = o
79
80  return {
81    ...(known as Partial<Item>),
82    ...(isObject(completion) ? { completion: completion as Item['completion'] } : {}),
83    ...(isObject(source) ? { source: source as Item['source'] } : {}),
84    ...(isObject(declaration) ? { declaration: declaration as Item['declaration'] } : {}),
85    id: o.id,
86    text: o.text.trim(),
87    project: typeof o.project === 'string' && o.project ? o.project : 'misc',
88    kind,
89    status: o.status === 'done' ? 'done' : 'open',
90    createdAt: typeof o.createdAt === 'string' ? o.createdAt : '',
91  }
92}
93
94// https://github.com/<owner>/<repo>/pull/<n>, nothing else
95export function parsePrUrl(url: string): PrSource | null {
96  const m = /^https:\/\/(github\.com)\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)\/?$/.exec(url.trim())
97
98  return m && m[1] && m[2] && m[3] ? { type: 'github-pr', host: m[1], repo: m[2], number: Number(m[3]) } : null
99}
100
101export function prKey(s: PrSource): string {
102  return `${s.host}/${s.repo}#${s.number}`
103}
104
105export function prUrl(s: PrSource): string {
106  return `https://${s.host}/${s.repo}/pull/${s.number}`
107}
108
109// the PR an item waits on, when its completion says so
110export function sourceOf(item: Item): PrSource | null {
111  const s = item.source
112  if (item.completion?.mode !== 'source' || s?.type !== 'github-pr') {
113    return null
114  }
115
116  return typeof s.repo === 'string' && /^[\w.-]+\/[\w.-]+$/.test(s.repo) && Number.isInteger(s.number) && s.number > 0
117    ? { type: 'github-pr', host: 'github.com', repo: s.repo, number: s.number }
118    : null
119}
120
121// is it done? a source item asks its source and ignores what the file says;
122// a declared item believes the file, and says who said it
123export function outcomeOf(item: Item, prStates: Readonly<Record<string, PrState>>): Outcome {
124  if (item.completion?.mode === 'source') {
125    const source = sourceOf(item)
126    if (!source || (item.completion.condition ?? 'merged') !== 'merged') {
127      return { state: 'unknown', basis: 'source', why: 'the done rule in the file is not understood' }
128    }
129    const pr = prStates[prKey(source)]
130    if (!pr) {
131      return { state: 'unknown', basis: 'source', why: 'not checked on github yet' }
132    }
133    if (pr.state === 'MERGED') {
134      return { state: 'done', basis: 'source', why: 'merged', endedAt: pr.endedAt }
135    }
136    if (pr.state === 'CLOSED') {
137      return { state: 'canceled', basis: 'source', why: 'closed without merge', endedAt: pr.endedAt }
138    }
139
140    return { state: 'open', basis: 'source', why: 'done when the PR merges' }
141  }
142  if (item.completion !== undefined && item.completion.mode !== 'declared') {
143    return { state: 'unknown', basis: 'declared', why: 'the done rule in the file is not understood' }
144  }
145  if (item.status !== 'done') {
146    return { state: 'open', basis: item.completion ? 'declared' : 'legacy' }
147  }
148  const d = item.declaration
149  if (d && typeof d.by === 'string') {
150    const said = d.recordedBy && d.recordedBy !== d.by ? `${d.by} said done (noted by ${d.recordedBy})` : `${d.by} said done`
151
152    return { state: 'done', basis: 'declared', why: said, endedAt: d.at || item.doneAt }
153  }
154
155  return { state: 'done', basis: 'legacy', why: 'marked done, nobody recorded who', endedAt: item.doneAt }
156}
157
158// the item after someone says it is done: who said it, who wrote it down (when someone else did), when
159export function declareDone(item: Item, how: { by: string; recordedBy?: string; note?: string; at?: string } = { by: 'person' }): Item {
160  const at = how.at ?? new Date().toISOString()
161  const { declaration: _gone, doneAt: _alsoGone, ...rest } = item
162
163  return {
164    ...rest,
165    schemaVersion: 2,
166    completion: item.completion ?? { mode: 'declared' },
167    status: 'done',
168    doneAt: at,
169    declaration: {
170      by: how.by,
171      ...(how.recordedBy && how.recordedBy !== how.by ? { recordedBy: how.recordedBy } : {}),
172      at,
173      ...(how.note ? { note: how.note } : {}),
174    },
175  }
176}
177
178// deleted: never shown again, the file stays (nothing is erased)
179export function deleteItem(item: Item, how: { by: string; at?: string }): Item {
180  return { ...item, schemaVersion: 2, deletedAt: how.at ?? new Date().toISOString(), deletedBy: how.by }
181}
182
183// the item open again: the old declaration no longer holds; reopenedAt keeps when
184export function reopen(item: Item, at: string = new Date().toISOString()): Item {
185  const { declaration: _gone, doneAt: _alsoGone, ...rest } = item
186
187  return { ...rest, schemaVersion: 2, completion: item.completion ?? { mode: 'declared' }, status: 'open', reopenedAt: at }
188}
189
190// what to say when someone tries to mark a PR item done: github is asked instead.
191// fresh is what github just said (empty when it could not be asked); known is what the pane knew
192export function prAnswer(item: Item, fresh: Readonly<Record<string, PrState>>, known: Readonly<Record<string, PrState>>): { states: Record<string, PrState>; said: string } {
193  // one set of states for the words and for the line beside them
194  const states = { ...known, ...fresh }
195  const o = outcomeOf(item, states)
196  const source = sourceOf(item)
197  const isFresh = source !== null && prKey(source) in fresh
198  const what = `${o.state}${o.why ? ` (${o.why})` : ''}`
199
200  return {
201    states,
202    said: isFresh
203      ? `not changed: this item is done when github says so. github: ${what}`
204      : `not changed: this item is done when github says so. could not ask github now; last known: ${what}`,
205  }
206}
207
208const isOpen = (o: Outcome) => o.state === 'open' || o.state === 'unknown'
209
210// open first (oldest first, so nothing sinks), then what ended in the last few days
211export function shown(items: readonly Item[], prStates: Readonly<Record<string, PrState>>, now: number): Item[] {
212  const withOutcome = items.filter(i => !i.deletedAt).map(i => ({ i, o: outcomeOf(i, prStates) }))
213  const open = withOutcome.filter(x => isOpen(x.o)).sort((a, b) => a.i.id.localeCompare(b.i.id))
214  const ended = withOutcome
215    .filter(x => !isOpen(x.o) && x.o.endedAt && now - Date.parse(x.o.endedAt) < DONE_DAYS * DAY)
216    .sort((a, b) => (b.o.endedAt ?? '').localeCompare(a.o.endedAt ?? ''))
217
218  return [...open, ...ended].map(x => x.i)
219}
220
221export function isOpenItem(item: Item, prStates: Readonly<Record<string, PrState>>): boolean {
222  return !item.deletedAt && isOpen(outcomeOf(item, prStates))
223}
224
225// a conversation as a person knows it: its title and folder. the engine's session id changes
226// each time the app opens it again, so it can't tie rows to one conversation on its own
227export function talkKey(title: string, cwd: string): string {
228  return `${title}\u0000${cwd}`
229}
230
231// the newest transcript of each conversation; older ones of the same conversation are past turns
232export function newestTalks(talks: readonly Talk[]): Talk[] {
233  const best = new Map<string, Talk>()
234  for (const t of talks) {
235    const key = talkKey(t.title, t.cwd)
236    const was = best.get(key)
237    if (!was || t.mtimeMs > was.mtimeMs) {
238      best.set(key, t)
239    }
240  }
241
242  return talks.filter(t => best.get(talkKey(t.title, t.cwd)) === t)
243}
244
245// an item belongs to this conversation when this session wrote it, or a past session of the same
246// conversation did (same title and folder)
247export function isHere(item: Item, here: { id: string; title: string; cwd: string }): boolean {
248  const s = item.session
249  if (!s) {
250    return false
251  }
252
253  return s.id === here.id || (here.title !== '' && s.title === here.title && s.cwd === here.cwd)
254}
255
256export function byProject<T extends { project: string }>(list: readonly T[]): [string, T[]][] {
257  const groups = new Map<string, T[]>()
258  for (const one of list) {
259    groups.set(one.project, [...(groups.get(one.project) ?? []), one])
260  }
261
262  return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))
263}
264
265// the lines SCAN prints: \x1e<path> starts a file, X says it couldn't be read, U stands for something the person typed,
266// C:"<cwd>" is the last working folder, every other line is a whole transcript row
267export function parseScan(out: string, mtimes: ReadonlyMap<string, number>): Talk[] {
268  const talks: Talk[] = []
269  let talk: Talk | null = null
270  // whether the last wrap-up still waits: nothing typed after it
271  let isOpen = false
272  let ask: string | undefined
273  let askId: string | undefined
274  const finish = () => {
275    if (talk) {
276      talks.push({ ...talk, ask: isOpen ? ask : undefined, askId: isOpen ? askId : undefined })
277    }
278  }
279  for (const line of out.split('\n')) {
280    if (line.startsWith('\x1e')) {
281      finish()
282      const path = line.slice(1)
283      const id = path.slice(path.lastIndexOf('/') + 1).replace(/\.jsonl$/, '')
284      talk = { id, title: '', titles: [], project: '', cwd: '', prs: [], mtimeMs: mtimes.get(path) ?? 0 }
285      isOpen = false
286      ask = undefined
287      askId = undefined
288      continue
289    }
290    if (!talk || line === '') {
291      continue
292    }
293    if (line === 'X') {
294      talk.isUnread = true
295      continue
296    }
297    if (line === 'U') {
298      isOpen = false
299      continue
300    }
301    if (line.startsWith('C:')) {
302      const cwd = line.slice(2).replace(/^"cwd":"|"$/g, '')
303      talk.cwd = cwd
304      talk.project = projectOf(cwd)
305      continue
306    }
307    let row: Record<string, unknown>
308    try {
309      row = JSON.parse(line)
310    } catch {
311      continue
312    }
313    if (row.type === 'custom-title' && typeof row.customTitle === 'string') {
314      talk.title = row.customTitle
315      if (!talk.titles.includes(row.customTitle)) {
316        talk.titles.push(row.customTitle)
317      }
318    } else if (row.type === 'pr-link' && typeof row.prUrl === 'string') {
319      if (!talk.prs.includes(row.prUrl)) {
320        talk.prs.push(row.prUrl)
321      }
322    } else if (row.type === 'assistant') {
323      const wrap = wrapUpOf(row)
324      if (wrap) {
325        talk.recap = wrap.recap
326        ask = wrap.ask
327        askId = wrap.id
328        isOpen = wrap.ask !== undefined
329      }
330    }
331  }
332  finish()
333
334  return talks
335}
336
337function wrapUpOf(row: Record<string, unknown>): { recap?: string; ask?: string; id?: string } | null {
338  const content = (row.message as { content?: unknown } | undefined)?.content
339  if (!Array.isArray(content)) {
340    return null
341  }
342  for (const block of content) {
343    const b = block as { type?: string; id?: unknown; name?: string; input?: { recap?: unknown; ask?: unknown } }
344    if (b.type === 'tool_use' && b.name === 'mcp__ccd_turn__wrap_up' && b.input) {
345      return {
346        id: typeof b.id === 'string' ? b.id : undefined,
347        recap: typeof b.input.recap === 'string' ? b.input.recap : undefined,
348        ask: typeof b.input.ask === 'string' && b.input.ask ? b.input.ask : undefined,
349      }
350    }
351  }
352
353  return null
354}
355
356// `gh pr view --json state,mergedAt,closedAt`
357export function parsePrState(out: string): PrState | null {
358  try {
359    const o = JSON.parse(out) as { state?: string; mergedAt?: string | null; closedAt?: string | null }
360    if (o.state === 'MERGED') {
361      return { state: 'MERGED', endedAt: o.mergedAt ?? o.closedAt ?? undefined }
362    }
363    if (o.state === 'CLOSED') {
364      return { state: 'CLOSED', endedAt: o.closedAt ?? undefined }
365    }
366
367    return o.state === 'OPEN' ? { state: 'OPEN' } : null
368  } catch {
369    return null
370  }
371}
372
373export function clip(text: string, max: number): string {
374  const one = text.replace(/\s+/g, ' ').trim()
375
376  return one.length > max ? `${one.slice(0, max - 1)}…` : one
377}
378
379export function clockTime(ms: number): string {
380  const d = new Date(ms)
381
382  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
383}
384
385export function ago(now: number, ms: number): string {
386  const m = Math.round((now - ms) / 60_000)
387  if (m < 60) {
388    return `${Math.max(m, 0)}m`
389  }
390  const hours = Math.round(m / 60)
391
392  return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`
393}
394
types/index.d.ts 127 lines
1// one to-do in the shared list: ~/.claude/todo/items/<id>.json
2export type Item = {
3  // 2 for items with source/completion/declaration; missing means the first format
4  schemaVersion?: number
5  id: string
6  text: string
7  // the repo or project it belongs to, e.g. baibai-music
8  project: string
9  // todo: the person does it; ask: a question waiting for the person; later: a conversation said it would do it later
10  kind: Kind
11  // for declared items: what someone said. for source items: ignored, the source decides
12  status: 'open' | 'done'
13  // the conversation that wrote it, when known
14  session?: { id?: string; title?: string; cwd?: string }
15  // a PR, issue or doc it points at (a link alone decides nothing)
16  link?: string
17  // who wrote the item: claude, codex or person
18  by?: string
19  createdAt: string
20  doneAt?: string
21  // where the answer to "is it done?" lives; only github PRs so far
22  source?: PrSource
23  // declared: someone says it is done. source: the source says it is done
24  completion?: { mode: 'declared' | 'source'; condition?: 'merged' }
25  // who said it is done, for declared items; cleared when it opens again
26  declaration?: Declaration
27  // when it was last opened again after being done
28  reopenedAt?: string
29  // deleted: never shown, the file stays
30  deletedAt?: string
31  deletedBy?: string
32}
33
34export type Kind = 'todo' | 'ask' | 'later'
35
36export type PrSource = { type: 'github-pr'; host: string; repo: string; number: number }
37
38export type Declaration = {
39  // who says it is done: person, claude or codex
40  by: string
41  // who wrote it down, when someone else said it
42  recordedBy?: string
43  at: string
44  note?: string
45}
46
47// what a PR is right now, by `gh pr view`; endedAt is when it was merged or closed
48export type PrState = { state: 'OPEN' | 'MERGED' | 'CLOSED'; endedAt?: string }
49
50// an item as the pane shows it: worked out from the file and its source, never stored
51export type Outcome = {
52  state: 'open' | 'done' | 'canceled' | 'unknown'
53  // source: checked at the source. declared: someone said so. legacy: an old file, nobody recorded who
54  basis: 'source' | 'declared' | 'legacy'
55  // a few words on why, shown dim
56  why?: string
57  // when it was done or canceled, for how long it stays shown
58  endedAt?: string
59}
60
61// what one conversation's transcript says right now
62export type Talk = {
63  // the transcript's file name without .jsonl (the engine's session id; a reopened conversation gets a new one)
64  id: string
65  title: string
66  // every title it had (a conversation can be renamed)
67  titles: string[]
68  project: string
69  cwd: string
70  // the last wrap-up recap
71  recap?: string
72  // the last wrap-up question, while nothing was typed after it
73  ask?: string
74  // the tool use that asked it
75  askId?: string
76  prs: string[]
77  mtimeMs: number
78  // the file could not be read: nothing in it is known
79  isUnread?: boolean
80}
81
82// one of the app's conversations, from its session list
83export type Session = {
84  // local_<uuid>, the app's id
85  id: string
86  title: string
87  cwd: string
88  isArchived: boolean
89  isPinned: boolean
90  lastActivityMs: number
91  prNumber?: number
92  prState?: string
93}
94
95// a worktree's git state: changed files and commits on no remote
96export type WorktreeState = { state: 'read'; changed: number; unpushed: number } | { state: 'unknown' }
97
98// clear: no open thing was found (not a promise it is finished). open: something is (reasons say what).
99// unknown: its last turn couldn't be read
100export type Verdict = { state: 'clear' | 'open' | 'unknown'; reasons: string[] }
101
102export type Convo = { session: Session; recap?: string; verdict: Verdict }
103
104export type View = {
105  // this conversation
106  here: { id: string; title: string; project: string; cwd: string; recap?: string }
107  talks: Talk[]
108  items: Item[]
109  // PR states for items whose source is a PR, by prKey
110  prStates: Record<string, PrState>
111  // every conversation the app has open, with what is still open in it; null when the app's list can't be read
112  convos: Convo[] | null
113  // the two folded rows at the bottom
114  isShowingDone: boolean
115  isShowingConvos: boolean
116  // ms since the epoch of the last refresh; null before the first
117  at: number | null
118  // why a source could not be read (gh missing, ...)
119  notes: string[]
120}
121
122declare module 'claude-code' {
123  interface PluginState {
124    'puppy-todo': { view: View }
125  }
126}
127