Pane with linked tasks, their blockers and status, agents and git worktrees

A Claude Code mod that adds a Work pane: the agents of the session as an org chart of cards, the tasks with what blocks them, and the git worktrees with their pull requests.
Agents. A card for the main session, joined by connector lines to a card per agent, with subagents hanging under their parent. Each card has:
Opus 5.5 · poteto-agent · pstack)▸ Edit api.ts), or the first line of its last answergh pr createThe header carries the agent counts and the session cost as /cost reports it.
Tasks. Tasks created with TaskCreate as cards, each blocked task under the task that blocks it, with done, running, blocked and ready states.
Worktrees. The worktrees with uncommitted changes or a branch of their own, with the number of changed files and the branch's pull request: number, open, draft, merged or closed, and CI checks. Detached and clean worktrees are folded into one line.
Notifications. A notification, through the channel Claude Code is set to use, when an agent starts waiting on you, and when the CI checks of an open PR turn red. Where the session cannot raise one, as in the desktop app, it shows as a toast. A PR that was already failing when the mod loaded stays quiet, and turning notifications off in Claude Code turns these off too.
For the model. The mod registers a work_status tool that returns the same picture as JSON: agents with their status, branch, current tool and PR; tasks with the open tasks they wait on; worktrees with their changed files, PR and checks. The main session and its agents can call it before taking a task or a worktree, to see what is blocked and what another agent already holds.
At the prompt of a Claude Code session:
/plugin install work-graph --marketplace mariano-aguero/claude-work-graph
From a shell:
claude plugin install work-graph --marketplace mariano-aguero/claude-work-graph
The first time, Claude Code asks to add the mariano-aguero/claude-work-graph marketplace. Answer y, then keep the user scope so the mod loads in every session you start. It runs in the current session right away, with no restart.
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with mariano-aguero/claude-work-graph, then Add plugin. A mod installed from a terminal at the user scope also loads in the desktop app's local Code sessions, so either road works.
The pane opens by itself when a session starts. /work opens it at any time. In a fullscreen terminal at least 110 columns wide it docks beside the transcript; otherwise it sits above the prompt.
To check the install, claude plugin list should show work-graph, and /work should answer Work pane opened. If the pane stays empty or never shows, start a session with claude --debug: the mod's errors are logged on lines starting with work-graph:.
To get a new version, run claude plugin update work-graph, then /reload-plugins in an open session. To remove it, claude plugin uninstall work-graph.
git for the worktree list.gh, signed in, for pull requests. Without it the pane shows worktrees without PRs.gh pr list --author @me.claude plugin validate .
claude plugin test .
To run a local copy, start a session with claude --plugin-dir <path to this folder>.
hooks/register.tsx 722 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Agent, AgentStats, PullRequest, SessionInfo, Task, Worktree } from '../types'
5
6const PANE = 'work-graph'
7const TITLE = 'Work'
8const POLL_MS = 4000
9const PR_POLL_MS = 60_000
10// Width asked for the dock beside a fullscreen transcript; the person's drag wins.
11const OPEN = { id: PANE, title: TITLE, columns: 110 }
12const CARD_MIN = 34
13const GAP = 1
14const MAX_TREES = 8
15const MAX_FILES = 50
16const MAIN = 'main'
17const STATUS_TOOL = 'work_status'
18
19const tasks = atom({ plugin: 'work-graph', key: 'tasks' } as const, [])
20const agents = atom({ plugin: 'work-graph', key: 'agents' } as const, [])
21const worktrees = atom({ plugin: 'work-graph', key: 'worktrees' } as const, [])
22const stats = atom({ plugin: 'work-graph', key: 'stats' } as const, {})
23const session = atom({ plugin: 'work-graph', key: 'session' } as const, {})
24
25const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'])
26const EMPTY_STATS: AgentStats = {
27 turns: 0,
28 tools: 0,
29 inputTokens: 0,
30 outputTokens: 0,
31 costUsd: 0,
32 contextTokens: 0,
33 totalMs: 0,
34 files: [],
35}
36
37// Dollars per million tokens: input, output, cache read. Cache writes bill at
38// 1.25x input. The first entry whose key the model id contains wins.
39const PRICES: [string, number, number, number][] = [
40 ['fable', 10, 50, 0.25],
41 ['mythos', 10, 50, 0.25],
42 ['opus-5-5', 4, 20, 0.2],
43 ['opus', 5, 25, 0.5],
44 ['sonnet-5', 2, 10, 0.2],
45 ['sonnet', 3, 15, 0.3],
46 ['haiku-5', 0.1, 0.5, 0.01],
47 ['haiku', 1, 5, 0.1],
48]
49
50type Usage = {
51 model: string
52 input_tokens: number
53 output_tokens: number
54 cache_read_input_tokens: number
55 cache_creation_input_tokens: number
56}
57
58function costOf(usage: Usage) {
59 const price = PRICES.find(([key]) => usage.model.includes(key))
60 if (!price) return 0
61 const [, input, output, cacheRead] = price
62 const dollars =
63 usage.input_tokens * input +
64 usage.output_tokens * output +
65 usage.cache_read_input_tokens * cacheRead +
66 usage.cache_creation_input_tokens * input * 1.25
67 return dollars / 1_000_000
68}
69
70function bumpStats($: EngineInterface, key: string, fn: (s: AgentStats) => AgentStats) {
71 return update($, stats, all => ({ ...all, [key]: fn(all[key] ?? EMPTY_STATS) }))
72}
73
74async function refreshTasks($: EngineInterface) {
75 const ran = await $.tool.call({ tool: 'TaskList' })
76 const list = (ran.result as { tasks?: Task[] } | undefined)?.tasks
77 if (list) await update($, tasks, () => list)
78}
79
80async function refreshWorktrees($: EngineInterface) {
81 const { exitCode, stdout } = await $.process.run(['git', 'worktree', 'list', '--porcelain'])
82 if (exitCode !== 0) return
83
84 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
85 const current = top.stdout.trim()
86 const found: Worktree[] = []
87 for (const block of stdout.trim().split('\n\n')) {
88 const path = block.match(/^worktree (.+)$/m)?.[1]
89 if (!path) continue
90 const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1] ?? 'detached'
91 const status = await $.process.run(['git', '-C', path, 'status', '--porcelain'])
92 const dirty = status.stdout.split('\n').filter(Boolean).length
93 found.push({ path, branch, dirty, isCurrent: path === current })
94 }
95 // PRs are read at write time, so a PR refresh that landed during the scan is kept.
96 await update($, worktrees, list => {
97 const prs = new Map(list.map(t => [t.branch, t.pr]))
98 return found.map(t => ({ ...t, pr: prs.get(t.branch) }))
99 })
100}
101
102type GhPr = {
103 headRefName: string
104 number: number
105 url: string
106 state: string
107 isDraft: boolean
108 statusCheckRollup: { conclusion?: string; status?: string; state?: string }[]
109}
110
111function checksOf(rollup: GhPr['statusCheckRollup']): PullRequest['checks'] {
112 if (rollup.length === 0) return 'none'
113 const outcomes = rollup.map(c => (c.conclusion || c.state || c.status || '').toUpperCase())
114 if (outcomes.some(o => ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT'].includes(o))) return 'failing'
115 if (outcomes.some(o => ['', 'PENDING', 'IN_PROGRESS', 'QUEUED', 'EXPECTED'].includes(o))) return 'pending'
116 return 'passing'
117}
118
119async function refreshPullRequests($: EngineInterface) {
120 const fields = 'headRefName,number,url,state,isDraft,statusCheckRollup'
121 const argv = ['gh', 'pr', 'list', '--author', '@me', '--state', 'all', '--limit', '100', '--json', fields]
122 // gh may be missing or signed out; the pane then shows worktrees without PRs.
123 const ran = await $.process.run(argv, { timeoutMs: 20_000 }).catch((error: unknown) => {
124 $.ui.log(`work-graph: gh pr list failed: ${String(error)}`, { to: 'debug' })
125 return undefined
126 })
127 if (ran?.exitCode !== 0) return
128
129 let listed: GhPr[]
130 try {
131 listed = JSON.parse(ran.stdout)
132 } catch (error) {
133 $.ui.log(`work-graph: gh pr list returned bad JSON: ${String(error)}`, { to: 'debug' })
134 return
135 }
136
137 // gh lists newest first, so the first PR seen for a branch is the current one.
138 const byBranch = new Map<string, PullRequest>()
139 for (const pr of listed) {
140 if (byBranch.has(pr.headRefName)) continue
141 byBranch.set(pr.headRefName, {
142 number: pr.number,
143 url: pr.url,
144 state: pr.state,
145 isDraft: pr.isDraft,
146 checks: checksOf(pr.statusCheckRollup ?? []),
147 })
148 }
149 let before = new Map<string, PullRequest | undefined>()
150 await update($, worktrees, list => {
151 before = new Map(list.map(t => [t.branch, t.pr]))
152 return list.map(t => ({ ...t, pr: byBranch.get(t.branch) }))
153 })
154
155 // Only a change seen between two polls alerts, so PRs already red at load stay quiet.
156 for (const [branch, pr] of byBranch) {
157 const was = before.get(branch)
158 if (pr.state === 'OPEN' && pr.checks === 'failing' && was && was.checks !== 'failing') {
159 void alert($, `#${pr.number} checks failing on ${branch}`)
160 }
161 }
162}
163
164// A native notification, or a toast where the session cannot raise one, as in
165// the desktop app. Notifications the person turned off stay off.
166async function alert($: EngineInterface, text: string) {
167 const sent = await $.ui.notify(text, { title: TITLE })
168 if (!sent.isSent && sent.reason !== 'disabled') $.ui.toast(text)
169}
170
171async function refreshSession($: EngineInterface) {
172 const { cost, context } = await $.session.usage()
173 const info: SessionInfo = {
174 costUsd: cost?.usd,
175 contextPercent: context.percent,
176 contextTokens: context.tokens,
177 }
178 await update($, session, () => info)
179}
180
181// Branches are only known for agents whose Agent call returned with a worktree.
182const agentBranches = new Map<string, string>()
183
184async function refreshAgents($: EngineInterface) {
185 const list = await $.agent.list()
186 const mapped: Agent[] = list.map(a => ({
187 id: a.id,
188 name: a.name ?? a.description,
189 description: a.description,
190 type: a.type,
191 status: a.status,
192 parentId: a.parentId,
193 branch: agentBranches.get(a.id),
194 }))
195 // The prior list is taken inside the write, so overlapping refreshes see each other's.
196 let before = new Map<string, string>()
197 await update($, agents, list => {
198 before = new Map(list.map(a => [a.id, a.status]))
199 return mapped
200 })
201
202 for (const agent of mapped) {
203 if (agent.status === 'waiting' && before.get(agent.id) !== 'waiting') void alert($, `${agent.name} needs you`)
204 }
205}
206
207function refreshAll($: EngineInterface) {
208 return Promise.allSettled([
209 refreshTasks($),
210 refreshAgents($),
211 refreshWorktrees($).then(() => refreshPullRequests($)),
212 refreshSession($),
213 ])
214}
215
216// What the pane shows, as data for the model and its agents. PRs come from the
217// last minute's poll; a gh call here would hold up the turn.
218async function workStatus($: EngineInterface) {
219 await Promise.allSettled([refreshTasks($), refreshAgents($), refreshWorktrees($)])
220 const taskList = await read($, tasks)
221 const byId = new Map(taskList.map(t => [t.id, t]))
222 const allStats = await read($, stats)
223 return {
224 agents: (await read($, agents)).map(a => ({
225 id: a.id,
226 name: a.name,
227 type: a.type,
228 status: a.status,
229 parentId: a.parentId,
230 branch: a.branch,
231 doing: allStats[a.id]?.currentTool,
232 pr: allStats[a.id]?.prUrl,
233 })),
234 tasks: taskList.map(t => ({
235 id: t.id,
236 subject: t.subject,
237 status: t.status,
238 owner: t.owner,
239 waitingOn: t.blockedBy.filter(id => byId.has(id) && byId.get(id)!.status !== 'completed'),
240 })),
241 worktrees: (await read($, worktrees)).map(t => ({
242 path: t.path,
243 branch: t.branch,
244 changedFiles: t.dirty,
245 isCurrent: t.isCurrent,
246 pr: t.pr,
247 })),
248 }
249}
250
251function toolLabel(e: { tool: string } & Record<string, unknown>) {
252 const target = e.file_path ?? e.command ?? e.pattern ?? e.description ?? e.url
253 if (typeof target !== 'string') return e.tool
254 return `${e.tool} ${target.split('\n')[0]!.replace(/^.*\//, '')}`
255}
256
257function isBlocked(task: Task, byId: Map<string, Task>) {
258 return task.status === 'pending' && task.blockedBy.some(id => byId.get(id)?.status !== 'completed')
259}
260
261type Badge = { text: string; color: string; isQuiet: boolean }
262
263const AGENT_BADGE: Record<string, Badge> = {
264 running: { text: '◐ Running', color: 'suggestion', isQuiet: false },
265 pending: { text: '◌ Starting', color: 'suggestion', isQuiet: false },
266 waiting: { text: '◷ Needs you', color: 'warning', isQuiet: false },
267 idle: { text: '○ Idle', color: 'subtle', isQuiet: true },
268 completed: { text: '✓ Done', color: 'success', isQuiet: true },
269 failed: { text: '✗ Failed', color: 'error', isQuiet: false },
270 killed: { text: '◼ Stopped', color: 'error', isQuiet: true },
271}
272
273// An idle teammate is one whose turn ended; how it ended says whether it succeeded.
274const TURN_BADGE: Record<string, Badge> = {
275 answer: { text: '✓ Done', color: 'success', isQuiet: true },
276 error: { text: '✗ Error', color: 'error', isQuiet: false },
277 refusal: { text: '✗ Refused', color: 'error', isQuiet: false },
278 aborted: { text: '◼ Stopped', color: 'subtle', isQuiet: true },
279}
280
281// Live agents first, then the ones that need the person, then the rest.
282const AGENT_ORDER = ['running', 'pending', 'waiting', 'failed', 'idle', 'completed', 'killed']
283
284function agentBadge(agent: Agent, s: AgentStats | undefined): Badge {
285 if (agent.status === 'idle' && s?.lastReason) return TURN_BADGE[s.lastReason] ?? AGENT_BADGE.idle!
286 return AGENT_BADGE[agent.status] ?? AGENT_BADGE.running!
287}
288
289function taskBadge(task: Task, byId: Map<string, Task>): Badge {
290 if (task.status === 'completed') return { text: '✓ Done', color: 'success', isQuiet: true }
291 if (task.status === 'in_progress') return { text: '◐ Running', color: 'suggestion', isQuiet: false }
292 if (isBlocked(task, byId)) return { text: '⧗ Blocked', color: 'warning', isQuiet: false }
293 return { text: '○ Ready', color: 'subtle', isQuiet: false }
294}
295
296// `plugin:agent` reads as the agent, then the plugin it comes from; built-in types have no plugin.
297function agentKind(type: string) {
298 const at = type.lastIndexOf(':')
299 return at === -1 ? [type] : [type.slice(at + 1), type.slice(0, at)]
300}
301
302function modelName(id: string | undefined) {
303 const match = id?.match(/claude-([a-z]+)-(\d+)(?:-(\d+))?/)
304 if (!match) return id
305 const [, family, major, minor] = match
306 return `${family![0]!.toUpperCase()}${family!.slice(1)} ${major}${minor ? `.${minor}` : ''}`
307}
308
309function tokens(n: number) {
310 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
311 if (n >= 1000) return `${Math.round(n / 1000)}k`
312 return String(n)
313}
314
315function duration(ms: number) {
316 const s = Math.round(ms / 1000)
317 if (s < 60) return `${s}s`
318 const m = Math.floor(s / 60)
319 return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
320}
321
322function money(usd: number) {
323 return usd < 10 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
324}
325
326function prLabel(pr: PullRequest) {
327 const state = pr.isDraft ? 'draft' : pr.state.toLowerCase()
328 const checks = { passing: ' ✓', failing: ' ✗', pending: ' ●', none: '' }[pr.checks]
329 return `#${pr.number} ${state}${checks}`
330}
331
332function prColor(pr: PullRequest) {
333 if (pr.checks === 'failing') return 'error'
334 if (pr.state === 'MERGED') return 'merged'
335 if (pr.state === 'CLOSED' || pr.isDraft) return 'subtle'
336 return pr.checks === 'pending' ? 'warning' : 'success'
337}
338
339// The line joining a parent card to a row of cards below it, as in an org chart:
340// ╭───────┬───┴───┬───────╮ with a drop at each card's center.
341function connector(cards: number, cardWidth: number) {
342 const width = cards * cardWidth + (cards - 1) * GAP
343 const centers = Array.from({ length: cards }, (_, i) => i * (cardWidth + GAP) + Math.floor(cardWidth / 2))
344 const first = centers[0]!
345 const last = centers[centers.length - 1]!
346 const root = Math.floor(width / 2)
347 if (cards === 1) return ' '.repeat(first) + '│'
348
349 let line = ''
350 for (let x = 0; x <= last; x++) {
351 const isDrop = centers.includes(x)
352 if (x < first) line += ' '
353 else if (x === root) line += isDrop ? '┼' : '┴'
354 else if (x === first) line += '╭'
355 else if (x === last) line += '╮'
356 else line += isDrop ? '┬' : '─'
357 }
358 return line
359}
360
361function relative(path: string, root: string) {
362 return path.startsWith(root) ? path.slice(root.length) || '.' : path
363}
364
365// The directory every path shares, with a trailing slash, so rows show what differs.
366function commonDir(paths: string[]) {
367 if (paths.length === 0) return ''
368 const parts = paths.map(p => p.split('/').slice(0, -1))
369 const first = parts[0]!
370 let n = 0
371 while (n < first.length && parts.every(p => p[n] === first[n])) n++
372 return first.slice(0, n).join('/') + '/'
373}
374
375export const register: Register = on => {
376 on('session.start', async ($, e, next) => {
377 const started = await next(e)
378 await $.command.register({
379 name: 'work',
380 description: 'Show agents, tasks with their blockers, worktrees and PRs in a pane',
381 })
382 await $.tool.register({
383 name: STATUS_TOOL,
384 description:
385 'The work of this session as JSON: each agent with its status, branch, current tool and PR; ' +
386 'each task with the open tasks it waits on; each git worktree with its changed files, PR and CI checks. ' +
387 'Call it before taking a task or a worktree, to see what is blocked and what another agent already holds.',
388 inputSchema: { type: 'object', properties: {} },
389 isDeferred: false,
390 })
391 void refreshAll($).then(() => $.ui.open(OPEN))
392 $.clock.every(POLL_MS, () => {
393 void refreshAgents($)
394 void refreshWorktrees($)
395 void refreshSession($)
396 })
397 $.clock.every(PR_POLL_MS, () => void refreshPullRequests($))
398
399 return started
400 })
401
402 on('command.run', { command: 'work' }, async $ => {
403 await refreshAll($)
404 const opened = await $.ui.open(OPEN)
405
406 return { text: opened.isPlaced ? 'Work pane opened.' : `Work pane waiting: ${opened.reason}` }
407 })
408
409 on('tool.call', { tool: `mcp__work-graph__${STATUS_TOOL}` }, async $ => {
410 const json = JSON.stringify(await workStatus($))
411 return { result: json, text: json, isReadOnly: true }
412 })
413
414 on('turn.step', async function* ($, e, next) {
415 const step = yield* next(e)
416 const usage = step.usage
417 if (usage) {
418 const context = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
419 await bumpStats($, e.agentId ?? MAIN, s => ({
420 ...s,
421 model: usage.model,
422 inputTokens: s.inputTokens + context,
423 outputTokens: s.outputTokens + usage.output_tokens,
424 costUsd: s.costUsd + costOf(usage),
425 contextTokens: context,
426 }))
427 }
428
429 return step
430 })
431
432 on('turn.complete', async ($, e, next) => {
433 await bumpStats($, e.agentId ?? MAIN, s => ({
434 ...s,
435 turns: s.turns + 1,
436 totalMs: s.totalMs + e.durationMs,
437 lastReason: e.reason,
438 lastAnswer: e.answer.trim().split('\n')[0]?.slice(0, 200),
439 currentTool: undefined,
440 }))
441
442 return next(e)
443 })
444
445 on('tool.call', async ($, e, next) => {
446 const key = e.agentId ?? MAIN
447 await bumpStats($, key, s => ({ ...s, currentTool: toolLabel(e) }))
448 const ran = await next(e)
449
450 const file = e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : undefined
451 const pr = e.tool === 'Bash' && e.command.includes('gh pr create')
452 ? ran.text?.match(/https:\/\/github\.com\/\S+\/pull\/\d+/)?.[0]
453 : undefined
454 await bumpStats($, key, s => ({
455 ...s,
456 tools: s.tools + 1,
457 currentTool: undefined,
458 files: file && !s.files.includes(file) ? [...s.files, file].slice(-MAX_FILES) : s.files,
459 prUrl: pr ?? s.prUrl,
460 }))
461 if (pr) void refreshPullRequests($)
462
463 if (TASK_TOOLS.has(e.tool)) {
464 await refreshTasks($)
465 void $.ui.open(OPEN)
466 }
467 if (e.tool === 'Agent') {
468 const result = ran.result as { agentId?: string; worktreeBranch?: string } | undefined
469 if (result?.agentId && result.worktreeBranch) agentBranches.set(result.agentId, result.worktreeBranch)
470 void refreshAgents($)
471 }
472
473 return ran
474 })
475
476 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
477 const { Box, Text } = $.ui.resolve(e)
478 const taskList = await read($, tasks)
479 const agentList = await read($, agents)
480 const treeList = await read($, worktrees)
481 const allStats = await read($, stats)
482 const sessionInfo = await read($, session)
483
484 const perRow = Math.max(1, Math.floor((e.props.bodyColumns + GAP) / (CARD_MIN + GAP)))
485 const cardWidth = Math.floor((e.props.bodyColumns - (perRow - 1) * GAP) / perRow)
486
487 type Line = { text: string; color?: string }
488
489 const card = (key: string, title: string, badge: Badge, lines: Line[], width: number): RenderChildren => (
490 <Box
491 key={key}
492 width={width}
493 flexDirection="column"
494 borderStyle="round"
495 borderColor={badge.isQuiet ? 'subtle' : badge.color}
496 paddingX={1}
497 >
498 <Box justifyContent="space-between">
499 <Box flexShrink={1}>
500 <Text bold dimColor={badge.isQuiet} wrap="truncate-end">
501 {title}
502 </Text>
503 </Box>
504 <Box flexShrink={0}>
505 <Text color={badge.color}> {badge.text}</Text>
506 </Box>
507 </Box>
508 {lines.map(line => (
509 <Text color={line.color} dimColor={!line.color} wrap="truncate-end">
510 {line.text}
511 </Text>
512 ))}
513 </Box>
514 )
515
516 // A parent card centered over its children, joined by connector lines.
517 const chart = (root: RenderChildren, columns: RenderChildren[]): RenderChildren => {
518 const rows: RenderChildren[][] = []
519 for (let i = 0; i < columns.length; i += perRow) rows.push(columns.slice(i, i + perRow))
520 const firstCount = rows[0]?.length ?? 0
521 return (
522 <Box flexDirection="column">
523 <Box justifyContent="center">{root}</Box>
524 {firstCount > 0 && <Text color="suggestion">{connector(firstCount, cardWidth)}</Text>}
525 {rows.map(row => (
526 <Box flexDirection="row" columnGap={GAP} alignItems="flex-start">
527 {row}
528 </Box>
529 ))}
530 </Box>
531 )
532 }
533
534 const metrics = (s: AgentStats | undefined): Line[] => {
535 if (!s) return []
536 // A teammate in its own process reports tool calls here but no usage.
537 const total = s.inputTokens + s.outputTokens
538 const parts = [
539 s.costUsd > 0 && money(s.costUsd),
540 total > 0 && `${tokens(total)} tok`,
541 s.contextTokens > 0 && `ctx ${tokens(s.contextTokens)}`,
542 s.totalMs > 0 && duration(s.totalMs),
543 s.tools > 0 && `${s.tools} tools`,
544 s.files.length > 0 && `${s.files.length} files`,
545 ].filter(Boolean)
546 return parts.length > 0 ? [{ text: parts.join(' · ') }] : []
547 }
548
549 const activity = (s: AgentStats | undefined, isLive: boolean): Line[] => {
550 if (s?.currentTool) return [{ text: `▸ ${s.currentTool}`, color: 'suggestion' }]
551 if (!isLive && s?.lastAnswer) return [{ text: s.lastAnswer }]
552 return []
553 }
554
555 const byStatus = (a: Agent, b: Agent) => AGENT_ORDER.indexOf(a.status) - AGENT_ORDER.indexOf(b.status)
556
557 const agentLines = (agent: Agent, s: AgentStats | undefined): Line[] => {
558 const lines: Line[] = [
559 { text: [modelName(s?.model), ...agentKind(agent.type)].filter(Boolean).join(' · ') },
560 ]
561 if (agent.description !== agent.name) lines.push({ text: agent.description })
562 lines.push(...activity(s, agent.status === 'running'), ...metrics(s))
563 if (agent.branch) lines.push({ text: `⑂ ${agent.branch}` })
564 if (s?.prUrl) lines.push({ text: `⇡ PR ${s.prUrl.replace(/^.*\/pull\//, '#')}`, color: 'success' })
565 return lines
566 }
567
568 const agentColumn = (agent: Agent): RenderChildren => {
569 const kids = agentList.filter(a => a.parentId === agent.id).sort(byStatus)
570 const s = allStats[agent.id]
571 return (
572 <Box key={`a${agent.id}`} flexDirection="column" width={cardWidth}>
573 {card(`c${agent.id}`, agent.name, agentBadge(agent, s), agentLines(agent, s), cardWidth)}
574 {kids.map(kid => (
575 <Box flexDirection="row">
576 <Text color="suggestion">╰─</Text>
577 {card(`c${kid.id}`, kid.name, agentBadge(kid, allStats[kid.id]), agentLines(kid, allStats[kid.id]), cardWidth - 2)}
578 </Box>
579 ))}
580 </Box>
581 )
582 }
583
584 const known = new Set(agentList.map(a => a.id))
585 const topAgents = agentList.filter(a => !a.parentId || !known.has(a.parentId)).sort(byStatus)
586 const running = agentList.filter(a => a.status === 'running' || a.status === 'pending').length
587 const waiting = agentList.filter(a => a.status === 'waiting').length
588
589 const mainStats = allStats[MAIN]
590 const mainTree = treeList.find(t => t.isCurrent) ?? treeList[0]
591 const mainLines: Line[] = [{ text: modelName(mainStats?.model) ?? 'this session' }]
592 mainLines.push(...activity(mainStats, true))
593 const sessionParts = [
594 sessionInfo.costUsd !== undefined && `${money(sessionInfo.costUsd)} session`,
595 sessionInfo.contextTokens !== undefined &&
596 `ctx ${tokens(sessionInfo.contextTokens)}${sessionInfo.contextPercent !== undefined ? ` (${sessionInfo.contextPercent}%)` : ''}`,
597 mainStats && `${mainStats.tools} tools`,
598 ].filter(Boolean)
599 if (sessionParts.length > 0) mainLines.push({ text: sessionParts.join(' · ') })
600 if (mainTree) mainLines.push({ text: `⑂ ${mainTree.branch}` })
601 if (mainTree?.pr) mainLines.push({ text: `⇡ PR ${prLabel(mainTree.pr)}`, color: prColor(mainTree.pr) })
602
603 // The session's own ledger counts its subagents; the estimate is the fallback.
604 const totalCost = sessionInfo.costUsd ?? Object.values(allStats).reduce((sum, s) => sum + s.costUsd, 0)
605
606 const byId = new Map(taskList.map(t => [t.id, t]))
607 const blockerOf = (task: Task) => task.blockedBy.find(id => byId.has(id))
608 // A task with several blockers hangs under the first one that exists.
609 const taskColumn = (task: Task): RenderChildren => {
610 const dependents = taskList.filter(t => blockerOf(t) === task.id)
611 const extra = task.blockedBy.filter(id => id !== blockerOf(task) && byId.has(id))
612 const lines: Line[] = [{ text: task.owner ?? 'unassigned' }]
613 if (extra.length > 0) lines.push({ text: `also waits on #${extra.join(', #')}` })
614 return (
615 <Box key={`t${task.id}`} flexDirection="column" width={cardWidth}>
616 {card(`k${task.id}`, `#${task.id} ${task.subject}`, taskBadge(task, byId), lines, cardWidth)}
617 {dependents.map(dep => (
618 <Box flexDirection="row">
619 <Text color="warning">╰─</Text>
620 {taskColumn(dep)}
621 </Box>
622 ))}
623 </Box>
624 )
625 }
626 const rootTasks = taskList.filter(t => !blockerOf(t))
627 const tasksRunning = taskList.filter(t => t.status === 'in_progress').length
628 const tasksBlocked = taskList.filter(t => isBlocked(t, byId)).length
629 const tasksDone = taskList.filter(t => t.status === 'completed').length
630
631 const shownTrees = treeList
632 .filter(t => t.dirty > 0 || t.branch !== 'detached')
633 .sort((a, b) => b.dirty - a.dirty || Number(Boolean(b.pr)) - Number(Boolean(a.pr)))
634 .slice(0, MAX_TREES)
635 const hiddenTrees = treeList.filter(t => !shownTrees.includes(t))
636 const hiddenDirty = hiddenTrees.filter(t => t.dirty > 0).length
637 const isHiddenDetached = hiddenTrees.every(t => t.branch === 'detached')
638 const hiddenNote = hiddenDirty > 0 ? `${hiddenDirty} with changes` : isHiddenDetached ? 'detached and clean' : 'clean'
639 const dirtyTrees = treeList.filter(t => t.dirty > 0).length
640 const openPrs = treeList.filter(t => t.pr?.state === 'OPEN').length
641 const root = commonDir(treeList.map(t => t.path))
642 const branchWidth = Math.min(30, Math.max(0, ...shownTrees.map(t => t.branch.length)))
643 const prWidth = Math.max(0, ...shownTrees.map(t => (t.pr ? prLabel(t.pr).length : 0)))
644
645 const header = (title: string, chips: [string, string][]): RenderChildren => (
646 <Box justifyContent="space-between">
647 <Text bold>
648 <Text color="claude">✦</Text> {title}
649 </Text>
650 <Text>
651 {chips.map(([text, color], i) => (
652 <Text color={color}>
653 {i > 0 ? ' ' : ''}
654 {text}
655 </Text>
656 ))}
657 </Text>
658 </Box>
659 )
660
661 return (
662 <Box flexDirection="column" gap={1}>
663 <Box flexDirection="column">
664 {header('Agents', [
665 [`${agentList.length} agents`, 'subtle'],
666 [`${running} running`, 'suggestion'],
667 ...(waiting > 0 ? [[`${waiting} needs you`, 'warning'] as [string, string]] : []),
668 [`~${money(totalCost)}`, 'text'],
669 ])}
670 {chart(
671 card('main', 'Main session', running > 0 ? AGENT_BADGE.running! : AGENT_BADGE.idle!, mainLines, cardWidth),
672 topAgents.map(agentColumn),
673 )}
674 </Box>
675
676 {taskList.length > 0 && (
677 <Box flexDirection="column">
678 {header('Tasks', [
679 [`${tasksDone}/${taskList.length} done`, 'subtle'],
680 [`${tasksRunning} running`, 'suggestion'],
681 [`${tasksBlocked} blocked`, 'warning'],
682 ])}
683 <Box flexDirection="row" flexWrap="wrap" columnGap={GAP} alignItems="flex-start">
684 {rootTasks.map(taskColumn)}
685 </Box>
686 </Box>
687 )}
688
689 <Box flexDirection="column">
690 {header('Worktrees', [
691 [`${treeList.length} total`, 'subtle'],
692 [`${dirtyTrees} with changes`, dirtyTrees > 0 ? 'warning' : 'success'],
693 [`${openPrs} open PRs`, 'suggestion'],
694 ])}
695 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
696 {shownTrees.map(tree => (
697 <Text key={tree.path} wrap="truncate-end">
698 <Text color={tree.dirty > 0 ? 'warning' : 'success'}>
699 {tree.dirty > 0 ? `● ${String(tree.dirty).padStart(2)}` : '○ '}
700 </Text>{' '}
701 <Text color={tree.branch === 'detached' ? 'subtle' : 'text'}>{tree.branch.padEnd(branchWidth)}</Text>
702 {prWidth > 0 && (
703 <Text color={tree.pr ? prColor(tree.pr) : 'subtle'}>
704 {' '}
705 {(tree.pr ? prLabel(tree.pr) : '').padEnd(prWidth)}
706 </Text>
707 )}
708 <Text dimColor> {relative(tree.path, root)}</Text>
709 </Text>
710 ))}
711 {hiddenTrees.length > 0 && (
712 <Text dimColor>
713 + {hiddenTrees.length} more, {hiddenNote}
714 </Text>
715 )}
716 </Box>
717 </Box>
718 </Box>
719 )
720 })
721}
722types/index.d.ts 55 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2export type Task = {
3 id: string
4 subject: string
5 status: TaskStatus
6 owner?: string
7 blockedBy: string[]
8}
9export type Agent = {
10 id: string
11 name: string
12 description: string
13 type: string
14 status: string
15 parentId?: string
16 branch?: string
17}
18export type PullRequest = {
19 number: number
20 url: string
21 state: string
22 isDraft: boolean
23 checks: 'passing' | 'failing' | 'pending' | 'none'
24}
25export type Worktree = { path: string; branch: string; dirty: number; isCurrent: boolean; pr?: PullRequest }
26// What one loop (the main session or one agent) has done since the mod loaded.
27export type AgentStats = {
28 model?: string
29 turns: number
30 tools: number
31 inputTokens: number
32 outputTokens: number
33 costUsd: number
34 contextTokens: number
35 totalMs: number
36 lastReason?: string
37 lastAnswer?: string
38 currentTool?: string
39 files: string[]
40 prUrl?: string
41}
42export type SessionInfo = { costUsd?: number; contextPercent?: number; contextTokens?: number }
43
44declare module 'claude-code' {
45 interface PluginState {
46 'work-graph': {
47 tasks: Task[]
48 agents: Agent[]
49 worktrees: Worktree[]
50 stats: Record<string, AgentStats>
51 session: SessionInfo
52 }
53 }
54}
55