SLOPSHOPPER

agent-board

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…

newpaneguardcommandstatusnetwork
v0.5.1MITupdated 2026-10-05JeremyHo1123/claude-code-agent-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-board
│ ┃ 子代理進度 ✕ › fix the failing auth test and add an audit log call │ ┃ 0 執行中 · 0 已結束 │ ┃ 這個 session ⏺ Read(src/auth.ts) │ ┃ 還沒有子代理。派出子代理後,這裡會即時顯示它 ⎿ Read 6 lines │ ┃ 們的進度。 ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agent-board │ ⎿ agent-board: 0 執行中 · 0 已結束 │ ⎿ agent-board: │ ⎿ agent-board: 這個 session 還沒有子代理。派出子代理後,這裡會自動更新。 │ ⎿ agent-board: │ ⎿ agent-board: 狀態符號:● 執行中 ⏸ 超過 2 分鐘沒動靜 ✓ 完成 ✗ 失敗 ■ 中止 │ ⎿ agent-board: │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 子代理進度
0 執行中 · 0 已結束 這個 session 還沒有子代理。派出子代理後,這裡會即時顯示它們的進度。
README

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

Why this exists

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:

HalfWhat it isWhat it does
The modA Claude Code plugin, loaded into every sessionWatches the session and writes small JSON files to ~/.claude/agent-board/
The extensionA VS Code extension with its own sidebar viewReads those files once a second and draws the board

What you get

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:

  • the task it was given, in full
  • every tool call on a timeline, with its input, its result, how long it took, and ✓ or ✕
  • what it said between steps
  • its final report, rendered as Markdown (tables, lists, code)
  • tokens, counted once per request

在編輯區開啟 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.

How it works

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/&lt;id&gt;.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
  • Snapshots. The mod hooks session, turn, tool-call and agent events. It writes 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, from two sources. Every API reply carries rate-limit readings, which are fresh but run slightly behind. The usage endpoint that /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.
  • Task details. Claude Code already writes each subagent's transcript next to its session's. The extension reads that file while a detail is open, and again whenever it grows. It also reads workflow run files for agent labels and phases.
  • Which conversation is "current". VS Code does not tell one extension which Claude conversation another extension's tab shows. The board matches the focused tab's title against the title Claude Code writes into each transcript.

Requirements

  • Claude Code 2.1.289 or later, with mods (function-hook plugins) available to your account. This is the build it was developed against.
  • VS Code 1.90 or later with the Claude Code extension.
  • A Claude subscription for the usage meters. A session on an API key shows no usage windows; everything else works.
  • git, and the 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.

Install

Let Claude Code do it

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.

Or do it by hand

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.

Verify

CheckExpected
claude plugin validate ~/.claude/mods/agent-boardends with Validation passed
Type /agent-board in a Claude Code conversationit 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-versionslists jeremy-local.agent-board@0.5.1
Look at VS Codean Agent Board icon in the activity bar; 5h …% · 週 …% and 上下文 …% in the status bar once a conversation has had one reply

Update

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.

Uninstall

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.

Reading the board

The interface is in Traditional Chinese. These are the labels you will meet:

On screenMeaning
訂閱用量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

Settings

All of them live under agentBoard.* in VS Code's settings.

SettingDefaultWhat it does
agentBoard.autoRevealtrueShows the board when two or more subagents run at once, without taking the keyboard
agentBoard.usageInStatusBartrueKeeps 5h % · 週 % in the status bar; it turns yellow from 70% and red from 90%
agentBoard.contextInStatusBartrueKeeps the current conversation's context fill in the status bar
agentBoard.notify.waitingtrueNotifies when a conversation has waited 15 seconds for an answer or a permission
agentBoard.notify.subagentstrueNotifies when a subagent ends or fails; three or more at once fold into one
agentBoard.notify.turnDonetrueNotifies when a turn that ran over two minutes finishes
agentBoard.notify.usagetrueNotifies 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.

What it reads and what it sends

  • It reads, on your machine only: the snapshots the mod writes, and Claude Code's own transcripts under ~/.claude/projects/ (for conversation titles and subagent details). It writes nothing there.
  • It makes one kind of network request: 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.
  • No telemetry. Nothing else leaves your machine.

claude plugin validate prints everything the mod hooks, calls and reads, so you can check this yourself.

Limitations

  • No bar above the prompt in VS Code. The Claude panel there takes no mod interface, so the context fill sits in the status bar instead.
  • A workflow's agents get their real names when the run ends. Claude Code writes the run file only then. Until then each agent goes by the first line of its task.
  • No thinking text. Transcripts keep only a signature for thinking blocks, so a detail shows how long it thought, not what.
  • "Waiting for permission" can linger while a tool you approved runs for a long time.
  • Conversations are matched by title. Two conversations with the same title cannot be told apart, and a brand-new untitled one is matched only when it is the only one.
  • The usage endpoint is not a public API. Its shape may change. If it does, the board falls back to the readings API replies carry.
  • Long content is clipped in the sidebar (1,500 characters per step result, 20,000 per report). The editor tab shows far more. A transcript over 8 MB is read in part.
  • Traditional Chinese only. The strings are in hooks/format.ts, hooks/register.tsx, vscode-extension/extension.js and vscode-extension/media/*.js.

Troubleshooting

What you seeWhat it meansWhat to do
還沒收到資料 on the boardNo snapshot yet: the mod is not loadedCheck CLAUDE_CODE_PLUGIN_DIRS, reload the window, type /agent-board
/agent-board is unknownMods are not available in this sessionUpdate 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 conversationNo Claude tab matched yetClick the Claude tab you mean
A detail says the transcript is not foundA subagent's transcript appears after its first replyWait a moment; old sessions whose files were cleared stay missing
A subagent called 未具名代理A workflow agent whose transcript is not written yetIt takes its task's first line within a second or two

Development

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.

Credits

License

MIT

Source 3 files
hooks/register.tsx 584 lines
1import { 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}
584
hooks/format.ts 312 lines
1import 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}
312
types/index.d.ts 35 lines
1export 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