Keeps the follow-ups Claude suggests in one backlog shared by every session, and a checklist Claude keeps for multi-step work in each session, each with a…

Keep the work Claude finds. See the work Claude is doing.
A Claude Code mod with two parts. Next Up collects the follow-ups Claude suggests in passing into one backlog shared by every session and project. Tasks has Claude keep a checklist for multi-step work in the session you are in, which you can watch and edit.
| Next Up | Tasks |
|---|---|
![]() | ![]() |
Claude is good at noticing work outside the task in front of it: a bug spotted in passing, a missing test, a docs gap, the refactor that should happen later. It offers these as suggestion chips or as a closing paragraph, and then the session ends and they are gone. Next Up keeps them, each with a prompt another session can act on without the conversation it came from.
Inside a session, the opposite problem: a long piece of work has steps, and it is hard to see which are done, which are next, and whether Claude has quietly dropped one. Tasks puts them in a list beside the conversation, kept by Claude as it works.
Claude Code 2.1.287 or later (mods are on by default from that version).
/plugin marketplace add aosmcleod/next-up-mod
/plugin install next-up@next-up-mod
Or from a shell:
claude plugin marketplace add aosmcleod/next-up-mod
claude plugin install next-up@next-up-mod
Then start a new session, or run /reload-plugins in an open one. Two buttons appear in the footer below the prompt, each with its count: Tasks ↗ and Next Up ↗.

Both parts are on by default. Switch either off in /config (Next Up backlog, Session tasks), with /plugin configure next-up@next-up-mod, or from a shell:
echo '{"tasks": "false"}' | claude plugin configure next-up@next-up-mod --values-stdin
Use "backlog" for Next Up, and "true" to switch a part back on. Restart Claude Code to apply a change. With a part off, its button, pane, tools and guidance are all gone; with both off, the mod does nothing.
| Source | Label | How |
|---|---|---|
| Claude's suggestion chips | Suggested (purple) | Whenever Claude offers a background task, it goes to the backlog instead; no chip shows in the chat |
| Claude, on its own or when you ask | Added (blue) | Claude is told about the backlog and given an add_tasks tool |
/next plan <goal> | Plan 2/5 (amber) | Claude breaks the goal into ordered steps and files each one, without starting the work |
A task that repeats an open one in the same project, word for word or nearly, is merged into it rather than listed twice.
Hover a card for its buttons:
When you send a prompt that still carries an open task's opening, in a session in that task's project, the task is marked done.
/next band) lists the project's top three above the prompt until you send your next message.For work that takes two or more steps, Claude plans the steps before it starts, marks each one done as it finishes, and adds, rewords or removes steps as the work changes. Before it ends a turn it checks the list: if steps remain and nothing blocks them, it carries on; otherwise it says which remain and why. Questions and one-step changes get no list.
The list belongs to the session and lasts as long as it does; it is not saved to disk.
| Command | Does |
|---|---|
/next | Opens or closes the Next Up pane (the Tasks pane, with Next Up off) |
/next tasks | Opens or closes the Tasks pane |
/next list · /next all | Lists open backlog tasks for this project, or for every project |
/next band | Shows the project's top three backlog tasks above the prompt |
/next plan <goal> | Asks Claude to plan the goal into the backlog as ordered steps |
Claude's tools: add_tasks, list_tasks and update_task for the backlog (so you can just ask: "add that to Next Up", "what's in Next Up for this repo?"), and update_session_tasks for Tasks (add, reword, mark done, remove).
~/.claude/next-up/log/<session>.jsonl, one append-only log per session. Each session writes only its own file and reads them all, so open sessions never overwrite each other. Plain JSON lines, safe to read or delete. Tasks are kept in memory only.add_tasks and update_session_tasks tools are listed for Claude up front rather than behind tool search.git rev-parse to find the session's project, uname once per session outside Windows, and open (macOS) or rundll32 (Windows) for New.add_tasks tool and /next plan.git clone https://github.com/aosmcleod/next-up-mod
claude plugin marketplace add ./next-up-mod
claude plugin install next-up@next-up-mod
Installing copies the plugin into ~/.claude/plugins/cache/next-up-mod/next-up/<version>/, and sessions load that copy, even though claude plugin list reports the checkout as where it is read from. After an edit, reinstall (claude plugin uninstall next-up@next-up-mod, then install again) and start a new session or run /reload-plugins.
Checks, from the repo root:
claude plugin validate . --strict
claude plugin test .
tsc -p . --noEmit also works once Claude Code has loaded the plugin, which writes the engine's type declarations to .claude-plugin/types/ (gitignored).
| Path | Holds |
|---|---|
hooks/register.tsx | The hooks: capture, the footer, panes and band, tools, guidance and /next |
hooks/store.ts | The backlog's logic: folding logs, duplicates, labels, ages, sent prompts |
hooks/store-io.ts | Reading and appending the per-session logs |
hooks/capture.ts | Turning chips and add_tasks input into backlog tasks |
hooks/view.ts | What the backlog's footer button, pane and band show |
hooks/session.ts | The session task list: changes, what Claude is told, the Tasks pane |
types/index.d.ts | The task models and the state the drawings read |
tests/ | claude plugin test suites for the plain modules |
Built by Alec McLeod (@aosmcleod), a product manager who kept losing Claude's best ideas at the end of a session.
Issues and pull requests are welcome.
MIT.
hooks/register.tsx 852 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Project, SessionTask, Tab, Task, TasksTab } from '../types'
5import { listText, taskFromChip, tasksFromAddInput } from './capture'
6import { applyChanges, editsNote, openReminder, sessionText, tasksLabel, tasksView } from './session'
7import type { SessionChanges, UserEdits } from './session'
8import type { AddInput, Stamp } from './capture'
9import { ageOf, eventForIncoming, fold, labelOf, submittedTask } from './store'
10import type { TaskEvent } from './store'
11import { appendEvents, logsSignature, readEvents } from './store-io'
12import type { Files, LogCache } from './store-io'
13import { bandTasks, cap, detailCap, footerLabel, labelColour, paneView, AMBER } from './view'
14
15// Next Up's hooks, for its two features (each can be switched off in /config):
16//
17// - The backlog: follow-ups come in from Claude's suggestion chips and its
18// add_tasks tool, are kept in per-session logs shared by every session, and
19// are shown by a footer button, a pane and (on request) a band above the
20// prompt. Sending a task's prompt marks it done.
21// - Tasks: a list Claude keeps for multi-step work in this session through
22// update_session_tasks, shown by a footer button and a pane. The user's edits
23// there reach Claude with their next prompt.
24//
25// Desktop limits this works within: cards are plain trees, not Client regions
26// (each Client boots its own frame, so a pane of them is slow to appear); only
27// native Buttons take clicks and they cannot be coloured; every state write
28// redraws every site, so state is written only when it changes.
29
30const PLUGIN = 'next-up'
31const PANE = 'next-up'
32const TASKS_PANE = 'next-up-tasks'
33const TOOL = (name: string) => `mcp__${PLUGIN}__${name}`
34const POLL_MS = 30_000
35const NEW_PROMPT_MAX = 4000
36
37const HOVER = '#2a2a2a'
38// Fully transparent, so a card's round border can sit there at rest unseen.
39const CLEAR = '#00000000'
40const TRAY = '#1f1f1f'
41const RULE = '#4a4a4a'
42const MUTED_RED = '#c0605a'
43const GREEN = '#6fbf73'
44// The trash can as a text glyph (U+FE0E asks for text, not emoji, presentation),
45// so it takes the button's hover colour.
46const TRASH = '\u{1F5D1}\uFE0E'
47// The band's Now slot: wide enough for the "Now ↓" button.
48const BAND_NOW_COLUMNS = 8
49// On hover a card's text fades to this, behind its buttons.
50const FADE = { color: '#5e5e5e', dimColor: false }
51
52// What Claude is told, per feature: file worthwhile follow-ups in the backlog
53// instead of trailing them at the end of a reply; keep a list for multi-step
54// work in the session.
55const NUDGE_BACKLOG = [
56 '# Next Up',
57 'The user keeps a shared backlog of follow-up work across their Claude sessions, called Next Up.',
58 `When you notice worthwhile work outside the current task (a bug spotted in passing, a missing test, a docs gap, a refactor worth doing later), add it with the ${TOOL('add_tasks')} tool rather than only mentioning it.`,
59 'Give each task a short imperative title and a self-contained prompt another session could act on without this conversation.',
60 'Do not add the current task, trivial items, or anything the user has just declined. Do not announce that you added them.',
61 `When the user asks you to add something to Next Up, or to plan work into it, use ${TOOL('add_tasks')}; for a plan, pass a plan title and list the steps in order.`,
62].join('\n')
63
64const NUDGE_TASKS = [
65 '# Session tasks',
66 `For work in this session that takes two or more steps, keep a task list with ${TOOL('update_session_tasks')}. The user watches it in a Tasks pane.`,
67 '- Before starting, add the steps you plan from the request.',
68 '- Work through it in order. When a step is finished, mark it done right away, then take the next one.',
69 '- Keep it true: add steps as you find them, reword ones that changed, remove ones that no longer apply.',
70 '- Before ending your turn, check the list. If steps remain and nothing blocks them, carry on; otherwise say which remain and why.',
71 '- Skip it for questions and single-step changes. Use it instead of any other todo or task-list tool, so there is one list.',
72 '- While steps remain, the open ones come with each user message. The user can also mark steps done or remove them; you are told with their next message. Treat those changes as decided.',
73].join('\n')
74
75// The two features, each switched in the plugin's settings (/config). Read
76// when the module loads, so a change applies from the next session or reload.
77const features = { backlog: true, tasks: true }
78
79// The guidance for the features switched on, narrowed to the tools a request
80// offers when that is known (a subagent may be given fewer).
81function nudgeText(tools?: readonly string[]): string {
82 const offers = (name: string) => !tools || tools.includes(TOOL(name))
83 const backlog = features.backlog && offers('add_tasks') ? NUDGE_BACKLOG : ''
84 const tasks = features.tasks && offers('update_session_tasks') ? NUDGE_TASKS : ''
85
86 return [backlog, tasks].filter(Boolean).join('\n\n')
87}
88
89const tasksAtom = atom({ plugin: 'next-up', key: 'tasks' } as const, [] as Task[])
90const projectAtom = atom({ plugin: 'next-up', key: 'project' } as const, null as Project)
91const tabAtom = atom({ plugin: 'next-up', key: 'tab' } as const, 'project' as Tab)
92const bandAtom = atom({ plugin: 'next-up', key: 'band' } as const, false)
93const sessionAtom = atom({ plugin: 'next-up', key: 'session' } as const, [] as SessionTask[])
94const tasksTabAtom = atom({ plugin: 'next-up', key: 'tasksTab' } as const, 'todo' as TasksTab)
95
96// What the user changed in the Tasks pane since Claude last saw the list; sent
97// with the next prompt, then cleared.
98const edits: UserEdits = { done: [], removed: [] }
99
100// Whether Claude has been told about Next Up this session: through the system
101// prompt where the engine composes it after Next Up starts (the terminal), or
102// else beside the first prompt sent (desktop, where no mod runs until the
103// first message and the system prompt is already built).
104const nudge = { composed: false, sent: false }
105
106// Repeat presses of the same button on the same task within this window are one.
107const REPEAT_MS = 1000
108const lastPress = new Map<string, number>()
109
110type Platform = 'windows' | 'macos' | 'linux'
111
112// Session-wide context, set at session start (a reload sets it again).
113const context = { session: '', dir: '', cwd: '', project: null as Project, platform: 'macos' as Platform }
114// The folded list, the log signature it was read at, and each log's events.
115const cache = { signature: '\u0000', tasks: [] as Task[], logs: new Map() as LogCache }
116
117function stamp(): Stamp {
118 return { session: context.session, now: new Date().toISOString(), newId: () => crypto.randomUUID() }
119}
120
121// Where the logs live: ~/.claude/next-up.
122async function dataDir($: EngineInterface): Promise<string> {
123 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
124
125 return `${home.replace(/\\/g, '/')}/.claude/next-up`
126}
127
128// The file operations the store uses. ($ may not cross an import, so the
129// store-io functions get these instead of $.fs itself.)
130function filesOf($: EngineInterface): Files {
131 return {
132 exists: path => $.fs.exists(path),
133 list: path => $.fs.list(path),
134 read: ((path: string) => $.fs.read(path)) as Files['read'],
135 write: (path, text) => $.fs.write(path, text),
136 }
137}
138
139// The project a folder belongs to: its git root, or null outside one (or
140// where git is not installed).
141async function projectOf($: EngineInterface, folder: string): Promise<Project> {
142 const ran = await $.process.run(['git', '-C', folder, 'rev-parse', '--show-toplevel']).catch(() => null)
143 const root = ran?.exitCode === 0 ? ran.stdout.trim().replace(/\\/g, '/') : ''
144
145 return root ? { root, name: root.split('/').pop() ?? root } : null
146}
147
148// Windows sets OS for every process; elsewhere uname tells macOS from Linux.
149async function platformOf($: EngineInterface): Promise<Platform> {
150 if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
151 const ran = await $.process.run(['uname', '-s']).catch(() => null)
152
153 return ran?.stdout.trim() === 'Darwin' ? 'macos' : 'linux'
154}
155
156async function setProject($: EngineInterface, project: Project) {
157 context.project = project
158 if ((await read($, projectAtom))?.root !== project?.root) await update($, projectAtom, () => project)
159}
160
161// Re-reads the logs when any changed, and only then writes the list to state
162// (a state write redraws every site, which drops clicks in flight).
163async function refresh($: EngineInterface): Promise<Task[]> {
164 const signature = await logsSignature(filesOf($), context.dir)
165 if (signature !== cache.signature) {
166 cache.tasks = fold(await readEvents(filesOf($), context.dir, cache.logs))
167 cache.signature = signature
168 await update($, tasksAtom, () => cache.tasks)
169 }
170
171 return cache.tasks
172}
173
174// This session's writes, one at a time. Appending rewrites the session's log,
175// so two hooks writing at once (two chips in one turn, quick clicks on two
176// cards) would otherwise both start from the same copy and one would be lost.
177let writes: Promise<unknown> = Promise.resolve()
178
179function serially<T>(work: () => Promise<T>): Promise<T> {
180 const run = writes.then(work, work)
181 writes = run.catch(() => undefined)
182
183 return run
184}
185
186async function record($: EngineInterface, events: TaskEvent[]) {
187 await appendEvents(filesOf($), context.dir, context.session, events)
188 await refresh($)
189}
190
191// Adds incoming tasks, merging each into an open duplicate where there is one
192// (within the batch too). Returns how many were new and how many merged.
193// The read and the write run together in the queue, so a duplicate arriving at
194// the same moment sees this batch.
195function addIncoming($: EngineInterface, incoming: Task[]): Promise<{ added: Task[]; merged: number }> {
196 return serially(() => addNow($, incoming))
197}
198
199async function addNow($: EngineInterface, incoming: Task[]): Promise<{ added: Task[]; merged: number }> {
200 const known = [...(await refresh($))]
201 const events: TaskEvent[] = []
202 const added: Task[] = []
203 for (const task of incoming) {
204 const event = eventForIncoming(task, known, context.session)
205 events.push(event)
206 if (event.op === 'add') {
207 known.push(task)
208 added.push(task)
209 }
210 }
211 await record($, events)
212
213 return { added, merged: incoming.length - added.length }
214}
215
216function setFields($: EngineInterface, id: string, fields: Extract<TaskEvent, { op: 'set' }>['fields']): Promise<void> {
217 return serially(() => record($, [{ v: 1, at: new Date().toISOString(), by: context.session, op: 'set', id, fields }]))
218}
219
220async function togglePane($: EngineInterface, id = PANE, title = 'Next Up') {
221 const isOpen = (await $.ui.panes()).some(p => p.id === id)
222 if (isOpen) await $.ui.close({ id })
223 else await $.ui.open({ id, title })
224}
225
226// The session list's changes, from Claude's tool or the pane, applied in order.
227// The change is applied to the list as it stands when the write lands, so a
228// press and a tool call at the same moment never overwrite each other.
229async function changeSession($: EngineInterface, changes: SessionChanges): Promise<{ tasks: SessionTask[]; unknown: string[] }> {
230 let result: { tasks: SessionTask[]; unknown: string[] } = { tasks: [], unknown: [] }
231 const now = new Date().toISOString()
232 await update($, sessionAtom, prev => {
233 result = applyChanges(prev ?? [], changes, now)
234 return result.tasks
235 })
236
237 return result
238}
239
240// A press in the Tasks pane: applied, and remembered for Claude's next prompt.
241async function editSession($: EngineInterface, op: 'done' | 'remove', id: string) {
242 // A slow desktop can deliver several queued clicks at once; act on the first.
243 const now = Date.now()
244 const pressKey = `session-${op}:${id}`
245 if (now - (lastPress.get(pressKey) ?? 0) < REPEAT_MS) return
246 lastPress.set(pressKey, now)
247 const task = ((await read($, sessionAtom)) ?? []).find(t => t.id === id)
248 if (!task || task.status !== 'todo') return
249 await changeSession($, op === 'done' ? { done: [id] } : { remove: [id] })
250 if (op === 'done') edits.done.push(task)
251 else edits.removed.push(task)
252}
253
254// For a button: runs the press, and says so if it fails rather than failing silently.
255function press($: EngineInterface, work: Promise<unknown>) {
256 void work.catch(() => $.ui.toast('Next Up: that did not work, try again'))
257}
258
259// Opens the desktop app's new-session screen in the task's project with its
260// prompt in the box, unsent: claude://code/new?q=<prompt>&folder=<path>. The
261// desktop app runs on macOS and Windows only.
262async function newSession($: EngineInterface, task: Task) {
263 if (context.platform === 'linux') {
264 $.ui.toast('Next Up: New needs the Claude desktop app (macOS or Windows). Use Now instead.')
265 return
266 }
267 const root = task.repo?.root ?? context.cwd
268 const folder = context.platform === 'windows' ? root.replace(/\//g, '\\') : root
269 // Links this long are cut by some URL handlers; the opening that marks the
270 // task done on send survives the cut.
271 const prompt = task.prompt.slice(0, NEW_PROMPT_MAX)
272 const url = `claude://code/new?q=${encodeURIComponent(prompt)}&folder=${encodeURIComponent(folder)}`
273 const argv = context.platform === 'windows' ? ['rundll32', 'url.dll,FileProtocolHandler', url] : ['open', url]
274 const ran = await $.process.run(argv).catch(() => null)
275 if (ran?.exitCode !== 0) $.ui.toast("Next Up: couldn't open a new session")
276}
277
278async function act($: EngineInterface, action: 'now' | 'new' | 'favourite' | 'delete', id: string) {
279 // A slow desktop can deliver several queued clicks at once; act on the first.
280 const now = Date.now()
281 const pressKey = `${action}:${id}`
282 if (now - (lastPress.get(pressKey) ?? 0) < REPEAT_MS) return
283 lastPress.set(pressKey, now)
284 const task = cache.tasks.find(t => t.id === id)
285 if (!task) return
286 try {
287 if (action === 'now') await $.prompt.fill({ text: task.prompt, mode: 'replace' })
288 if (action === 'new') await newSession($, task)
289 if (action === 'favourite') await setFields($, id, { favourite: !task.favourite })
290 if (action === 'delete') await setFields($, id, { status: 'dropped' })
291 } catch {
292 $.ui.toast(`Next Up: couldn't ${action === 'favourite' ? 'update' : action} "${task.title}"`)
293 }
294}
295
296type CardModel = {
297 // Unique per site, so the band and the panes never share a key.
298 key: string
299 title: string
300 detail: string
301 favourite?: boolean
302 // A finished task: the title struck through.
303 struck?: boolean
304 // Top right, in order: the kind or Done in colour, or an age (dim).
305 topRight: Array<{ text: string; colour?: string }>
306 // Bottom right in a pane: the age, where the top right holds a label.
307 bottomRight?: string
308}
309
310type CardOptions = {
311 site: 'band' | 'pane'
312 // The width the card draws into, for the three-line description cap.
313 columns: number
314 // The band's one button, shown on hover in a fixed slot at the bottom right.
315 slot?: RenderChildren
316 // The pane's buttons, shown on hover on a tray centred over the card.
317 tray?: RenderChildren
318 // No buttons and no hover: a card only to read.
319 still?: boolean
320}
321
322// One card, shared by the band and both panes:
323//
324// ★ Title, cut so the label always fits ................... Suggested
325// Description, about three lines ........................... 2d
326//
327// On hover the text fades and the buttons sit centred on a solid tray over it,
328// so nothing reflows.
329function card(els: Elements['desktop'], m: CardModel, o: CardOptions) {
330 const { Box, Text } = els
331 // A still card (a finished task) has nothing to press, so no hover either.
332 const fade = o.still ? undefined : FADE
333
334 return (
335 // Always bordered (round), transparent at rest and the hover colour on
336 // hover, so the hover reads as a rounded card and nothing reflows. Half a
337 // cell short of the edges, so the corners are not clipped (the right one
338 // also clears the pane's scrollbar).
339 <Box
340 key={m.key}
341 flexDirection="column"
342 marginLeft={-0.5}
343 marginRight={-0.5}
344 paddingLeft={1}
345 paddingRight={1}
346 position="relative"
347 borderStyle="round"
348 borderColor={CLEAR}
349 hover={o.still ? undefined : { backgroundColor: HOVER, borderColor: HOVER }}
350 >
351 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
352 <Box flexDirection="row" flexShrink={1} minWidth={0}>
353 {m.favourite ? <Text color={AMBER} bold hover={fade}>★ </Text> : null}
354 <Text wrap="truncate-end" strikethrough={m.struck} dimColor={m.struck} hover={fade}>{m.title}</Text>
355 </Box>
356 <Box flexDirection="row" flexShrink={0} gap={2}>
357 {m.topRight.map(part =>
358 part.colour ? (
359 <Text key={part.text} color={part.colour} hover={fade && { color: FADE.color }}>{part.text}</Text>
360 ) : (
361 <Text key={part.text} dimColor hover={fade}>{part.text}</Text>
362 ),
363 )}
364 </Box>
365 </Box>
366 <Box flexDirection="row" justifyContent="space-between" alignItems={o.site === 'band' ? 'flex-end' : 'flex-start'} gap={2}>
367 <Box flexShrink={1} minWidth={0}>
368 <Text dimColor wrap="wrap" hover={fade}>{cap(m.detail, detailCap(o.columns))}</Text>
369 </Box>
370 {o.site === 'band' ? (
371 // The band draws any floating box as a popover with its own frame, so
372 // its button lives in a fixed-size slot at the bottom right instead:
373 // empty at rest, the button on hover, and the slot's size never changes.
374 <Box width={BAND_NOW_COLUMNS} height={1} flexShrink={0} justifyContent="flex-end">
375 <Box display="none" hover={{ display: 'flex' }}>{o.slot}</Box>
376 </Box>
377 ) : m.bottomRight ? (
378 <Box flexShrink={0}>
379 <Text dimColor hover={fade}>{m.bottomRight}</Text>
380 </Box>
381 ) : null}
382 </Box>
383 {o.site === 'band' || o.still ? null : (
384 <Box position="absolute" top={0} bottom={0} left={0} right={0} display="none" hover={{ display: 'flex' }} flexDirection="row" justifyContent="center" alignItems="center">
385 {/* A solid tray, so the translucent native buttons read as solid. */}
386 <Box flexDirection="row" gap={1} paddingX={1} borderStyle="round" borderColor={TRAY} backgroundColor={TRAY}>
387 {o.tray}
388 </Box>
389 </Box>
390 )}
391 </Box>
392 )
393}
394
395// A Next Up task's card: Now (band); Now, New, favourite and delete (pane).
396function backlogCard($: EngineInterface, els: Elements['desktop'], task: Task, site: 'band' | 'pane', showProject: boolean, columns: number) {
397 const { Button } = els
398 const key = `${site}-${task.id}`
399 const age = ageOf(task.createdAt, Date.now())
400 const label = { text: labelOf(task), colour: labelColour(task) }
401 const model: CardModel = {
402 key,
403 title: task.title,
404 detail: showProject && task.repo ? `${task.repo.name} · ${task.detail}` : task.detail,
405 favourite: task.favourite,
406 // The band puts the age before the label; the pane puts it below.
407 topRight: site === 'band' ? [{ text: age }, label] : [label],
408 bottomRight: site === 'band' ? undefined : age,
409 }
410 if (site === 'band') {
411 return card(els, model, { site, columns, slot: <Button key={`${key}-now`} label="Now ↓" variant="primary" onPress={() => void act($, 'now', task.id)} /> })
412 }
413
414 return card(els, model, {
415 site,
416 columns,
417 tray: [
418 <Button key={`${key}-now`} label="↙ Now" variant="primary" onPress={() => void act($, 'now', task.id)} />,
419 <Button key={`${key}-new`} label="New ↗" onPress={() => void act($, 'new', task.id)} />,
420 <Button key={`${key}-fav`} label={task.favourite ? '★' : '☆'} hover={{ color: AMBER }} onPress={() => void act($, 'favourite', task.id)} />,
421 <Button key={`${key}-delete`} label={TRASH} hover={{ color: MUTED_RED }} onPress={() => void act($, 'delete', task.id)} />,
422 ],
423 })
424}
425
426// A session task's card: "To do" in amber or "Done" in green with the title
427// struck, and below it when it was planned or finished. Buttons: Done (to do
428// only) and delete; a finished task's card has none.
429function sessionCard($: EngineInterface, els: Elements['desktop'], task: SessionTask, columns: number) {
430 const { Button } = els
431 const key = `tasks-${task.id}`
432 const isDone = task.status === 'done'
433 const model: CardModel = {
434 key,
435 title: task.title,
436 detail: task.detail,
437 struck: isDone,
438 topRight: [isDone ? { text: 'Done', colour: GREEN } : { text: 'To do', colour: AMBER }],
439 // When it was finished, or when it was planned.
440 bottomRight: ageOf(isDone ? (task.doneAt ?? task.createdAt) : task.createdAt, Date.now()),
441 }
442
443 if (isDone) return card(els, model, { site: 'pane', columns, still: true })
444
445 return card(els, model, {
446 site: 'pane',
447 columns,
448 tray: [
449 <Button key={`${key}-done`} label="✓ Done" variant="primary" onPress={() => press($, editSession($, 'done', task.id))} />,
450 <Button key={`${key}-delete`} label={TRASH} hover={{ color: MUTED_RED }} onPress={() => press($, editSession($, 'remove', task.id))} />,
451 ],
452 })
453}
454
455async function registerBacklogTools($: EngineInterface) {
456 await $.tool.register({
457 name: 'add_tasks',
458 description:
459 "Add tasks to the user's Next Up backlog: follow-up work shared across all their Claude sessions. " +
460 'Use it when the user asks to add something to Next Up or to plan work into it, and for worthwhile follow-ups outside the current task. ' +
461 'Each task needs a short imperative title; give a self-contained prompt another session could act on, or details to build one from. ' +
462 'Pass plan.title to group the tasks as the ordered steps of one plan.',
463 inputSchema: {
464 type: 'object',
465 properties: {
466 tasks: {
467 type: 'array',
468 items: {
469 type: 'object',
470 properties: {
471 title: { type: 'string', description: 'Short and imperative, under 80 characters.' },
472 details: { type: 'string', description: 'A one- to three-line summary shown on the card.' },
473 prompt: { type: 'string', description: 'The full prompt to send when the task is started. Defaults to title plus details.' },
474 },
475 required: ['title'],
476 },
477 },
478 plan: { type: 'object', properties: { title: { type: 'string' } }, description: 'Group the tasks as the ordered steps of one plan.' },
479 },
480 required: ['tasks'],
481 },
482 })
483 await $.tool.register({
484 name: 'list_tasks',
485 description: "List the open tasks in the user's Next Up backlog, for the current project or all projects, with their ids.",
486 inputSchema: { type: 'object', properties: { scope: { type: 'string', enum: ['project', 'all'] } } },
487 })
488 await $.tool.register({
489 name: 'update_task',
490 description: 'Update one Next Up task by id: mark it done or dropped, reopen it, or set it as a favourite. Use when you have finished a task that came from Next Up.',
491 inputSchema: {
492 type: 'object',
493 properties: {
494 id: { type: 'string' },
495 status: { type: 'string', enum: ['open', 'done', 'dropped'] },
496 favourite: { type: 'boolean' },
497 },
498 required: ['id'],
499 },
500 })
501}
502
503async function registerTasksTools($: EngineInterface) {
504 await $.tool.register({
505 name: 'update_session_tasks',
506 description:
507 'Keep the task list for the work in this session, shown to the user in a Tasks pane. ' +
508 'add appends steps in order; update rewords steps; done and remove take ids (s1, s2, ...). Call with no changes to read the list. ' +
509 'Returns the current list.' +
510 (features.backlog ? ' This is separate from the Next Up backlog, which is for work outside this session.' : ''),
511 inputSchema: {
512 type: 'object',
513 properties: {
514 add: {
515 type: 'array',
516 items: {
517 type: 'object',
518 properties: {
519 title: { type: 'string', description: 'Short and imperative, under 80 characters.' },
520 details: { type: 'string', description: 'Optional: one line on what the step involves.' },
521 },
522 required: ['title'],
523 },
524 },
525 update: {
526 type: 'array',
527 items: {
528 type: 'object',
529 properties: { id: { type: 'string' }, title: { type: 'string' }, details: { type: 'string' } },
530 required: ['id'],
531 },
532 description: 'Reword steps by id: a new title, details, or both.',
533 },
534 done: { type: 'array', items: { type: 'string' }, description: 'Ids of tasks now finished.' },
535 remove: { type: 'array', items: { type: 'string' }, description: 'Ids of tasks no longer needed.' },
536 },
537 },
538 })
539}
540
541function plural(n: number, word: string) {
542 return `${n} ${word}${n === 1 ? '' : 's'}`
543}
544
545// What /next does, for the commands list: only the features switched on.
546function commandHelp(): string {
547 if (!features.backlog) return 'Next Up: open the Tasks pane'
548 return features.tasks
549 ? 'Next Up: open the pane; `/next tasks`, `/next list`, `/next all`, `/next band`, or `/next plan <goal>`'
550 : 'Next Up: open the pane; `/next list`, `/next all`, `/next band`, or `/next plan <goal>`'
551}
552
553// /next plan <goal>: asks Claude, as a turn of its own, to plan into Next Up.
554function planPrompt(goal: string) {
555 return `Plan this into my Next Up backlog: ${goal}\n\nBreak it into a few ordered steps and add them with ${TOOL('add_tasks')}, passing a plan title. Give each step a self-contained prompt. Do not start the work.`
556}
557
558// Next Up sits in the path of session start, Claude's suggestion chips and
559// every sent prompt. If one of its hooks fails, the catch below lets the event
560// carry on as if Next Up were not there.
561export const register: Register = (on, options) => {
562 features.backlog = options.backlog !== false
563 features.tasks = options.tasks !== false
564 if (!features.backlog && !features.tasks) return
565
566 on('session.start', async ($, e, next) => {
567 context.session = await $.session.id()
568 context.dir = await dataDir($)
569 context.cwd = e.cwd
570 nudge.composed = false
571 nudge.sent = false
572 // The tools and command first: the nudge tells Claude about them, so they
573 // must exist even if what follows fails.
574 // Each step on its own, so one failing does not take the others with it.
575 await $.command.register({ name: 'next', description: commandHelp() }).catch(() => undefined)
576 if (features.tasks) await registerTasksTools($).catch(() => undefined)
577 if (!features.backlog) return next(e)
578 await registerBacklogTools($).catch(() => undefined)
579 context.platform = await platformOf($)
580 await setProject($, await projectOf($, e.cwd))
581 await refresh($)
582 // Other sessions write their own logs; pick their changes up. A failed
583 // poll is retried on the next tick.
584 $.clock.every(POLL_MS, () => void refresh($).catch(() => undefined))
585 // The session-start list is off by default: on desktop no mod runs until the
586 // first message is sent, so it could only flash and vanish as that prompt
587 // arrives. /next band shows it on demand.
588 await update($, bandAtom, () => false)
589
590 return next(e)
591 }).catch(($, e, next) => next(e))
592
593 // A /cd or the host moving the session: follow it to its project.
594 if (features.backlog) on('classic.CwdChanged', async ($, e, next) => {
595 context.cwd = e.new_cwd
596 await setProject($, await projectOf($, e.new_cwd))
597
598 return next(e)
599 }).catch(($, e, next) => next(e))
600
601 // The footer: two native Buttons, "<n> Tasks ↗" and "<n> Next Up ↗".
602 // Desktop forces mod buttons there to its own look and one line
603 // (anthropics/claude-code#100116).
604 on('ui.render', { component: 'SessionMode' }, async ($, e) => {
605 const { Box, Button } = $.ui.resolve(e)
606 const tasks = features.tasks ? tasksLabel((await read($, sessionAtom)) ?? []) : null
607 const backlog = features.backlog ? footerLabel((await read($, tasksAtom)) ?? [], (await read($, projectAtom)) ?? null) : null
608
609 return (
610 <Box key="next-up-footer" flexDirection="row" alignItems="center" gap={1}>
611 {tasks ? <Button key="next-up-tasks-open" label={tasks} hover={{ color: AMBER }} onPress={() => press($, togglePane($, TASKS_PANE, 'Tasks'))} /> : null}
612 {backlog ? <Button key="next-up-open" label={backlog} hover={{ color: AMBER }} onPress={() => press($, togglePane($))} /> : null}
613 </Box>
614 )
615 })
616
617 // The Tasks pane: To Do and Done, over the same cards.
618 if (features.tasks) on('ui.render', { component: 'Pane', requestId: TASKS_PANE }, async ($, e) => {
619 if (e.surface !== 'desktop' && e.surface !== 'terminal') {
620 const { Text } = $.ui.resolve(e)
621 return <Text>Tasks needs the terminal or the desktop app.</Text>
622 }
623 const els = $.ui.resolve(e) as Elements['desktop']
624 const { Box, Button, Text } = els
625 const tab = (await read($, tasksTabAtom)) ?? 'todo'
626 const view = tasksView((await read($, sessionAtom)) ?? [], tab)
627 const empty = tab === 'todo' ? 'Nothing to do. Claude lists the steps here when work takes more than one.' : 'Nothing done yet.'
628
629 return (
630 <Box flexDirection="column" gap={1}>
631 <Box flexDirection="row" gap={1}>
632 {view.tabs.map(t => (
633 <Button key={`tasks-tab-${t.id}`} label={t.label} variant={t.id === tab ? 'primary' : undefined} dimColor={t.id !== tab} onPress={() => press($, update($, tasksTabAtom, () => t.id))} />
634 ))}
635 </Box>
636 {view.items.length === 0 ? <Text dimColor>{empty}</Text> : null}
637 <Box flexDirection="column">{view.items.map(t => sessionCard($, els, t, e.props.bodyColumns))}</Box>
638 </Box>
639 )
640 })
641
642 // The session-start list: new sessions only, gone after the first prompt.
643 if (features.backlog) on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
644 if (e.props.hasSurvey || !(await read($, bandAtom))) return next(e)
645 if (e.surface !== 'desktop' && e.surface !== 'terminal') return next(e)
646 const els = $.ui.resolve(e) as Elements['desktop']
647 const { Box, Button, Text } = els
648 const project = (await read($, projectAtom)) ?? null
649 const top = bandTasks((await read($, tasksAtom)) ?? [], project)
650 if (top.length === 0) return next(e)
651
652 return (
653 <Box flexDirection="column" gap={1}>
654 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
655 <Text dimColor>Next Up</Text>
656 <Box flexDirection="row" gap={1} alignItems="center">
657 <Button key="band-see-all" label="See all ↗" dimColor onPress={() => press($, togglePane($))} />
658 <Button key="band-hide" label="Hide" role="dismiss" onPress={() => press($, update($, bandAtom, () => false))} />
659 </Box>
660 </Box>
661 <Box flexDirection="column">
662 {top.map(task => backlogCard($, els, task, 'band', project === null, e.props.bodyColumns))}
663 </Box>
664 </Box>
665 )
666 })
667
668 // The pane: ★, the project and All, over the same cards.
669 if (features.backlog) on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
670 if (e.surface !== 'desktop' && e.surface !== 'terminal') {
671 const { Text } = $.ui.resolve(e)
672 return <Text>Next Up needs the terminal or the desktop app.</Text>
673 }
674 const els = $.ui.resolve(e) as Elements['desktop']
675 const { Box, Button, Text } = els
676 const view = paneView((await read($, tasksAtom)) ?? [], (await read($, projectAtom)) ?? null, (await read($, tabAtom)) ?? 'project')
677
678 return (
679 <Box flexDirection="column" gap={1}>
680 <Box flexDirection="row" gap={1}>
681 {view.tabs.map(tab => (
682 <Button key={`tab-${tab.id}`} label={tab.label} variant={tab.id === view.current ? 'primary' : undefined} dimColor={tab.id !== view.current} onPress={() => press($, update($, tabAtom, () => tab.id))} />
683 ))}
684 </Box>
685 {view.items.length === 0 ? <Text dimColor>Nothing here yet. Claude adds tasks as it suggests them.</Text> : null}
686 <Box flexDirection="column">
687 {view.items.map(item =>
688 item.kind === 'header' ? (
689 // A group header outside the cards: the project, then a rule to the edge.
690 <Box key={`group-${item.key}`} flexDirection="row" alignItems="center" gap={1} marginTop={1}>
691 <Text dimColor>{item.name}</Text>
692 {/* A bar a sixteenth of a line tall (~1px): an Svg line drew nothing, text rules wrapped. */}
693 <Box flexGrow={1} minWidth={0} height={0.06} backgroundColor={RULE} />
694 </Box>
695 ) : (
696 backlogCard($, els, item.task, 'pane', false, e.props.bodyColumns)
697 ),
698 )}
699 </Box>
700 </Box>
701 )
702 })
703
704 // Claude's suggestion chips: answered here, so the task lands in the backlog
705 // and no chip shows. One Next Up cannot keep (no title or prompt), or a
706 // failed save, goes on to the chip so the suggestion is not lost.
707 if (features.backlog) on('tool.call', { tool: /spawn_task$/ }, async ($, e, next) => {
708 const task = taskFromChip(e as unknown as Record<string, unknown>, context.project, stamp())
709 if (!task) return next(e)
710 const { added } = await addIncoming($, [task])
711 const how = added.length > 0 ? 'Saved' : 'Already in'
712 const where = context.project?.name ?? 'no project'
713
714 return { result: `${how} the user's Next Up backlog (${where}) as "${task.title}". No suggestion chip was shown, so there is no task_id and nothing to dismiss.` }
715 }).catch(($, e, next) => next(e))
716
717 if (features.backlog) on('tool.call', { tool: /^mcp__next-up__add_tasks$/ }, async ($, e) => {
718 const incoming = tasksFromAddInput(e as unknown as AddInput, context.project, stamp())
719 if (incoming.length === 0) return { result: 'No tasks added: each task needs a title.' }
720 const { added, merged } = await addIncoming($, incoming)
721 const where = context.project?.name ?? 'no project'
722 const parts = [`Added ${plural(added.length, 'task')} to Next Up (${where})`]
723 if (merged > 0) parts.push(`${plural(merged, 'task')} matched existing ${merged === 1 ? 'one and was' : 'ones and were'} merged`)
724
725 return { result: `${parts.join('; ')}.` }
726 }).catch(() => ({ result: 'Next Up could not save the tasks. Nothing was added.' }))
727
728 if (features.backlog) on('tool.call', { tool: /^mcp__next-up__list_tasks$/ }, async ($, e) => {
729 const scope = (e as unknown as { scope?: string }).scope === 'all' ? 'all' : 'project'
730
731 return { result: listText(await refresh($), context.project, scope) }
732 }).catch(() => ({ result: 'Next Up could not read the backlog.' }))
733
734 if (features.backlog) on('tool.call', { tool: /^mcp__next-up__update_task$/ }, async ($, e) => {
735 const input = e as unknown as { id?: string; status?: Task['status']; favourite?: boolean }
736 const task = (await refresh($)).find(t => t.id === input.id)
737 if (!task) return { result: `No Next Up task with id ${String(input.id)}.` }
738 const fields: Extract<TaskEvent, { op: 'set' }>['fields'] = {}
739 if (input.status) fields.status = input.status
740 if (typeof input.favourite === 'boolean') fields.favourite = input.favourite
741 if (Object.keys(fields).length === 0) return { result: 'Nothing to update: pass a status or favourite.' }
742 await setFields($, task.id, fields)
743
744 return { result: `Updated "${task.title}".` }
745 }).catch(() => ({ result: 'Next Up could not update the task.' }))
746
747 // The two tools Claude reaches for unprompted sit in its tool list, not
748 // behind tool search: a deferred tool is a bare name until it is loaded,
749 // and on desktop Claude did not go looking for it.
750 on('tool.describe', { tool: /^mcp__next-up__(add_tasks|update_session_tasks)$/ }, async ($, e, next) => {
751 const described = await next(e)
752
753 return { ...described, isDeferred: false }
754 }).catch(($, e, next) => next(e))
755
756 // Claude's changes to the session list. It sees the whole list in the result,
757 // so the user's earlier edits are known to it from here on.
758 if (features.tasks) on('tool.call', { tool: /^mcp__next-up__update_session_tasks$/ }, async ($, e) => {
759 const { tasks, unknown } = await changeSession($, e as unknown as SessionChanges)
760 edits.done = []
761 edits.removed = []
762 const note = unknown.length ? `\n(No task with id ${unknown.join(', ')}: it may have been removed.)` : ''
763
764 return { result: `${sessionText(tasks)}${note}` }
765 }).catch(() => ({ result: 'Next Up could not update the session task list.' }))
766
767 // In the terminal Claude Code nudges Claude toward its own task tools; with
768 // Next Up's list in use, that would make two lists.
769 if (features.tasks) on('prompt.attachment', async ($, e, next) => {
770 if (e.type === 'todo_reminder' || e.type === 'task_reminder') return { text: null }
771
772 return next(e)
773 }).catch(($, e, next) => next(e))
774
775 // The nudge, appended after the engine's own sections.
776 on('prompt.compose', async ($, e, next) => {
777 const composed = await next(e)
778 // Already given beside the first prompt: not twice.
779 if (nudge.sent) return composed
780 const text = nudgeText(e.tools)
781 if (!text) return composed
782 nudge.composed = true
783
784 return { sections: [...composed.sections, { id: 'next-up', text, scope: 'session' as const }] }
785 }).catch(($, e, next) => next(e))
786
787 // The first prompt takes the session-start list down. Sending a task's
788 // prompt (from Now, or a New session's pre-filled box) marks it done;
789 // filling the box alone does not.
790 on('prompt.submit', async ($, e, next) => {
791 // Only a prompt the user sent can start a task (not a peer's message, a
792 // notification or a plugin's own prompt).
793 const fromUser = e.origin?.kind === 'composer' || e.origin?.kind === 'bridge' || e.origin?.kind === 'sdk'
794 if (features.backlog && fromUser) {
795 if (await read($, bandAtom)) await update($, bandAtom, () => false)
796 const sent = submittedTask(e.text, await refresh($), context.project)
797 if (sent) {
798 await setFields($, sent.id, { status: 'done' })
799 $.ui.toast(`Next Up: "${sent.title}" started`)
800 }
801 }
802 // Unseen by the user, beside this prompt: Next Up's guidance, if the system
803 // prompt never carried it, and their edits in the Tasks pane.
804 // Both are marked delivered only once the prompt has gone through.
805 const extra: string[] = []
806 const sendsNudge = !nudge.composed && !nudge.sent
807 if (sendsNudge) extra.push(`From the Next Up mod the user has installed:\n\n${nudgeText()}`)
808 const pending = { done: [...edits.done], removed: [...edits.removed] }
809 if (features.tasks) {
810 const session = (await read($, sessionAtom)) ?? []
811 const note = editsNote(session, pending)
812 const reminder = note ? null : openReminder(session)
813 if (note ?? reminder) extra.push((note ?? reminder) as string)
814 }
815 if (extra.length === 0) return next(e)
816 const entered = await next({ ...e, context: [...(e.context ?? []), ...extra] })
817 if (!entered.drop) {
818 if (sendsNudge) nudge.sent = true
819 edits.done = edits.done.filter(t => !pending.done.includes(t))
820 edits.removed = edits.removed.filter(t => !pending.removed.includes(t))
821 }
822
823 return entered
824 }).catch(($, e, next) => next(e))
825
826 on('command.run', { command: 'next' }, async ($, e) => {
827 const args = e.args.trim()
828 if (!features.backlog || (args === 'tasks' && features.tasks)) {
829 await togglePane($, TASKS_PANE, 'Tasks')
830 return { text: 'Tasks pane toggled.' }
831 }
832 if (args === 'tasks') return { text: 'Tasks is switched off in /config (Next Up: Session tasks).' }
833 if (/^plan(\s|$)/.test(args)) {
834 const goal = args.slice(4).trim()
835 if (!goal) return { text: 'Usage: /next plan <goal>' }
836 await $.prompt.submit({ text: planPrompt(goal), asUser: true })
837 return { text: `Planning "${goal}" into Next Up.` }
838 }
839 if (args === 'list' || args === 'all') return { text: listText(await refresh($), context.project, args === 'all' ? 'all' : 'project') }
840 // Shows the session-start list in this session, which normally appears only
841 // in a new one.
842 if (args === 'band') {
843 await update($, bandAtom, () => true)
844 return { text: 'Next Up list shown above the prompt.' }
845 }
846 if (args) return { text: `Usage: /next [${features.tasks ? 'tasks | ' : ''}list | all | band | plan <goal>]` }
847 await togglePane($)
848
849 return { text: 'Next Up pane toggled.' }
850 })
851}
852hooks/capture.ts 90 lines1// How tasks come in, as plain functions: from Claude's suggestion chips and
2// from the add_tasks tool. No `$`; the hooks module does the reading and writing.
3
4import { labelOf } from './store'
5import type { Project, Task } from './store'
6
7export type { Project } from './store'
8
9export type Stamp = { session: string; now: string; newId: () => string }
10
11const TITLE_MAX = 80
12
13function clean(text: unknown): string {
14 return typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
15}
16
17function short(text: string, max: number): string {
18 return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`
19}
20
21function base(stamp: Stamp, project: Project): Pick<Task, 'repo' | 'status' | 'favourite' | 'seen' | 'createdAt' | 'updatedAt'> {
22 return { repo: project, status: 'open', favourite: false, seen: 1, createdAt: stamp.now, updatedAt: stamp.now }
23}
24
25// A suggestion chip (the desktop's spawn_task: title, tldr, prompt) as a task.
26// Null when it has no title or prompt to keep.
27export function taskFromChip(args: { title?: unknown; tldr?: unknown; prompt?: unknown }, project: Project, stamp: Stamp): Task | null {
28 const title = clean(args.title)
29 const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : ''
30 if (!title || !prompt) return null
31
32 return {
33 ...base(stamp, project),
34 id: stamp.newId(),
35 title: short(title, TITLE_MAX),
36 detail: clean(args.tldr),
37 prompt,
38 kind: 'suggested',
39 sources: [{ kind: 'chip', session: stamp.session, at: stamp.now }],
40 }
41}
42
43export type AddInput = {
44 tasks?: Array<{ title?: unknown; details?: unknown; prompt?: unknown }>
45 plan?: { title?: unknown }
46}
47
48// add_tasks' input as tasks: "Added", or "Plan n/m" when a plan title is given
49// (steps numbered in the order listed). A task without a prompt gets its title
50// and details as one.
51export function tasksFromAddInput(input: AddInput, project: Project, stamp: Stamp): Task[] {
52 const items = (input.tasks ?? []).filter(t => clean(t.title))
53 const planTitle = clean(input.plan?.title)
54 const planId = planTitle ? stamp.newId() : ''
55
56 return items.map((item, i) => {
57 const title = short(clean(item.title), TITLE_MAX)
58 const details = typeof item.details === 'string' ? item.details.trim() : ''
59 const prompt = typeof item.prompt === 'string' && item.prompt.trim() ? item.prompt.trim() : [title, details].filter(Boolean).join('\n\n')
60
61 return {
62 ...base(stamp, project),
63 id: stamp.newId(),
64 title,
65 detail: clean(details) || (planTitle ? `${planTitle}, step ${i + 1} of ${items.length}` : ''),
66 prompt,
67 kind: planTitle ? ('plan' as const) : ('added' as const),
68 ...(planTitle ? { plan: { id: planId, title: planTitle, step: i + 1, of: items.length } } : {}),
69 sources: [{ kind: 'tool' as const, session: stamp.session, at: stamp.now }],
70 }
71 })
72}
73
74// Open tasks as text, for list_tasks and /next: favourites first, newest first.
75// Outside a project (or for "all"), each line names its project.
76export function listText(tasks: Task[], project: Project, scope: 'project' | 'all'): string {
77 const open = tasks.filter(t => t.status === 'open' && (scope === 'all' || project === null || t.repo?.root === project.root))
78 if (open.length === 0) return scope === 'all' || project === null ? 'Next Up is empty.' : `No open tasks for ${project.name}.`
79 const sorted = [...open].sort((a, b) => Number(b.favourite) - Number(a.favourite) || b.createdAt.localeCompare(a.createdAt))
80 const showProject = scope === 'all' || project === null
81
82 return sorted
83 .map(t => {
84 const where = showProject ? ` [${t.repo?.name ?? 'no project'}]` : ''
85 const star = t.favourite ? '★ ' : ''
86 return `- ${star}${t.title} (${labelOf(t)}, id ${t.id})${where}${t.detail ? `: ${t.detail}` : ''}`
87 })
88 .join('\n')
89}
90hooks/session.ts 128 lines1// The session's own task list, as plain functions: the steps of the work in
2// hand, kept by Claude through update_session_tasks and edited from the Tasks
3// pane. No `$`; the hooks module keeps the list in state.
4
5import type { SessionTask, TasksTab } from '../types'
6import { GAP } from './view'
7
8export type { SessionTask, TasksTab } from '../types'
9
10export type SessionChanges = {
11 add?: Array<{ title?: unknown; details?: unknown }>
12 update?: Array<{ id?: unknown; title?: unknown; details?: unknown }>
13 done?: unknown[]
14 remove?: unknown[]
15}
16
17const TITLE_MAX = 80
18
19function clean(text: unknown): string {
20 return typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
21}
22
23function shorten(title: string): string {
24 return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1).trimEnd()}…`
25}
26
27// The next free short id: one past the highest s<n> ever given (removed tasks
28// stay in the list, hidden, for this).
29function nextNumber(tasks: SessionTask[]): number {
30 return tasks.reduce((n, t) => Math.max(n, Number(t.id.slice(1)) || 0), 0) + 1
31}
32
33// Applies Claude's changes: rewordings, removals, completions, then additions
34// (which go to the end, in the order given). Unknown ids are reported, not
35// fatal.
36export function applyChanges(tasks: SessionTask[], changes: SessionChanges, now: string): { tasks: SessionTask[]; unknown: string[] } {
37 const ids = new Set(tasks.filter(t => t.status !== 'removed').map(t => t.id))
38 const named = (list: unknown[] | undefined) => (list ?? []).map(id => String(id).trim()).filter(Boolean)
39 const remove = new Set(named(changes.remove))
40 const done = new Set(named(changes.done))
41 const updates = new Map<string, { title: string; details: string | null }>()
42 for (const u of changes.update ?? []) {
43 const id = String(u.id ?? '').trim()
44 if (id) updates.set(id, { title: clean(u.title), details: typeof u.details === 'string' ? clean(u.details) : null })
45 }
46 const unknown = [...remove, ...done, ...updates.keys()].filter(id => !ids.has(id))
47
48 let next = tasks.map(t => {
49 if (t.status === 'removed') return t
50 const u = updates.get(t.id)
51 if (u) t = { ...t, ...(u.title ? { title: shorten(u.title) } : {}), ...(u.details !== null ? { detail: u.details } : {}) }
52 if (remove.has(t.id)) return { ...t, status: 'removed' as const }
53 if (done.has(t.id) && t.status === 'todo') return { ...t, status: 'done' as const, doneAt: now }
54 return t
55 })
56 let n = nextNumber(tasks)
57 for (const item of changes.add ?? []) {
58 const title = clean(item.title)
59 if (!title) continue
60 next = [...next, { id: `s${n}`, title: shorten(title), detail: clean(item.details), status: 'todo', createdAt: now }]
61 n += 1
62 }
63
64 return { tasks: next, unknown: [...new Set(unknown)] }
65}
66
67// The list as Claude reads it, in a tool result or beside a prompt.
68export function sessionText(tasks: SessionTask[]): string {
69 if (tasks.length === 0) return 'The session task list is empty.'
70 const line = (t: SessionTask) => `- [${t.id}] ${t.title}${t.detail ? `: ${t.detail}` : ''}`
71 const todo = tasks.filter(t => t.status === 'todo')
72 const done = tasks.filter(t => t.status === 'done')
73
74 return [
75 `To do (${todo.length}):`,
76 ...(todo.length ? todo.map(line) : ['- nothing left']),
77 `Done (${done.length}):`,
78 ...(done.length ? done.map(line) : ['- nothing yet']),
79 ].join('\n')
80}
81
82// What the user changed from the pane, for Claude to hear before its next turn.
83export type UserEdits = { done: SessionTask[]; removed: SessionTask[] }
84
85export function editsNote(tasks: SessionTask[], edits: UserEdits): string | null {
86 if (edits.done.length === 0 && edits.removed.length === 0) return null
87 const names = (list: SessionTask[]) => list.map(t => `[${t.id}] ${t.title}`).join('; ')
88 const lines = ['The user edited the session task list (in the Tasks pane) since you last saw it. Treat their changes as decided: do not redo or re-add these.']
89 if (edits.done.length) lines.push(`Marked done by the user: ${names(edits.done)}.`)
90 if (edits.removed.length) lines.push(`Removed by the user: ${names(edits.removed)}.`)
91 lines.push('', 'The list now:', sessionText(tasks))
92
93 return lines.join('\n')
94}
95
96// Beside each prompt while steps remain, so Claude sees the list without
97// asking for it.
98export function openReminder(tasks: SessionTask[]): string | null {
99 const todo = tasks.filter(t => t.status === 'todo')
100 if (todo.length === 0) return null
101
102 return [
103 `Session tasks still to do (${todo.length}), in order. Keep them current with update_session_tasks: mark each done as it is finished, add or reword steps as the work changes.`,
104 ...todo.map(t => `- [${t.id}] ${t.title}`),
105 ].join('\n')
106}
107
108// The footer button: "<to do> Tasks ↗", or just the name with none.
109export function tasksLabel(tasks: SessionTask[]): string {
110 const count = tasks.filter(t => t.status === 'todo').length
111
112 return count > 0 ? `${count}${GAP}Tasks ↗` : 'Tasks ↗'
113}
114
115// The Tasks pane: To Do in planned order, Done newest first.
116export function tasksView(tasks: SessionTask[], tab: TasksTab): { tabs: Array<{ id: TasksTab; label: string }>; items: SessionTask[] } {
117 const todo = tasks.filter(t => t.status === 'todo')
118 const done = tasks.filter(t => t.status === 'done').sort((a, b) => (b.doneAt ?? '').localeCompare(a.doneAt ?? ''))
119
120 return {
121 tabs: [
122 { id: 'todo', label: `To Do ${todo.length}` },
123 { id: 'done', label: `Done ${done.length}` },
124 ],
125 items: tab === 'done' ? done : todo,
126 }
127}
128hooks/store.ts 137 lines1// Next Up's store: plain functions, no `$`. Every session appends events to its
2// own log (~/.claude/next-up/log/<session>.jsonl); every session folds all logs
3// into one task list. Last write wins per field.
4
5// The task model lives in the state contract (types/), which the drawings read.
6import type { Source, Task } from '../types'
7
8export type { Kind, Project, Source, Task } from '../types'
9
10type Stamp = { v: 1; at: string; by: string }
11
12export type TaskEvent =
13 | (Stamp & { op: 'add'; task: Task })
14 | (Stamp & { op: 'merge'; id: string; source: Source })
15 | (Stamp & { op: 'set'; id: string; fields: Partial<Pick<Task, 'title' | 'detail' | 'prompt' | 'status' | 'favourite'>> })
16
17// Folds events from every log into the current list, oldest event first.
18export function fold(events: TaskEvent[]): Task[] {
19 const byId = new Map<string, Task>()
20 const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at) || a.by.localeCompare(b.by))
21 for (const e of sorted) {
22 if (e.op === 'add') {
23 if (!byId.has(e.task.id)) byId.set(e.task.id, { ...e.task })
24 continue
25 }
26 const task = byId.get(e.id)
27 if (!task) continue
28 if (e.op === 'merge') {
29 byId.set(e.id, { ...task, seen: task.seen + 1, sources: [...task.sources, e.source], updatedAt: e.at })
30 } else {
31 byId.set(e.id, { ...task, ...e.fields, updatedAt: e.at })
32 }
33 }
34
35 return [...byId.values()]
36}
37
38const STOPWORDS = new Set(['a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'and', 'or', 'with', 'is', 'be', 'this', 'that', 'it', 'at', 'by'])
39
40// Title words for comparison: lowercase, punctuation dropped, stopwords out,
41// a trailing plural "s" trimmed. Letters and digits of any script count.
42export function words(text: string): string[] {
43 return text
44 .toLowerCase()
45 .replace(/[^\p{L}\p{N}]+/gu, ' ')
46 .split(' ')
47 .filter(w => w && !STOPWORDS.has(w))
48 .map(w => (w.length > 3 && w.endsWith('s') ? w.slice(0, -1) : w))
49}
50
51// Word overlap of two titles, 0 to 1 (Jaccard).
52export function similarity(a: string, b: string): number {
53 const x = new Set(words(a))
54 const y = new Set(words(b))
55 if (x.size === 0 || y.size === 0) return 0
56 let shared = 0
57 for (const w of x) if (y.has(w)) shared += 1
58
59 return shared / (x.size + y.size - shared)
60}
61
62// Word overlap at or above this is a near duplicate. At 0.6, short titles that
63// differ by one meaningful word ("Add tests for X" / "Add docs for X") merged.
64const NEAR = 0.75
65
66// An open task in the same project that the candidate repeats: the same words
67// (exact) or most of them (near). A plan's steps are asked for deliberately,
68// so they are never merged away. A title with no words matches nothing.
69export function findDuplicate(candidate: Pick<Task, 'title' | 'repo' | 'plan'>, tasks: Task[]): { task: Task; match: 'exact' | 'near' } | null {
70 if (candidate.plan) return null
71 const key = words(candidate.title).join(' ')
72 if (!key) return null
73 let best: { task: Task; score: number } | null = null
74 for (const task of tasks) {
75 if (task.status !== 'open' || (task.repo?.root ?? null) !== (candidate.repo?.root ?? null)) continue
76 if (words(task.title).join(' ') === key) return { task, match: 'exact' }
77 const score = similarity(task.title, candidate.title)
78 if (score >= NEAR && (!best || score > best.score)) best = { task, score }
79 }
80
81 return best ? { task: best.task, match: 'near' } : null
82}
83
84// The event to append for an incoming task: a merge into a duplicate, or an add.
85export function eventForIncoming(task: Task, tasks: Task[], by: string): TaskEvent {
86 const duplicate = findDuplicate(task, tasks)
87 const source = task.sources[0] ?? { kind: 'tool', session: by, at: task.createdAt }
88 if (duplicate) return { v: 1, at: task.createdAt, by, op: 'merge', id: duplicate.task.id, source }
89
90 return { v: 1, at: task.createdAt, by, op: 'add', task }
91}
92
93// The card's label: Suggested (a Claude chip), Added (you, or Claude when
94// asked), Plan n/m.
95export function labelOf(task: Task): string {
96 if (task.kind === 'plan' && task.plan) return `Plan ${task.plan.step}/${task.plan.of}`
97
98 return task.kind === 'added' ? 'Added' : 'Suggested'
99}
100
101const OPENING = 80
102// Below this an opening is too generic to find inside a longer prompt
103// ("Fix lint"), so a short prompt has to be sent exactly.
104const MIN_OPENING = 30
105
106const squash = (text: string) => text.replace(/\s+/g, ' ').trim().toLowerCase()
107
108// A sent prompt "is" a task when it still carries the task's opening: the
109// first 80 characters, whitespace folded. Edits after that still count.
110export function isSubmissionOf(submitted: string, task: Task): boolean {
111 const prompt = squash(task.prompt)
112 const opening = prompt.slice(0, OPENING)
113 if (opening.length < MIN_OPENING) return opening.length > 0 && squash(submitted) === prompt
114
115 return squash(submitted).includes(opening)
116}
117
118// The open task in this project (or outside any, outside one) that a sent
119// prompt starts; the longest opening wins when several fit.
120export function submittedTask(submitted: string, tasks: Task[], project: Task['repo']): Task | null {
121 const here = tasks.filter(t => t.status === 'open' && (t.repo?.root ?? null) === (project?.root ?? null))
122 const matches = here.filter(t => isSubmissionOf(submitted, t))
123
124 return matches.sort((a, b) => squash(b.prompt).slice(0, OPENING).length - squash(a.prompt).slice(0, OPENING).length)[0] ?? null
125}
126
127// "now", "5m", "2h", "3d", "2w".
128export function ageOf(iso: string, now: number): string {
129 const seconds = Math.max(0, (now - Date.parse(iso)) / 1000)
130 if (seconds < 60) return 'now'
131 if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
132 if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
133 if (seconds < 604800) return `${Math.floor(seconds / 86400)}d`
134
135 return `${Math.floor(seconds / 604800)}w`
136}
137hooks/store-io.ts 91 lines1import type { EngineInterface } from 'claude-code'
2
3import type { TaskEvent } from './store'
4
5// The store's files. Each session writes only its own log, so two sessions
6// never write the same file; every session reads all of them.
7//
8// ~/.claude/next-up/log/<session>.jsonl one event per line
9//
10// The functions take only the file operations they use (the hooks module
11// wraps $.fs), so tests can hand them an in-memory stand-in.
12
13export type Files = Pick<EngineInterface['fs'], 'exists' | 'list' | 'read' | 'write'>
14
15// The logs' names, sizes and times, joined: a change in any log changes it, so
16// a session can poll this cheaply and fold again only when it moves.
17export async function logsSignature(fs: Files, dir: string): Promise<string> {
18 const logDir = `${dir}/log`
19 if (!(await fs.exists(logDir))) return ''
20 const entries = await fs.list(logDir)
21
22 return entries
23 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.jsonl'))
24 .map(entry => `${entry.name}:${entry.size}:${entry.mtimeMs}`)
25 .sort()
26 .join('|')
27}
28
29// Each log's parsed events, by file name, with the size and time they were read
30// at. Logs only grow, and only one session writes each, so most stay unchanged
31// between reads.
32export type LogCache = Map<string, { stamp: string; events: TaskEvent[] }>
33
34function parse(text: string): TaskEvent[] {
35 const events: TaskEvent[] = []
36 for (const line of text.split('\n')) {
37 if (!line.trim()) continue
38 try {
39 events.push(JSON.parse(line) as TaskEvent)
40 } catch {
41 // A torn or hand-edited line: skip it.
42 }
43 }
44
45 return events
46}
47
48// Every event in every log. A line that does not parse is skipped, so one bad
49// write never hides the rest. Given a cache, only logs whose size or time
50// moved since the last read are read again.
51export async function readEvents(fs: Files, dir: string, cache: LogCache = new Map()): Promise<TaskEvent[]> {
52 const logDir = `${dir}/log`
53 if (!(await fs.exists(logDir))) {
54 cache.clear()
55 return []
56 }
57 const seen = new Set<string>()
58 const events: TaskEvent[] = []
59 for (const entry of await fs.list(logDir)) {
60 if (entry.kind !== 'file' || !entry.name.endsWith('.jsonl')) continue
61 seen.add(entry.name)
62 const stamp = `${entry.size}:${entry.mtimeMs}`
63 let cached = cache.get(entry.name)
64 if (cached?.stamp !== stamp) {
65 // A log that cannot be read (removed since the listing, too large, no
66 // permission) keeps what was last read of it, or is skipped, so it never
67 // hides the others.
68 const text = await fs.read(`${logDir}/${entry.name}`).catch(() => null)
69 if (text === null) {
70 if (cached) events.push(...cached.events)
71 continue
72 }
73 cached = { stamp, events: parse(text) }
74 cache.set(entry.name, cached)
75 }
76 events.push(...cached.events)
77 }
78 for (const name of cache.keys()) if (!seen.has(name)) cache.delete(name)
79
80 return events
81}
82
83// Appends to this session's own log. $.fs has no append, so this rewrites the
84// file; safe because no other session writes it.
85export async function appendEvents(fs: Files, dir: string, session: string, events: TaskEvent[]): Promise<void> {
86 if (events.length === 0) return
87 const file = `${dir}/log/${session}.jsonl`
88 const before = (await fs.exists(file)) ? await fs.read(file) : ''
89 await fs.write(file, before + events.map(event => `${JSON.stringify(event)}\n`).join(''))
90}
91hooks/view.ts 93 lines1// What the footer, pane and session-start list show, as plain functions.
2
3import type { Project, Tab, Task } from '../types'
4
5// Between the count and the name: a space and a no-break space, since the
6// desktop draws labels as web text and folds two plain spaces into one.
7export const GAP = ' \u00a0'
8
9export const AMBER = '#d48a1a'
10export const BLUE = '#6ea8e0'
11export const PURPLE = '#b392f0'
12
13// Open tasks for a project, or every open task outside one.
14export function openFor(tasks: Task[], project: Project): Task[] {
15 return tasks.filter(t => t.status === 'open' && (project === null || t.repo?.root === project.root))
16}
17
18// Favourites first, then newest first.
19export function ordered(tasks: Task[]): Task[] {
20 return [...tasks].sort((a, b) => Number(b.favourite) - Number(a.favourite) || b.createdAt.localeCompare(a.createdAt))
21}
22
23// The footer button: "<open here> Next Up ↗", or just the name with none.
24export function footerLabel(tasks: Task[], project: Project): string {
25 const count = openFor(tasks, project).length
26
27 return count > 0 ? `${count}${GAP}Next Up ↗` : 'Next Up ↗'
28}
29
30export type PaneView = {
31 tabs: Array<{ id: Tab; label: string }>
32 current: Tab
33 // Cards in order; a header starts each project group in ★ and All.
34 // A header's key is the project's root (two clones can share a name).
35 items: Array<{ kind: 'header'; key: string; name: string } | { kind: 'card'; task: Task }>
36}
37
38const NO_PROJECT = 'No project'
39
40// Projects are told apart by their root; tasks outside any share one group.
41function groupOf(task: Task): { key: string; name: string } {
42 return task.repo ? { key: task.repo.root, name: task.repo.name } : { key: '', name: NO_PROJECT }
43}
44
45// The pane's tabs and list. The project tab exists only inside a project;
46// ★ and All group by project, this project first, then by name.
47export function paneView(tasks: Task[], project: Project, tab: Tab): PaneView {
48 const open = tasks.filter(t => t.status === 'open')
49 const here = project ? open.filter(t => t.repo?.root === project.root) : []
50 const favourites = open.filter(t => t.favourite)
51 const tabs: PaneView['tabs'] = [
52 { id: 'favourites', label: `★ ${favourites.length}` },
53 ...(project ? [{ id: 'project' as const, label: `${project.name} ${here.length}` }] : []),
54 { id: 'all', label: `All ${open.length}` },
55 ]
56 const current: Tab = tab === 'project' && !project ? 'all' : tab
57 if (current === 'project') return { tabs, current, items: ordered(here).map(task => ({ kind: 'card', task })) }
58
59 const shown = current === 'favourites' ? favourites : open
60 const groups = [...new Map(shown.map(t => [groupOf(t).key, groupOf(t)])).values()].sort((a, b) =>
61 a.key === project?.root ? -1 : b.key === project?.root ? 1 : a.key === '' ? 1 : b.key === '' ? -1 : a.name.localeCompare(b.name),
62 )
63 const items: PaneView['items'] = groups.flatMap(group => [
64 { kind: 'header' as const, ...group },
65 ...ordered(shown.filter(t => groupOf(t).key === group.key)).map(task => ({ kind: 'card' as const, task })),
66 ])
67
68 return { tabs, current, items }
69}
70
71// The session-start list: up to three open tasks for the project (every
72// project outside one), favourites first.
73export function bandTasks(tasks: Task[], project: Project): Task[] {
74 return ordered(openFor(tasks, project)).slice(0, 3)
75}
76
77// The label's colour, text only: plans amber, added blue, suggestions purple.
78export function labelColour(task: Task): string {
79 if (task.kind === 'plan') return AMBER
80
81 return task.kind === 'added' ? BLUE : PURPLE
82}
83
84// About three lines of description: the site's width in cells times three,
85// plus a third for the desktop's proportional font fitting more per cell.
86export function detailCap(columns: number): number {
87 return Math.max(60, Math.floor((columns - 6) * 3 * 1.3))
88}
89
90export function cap(text: string, max: number): string {
91 return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`
92}
93types/index.d.ts 62 lines1// Next Up's state contract: the task model, and the values its drawings read.
2
3export type Kind = 'suggested' | 'added' | 'plan'
4
5export type Source = { kind: 'chip' | 'tool' | 'manual'; session: string; at: string }
6
7export type Task = {
8 id: string
9 title: string
10 // The card's summary, about three lines.
11 detail: string
12 // The full prompt Now and New send.
13 prompt: string
14 repo: { root: string; name: string } | null
15 kind: Kind
16 plan?: { id: string; title: string; step: number; of: number }
17 // done: its prompt was sent.
18 status: 'open' | 'done' | 'dropped'
19 favourite: boolean
20 seen: number
21 sources: Source[]
22 createdAt: string
23 updatedAt: string
24}
25
26export type Project = Task['repo']
27
28export type Tab = 'favourites' | 'project' | 'all'
29
30// A step of the work in this session, kept by Claude through
31// update_session_tasks. In memory only: it ends with the session.
32export type SessionTask = {
33 // Short, for Claude to refer to: s1, s2, ...
34 id: string
35 title: string
36 detail: string
37 // removed: dropped by Claude or the user. Kept, hidden, so its id is never
38 // given to another task.
39 status: 'todo' | 'done' | 'removed'
40 createdAt: string
41 doneAt?: string
42}
43
44export type TasksTab = 'todo' | 'done'
45
46declare module 'claude-code' {
47 interface PluginState {
48 'next-up': {
49 // Every task, folded from all sessions' logs.
50 tasks: Task[]
51 // The session's project (its git root), or null outside one.
52 project: Project
53 tab: Tab
54 // The session-start list: shown in a new session until the first prompt.
55 band: boolean
56 // This session's own task list, in the order Claude planned it.
57 session: SessionTask[]
58 tasksTab: TasksTab
59 }
60 }
61}
62