Live pane with the tree of the orchestrator and its subagents: model, effort, tokens, tools and duration

A Claude Code mod that opens an Agents pane beside the chat with a live tree of the main session (the orchestrator) and every subagent it spawns: model, effort, tokens (input, cache, output), tool calls, duration and the first line of each answer.
It costs no model tokens: the hooks run inside the app and the pane is drawn by the interface, so nothing is added to the prompt.

The same tree as a web page (sample data). Each card shows the agent's estimated cost, its rating once it ends and how full its context is; the dots on a wire run from parent to child while that agent is working.
Main session · opus-5-5 · high
48.2k in (41.0k cache) / 1.3k out
├─ [ok] Explore · haiku-5-5 · low
│ Find the auth middleware · 12.4k in (9.8k cache) / 0.6k out · 7 tools · 14.2s
│ The middleware lives in app/middleware/auth.ts and ...
└─ [running] general-purpose · sonnet-5-5 · medium
Review the migration · 3 tools
Subagents: 12.4k in / 0.6k out
git clone https://github.com/pedrowillen/claude-agent-tree.git C:\projects\claude-agent-tree
[Environment]::SetEnvironmentVariable('CLAUDE_CODE_PLUGIN_DIRS', 'C:\projects\claude-agent-tree', 'User')
On macOS/Linux, export CLAUDE_CODE_PLUGIN_DIRS=/path/to/claude-agent-tree in your shell profile (and make sure the desktop app inherits it).
/agent-tree to choose again.From the terminal CLI, you can load it for a single session instead:
claude --plugin-dir C:\projects\claude-agent-tree
in includes cache; the cached part is shown in parentheses).[running], [ok], [error] or [aborted], the subagent type, model and effort, the task description, tokens, tool calls and duration, then the first line of its answer (up to 200 characters).….The same tree is served as a live web page at http://127.0.0.1:47821/?t=<key>, in a monospaced font. The key is made by the server each time it starts; the pane, the status line and the Edge window carry the full link. Agents sit on one row per level, joined by wires; the wire of an agent that is working has dots running down it, from parent to child (they stop with prefers-reduced-motion). The mod starts a small Node server when the session starts and stops it with the session. If the port is in use it tries the next one, up to 47830, and the pane's last line says which address it took:
Web: http://127.0.0.1:47822/?t=3f9a1c07d2b4e856 (47821 in use)
Click an agent's card to open it: the list of its tool calls (the last 50, each as Tool: what it was given) and its answer (up to 4000 characters). A card flashes when its agent finishes (green), fails (red) or is aborted (yellow).
Each card also shows what the agent has cost so far and how full its context is, for example ~$0.092 · ok · ctx 126.0k / 1.0M (13%):
src/pricing.ts. Prices change, and a model that is not in that table (an older 4.x, for one) shows no cost, and a + after the cost (~$0.012+) means a request on such a model is missing from it; a plan that is not billed per token does not pay it. The cache write is priced at the 5-minute rate. Haiku 5.5's dearer tier above 100K prompt tokens is applied per request.+, is not rated). It judges what the whole run spent, not how well it worked: perfect up to $0.03, ok up to $0.15, fair up to $0.60, poor up to $2.00 and awful above that. The ceilings are the CEILINGS list in src/pricing.ts. The main session shows its cost but is not rated.The terminal pane shows the same cost, rating and context, and the footer adds up the subagents' cost.
When a subagent finishes, a toast (with its cost and rating) says so; a failed one also raises a system notification. An aborted one (usually your own Esc) only gets the toast.
The server always runs; what you pick decides what is shown. Web page shows the address as a toast and in the status line. Edge window opens the page as its own window through Edge's app mode (msedge --app=...: no tabs, no address bar, with the Agent Tree icon in the title bar and the taskbar); it needs Edge, which comes with Windows 11, so the option is offered on Windows only. If you dismiss the question or type something else, the pane opens.
If every port is taken the line reads Web: ports 47821-47830 in use; without Node on the PATH it reads Web: node not found. The pane works the same either way. Each session gets its own server, so a second session takes the next free port.
The page is plain HTML, CSS and JavaScript, follows the system's light or dark theme and updates through server-sent events. The server listens on 127.0.0.1 only and refuses requests whose Host is not 127.0.0.1 or localhost. The tree holds tool commands and answers, so /events and /state also need the key (403 without it): another program on the machine, or a website, cannot read the tree or replace it. The page and its icon hold no data and are served without it. The mod sends the tree at most about ten times a second, and always the latest one.
A mod is a Claude Code plugin whose hooks module runs inside the app. agent-tree listens to the engine's events, keeps the tree in the session state ($.state, key agent-tree.tree) and redraws the pane whenever that state changes.
| Event | What the mod does |
|---|---|
session.start | Starts the page's server (kept up until the session ends), asks how to see the agents and registers /agent-tree |
command.run (agent-tree) | Asks again |
turn.start | On a new user prompt, marks the tree as stale |
agent.spawn | Adds a subagent node (clearing finished ones if stale) |
turn.step | Records the real model and effort of each request, and from the usage that ends it adds the request's estimated cost and sets the context fill |
tool.call | Counts a subagent's tool calls |
turn.complete | Adds token usage; for a subagent, sets status, duration and answer summary |
ui.render (Pane) | Draws the tree with Box and Text |
The hooks on the engine's events call next(e) and change nothing, so the mod only observes: it never changes, blocks or delays what the engine does. Only the mod's own /agent-tree command and the drawing of its pane answer for themselves.
The page's server runs through $.process.spawn (node -, its source on stdin) and lives as long as the module. It prints BUSY <port>, LISTEN <port> <key> or FAILED <port> <code>, which the mod reads into the state (agent-tree.web). After each change to the tree the mod sends it to the server with $.http.fetch (POST /state), and the server streams it to the open pages (GET /events).
.claude-plugin/plugin.json manifest (name, version, description, types)
hooks/hooks.json points the engine at the hooks module
hooks/register.tsx entry: composes the three features below
src/session.ts session start (server, question, command) and the command that asks again
src/view.ts asks how to see the agents and shows the page the way the user chose
src/choice.ts the question's options and how its answer is read
src/tree.ts keeps the tree of agents up to date from the loop's events
src/pane.tsx draws the terminal pane
src/state.ts shared constants and initial values
src/format.ts number and text formatters
src/pricing.ts list prices, context windows, cost of a request and the rating of a cost
src/web/serve.ts runs the page's server and pushes the tree to it
src/web/window.ts opens the page as an Edge app window
src/web/icon.ts the page's icon (PNG, base64)
src/web/server.ts the Node server's source and its ports
src/web/page.ts the web page (HTML, CSS, JS) as a string
types/index.d.ts type contract of the state (AgentNode, Tree, Web, View)
tests/pane.test.tsx tests with the claude-code/testing kit
tsconfig.json extends the types the engine generates
.claude-plugin/types/ is generated by the engine when the mod loads and is git-ignored.
Two rules of the engine's scan shape this layout: a function that receives $ from another file must be a function declaration, and an atom must be declared (atom({ plugin, key }, initial)) in each file that reads or writes it.
claude plugin validate .
claude plugin test .
tsc -p .
tsc needs .claude-plugin/types/, which exists once the mod has been loaded at least once. While the folder is loaded through CLAUDE_CODE_PLUGIN_DIRS or --plugin-dir, edits reload the mod at the end of the turn.
Issues and pull requests are welcome: see CONTRIBUTING.md for how to run your clone, what to check and the commit style.
hooks/register.tsx 14 lines1import type { Register } from 'claude-code'
2
3import { registerPane } from '../src/pane'
4import { registerSession } from '../src/session'
5import { registerTree } from '../src/tree'
6
7export { webLines } from '../src/pane'
8
9export const register: Register = (on, options) => {
10 registerSession(on, options)
11 registerTree(on, options)
12 registerPane(on, options)
13}
14src/pane.tsx 86 lines1import { atom, read } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AgentNode, AgentStatus, Web } from '../types'
5import { contextText, costText, head, k, pageUrl, secs, tokens, usd } from './format'
6import { INITIAL_TREE, INITIAL_WEB } from './state'
7import { FIRST_PORT, PORT_TRIES } from './web/server'
8
9const PANE = 'agent-tree' // a literal here, for the scan; the same id as state's PANE
10
11const tree = atom({ plugin: 'agent-tree', key: 'tree' } as const, INITIAL_TREE)
12const web = atom({ plugin: 'agent-tree', key: 'web' } as const, INITIAL_WEB)
13
14const NARROW = 70 // below this many columns each detail gets its own line
15const LAST_PORT = FIRST_PORT + PORT_TRIES - 1
16const LABEL: Record<AgentStatus, string> = { running: 'running', done: 'ok', error: 'error', aborted: 'aborted' }
17
18// the pane's lines about the page: its address, or why there is none
19export const webLines = (w: Web, isNarrow: boolean): string[] => {
20 if (w.status === 'starting') return ['Web: starting…']
21 if (w.status === 'failed') {
22 return [w.error === 'EADDRINUSE' ? `Web: ports ${FIRST_PORT}-${LAST_PORT} in use` : `Web: ${w.error}`]
23 }
24 const url = `Web: ${pageUrl(w.port, w.token)}`
25 if (w.busy.length === 0) return [url]
26 const note = `${w.busy.join(', ')} in use`
27
28 return isNarrow ? [url, ` ${note}`] : [`${url} (${note})`]
29}
30
31// the lines of a node: joined on a wide pane, one detail per line on a narrow one
32const detailLines = (a: AgentNode, isNarrow: boolean): string[] => {
33 const toolCount = `${a.tools} tool${a.tools === 1 ? '' : 's'}`
34 const usage = a.status === 'running' ? [toolCount] : [tokens(a), `${toolCount} · ${secs(a.durationMs)}`]
35 const spend = [costText(a), contextText(a)].filter(s => s !== '')
36 const lines = isNarrow
37 ? [`[${LABEL[a.status]}] ${a.type}`, head(a), a.description, ...usage, ...spend]
38 : [`[${LABEL[a.status]}] ${a.type} · ${head(a)}`, [a.description, ...usage].join(' · '), ...(spend.length > 0 ? [spend.join(' · ')] : [])]
39
40 return a.summary === '' ? lines : [...lines, a.summary]
41}
42
43// draws the pane: the orchestrator, its subagents and the page's address
44export const registerPane: Register = on => {
45 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
46 const { Box, Text } = $.ui.resolve(e)
47 const { main, agents } = await read($, tree)
48 const w = await read($, web)
49 const cols = e.props.bodyColumns
50 const isNarrow = cols < NARROW
51 // cuts a line to the pane width, so it never wraps
52 const fit = (s: string) => (s.length > cols ? `${s.slice(0, Math.max(1, cols - 1))}…` : s)
53 const sumIn = agents.reduce((s, a) => s + a.tokensIn, 0)
54 const sumOut = agents.reduce((s, a) => s + a.tokensOut, 0)
55 const sumCost = agents.reduce((s, a) => s + (a.costUsd ?? 0), 0)
56 const mainSpend = [costText(main), contextText(main)].filter(s => s !== '').join(' · ')
57
58 return (
59 <Box flexDirection="column">
60 <Text mono weight="bold">{fit(isNarrow ? 'Main session' : `Main session · ${head(main)}`)}</Text>
61 {isNarrow && <Text mono dimColor>{fit(` ${head(main)}`)}</Text>}
62 <Text mono dimColor>{fit(` ${tokens(main)}`)}</Text>
63 {mainSpend !== '' && <Text mono dimColor>{fit(` ${mainSpend}`)}</Text>}
64 {agents.length === 0 && <Text mono dimColor>{fit('└─ no subagents this round')}</Text>}
65 {agents.map((a, i) => {
66 const isLast = i === agents.length - 1
67 const [first = '', ...rest] = detailLines(a, isNarrow)
68
69 return (
70 <Box flexDirection="column">
71 <Text mono color={a.status === 'error' ? 'error' : undefined} dimColor={a.status === 'done'}>
72 {fit(`${isLast ? '└─' : '├─'} ${first}`)}
73 </Text>
74 {rest.map(line => <Text mono dimColor>{fit(`${isLast ? ' ' : '│ '} ${line}`)}</Text>)}
75 </Box>
76 )
77 })}
78 {agents.length > 0 && <Text mono dimColor>{fit(`Subagents: ${k(sumIn)} in / ${k(sumOut)} out${sumCost > 0 ? ` · ~${usd(sumCost)}` : ''}`)}</Text>}
79 {webLines(w, isNarrow).map(line => (
80 <Text mono dimColor={w.status !== 'failed'} color={w.status === 'failed' ? 'warning' : undefined}>{fit(line)}</Text>
81 ))}
82 </Box>
83 )
84 })
85}
86src/session.ts 23 lines1import type { Register } from 'claude-code'
2
3import { choose } from './view'
4import { serve } from './web/serve'
5
6// the session's start (server, question, command) and the command that asks again
7export const registerSession: Register = on => {
8 on('session.start', async ($, e, next) => {
9 // the server lives as long as the session: its child ends with the module
10 void serve($)
11 void choose($)
12 await $.command.register({ name: 'agent-tree', description: 'Choose how to see the agent tree' })
13
14 return next(e)
15 })
16
17 on('command.run', { command: 'agent-tree' }, async $ => {
18 void choose($)
19
20 return { text: 'Choose how to see the agents.' }
21 })
22}
23src/tree.ts 139 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnUsage } from 'claude-code'
3
4import type { AgentNode, AgentStatus, Tree } from '../types'
5import { callLabel, costText } from './format'
6import { costOf, rate, windowOf } from './pricing'
7import { INITIAL_TREE, MAX_AGENTS, MAX_ANSWER, MAX_CALLS, node } from './state'
8import { push } from './web/serve'
9
10const tree = atom({ plugin: 'agent-tree', key: 'tree' } as const, INITIAL_TREE)
11
12const STATUS: Record<string, AgentStatus> = { answer: 'done', aborted: 'aborted', error: 'error', refusal: 'error' }
13
14const save = async ($: EngineInterface, fn: (t: Tree) => Tree) => {
15 await update($, tree, fn)
16 void push($)
17}
18
19// applies fn to the node of the loop (a subagent by id, or the main one)
20const patch = (t: Tree, agentId: string | undefined, fn: (n: AgentNode) => AgentNode): Tree =>
21 agentId === undefined
22 ? { ...t, main: fn(t.main) }
23 : { ...t, agents: t.agents.map(a => (a.id === agentId ? fn(a) : a)) }
24
25const addUsage = (n: AgentNode, u?: TurnUsage): AgentNode => !u ? n : {
26 ...n,
27 tokensIn: n.tokensIn + u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens,
28 cacheRead: n.cacheRead + u.cache_read_input_tokens,
29 tokensOut: n.tokensOut + u.output_tokens,
30}
31
32// one request: how much its prompt filled the context, and what it cost (nothing, if the model has no price)
33const addRequest = (n: AgentNode, u: TurnUsage): AgentNode => {
34 const cost = costOf(u.model, u)
35
36 return {
37 ...n,
38 contextTokens: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens,
39 contextWindow: windowOf(u.model) ?? n.contextWindow,
40 costUsd: cost === undefined ? n.costUsd : (n.costUsd ?? 0) + cost,
41 // a request on a model with no price leaves the total short: it is shown as such, and not rated
42 isCostPartial: n.isCostPartial === true || cost === undefined || undefined,
43 }
44}
45
46// keeps the tree of the orchestrator and its subagents up to date from the loop's events
47export const registerTree: Register = on => {
48 // a new user prompt: mark the tree to be cleared on the next spawn
49 on('turn.start', async ($, e, next) => {
50 if (e.agentId === undefined && e.text !== '') await update($, tree, t => ({ ...t, isStale: true }))
51
52 return next(e)
53 })
54
55 on('agent.spawn', async ($, e, next) => {
56 const spawned = await next(e)
57 if (spawned.agentId === undefined) return spawned
58
59 const added = { ...node(spawned.agentId, e.subagentType, e.description, spawned.model), parentId: e.parentAgentId }
60 await save($, t => {
61 const kept = t.isStale ? t.agents.filter(a => a.status === 'running') : t.agents
62
63 return { ...t, isStale: false, agents: [...kept, added].slice(-MAX_AGENTS) }
64 })
65
66 return spawned
67 })
68
69 // the real model and effort of each request, and what each request cost and filled
70 on('turn.step', async function* ($, e, next) {
71 await save($, t => patch(t, e.agentId, n => ({ ...n, model: e.model, effort: e.effort?.toString() })))
72
73 // the chunks pass through unchanged; the one that stops the request carries its usage
74 const stream = next(e)
75 try {
76 for (;;) {
77 const step = await stream.next()
78 if (step.done) return step.value
79
80 const chunk = step.value
81 if (chunk.kind === 'stop' && chunk.usage) {
82 const usage = chunk.usage
83 await save($, t => patch(t, e.agentId, n => addRequest(n, usage)))
84 }
85 yield chunk
86 }
87 } finally {
88 // what `yield*` did on its own: an interrupted request closes the stream beneath
89 await stream.return(undefined as never)
90 }
91 })
92
93 on('tool.call', async ($, e, next) => {
94 // SubagentHandback is how a subagent hands its report back: not work worth listing
95 if (e.agentId !== undefined && e.tool !== 'SubagentHandback') {
96 const call = callLabel({ ...e })
97 await save($, t => patch(t, e.agentId, n => ({ ...n, tools: n.tools + 1, calls: [...n.calls, call].slice(-MAX_CALLS) })))
98 }
99
100 return next(e)
101 })
102
103 on('turn.complete', async ($, e, next) => {
104 const done = await next(e)
105 await save($, t => patch(t, e.agentId, n => {
106 const used = addUsage(n, e.usage)
107 if (e.agentId === undefined) return used
108
109 return {
110 ...used,
111 status: STATUS[e.reason] ?? 'done',
112 durationMs: n.durationMs + e.durationMs,
113 summary: (e.answer.trim().split('\n')[0] ?? '').slice(0, 200),
114 answer: e.answer.trim().slice(0, MAX_ANSWER),
115 // a verdict on a run that ended with its answer and whose whole cost is known: a run that was
116 // stopped or failed would read as praise for doing nothing
117 rating: used.costUsd === undefined || used.isCostPartial || STATUS[e.reason] !== 'done' ? undefined : rate(used.costUsd),
118 }
119 }))
120
121 // a subagent finished: say so, and a failed or aborted one louder
122 if (e.agentId !== undefined) {
123 const agent = (await read($, tree)).agents.find(a => a.id === e.agentId)
124 if (agent !== undefined) {
125 const cost = costText(agent)
126 const what = `${agent.type}: ${agent.description}${cost === '' ? '' : ` · ${cost}`}`
127 if (agent.status === 'done') $.ui.toast(`Agent finished · ${what}`)
128 else {
129 $.ui.toast(`Agent ${agent.status === 'aborted' ? 'aborted' : 'failed'} · ${what}`)
130 // an abort is usually the user's own doing (Esc stops every agent): only a failure notifies
131 if (agent.status === 'error') void $.ui.notify(what, { title: 'Agent failed' }).catch(() => undefined)
132 }
133 }
134 }
135
136 return done
137 })
138}
139src/format.ts 41 lines1import type { AgentNode } from '../types'
2
3const DETAIL_KEYS = ['file_path', 'command', 'pattern', 'path', 'url', 'query', 'description', 'prompt']
4
5// a tool call as one line: the tool and the first thing it was given (`Grep: useState`)
6export const callLabel = (call: { tool: string; [input: string]: unknown }): string => {
7 const key = DETAIL_KEYS.find(name => typeof call[name] === 'string')
8 const detail = key === undefined ? '' : String(call[key]).replace(/\s+/g, ' ').trim().slice(0, 80)
9
10 return detail === '' ? call.tool : `${call.tool}: ${detail}`
11}
12
13// an estimated cost in USD: more digits for the cents a small agent costs (the limits sit just
14// under 0.01 and 1, so a cost that rounds up to them never shows as `$0.0100` or `$1.000`)
15export const usd = (cost: number) => `$${cost < 0.00995 ? cost.toFixed(4) : cost < 0.9995 ? cost.toFixed(3) : cost.toFixed(2)}`
16
17// `~$0.0123 · ok`: the estimated cost, and the verdict once a subagent has ended; '' with no price
18// (a `+` after the cost: a request on a model with no price is not in it)
19export const costText = (n: AgentNode): string =>
20 n.costUsd === undefined ? '' : `~${usd(n.costUsd)}${n.isCostPartial ? '+' : ''}${n.rating === undefined ? '' : ` · ${n.rating}`}`
21
22// `ctx 126.0k / 1.0M (13%)`: what the last request's prompt held, against the window the mod assumes
23// for the model (shown, so a smaller real window is not hidden); '' before any request
24export const contextText = (n: AgentNode): string => {
25 if (n.contextTokens === 0) return ''
26 const share = n.contextWindow > 0
27 ? ` / ${k(n.contextWindow)} (${Math.max(1, Math.round((n.contextTokens / n.contextWindow) * 100))}%)`
28 : ''
29
30 return `ctx ${k(n.contextTokens)}${share}`
31}
32
33// where the page is, with the key its data needs
34export const pageUrl = (port: number, token: string) => `http://127.0.0.1:${port}/?t=${token}`
35
36export const k = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
37export const secs = (ms: number) => `${(ms / 1000).toFixed(1)}s`
38export const short = (model: string) => model.replace(/^claude-/, '')
39export const tokens = (n: AgentNode) => `${k(n.tokensIn)} in (${k(n.cacheRead)} cache) / ${k(n.tokensOut)} out`
40export const head = (n: AgentNode) => [short(n.model) || '?', n.effort].filter(Boolean).join(' · ')
41src/state.ts 19 lines1import type { AgentNode, Tree, View, Web } from '../types'
2
3// The engine's scan reads an atom's reference only where `read`/`update` use it, so each file
4// declares its own `atom({ plugin: 'agent-tree', key }, ...)`; what they share lives here.
5
6export const PANE = 'agent-tree'
7export const MAX_AGENTS = 20
8export const MAX_CALLS = 50 // tool calls kept per agent
9export const MAX_ANSWER = 4000 // characters of an agent's answer kept
10
11export const node = (id: string, type: string, description: string, model: string): AgentNode => ({
12 id, type, description, model,
13 status: 'running', tokensIn: 0, cacheRead: 0, tokensOut: 0, tools: 0, contextTokens: 0, contextWindow: 0, calls: [], durationMs: 0, summary: '', answer: '',
14})
15
16export const INITIAL_TREE: Tree = { main: node('main', 'Main session', '', ''), agents: [], isStale: false }
17export const INITIAL_WEB: Web = { status: 'starting', port: 0, token: '', busy: [], error: '' }
18export const INITIAL_VIEW: View = { chosen: false, pane: false, web: false, window: false, isShown: false }
19src/web/server.ts 61 lines1// The local page's server: a Node script with no dependencies, handed to `node -` on
2// stdin, so the mod needs no path to its own folder. It prints one line per event the
3// mod reads: `BUSY <port>`, `LISTEN <port> <key>` or `FAILED <port> <code>`.
4// POST /state takes the tree as JSON; GET /events streams it (server-sent events); any
5// other GET answers the page. Only 127.0.0.1 is bound, a foreign Host is refused, and
6// /state and /events need the key the server made when it started (`?t=<key>`): the tree
7// holds commands and answers, so another local process or a website must not read or
8// replace it. The page itself holds no data, so it is served without the key.
9
10export const FIRST_PORT = 47821
11export const PORT_TRIES = 10
12
13export const serverSource = (page: string, icon: string): string => String.raw`
14const http = require('http');
15const crypto = require('crypto');
16const FIRST = ${FIRST_PORT}, LAST = ${FIRST_PORT + PORT_TRIES - 1};
17const PAGE = ${JSON.stringify(page)};
18const ICON = ${JSON.stringify(icon)};
19const KEY = crypto.randomBytes(8).toString('hex');
20let state = 'null';
21const clients = new Set();
22
23const server = http.createServer((req, res) => {
24 if (!/^(127\.0\.0\.1|localhost):\d+$/.test(req.headers.host || '')) { res.writeHead(403); res.end(); return; }
25 const url = new URL(req.url, 'http://127.0.0.1');
26 if (url.pathname === '/favicon.png') { res.writeHead(200, { 'content-type': 'image/png' }); res.end(Buffer.from(ICON, 'base64')); return; }
27 const isData = url.pathname === '/state' || url.pathname === '/events';
28 if (isData && url.searchParams.get('t') !== KEY) { res.writeHead(403); res.end(); return; }
29 if (req.method === 'POST' && url.pathname === '/state') {
30 let body = '';
31 req.on('data', c => { body += c; if (body.length > 1e6) req.destroy(); });
32 req.on('end', () => {
33 state = body;
34 for (const c of clients) c.write('data: ' + state + '\n\n');
35 res.end();
36 });
37 return;
38 }
39 if (url.pathname === '/events') {
40 res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
41 res.write('data: ' + state + '\n\n');
42 clients.add(res);
43 req.on('close', () => clients.delete(res));
44 return;
45 }
46 res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
47 res.end(PAGE);
48});
49
50let port = FIRST;
51server.on('error', err => {
52 if (err.code !== 'EADDRINUSE') { console.log('FAILED ' + port + ' ' + err.code); process.exit(1); }
53 console.log('BUSY ' + port);
54 if (port === LAST) { console.log('FAILED ' + port + ' EADDRINUSE'); process.exit(1); }
55 port++;
56 server.listen(port, '127.0.0.1');
57});
58server.on('listening', () => console.log('LISTEN ' + port + ' ' + KEY));
59server.listen(port, '127.0.0.1');
60`
61src/view.ts 45 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import { optionsFor, parseChoice, QUESTION } from './choice'
5import { pageUrl } from './format'
6import { INITIAL_VIEW, INITIAL_WEB, PANE } from './state'
7import { openWindow } from './web/window'
8
9const web = atom({ plugin: 'agent-tree', key: 'web' } as const, INITIAL_WEB)
10const view = atom({ plugin: 'agent-tree', key: 'view' } as const, INITIAL_VIEW)
11
12async function isWindows($: EngineInterface) {
13 const run = await $.process.run(['cmd', '/c', 'ver']).catch(() => undefined)
14
15 return run?.exitCode === 0
16}
17
18// Shows the page the way the user chose, once both the choice and the server's port are known:
19// whichever comes last calls it, and it acts once. (Function declarations: the engine follows
20// `$` across files only into those.)
21export async function reveal($: EngineInterface) {
22 const w = await read($, web)
23 const v = await read($, view)
24 if (!v.chosen || v.isShown || w.status !== 'listening') return
25
26 await update($, view, x => ({ ...x, isShown: true }))
27 const url = pageUrl(w.port, w.token)
28 if (v.web) {
29 $.ui.toast(`Agent Tree: ${url}`)
30 $.ui.status(`Agent Tree: ${url}`)
31 }
32 if (v.window) await openWindow($, url)
33}
34
35// asks how to see the agents, in the engine's own question dialog, and applies the answer
36export async function choose($: EngineInterface) {
37 const options = optionsFor(await isWindows($))
38 const answer = await $.ui.ask(QUESTION, { options, header: 'Agent Tree', multiSelect: true }).catch(() => '')
39 const picked = parseChoice(answer)
40
41 await update($, view, () => ({ ...picked, chosen: true, isShown: false }))
42 if (picked.pane) await $.ui.open({ id: PANE, title: 'Agents' })
43 await reveal($)
44}
45src/web/serve.ts 66 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import { INITIAL_TREE, INITIAL_WEB } from '../state'
5import { reveal } from '../view'
6import { ICON } from './icon'
7import { PAGE } from './page'
8import { serverSource } from './server'
9
10const tree = atom({ plugin: 'agent-tree', key: 'tree' } as const, INITIAL_TREE)
11const web = atom({ plugin: 'agent-tree', key: 'web' } as const, INITIAL_WEB)
12
13const PUSH_GAP_MS = 100 // at most about ten sends a second, however many events come
14
15// one send at a time: a burst of events (parallel agents) is folded into the next send, and
16// what is sent is always the latest tree, so a slow request cannot land after a newer one
17let isSending = false
18let isDirty = false
19
20// sends the tree to the local page, when its server listens
21// (function declarations: the engine follows `$` across files only into those)
22export async function push($: EngineInterface) {
23 isDirty = true
24 if (isSending) return
25 isSending = true
26 try {
27 while (isDirty) {
28 isDirty = false
29 const w = await read($, web)
30 if (w.status !== 'listening') return
31 const body = JSON.stringify(await read($, tree))
32 await $.http.fetch(`http://127.0.0.1:${w.port}/state?t=${w.token}`, { method: 'POST', body }).catch(() => undefined)
33 await $.clock.sleep(PUSH_GAP_MS).catch(() => undefined)
34 }
35 } finally {
36 isSending = false
37 }
38}
39
40// runs the page's server for the module's life, reading the lines it prints
41export async function serve($: EngineInterface) {
42 await update($, web, () => INITIAL_WEB)
43 try {
44 let rest = ''
45 for await (const { stream, text } of $.process.spawn({ argv: ['node', '-'], input: serverSource(PAGE, ICON) })) {
46 if (stream !== 'stdout') continue
47 const lines = (rest + text).split('\n')
48 rest = lines.pop() ?? ''
49 for (const line of lines) {
50 // BUSY <port>, LISTEN <port> <key>, FAILED <port> <code>
51 const [word = '', port = '', code = ''] = line.trim().split(' ')
52 if (word === 'BUSY') await update($, web, w => ({ ...w, busy: [...w.busy, Number(port)] }))
53 if (word === 'FAILED') await update($, web, w => ({ ...w, status: 'failed', error: code }))
54 if (word === 'LISTEN') {
55 await update($, web, w => ({ ...w, status: 'listening', port: Number(port), token: code }))
56 await push($)
57 await reveal($)
58 }
59 }
60 }
61 await update($, web, w => (w.status === 'failed' ? w : { ...w, status: 'failed', error: 'server stopped' }))
62 } catch {
63 await update($, web, w => ({ ...w, status: 'failed', error: 'node not found' })).catch(() => undefined)
64 }
65}
66src/pricing.ts 76 lines1import type { CostRating } from '../types'
2
3type Usage = {
4 input_tokens: number
5 output_tokens: number
6 cache_read_input_tokens: number
7 cache_creation_input_tokens: number
8}
9
10// A price row: USD per million tokens, and the model's context window in tokens. `cacheRead` is
11// stated where it is not the usual tenth of the input price; `over` is a dearer tier for a
12// prompt longer than `tokens`.
13type Price = { in: number; out: number; window: number; cacheRead?: number; over?: { tokens: number; in: number; out: number } }
14
15const M = 1_000_000
16
17// List prices of Anthropic's own API (the claude-api reference, cached 2026-10-06). They change:
18// the cost the mod shows is an estimate, and a model missing here shows no cost at all.
19// A model id matches the longest key it contains, so `claude-opus-5-5` wins over `claude-opus-5`.
20const PRICES: Record<string, Price> = {
21 'claude-fable-5': { in: 10, out: 50, window: M, cacheRead: 0.25 },
22 'claude-mythos-5': { in: 10, out: 50, window: M, cacheRead: 0.25 },
23 'claude-opus-5': { in: 5, out: 25, window: M },
24 'claude-opus-5-5': { in: 4, out: 20, window: M, cacheRead: 0.2 },
25 // only the 4.x models whose price is confirmed: an older one (4, 4.1, 4.5) shows no cost rather than a wrong one
26 'claude-opus-4-6': { in: 5, out: 25, window: M },
27 'claude-opus-4-7': { in: 5, out: 25, window: M },
28 'claude-opus-4-8': { in: 5, out: 25, window: M },
29 'claude-sonnet-5': { in: 2, out: 10, window: M },
30 'claude-sonnet-5-5': { in: 2, out: 10, window: M, cacheRead: 0.2 },
31 'claude-sonnet-4-6': { in: 3, out: 15, window: M },
32 'claude-haiku-5-5': { in: 0.1, out: 0.5, window: M, over: { tokens: 100_000, in: 0.5, out: 2.5 } },
33 'claude-haiku-4-5': { in: 1, out: 5, window: 200_000 },
34}
35
36const WRITE_FACTOR = 1.25 // writing the prompt cache (5-minute) costs this much times the input price
37const READ_FACTOR = 0.1 // reading it costs this much, unless the row says otherwise
38
39const KEYS = Object.keys(PRICES).sort((a, b) => b.length - a.length) // longest first, once
40
41const priceOf = (model: string): Price | undefined => {
42 const key = KEYS.find(name => model.includes(name))
43
44 return key === undefined ? undefined : PRICES[key]
45}
46
47export const windowOf = (model: string): number | undefined => priceOf(model)?.window
48
49// what one request cost, in USD; undefined for a model with no price row
50export const costOf = (model: string, usage: Usage): number | undefined => {
51 const price = priceOf(model)
52 if (price === undefined) return undefined
53
54 const prompt = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
55 const tier = price.over !== undefined && prompt > price.over.tokens ? price.over : price
56 const read = (price.cacheRead ?? price.in * READ_FACTOR) * (tier.in / price.in)
57
58 return (
59 usage.input_tokens * tier.in +
60 usage.cache_creation_input_tokens * tier.in * WRITE_FACTOR +
61 usage.cache_read_input_tokens * read +
62 usage.output_tokens * tier.out
63 ) / M
64}
65
66// A verdict on what a subagent spent, not on how well it worked: the estimated cost of its whole
67// run against these ceilings, in USD. Change them here to suit the budget.
68const CEILINGS: readonly [CostRating, number][] = [
69 ['perfect', 0.03],
70 ['ok', 0.15],
71 ['fair', 0.6],
72 ['poor', 2],
73]
74
75export const rate = (costUsd: number): CostRating => CEILINGS.find(([, ceiling]) => costUsd <= ceiling)?.[0] ?? 'awful'
76src/choice.ts 23 lines1import type { View } from '../types'
2
3export const QUESTION = 'How do you want to see the agent orchestration?'
4
5export const LABEL = { pane: 'Agents pane', web: 'Web page', window: 'Edge window' } as const
6
7// the window needs Edge, so it is offered on Windows only
8export const optionsFor = (isWindows: boolean): string[] =>
9 isWindows ? [LABEL.pane, LABEL.web, LABEL.window] : [LABEL.pane, LABEL.web]
10
11// the answer is the labels chosen, comma-joined; anything else (free text, a dismissed
12// dialog) falls back to the pane, so the user always sees something
13export const parseChoice = (answer: string): Pick<View, 'pane' | 'web' | 'window'> => {
14 const picked = answer.split(',').map(s => s.trim())
15 const view = {
16 pane: picked.includes(LABEL.pane),
17 web: picked.includes(LABEL.web),
18 window: picked.includes(LABEL.window),
19 }
20
21 return view.pane || view.web || view.window ? view : { pane: true, web: false, window: false }
22}
23src/web/window.ts 9 lines1import type { EngineInterface } from 'claude-code'
2
3// Opens the page as its own window (Edge's app mode: no tabs, no address bar).
4// A function declaration: the engine follows `$` across files only into those.
5export async function openWindow($: EngineInterface, url: string) {
6 // `start` finds Edge through Windows' app paths, so no install path is needed
7 await $.process.run(['cmd', '/c', 'start', '', 'msedge', `--app=${url}`]).catch(() => undefined)
8}
9