SLOPSHOPPER

helper-desk

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

newpaneguardcommandtimeragents
v0.2.0no licenseupdated 2026-10-23weizhongl/claude-code-mods/helper-desk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · helper-desk
│ ┃ Helper Desk ✕ › fix the failing auth test and add an audit log call │ ┃ Helper Desk · 0 个在跑 / 共 0 个 │ ┃ ⏺ Read(src/auth.ts) │ ┃ 还没有子代理。Claude ⎿ Read 6 lines │ ┃ 派出帮手后会在这里出现。 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 本次对话总花费 (读取中) ⏺ Bash(bun test) │ ┃ 其中子代理合计约 ⎿ 3 pass, 1 fail │ ┃ $0.0000(按公开标价估算,以账单为准) │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /helpers │ ⎿ helper-desk: Helper Desk 已打开。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Helper Desk
Helper Desk · 0 个在跑 / 共 0 个 还没有子代理。Claude 派出帮手后会在这里出现。 本次对话总花费 (读取中) 其中子代理合计约 $0.0000(按公开标价估算,以账单为准)
README

Claude Code Mods

十个给 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 怎么用

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 之间不能共享文件,所以每个放一份。

Source 6 files
hooks/register.tsx 280 lines
1import { 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}
280
hooks/flag.ts 14 lines
1import 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}
14
hooks/plan.ts 31 lines
1// 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}
31
hooks/prices.ts 30 lines
1// 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}
30
hooks/roles.ts 24 lines
1// 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}
24
types/index.d.ts 36 lines
1export 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