把 context window 畫成一顆腦:工具呼叫點亮對應腦區,用量越滿越擁擠,compact 時睡眠整理

Learning Hacker 的 Claude Code mods 合集。主題有兩個:把 agent 的運作畫成看得懂的東西,以及讓「同時開好幾個 Claude Code session 分工」這件事變順。
狀態:照現狀提供(as-is),作為示範與實用工具分享,不提供支援。
| mod | 做什麼 | 版本 |
|---|---|---|
dispatch-board | 多個 session 分工時的派工看板:讀你寫的 mission 檔,顯示誰完成、誰在跑、誰在等你,狀態轉換時跳 toast | 0.1.0 |
dispatch-relay | dispatch-board 的搭檔:worker 把回報寫進結果檔後以一行結束,由 mod 在 turn 結束時轉送給指揮站 | 0.1.0 |
handoff-runner | bg session 在 worktree 做完的分支,由你在面板按一顆按鈕快轉進 main,執行前檢查、執行後驗證 | 0.1.0 |
working-memory | 把 context window 畫成一顆腦:工具呼叫點亮對應腦區,用量越滿越擁擠,compact 時睡眠整理 | 0.1.0 |
每個 mod 都可以單獨安裝,不用全裝。
需要 Claude Code v2.1.287 以上(claude --version 確認)。
先加入 marketplace(一次就好):
/plugin marketplace add Hangghost/learning-hacker-claude-mod
再挑想要的裝:
/plugin install dispatch-board@learning-hacker-claude-mod
/plugin install dispatch-relay@learning-hacker-claude-mod
/plugin install handoff-runner@learning-hacker-claude-mod
/plugin install working-memory@learning-hacker-claude-mod
dispatch-relay 需要 v2.1.289 以上。
在 shell 裡用 claude plugin marketplace add …、claude plugin install … 也可以。已開著的 session 執行 /reload-plugins 載入。
更新:第三方 marketplace 的自動更新預設關閉。可在 /plugin 的 Marketplaces 分頁對本 marketplace 選 Enable auto-update,或手動執行 /plugin marketplace update learning-hacker-claude-mod。
同時開幾個 session 各做一段工作時,你很難一眼看出誰做完了、誰卡住在等你。dispatch-board 讀你寫的 mission 檔,在提示框上方放一行摘要,/dispatch 打開完整面板。
claude agents --json/dispatch demo 用假資料試看畫面安全邊界:完成條件是 shell 指令,mod 會執行它,所以 mission 檔只從使用者層級的目錄讀(預設 ~/.claude/missions/),不讀任何專案目錄或 repo 內的檔案,也不接受專案設定改掉這個目錄。clone 一個陌生 repo 不會讓 mod 執行對方寫的指令。
mission 檔格式與範例見 plugins/dispatch-board/README.md。
分工時 worker 做完要自己 SendMessage 把回報全文送給指揮站,同一份回報等於生成兩次,送完還要多跑一次收尾。dispatch-relay 裝在 worker 那一側:worker 把回報寫進結果檔、以一行結束 turn,mod 在 turn 結束時把新增的內容和最終回覆原文送給指揮站。
station(指揮站的 session 名稱)才啟用;沒寫的 mission 不受影響claude agents --json 查出名稱 → mission 檔裡恰好一個節點指派給這個名稱才啟用SendMessage,同一份內容只請一次前提:指揮站與 worker 都要用 --name 開,名稱與 claude agents 一致;新目錄第一次 claude --bg 前先在該目錄跑一次 claude 接受信任(否則回 Workspace not trusted…);用 --worktree <名> 開的 worker,result_file 相對的是 <repo>/.claude/worktrees/<名>。自訂 mission 目錄只要在 dispatch-board 設一次,relay 會沿用。
安全邊界:mod 會把檔案內容送到另一個 session。它只送對到的節點的結果檔(必須在 worker 的專案根目錄底下,解析符號連結後也一樣)與最終回覆,只送給名冊上恰好一筆對到 station 的 session;mission 檔的讀取規則與 dispatch-board 相同。tool.check 只放行它自己發出的送件。
細節見 plugins/dispatch-relay/README.md。
背景 session 在自己的 worktree 做完一條分支後,要併進 main,但 main 正被你的主 checkout 持有,背景 session 動不了它。handoff-runner 讓 session 簽發一張「交接單」,你在 /handoff 面板看過步驟後按「執行」,由 mod 完成 fast-forward。
handoff_issue 簽發交接單,也可以用 /handoff issue <branch> [target] 手動簽發<git common dir>/handoffs/,在 .git 裡面,不會被 commit安全邊界:交接單只帶參數(分支、目標、釘住的 commit),不帶任何指令。要執行的 git 步驟由 mod 依參數產生,執行前比對「畫面上顯示的步驟」與「實際要跑的步驟」完全一致,並只允許 fast-forward、帶預期舊值的 ref 更新、非強制的 push。偽造的交接單最多只能讓自己從面板消失,不能讓 mod 執行別的指令。
細節見 plugins/handoff-runner/README.md。
/brain 在側邊開一個 pane,畫出一顆側面的腦(左邊是前額)。
| 腦區 | 比喻 | 什麼時候亮 |
|---|---|---|
| 額葉 | 規劃 | 送出 prompt、TaskCreate/TodoWrite、Skill |
| 運動皮質 | 動手改 | Edit、Write |
| 頂葉 | 操作環境 | Bash 與其他工具 |
| 枕葉 | 閱讀 | Read、Grep、Glob |
| 顳葉 | 聽外界 | WebFetch、WebSearch、MCP 工具 |
| 小腦 | 分身協作 | Agent、SendMessage |
░ 填滿的格子越多;超過 85% 會紅色閃爍✦ 光波從後腦掃到前額,代表「睡眠整理記憶」腦區對應是比喻,不是神經科學主張。
mod 不在沙盒裡執行,會以你的權限跑在 Claude Code 內。建議安裝前先讀原始碼,或 clone 後執行 claude plugin validate ./plugins/<名稱>,看它處理哪些事件、呼叫哪些能力。以下是 v0.1.0 的結果:
dispatch-board
hooks: session.start, command.run{command=dispatch}, ui.close, ui.render{component=AbovePrompt},
ui.render{component=Pane, requestId=dispatch-board}
calls: $.clock.every, $.clock.now, $.command.register, $.env.get, $.fs.exists, $.fs.list, $.fs.read,
$.fs.stat, $.process.run, $.session.cwd, $.session.root, $.settings.read, $.state.get,
$.state.set, $.ui.close, $.ui.copy, $.ui.open, $.ui.resolve, $.ui.toast
$.process.run 只用來跑你 mission 檔裡的完成條件(/bin/sh -c,每條 5 秒逾時)和 claude agents --json;檔案只讀不寫;不連網、不呼叫模型、不送 prompt。
dispatch-relay
hooks: session.start, prompt.submit, prompt.compose, turn.start, tool.call{tool=SendMessage},
session.send, tool.check{tool=SendMessage}, turn.complete
calls: $.env.get, $.fs.exists, $.fs.list, $.fs.read, $.fs.stat, $.process.run, $.prompt.submit,
$.session.cwd, $.session.id, $.session.root, $.session.send, $.settings.read,
$.store.delete, $.store.get, $.store.set, $.ui.log
$.process.run 只跑 claude agents --json;$.session.send 只送給 mission 檔 station 對到的那一個 session;$.prompt.submit 只在送不到時請 worker 自己送;檔案只讀不寫;不連網、不呼叫模型。
handoff-runner
hooks: session.start, tool.call{tool=mcp__handoff-runner__handoff_issue}, command.run{command=handoff},
ui.close, ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=handoff-runner}
calls: $.clock.every, $.command.register, $.fs.list, $.fs.read, $.fs.write, $.process.run,
$.session.cwd, $.state.get, $.state.set, $.tool.register, $.ui.close, $.ui.open,
$.ui.resolve, $.ui.toast
檔案只讀寫 <git common dir>/handoffs/ 底下;$.process.run 只跑唯讀的 git 檢查、交接單的鎖,以及你按下「執行」後的落地步驟;只有開啟 push 時才連網;不呼叫模型、不送 prompt。
working-memory
hooks: session.start, command.run{command=brain}, prompt.submit, tool.call,
session.measure, session.compact, ui.render{component=Pane, requestId=working-memory}
calls: $.clock.every, $.command.register, $.session.usage, $.state.get, $.state.set,
$.ui.open, $.ui.resolve
只讀工具呼叫的名稱與參數摘要來畫圖,不讀寫檔案、不啟動程式、不連網、不呼叫模型。
claude --plugin-dir ./plugins/<名稱> # 單次載入
claude plugin test ./plugins/<名稱> # 跑測試
handoff-runner 另有兩支需要 Node 23.6 以上的測試:node plugins/handoff-runner/tests/real-git.mjs(在暫存 repo 上用真的 git 落地)與 node plugins/handoff-runner/tests/boundary.mjs(檢查執行入口只有一個)。
各 plugin 的 tsconfig.json 依賴 .claude-plugin/types/,那是 Claude Code 產生的型別檔,不放進 repo。
A collection of Claude Code mods by Learning Hacker. Shared as-is, without support. Each mod installs on its own.
/plugin marketplace add Hangghost/learning-hacker-claude-mod
/plugin install dispatch-board@learning-hacker-claude-mod
/plugin install dispatch-relay@learning-hacker-claude-mod
/plugin install handoff-runner@learning-hacker-claude-mod
/plugin install working-memory@learning-hacker-claude-mod
Requires Claude Code v2.1.287+ (dispatch-relay: v2.1.289+).
~/.claude/missions/), never from a project, so cloning a repo cannot make it run someone else's commands.station field (opt-in per mission). It sends only the matched node's result file, kept inside the worker's project root, and only to the one roster session matching station.main with one button. Tickets carry only parameters (branch, target, pinned commit); the git steps are derived by the mod, checked before running and verified after. Push is off by default.MIT
hooks/register.tsx 279 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage } from 'claude-code'
3
4import type { Brain, LogEntry, Region } from '../types'
5
6// ── 腦區與工具的對應(比喻,不是神經科學主張)────────────────────────
7const REGIONS: Region[] = ['frontal', 'motor', 'parietal', 'occipital', 'temporal', 'cerebellum']
8
9const INFO: Record<Region, { name: string; role: string; color: string }> = {
10 frontal: { name: '額葉', role: '規劃', color: 'magenta' },
11 motor: { name: '運動皮質', role: '動手改', color: 'yellow' },
12 parietal: { name: '頂葉', role: '操作環境', color: 'green' },
13 occipital: { name: '枕葉', role: '閱讀', color: 'cyan' },
14 temporal: { name: '顳葉', role: '聽外界', color: 'blue' },
15 cerebellum: { name: '小腦', role: '分身協作', color: 'white' },
16}
17
18const regionOfTool = (tool: string): Region => {
19 if (/^(Read|Grep|Glob|NotebookRead|LS)$/.test(tool)) return 'occipital'
20 if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return 'motor'
21 if (/^(WebFetch|WebSearch)$/.test(tool) || tool.startsWith('mcp__')) return 'temporal'
22 if (/^(Agent|Task|SendMessage)$/.test(tool)) return 'cerebellum'
23 if (/^(TaskCreate|TaskUpdate|TodoWrite|Skill|EnterPlanMode|ExitPlanMode)$/.test(tool)) return 'frontal'
24 return 'parietal'
25}
26
27// ── 腦的形狀:側面圖,左邊是前額 ─────────────────────────────────────
28const W = 46
29const H = 15
30const TICK_MS = 100
31const DECAY = 0.86
32const SLEEP_TICKS = 28
33
34type Cell = { region: Region | 'stem'; u: number; seed: number }
35
36const hash = (n: number) => {
37 let x = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b)
38 x ^= x >>> 13
39 x = Math.imul(x, 0xc2b2ae35)
40 x ^= x >>> 16
41 return (x >>> 0) / 4294967296
42}
43
44const MAP: (Cell | null)[][] = Array.from({ length: H }, (_, y) =>
45 Array.from({ length: W }, (_, x) => {
46 const u = (x + 0.5) / W
47 const v = (y + 0.5) / H
48 const seed = y * W + x
49 // 大腦:外緣加一點起伏,像腦回
50 const dx = (u - 0.47) / 0.46
51 const dy = (v - 0.4) / 0.38
52 const wobble = 1 + 0.05 * Math.sin(Math.atan2(dy, dx) * 9)
53 const inCerebrum = dx * dx + dy * dy <= wobble * wobble
54 // 小腦與腦幹
55 const cx = (u - 0.77) / 0.15
56 const cy = (v - 0.8) / 0.17
57 const inCerebellum = cx * cx + cy * cy <= 1
58 const inStem = u > 0.57 && u < 0.65 && v > 0.7
59 if (inCerebrum) {
60 // 外側溝與中央溝留白,讓腦區分得出來
61 const sylvian = Math.abs(v - (0.62 - (u - 0.2) * 0.25)) < 0.035 && u > 0.2 && u < 0.62
62 const central = Math.abs(u - (0.42 + (v - 0.1) * 0.12)) < 0.012 && v < 0.55
63 if (sylvian || central) return null
64 let region: Region
65 if (v > 0.6 - (u - 0.2) * 0.25 && u > 0.2 && u < 0.74) region = 'temporal'
66 else if (u < 0.42 + (v - 0.1) * 0.12 - 0.09) region = 'frontal'
67 else if (u < 0.42 + (v - 0.1) * 0.12) region = 'motor'
68 else if (u > 0.76) region = 'occipital'
69 else region = 'parietal'
70 return { region, u, seed }
71 }
72 if (inCerebellum) return { region: 'cerebellum', u, seed }
73 if (inStem) return { region: 'stem', u, seed }
74 return null
75 }),
76)
77
78// ── 狀態 ─────────────────────────────────────────────────────────────
79const EMPTY: Brain = { tick: 0, act: REGIONS.map(() => 0), fill: 0, tokens: 0, window: 0, sleepAt: null, log: [] }
80const brain = atom({ plugin: 'working-memory', key: 'brain' } as const, EMPTY)
81
82const PANE = 'working-memory'
83
84async function fire($: EngineInterface, region: Region, entry: LogEntry | null) {
85 await update($, brain, b => ({
86 ...b,
87 act: b.act.map((a, i) => (REGIONS[i] === region ? 1 : a)),
88 log: entry ? [...b.log, entry].slice(-6) : b.log,
89 }))
90}
91
92async function measure($: EngineInterface, c: SessionContextUsage) {
93 if (c.percent === undefined || c.tokens === undefined) return
94 const { percent, tokens, window } = c
95 await update($, brain, b => ({ ...b, fill: percent / 100, tokens, window }))
96}
97
98let ticking = false
99
100// 每一格:活化衰減、推進睡眠動畫;沒有東西在動就不重畫
101async function step($: EngineInterface) {
102 if (ticking) return
103 ticking = true
104 try {
105 const b = await read($, brain)
106 const isMoving = b.act.some(a => a > 0.02) || b.sleepAt !== null || b.fill >= 0.85
107 if (!isMoving) return
108 await update($, brain, cur => {
109 const tick = cur.tick + 1
110 const isAwake = cur.sleepAt !== null && tick - cur.sleepAt > SLEEP_TICKS
111 return {
112 ...cur,
113 tick,
114 act: cur.act.map(a => (a * DECAY < 0.02 ? 0 : a * DECAY)),
115 sleepAt: isAwake ? null : cur.sleepAt,
116 }
117 })
118 } finally {
119 ticking = false
120 }
121}
122
123// ── 畫面 ─────────────────────────────────────────────────────────────
124type Run = { text: string; color?: string; bold?: boolean; dim?: boolean }
125
126const paintCell = (cell: Cell | null, b: Brain): Run => {
127 if (cell === null) return { text: ' ' }
128 if (cell.region === 'stem') return { text: '▒', dim: true }
129
130 // 睡眠:一道波從後腦掃到前額,掃過的地方清空
131 if (b.sleepAt !== null) {
132 const wave = 1 - (b.tick - b.sleepAt) / (SLEEP_TICKS * 0.8)
133 const d = cell.u - wave
134 if (Math.abs(d) < 0.04) return { text: '✦', color: 'white', bold: true }
135 if (d > 0) return { text: '·', color: 'blue', dim: true }
136 }
137
138 const color = INFO[cell.region].color
139 const act = b.act[REGIONS.indexOf(cell.region)] ?? 0
140 // 神經元放電:活化越強,同時閃的格子越多,每一格隨影格隨機
141 if (act > 0.02 && hash(cell.seed * 31 + b.tick) < act * 0.85) {
142 return { text: act > 0.55 ? '█' : '▓', color, bold: true }
143 }
144 // 記憶佔用:用量多少,就有多少比例的格子被填滿
145 if (hash(cell.seed) < b.fill) {
146 const isOverload = b.fill >= 0.85 && b.tick % 8 < 4
147 return { text: '░', color: isOverload ? 'red' : color }
148 }
149 return { text: '·', dim: true }
150}
151
152const runsOf = (cells: Run[]): Run[] =>
153 cells.reduce<Run[]>((acc, c) => {
154 const last = acc[acc.length - 1]
155 if (last && last.color === c.color && last.bold === c.bold && last.dim === c.dim) {
156 last.text += c.text
157 } else acc.push({ ...c })
158 return acc
159 }, [])
160
161const fmt = (n: number) =>
162 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
163
164const mood = (b: Brain) =>
165 b.sleepAt !== null
166 ? '睡眠中:整理記憶…'
167 : b.fill >= 0.85
168 ? '過載!快要需要睡一覺(compact)'
169 : b.fill >= 0.6
170 ? '有點擁擠'
171 : '清醒、專注'
172
173const detailOf = (e: Record<string, unknown>): string => {
174 const raw = e.file_path ?? e.command ?? e.pattern ?? e.url ?? e.query ?? e.description ?? e.skill ?? ''
175 const s = String(raw).replace(/\s+/g, ' ')
176 const short = s.includes('/') && !s.includes(' ') ? (s.split('/').pop() ?? s) : s
177 return short.length > 36 ? short.slice(0, 35) + '…' : short
178}
179
180export const register: Register = on => {
181 on('session.start', async ($, e, next) => {
182 await $.command.register({ name: 'brain', description: '打開工作記憶腦圖' })
183 const { context } = await $.session.usage()
184 await measure($, context)
185 $.clock.every(TICK_MS, () => void step($))
186 return next(e)
187 })
188
189 on('command.run', { command: 'brain' }, async $ => {
190 await $.ui.open({ id: PANE, title: '工作記憶 · Working Memory' })
191 return { text: '工作記憶腦圖已打開。' }
192 })
193
194 on('prompt.submit', async ($, e, next) => {
195 await fire($, 'frontal', { region: 'frontal', tool: '思考', detail: '收到新任務' })
196 return next(e)
197 })
198
199 on('tool.call', async ($, e, next) => {
200 const region = regionOfTool(e.tool)
201 await fire($, region, { region, tool: e.tool.replace(/^mcp__[^_]+__/, ''), detail: detailOf(e as Record<string, unknown>) })
202 return next(e)
203 })
204
205 on('session.measure', async ($, e, next) => {
206 if (e.changed.includes('context')) await measure($, e.context)
207 return next(e)
208 })
209
210 on('session.compact', async ($, e, next) => {
211 if (e.trigger !== 'precompute') {
212 await update($, brain, b => ({ ...b, sleepAt: b.tick }))
213 }
214 return next(e)
215 })
216
217 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
218 const { Box, Text } = $.ui.resolve(e)
219 const b = await read($, brain)
220 const pct = Math.round(b.fill * 100)
221 const barCells = 20
222 const filled = Math.round(b.fill * barCells)
223 const barColor = b.fill >= 0.85 ? 'red' : b.fill >= 0.6 ? 'yellow' : 'green'
224
225 return (
226 <Box flexDirection="column">
227 <Box>
228 <Text bold>工作記憶 </Text>
229 <Text color={barColor}>{'█'.repeat(filled)}</Text>
230 <Text dimColor>{'░'.repeat(barCells - filled)}</Text>
231 <Text bold color={barColor}> {pct}%</Text>
232 <Text dimColor> {b.window > 0 ? `${fmt(b.tokens)}/${fmt(b.window)}` : ''}</Text>
233 </Box>
234 <Text dimColor>狀態:{mood(b)}</Text>
235 <Text> </Text>
236 {MAP.map((row, y) => (
237 <Box key={`row${y}`}>
238 {runsOf(row.map(cell => paintCell(cell, b))).map((r, i) => (
239 <Text key={`r${y}-${i}`} color={r.color} bold={r.bold} dimColor={r.dim}>
240 {r.text}
241 </Text>
242 ))}
243 </Box>
244 ))}
245 <Text> </Text>
246 <Box flexDirection="column">
247 {[REGIONS.slice(0, 3), REGIONS.slice(3)].map((group, gi) => (
248 <Box key={`legend${gi}`}>
249 {group.map(r => {
250 const isLit = (b.act[REGIONS.indexOf(r)] ?? 0) > 0.3
251 return (
252 <Text key={`lg-${r}`} color={INFO[r].color} bold={isLit} dimColor={!isLit}>
253 {isLit ? '● ' : '○ '}
254 {INFO[r].name}·{INFO[r].role}
255 {' '}
256 </Text>
257 )
258 })}
259 </Box>
260 ))}
261 </Box>
262 <Text> </Text>
263 <Text dimColor>最近的神經活動</Text>
264 {b.log.length === 0 && <Text dimColor>(還沒有活動)</Text>}
265 {b.log.map((l, i) => (
266 <Box key={`log${i}`}>
267 <Text color={INFO[l.region].color} bold={i === b.log.length - 1}>
268 {i === b.log.length - 1 ? '▸ ' : ' '}
269 {INFO[l.region].name.padEnd(4, ' ')}
270 </Text>
271 <Text> {l.tool}</Text>
272 <Text dimColor> {l.detail}</Text>
273 </Box>
274 ))}
275 </Box>
276 )
277 })
278}
279types/index.d.ts 24 lines1export type Region = 'frontal' | 'motor' | 'parietal' | 'occipital' | 'temporal' | 'cerebellum'
2
3export type LogEntry = { region: Region; tool: string; detail: string }
4
5export type Brain = {
6 /** 動畫影格計數 */
7 tick: number
8 /** 各腦區活化程度 0–1,順序同 REGIONS */
9 act: number[]
10 /** context 用量 0–1 */
11 fill: number
12 tokens: number
13 window: number
14 /** compact 動畫開始的影格;null 表示沒在睡 */
15 sleepAt: number | null
16 log: LogEntry[]
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'working-memory': { brain: Brain }
22 }
23}
24