A right-side Claude Code panel for your task tracker: browse roadmaps, start a ready task, and clear-and-start with a handoff note when you stay in the same…

A Claude Code plugin for big, long-running work: a feature that takes many tasks and many sessions.
/work create <name>. The agent writes the plan, cuts it into tasks with their dependencies, shows you a preview, and writes nothing to the tracker until you say OK.It is one plugin with three parts: a panel on the right side of the terminal, a roadmap skill the agent follows, and a small tracker script that keeps tasks as Markdown files in your repo.

Full-quality video with sound (mp4, 16:9) · 4:5 version
In a Claude Code terminal session:
/plugin install work-panel --marketplace ilikeeatingrice/claude-code-work-panel
Or run it from a clone: claude --plugin-dir /path/to/claude-code-work-panel.
Python 3.10+ is needed for the tracker. No other dependencies.
/work create checkout-redesign. The agent writes docs/programs/checkout-redesign/PLAN.md, then shows the preview: every task, and what each one waits on. Reply OK (or ask for changes)./work shows it; it stays until you type /work again. New sessions start with it hidden. Arrow keys move, Enter opens a roadmap or picks a task.● task. In a fresh session the button is Start; it sends the first message, and the agent claims the task.| Mark | State | Can you start it? |
|---|---|---|
● | Ready: every prerequisite is Done | Yes |
○ | Waiting: shows what it needs (needs .15) | No |
◐ | In progress | Only if no live session is on it |
■ | Blocked: shows its resume condition | No |
✓ | Done, folded into one N done line | No |
Once a session has a task, the panel shows only that task's roadmap (the rest fold into one row) and a header:
▲ THIS SESSION
TASK-001.02 · Guest checkout without an account
now: writing tests for the guest token · 2m ago
The agent keeps it current through a small focus tool the plugin gives it: it calls it when it claims or switches a task, at each milestone, and when it finishes. If an agent never calls it, the panel still follows its claims: after a tool call that marks a task In Progress, the panel switches to that task. After a plugin reload it reads the task back from the session's own live file, and when it still doesn't know (a session that claimed its task earlier), it asks the session's model once, with no tools, and checks the answer against the tracker. If the tracker stops listing the session's task (say its file no longer parses), the panel keeps the task and says so instead of forgetting it.
The task the session is on is drawn in bold teal; a task another live session holds, in plain teal.
| Your session | Button | What happens |
|---|---|---|
| Fresh, nothing said yet | Start | Sends the first message for the task. |
| Working on a task in the same roadmap | Clear and start | The session writes a handoff note (changes, commands, commits, decisions, findings, risk, next action), the plugin saves it on the old task with the branch and git status, runs /clear, and sends the first message with a summary of the note. |
| A different roadmap, or no task | Clear and start | Plain /clear, then the first message. No note. |
The old conversation stays saved; claude --resume opens it.
Each session running the plugin rewrites a small file, ~/.claude/work-panel/live/<session id>.json, every minute. A task named by a live file (written in the last 3 minutes) shows being worked on now and is locked. A stale claim can be taken over; one younger than 2 hours with no live session asks for a second press. Sessions without the plugin write no file, so the panel can't see them.
Everything lives in your repo as plain text:
WORK.md the active roadmaps
backlog.config.yml task id prefix, folders
backlog/tasks/task-001.02 - Coupon-codes.md
docs/programs/<roadmap>/PLAN.md the design
Tasks use the Backlog.md file format: YAML front matter (status, labels, dependencies, parent), acceptance criteria, and a comments block that serves as the task's journal. See tracker/README.md for the format and commands (plans, tree, ready, validate, init, create, comment, status).
In /config → Work Panel:
| Setting | Default | Meaning |
|---|---|---|
treeCommand | scripts/work_tracker.py tree --json | Use your own tracker instead of the bundled one. When this script is missing and the folder has a WORK.md, the bundled tracker is used. |
startHint | empty | Added to each first message, e.g. Use the work-tracker skill. |
noteAuthor | @claude | Author written on handoff notes. |
A custom tracker command must print the same JSON as python3 tracker/roadmap.py tree --json: a list of roadmaps, each with plan_id, state, parent_task_id, counts and tasks; each task with task_id, title, status, ready, waiting_on, open_children, resume_condition, last_comment_at (UTC YYYY-MM-DD HH:MM) and path. The panel draws ready; it never computes it.
hooks/register.tsx: the Claude Code hooks module. The panel is a pane drawn by a ui.render hook. The handoff note comes from $.model.fork over the session's own history with no tools; then $.command.run({ command: 'clear' }) and $.prompt.submit start the next session. State lives in module variables, because /clear gives the session a new id.skills/roadmap/: how the agent creates a roadmap (with the preview gate), claims a task, hands off, and retires a roadmap. A project's own tracking doc (docs/work-tracking.md) takes precedence when present.tracker/roadmap.py: the tracker, Python standard library only (uses PyYAML when installed).Check with claude plugin validate . and python3 -m pytest tracker/tests.
demo/ is a Remotion project that recreates the Claude Code terminal cell by cell. The music is original, generated by demo/audio/make_track.py at 120 BPM, and every cut, key press and caption sits on its beat grid (demo/src/beat.ts; npm run sync prints the check).
cd demo && npm install
npm run audio # regenerate audio/track.wav (needs Python with numpy)
npm run render # 16:9 -> out/work-panel-demo.mp4
npm run render:4x5 # 4:5 -> out/work-panel-demo-4x5.mp4
npm run gif
If Remotion can't download its headless Chrome, set REMOTION_CHROME to a local chrome-headless-shell binary.
MIT
hooks/register.tsx 782 lines1import type { Register } from 'claude-code'
2
3import type { TreePlan, TreeTask, LiveEntry } from '../types'
4
5// The Work panel: browse the roadmaps of the task tracker in the folder this
6// session runs in, start a task whose prerequisites are done, and on "Clear
7// and start" in the same roadmap have this session write its handoff note
8// first. The tracker is read through one command (`treeCommand`, JSON on
9// stdout; the shape is in ../types/index.d.ts and the README).
10//
11// State lives in module variables, not $.state: /clear gives the process a new
12// session id and $.state belongs to the old one, while the module keeps going.
13
14const PANE = 'work-panel'
15const LIVE_FRESH_MS = 3 * 60_000
16const CLAIM_GUARD_MS = 2 * 60 * 60_000
17const TEAL = '#6fb7b9'
18const AMBER = '#e0a85a'
19
20let root = ''
21let treeArgv: string[] = []
22let startHint = ''
23let noteAuthor = '@claude'
24let liveDir = ''
25let isEnabled = false
26let sessionId = ''
27
28let plans: TreePlan[] = []
29let loadError: string | undefined
30let live = new Map<string, LiveEntry>()
31const expanded = new Set<string>()
32let selected: string | undefined
33let armed: string | undefined
34let busy: string | undefined
35let problem: string | undefined
36let sessionTask: { id: string; plan: string } | undefined
37// This session's task when the tracker does not list it (a task file that no
38// longer parses, say): kept and shown with a warning instead of forgotten.
39let missingTask: string | undefined
40let showOthers = false
41let nowText: string | undefined
42let nowAt = 0
43let isTurnRunning = false
44let isFresh = true
45let heartbeat: { cancel: () => void } | undefined
46
47// ---------- data ----------
48
49async function refresh($: any) {
50 if (!isEnabled) await resolveTracker($)
51 if (!isEnabled) {
52 plans = []
53 $.ui.invalidate('ui.render')
54 return
55 }
56 try {
57 let out = await $.process.run(treeArgv, { cwd: root, timeoutMs: 20_000 })
58 const bundled = bundledArgv($)
59 if (out.exitCode !== 0 && treeArgv.join(' ') !== bundled.join(' ') && (await $.fs.exists(`${root}/WORK.md`))) {
60 // The configured tracker failed (an older copy without `tree`, say);
61 // the bundled one reads the same files.
62 const retry = await $.process.run(bundled, { cwd: root, timeoutMs: 20_000 })
63 if (retry.exitCode === 0) {
64 treeArgv = bundled
65 out = retry
66 }
67 }
68 if (out.exitCode !== 0) {
69 loadError = (out.stderr || out.stdout || 'tree failed').trim().split('\n').slice(-1)[0]
70 } else {
71 plans = JSON.parse(out.stdout) as TreePlan[]
72 loadError = undefined
73 }
74 } catch (err) {
75 loadError = String((err as Error)?.message ?? err)
76 }
77 live = await readLive($)
78 if (missingTask && !sessionTask) {
79 const found = findTask(missingTask)
80 if (found) {
81 sessionTask = { id: missingTask, plan: found.plan.plan_id }
82 expanded.add(found.plan.plan_id)
83 missingTask = undefined
84 }
85 }
86 try {
87 isFresh = (await $.session.messages()).length === 0
88 } catch {
89 isFresh = false
90 }
91 $.ui.invalidate('ui.render')
92}
93
94async function readLive($: any): Promise<Map<string, LiveEntry>> {
95 const map = new Map<string, LiveEntry>()
96 try {
97 if (!(await $.fs.exists(liveDir))) return map
98 const now = await $.clock.now()
99 for (const entry of await $.fs.list(liveDir)) {
100 if (!entry.name.endsWith('.json')) continue
101 try {
102 const value = JSON.parse(await $.fs.read(`${liveDir}/${entry.name}`)) as LiveEntry
103 if (value.sessionId !== sessionId && now - value.at > 24 * 60 * 60_000) {
104 // a session that ended a day ago: drop its file
105 await $.process.run(['rm', '-f', `${liveDir}/${entry.name}`])
106 continue
107 }
108 if (!value.task || value.sessionId === sessionId) continue
109 if (now - value.at > LIVE_FRESH_MS) continue
110 map.set(value.task, value)
111 } catch {
112 // a half-written file from another session: skip it this round
113 }
114 }
115 } catch {
116 // no live folder yet
117 }
118 return map
119}
120
121async function beat($: any) {
122 if (!isEnabled || !sessionId) return
123 const value: LiveEntry = {
124 sessionId,
125 task: sessionTask?.id ?? missingTask ?? null,
126 now: nowText ?? null,
127 nowAt,
128 cwd: root,
129 at: await $.clock.now(),
130 }
131 await $.fs.write(`${liveDir}/${sessionId}.json`, JSON.stringify(value))
132}
133
134// After a reload the module starts blank: take this session's task back from
135// its own live file, which outlives the module.
136async function restoreFromLive($: any) {
137 try {
138 const path = `${liveDir}/${sessionId}.json`
139 if (!(await $.fs.exists(path))) return
140 const saved = JSON.parse(await $.fs.read(path)) as LiveEntry
141 if (!saved.task) return
142 nowText = saved.now ?? undefined
143 nowAt = saved.nowAt ?? 0
144 const found = findTask(saved.task)
145 if (!found) {
146 missingTask = saved.task
147 return
148 }
149 sessionTask = { id: saved.task, plan: found.plan.plan_id }
150 expanded.add(found.plan.plan_id)
151 nowText = saved.now ?? undefined
152 nowAt = saved.nowAt ?? 0
153 } catch {
154 // no saved state
155 }
156}
157
158// When the panel does not know this session's task but the conversation has
159// history, ask the session's own model once (no tools, cached prefix).
160let askedModel = false
161async function askModelForTask($: any) {
162 if (sessionTask || missingTask || isFresh || askedModel || !plans.length) return
163 askedModel = true
164 const example = plans.find(plan => plan.tasks.length)?.tasks[0]?.task_id ?? 'TASK-001.01'
165 try {
166 const reply = await $.model.fork({
167 prompt: `Which tracked task is this session working on right now (task ids look like ${example})? Reply with exactly one line: the task id, a space, a vertical bar, a space, then what you are doing now in under 8 words. If this session is not working on a tracked task, reply NONE.`,
168 })
169 if (!reply.isAnswered || !reply.text) return
170 const line = reply.text.trim().split('\n')[0] ?? ''
171 const match = line.match(/\b([A-Za-z][A-Za-z0-9]*-\d+(?:\.\d+)*)\b\s*\|?\s*(.*)$/)
172 if (!match) return
173 const id = match[1]!.toUpperCase()
174 const found = findTask(id)
175 if (!found || found.task.status === 'Done' || found.task.completed) return
176 sessionTask = { id, plan: found.plan.plan_id }
177 expanded.add(found.plan.plan_id)
178 showOthers = false
179 nowText = (match[2] ?? '').trim().slice(0, 80) || undefined
180 nowAt = await $.clock.now()
181 await beat($)
182 $.ui.invalidate('ui.render')
183 } catch {
184 // the question is a convenience; the panel works without it
185 }
186}
187
188function findTask(id: string): { plan: TreePlan; task: TreeTask } | undefined {
189 for (const plan of plans) {
190 const task = plan.tasks.find(one => one.task_id === id)
191 if (task) return { plan, task }
192 }
193 return undefined
194}
195
196function lastCommentMs(task: TreeTask): number | undefined {
197 if (!task.last_comment_at) return undefined
198 const ms = Date.parse(`${task.last_comment_at.trim().replace(' ', 'T')}Z`)
199 return Number.isNaN(ms) ? undefined : ms
200}
201
202// Can this task be started from the panel, and does it need a second press?
203function startability(task: TreeTask, now: number): { canStart: boolean; why?: string; guard?: string } {
204 if (task.completed || task.status === 'Done') return { canStart: false, why: 'done' }
205 if (sessionTask?.id === task.task_id) return { canStart: false, why: 'this session' }
206 if (task.status === 'Blocked') return { canStart: false, why: task.resume_condition ? `blocked · ${task.resume_condition}` : 'blocked' }
207 if (task.status === 'In Progress') {
208 const holder = live.get(task.task_id)
209 if (holder) return { canStart: false, why: 'being worked on now' }
210 const last = lastCommentMs(task)
211 const age = last === undefined ? undefined : now - last
212 const ago = age === undefined ? 'claimed' : `claimed ${agoText(age)} ago`
213 if (age !== undefined && age < CLAIM_GUARD_MS) return { canStart: true, why: `${ago} · no live session`, guard: `${ago}, no live session seen` }
214 return { canStart: true, why: `${ago} · nobody on it now` }
215 }
216 if (task.status === 'To Do' && task.ready) return { canStart: true }
217 if (task.status === 'To Do') {
218 const waits = [...task.waiting_on, ...task.open_children].map(shortId)
219 return { canStart: false, why: waits.length ? `needs ${waits.join(', ')}` : 'not ready' }
220 }
221 return { canStart: false, why: task.status.toLowerCase() }
222}
223
224function agoText(ms: number): string {
225 const min = Math.round(ms / 60_000)
226 if (min < 60) return `${min}m`
227 const h = Math.round(min / 60)
228 if (h < 48) return `${h}h`
229 return `${Math.round(h / 24)}d`
230}
231
232function nowLine(text: string, ageMs: number): string {
233 const ago = ageMs < 60_000 ? 'just now' : `${agoText(ageMs)} ago`
234 return text === 'done' ? `finished ${ago}` : `now: ${text} · ${ago}`
235}
236
237function shortId(id: string): string {
238 const dot = id.indexOf('.')
239 return dot === -1 ? id : id.slice(dot)
240}
241
242type Case = 'start' | 'same' | 'other'
243
244function caseFor(plan: TreePlan): Case {
245 if (isFresh && !sessionTask) return 'start'
246 if (sessionTask && sessionTask.plan === plan.plan_id) return 'same'
247 return 'other'
248}
249
250// ---------- the start flow ----------
251
252const NOTE_PROMPT = `Write the handoff note for the task this session worked on, for the next session that continues the same roadmap. Use exactly this shape, replacing every placeholder with exact facts from this session (write "None" where nothing applies). Reply with the note only, no preamble.
253
254session end
255 Changes: <exact files and behavior changed>
256 Commands/results: \`<exact command>\`: <trimmed actual result>
257 Commits or dirty state: <SHA(s), or the exact git status>
258 Decisions: <decisions made and why>
259 Findings: <facts discovered, including failed attempts>
260 Residual risk: <known unverified behavior, or None>
261 Next action: <the next concrete action or None>`
262
263async function appendComment($: any, path: string, entry: string) {
264 const text: string = await $.fs.read(path)
265 const end = '<!-- COMMENTS:END -->'
266 const at = text.lastIndexOf(end)
267 if (at === -1) throw new Error('task file has no comments block')
268 const now = new Date(await $.clock.now())
269 const iso = now.toISOString().replace(/\.\d+Z$/, 'Z')
270 const created = `${iso.slice(0, 10)} ${iso.slice(11, 16)}`
271 const body = entry.trim().split('\n')
272 const record = `author: ${noteAuthor}\ncreated: ${created}\n---\n${iso}: ${body[0]}\n${body.slice(1).join('\n')}\n---\n`
273 const before = text.slice(0, at)
274 await $.fs.write(path, `${before}${before.endsWith('\n') ? '' : '\n'}${record}${text.slice(at)}`)
275}
276
277async function gitFacts($: any): Promise<string> {
278 try {
279 const branch = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root })
280 const status = await $.process.run(['git', 'status', '--short'], { cwd: root })
281 const lines = status.stdout.trim().split('\n').filter(Boolean)
282 const shown = lines.slice(0, 15).map((line: string) => ` ${line}`).join('\n')
283 return ` Branch (read by the work panel): ${branch.stdout.trim()}\n git status --short (${lines.length} lines):\n${shown || ' clean'}${lines.length > 15 ? '\n …' : ''}`
284 } catch {
285 return ' Branch: (could not read git)'
286 }
287}
288
289function firstMessage(plan: TreePlan, task: TreeTask, note?: { from: string; text: string }): string {
290 const lines = [
291 `Start ${task.task_id} (${task.title}). Roadmap: ${plan.plan_id} (${plan.parent_task_id}).${startHint ? ` ${startHint}` : ''}`,
292 ]
293 if (task.status === 'In Progress') {
294 lines.push(
295 `${task.task_id} is already In Progress${task.assignees.length ? ` (assignee ${task.assignees.join(', ')})` : ''}, but no live session is working on it. Read its latest comment first, take over the claim, and record the takeover in its journal.`,
296 )
297 }
298 if (note) {
299 lines.push(
300 '',
301 `This continues the same roadmap. The previous session just handed off ${note.from}; its full note is the latest comment on ${note.from}. Read it before you start. The note:`,
302 '',
303 note.text.trim(),
304 )
305 }
306 return lines.join('\n')
307}
308
309async function start($: any, planId: string, taskId: string) {
310 const found = findTask(taskId)
311 if (!found || found.plan.plan_id !== planId) return
312 const { plan, task } = found
313 const which = caseFor(plan)
314 problem = undefined
315 armed = undefined
316 try {
317 let note: { from: string; text: string } | undefined
318 if (which === 'same' && sessionTask) {
319 const from = findTask(sessionTask.id)
320 busy = `writing the handoff note on ${sessionTask.id}…`
321 $.ui.invalidate('ui.render')
322 const reply = await $.model.fork({ prompt: NOTE_PROMPT })
323 if (!reply.isAnswered || !reply.text?.trim()) {
324 throw new Error(`the note could not be written (${reply.reason ?? 'empty reply'}); nothing was cleared`)
325 }
326 const text = `${reply.text.trim()}\n${await gitFacts($)}`
327 if (from) await appendComment($, from.task.path, text)
328 note = { from: sessionTask.id, text }
329 }
330 if (which !== 'start') {
331 busy = 'clearing…'
332 $.ui.invalidate('ui.render')
333 await $.command.run({ command: 'clear' })
334 sessionId = await $.session.id()
335 }
336 sessionTask = { id: task.task_id, plan: plan.plan_id }
337 missingTask = undefined
338 nowText = 'starting'
339 nowAt = await $.clock.now()
340 expanded.add(plan.plan_id)
341 showOthers = false
342 selected = undefined
343 busy = undefined
344 isFresh = false
345 await beat($)
346 $.ui.invalidate('ui.render')
347 await $.prompt.submit({ text: firstMessage(plan, task, note) })
348 } catch (err) {
349 busy = undefined
350 problem = String((err as Error)?.message ?? err)
351 $.ui.invalidate('ui.render')
352 }
353}
354
355// ---------- hooks ----------
356
357// About 30% of the terminal, 56-96 columns; remembered for opens at startup.
358function paneColumns(terminalColumns: number | undefined): number {
359 return Math.min(96, Math.max(56, Math.round((terminalColumns ?? 190) * 0.3)))
360}
361
362async function openPane($: any, terminalColumns?: number) {
363 const stored = await $.store.get('columns')
364 const columns = terminalColumns ? paneColumns(terminalColumns) : typeof stored === 'number' ? stored : paneColumns(undefined)
365 await $.store.set('columns', columns)
366 return $.ui.open({ id: PANE, title: 'Work', columns, focus: true })
367}
368
369// The command's first word: a script path relative to the session folder
370// (a .py one runs with that folder's .venv python, else python3), or a
371// program on PATH.
372async function resolveTree($: any, command: string): Promise<string[] | undefined> {
373 const words = command.trim().split(/\s+/).filter(Boolean)
374 const first = words[0]
375 if (!first) return undefined
376 const isPath = first.includes('/')
377 if (isPath && !(await $.fs.exists(`${root}/${first}`))) return undefined
378 if (first.endsWith('.py')) {
379 const venv = `${root}/.venv/bin/python`
380 return [(await $.fs.exists(venv)) ? venv : 'python3', ...words]
381 }
382 return words
383}
384
385let treeCommand = ''
386
387const FOCUS_TOOL = 'mcp__work-panel__focus'
388const FOCUS_SPEC = {
389 name: 'focus',
390 description:
391 "Tell the Work Panel (the task list beside this conversation) which tracked task this session is working on and what you are doing right now. Call it when you claim or switch to a task, at each milestone (a new step, tests running, waiting on the person), and with done: true when the task is finished. Keep `now` to a short phrase, e.g. 'writing tests for coupon rules'. It only updates the panel; it changes no files.",
392 inputSchema: {
393 type: 'object',
394 properties: {
395 task_id: { type: 'string', description: 'The tracked task id, e.g. TASK-001.02' },
396 now: { type: 'string', description: 'What you are doing right now, a short phrase (under 60 characters)' },
397 done: { type: 'boolean', description: 'True when the task is finished' },
398 },
399 required: ['task_id', 'now'],
400 },
401}
402
403function bundledArgv($: any): string[] {
404 return ['python3', `${$.plugin.root}/tracker/roadmap.py`, 'tree', '--json']
405}
406
407// The configured tracker command when its script is here; otherwise the
408// bundled tracker once the folder has a WORK.md registry.
409async function resolveTracker($: any) {
410 const argv = await resolveTree($, treeCommand)
411 if (argv) {
412 treeArgv = argv
413 } else if (await $.fs.exists(`${root}/WORK.md`)) {
414 treeArgv = bundledArgv($)
415 } else {
416 isEnabled = false
417 return
418 }
419 isEnabled = true
420}
421
422function createPrompt(name: string): string {
423 return [
424 `Create a roadmap named "${name}" from the design we discussed in this conversation.`,
425 'Follow the "Create a roadmap from a design" steps of the roadmap skill (work-panel:roadmap):',
426 'write the PLAN.md, cut the slices, show me the dry-run preview, and wait for my OK before anything is written.',
427 ].join(' ')
428}
429
430export const register: Register = (on, options) => {
431 const settings = options as { treeCommand?: string; startHint?: string; noteAuthor?: string }
432 on('session.start', async ($, e, next) => {
433 const started = await next(e)
434 root = e.cwd
435 treeCommand = settings.treeCommand || 'scripts/work_tracker.py tree --json'
436 startHint = settings.startHint || 'Use the roadmap skill (work-panel:roadmap).'
437 noteAuthor = settings.noteAuthor || '@claude'
438 const home = (await $.process.run(['printenv', 'HOME'])).stdout.trim()
439 liveDir = `${home}/.claude/work-panel/live`
440 sessionId = await $.session.id()
441 await $.command.register({
442 name: 'work',
443 description: 'Show or hide the Work panel; /work create <name> turns the design discussed here into a roadmap',
444 argumentHint: '[create <name>]',
445 })
446 await $.tool.register(FOCUS_SPEC)
447 await refresh($)
448 await restoreFromLive($)
449 $.ui.invalidate('ui.render')
450 await beat($)
451 heartbeat?.cancel()
452 heartbeat = $.clock.every(60_000, () => {
453 void beat($).then(() => (isTurnRunning ? undefined : refresh($)))
454 })
455 return started
456 })
457
458 on('session.end', async ($, e, next) => {
459 // The process goes on after /clear under a new id; free the old id's entry.
460 if (isEnabled && e.sessionId) {
461 try {
462 await $.fs.write(`${liveDir}/${e.sessionId}.json`, JSON.stringify({ sessionId: e.sessionId, task: null, cwd: root, at: 0 }))
463 } catch {
464 // nothing to free
465 }
466 }
467 if (e.reason === 'clear') {
468 sessionTask = undefined
469 missingTask = undefined
470 nowText = undefined
471 askedModel = false
472 isFresh = true
473 }
474 return next(e)
475 })
476
477 on('command.run', { command: 'work' }, async ($, e) => {
478 const args = (e.args ?? '').trim()
479 if (/^create\b/i.test(args)) {
480 const name = args.replace(/^create\b/i, '').trim()
481 if (!name) return { text: 'Usage: /work create <roadmap name>' }
482 if (isTurnRunning) return { text: 'Work panel: wait for the current turn to finish, then /work create again.' }
483 $.clock.after(50, () => void $.prompt.submit({ text: createPrompt(name) }))
484 return { text: `Work panel: asked the agent to draft the roadmap "${name}". It will show a preview first.` }
485 }
486 const panes = await $.ui.panes()
487 // A pane opened unasked waits undrawn below 144 columns: only one the
488 // person can see is closed; otherwise /work opens it for real.
489 if (panes.some(pane => pane.id === PANE && pane.isPlaced && pane.isShown)) {
490 await $.ui.close({ id: PANE })
491 return { text: 'Work panel hidden.' }
492 }
493 await refresh($)
494 void askModelForTask($)
495 const opened = await openPane($, e.presentation?.columns)
496 return { text: opened.isPlaced ? 'Work panel open.' : 'Work panel waits for a wider terminal.' }
497 })
498
499 on('prompt.submit', async ($, e, next) => {
500 isTurnRunning = true
501 isFresh = false
502 return next(e)
503 }).catch(($, e, next) => next(e))
504
505 // The agent reports its task and what it is doing now; the panel shows it.
506 on('tool.call', { tool: FOCUS_TOOL }, async ($, e) => {
507 const input = e as unknown as { task_id?: string; now?: string; done?: boolean }
508 const id = String(input.task_id ?? '').trim().toUpperCase()
509 let found = findTask(id)
510 if (!found) {
511 await refresh($)
512 found = findTask(id)
513 }
514 if (!found) return { result: `Work Panel: no task ${id} in this folder's tracker; nothing changed.` }
515 if (sessionTask?.id !== id) {
516 expanded.add(found.plan.plan_id)
517 showOthers = false
518 selected = undefined
519 armed = undefined
520 }
521 sessionTask = { id, plan: found.plan.plan_id }
522 missingTask = undefined
523 nowText = input.done ? 'done' : String(input.now ?? '').trim().slice(0, 80) || undefined
524 nowAt = await $.clock.now()
525 await beat($)
526 $.ui.invalidate('ui.render')
527 return { result: `Work Panel shows this session on ${id}${nowText ? ` (now: ${nowText})` : ''}.` }
528 }).catch(() => ({ result: 'Work Panel could not update; carry on with the task.' }))
529
530 // This session's task follows its claims as they happen: after a tool call
531 // that names a task and looks like a status change, the tracker is re-read,
532 // and a named task that has just become In Progress is this session's task.
533 on('tool.call', async ($, e, next) => {
534 const result = await next(e)
535 if (!isEnabled || e.tool === FOCUS_TOOL) return result
536 const fields = e as unknown as Record<string, unknown>
537 const text = [fields.command, fields.file_path, fields.new_string, fields.content]
538 .filter(v => typeof v === 'string')
539 .join(' ')
540 if (!/in progress|status|tasks\//i.test(text)) return result
541 const named = new Set<string>()
542 for (const match of text.matchAll(/\b([a-z][a-z0-9]*)-(\d+(?:\.\d+)*)\b/gi)) {
543 named.add(`${match[1]!.toUpperCase()}-${match[2]}`)
544 }
545 const before = [...named].filter(id => {
546 const found = findTask(id)
547 return found !== undefined && found.task.status !== 'In Progress'
548 })
549 if (!before.length) return result
550 await refresh($)
551 for (const id of before) {
552 const found = findTask(id)
553 if (found && found.task.status === 'In Progress' && sessionTask?.id !== id) {
554 sessionTask = { id, plan: found.plan.plan_id }
555 missingTask = undefined
556 expanded.add(found.plan.plan_id)
557 showOthers = false
558 selected = undefined
559 armed = undefined
560 await beat($)
561 $.ui.invalidate('ui.render')
562 }
563 }
564 return result
565 }).catch(($, e, next) => next(e))
566
567 on('turn.complete', async ($, e, next) => {
568 const done = await next(e)
569 isTurnRunning = false
570 if (isEnabled) void refresh($).then(() => askModelForTask($))
571 return done
572 })
573
574 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
575 const { Box, Text, Button } = $.ui.resolve(e)
576 // The pane's own body width (viewport.columns is the whole conversation's).
577 const width = Math.max(30, (e.props?.bodyColumns ?? 58) - 1)
578 const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
579 const now = Date.now()
580
581 if (!isEnabled) {
582 return (
583 <Box flexDirection="column" gap={1}>
584 <Text bold>WORK</Text>
585 <Text dimColor wrap="wrap">No roadmaps here yet.</Text>
586 <Text wrap="wrap">{'Talk through a design with the agent, then type /work create <name>.'}</Text>
587 </Box>
588 )
589 }
590 if (loadError && !plans.length) {
591 return (
592 <Box flexDirection="column">
593 <Text color="error">Could not read the tracker</Text>
594 <Text dimColor wrap="wrap">{loadError}</Text>
595 <Button plain dimColor onPress={() => void refresh($)}>Retry</Button>
596 </Box>
597 )
598 }
599
600 const current = sessionTask ? findTask(sessionTask.id) : undefined
601 const lostId = current ? undefined : (sessionTask?.id ?? missingTask)
602 const active = plans.filter(plan => plan.state === 'Active')
603 const pickedRow = selected ? findTask(selected) : undefined
604
605 // A session with a task sees its own roadmap; the rest fold into one row.
606 const focus = sessionTask ? active.find(plan => plan.plan_id === sessionTask?.plan) : undefined
607 const others = focus ? active.length - 1 : 0
608 const shown = focus && !showOthers ? [focus] : active
609
610 const rows: any[] = []
611 for (const plan of shown) {
612 const isOpen = expanded.has(plan.plan_id)
613 const isSame = sessionTask?.plan === plan.plan_id
614 const meta = `${plan.counts.done}/${plan.counts.total} · ${plan.counts.ready} ready`
615 const name = `${plan.parent_task_id.replace(/^[A-Za-z]+-/, '')} ${plan.plan_id}`
616 rows.push(
617 <Box key={`p-${plan.plan_id}`} flexDirection="row" justifyContent="space-between">
618 <Button
619 key={`plan-${plan.plan_id}`}
620 plain
621 label={`${isOpen ? '▾' : '▸'} ${cut(name, width - meta.length - 4)}`}
622 onPress={() => {
623 if (isOpen) expanded.delete(plan.plan_id)
624 else expanded.add(plan.plan_id)
625 $.ui.invalidate('ui.render')
626 }}
627 />
628 <Text color={isSame ? TEAL : undefined} dimColor={!isSame}>{isSame ? 'this roadmap' : meta}</Text>
629 </Box>,
630 )
631 if (!isOpen) continue
632 const open = plan.tasks.filter(task => task.status !== 'Done' && !task.completed)
633 const doneCount = plan.tasks.length - open.length
634 const order = (task: TreeTask) => {
635 const s = startability(task, now)
636 if (task.status === 'In Progress') return s.canStart ? 1 : 0
637 if (s.canStart) return 1
638 if (task.status === 'Blocked') return 3
639 return 2
640 }
641 const sorted = [...open].sort((a, b) => order(a) - order(b))
642 for (const task of sorted) {
643 const s = startability(task, now)
644 const mark =
645 task.status === 'In Progress' ? '◐' : task.status === 'Blocked' ? '■' : s.canStart ? '●' : '○'
646 const isPicked = selected === task.task_id
647 // A task someone is on right now is drawn in teal; this session's own, bold.
648 const isMine = s.why === 'this session'
649 const working = isMine || s.why === 'being worked on now'
650 const label = ` ${isPicked ? '▶' : mark} ${shortId(task.task_id).padEnd(4)} ${cut(task.title, width - 12)}`
651 rows.push(
652 s.canStart ? (
653 <Button
654 key={`t-${task.task_id}`}
655 plain
656 dimColor={!isPicked && task.status === 'In Progress'}
657 label={label}
658 onPress={() => {
659 selected = task.task_id
660 armed = undefined
661 problem = undefined
662 $.ui.invalidate('ui.render')
663 }}
664 />
665 ) : (
666 <Text
667 key={`t-${task.task_id}`}
668 dimColor={!working}
669 bold={isMine}
670 color={working ? TEAL : task.status === 'Blocked' ? AMBER : undefined}
671 wrap="truncate-end"
672 >
673 {label}
674 </Text>
675 ),
676 )
677 if (s.why) {
678 rows.push(
679 <Text key={`w-${task.task_id}`} dimColor={!isMine} color={working ? TEAL : undefined} wrap="truncate-end">
680 {` ${cut(s.why, width - 10)}`}
681 </Text>,
682 )
683 }
684 }
685 if (doneCount) rows.push(<Text key={`d-${plan.plan_id}`} dimColor>{` ✓ ${doneCount} done`}</Text>)
686 }
687 if (others > 0) {
688 rows.push(
689 <Button
690 key="others"
691 plain
692 dimColor
693 label={showOthers ? '▴ only this roadmap' : `▸ ${others} other roadmap${others === 1 ? '' : 's'}`}
694 onPress={() => {
695 showOthers = !showOthers
696 $.ui.invalidate('ui.render')
697 }}
698 />,
699 )
700 }
701
702 let action: any = null
703 if (busy) {
704 action = <Text color={TEAL}>{busy}</Text>
705 } else if (pickedRow) {
706 const which = caseFor(pickedRow.plan)
707 const s = startability(pickedRow.task, now)
708 const id = shortId(pickedRow.task.task_id)
709 const verb = which === 'start' ? 'Start' : 'Clear and start'
710 const needsConfirm = which !== 'start' || Boolean(s.guard)
711 const isArmed = armed === pickedRow.task.task_id
712 const hint =
713 which === 'same'
714 ? `Same roadmap: a handoff note goes on ${sessionTask?.id} first.`
715 : which === 'other'
716 ? 'No handoff note: different roadmap or no task here.'
717 : 'Fresh session: nothing to clear.'
718 action = (
719 <Box flexDirection="column">
720 {s.guard ? <Text color={AMBER} wrap="wrap">{`Careful: ${s.guard}.`}</Text> : null}
721 <Button
722 key="go"
723 variant="primary"
724 autoFocus
725 label={isArmed ? `Press again: ${verb.toLowerCase()} ${id}` : `${verb} ${pickedRow.task.task_id}`}
726 onPress={() => {
727 if (isTurnRunning) {
728 problem = 'Wait for the current turn to finish.'
729 $.ui.invalidate('ui.render')
730 return
731 }
732 if (needsConfirm && !isArmed) {
733 armed = pickedRow.task.task_id
734 $.ui.invalidate('ui.render')
735 return
736 }
737 const planId = pickedRow.plan.plan_id
738 const taskId = pickedRow.task.task_id
739 $.clock.after(50, () => void start($, planId, taskId))
740 }}
741 />
742 <Text dimColor wrap="wrap">{hint}</Text>
743 </Box>
744 )
745 } else {
746 action = <Text dimColor>Pick a ● task to start it.</Text>
747 }
748
749 return (
750 <Box flexDirection="column" gap={1}>
751 <Box flexDirection="row" justifyContent="space-between">
752 <Text bold>WORK</Text>
753 <Text dimColor>{`${active.length} roadmap${active.length === 1 ? '' : 's'} · /work hides`}</Text>
754 </Box>
755 {current ? (
756 <Box flexDirection="column">
757 <Text color={AMBER}>
758 {current.task.status === 'Done' || current.task.completed ? '▲ THIS SESSION · done' : '▲ THIS SESSION'}
759 </Text>
760 <Text>{cut(`${current.task.task_id} · ${current.task.title}`, width)}</Text>
761 {nowText ? <Text dimColor>{cut(nowLine(nowText, now - nowAt), width)}</Text> : null}
762 </Box>
763 ) : lostId ? (
764 <Box flexDirection="column">
765 <Text color={AMBER}>▲ THIS SESSION</Text>
766 <Text>{cut(`${lostId} · not in the tracker's list`, width)}</Text>
767 <Text color="warning" wrap="wrap">
768 Its task file may not parse. Run the tracker's validate command to see why.
769 </Text>
770 </Box>
771 ) : null}
772 <Box flexDirection="column">{rows}</Box>
773 <Box flexDirection="column">
774 {action}
775 {problem ? <Text color="error" wrap="wrap">{problem}</Text> : null}
776 {loadError ? <Text color="warning" wrap="wrap">{`Tracker: ${loadError}`}</Text> : null}
777 </Box>
778 </Box>
779 )
780 })
781}
782types/index.d.ts 42 lines1// Shapes the Work panel reads: the tracker command's JSON (treeCommand) and
2// the per-session live files under ~/.claude/work-panel/live/.
3
4export type TreeTask = {
5 task_id: string
6 title: string
7 status: string
8 priority: string | null
9 ordinal: number
10 assignees: string[]
11 labels: string[]
12 parent_task_id: string | null
13 dependencies: string[]
14 ready: boolean
15 waiting_on: string[]
16 open_children: string[]
17 resume_condition: string | null
18 last_comment_at: string | null
19 completed: boolean
20 path: string
21}
22
23export type TreePlan = {
24 plan_id: string
25 priority: number
26 state: string
27 path: string
28 parent_task_id: string
29 parent_title: string | null
30 counts: { total: number; done: number; ready: number; in_progress: number; blocked: number; waiting: number }
31 tasks: TreeTask[]
32}
33
34export type LiveEntry = {
35 sessionId: string
36 task: string | null
37 now?: string | null
38 nowAt?: number
39 cwd: string
40 at: number
41}
42