SLOPSHOPPER

task-lists

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

newpanecommand
v0.1.0no licenseupdated 2026-10-26lpareja99/claude-mods/task-lists
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-lists
│ ┃ Task lists ✕ › fix the failing auth test and add an audit log call │ ┃ Using: this session's own list (empty) │ ┃ ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ──────────── ⏺ Update(src/auth.ts) │ ┃ 1: scope-repo2: scope-allr: refresh ⎿ Added 2 lines, removed 1 line │ ┃ 0 lists · 0 with open tasks ⏺ Bash(bun test) │ ┃ No saved lists yet. ⎿ 3 pass, 1 fail │ ┃ ──────────────────────────────────────────── │ ┃ ──────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ s: use-own │ ┃ click the pane to enable hotkeys ✻ Worked for 42s · done 4:20 PM │ │ › /task-lists │ ⎿ task-lists: Task lists pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Task lists
Using: this session's own list (empty) ──────────────────────────────────────────────────────── 1: scope-repo2: scope-allr: refresh 0 lists · 0 with open tasks No saved lists yet. ──────────────────────────────────────────────────────── s: use-own click the pane to enable hotkeys
README

task-lists

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.

Why

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.

Install

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.

Commands

CommandWhat it does
/task-listsOpens the Task lists pane
/todoOpens the Task lists pane and, when todo-pane is installed, the Todos pane too

Using it

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.

KeyAction
1 / 2Lists of this repo / of all repos
rRefresh
uUse the open list
sGo 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.

How the switch works

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.

Limits

  • It relies on Claude Code reading 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.
  • The choice lasts for the session. A restart goes back to the session's own list unless you launch with the variable set.
  • Claude's new tasks go into the list in use, so pick the right one before work continues.
  • Counts and order refresh when you open the pane or press r, not on every task change.
  • The pane only reads task files and never deletes or edits them.
  • Built and tested against Claude Code 2.1.304. It has no automated tests yet.

Develop

claude plugin validate .

Edit the files, then run /reload-plugins.

Author

Laura Pareja Prieto

Source 2 files
hooks/register.tsx 376 lines
1import { 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}
376
types/index.d.ts 43 lines
1export 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