A pane that lists every saved Claude task list, for this repo or all of them, so old lists never get lost after /clear

A pane for Claude Code that lists every saved task list and lets you choose which one the session uses, so a /clear or a new session never leaves your todos stranded.
It is a mod: everything it needs is in this folder. Nothing goes in your CLAUDE.md.
Claude Code keeps each session's tasks in their own folder (<config dir>/tasks/<session id>/N.json). A /clear or a new session starts a new, empty list and the old one stays on disk, out of sight. This mod shows those lists and points the session back at one of them.
In a Claude Code session:
/plugin install task-lists --marketplace lpareja99/claude-mods
Or from a local copy of this repository:
claude plugin marketplace add <path to this repository>
claude plugin install task-lists@local-mods
Then run /reload-plugins (or start a new session).
It works on its own. With todo-pane installed as well, the Todos pane follows the list you pick.
| Command | What it does |
|---|---|
/task-lists | Opens the Task lists pane |
/todo | Opens the Task lists pane and, when todo-pane is installed, the Todos pane too |
The top of the pane says which list is in use. Each row is title done/total age, and a ● marks the list in use. Click a row to see its tasks, then click Use this list.
| Key | Action |
|---|---|
1 / 2 | Lists of this repo / of all repos |
r | Refresh |
u | Use the open list |
s | Go back to this session's own list |
Keys work while the pane has focus (click it, or open it with /task-lists).
"This repo" is worked out from the session transcripts under <config dir>/projects/: a list belongs to the repo whose transcript has the same session id. A list with no transcript only shows under "all repos", unless it is the one in use.
A list has no name of its own, so a row shows the subject of its first task.
Picking a list sets CLAUDE_CODE_TASK_LIST_ID for the running session, then asks TaskList what it sees. If the tasks match the list you picked, the switch is confirmed: Claude's task tools now read and write that list, and the Todos pane re-reads it through /todos-sync. If they do not match, the variable is put back and the pane says so.
CLAUDE_CODE_TASK_LIST_ID on every call. That is not documented; it was checked on 2.1.304. The pane tells you when a switch does not take.r, not on every task change.claude plugin validate .
Edit the files, then run /reload-plugins.
Laura Pareja Prieto
hooks/register.tsx 376 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ListInfo, ListScope, ListTask, ToolStatus } from '../types'
5
6const PANE = 'task-lists'
7const lists = atom({ plugin: 'task-lists', key: 'lists' } as const, [] as ListInfo[])
8const scope = atom({ plugin: 'task-lists', key: 'scope' } as const, 'repo' as ListScope)
9const openId = atom({ plugin: 'task-lists', key: 'openId' } as const, null as string | null)
10const slug = atom({ plugin: 'task-lists', key: 'slug' } as const, '')
11const scannedAt = atom({ plugin: 'task-lists', key: 'scannedAt' } as const, 0)
12const failure = atom({ plugin: 'task-lists', key: 'error' } as const, null as string | null)
13const activeId = atom({ plugin: 'task-lists', key: 'activeId' } as const, null as string | null)
14const note = atom({ plugin: 'task-lists', key: 'note' } as const, null as string | null)
15
16type RawTask = {
17 id?: unknown
18 subject?: unknown
19 status?: unknown
20 blockedBy?: unknown
21}
22
23const ICON: Record<ToolStatus, string> = { pending: '○', in_progress: '▶', completed: '✓' }
24
25/** Claude Code names a project folder after its path, every other character a dash. */
26const projectSlug = (cwd: string) => cwd.replace(/[^a-zA-Z0-9]/g, '-')
27
28/** "-Users-me-Documents-CV-Portal" reads as "Documents-CV-Portal". */
29const shortProject = (project: string) =>
30 project === '' ? 'unknown repo' : project.replace(/^-Users-[^-]+-/, '')
31
32const clip = (text: string, max: number) =>
33 text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text
34
35const pad = (text: string, width: number) =>
36 text.length >= width ? text.slice(0, width) : text + ' '.repeat(width - text.length)
37
38/** "5m", "3h", "2d": how long ago a list last changed. */
39const age = (at: number) => {
40 const minutes = Math.max(0, Math.round((Date.now() - at) / 60000))
41 if (minutes < 60) return `${minutes}m`
42 if (minutes < 1440) return `${Math.round(minutes / 60)}h`
43
44 return `${Math.round(minutes / 1440)}d`
45}
46
47const stamp = (at: number) => {
48 const d = new Date(at)
49 const two = (n: number) => String(n).padStart(2, '0')
50
51 return `${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`
52}
53
54const configRoot = async ($: EngineInterface) => {
55 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
56 if (dir) return dir.replace(/\/$/, '')
57 const home = await $.env.get('HOME')
58
59 return `${home ?? ''}/.claude`
60}
61
62const readTask = async ($: EngineInterface, path: string): Promise<ListTask | undefined> => {
63 try {
64 const raw = JSON.parse(await $.fs.read(path)) as RawTask
65 if (typeof raw.id !== 'string' || typeof raw.subject !== 'string') return undefined
66 const status: ToolStatus =
67 raw.status === 'completed' || raw.status === 'in_progress' ? raw.status : 'pending'
68
69 return {
70 id: raw.id,
71 subject: raw.subject,
72 status,
73 blockedBy: Array.isArray(raw.blockedBy) ? raw.blockedBy.map(String) : [],
74 }
75 } catch {
76 return undefined
77 }
78}
79
80/** Which project each session id belongs to, from the transcripts named after them. */
81const sessionOwners = async ($: EngineInterface, root: string) => {
82 const owners = new Map<string, string>()
83 const projects = await $.fs.list(`${root}/projects`).catch(() => [])
84 for (const project of projects) {
85 if (project.kind !== 'dir') continue
86 const files = await $.fs.list(`${root}/projects/${project.name}`).catch(() => [])
87 for (const file of files) {
88 if (file.name.endsWith('.jsonl')) owners.set(file.name.slice(0, -'.jsonl'.length), project.name)
89 }
90 }
91
92 return owners
93}
94
95const scan = async ($: EngineInterface) => {
96 const root = await configRoot($)
97 const owners = await sessionOwners($, root)
98 const found: ListInfo[] = []
99
100 // No tasks folder yet is an empty answer, not an error.
101 for (const dir of await $.fs.list(`${root}/tasks`).catch(() => [])) {
102 if (dir.kind !== 'dir') continue
103 const entries = await $.fs.list(`${root}/tasks/${dir.name}`)
104 const files = entries.filter(f => f.kind === 'file' && f.name.endsWith('.json'))
105 const read = await Promise.all(files.map(f => readTask($, `${root}/tasks/${dir.name}/${f.name}`)))
106 const tasks = read
107 .filter((t): t is ListTask => t !== undefined)
108 .sort((a, b) => Number(a.id) - Number(b.id))
109 if (tasks.length === 0) continue
110
111 const done = tasks.filter(t => t.status === 'completed').length
112 found.push({
113 id: dir.name,
114 project: owners.get(dir.name) ?? '',
115 title: tasks[0]?.subject ?? '',
116 open: tasks.length - done,
117 done,
118 updatedAt: Math.max(...files.map(f => f.mtimeMs)),
119 tasks,
120 })
121 }
122
123 // Lists with work left come first, newest first within each half.
124 found.sort((a, b) => Number(b.open > 0) - Number(a.open > 0) || b.updatedAt - a.updatedAt)
125 await update($, lists, () => found)
126 await update($, scannedAt, () => Date.now())
127 await update($, failure, () => null)
128}
129
130/** A scan that fails shows its reason in the pane and never breaks the session. */
131const refresh = async ($: EngineInterface) => {
132 try {
133 await scan($)
134 } catch (error) {
135 await update($, failure, () => (error instanceof Error ? error.message : String(error)))
136 }
137}
138
139/** Two quick picks would race the variable, the check and the sync; one at a time. */
140let switching = false
141
142/**
143 * Points the task tools at a saved list, or back at the session's own with no list,
144 * then checks that TaskList really sees it. Only a list the engine confirms becomes
145 * the active one and reaches the Todos pane; otherwise the variable is put back.
146 */
147const useList = async ($: EngineInterface, list: ListInfo | undefined) => {
148 if (switching) return
149 switching = true
150 try {
151 const before = await $.env.get('CLAUDE_CODE_TASK_LIST_ID')
152 const set = await $.env.set('CLAUDE_CODE_TASK_LIST_ID', list?.id)
153 if (set.deny !== undefined) {
154 await update($, note, () => `⚠ Refused: ${set.deny}`)
155
156 return
157 }
158
159 if (list !== undefined) {
160 const listed = await $.tool.call({ tool: 'TaskList' })
161 const seen = (listed.result as { tasks?: { id: unknown; subject: unknown }[] } | undefined)?.tasks ?? []
162 const isSame =
163 seen.length === list.tasks.length &&
164 list.tasks.every(task => seen.some(s => String(s.id) === task.id && s.subject === task.subject))
165 if (!isSame) {
166 await $.env.set('CLAUDE_CODE_TASK_LIST_ID', before)
167 await update($, note, () => `⚠ Claude is still using its current list; "${clip(list.title, 24)}" was not applied`)
168
169 return
170 }
171 }
172
173 await update($, activeId, () => list?.id ?? null)
174 await update($, note, () => (list ? `✓ Claude now uses "${clip(list.title, 28)}"` : "✓ Back on this session's own list"))
175 // The Todos pane follows task events, not the folder: ask it to re-read. A session
176 // without todo-pane has no such command, and the switch itself still stands.
177 await $.command.run({ command: 'todos-sync' }).catch(() => undefined)
178 void refresh($)
179 } catch (error) {
180 await update($, note, () => `⚠ Switch failed: ${error instanceof Error ? error.message : String(error)}`)
181 } finally {
182 switching = false
183 }
184}
185
186export const register: Register = on => {
187 on('session.start', async ($, e, next) => {
188 await update($, slug, () => projectSlug(e.cwd))
189 // A list pinned before this load (a reload, a launcher) is the one in use.
190 await update($, activeId, () => null)
191 const pinned = await $.env.get('CLAUDE_CODE_TASK_LIST_ID')
192 if (pinned) await update($, activeId, () => pinned)
193 await $.command.register({
194 name: 'task-lists',
195 description: 'Show every saved task list, for this repo or all',
196 })
197 // A short name for both panes at once; a refused name must not stop the session starting.
198 await $.command
199 .register({ name: 'todo', description: 'Open the Todos pane and the Task lists pane together' })
200 .catch(() => undefined)
201 void refresh($)
202
203 return next(e)
204 })
205
206 on('command.run', { command: 'todo' }, async $ => {
207 // Todos belongs to the todo-pane mod: open it only where that mod is installed.
208 const hasTodos = (await $.command.list()).some(c => c.name === 'todos')
209 await $.ui.open({ id: PANE, title: 'Task lists' })
210 if (hasTodos) await $.ui.open({ id: 'todo-pane', title: 'Todos', focus: true })
211 void refresh($)
212
213 return { text: hasTodos ? 'Todos and Task lists panes opened.' : 'Task lists pane opened (todo-pane is not installed).' }
214 })
215
216 on('command.run', { command: 'task-lists' }, async $ => {
217 await $.ui.open({ id: PANE, title: 'Task lists', focus: true })
218 void refresh($)
219
220 return { text: 'Task lists pane opened.' }
221 })
222
223 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
224 const { Box, Text, Button } = $.ui.resolve(e)
225 const all = await read($, lists)
226 const by = await read($, scope)
227 const mine = await read($, slug)
228 const open = await read($, openId)
229 const when = await read($, scannedAt)
230 const problem = await read($, failure)
231 const active = await read($, activeId)
232 const verdict = await read($, note)
233 // /clear changes the id without a session.start, so it is read at every draw.
234 const sessionId = await $.session.id()
235
236 const width = Math.max(30, e.props.bodyColumns)
237 const rule = '─'.repeat(width)
238 // The list the task tools use: the pinned one, else the session's own.
239 const inUse = (list: ListInfo) => list.id === (active ?? sessionId)
240 const using = all.find(inUse)
241
242 // A list in use never disappears behind a filter, even with no transcript to place it.
243 const visible = (by === 'all' ? all : all.filter(list => list.project === mine || inUse(list)))
244 .slice()
245 .sort((a, b) => Number(inUse(b)) - Number(inUse(a)))
246 const openLists = visible.filter(list => list.open > 0).length
247
248 const banner = using
249 ? clip(using.title, width - 8)
250 : active
251 ? `list ${active.slice(0, 8)}`
252 : "this session's own list (empty)"
253 const detail = using ? `${using.open} open · ${using.done} done · ${using.id.slice(0, 8)}` : ''
254
255 return (
256 <Box flexDirection="column">
257 <Text bold wrap="truncate-end">
258 Using: {banner}
259 </Text>
260 <Text dimColor wrap="truncate-end">
261 {detail === '' ? ' ' : detail}
262 </Text>
263 <Text dimColor>{rule}</Text>
264 <Box>
265 <Button
266 key="scope-repo"
267 plain
268 hotkey="1"
269 onPress={async () => {
270 await update($, scope, () => 'repo')
271 await update($, note, () => null)
272 }}
273 >
274 <Text bold={by === 'repo'} dimColor={by !== 'repo'}>
275 this repo{' '}
276 </Text>
277 </Button>
278 <Button
279 key="scope-all"
280 plain
281 hotkey="2"
282 onPress={async () => {
283 await update($, scope, () => 'all')
284 await update($, note, () => null)
285 }}
286 >
287 <Text bold={by === 'all'} dimColor={by !== 'all'}>
288 all repos{' '}
289 </Text>
290 </Button>
291 <Button key="refresh" plain hotkey="r" onPress={() => void refresh($)}>
292 <Text dimColor>refresh</Text>
293 </Button>
294 </Box>
295 <Text dimColor>
296 {visible.length} lists · {openLists} with open tasks
297 </Text>
298 {verdict !== null && (
299 <Text color={verdict.startsWith('⚠') ? 'red' : 'green'} wrap="truncate-end">
300 {verdict}
301 </Text>
302 )}
303 {problem !== null && <Text color="red">Could not read the lists: {problem}</Text>}
304 {problem === null && visible.length === 0 && (
305 <Text dimColor>
306 {when === 0
307 ? 'Reading the lists…'
308 : all.length === 0
309 ? 'No saved lists yet.'
310 : 'No lists for this repo. Press 2 for all.'}
311 </Text>
312 )}
313 {visible.map(list => {
314 const isOpen = open === list.id
315 const total = list.open + list.done
316 const meta = `${list.done}/${total} ${age(list.updatedAt)}`
317 const head = `${isOpen ? '▾' : '▸'}${inUse(list) ? '●' : ' '} `
318 const room = Math.max(8, width - head.length - meta.length - 1)
319
320 return (
321 <Box flexDirection="column">
322 <Button
323 key={list.id}
324 plain
325 onPress={() => update($, openId, now => (now === list.id ? null : list.id))}
326 >
327 <Text
328 bold={inUse(list)}
329 color={inUse(list) ? 'green' : list.open === 0 ? undefined : 'yellow'}
330 dimColor={!inUse(list) && list.open === 0}
331 >
332 {head}
333 {pad(clip(list.title, room), room)} {meta}
334 </Text>
335 </Button>
336 {isOpen && (
337 <Text dimColor wrap="truncate-end">
338 {' '}
339 {list.id.slice(0, 8)} · {shortProject(list.project)} · {stamp(list.updatedAt)}
340 </Text>
341 )}
342 {isOpen &&
343 list.tasks.slice(0, 12).map(task => (
344 <Text dimColor={task.status === 'completed'} wrap="truncate-end">
345 {' '}
346 {ICON[task.status]} #{task.id} {task.subject}
347 </Text>
348 ))}
349 {isOpen && list.tasks.length > 12 && (
350 <Text dimColor>
351 {' '}… {list.tasks.length - 12} more
352 </Text>
353 )}
354 {isOpen && !inUse(list) && (
355 <Button key={`use-${list.id}`} hotkey="u" onPress={() => void useList($, list)}>
356 <Text>Use this list</Text>
357 </Button>
358 )}
359 {isOpen && inUse(list) && <Text dimColor>{' '}in use</Text>}
360 </Box>
361 )
362 })}
363 <Text dimColor>{rule}</Text>
364 <Box>
365 <Button key="use-own" plain hotkey="s" onPress={() => void useList($, undefined)}>
366 <Text dimColor>this session's own list</Text>
367 </Button>
368 </Box>
369 <Text dimColor wrap="truncate-end">
370 {e.props.isFocused ? 'u use the open list' : 'click the pane to enable hotkeys'}
371 </Text>
372 </Box>
373 )
374 })
375}
376types/index.d.ts 43 lines1export type ToolStatus = 'pending' | 'in_progress' | 'completed'
2
3export type ListTask = {
4 id: string
5 subject: string
6 status: ToolStatus
7 blockedBy: string[]
8}
9
10/** One folder under `<config>/tasks`, read from its N.json files. */
11export type ListInfo = {
12 /** The folder name: the id of the session that owned the list. */
13 id: string
14 /** Slug of the project that session ran in; empty when no transcript names it. */
15 project: string
16 /** Subject of the list's first task. */
17 title: string
18 open: number
19 done: number
20 updatedAt: number
21 tasks: ListTask[]
22}
23
24export type ListScope = 'repo' | 'all'
25
26declare module 'claude-code' {
27 interface PluginState {
28 'task-lists': {
29 lists: ListInfo[]
30 scope: ListScope
31 openId: string | null
32 /** The session's working directory as a project slug, to match `ListInfo.project`. */
33 slug: string
34 scannedAt: number
35 error: string | null
36 /** The list id this mod last pointed CLAUDE_CODE_TASK_LIST_ID at; null is the default. */
37 activeId: string | null
38 /** What the last "use this list" probe found, shown under the tabs. */
39 note: string | null
40 }
41 }
42}
43