SLOPSHOPPER

agent-todos

One board for the todos of every agent working in this project, with the items only you can do pinned on top. Search + priority.

newpaneguardcommandtoaststatus
v0.3.0MITupdated 2026-10-20XyndoX/agent-todos
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-todos
│ ┃ Agent todos ✕ › fix the failing auth test and add an audit log call │ ┃ ☑ Agent Todos app · 0 open scan │ ┃ view:youview:urgentview:activeview:blockedvi ⏺ Read(src/auth.ts) │ ┃ ⌕: search titles, notes, agents, p0… ⏎ filte ⎿ Read 6 lines │ ┃ All clear — agents add todos with ⏺ Update(src/auth.ts) │ ┃ TaskCreate / human_todo. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /todos │ ⎿ agent-todos: Agent todo board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent todos
☑ Agent Todos app · 0 open scan view:youview:urgentview:activeview:blockedview:all ⌕: search titles, notes, agents, p0… ⏎ filter All clear — agents add todos with TaskCreate / human_todo.
README

agent-todos

A Claude Code mod that puts the todos of every agent working in this project on one board: your main session, its subagents, and any other Claude Code session open in the same folder tree. Todos that only you can do are pinned to the top.

☑ Agent Todos                       AiSetup · 6 open
 ◆ You 2  ⚑ Urgent 3  ◐ Active 1  ⊘ Blocked 1  ≡ All 6
⌕: search titles, notes, agents, p0…
◆ Waiting on you  2
 ◆ Add STRIPE_KEY to Vercel env ⚑ P0
 ◆ Approve the new pricing copy P2
● main agent  session 8c2209 · 2
 ◐ Wire checkout webhook ⚑ P1  ⇢1
 ⊘ Deploy preview build ⚑ P0
╭──────────────────────────────────────────╮
│ ◐ Wire checkout webhook                  │
│ Status    in progress                    │
│ Priority  P1 · high                      │
│ [ ▶ Kick off ] [ ✓ Done ] [ Close ]      │
│ Change to   P0   P1   P2   P3            │
╰──────────────────────────────────────────╯

Inspired by OmniFocus 4: perspectives, todos grouped by agent like projects, a colored bullet per kind of todo, and an inspector for the selected todo.

BulletMeaning
◆ purpleonly you can do it
◐ greenan agent is working on it
⊘ grayblocked by another task
◉ red / ○ orange·blue / ◌ grayP0 / P1·P2 / P3
⚑P0 or P1
⇢nfinishing it unblocks n other tasks
☐ / ✎found by ⟳ Scan repo: an open checkbox / a code comment
✝its session has ended

Install

/plugin install agent-todos --marketplace XyndoX/agent-todos

Answer y to add the marketplace, then pick a scope. User scope puts it in every session.

Use

  • /todos opens the board. /todos stripe p0 opens it with a search already filled in.
  • While something is waiting on you, the status line shows ★ N on you.
  • A row matches the search only if it contains every word you typed. Words are checked against the title, notes and agent name. You can also type p0–p3, you, blocked or in_progress.
  • Click a todo to open the inspector, then:
  • ▶ Kick off asks the agent that owns the todo to start on it now. A todo of this session starts a turn here. A todo of another session is sent to that session as a message.
  • ✓ Done ticks it off.
  • P0–P3 changes its priority (stored per todo on your machine).
  • ⟳ Scan repo (or /todos scan) collects todos from the project into a "Found in repo" group:
  • open - [ ] checkboxes in Markdown. ROADMAP / NEXT-TASKS / TODO / BACKLOG files keep their priority, and checkboxes in other docs (plans, notes) drop to P3.
  • TODO / FIXME / HACK / XXX at the start of a comment. FIXME counts as P1.
  • A line that starts with your name (Chris:, Chris-only:, taken from git config user.name and your home folder) is marked as yours (◆).
  • Crossed-out … lines are skipped. A new scan replaces the last one, and at most 300 items are kept (roadmap first, then code, then other docs).
  • It uses git grep, which respects .gitignore. Outside a git repo it falls back to grep -r, skipping node_modules, dist and build.
  • ▶ Kick off on a found item hands it to the current session together with its file:line.
  • The tabs at the top (You, Urgent, Active, Blocked, All) switch between perspectives.

How items get on the board

Each session running the mod copies its agents' TaskCreate, TaskUpdate and TodoWrite calls into ~/.claude/agent-todos/<sessionId>.json. Every session reads all of those files. It shows the ones whose folder is inside its project root, or contains it.

Yours (◆): any of

  • the model calls the human_todo tool (the mod tells every agent to do this for logins, secrets, purchases, decisions and approvals)
  • a task subject starts with [you], you:, @me, …
  • TaskUpdate sets owner to user / you / me / human
  • TaskCreate with metadata: { human: true }

Priority: P0…P3 in a subject, or metadata: { priority: 0..3 }. The default is P2.

Order: yours first, then unblocked before blocked, then by priority, then in-progress items, then by how many other items each one unblocks.

An agent's items disappear when its session ends. Yours stay until you tick them off.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

Current limits

  • Ticking off one of your items doesn't tell the agent that asked for it. Tell the agent yourself, or it will still see the task as pending.
  • A session without this mod installed doesn't share its todos.

MIT

Source 3 files
hooks/register.tsx 408 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Item, Row, SessionFile } from '../types'
5import { VIEWS, inView, isHumanOwner, matches, parseScan, parseTitle, rank, rowKey } from './board'
6
7const PANE = 'agent-todos'
8const rowsAtom = atom({ plugin: 'agent-todos', key: 'rows' } as const, [] as Row[])
9const queryAtom = atom({ plugin: 'agent-todos', key: 'query' } as const, '')
10const rootAtom = atom({ plugin: 'agent-todos', key: 'root' } as const, '')
11const viewAtom = atom({ plugin: 'agent-todos', key: 'view' } as const, 'all')
12const selAtom = atom({ plugin: 'agent-todos', key: 'selected' } as const, '')
13
14const GUIDE = `## Agent todo board
15The person sees one board of every agent's todos in this project. When something can only be done by the person (a login, a secret, a purchase, a decision, a physical action, an approval), call \`mcp__agent-todos__human_todo\` with a short imperative title and a priority (0 urgent … 3 someday) instead of only mentioning it in prose. Mark a task as the person's with TaskUpdate owner "user", or prefix its subject with "[you]". "P0".."P3" in a subject sets its priority.`
16
17type $ = EngineInterface
18
19async function paths($: $) {
20  const home = (await $.env.get('HOME')) ?? ''
21  return { dir: `${home}/.claude/agent-todos`, sessions: `${home}/.claude/sessions` }
22}
23
24async function readJson<T>($: $, path: string): Promise<T | undefined> {
25  try { return JSON.parse(await $.fs.read(path)) as T } catch { return undefined }
26}
27
28// This session's own file: read, change, write. Serialized so parallel tool calls don't clobber each other.
29let chain: Promise<unknown> = Promise.resolve()
30function editMine($: $, fn: (items: Item[]) => Item[]) {
31  chain = chain.then(async () => {
32    const { dir } = await paths($)
33    const sessionId = await $.session.id()
34    const path = `${dir}/${sessionId}.json`
35    const old = await readJson<SessionFile>($, path)
36    const file: SessionFile = {
37      sessionId,
38      cwd: await $.session.root(),
39      updatedAt: await $.clock.now(),
40      items: fn(old?.items ?? []),
41    }
42    await $.fs.write(path, JSON.stringify(file, null, 1))
43  }).then(() => refresh($)).catch(() => undefined)
44  return chain
45}
46
47async function refresh($: $) {
48  const { dir, sessions } = await paths($)
49  const root = await $.session.root()
50  const live = new Set<string>()
51  for (const f of await $.fs.list(sessions).catch(() => [])) {
52    if (!f.name.endsWith('.json')) continue
53    const s = await readJson<{ sessionId?: string }>($, `${sessions}/${f.name}`)
54    if (s?.sessionId) live.add(s.sessionId)
55  }
56  const files: SessionFile[] = []
57  for (const f of await $.fs.list(dir).catch(() => [])) {
58    if (!f.name.endsWith('.json')) continue
59    const s = await readJson<SessionFile>($, `${dir}/${f.name}`)
60    if (s?.items) files.push(s)
61  }
62  const done = new Set(((await $.store.get('done')) as string[] | undefined) ?? [])
63  const prio = ((await $.store.get('prio')) as Record<string, number> | undefined) ?? {}
64  const rows = rank(files, root, live, done, prio)
65  await update($, rowsAtom, () => rows)
66  await update($, rootAtom, () => root)
67  const mine = rows.filter(r => r.isHuman).length
68  await $.ui.status(mine ? `★ ${mine} on you` : undefined)
69}
70
71async function tickDone($: $, row: Row) {
72  const done = ((await $.store.get('done')) as string[] | undefined) ?? []
73  // ponytail: done-list grows forever; prune by age if it ever matters
74  await $.store.set('done', [...done, rowKey(row)].slice(-2000))
75  await update($, selAtom, () => '')
76  await refresh($)
77  await $.ui.toast(`✓ ${row.title}`)
78}
79
80async function setPrio($: $, row: Row, priority: number) {
81  const prio = ((await $.store.get('prio')) as Record<string, number> | undefined) ?? {}
82  await $.store.set('prio', { ...prio, [rowKey(row)]: priority })
83  await refresh($)
84}
85
86const SCAN_RE = '(TODO|FIXME|HACK|XXX)|^[[:space:]]*([-*+]|[0-9]+\\.)[[:space:]]+\\[ \\]'
87
88/** Find TODO/FIXME/HACK/XXX comments and open `- [ ]` checkboxes in the project; they replace the last scan. */
89async function scanRepo($: $) {
90  const root = await $.session.root()
91  await $.ui.toast('⟳ Scanning repo for todos…')
92  try {
93    const git = await $.process.run(['git', 'grep', '-n', '-I', '-E', SCAN_RE, '--', '.', ':!*.lock', ':!*.min.*', ':!*.map'], { cwd: root, timeoutMs: 60_000 })
94    const out = git.exitCode <= 1 && !git.stderr.includes('not a git repository')
95      ? git.stdout
96      : (await $.process.run(['grep', '-rnI', '-E', SCAN_RE, '--exclude-dir=node_modules', '--exclude-dir=.git', '--exclude-dir=dist', '--exclude-dir=build', '.'], { cwd: root, timeoutMs: 60_000 })).stdout
97    const name = (await $.process.run(['git', 'config', 'user.name'], { cwd: root }).catch(() => undefined))?.stdout.trim() ?? ''
98    const home = ((await $.env.get('HOME')) ?? '').split('/').pop() ?? ''
99    const found = parseScan(out, { me: [...name.split(/\s+/), home] })
100    await editMine($, items => [...items.filter(i => !i.id.startsWith('scan:')), ...found])
101    await update($, viewAtom, () => 'all')
102    await $.ui.toast(found.length ? `⟳ Found ${found.length} todo${found.length > 1 ? 's' : ''} in the repo` : '⟳ No TODOs or open checkboxes found')
103  } catch (err) {
104    await $.ui.toast(`Scan failed: ${String(err).slice(0, 120)}`)
105  }
106}
107
108/** Hand the task to the agent that owns it: a turn here, or a message to the other session. */
109async function kickOff($: $, row: Row) {
110  const where = row.agent === 'repo' ? `\n\nFound at ${row.note} — remove the TODO / tick the checkbox when done.` : row.note ? `\n\n${row.note}` : ''
111  const text = `Start on this task now${row.priority <= 1 ? ' (high priority)' : ''}: "${row.title}"${where}`
112  try {
113    if (row.agent === 'repo' || row.sessionId === (await $.session.id())) await $.prompt.submit({ text })
114    else await $.session.send({ to: { sessionId: row.sessionId }, text })
115    await $.ui.toast(`▶ Kicked off: ${row.title}`)
116  } catch (err) {
117    await $.ui.toast(`Could not reach that agent: ${String(err).slice(0, 120)}`)
118  }
119}
120
121const label = (e: { agentId?: string }) => e.agentId ?? 'main'
122
123export const register: Register = on => {
124  on('session.start', async ($, e, next) => {
125    await $.command.register({ name: 'todos', description: 'Open the agent todo board (a search query, or "scan" to collect TODOs from the repo)' })
126    await $.tool.register({
127      name: 'human_todo',
128      description:
129        'Put a todo on the person\'s board that only they can complete (login, secret, purchase, decision, approval, physical action). Short imperative title.',
130      inputSchema: {
131        type: 'object',
132        properties: {
133          title: { type: 'string', description: 'What the person must do, imperative, < 80 chars' },
134          priority: { type: 'integer', minimum: 0, maximum: 3, description: '0 urgent, 1 high, 2 normal, 3 someday' },
135          why: { type: 'string', description: 'What it unblocks / extra context' },
136        },
137        required: ['title'],
138      },
139      isDeferred: false,
140    })
141    await editMine($, items => items) // announce this session
142    await $.clock.every(5000, () => refresh($))
143    return next(e)
144  })
145
146  on('prompt.compose', async ($, e, next) => {
147    const r = await next(e)
148    return { sections: [...r.sections, { id: 'agent-todos:guide', text: GUIDE, scope: 'session' }] }
149  })
150
151  on('command.run', { command: 'todos' }, async ($, e) => {
152    const isScan = e.args.trim() === 'scan'
153    await $.state.set({ plugin: 'agent-todos', key: 'query' }, isScan ? '' : e.args.trim())
154    if (isScan) await scanRepo($)
155    else await refresh($)
156    await $.ui.open({ id: PANE, title: 'Agent todos' })
157    return { text: 'Agent todo board opened.' }
158  })
159
160  on('tool.call', { tool: 'mcp__agent-todos__human_todo' }, async ($, e) => {
161    const args = e as unknown as { title?: unknown; priority?: unknown; why?: unknown; agentId?: string }
162    if (typeof args.title !== 'string' || !args.title.trim()) return { deny: 'title is required' }
163    const p = Number(args.priority)
164    const id = `human:${await $.clock.now()}`
165    await editMine($, items => [...items, {
166      id,
167      title: args.title as string,
168      status: 'pending',
169      priority: Number.isInteger(p) && p >= 0 && p <= 3 ? p : 2,
170      isHuman: true,
171      blockedBy: [],
172      agent: label(args),
173      note: typeof args.why === 'string' ? args.why : undefined,
174    }])
175    return { result: `Added to the person's board (${id}). They tick it off there.` }
176  }).catch(() => ({ deny: 'agent-todos could not save the item' }))
177
178  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
179    const r = await next(e)
180    if ('deny' in r && r.deny) return r
181    const id = (r.result as { task?: { id?: string } } | undefined)?.task?.id
182    if (!id) return r
183    const parsed = parseTitle(e.subject)
184    const meta = (e.metadata ?? {}) as { priority?: unknown; human?: unknown }
185    const p = Number(meta.priority)
186    await editMine($, items => [...items.filter(i => i.id !== `task:${id}`), {
187      id: `task:${id}`,
188      title: parsed.title,
189      status: 'pending',
190      priority: Number.isInteger(p) && p >= 0 && p <= 3 ? p : parsed.priority,
191      isHuman: parsed.isHuman || meta.human === true,
192      blockedBy: [],
193      agent: label(e),
194      note: e.description,
195    }])
196    return r
197  }).catch(($, e, next) => next(e))
198
199  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
200    const r = await next(e)
201    if ('deny' in r && r.deny) return r
202    const id = `task:${e.taskId}`
203    await editMine($, items => {
204      if (e.status === 'deleted') return items.filter(i => i.id !== id)
205      return items.map(i => {
206        if (i.id !== id) return i
207        const parsed = e.subject ? parseTitle(e.subject) : undefined
208        return {
209          ...i,
210          ...(parsed && { title: parsed.title, priority: parsed.priority, isHuman: i.isHuman || parsed.isHuman }),
211          ...(e.status && e.status !== 'deleted' && { status: e.status }),
212          ...(e.description && { note: e.description }),
213          ...(e.owner !== undefined && { isHuman: isHumanOwner(e.owner) }),
214          blockedBy: [...new Set([...i.blockedBy, ...(e.addBlockedBy ?? []).map(b => `task:${b}`)])],
215        }
216      })
217    })
218    return r
219  }).catch(($, e, next) => next(e))
220
221  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
222    const r = await next(e)
223    if ('deny' in r && r.deny) return r
224    const agent = label(e)
225    await editMine($, items => [
226      ...items.filter(i => !(i.agent === agent && i.id.startsWith('todo:'))),
227      ...e.todos.map((t, n) => {
228        const parsed = parseTitle(t.content)
229        return { id: `todo:${agent}:${n}`, ...parsed, status: t.status, blockedBy: [], agent }
230      }),
231    ])
232    return r
233  }).catch(($, e, next) => next(e))
234
235
236  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
237    const ui = $.ui.resolve(e)
238    const { Box, Text, Button } = ui
239    const Input = 'Input' in ui ? ui.Input : undefined // mobile has no Input
240    const all = await read($, rowsAtom)
241    const query = await read($, queryAtom)
242    const view = await read($, viewAtom)
243    const selected = await read($, selAtom)
244    const root = await read($, rootAtom)
245    const me = await $.session.id()
246    const width = Math.max(30, (e.props as { bodyColumns?: number }).bodyColumns ?? e.viewport?.columns ?? 80)
247    const rows = all.filter(r => inView(r, view) && matches(r, query))
248    const sel = all.find(r => rowKey(r) === selected)
249    const project = root.split('/').filter(Boolean).pop() ?? root
250    let room = Math.max(3, (e.viewport?.rows ?? 30) - 8 - (sel ? 11 : 0))
251
252    // Agent groups, OmniFocus-project style; your items first as their own group.
253    const groups = new Map<string, Row[]>()
254    for (const r of rows) {
255      const g = r.isHuman ? 'you' : r.agent === 'repo' ? 'repo' : `${r.sessionId}|${r.agent}`
256      groups.set(g, [...(groups.get(g) ?? []), r])
257    }
258
259    const tab = (v: string) => {
260      const n = all.filter(r => inView(r, v)).length
261      const isOn = v === view
262      const { glyph, name, color } = TABS[v as keyof typeof TABS]
263      return (
264        <Button key={`view:${v}`} plain onPress={() => void update($, viewAtom, () => v)}>
265          <Text color={isOn ? '#1c1c1e' : color} backgroundColor={isOn ? color : undefined} bold={isOn}>{` ${glyph} ${name} ${n} `}</Text>
266        </Button>
267      )
268    }
269
270    const row = (r: Row) => {
271      const b = bullet(r)
272      const pc = PRIO[r.priority] ?? PRIO[2]!
273      const isSel = rowKey(r) === selected
274      const tail = `${r.priority <= 1 ? ' ⚑' : ''} P${r.priority}${r.blocks ? ` ⇢${r.blocks}` : ''}${r.isLive ? '' : ' ✝'}`
275      const title = cut(r.title, width - tail.length - 5)
276      return (
277        <Button key={`row:${rowKey(r)}`} plain onPress={() => void update($, selAtom, s => (s === rowKey(r) ? '' : rowKey(r)))}>
278          <Text color={b.color} bold>{` ${b.glyph} `}</Text>
279          <Text bold={isSel} inverse={isSel} strikethrough={false} dimColor={r.isBlocked && !isSel}>{title}</Text>
280          {r.priority <= 1 && <Text color={pc.color}>{' ⚑'}</Text>}
281          <Text color="#1c1c1e" backgroundColor={pc.color}>{` P${r.priority} `}</Text>
282          {r.blocks > 0 && <Text color="#64d2ff">{` ⇢${r.blocks}`}</Text>}
283          {!r.isLive && <Text dimColor>{' ✝'}</Text>}
284        </Button>
285      )
286    }
287
288    const header = (g: string, list: Row[]) => {
289      if (g === 'you') return <Text key="h:you" color={TABS.you.color} bold>{`◆ Waiting on you  ${list.length}`}</Text>
290      if (g === 'repo') return <Text key="h:repo"><Text color={REPO_COLOR} bold>▤ Found in repo</Text><Text dimColor>{`  TODO comments & open checkboxes · ${list.length}`}</Text></Text>
291      const r = list[0]!
292      const c = agentColor(`${r.sessionId}${r.agent}`)
293      const where = r.sessionId === me ? 'this session' : `session ${r.sessionId.slice(0, 6)}`
294      return (
295        <Text key={`h:${g}`}>
296          <Text color={c} bold>{`● ${r.agent === 'main' ? 'main agent' : `agent ${r.agent.slice(0, 8)}`}`}</Text>
297          <Text dimColor>{`  ${where}${r.isLive ? '' : ' · ended'} · ${list.length}`}</Text>
298        </Text>
299      )
300    }
301
302    const inspector = (r: Row) => {
303      const pc = PRIO[r.priority] ?? PRIO[2]!
304      const blockers = r.blockedBy.map(id => all.find(o => o.sessionId === r.sessionId && o.id === id)?.title).filter(Boolean)
305      const field = (k: string, v: string, color?: string) => (
306        <Text key={`f:${k}`}><Text dimColor>{k.padEnd(10)}</Text><Text color={color}>{v}</Text></Text>
307      )
308      return (
309        <Box key="inspector" flexDirection="column" borderStyle="round" borderColor={bullet(r).color} paddingX={1} marginTop={1}>
310          <Text bold color={bullet(r).color}>{`${bullet(r).glyph} ${r.title}`}</Text>
311          {r.note && r.agent !== 'repo' ? <Text dimColor wrap="wrap">{r.note}</Text> : null}
312          {field('Status', r.isBlocked ? 'blocked' : r.status.replace('_', ' '), bullet(r).color)}
313          {field('Priority', `P${r.priority} · ${pc.name}`, pc.color)}
314          {r.agent === 'repo' && field('Found at', r.note ?? '', REPO_COLOR)}
315          {field('Owner', r.isHuman ? 'you' : r.agent === 'repo' ? 'repo · kick off assigns it here' : r.agent, r.isHuman ? TABS.you.color : agentColor(`${r.sessionId}${r.agent}`))}
316          {field('Session', `${r.sessionId.slice(0, 8)}${r.sessionId === me ? ' (this one)' : ''}${r.isLive ? '' : ' · ended'}`)}
317          {blockers.length > 0 && field('Waits on', blockers.join(', '), '#8e8e93')}
318          {r.blocks > 0 && field('Unblocks', `${r.blocks} task${r.blocks > 1 ? 's' : ''}`, '#64d2ff')}
319          <Box flexDirection="row" gap={1} marginTop={1}>
320            {!r.isHuman && (r.isLive || r.agent === 'repo') && <Button key="act:kick" hotkey="k" variant="primary" label="▶ Kick off" onPress={() => void kickOff($, r)} />}
321            <Button key="act:done" hotkey="d" label="✓ Done" onPress={() => void tickDone($, r)} />
322            <Button key="act:close" hotkey="x" role="dismiss" label="Close" onPress={() => void update($, selAtom, () => '')} />
323          </Box>
324          <Box flexDirection="row" gap={1}>
325            <Text dimColor>{'Change to'.padEnd(10)}</Text>
326            {PRIO.map((p, n) => (
327              <Button key={`act:p${n}`} plain onPress={() => void setPrio($, r, n)}>
328                <Text color={n === r.priority ? '#1c1c1e' : p.color} backgroundColor={n === r.priority ? p.color : undefined} bold={n === r.priority}>{` P${n} `}</Text>
329              </Button>
330            ))}
331          </Box>
332        </Box>
333      )
334    }
335
336    const body: unknown[] = []
337    for (const [g, list] of groups) {
338      if (room <= 1) break
339      body.push(header(g, list))
340      room--
341      for (const r of list.slice(0, room)) body.push(row(r))
342      room -= Math.min(list.length, room)
343    }
344    const shown = body.length - groups.size
345
346    return (
347      <Box flexDirection="column">
348        <Box flexDirection="row" justifyContent="space-between">
349          <Text bold color={TABS.you.color}>☑ Agent Todos</Text>
350          <Box flexDirection="row" gap={1}>
351            <Text dimColor>{`${project} · ${all.length} open`}</Text>
352            <Button key="scan" plain onPress={() => void scanRepo($)}>
353              <Text color="#1c1c1e" backgroundColor={REPO_COLOR}>{' ⟳ Scan repo '}</Text>
354            </Button>
355          </Box>
356        </Box>
357        <Box flexDirection="row" flexWrap="wrap">{VIEWS.map(tab)}</Box>
358        {Input && (
359          <Input key="q" label="⌕" placeholder="search titles, notes, agents, p0…" value={query} submitLabel="filter"
360            onInput={v => void update($, queryAtom, () => v)} onSubmit={v => void update($, queryAtom, () => v)} />
361        )}
362        {rows.length === 0 && (
363          <Text dimColor>{all.length ? '  Nothing in this perspective.' : '  All clear — agents add todos with TaskCreate / human_todo.'}</Text>
364        )}
365        {body as never}
366        {rows.length > shown && shown >= 0 && <Text dimColor>{`  +${rows.length - Math.max(0, shown)} more — search or switch perspective`}</Text>}
367        {sel && inspector(sel)}
368      </Box>
369    )
370  })
371}
372
373const TABS = {
374  you: { glyph: '◆', name: 'You', color: '#bf5af2' },
375  urgent: { glyph: '⚑', name: 'Urgent', color: '#ff9f0a' },
376  active: { glyph: '◐', name: 'Active', color: '#30d158' },
377  blocked: { glyph: '⊘', name: 'Blocked', color: '#8e8e93' },
378  all: { glyph: '≡', name: 'All', color: '#0a84ff' },
379}
380
381const PRIO = [
382  { name: 'urgent', color: '#ff453a' },
383  { name: 'high', color: '#ff9f0a' },
384  { name: 'normal', color: '#0a84ff' },
385  { name: 'someday', color: '#8e8e93' },
386]
387
388/** Each kind of todo gets its own bullet: yours, running, blocked, then by priority. */
389function bullet(r: Row) {
390  if (r.isHuman) return { glyph: '◆', color: TABS.you.color }
391  if (r.status === 'in_progress') return { glyph: '◐', color: TABS.active.color }
392  if (r.isBlocked) return { glyph: '⊘', color: TABS.blocked.color }
393  const p = PRIO[r.priority] ?? PRIO[2]!
394  if (r.agent === 'repo') return { glyph: r.id.startsWith('scan:md:') ? '☐' : '✎', color: p.color }
395  return { glyph: r.priority === 0 ? '◉' : r.priority === 3 ? '◌' : '○', color: p.color }
396}
397
398const REPO_COLOR = '#ac8e68'
399
400const AGENT_COLORS = ['#64d2ff', '#ffd60a', '#ff375f', '#66d4cf', '#5e5ce6', '#ac8e68', '#30d158']
401function agentColor(s: string) {
402  let h = 0
403  for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) | 0
404  return AGENT_COLORS[Math.abs(h) % AGENT_COLORS.length]!
405}
406
407const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
408
hooks/board.ts 136 lines
1import type { Item, Row, SessionFile } from '../types'
2
3const HUMAN_RE = /^\s*(\[(you|human|me|user)\]|@(you|me|human|user)\b|(you|human|user):)\s*/i
4const PRIO_RE = /\bP([0-3])\b/i
5const HUMAN_OWNER_RE = /^(you|me|human|user)$/i
6
7/** "[you] P0 rotate the API key" → human, priority 0, "rotate the API key" */
8export function parseTitle(raw: string) {
9  const isHuman = HUMAN_RE.test(raw)
10  const m = raw.match(PRIO_RE)
11  const title = raw.replace(HUMAN_RE, '').replace(PRIO_RE, '').replace(/\s+/g, ' ').trim()
12  return { title: title || raw.trim(), priority: m ? Number(m[1]) : 2, isHuman }
13}
14
15export const isHumanOwner = (owner: unknown) => typeof owner === 'string' && HUMAN_OWNER_RE.test(owner)
16
17const isUnder = (a: string, b: string) => a === b || a.startsWith(b.endsWith('/') ? b : b + '/')
18
19/** Relevant = same project tree, either direction. */
20export const isRelevant = (cwd: string, root: string) => isUnder(cwd, root) || isUnder(root, cwd)
21
22export function matches(row: Row, query: string) {
23  const hay = [
24    row.title, row.note ?? '', row.agent, `p${row.priority}`, row.status,
25    row.isHuman ? 'you human me' : '', row.isBlocked ? 'blocked' : '',
26  ].join(' ').toLowerCase()
27  return query.toLowerCase().split(/\s+/).filter(Boolean).every(t => hay.includes(t))
28}
29
30const open = (i: Item) => i.status !== 'completed'
31
32/**
33 * Open items of relevant sessions, ranked: yours first, then unblocked before
34 * blocked, priority, in-progress, how much each one unblocks.
35 * Agent items die with their session; your items stay until you tick them.
36 */
37/** Scan items belong to the repo, not the session that found them: ticking one off sticks across sessions. */
38export const rowKey = (r: { sessionId: string; id: string }) => (r.id.startsWith('scan:') ? r.id : `${r.sessionId}:${r.id}`)
39
40// The marker must open the line or a comment, so prose like "closes the INTEGRATION TODO in x" is skipped.
41const MARK_RE = /(?:^|\/\/|\/\*|<!--|#|--|;|\*|\{)\s*(TODO|FIXME|HACK|XXX)\b\s*(?:\([^)]*\))?\s*[:\-]?\s*(.*)$/
42const BOX_RE = /^\s*(?:[-*+]|\d+\.)\s+\[ \]\s+(.*)$/
43const CLOSER_RE = /\s*(\*\/|-->|#\}|%>|\}\}|\*\/\})\s*$/
44
45const ROADMAP_RE = /(^|\/)(ROADMAP|NEXT[-_]?TASKS|TODOS?|BACKLOG|TASKS|CHANGELOG-TODO)\.(md|txt)$/i
46
47/**
48 * `git grep -n` output → items: open markdown checkboxes and TODO/FIXME/HACK/XXX comments.
49 * Roadmap-type files keep their priority; checkboxes in other docs (plans, notes) drop to P3.
50 * A line that starts with one of `me` ("Chris:") is the person's.
51 */
52export function parseScan(out: string, opts: { max?: number; me?: string[] } = {}): Item[] {
53  const max = opts.max ?? 300
54  const me = (opts.me ?? []).filter(n => n.length > 1).map(n => n.toLowerCase())
55  const items = new Map<string, Item>()
56  for (const line of out.split('\n')) {
57    const m = line.match(/^(.+?):(\d+):(.*)$/)
58    if (!m) continue
59    const [, rawPath, ln, text] = m as unknown as [string, string, string, string]
60    const path = rawPath.replace(/^\.\//, '')
61    const isMd = /\.(md|markdown|txt)$/i.test(path)
62    const box = isMd ? text.match(BOX_RE) : null
63    const mark = isMd ? null : text.match(MARK_RE) // in prose only checkboxes count
64    const raw = (box?.[1] ?? mark?.[2] ?? '').replace(CLOSER_RE, '').replace(/\*\*|__|`/g, '').trim()
65    if (raw.length < 3 || raw.startsWith('~~')) continue // struck through = done
66    const owner = raw.match(/^([\p{L}][\p{L}'-]*)\s*:\s*/u)
67    const isMine = !!owner && me.includes(owner[1]!.toLowerCase().split(/[-']/)[0]!) // "Chris:", "Chris-only:"
68    const parsed = parseTitle(isMine ? raw.slice(owner![0].length) : raw)
69    if (isMine) parsed.isHuman = true
70    if (box && !ROADMAP_RE.test(path) && parsed.priority === 2) parsed.priority = 3
71    const kind = box ? 'md' : 'code'
72    const id = `scan:${kind}:${path}:${raw.slice(0, 80)}`
73    if (items.has(id)) continue
74    items.set(id, {
75      id,
76      ...parsed,
77      priority: mark?.[1] === 'FIXME' && parsed.priority === 2 ? 1 : parsed.priority,
78      status: 'pending',
79      blockedBy: [],
80      agent: 'repo',
81      note: `${path}:${ln}`,
82    })
83  }
84  // Cap after sorting so a docs/ folder full of plan checkboxes can't crowd out the roadmap and the code.
85  const tier = (i: Item) => (ROADMAP_RE.test(i.note!.replace(/:\d+$/, '')) ? 0 : i.id.startsWith('scan:code:') ? 1 : 2)
86  return [...items.values()].map((i, n) => ({ i, n })).sort((a, b) => tier(a.i) - tier(b.i) || a.n - b.n).slice(0, max).map(x => x.i)
87}
88
89export const VIEWS = ['you', 'urgent', 'active', 'blocked', 'all'] as const
90export type View = (typeof VIEWS)[number]
91
92/** OmniFocus-style perspectives. */
93export function inView(r: Row, view: string) {
94  if (view === 'you') return r.isHuman
95  if (view === 'urgent') return r.priority <= 1
96  if (view === 'active') return r.status === 'in_progress'
97  if (view === 'blocked') return r.isBlocked
98  return true
99}
100
101export function rank(
102  files: SessionFile[], root: string, live: Set<string>, done: Set<string>, prio: Record<string, number> = {},
103): Row[] {
104  const rows: Row[] = []
105  const seen = new Set<string>()
106  for (const f of files) {
107    if (!isRelevant(f.cwd, root)) continue
108    const isLive = live.has(f.sessionId)
109    const items = f.items.filter(i => open(i) && !done.has(rowKey({ sessionId: f.sessionId, id: i.id })))
110    const openIds = new Set(items.map(i => i.id))
111    for (const i of items) {
112      if (!isLive && !i.isHuman) continue
113      const k = rowKey({ sessionId: f.sessionId, id: i.id })
114      if (seen.has(k)) continue // the same repo scanned from two sessions
115      seen.add(k)
116      rows.push({
117        ...i,
118        priority: prio[k] ?? i.priority,
119        sessionId: f.sessionId,
120        cwd: f.cwd,
121        isLive,
122        isBlocked: i.blockedBy.some(id => openIds.has(id)),
123        blocks: items.filter(o => o.blockedBy.includes(i.id)).length,
124      })
125    }
126  }
127  const key = (r: Row) => [
128    r.isHuman ? 0 : 1, r.isBlocked ? 1 : 0, r.priority, r.status === 'in_progress' ? 0 : 1, -r.blocks,
129  ]
130  return rows.sort((a, b) => {
131    const ka = key(a), kb = key(b)
132    for (let n = 0; n < ka.length; n++) if (ka[n] !== kb[n]) return (ka[n] ?? 0) - (kb[n] ?? 0)
133    return a.title.localeCompare(b.title)
134  })
135}
136
types/index.d.ts 34 lines
1export type Status = 'pending' | 'in_progress' | 'completed'
2
3export type Item = {
4  id: string
5  title: string
6  status: Status
7  /** 0 (urgent) … 3 (someday); 2 when unset */
8  priority: number
9  /** only the human can complete it */
10  isHuman: boolean
11  blockedBy: string[]
12  /** 'main' or the subagent's id */
13  agent: string
14  note?: string
15}
16
17/** ~/.claude/agent-todos/<sessionId>.json — each session writes only its own */
18export type SessionFile = { sessionId: string; cwd: string; updatedAt: number; items: Item[] }
19
20export type Row = Item & {
21  sessionId: string
22  cwd: string
23  isLive: boolean
24  isBlocked: boolean
25  /** open items in the same session waiting on this one */
26  blocks: number
27}
28
29declare module 'claude-code' {
30  interface PluginState {
31    'agent-todos': { rows: Row[]; query: string; root: string; view: string; selected: string }
32  }
33}
34