Day 17 的 hooks 在事件發生時跑一支 Claude Code 外面的 shell 腳本,回一個退出碼就結束。Day 21 的 plugin 是打包層。
2.1.287 加進來的 mods 介於兩者之間:它是一段 JavaScript 或 TypeScript,跑在 Claude Code 的行程裡面,能攔事件、改事件,還能畫介面:提示上方的一行狀態、逐字稿旁的面板、不等 Claude 回答就執行的指令。
這個機制 2026 年 10 月初才上線,API 還在變。
mod 是一種 plugin。plugin 的 hooks/hooks.json 裡如果有 modules 這個欄位,指向一個 JS 或 TS 檔,這個 plugin 就是 mod。它和其他 plugin 一樣從 marketplace 裝,同一個 plugin 也能同時帶 skill 與 MCP server。
那個檔案叫 hooks module,匯出 register(on),用 on('事件名', handler) 掛處理函式。handler 拿到三個參數:$ 是 mods API,e 是事件,next 交給下一個處理者,很像 middleware。處理一個事件有三種做法:
next。next,例如拒絕一個工具呼叫。和其他擴充方式比,差在跑在哪、能不能畫畫面:
| mod | settings hook | skill | MCP server | |
|---|---|---|---|---|
| 是什麼 | plugin 裡的函式,在 Claude Code 行程內被呼叫 | 事件發生時跑的 shell 指令、HTTP 請求或 prompt | Claude 會讀的 SKILL.md |
外部行程,提供工具給 Claude |
| 能畫介面 | 能 | 不能 | 不能 | 不能 |
| 適合 | 要面板、提示上方的一行、自訂指令,或要改寫事件 | 已經有腳本,只想擋、放行或記錄 | 同樣的說明一直貼進對話 | Claude 要碰外部系統 |

畫介面的位置叫 render site,最常用兩個:Pane 在全螢幕且夠寬的終端機裡是逐字稿旁的側欄,否則是提示上方一塊有框的區域;AbovePrompt 是提示輸入框正上方的一條帶狀區(band),所有 mod 共用。
有一處刻意碰不到:mod 不能改權限提示顯示的內容。settings.json 的 statusLine 則是另一套機制,跑 shell 腳本,不是 mod。
Claude Code 自己也有幾個功能是 mod,列在 /plugin 的 Installed 分頁 Built-in 區,例如接手 /diff 的 cc-plugin-diff。其中 You should know 預設關閉,它用側邊 agent 在長任務中幫你盯,在提示上方提醒你可能漏掉的事。用 /plugin enable cc-plugin-you-should-know@builtin 開啟,條件是第一方 session 且開著 telemetry。
mod 跑在 Claude Code 裡,用的是你的權限,載入之後它可以:
而且 mod 沒有 sandbox。Day 26 的 sandbox 隔離的是 Claude 跑的 Bash 指令,mod 自己啟動的程式在 sandbox 外面。權限規則也一樣:Read(.env) 的 deny 管的是 Claude 的工具呼叫,mod 用自己的 $.fs.read 照樣讀得到。Day 26 說 deny 在每個 mode 都有效、settings hook 的 allow 也蓋不掉,mod 是例外:在沒有 managed settings、也不是 Team 或 Enterprise 方案的機器上,mod 可以核准一個 deny 規則會拒絕的呼叫。
所以要先看它碰什麼。mod 只能透過 $ 做事,靜態就看得出來,不用執行:
claude plugin validate ./some-mod
輸出裡的 hooks: 列出它處理哪些事件,calls: 列出它呼叫哪些 API。看到這幾種要多想一下:$.fs.write(寫檔)、$.process.run 與 $.process.spawn(啟動程式)、$.http.fetch(連網)、$.env.get 與 $.settings.read(可能讀到金鑰)、$.model.complete(花你的額度)、$.prompt.submit 與 $.session.send(替你送出)。calls: 只列方法、不列參數,出現 $.process.run 就等於什麼程式都能跑,沒有 $.http.fetch 也不代表不會連網。

有 managed settings 的機器、或登入 Team 與 Enterprise 時,會載入內建守衛,讓使用者的 mod 改不到組織管的 hooks、system prompt 與指令檔;組織也可以只允許自己管的 mod。互動 session 在還沒信任的目錄裡,回答信任提示之前不載入任何 mod。
要關掉有三種粒度:在 /plugin 停用單一個,內建的多數也能這樣關;用 --safe-mode 啟動,這次 session 全關,CLAUDE.md、skills、MCP 也會一起關;在 ~/.claude/settings.json 設 "disableAllHooks": true,永遠關,settings hooks 與自訂狀態列也會停,組織管的照跑。後兩種關不到內建 mod。
目前有三個地方:
claude-community 安裝。三個範例的原始碼在 anthropics/claude-code-playground 的 claude-code/mods 目錄,官方標明是照原樣分享、不提供支援。karanb192/awesome-claude-code-mods。它自動掃描公開 repo 並整理 validate 結果,可以依 Draws & remembers、Reads files、Writes or runs、Uses the network、In Anthropic directory 篩選。它自己寫明是獨立掃描,不是官方目錄,掃描也不代表行為安全。依存取範圍分兩組。先從第一組裝起:
只讀資料、只畫畫面
scasella/claude-flightdeck):用 /flightdeck 開即時面板,列出 context、費用、權限判斷結果與 subagent 狀態。它的工具呼叫 hook 只觀察、原樣放行,calls: 裡沒有讀寫檔、跑程式、連網的 API。會跑程式或讀寫檔,裝之前先讀 README
rm -rf、git reset --hard、強制推送這類指令,開面板列出會動到的檔案或 commit,按 Proceed 才放行。為了算出影響,它會跑 git、bash,以及各框架查 migration 狀態的指令,後者會執行專案自己的程式碼。它比對的是命令文字,$(…)、別名都攔不到,README 寫明是安全網,不是權限系統,真要擋用權限規則。jarrodwatts/claude-image-view):把貼進來的圖片縮圖畫在提示上方。會讀圖片暫存檔,必要時用 sips 轉檔並寫入暫存資料夾,不連網。Ghostty、kitty 顯示真實圖片,其他終端機改用色塊縮圖。data-goblin/claude-code-filetree):側欄檔案樹,標出 git 狀態與 Claude 正在讀改的檔案。會跑 find、du、git,雙擊檔案預設用外部程式開啟;側欄需要全螢幕且至少 110 欄。另外要小心會把資料送到第三方、把金鑰寫進明文檔、在背景起常駐伺服器、每輪額外花 token 的 mod。
試官方範例:clone 那個 repo,在 claude-code/mods 目錄跑 claude --plugin-dir ./token-weather,只在這次 session 載入。想長期用,在同一個目錄跑 claude plugin marketplace add ./,再 claude plugin install token-weather@claude-code-playground-mods。裝的時候若已開著 session,在那裡跑 /reload-plugins,否則下次啟動自動載入。
有兩條路。一條是請 Claude 寫:Claude Code 內建一個 plugin-authoring skill,描述你要的 mod,它會寫在 ~/.claude/dev-mods/ 底下這個 session 的目錄裡。那是受保護的路徑,default 與 acceptEdits 模式下每個檔案都會先問你,第一次存檔時還會問要不要啟用熱重載。它只在那個 session 載入,過了 cleanupPeriodDays 會被清掉。
另一條是自己寫三個檔案,用 claude --plugin-dir 載入,存檔就熱重載。下面這個示意 mod 接 Day 28 的交接流程:每輪結束,在提示上方顯示 review.md 的指紋是否等於 staged diff 的指紋,需要 git 與 shasum。綠燈只代表指紋對得上,能不能提交仍由 Day 28 的守門腳本判斷。
.claude-plugin/plugin.json 寫 name、version、description,hooks/hooks.json 只有一行 { "modules": ["./register.js"] },程式放在 hooks/register.js:
// 指紋要在 shell 裡算:$.process.run 不經 shell,拿回來的 stdout 也是解碼過的文字
const FINGERPRINT =
"git diff --cached --binary --no-ext-diff --no-textconv -- . ':!plan.md' ':!review.md' | shasum -a 256"
const EMPTY = 'e3b0c442'
let line = null
async function check($) {
const cwd = await $.session.cwd()
const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 5000 })
if (top.exitCode !== 0) return null
// 一律到 repo 根目錄算,否則在子目錄只會算到那個子目錄的變更
const root = top.stdout.trim()
if (!(await $.fs.exists(root + '/review.md'))) return null
// 和 Day 28 的守門腳本一樣:只看第一行 reviewed-fingerprint,值要剛好是 64 碼
const header = (await $.fs.read(root + '/review.md')).match(/^reviewed-fingerprint: *([^\n]*)/m)
const reviewed = header ? header[1] : ''
if (!/^[0-9a-f]{64}$/.test(reviewed)) return 'handoff: review.md has no valid fingerprint'
const run = await $.process.run(['sh', '-c', FINGERPRINT], { cwd: root, timeoutMs: 20000 })
const staged = run.stdout.trim().split(/\s+/)[0]
if (run.exitCode !== 0 || !/^[0-9a-f]{64}$/.test(staged)) return 'handoff: cannot hash staged diff'
if (staged.startsWith(EMPTY)) return 'handoff: nothing staged'
return staged === reviewed
? 'handoff: review matches ' + staged.slice(0, 8)
: 'handoff: STALE review ' + reviewed.slice(0, 8) + ' vs staged ' + staged.slice(0, 8)
}
export function register(on) {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'handoff-check', description: 'Check review.md against the staged diff' })
return next(e)
})
// 主回合結束後重算,subagent 的回合跳過
on('turn.complete', async ($, e, next) => {
const result = await next(e)
if (e.agentId) return result
line = await check($).catch(() => 'handoff: check failed')
$.ui.invalidate('ui.render')
return result
})
// 沒有畫面的時候(例如 claude -p),用指令拿同一個結果
on('command.run', { command: 'handoff-check' }, async ($) => {
const text = await check($).catch(() => 'handoff: check failed')
return { text: text ?? 'handoff: no review.md here' }
})
// 問卷出現時讓開;其他 mod 畫的內容放在上面一起留著
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
if (e.props.hasSurvey || line === null) return next(e)
const { Box, Text } = $.ui.resolve(e)
const color = line.includes('matches') ? 'green' : 'yellow'
return Box({ flexDirection: 'column', children: [await next(e), Text({ children: [line], color })] })
})
}
寫的時候會碰到的幾件事:
$.process.run 不經 shell,stdout 是解碼過的文字且上限 4 MiB,拿到 JS 裡算雜湊會和位元組的不一樣。-- . 相對於目前目錄,在子目錄跑只會算到那個子目錄的變更,不會報錯。$ 只能傳給同檔頂層的函式。 事件名要是字串字面值、不能動態 import(),好讓 validate 看得出每個呼叫,違反會被拒絕載入。await next(e) 一起放進 Box;有問卷時讓開。subagent 的回合也會觸發 turn.complete,用 e.agentId 跳過。line 歸零後,要等下一輪結束 band 才回來;要保留的放 $.state。$ 的時間不算。.claude-plugin/types/ 的型別檔為準。驗證不需要登入:claude plugin validate . 列出呼叫;claude plugin test 跑自己寫的 tests/*.test.ts(從 claude-code/testing 引入),不需 session 與網路;指令型的功能在要檢查的 repo 裡跑 claude -p "/handoff-check" --plugin-dir 加上 mod 的絕對路徑。路徑寫錯不會報錯,指令會被當成一般 prompt。band 的實際外觀要開互動 session 才看得到。
分享 mod 和 Day 21 分享 plugin 一樣,依對象選:

.zip。/plugin marketplace add 加入之後安裝。data/seeds.txt,加上 claude-code-mod topic 能讓掃描更快找到。命名避開 claude- 開頭這類像官方的名稱,validate 會擋下。
hooks/hooks.json 有 modules 欄位,指向在 Claude Code 行程內執行的 JS 或 TS。它能看、改、接手事件,還能畫介面,這是 settings hook、skill、MCP 都做不到的。claude plugin validate,看 hooks: 與 calls:。$ 只傳給頂層函式、跨重載的狀態放 $.state。validate、claude plugin test 與 claude -p "/指令" 都不用登入就能驗。