子代理透明看板:输入框上方的紫色总进度条 + 侧边栏每个子代理一张卡(进度、动作、花费),底部汇总总花费。纯追踪,零 Token

十个给 Claude Code 用的 Mod。只能装在 Claude Code(终端和桌面 App 的 Code 页)。Codex、Anti-Gravity 没有 Mod 接口,装不了。
先装 switchboard(总控台),其余随意挑。
终端里:
/plugin install switchboard --marketplace weizhongl/claude-code-mods
第一次会问 Add marketplace?,回 y,再选 user scope。其余 Mod 把名字换掉即可:usage-gauge、brain-dump、helper-desk、cache-clock、handoff、reflect、chat-inventory、screen-share、replay-theater。
在 shell 里:
claude plugin install switchboard --marketplace weizhongl/claude-code-mods
桌面 App(/plugin 在那里不能用):点输入框旁的 +,选 Plugins → Add marketplace,填 weizhongl/claude-code-mods,再逐个 Add plugin。
| Mod | 怎么用 | 注意 |
|---|---|---|
| switchboard | 输入 /mods 打开总控台。每个 Mod 一行:On/Off、Hide UI Panel。输入框上方那一行条(额度、缓存倒计时、任务进度、👁 按钮)也是它画的:平时只占一行,别的 Mod 有事才在它旁边多出一行。条的右端有按钮:「收起」变成一小行(只剩几个百分比和倒计时),再点「隐藏」只留一个「📊 展开」按钮;也可以输入 /bar(或 /bar full、/bar mini、/bar off)切换,你的选择会记住。进度条在桌面版是细线,在终端里是小方块。也可以 /mods off 名字、/mods on 名字、/mods hide 名字、/mods show 名字。新放进同一个文件夹的 Mod 会自动出现。 | Off = 该 Mod 的钩子立刻停手;Hide = 后台照跑,只收起面板。 |
| usage-gauge | 自动。显示在总控台那一行条里:对话上下文用了多少、5 小时(会话)额度、本周额度,各有短进度条、颜色(绿 / 黄 / 红)和重置时间,最后是本次对话花费。/gauge 看文字版。 | 读的是引擎自己的数字(和状态栏同一份),零 Token。额度数字要等发过一条消息、引擎读到后才出现。 |
| brain-dump | 直接打字或语音说一大段(默认 180 字以上、没有排版)。它会拦住,先问你 2–3 个问题;你在输入框直接回答(回「跳过」= 不补充),然后重写成 The Task / The Why / Guardrails / What Done Looks Like 再发给 Claude。输入框上方有 跳过提问 / 原话直接发 / 放弃 三个按钮,也可用 /dump skip、/dump raw、/dump cancel。消息开头加 >> 可以跳过拦截。 | 会用到两次小的模型调用(提问用 haiku,重写用 sonnet)。 |
| helper-desk | 自动。Claude 有待办清单时,一行条里显示「任务 n/m」和短进度条;Claude 派出子代理时,侧边栏出现 Helper Desk,每个子代理一张卡(进度、正在做什么、读入/输出 token、花费)。/helpers 手动打开。 | 子代理花费是按公开标价估算;整段对话总花费取自引擎账本。零额外 Token。 |
| cache-clock | 自动。第一条回复之后,一行条里显示「⏱ mm:ss」。点旁边的 Keep Warm 或输入 /keep warm,快冷之前会自动发一条极小的心跳续命。你发下一条真实消息,或连续保温满 2 小时,会自动关闭。 | 拿不到服务器的缓存时间,所以倒计时是推算(订阅登入 60 分钟,API 5 分钟,并按真实缓存命中自动校正)。设置里的「缓存有效分钟数」可固定。 |
| handoff | 对话累计到 250,000 token、或缓存变冷时,输入框上方提醒你。按「生成交接」或输入 /handoff,会写出一份详细的交接提示词(存在 ~/.claude/handoffs/,可直接发给同事),再按 Start Fresh Chat with Handoff 带去新对话。 | 生成会花 Token,默认只提醒、不自动生成(设置里「触发后做什么」选 generate 才会自动生成)。桌面 App 不允许自动开新对话时,会改成复制到剪贴板。 |
| reflect | 你在对话里说「以后永远不要…」「每次都要…」「记住…」这类话,输入框上方弹出小卡片,一键存进:内置 Memory / 项目 CLAUDE.md(没有就 AGENTS.md)/ 你指定的文件 / 仅本次忽略。 | 只在句子里有规则关键词时才调用一次小模型判断。设置里可填默认的第二大脑文件路径。 |
| chat-inventory | 自动。侧边栏「In this chat」:目标与待办、调用过的连接器(含次数)、用过的技能、读过和新建/修改过的文件(点文件在文件管理器里定位)。/inventory 手动打开。 | 纯追踪,零 Token。 |
| screen-share | 输入 /screen share 开或关,或点一行条右端的 👁 按钮。开启后,画面上的邮箱、电话、金额、API key、公司名、人名遮成灰条。 | 只改画面,不改真实数据;不遮输入框里正在打的字。人名只遮设置里填的名单和「客户:xxx」「陈小姐」这类写法,没有真正的人名识别。 |
| replay-theater | 自动录下 Claude 用 Edit / Write / NotebookEdit 对文件做的每次修改。输入 /replay 打开:选文件、播放/暂停、上一步/下一步、点步骤号跳转,看每一步的 diff。Revert 按钮按一次变成「再按一次确认」,第二次才会写回文件。/replay clear 清空。 | 用 Bash 改文件(如 sed)录不到;单个文件超过 20 万字不录。记录只在当前会话。 |
每个 Mod 一个文件夹:.claude-plugin/plugin.json、hooks/hooks.json、hooks/register.tsx,有状态的还有 types/index.d.ts。switchboard、screen-share、replay-theater 带自动测试(claude plugin test <文件夹>)。
各 Mod 里的 hooks/flag.ts 是同一小段代码(读总控台的开关),Mod 之间不能共享文件,所以每个放一份。
hooks/register.tsx 280 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { flagOf } from './flag'
5import { applyPlan } from './plan'
6import type { PlanInput } from './plan'
7import { usdOf } from './prices'
8import { PALETTE, roleOf } from './roles'
9import type { HelperCard } from '../types'
10
11const MOD = 'helper-desk'
12const PANE = 'helper-desk'
13const PURPLE = '#8b5cf6'
14const BAR = 24
15
16const cards = atom({ plugin: 'helper-desk', key: 'cards' } as const, [])
17const plan = atom({ plugin: 'helper-desk', key: 'plan' } as const, [])
18const now = atom({ plugin: 'helper-desk', key: 'now' } as const, 0)
19const sessionUsd = atom({ plugin: 'helper-desk', key: 'sessionUsd' } as const, 0)
20
21let ticks = 0
22
23const money = (usd: number) => (usd < 1 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`)
24const tokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
25const fill = (ratio: number) => {
26 const on = Math.max(0, Math.min(BAR, Math.round(ratio * BAR)))
27
28 return { on: '█'.repeat(on), off: '░'.repeat(BAR - on) }
29}
30
31// One short line saying what a tool call is doing.
32const briefOf = (tool: string, input: Record<string, unknown>) => {
33 const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
34 const file = text('file_path') || text('notebook_path')
35 const arg =
36 file !== ''
37 ? file.split(/[\\/]/).pop()
38 : text('command') || text('pattern') || text('query') || text('url') || text('description')
39
40 return `${tool} ${arg ?? ''}`.trim().slice(0, 60)
41}
42
43async function patchCard(
44 $: EngineInterface,
45 id: string,
46 change: (card: HelperCard) => Partial<HelperCard>,
47) {
48 await update($, cards, list =>
49 list.map(card => (card.id === id ? { ...card, ...change(card) } : card)),
50 )
51}
52
53async function ensureCard(
54 $: EngineInterface,
55 id: string,
56 type?: string,
57 description?: string,
58) {
59 const held = await read($, cards)
60 if (held.some(card => card.id === id)) return
61
62 let kind = type
63 let about = description
64 if (kind === undefined || about === undefined) {
65 const known = (await $.agent.list()).find(one => one.id === id)
66 kind = known?.type ?? 'agent'
67 about = known?.description ?? ''
68 }
69 const role = roleOf(kind, about)
70 const card: HelperCard = {
71 id,
72 name: role.name,
73 icon: role.icon,
74 color: PALETTE[held.length % PALETTE.length] ?? PURPLE,
75 status: 'running',
76 steps: 0,
77 tools: 0,
78 action: about === '' ? '启动中…' : about.slice(0, 60),
79 inTokens: 0,
80 outTokens: 0,
81 usd: 0,
82 startedAt: await $.clock.now(),
83 endedAt: null,
84 todoDone: 0,
85 todoTotal: 0,
86 }
87 await update($, cards, list => (list.some(one => one.id === id) ? list : [...list, card]))
88}
89
90async function refreshTotal($: EngineInterface) {
91 const usage = await $.session.usage()
92 const usd = usage.cost?.usd
93 if (usd !== undefined) await update($, sessionUsd, () => usd)
94}
95
96async function tick($: EngineInterface) {
97 const isBusy = (await read($, cards)).some(card => card.status === 'running')
98 if (!isBusy) return
99
100 ticks += 1
101 await update($, now, () => ticks)
102 if (ticks % 5 === 0) await refreshTotal($)
103}
104
105export const register: Register = on => {
106 on('session.start', async ($, e, next) => {
107 await $.command.register({
108 name: 'helpers',
109 description: '打开 Helper Desk:子代理进度与花费看板',
110 })
111 $.clock.every(1000, () => void tick($).catch(() => undefined))
112
113 return next(e)
114 })
115
116 on('command.run', { command: 'helpers' }, async $ => {
117 await $.ui.open({ id: PANE, title: 'Helper Desk' })
118
119 return { text: 'Helper Desk 已打开。' }
120 })
121
122 on('agent.spawn', async ($, e, next) => {
123 const ran = await next(e)
124 if (ran.deny === undefined && ran.agentId !== undefined) {
125 await ensureCard($, ran.agentId, e.subagentType, e.description)
126 if ((await flagOf($, MOD)).isOn) void $.ui.open({ id: PANE, title: 'Helper Desk' })
127 }
128
129 return ran
130 }).catch(($, e, next) => next(e))
131
132 on('turn.start', async ($, e, next) => {
133 if (e.agentId !== undefined) await ensureCard($, e.agentId)
134
135 return next(e)
136 }).catch(($, e, next) => next(e))
137
138 on('tool.call', async ($, e, next) => {
139 if (!(await flagOf($, MOD)).isOn) return next(e)
140 const input = e as unknown as Record<string, unknown>
141 const action = briefOf(e.tool, input)
142
143 if (e.agentId !== undefined) {
144 await ensureCard($, e.agentId)
145 await patchCard($, e.agentId, card => ({ tools: card.tools + 1, action }))
146 }
147 const ran = await next(e)
148
149 if (ran.deny === undefined) {
150 let steps: PlanInput | undefined
151 if (e.tool === 'TodoWrite') steps = { items: e.todos }
152 else if (e.tool === 'mcp__ccd_turn__update_plan') steps = input as PlanInput
153
154 if (steps !== undefined && e.agentId === undefined) {
155 const input = steps
156 await update($, plan, list => applyPlan(list, input))
157 } else if (steps !== undefined && e.agentId !== undefined && e.tool === 'TodoWrite') {
158 const done = e.todos.filter(one => one.status === 'completed').length
159 await patchCard($, e.agentId, () => ({ todoDone: done, todoTotal: e.todos.length }))
160 } else if (e.tool === 'TaskCreate' && e.agentId === undefined) {
161 await update($, plan, list => [...list, { content: e.subject, status: 'pending' as const }])
162 } else if (e.tool === 'TaskUpdate' && e.agentId === undefined) {
163 await update($, plan, list =>
164 list.flatMap((one, at) =>
165 String(at + 1) !== e.taskId
166 ? [one]
167 : e.status === 'deleted'
168 ? []
169 : [{ content: e.subject ?? one.content, status: e.status ?? one.status }],
170 ),
171 )
172 }
173 }
174
175 return ran
176 }).catch(($, e, next) => next(e))
177
178 on('turn.step', async function* ($, e, next) {
179 const result = yield* next(e)
180 if (e.agentId !== undefined && result.usage !== null) {
181 const usage = result.usage
182 await ensureCard($, e.agentId)
183 await patchCard($, e.agentId, card => ({
184 steps: card.steps + 1,
185 inTokens: card.inTokens + usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
186 outTokens: card.outTokens + usage.output_tokens,
187 usd: card.usd + usdOf(usage.model, usage),
188 }))
189 }
190
191 return result
192 })
193
194 on('turn.complete', async ($, e, next) => {
195 if (e.agentId !== undefined) {
196 const at = await $.clock.now()
197 await ensureCard($, e.agentId)
198 await patchCard($, e.agentId, () => ({
199 status: e.reason === 'answer' ? 'done' : 'failed',
200 endedAt: at,
201 action: e.reason === 'answer' ? '已完成' : `已中断(${e.reason})`,
202 }))
203 await refreshTotal($)
204 }
205
206 return next(e)
207 }).catch(($, e, next) => next(e))
208
209 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
210 const { Box, Text, Button } = $.ui.resolve(e)
211 const flag = await flagOf($, MOD)
212 if (!flag.isOn || flag.isHidden) {
213 return <Text dimColor>Helper Desk 已{flag.isOn ? '隐藏' : '关闭'}(/mods 可重新打开)</Text>
214 }
215 const list = await read($, cards)
216 const tick_ = await read($, now)
217 const total = await read($, sessionUsd)
218 const estimated = list.reduce((sum, card) => sum + card.usd, 0)
219
220 return (
221 <Box flexDirection="column" gap={1}>
222 <Text bold>Helper Desk · {list.filter(c => c.status === 'running').length} 个在跑 / 共 {list.length} 个</Text>
223 {list.length === 0 && <Text dimColor>还没有子代理。Claude 派出帮手后会在这里出现。</Text>}
224 {list.map(card => {
225 const ratio = card.todoTotal > 0 ? card.todoDone / card.todoTotal : card.status === 'running' ? -1 : 1
226 const sweep = ratio < 0 ? (tick_ % (BAR - 5)) : 0
227 const shown =
228 ratio < 0
229 ? { on: '░'.repeat(sweep) + '▓▓▓▓▓', off: '░'.repeat(BAR - 5 - sweep) }
230 : fill(ratio)
231
232 return (
233 <Box key={card.id} flexDirection="column" borderStyle="round" borderColor={card.color} paddingX={1}>
234 <Box gap={1}>
235 <Text>{card.icon}</Text>
236 <Text bold color={card.color}>{card.name}</Text>
237 <Text dimColor>
238 {card.status === 'running' ? '● 运行中' : card.status === 'done' ? '✔ 完成' : '✖ 中断'}
239 </Text>
240 <Text bold>{money(card.usd)}</Text>
241 </Box>
242 <Box>
243 <Text color={card.color}>{shown.on}</Text>
244 <Text dimColor>{shown.off}</Text>
245 <Text dimColor>
246 {' '}
247 {card.todoTotal > 0
248 ? `${card.todoDone}/${card.todoTotal}`
249 : card.status === 'running'
250 ? `第 ${card.steps} 步`
251 : '100%'}
252 </Text>
253 </Box>
254 <Text wrap="truncate-end">{card.action}</Text>
255 <Text dimColor>
256 {card.tools} 次工具 · 读入 {tokens(card.inTokens)} · 输出 {tokens(card.outTokens)}
257 </Text>
258 </Box>
259 )
260 })}
261 <Box flexDirection="column">
262 <Text bold>
263 本次对话总花费 {total > 0 ? money(total) : '(读取中)'}
264 </Text>
265 <Text dimColor>
266 其中子代理合计约 {money(estimated)}(按公开标价估算,以账单为准)
267 </Text>
268 </Box>
269 {list.some(card => card.status !== 'running') && (
270 <Button
271 key="clear"
272 label="清除已结束的卡片"
273 onPress={() => update($, cards, all => all.filter(card => card.status === 'running'))}
274 />
275 )}
276 </Box>
277 )
278 })
279}
280hooks/flag.ts 14 lines1import type { EngineInterface } from 'claude-code'
2
3const FLAGS = { plugin: 'switchboard', key: 'flags' } as const
4
5export type Flag = { isOn: boolean; isHidden: boolean }
6
7// Switchboard owns the flags. A mod it has never heard of counts as on, shown.
8export async function flagOf($: EngineInterface, mod: string): Promise<Flag> {
9 const { value } = await $.state.get(FLAGS)
10 const own = value?.[mod]
11
12 return { isOn: own?.isOn ?? true, isHidden: own?.isHidden ?? false }
13}
14hooks/plan.ts 31 lines1// Pure: how each way of writing a to-do list changes the list.
2import type { HelperPlanStep } from '../types'
3
4export type PlanInput = {
5 items?: { content: string; status: HelperPlanStep['status'] }[]
6 advance?: boolean
7 done?: number[]
8 start?: number
9}
10
11export const applyPlan = (list: HelperPlanStep[], input: PlanInput): HelperPlanStep[] => {
12 if (input.items !== undefined) {
13 return input.items.map(one => ({ content: one.content, status: one.status }))
14 }
15 const next = list.map(one => ({ ...one }))
16 if (input.advance === true) {
17 const active = next.find(one => one.status === 'in_progress')
18 if (active !== undefined) active.status = 'completed'
19 const waiting = next.find(one => one.status === 'pending')
20 if (waiting !== undefined) waiting.status = 'in_progress'
21 }
22 for (const n of input.done ?? []) {
23 const one = next[n - 1]
24 if (one !== undefined) one.status = 'completed'
25 }
26 const started = input.start === undefined ? undefined : next[input.start - 1]
27 if (started !== undefined) started.status = 'in_progress'
28
29 return next
30}
31hooks/prices.ts 30 lines1// Estimated list prices, USD per million tokens: [input, output]. Cache reads
2// cost 0.1x input, cache writes 1.25x. An estimate only: the session total
3// shown beside it comes from the engine's own cost ledger.
4
5const FAMILIES: [RegExp, [number, number]][] = [
6 [/opus/i, [5, 25]],
7 [/sonnet/i, [3, 15]],
8 [/haiku/i, [1, 5]],
9 [/fable/i, [10, 50]],
10]
11
12export type Tokens = {
13 input_tokens: number
14 output_tokens: number
15 cache_read_input_tokens: number
16 cache_creation_input_tokens: number
17}
18
19export const usdOf = (model: string, t: Tokens): number => {
20 const [pin, pout] = FAMILIES.find(([pattern]) => pattern.test(model))?.[1] ?? [3, 15]
21
22 return (
23 (t.input_tokens * pin +
24 t.output_tokens * pout +
25 t.cache_read_input_tokens * pin * 0.1 +
26 t.cache_creation_input_tokens * pin * 1.25) /
27 1_000_000
28 )
29}
30hooks/roles.ts 24 lines1// Pure: a helper's name, icon and color from what it was asked to do.
2
3const ROLES: [RegExp, string, string][] = [
4 [/review|审查|审核|audit/i, '代码审查代理', '🔍'],
5 [/search|research|find|explore|调研|搜集|查找|资料|investigat/i, '资料搜集代理', '📚'],
6 [/test|测试|verify|验证/i, '测试验证代理', '🧪'],
7 [/fix|debug|bug|修复|排错/i, '排错代理', '🛠'],
8 [/write|draft|copy|文案|撰写|写作|blog/i, '写作代理', '✍️'],
9 [/plan|规划|方案|design|设计|architect/i, '规划设计代理', '🧭'],
10 [/seo|geo|rank/i, 'SEO 代理', '📈'],
11 [/image|图|视频|video/i, '素材代理', '🎞'],
12]
13
14export const PALETTE = ['#8b5cf6', '#06b6d4', '#f59e0b', '#10b981', '#ec4899', '#3b82f6', '#ef4444']
15
16export const roleOf = (type: string, description: string) => {
17 const text = `${type} ${description}`
18 const hit = ROLES.find(([pattern]) => pattern.test(text))
19 if (hit !== undefined) return { name: hit[1], icon: hit[2] }
20 const short = description.trim().slice(0, 14)
21
22 return { name: short === '' ? `${type} 代理` : `${short} 代理`, icon: '🤖' }
23}
24types/index.d.ts 36 lines1export type HelperCard = {
2 id: string
3 name: string
4 icon: string
5 color: string
6 status: 'running' | 'done' | 'failed'
7 steps: number
8 tools: number
9 action: string
10 inTokens: number
11 outTokens: number
12 usd: number
13 startedAt: number
14 endedAt: number | null
15 todoDone: number
16 todoTotal: number
17}
18
19export type HelperPlanStep = {
20 content: string
21 status: 'pending' | 'in_progress' | 'completed'
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'helper-desk': {
27 cards: HelperCard[]
28 plan: HelperPlanStep[]
29 now: number
30 sessionUsd: number
31 }
32 // Read-only view of Switchboard's value (it alone writes it).
33 switchboard: { flags: Record<string, { isOn: boolean; isHidden: boolean }> }
34 }
35}
36