回合跑超過一分鐘才結束時,發一則可愛的 macOS 通知叫你回來;/ding 可以預覽

我為自己的開發流程寫的四個 Claude Code mod。Mod 是跑在 Claude Code 裡面的 TypeScript 事件處理函式,可以攔截工具呼叫、改寫 prompt,或在介面上畫出自己的區塊。
| Mod | 做什麼 | 指令 |
|---|---|---|
| ding-dong | 回合跑超過一分鐘才結束時,發一則系統通知叫你回來 | /ding 預覽通知 |
| ship-it | 依序執行專案的階段(檢查、測試、打包、部署),在 prompt 上方的深色面板用像素吉祥物顯示進度 | /ship、/ship demo、/ship init |
| secret-guard | 遮蔽 prompt 與工具輸出裡的金鑰,擋下讀寫 .env 與私鑰 | /guard、/guard on、/guard off |
| pii-mask | 把個資換成代號再送給模型,對照表只留在本機,之後可以還原 | /pii、/pii on、/pii add、/pii restore |
ding-dong 的系統通知只支援 macOS,其他平台會改用 Claude Code 內的 toast把這個 repo 加成 marketplace,再安裝想要的 mod:
claude plugin marketplace add builtbyjia/claude-code-mods
claude plugin install ship-it@builtbyjia-mods
只想試用一次的話,clone 下來後用 --plugin-dir 載入,只在那個 session 生效:
claude --plugin-dir ./ship-it
Mod 會用你的權限執行,可以讀寫檔案、執行指令。安裝任何人的 mod 之前(包括這裡的),建議先用
claude plugin validate <資料夾>看它掛了哪些事件、呼叫了哪些 API,並讀過原始碼。
回合依時長分成四種語氣(一到三分鐘、三到十分鐘、十分鐘以上、出錯),每種隨機抽一句文案,副標題顯示專案資料夾、耗時與工具次數。你自己中斷的回合不會通知。
通知預設由系統的 osascript 發出。想讓通知圖示變成橘色像素吉祥物,執行一次:
./ding-dong/notifier/build.sh
它會在本機編譯出 DingDong.app,之後通知就由這個小程式發出。文案與門檻秒數都在 ding-dong/hooks/register.ts 最上方。
/ship 會在 prompt 正上方畫出一塊深色的橫式面板,吉祥物沿著軌道走向正在執行的階段,每個階段顯示耗時;失敗時流程停下,並列出最後幾行錯誤輸出。面板右上角的「關」可以收起來,再輸入一次 /ship 就會重新出現。
「現在是哪個階段」不是偵測出來的:mod 自己依序執行每個階段的指令,正在跑的那個指令就是目前階段,指令結束時的結束碼是 0 才會往下一個走。吉祥物在兩個節點之間的位置只是依時間估算的動畫,不代表真實進度。
階段的來源依序是:
.claude/ship.jsonpackage.json 裡的 lint、test、build、deploy 這四個 script用 /ship init 可以產生設定檔,格式如下:
{
"stages": [
{ "name": "跑測試", "run": "npm test" },
{ "name": "打包", "run": "npm run build" },
{ "name": "部署", "run": "npm run deploy" }
]
}
run 會交給 bash -c 執行,所以只在你信任的專案裡使用 /ship。/ship demo 用 sleep 模擬五個階段,不會動到專案。
三層防護:
[已遮蔽:GitHub token] 這類標記。.env、私鑰、.npmrc 等檔案,以及 cat .env、printenv 這類指令;.env.example 這類範本檔放行。已知限制:
預設關閉。用 /pii on 開啟後,prompt 和工具輸出裡的個資會先換成 <PERSON_1>、<TW_MOBILE_1> 這類代號才送給模型;同一個原文永遠對應同一個代號。對照表存在本機,只會畫在 /pii 打開的面板上,不會進入對話。
模型產出的檔案裡如果有代號,用 /pii restore 檔案路徑 換回原文,結果另存成 .restored 檔,原檔不動。
能辨識的類型:
| 類型 | 辨識方式 |
|---|---|
| 手機、市話、Email | 格式比對 |
| 身分證、居留證、信用卡 | 格式比對加檢查碼 |
| 統一編號 | 前面寫明「統編」或「統一編號」的八位數字 |
| 地址 | 正式寫法(縣市、鄉鎮市區、路街) |
| 公司 | 以「有限公司」「企業社」「商行」「事務所」結尾 |
| 人名 | 「姓名:」「客戶:」「聯絡人:」這類欄位後面的名字,或用 /pii add 加進名單的詞 |
已知限制:
/pii 檢查對照表是否抓齊。每個 mod 是一個獨立的 plugin 資料夾:
<mod>/
├── .claude-plugin/plugin.json
├── hooks/
│ ├── hooks.json
│ └── register.ts
└── tests/
檢查 Claude Code 會從 mod 讀到什麼:
claude plugin validate ./secret-guard
在 mod 的資料夾裡執行測試,不需要開 session:
claude plugin test
用 --plugin-dir 載入時,存檔就會自動重新載入。
通知圖示與 ship-it 裡的像素小生物是我自己畫的,靈感來自 Claude 的橘色吉祥物,不是 Anthropic 的官方素材。這個專案與 Anthropic 沒有從屬關係。
以 MIT 授權釋出,詳見 LICENSE。
hooks/register.ts 185 lines1import type { EngineInterface, Register, TurnCompleteReason } from 'claude-code'
2
3// 回合至少跑這麼久才通知;短回合你人還在螢幕前,不用吵你
4const MIN_SECONDS = 60
5
6type Mood = 'snack' | 'tea' | 'marathon' | 'oops'
7type Card = { title: string; body: string }
8type Preview = { mood: Mood; seconds: number }
9// 至少有一個元素的陣列,取第一個時型別不會是 undefined
10type NonEmpty<T> = readonly [T, ...T[]]
11
12// 每種心情的文案,通知時隨機抽一張。想改語氣,改這裡就好
13const CARDS: Record<Mood, NonEmpty<Card>> = {
14 // 1 到 3 分鐘
15 snack: [
16 { title: '叮咚!出爐囉', body: '熱騰騰的,趁熱回來看 ٩(ˊᗜˋ*)و' },
17 { title: '好了好了~', body: '我有乖乖做完,快來誇我 (。•̀ᴗ-)✧' },
18 { title: '報告長官', body: '任務完成,請回來驗收!' },
19 ],
20 // 3 到 10 分鐘
21 tea: [
22 { title: '你的茶泡好了嗎', body: '我這邊收工囉,慢慢走回來就好' },
23 { title: '呼~做完了', body: '慢工出細活,回來看看成果吧 ₍ᐢ. .ᐢ₎' },
24 { title: '下課鐘響', body: '作業寫完了,可以來檢查了' },
25 ],
26 // 10 分鐘以上
27 marathon: [
28 { title: '馬拉松跑完了', body: '腿好痠,給我一點掌聲' },
29 { title: '我還活著!', body: '這回合好長,但我撐到終點了' },
30 { title: '大工程竣工', body: '先伸個懶腰再回來看' },
31 ],
32 // 出錯或被拒絕
33 oops: [
34 { title: '呱…卡住了', body: '這回合沒有順利結束,回來救我一下 (;ω;)' },
35 { title: '嗚嗚出包了', body: '我跌倒了,需要你回來看看' },
36 ],
37}
38
39// macOS 內建音效,檔案在 /System/Library/Sounds
40const SOUNDS: Record<Mood, string> = {
41 snack: 'Pop',
42 tea: 'Glass',
43 marathon: 'Hero',
44 oops: 'Frog',
45}
46
47// /ding 預覽時輪流展示的心情,秒數是示範用的假時長
48const PREVIEWS: NonEmpty<Preview> = [
49 { mood: 'snack', seconds: 95 },
50 { mood: 'tea', seconds: 320 },
51 { mood: 'marathon', seconds: 1260 },
52 { mood: 'oops', seconds: 75 },
53]
54
55function moodOf(reason: TurnCompleteReason, seconds: number): Mood {
56 if (reason !== 'answer') {
57 return 'oops'
58 }
59
60 if (seconds < 180) {
61 return 'snack'
62 }
63
64 return seconds < 600 ? 'tea' : 'marathon'
65}
66
67function pickCard(mood: Mood): Card {
68 const cards = CARDS[mood]
69
70 return cards[Math.floor(Math.random() * cards.length)] ?? cards[0]
71}
72
73function clock(seconds: number): string {
74 const hours = Math.floor(seconds / 3600)
75 const minutes = Math.floor((seconds % 3600) / 60)
76
77 if (hours > 0) {
78 return `${hours} 小時 ${minutes} 分`
79 }
80
81 return minutes > 0 ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`
82}
83
84function folderOf(path: string): string {
85 return path.split('/').filter(Boolean).pop() ?? path
86}
87
88async function send($: EngineInterface, mood: Mood, card: Card, seconds: number, tools: number): Promise<void> {
89 const folder = folderOf(await $.session.cwd())
90 const subtitle = `${folder} · ${clock(seconds)} · ${tools} 次工具`
91 const notifier = `${$.plugin.root}/notifier`
92
93 try {
94 // 首選:透過 DingDong.app 發通知,圖示才會是橘色吉祥物。
95 // app 讀同資料夾的 message.txt,四行依序是標題、副標題、內文、音效
96 if (await $.fs.exists(`${notifier}/DingDong.app`)) {
97 await $.fs.write(`${notifier}/message.txt`, [card.title, subtitle, card.body, SOUNDS[mood]].join('\n'))
98 const opened = await $.process.run(['open', '-g', '-n', '-a', `${notifier}/DingDong.app`], { timeoutMs: 5000 })
99
100 if (opened.exitCode === 0) {
101 return
102 }
103 }
104
105 // 備援:沒有 app 時直接用 osascript,圖示會是系統的「工序指令編寫程式」。
106 // 文字用 argv 傳給 AppleScript,不拼進腳本字串,引號和反斜線就不必跳脫
107 const sent = await $.process.run(
108 [
109 'osascript',
110 '-e',
111 'on run argv',
112 '-e',
113 'display notification (item 1 of argv) with title (item 2 of argv) subtitle (item 3 of argv) sound name (item 4 of argv)',
114 '-e',
115 'end run',
116 card.body,
117 card.title,
118 subtitle,
119 SOUNDS[mood],
120 ],
121 { timeoutMs: 5000 },
122 )
123
124 if (sent.exitCode === 0) {
125 return
126 }
127 } catch {
128 // 兩種都發不出去(例如不是 macOS)時,改用下面的 toast
129 }
130
131 $.ui.toast(`${card.title} ${card.body}`)
132}
133
134export const register: Register = on => {
135 let tools = 0
136 let previews = 0
137
138 on('session.start', async ($, e, next) => {
139 await $.command.register({
140 name: 'ding',
141 description: '預覽回合完成通知,每次換一種心情',
142 })
143
144 return next(e)
145 })
146
147 on('turn.start', ($, e, next) => {
148 tools = 0
149
150 return next(e)
151 })
152
153 on('tool.call', ($, e, next) => {
154 // 只算主對話的工具,subagent 的不算
155 if (e.agentId === undefined) {
156 tools += 1
157 }
158
159 return next(e)
160 })
161
162 on('turn.complete', async ($, e, next) => {
163 const done = await next(e)
164 const seconds = Math.round(e.durationMs / 1000)
165 // 被你中斷的回合不通知:你人就在現場
166 const isWorthIt = e.agentId === undefined && !e.isAborted && seconds >= MIN_SECONDS
167
168 if (isWorthIt) {
169 const mood = moodOf(e.reason, seconds)
170 await send($, mood, pickCard(mood), seconds, tools)
171 }
172
173 return done
174 })
175
176 on('command.run', { command: 'ding' }, async $ => {
177 const { mood, seconds } = PREVIEWS[previews % PREVIEWS.length] ?? PREVIEWS[0]
178 const card = pickCard(mood)
179 previews += 1
180 await send($, mood, card, seconds, 7)
181
182 return { text: `已送出預覽通知(${mood}):${card.title}` }
183 })
184}
185