Writes each session's subagents, activity, context fill and subscription usage to ~/.claude/agent-board for the Agent Board VS Code extension, and draws a…

<h1 align="center">Agent Board for Claude Code</h1>
<b>See what every Claude Code subagent is doing, right inside VS Code.</b><br> Live subagent cards, full task details, subscription usage and context fill, across all your open conversations.
<img alt="version 0.5.1" src="https://img.shields.io/badge/version-0.5.1-2a78d6"> <img alt="MIT license" src="https://img.shields.io/badge/license-MIT-199e70"> <img alt="Claude Code 2.1.289 or later" src="https://img.shields.io/badge/Claude%20Code-2.1.289%2B-d97757"> <img alt="VS Code 1.90 or later" src="https://img.shields.io/badge/VS%20Code-1.90%2B-007acc"> <img alt="Interface in Traditional Chinese" src="https://img.shields.io/badge/UI-%E7%B9%81%E9%AB%94%E4%B8%AD%E6%96%87-8a8a8a">
<table> <tr> <td width="33%" valign="top"><img src="docs/images/board-dark.png" alt="The board: usage meters, conversations that need you, running subagents"></td> <td width="33%" valign="top"><img src="docs/images/detail-running.png" alt="A running subagent's detail: what it does now, its task and its steps"></td> <td width="33%" valign="top"><img src="docs/images/detail-done.png" alt="An ended subagent's detail in the light theme, with its report"></td> </tr> <tr> <td valign="top"><sub><b>The board.</b> Usage, the conversations waiting for you, every running subagent.</sub></td> <td valign="top"><sub><b>A subagent mid-run.</b> What it does this second, the task it was given, each step with its input and result.</sub></td> <td valign="top"><sub><b>The same subagent, ended.</b> Its report, rendered. (Light theme.)</sub></td> </tr> </table>
<sub>All screenshots use made-up data.</sub>
Claude Code can run several subagents at once, and several conversations at once. In VS Code it is hard to see what each of them is doing.
Claude Code mods (plugins made of function hooks) can draw panes and bars, but VS Code's Claude Code extension does not draw them (checked up to 2.1.289). Its webview takes no mod interface, so a mod's pane or its bar above the prompt never shows there. They show in the terminal and in the desktop app only.
Agent Board gets around this by splitting the job in two:
| Half | What it is | What it does |
|---|---|---|
| The mod | A Claude Code plugin, loaded into every session | Watches the session and writes small JSON files to ~/.claude/agent-board/ |
| The extension | A VS Code extension with its own sidebar view | Reads those files once a second and draws the board |
A live board of subagents. One card per running subagent: its state, type, step count, the tool call it is on, and the time it has run. Ended subagents keep a row with their token breakdown. A subagent with no tool call for two minutes is flagged as quiet, and one whose session stopped reporting as possibly lost.
Task details, like the built-in agent card. Click a card or row to open that subagent's detail:
在編輯區開啟 opens the same detail wide in an editor tab. A workflow's agents show their real labels and phases.
A "needs you" section. Conversations waiting for a permission or an answer, subagents gone quiet, and recent failures are grouped at the top. The status bar turns to "在等你" while a conversation waits.
Subscription usage that keeps up. The 5-hour and weekly windows of your Claude plan, with per-model buckets and extra usage when you have them. Resets are worded as claude.ai's usage page words them. The status bar always shows 5h % · 週 %.
The right conversation's context. Each conversation's context fill, computed the way /context computes it. The meter follows the Claude tab you are looking at, and the status bar shows it too.
Notifications when a conversation has waited 15 seconds, a subagent ends or fails, a long turn finishes, or usage crosses 80% or 90%. Each kind has its own switch.
flowchart LR
subgraph session["Each Claude Code session"]
mod["agent-board mod<br/>(function hooks)"]
end
api["api.anthropic.com<br/>/api/oauth/usage"]
board[("~/.claude/agent-board/<br/>sessions/<id>.json<br/>plan.json")]
transcripts[("~/.claude/projects/…<br/>subagent transcripts<br/>workflow run files")]
ext["Agent Board<br/>VS Code extension"]
ui["Sidebar board<br/>Detail panel<br/>Status bar<br/>Notifications"]
mod -- "a snapshot, at most once a second" --> board
api -. "one shared read every 30 s" .-> mod
board -- "read every second" --> ext
transcripts -- "read while a detail is open" --> ext
ext --> ui
sessions/<sessionId>.json whenever something changed, at most once a second, and once a minute as a heartbeat. The file holds the subagent rows, the session's activity (working, waiting, idle), its context fill and its rate-limit readings./usage reads is exact, so one session reads it for all of them every 30 seconds, through plan.json. Use within a window only grows, so the board shows the highest reading of the window in force. After a reset or a change of account, the earlier window's readings fall away at once.code command on your PATH.Developed and tested on Windows 11. The paths are resolved for macOS and Linux as well, but those are untested.
Paste this into Claude Code on the machine you want it on:
Install Agent Board from https://github.com/JeremyHo1123/claude-code-agent-board.
Follow the "Install" section of its README step by step, then run every check
under "Verify" and tell me the result of each one.
1. Clone the repository into Claude Code's mods folder.
# macOS, Linux, Git Bash
git clone https://github.com/JeremyHo1123/claude-code-agent-board.git ~/.claude/mods/agent-board
# Windows PowerShell
git clone https://github.com/JeremyHo1123/claude-code-agent-board.git "$env:USERPROFILE\.claude\mods\agent-board"
2. Tell Claude Code to load the mod. Add CLAUDE_CODE_PLUGIN_DIRS to the env block of ~/.claude/settings.json. Keep every other key in the file as it is.
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/agent-board"
}
}
If CLAUDE_CODE_PLUGIN_DIRS already has a value, append this folder to it. Separate the paths with ; on Windows and : elsewhere.
3. Install the VS Code extension. A built package ships in dist/, so nothing needs compiling.
# macOS, Linux, Git Bash
code --install-extension ~/.claude/mods/agent-board/dist/agent-board.vsix --force
# Windows PowerShell
code --install-extension "$env:USERPROFILE\.claude\mods\agent-board\dist\agent-board.vsix" --force
4. Reload. In VS Code, run Developer: Reload Window from the Command Palette. This restarts the Claude Code sessions in that window, so do it while none of them is mid-task. Each session loads the mod as it starts.
| Check | Expected |
|---|---|
claude plugin validate ~/.claude/mods/agent-board | ends with Validation passed |
Type /agent-board in a Claude Code conversation | it answers with the subagent board |
List ~/.claude/agent-board/sessions/ | one <sessionId>.json per open conversation, modified within the last minute |
code --list-extensions --show-versions | lists jeremy-local.agent-board@0.5.1 |
| Look at VS Code | an Agent Board icon in the activity bar; 5h …% · 週 …% and 上下文 …% in the status bar once a conversation has had one reply |
git -C ~/.claude/mods/agent-board pull
code --install-extension ~/.claude/mods/agent-board/dist/agent-board.vsix --force
Then reload the window. A change to the mod alone needs no window reload: interactive sessions watch the folder and reload the mod themselves, and /reload-plugins forces it.
code --uninstall-extension jeremy-local.agent-board
Remove the folder from CLAUDE_CODE_PLUGIN_DIRS, then delete ~/.claude/mods/agent-board and ~/.claude/agent-board.
The interface is in Traditional Chinese. These are the labels you will meet:
| On screen | Meaning |
|---|---|
| 訂閱用量 | Subscription usage: the 5-hour window (5 小時工作階段) and the weekly windows (每週) |
| 目前對話 · 上下文 | The current conversation and its context fill |
| 需要你 | Needs you: conversations waiting, subagents gone quiet, recent failures |
| 對話 | Conversations, listed when more than one is open; 目前 marks the one the context meter shows |
| 執行中 / 已結束 | Running / ended subagents |
| 等你 · 完成 · 失敗 · 已中止 | Waiting for you · done · failed · stopped |
| 任務指示 · 過程 · 回報 | In a detail: the task it was given · its steps · its report |
| 在編輯區開啟 · 返回 · 複製 | Open in an editor tab · back · copy the report |
All of them live under agentBoard.* in VS Code's settings.
| Setting | Default | What it does |
|---|---|---|
agentBoard.autoReveal | true | Shows the board when two or more subagents run at once, without taking the keyboard |
agentBoard.usageInStatusBar | true | Keeps 5h % · 週 % in the status bar; it turns yellow from 70% and red from 90% |
agentBoard.contextInStatusBar | true | Keeps the current conversation's context fill in the status bar |
agentBoard.notify.waiting | true | Notifies when a conversation has waited 15 seconds for an answer or a permission |
agentBoard.notify.subagents | true | Notifies when a subagent ends or fails; three or more at once fold into one |
agentBoard.notify.turnDone | true | Notifies when a turn that ran over two minutes finishes |
agentBoard.notify.usage | true | Notifies when the 5-hour or weekly window crosses 80% or 90% |
agentBoard.dataDirectory | "" | Where the snapshots are; empty means ~/.claude/agent-board/sessions |
In a terminal session the mod also answers /agent-board with a pane of that session's subagents.
~/.claude/projects/ (for conversation titles and subagent details). It writes nothing there.GET https://api.anthropic.com/api/oauth/usage, the endpoint Claude Code's /usage reads. The request goes through Claude Code's credential handle ($.session.authorize()), so the token never reaches the mod's code. All sessions together make at most one request every 30 seconds, and back off for up to 10 minutes when refused.claude plugin validate prints everything the mod hooks, calls and reads, so you can check this yourself.
hooks/format.ts, hooks/register.tsx, vscode-extension/extension.js and vscode-extension/media/*.js.| What you see | What it means | What to do |
|---|---|---|
| 還沒收到資料 on the board | No snapshot yet: the mod is not loaded | Check CLAUDE_CODE_PLUGIN_DIRS, reload the window, type /agent-board |
/agent-board is unknown | Mods are not available in this session | Update Claude Code; run claude plugin validate on the folder and read what it says |
| ⚠ 用量查詢被限流 | The usage endpoint refused (HTTP 429) | Nothing: it retries by itself, and replies keep the 5-hour and weekly figures moving |
| The context meter shows another conversation | No Claude tab matched yet | Click the Claude tab you mean |
| A detail says the transcript is not found | A subagent's transcript appears after its first reply | Wait a moment; old sessions whose files were cleared stay missing |
| A subagent called 未具名代理 | A workflow agent whose transcript is not written yet | It takes its task's first line within a second or two |
agent-board/ the repository, and the mod's own folder
├─ .claude-plugin/plugin.json the mod's manifest
├─ .claude/CLAUDE.md notes for whoever works on this
├─ hooks/ the mod: register.tsx (hooks), format.ts (pure helpers)
├─ types/ the mod's $.state contract
├─ tests/ the mod's tests
├─ vscode-extension/ the extension
│ ├─ extension.js polling, merging usage, transcripts, status bar, notifications
│ ├─ media/ the webviews: board.js, detail.js, panel.js, board.css
│ ├─ test/ extension tests, and two read-only checks against real data
│ └─ preview/ the webviews in a plain browser, with made-up data
├─ dist/agent-board.vsix the built extension
└─ docs/images/ the screenshots above
The mod. Claude Code lays its API types into .claude-plugin/types/ the first time the mod loads; tsc needs them.
claude plugin validate . # what the engine would accept or refuse
claude plugin test . # the tests under tests/
npx -y -p typescript@5.8 tsc -p . --noEmit # type-check, once the types are laid
The extension. Plain JavaScript with no dependencies.
cd vscode-extension
node test/extension.test.js # logic tests, with a stand-in vscode module
node test/real-transcripts.js 30 # parses your 30 latest subagent transcripts (read-only)
node test/live-smoke.js # one poll over your real snapshots (read-only)
Open vscode-extension/preview/index.html in a browser to see the board without VS Code; its header lists the query parameters. preview/shoot.sh takes screenshots with headless Chrome.
Packaging.
cd vscode-extension
npx -y @vscode/vsce@latest package --no-dependencies --skip-license -o ../dist/agent-board.vsix
code --install-extension ../dist/agent-board.vsix --force
.claude/CLAUDE.md holds the notes an agent needs to work on this: the data formats, the rules the code follows, and what was learned the hard way.
hooks/register.tsx 584 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register } from 'claude-code'
3
4import type { AgentBoardRow, AgentBoardStatus } from '../types'
5import {
6 addUsage,
7 boardRootOf,
8 clip,
9 countRows,
10 describeCall,
11 formatElapsed,
12 orderRows,
13 parsePlanUsage,
14 renderMarkdown,
15 rowDetail,
16 statusIcon,
17} from './format'
18import type { PlanUsage, SessionActivity } from './format'
19
20/**
21 * Where the board's files go, read by the Agent Board VS Code extension: `agent-board` under
22 * Claude Code's own folder (CLAUDE_CONFIG_DIR, else ~/.claude). VS Code's Claude extension draws
23 * no mod pane (it never attaches as a ui_render surface), so there the board is that extension's
24 * webview. Outside every project, so the rewrites never reach a repository.
25 *
26 * `sessions/<sessionId>.json` is one snapshot per session; `plan.json` the read of the
27 * subscription's usage all sessions share. Undefined until first asked; null with no home known.
28 */
29let boardRoot: string | null | undefined
30const PANE = 'agent-board'
31const TITLE = '子代理進度'
32const TICK_MS = 5_000
33const FLUSH_MS = 1_000
34const MAX_ROWS = 100
35const SHOWN_ENDED = 30
36/** Opened unasked once this many subagents run at the same time. */
37const AUTO_OPEN_AT = 2
38
39const rows = atom({ plugin: 'agent-board', key: 'rows' } as const, [])
40const now = atom({ plugin: 'agent-board', key: 'now' } as const, 0)
41const isDismissed = atom({ plugin: 'agent-board', key: 'isDismissed' } as const, false)
42
43/** How `$.agent.list()` statuses map onto a row's, for runs whose turn.complete never came (killed). */
44const LIST_STATUS: Readonly<Record<string, AgentBoardStatus>> = {
45 completed: 'done',
46 failed: 'failed',
47 killed: 'stopped',
48}
49
50/**
51 * The subscription's usage, from the endpoint Claude Code's /usage reads. The session's credential
52 * rides through `$.session.authorize()`: the host sets the header, the secret never reaches here.
53 */
54const PLAN_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
55/**
56 * Every session shares one read through this file (under the board's folder): whichever finds it
57 * due claims it, reads, and writes the answer back. One read per interval however many Claude tabs
58 * are open; each session polling on its own got the endpoint to refuse most of them.
59 */
60const PLAN_FILE = 'plan.json'
61/** One read across all sessions this often; while Claude works, each API response's own readings are fresher. */
62const PLAN_INTERVAL_MS = 30_000
63/**
64 * After a refusal without Retry-After, or any other failure, nobody asks again for this long,
65 * doubled for each failure in a row up to the cap; a success starts it over.
66 */
67const PLAN_BACKOFF_MS = 60_000
68const PLAN_BACKOFF_MAX_MS = 600_000
69/** How often a session looks at the shared file. */
70const PLAN_CHECK_MS = 5_000
71/** An idle session rewrites its snapshot this often, so the board can tell it from a closed one. */
72const HEARTBEAT_MS = 60_000
73
74type SharedPlan = {
75 plan?: PlanUsage
76 /** No session reads before this time: the next interval, or a backoff after a failure. */
77 nextAttemptAt: number
78 lastAttemptAt?: number
79 lastError?: { at: number; status?: number; message?: string }
80 /** Failed reads in a row: each one doubles the wait. */
81 failures?: number
82}
83
84/** Set by frequent events (tool calls, main turns); the flush timer writes the snapshot at most once a second. */
85let isDirty = false
86let isFlushing = false
87let planCheckedAt = 0
88/** When this session last saw an API response arrive (a tool call follows one): how fresh its rate-limit readings are. */
89let lastResponseAt = 0
90/** Module state, as the board reads it: a reload starts it over as idle, the next turn corrects it. */
91let activity: SessionActivity = { state: 'idle', since: Date.now() }
92
93function setActivity(state: SessionActivity['state'], wait?: { reason: string; kind: 'question' | 'permission' }): void {
94 if (activity.state === state && activity.reason === wait?.reason) {
95 return
96 }
97 activity = wait === undefined ? { state, since: Date.now() } : { state, since: Date.now(), ...wait }
98 isDirty = true
99}
100
101/** A permission dialog was answered once the next step starts or the approved tool returns. */
102function endPermissionWait(): void {
103 if (activity.state === 'waiting' && activity.kind === 'permission') {
104 setActivity('working')
105 }
106}
107
108function freshRow(id: string, at: number): AgentBoardRow {
109 return {
110 id,
111 label: `未具名代理 ${id.slice(0, 7)}`,
112 type: '其他(workflow 等)',
113 isListed: false,
114 status: 'running',
115 startedAt: at,
116 lastAt: at,
117 steps: 0,
118 lastAction: '',
119 }
120}
121
122function asRunning(row: AgentBoardRow): AgentBoardRow {
123 const { endedAt: _endedAt, ...rest } = row
124
125 return { ...rest, status: 'running' }
126}
127
128/** Applies `change` to the row of `id` (made first when `create`), stamping it with the time. */
129async function upsert(
130 $: EngineInterface,
131 id: string,
132 change: (row: AgentBoardRow, at: number) => AgentBoardRow,
133 create: boolean,
134): Promise<void> {
135 const at = await $.clock.now()
136
137 await update($, rows, list => {
138 const found = list.find(row => row.id === id)
139
140 if (found === undefined && !create) {
141 return list
142 }
143
144 const changed = change({ ...(found ?? freshRow(id, at)), lastAt: at }, at)
145
146 return found === undefined
147 ? [...list, changed].slice(-MAX_ROWS)
148 : list.map(row => (row.id === id ? changed : row))
149 })
150 await update($, now, () => at)
151}
152
153/**
154 * A file of the board's, by its path under the board's folder. The home folder comes from the
155 * environment (the hooks environment has no `os`): USERPROFILE on Windows, HOME elsewhere.
156 * Rejects where neither is set: a caller's own catch leaves that file unwritten.
157 */
158async function boardPath($: EngineInterface, name: string): Promise<string> {
159 if (boardRoot === undefined) {
160 boardRoot =
161 boardRootOf(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('USERPROFILE'), await $.env.get('HOME')) ?? null
162 }
163 if (boardRoot === null) {
164 throw new Error('agent-board: no home folder to keep the board in')
165 }
166
167 return `${boardRoot}/${name}`
168}
169
170async function readSharedPlan($: EngineInterface): Promise<SharedPlan | undefined> {
171 try {
172 return JSON.parse(await $.fs.read(await boardPath($, PLAN_FILE))) as SharedPlan
173 } catch {
174 return undefined
175 }
176}
177
178/** One read of the endpoint: the windows, or the failure and how long everyone should wait. */
179async function fetchPlan($: EngineInterface, at: number, failuresBefore: number): Promise<Partial<SharedPlan>> {
180 const failures = failuresBefore + 1
181 const backoff = Math.min(PLAN_BACKOFF_MS * 2 ** (failures - 1), PLAN_BACKOFF_MAX_MS)
182 const failed = (lastError: NonNullable<SharedPlan['lastError']>, wait = backoff): Partial<SharedPlan> => ({
183 lastError,
184 nextAttemptAt: at + wait,
185 failures,
186 })
187 try {
188 const auth = await $.session.authorize()
189 if (auth === null || auth.kind !== 'bearer') {
190 // an API key or a third-party provider: no subscription windows to read
191 return failed({ at, message: '沒有訂閱帳號的憑證' })
192 }
193 const response = await $.http.fetch(PLAN_USAGE_URL, {
194 auth: auth.handle,
195 headers: { 'anthropic-beta': 'oauth-2025-04-20', 'Content-Type': 'application/json' },
196 })
197 if (!response.ok) {
198 const retryAfter = Number(response.headers['retry-after'])
199 const isTold = response.status === 429 && Number.isFinite(retryAfter) && retryAfter > 0
200
201 return failed({ at, status: response.status }, isTold ? Math.max(retryAfter * 1000, PLAN_INTERVAL_MS) : backoff)
202 }
203 const parsed = parsePlanUsage(JSON.parse(response.text), at)
204 if (parsed === undefined) {
205 return failed({ at, message: '回應的格式讀不懂' })
206 }
207
208 // a success clears the error and the streak: JSON drops an undefined field
209 return { plan: parsed, lastError: undefined, failures: undefined }
210 } catch (error) {
211 return failed({ at, message: String(error).slice(0, 160) })
212 }
213}
214
215/** Reads the subscription's usage when the shared file says it is due, claiming the read first. */
216async function maybeReadPlan($: EngineInterface): Promise<void> {
217 const at = await $.clock.now()
218 if (at - planCheckedAt < PLAN_CHECK_MS) {
219 return
220 }
221 planCheckedAt = at
222
223 const shared = await readSharedPlan($)
224 if (shared !== undefined && at < shared.nextAttemptAt) {
225 return
226 }
227
228 // claimed before the request, so another session checking now skips this round
229 const file = await boardPath($, PLAN_FILE)
230 const claimed: SharedPlan = { ...shared, nextAttemptAt: at + PLAN_INTERVAL_MS, lastAttemptAt: at }
231 await $.fs.write(file, JSON.stringify(claimed))
232 const outcome = await fetchPlan($, at, shared?.failures ?? 0)
233 await $.fs.write(file, JSON.stringify({ ...claimed, ...outcome }))
234}
235
236/** Once a second: read the shared plan usage when it is due, then write the snapshot when anything changed. */
237async function flush($: EngineInterface): Promise<void> {
238 if (isFlushing) {
239 return
240 }
241 isFlushing = true
242 try {
243 await maybeReadPlan($).catch(() => undefined)
244 if (isDirty) {
245 isDirty = false
246 await writeSnapshot($)
247 }
248 } finally {
249 isFlushing = false
250 }
251}
252
253/** Writes this session's snapshot; a failed write leaves the previous one for the extension. */
254async function writeSnapshot($: EngineInterface, ending?: { sessionId: string }): Promise<void> {
255 try {
256 const list = await read($, rows)
257 const at = Math.max(await $.clock.now(), ...list.map(row => row.lastAt))
258 const sessionId = ending?.sessionId ?? (await $.session.id())
259 // `summary` is /context's own figure: the last response's usage plus a local estimate of what
260 // came after it (tool results, the new prompt), so the meter does not trail a step behind
261 const [cwd, model, usage] = await Promise.all([
262 $.session.cwd(),
263 $.session.model(),
264 $.session
265 .usage({ breakdown: 'summary', columns: 40 })
266 .catch(() => $.session.usage())
267 .catch(() => undefined),
268 ])
269 const breakdown = usage?.context.breakdown
270 const snapshot = {
271 version: 1,
272 sessionId,
273 cwd,
274 model,
275 updatedAt: at,
276 isClosed: ending !== undefined,
277 usage:
278 usage === undefined
279 ? undefined
280 : {
281 contextPercent: breakdown?.percentage ?? usage.context.percent,
282 contextTokens: breakdown?.totalTokens ?? usage.context.tokens,
283 contextWindow: breakdown?.rawMaxTokens ?? usage.context.window,
284 isContextEstimate: breakdown !== undefined,
285 rateLimits: usage.rateLimits.map(limit => ({
286 kind: limit.kind,
287 percentUsed: limit.percentUsed,
288 resetsAt: limit.resetsAt,
289 })),
290 // the readings are as fresh as the last response this session saw
291 rateLimitsAt: usage.rateLimits.length > 0 && lastResponseAt > 0 ? lastResponseAt : undefined,
292 },
293 activity,
294 agents: list,
295 }
296
297 await $.fs.write(await boardPath($, `sessions/${sessionId}.json`), JSON.stringify(snapshot))
298 } catch {
299 // the terminal pane, where there is one, still stands
300 }
301}
302
303async function refreshStatusLine($: EngineInterface): Promise<void> {
304 const { running, ended } = countRows(await read($, rows))
305
306 $.ui.status(running === 0 ? undefined : `子代理 ${running} 執行中 · ${ended} 已結束 — /agent-board`)
307}
308
309async function maybeAutoOpen($: EngineInterface): Promise<void> {
310 if (await read($, isDismissed)) {
311 return
312 }
313 if (countRows(await read($, rows)).running >= AUTO_OPEN_AT) {
314 void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
315 }
316}
317
318/** Every few seconds while something runs: move the clock the pane draws from, settle killed runs. */
319async function tick($: EngineInterface): Promise<void> {
320 const list = await read($, rows)
321
322 if (!list.some(row => row.status === 'running')) {
323 return
324 }
325
326 let listed: readonly AgentInfo[] = []
327 try {
328 listed = await $.agent.list()
329 } catch {
330 listed = []
331 }
332
333 const at = await $.clock.now()
334 const settled = new Map<string, AgentBoardStatus>()
335 for (const info of listed) {
336 const status = LIST_STATUS[info.status]
337 if (status !== undefined) {
338 settled.set(info.id, status)
339 }
340 }
341
342 if (settled.size > 0) {
343 await update($, rows, current =>
344 current.map(row => {
345 const status = row.isListed && row.status === 'running' ? settled.get(row.id) : undefined
346
347 return status === undefined ? row : { ...row, status, endedAt: row.endedAt ?? at }
348 }),
349 )
350 }
351 await update($, now, () => at)
352 await refreshStatusLine($)
353 isDirty = false
354 await writeSnapshot($)
355}
356
357export const register: Register = on => {
358 on('session.start', async ($, e, next) => {
359 await $.command.register({
360 name: 'agent-board',
361 description: '子代理進度:每個子代理的狀態、步數、最近一步、時間與 token',
362 immediate: true,
363 })
364 $.clock.every(TICK_MS, () => {
365 void tick($).catch(() => undefined)
366 })
367 $.clock.every(FLUSH_MS, () => {
368 void flush($).catch(() => undefined)
369 })
370 $.clock.every(HEARTBEAT_MS, () => {
371 // an idle session's snapshot stays young, so the board can tell it from a closed one
372 isDirty = true
373 })
374 await writeSnapshot($)
375
376 return next(e)
377 })
378
379 // Pushed by the engine after each main-thread turn and whenever a rate-limit window moves a whole
380 // point: the usage meters follow it without polling (the idea comes from Learning Hacker's working-memory mod).
381 on('session.measure', async ($, e, next) => {
382 isDirty = true
383 lastResponseAt = Date.now()
384
385 return next(e)
386 })
387
388 on('session.end', async ($, e, next) => {
389 await writeSnapshot($, { sessionId: e.sessionId })
390 if (e.reason === 'clear') {
391 // the process goes on under a new session id: its board starts empty
392 await update($, rows, () => [])
393 }
394
395 return next(e)
396 })
397
398 on('command.run', { command: 'agent-board' }, async $ => {
399 const at = await $.clock.now()
400 await update($, now, () => at)
401 await update($, isDismissed, () => false)
402
403 // Draws the pane in a terminal; VS Code draws nothing for it, hence the text and the extension.
404 void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
405 await writeSnapshot($)
406
407 const list = await read($, rows)
408 const board = renderMarkdown(list, Math.max(at, ...list.map(row => row.lastAt)), SHOWN_ENDED, false)
409
410 return {
411 text: `${board}\n圖形面板:VS Code 左側活動列的 Agent Board 圖示,或 Ctrl+Shift+P →「Agent Board: 打開子代理面板」。`,
412 }
413 })
414
415 on('ui.close', { id: PANE }, async ($, e, next) => {
416 if (e.origin.kind === 'person') {
417 await update($, isDismissed, () => true)
418 }
419
420 return next(e)
421 })
422
423 on('agent.spawn', async ($, e, next) => {
424 const started = await next(e)
425 const id = started.agentId
426
427 if (id !== undefined) {
428 const label = clip(e.description === '' ? e.subagentType : e.description, 60)
429
430 await upsert(
431 $,
432 id,
433 row => ({
434 ...asRunning(row),
435 label,
436 type: e.subagentType,
437 isListed: true,
438 lastAction: row.lastAction === '' ? '啟動中' : row.lastAction,
439 }),
440 true,
441 )
442 await refreshStatusLine($)
443 await maybeAutoOpen($)
444 await writeSnapshot($)
445 }
446
447 return started
448 })
449
450 on('tool.call', async ($, e, next) => {
451 const id = e.agentId
452 endPermissionWait()
453
454 // a tool call follows a fresh API response: its rate-limit readings are as of now
455 lastResponseAt = Date.now()
456
457 if (id === undefined) {
458 isDirty = true
459
460 if (String(e.tool) === 'AskUserQuestion') {
461 setActivity('waiting', { reason: '問你問題', kind: 'question' })
462 try {
463 return await next(e)
464 } finally {
465 setActivity('working')
466 }
467 }
468
469 const result = await next(e)
470 endPermissionWait()
471 // the tool's result joins the context: the meter follows within a second
472 isDirty = true
473
474 return result
475 }
476
477 const action = describeCall(e as unknown as Readonly<Record<string, unknown>>)
478
479 await upsert($, id, row => ({ ...asRunning(row), steps: row.steps + 1, lastAction: action }), true)
480 await refreshStatusLine($)
481 isDirty = true
482
483 const result = await next(e)
484 endPermissionWait()
485
486 return result
487 })
488
489 on('turn.start', async ($, e, next) => {
490 setActivity('working')
491
492 return next(e)
493 })
494
495 // Raised just before a permission dialog opens (the classic PermissionRequest hook's moment).
496 on('classic.PermissionRequest', async ($, e, next) => {
497 setActivity('waiting', { reason: `等你允許 ${e.tool_name}`, kind: 'permission' })
498
499 return next(e)
500 })
501
502 on('classic.PermissionDenied', async ($, e, next) => {
503 endPermissionWait()
504
505 return next(e)
506 })
507
508 on('turn.complete', async ($, e, next) => {
509 const result = await next(e)
510 const id = e.agentId
511
512 if (id === undefined) {
513 // a main-loop turn moved the context window and the rate limits, and the session now waits for a prompt
514 isDirty = true
515 lastResponseAt = Date.now()
516 setActivity('idle')
517
518 return result
519 }
520
521 const status: AgentBoardStatus = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
522 const usage = e.usage
523
524 await upsert(
525 $,
526 id,
527 (row, at) => ({
528 ...row,
529 status,
530 endedAt: at,
531 ...(usage === undefined ? {} : { tokens: addUsage(row.tokens, usage) }),
532 }),
533 false,
534 )
535 await refreshStatusLine($)
536 await writeSnapshot($)
537
538 return result
539 })
540
541 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
542 const { Box, Button, Text } = $.ui.resolve(e)
543 const list = await read($, rows)
544 const at = Math.max(await read($, now), ...list.map(row => row.lastAt))
545 const { running, ended, failed } = countRows(list)
546 const summary = `${running} 執行中 · ${ended} 已結束${failed > 0 ? ` · ${failed} 失敗` : ''}`
547
548 return (
549 <Box flexDirection="column">
550 <Box flexDirection="row" justifyContent="space-between">
551 <Text bold>{summary}</Text>
552 {ended > 0 && (
553 <Button
554 key="clear"
555 label="清除已結束"
556 dimColor
557 onPress={async () => {
558 await update($, rows, current => current.filter(row => row.status === 'running'))
559 await writeSnapshot($)
560 }}
561 />
562 )}
563 </Box>
564 {list.length === 0 && (
565 <Text dimColor>這個 session 還沒有子代理。派出子代理後,這裡會即時顯示它們的進度。</Text>
566 )}
567 {orderRows(list, SHOWN_ENDED).map(row => (
568 <Box flexDirection="column" marginTop={1}>
569 <Box flexDirection="row" justifyContent="space-between">
570 <Text bold={row.status === 'running'} wrap="truncate-end">
571 {`${statusIcon(row, at)} ${row.label}`}
572 </Text>
573 <Text dimColor>{formatElapsed((row.endedAt ?? at) - row.startedAt)}</Text>
574 </Box>
575 <Text dimColor wrap="truncate-end">
576 {rowDetail(row, at)}
577 </Text>
578 </Box>
579 ))}
580 </Box>
581 )
582 })
583}
584hooks/format.ts 312 lines1import type { ModelUsage } from 'claude-code'
2
3import type { AgentBoardRow, AgentBoardTokens } from '../types'
4
5export const IDLE_MS = 120_000
6
7/**
8 * What the session is doing, for a board that watches several at once: working on a turn, waiting
9 * on the person (a question, a permission dialog), or idle with its turn done.
10 */
11export type SessionActivity = {
12 state: 'working' | 'waiting' | 'idle'
13 /** Milliseconds since the epoch the state began (Date.now(): the board compares it with its own clock). */
14 since: number
15 /** For `waiting`: what it waits on, to show. */
16 reason?: string
17 /** For `waiting`: a question clears when it is answered; a permission when the next step starts. */
18 kind?: 'question' | 'permission'
19}
20
21/** One window of the subscription's usage limits, as claude.ai's usage page shows it. */
22export type PlanWindow = {
23 key: string
24 label: string
25 /** Share of the window used, 0-100. */
26 percent: number
27 resetsAt?: string
28}
29
30/** Extra usage (overage) for the billing period; amounts in minor units of `currency` (cents for USD). */
31export type PlanExtra = {
32 percent: number | null
33 usedCredits: number | null
34 monthlyLimit: number | null
35 currency: string | null
36}
37
38export type PlanUsage = {
39 fetchedAt: number
40 windows: PlanWindow[]
41 extra?: PlanExtra
42}
43
44const PLAN_WINDOWS: ReadonlyArray<readonly [string, string]> = [
45 ['five_hour', '5 小時工作階段'],
46 ['seven_day', '每週・所有模型'],
47 ['seven_day_opus', '每週・Opus'],
48 ['seven_day_sonnet', '每週・Sonnet'],
49]
50
51function asRecord(value: unknown): Record<string, unknown> | undefined {
52 return value !== null && typeof value === 'object' && !Array.isArray(value)
53 ? (value as Record<string, unknown>)
54 : undefined
55}
56
57function numberOrNull(value: unknown): number | null {
58 return typeof value === 'number' && Number.isFinite(value) ? value : null
59}
60
61function windowOf(key: string, label: string, value: unknown): PlanWindow | undefined {
62 const record = asRecord(value)
63 const percent = numberOrNull(record?.utilization)
64 if (record === undefined || percent === null) {
65 return undefined
66 }
67 const resetsAt = record.resets_at
68
69 return typeof resetsAt === 'string' ? { key, label, percent, resetsAt } : { key, label, percent }
70}
71
72/**
73 * Reads the body of `GET /api/oauth/usage` (what Claude Code's /usage reads): the 5-hour and weekly
74 * windows, any per-model weekly bucket, and extra usage. The endpoint is not a public API and its
75 * shape may change: whatever does not read as a window is left out, and no window at all is undefined.
76 */
77export function parsePlanUsage(body: unknown, fetchedAt: number): PlanUsage | undefined {
78 const record = asRecord(body)
79 if (record === undefined) {
80 return undefined
81 }
82
83 const windows: PlanWindow[] = []
84 for (const [key, label] of PLAN_WINDOWS) {
85 const window = windowOf(key, label, record[key])
86 if (window !== undefined) {
87 windows.push(window)
88 }
89 }
90 for (const list of [record.model_scoped, record.limits]) {
91 if (!Array.isArray(list)) {
92 continue
93 }
94 for (const item of list) {
95 const name = asRecord(item)?.display_name
96 if (typeof name === 'string' && !windows.some(window => window.key === `model:${name}`)) {
97 const window = windowOf(`model:${name}`, `每週・${name}`, item)
98 if (window !== undefined) {
99 windows.push(window)
100 }
101 }
102 }
103 }
104 if (windows.length === 0) {
105 return undefined
106 }
107
108 const extra = asRecord(record.extra_usage)
109 if (extra?.is_enabled === true) {
110 return {
111 fetchedAt,
112 windows,
113 extra: {
114 percent: numberOrNull(extra.utilization),
115 usedCredits: numberOrNull(extra.used_credits),
116 monthlyLimit: numberOrNull(extra.monthly_limit),
117 currency: typeof extra.currency === 'string' ? extra.currency : null,
118 },
119 }
120 }
121
122 return { fetchedAt, windows }
123}
124
125/**
126 * The board's folder: `agent-board` under Claude Code's own folder, which is CLAUDE_CONFIG_DIR
127 * where that is set and `.claude` in the home folder otherwise (USERPROFILE on Windows, HOME
128 * elsewhere). Forward slashes throughout; undefined with no home known.
129 */
130export function boardRootOf(
131 configDir: string | undefined,
132 userProfile: string | undefined,
133 home: string | undefined,
134): string | undefined {
135 const set = (value: string | undefined): string | undefined =>
136 value === undefined || value.trim() === '' ? undefined : value.trim()
137 const person = set(userProfile) ?? set(home)
138 const base = set(configDir) ?? (person === undefined ? undefined : `${person.replace(/[\\/]+$/, '')}/.claude`)
139
140 return base === undefined ? undefined : `${base.replace(/\\/g, '/').replace(/\/+$/, '')}/agent-board`
141}
142
143/** One line, at most `max` characters. */
144export function clip(text: string, max: number): string {
145 const line = text.replace(/\s+/g, ' ').trim()
146
147 return line.length > max ? `${line.slice(0, max - 1)}…` : line
148}
149
150/** The last two segments of a path, either slash. */
151export function tailPath(path: string): string {
152 return path.replace(/\\/g, '/').split('/').filter(Boolean).slice(-2).join('/')
153}
154
155/** What a subagent's tool call is doing, short enough for one row. */
156export function describeCall(args: Readonly<Record<string, unknown>>): string {
157 const tool = String(args.tool)
158 const text = (key: string): string => {
159 const value = args[key]
160
161 return typeof value === 'string' ? value : ''
162 }
163
164 switch (tool) {
165 case 'Bash':
166 case 'PowerShell':
167 return `${tool} ${clip(text('command'), 60)}`
168 case 'Read':
169 case 'Edit':
170 case 'Write':
171 return `${tool} ${tailPath(text('file_path'))}`
172 case 'NotebookEdit':
173 return `${tool} ${tailPath(text('notebook_path'))}`
174 case 'Grep':
175 return `Grep "${clip(text('pattern'), 40)}"`
176 case 'Glob':
177 return `Glob ${clip(text('pattern'), 50)}`
178 case 'WebSearch':
179 return `WebSearch "${clip(text('query'), 50)}"`
180 case 'WebFetch':
181 return `WebFetch ${clip(text('url'), 60)}`
182 case 'Agent':
183 return `Agent ${clip(text('description'), 40)}`
184 default:
185 return clip(tool.replace(/^mcp__/, ''), 50)
186 }
187}
188
189export function addUsage(base: AgentBoardTokens | undefined, usage: ModelUsage): AgentBoardTokens {
190 return {
191 input: (base?.input ?? 0) + usage.input_tokens,
192 output: (base?.output ?? 0) + usage.output_tokens,
193 cacheRead: (base?.cacheRead ?? 0) + usage.cache_read_input_tokens,
194 cacheWrite: (base?.cacheWrite ?? 0) + usage.cache_creation_input_tokens,
195 }
196}
197
198export function totalTokens(tokens: AgentBoardTokens): number {
199 return tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite
200}
201
202export function formatTokens(count: number): string {
203 if (count >= 1_000_000) {
204 return `${(count / 1_000_000).toFixed(1)}M`
205 }
206 if (count >= 1_000) {
207 return `${Math.round(count / 1_000)}k`
208 }
209
210 return String(count)
211}
212
213/** m:ss, or h:mm:ss from an hour. */
214export function formatElapsed(ms: number): string {
215 const seconds = Math.max(0, Math.round(ms / 1000))
216 const minutes = Math.floor(seconds / 60)
217 const ss = String(seconds % 60).padStart(2, '0')
218
219 if (minutes >= 60) {
220 return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${ss}`
221 }
222
223 return `${minutes}:${ss}`
224}
225
226export function isIdle(row: AgentBoardRow, now: number): boolean {
227 return row.status === 'running' && now - row.lastAt > IDLE_MS
228}
229
230export function statusIcon(row: AgentBoardRow, now: number): string {
231 if (isIdle(row, now)) {
232 return '⏸'
233 }
234
235 return { running: '●', done: '✓', failed: '✗', stopped: '■' }[row.status]
236}
237
238/** What a row is doing now, or what it cost once it ended. */
239export function rowNote(row: AgentBoardRow, now: number): string {
240 const last = row.lastAction === '' ? '—' : row.lastAction
241
242 switch (row.status) {
243 case 'running': {
244 const idle = isIdle(row, now) ? `${Math.floor((now - row.lastAt) / 60_000)} 分鐘沒有動靜 · ` : ''
245
246 return `${idle}最近:${last}`
247 }
248 case 'done':
249 return row.tokens === undefined
250 ? 'token —'
251 : `token ${formatTokens(totalTokens(row.tokens))}(快取讀取 ${formatTokens(row.tokens.cacheRead)})`
252 case 'failed':
253 return `失敗,最後一步:${last}`
254 case 'stopped':
255 return '已中止'
256 }
257}
258
259/** The second line of a row: type, steps, and its note. */
260export function rowDetail(row: AgentBoardRow, now: number): string {
261 return `${row.type} · ${row.steps} 步 · ${rowNote(row, now)}`
262}
263
264function tableCell(text: string): string {
265 return text.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
266}
267
268/**
269 * The board as Markdown: a status file VS Code's Markdown preview shows live (VS Code draws no
270 * mod pane), and the text `/agent-board` prints.
271 */
272export function renderMarkdown(list: readonly AgentBoardRow[], now: number, keep: number, withHeading: boolean): string {
273 const { running, ended, failed } = countRows(list)
274 const lines = withHeading ? ['# 子代理進度', ''] : []
275
276 // no clock time: the hooks environment's time zone is not the person's
277 lines.push(`${running} 執行中 · ${ended} 已結束${failed > 0 ? ` · ${failed} 失敗` : ''}`, '')
278
279 if (list.length === 0) {
280 lines.push('這個 session 還沒有子代理。派出子代理後,這裡會自動更新。')
281 } else {
282 lines.push('| 狀態 | 子代理 | 類型 | 時間 | 步數 | 最近一步/token |', '|:-:|---|---|--:|--:|---|')
283 for (const row of orderRows(list, keep)) {
284 const elapsed = formatElapsed((row.endedAt ?? now) - row.startedAt)
285
286 lines.push(
287 `| ${statusIcon(row, now)} | ${tableCell(row.label)} | ${tableCell(row.type)} | ${elapsed} | ${row.steps} | ${tableCell(rowNote(row, now))} |`,
288 )
289 }
290 }
291
292 lines.push('', '狀態符號:● 執行中 ⏸ 超過 2 分鐘沒動靜 ✓ 完成 ✗ 失敗 ■ 中止', '')
293
294 return lines.join('\n')
295}
296
297/** Running rows in start order, then up to `keep` ended rows, newest first. */
298export function orderRows(list: readonly AgentBoardRow[], keep: number): AgentBoardRow[] {
299 const ended = list
300 .filter(row => row.status !== 'running')
301 .sort((a, b) => (b.endedAt ?? b.lastAt) - (a.endedAt ?? a.lastAt))
302
303 return [...list.filter(row => row.status === 'running'), ...ended.slice(0, keep)]
304}
305
306export function countRows(list: readonly AgentBoardRow[]): { running: number; ended: number; failed: number } {
307 const running = list.filter(row => row.status === 'running').length
308 const failed = list.filter(row => row.status === 'failed').length
309
310 return { running, ended: list.length - running, failed }
311}
312types/index.d.ts 35 lines1export type AgentBoardStatus = 'running' | 'done' | 'failed' | 'stopped'
2
3export type AgentBoardTokens = {
4 input: number
5 output: number
6 cacheRead: number
7 cacheWrite: number
8}
9
10export type AgentBoardRow = {
11 /** The agent loop's id: what its tool.call / turn.complete events carry as agentId. */
12 id: string
13 label: string
14 type: string
15 /** True when the Agent tool started it (agent.spawn); false for loops seen only through their tool calls (workflow agents). */
16 isListed: boolean
17 status: AgentBoardStatus
18 startedAt: number
19 lastAt: number
20 endedAt?: number
21 steps: number
22 lastAction: string
23 tokens?: AgentBoardTokens
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'agent-board': {
29 rows: AgentBoardRow[]
30 now: number
31 isDismissed: boolean
32 }
33 }
34}
35