SLOPSHOPPER

work-graph

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

newpaneguardcommandtoasttool
★ 1v0.3.0no licenseupdated 2026-10-23mariano-aguero/claude-work-graph
A shopper browsing a rack in a slop shop
Preview could not run: the mod kept running for 20s in the preview session (a hold loop or a long wait); no drawing was captured
README

work-graph

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.

The Work pane with demo data

What it shows

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:

  • status: running, starting, needs you, idle, done, error, refused, stopped. An idle teammate shows how its last turn ended.
  • model, agent type and the plugin the agent comes from (Opus 5.5 · poteto-agent · pstack)
  • what it is doing now (▸ Edit api.ts), or the first line of its last answer
  • estimated cost, tokens, context in use, time spent, tool calls and files edited
  • the pull request it opened with gh pr create

The 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.

Install

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.

Requirements

  • Claude Code with mod support (function hooks).
  • git for the worktree list.
  • gh, signed in, for pull requests. Without it the pane shows worktrees without PRs.

Notes

  • Per-agent cost is an estimate: tokens times the model's list price, cache writes at 1.25x input. The header total is the session's own figure.
  • Figures count from the moment the mod loads; earlier turns are not seen.
  • A teammate running in a terminal pane of its own is a separate process: its card shows its status, not its usage.
  • Pull requests are fetched once a minute with a single gh pr list --author @me.

Development

claude plugin validate .
claude plugin test .

To run a local copy, start a session with claude --plugin-dir <path to this folder>.

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