Animated desktop notifications when a session starts, a turn finishes, a tool fails, the context fills up, or Claude asks for permission. Nothing extra to…

A lean, curated set of custom skills, commands, hooks, and plugin config for Claude Code.
The document skills (
docx,pptx,xlsx) are not redistributed here: they are under Anthropic's proprietary license. Get them from the official anthropics/skills repo or thedocument-skillsplugin.
cc-plus doesn't vendor these, but declares them as plugin dependencies in .claude-plugin/plugin.json, so installing cc-plus also resolves and enables them:
/impeccable polish, audit, critique, ...Their marketplaces must already be known to your Claude Code installation, otherwise install reports a dependency-unsatisfied error naming the command to run first:
/plugin marketplace add blader/humanizer
/plugin marketplace add pbakaus/impeccable
cc-plus/
├── .claude-plugin/ # Plugin metadata (plugin.json, marketplace.json)
├── commands/ # Custom commands
├── examples/ # Reference files (e.g. settings.json for manual installs)
├── mods/ # Function-hook mods, each its own plugin in the marketplace
├── resources/ # Reference material: example CLAUDE.md files, slash-commands, workflow guides
├── scripts/ # Standalone utility scripts (model switching, litellm proxy)
├── skills/ # Custom skills
├── .dockerignore
├── .env.example
├── .gitignore
├── .mcp.json
├── docker-compose.yml
├── Dockerfile
├── LICENCE
├── README.docker.md
└── README.md
The easiest way to use this collection - install as a Claude Code plugin:
# Add this repo as a marketplace
/plugin marketplace add XD3an/cc-plus
# Install the plugin
/plugin install cc-plus@cc-plus
Or add directly to your ~/.claude/settings.json:
{
"enabledPlugins": {
"cc-plus@cc-plus": true
}
}
If you prefer manual control over what's installed:
# Clone the repo
git clone https://github.com/<your-username>/cc-plus.git
# Copy commands
cp cc-plus/commands/**/*.md ~/.claude/commands/
# Copy skills
cp -r cc-plus/skills/* ~/.claude/skills/
Desktop notifications come from the notify mod: install it with /plugin install notify@cc-plus, or load it with claude --plugin-dir path/to/cc-plus/mods/notify.
Copy desired MCP servers from .mcp.json to your ~/.claude.json.
Important: Replace any YOUR_*_HERE placeholders with your actual API keys.
claude --plugin-dir "path/to/cc-plus"
For a containerized deployment (isolated Linux environment with Claude Code pre-installed), see README.docker.md for detailed instructions.
mods/ holds Claude Code mods: plugins written as TypeScript function hooks (hooks/hooks.json → { "modules": ["./register.ts"] }). Each mod is a separate plugin in this marketplace, so you can install, disable, or break one without touching the others.
| Mod | What it does |
|---|---|
notify | Notifications when a session starts, a long turn finishes, a tool fails, or Claude asks for permission. On Windows, an animated popup with a little Claude buddy (built-in WPF); on macOS osascript, on Linux notify-send. Nothing extra to install. |
/plugin install notify@cc-plus
While developing a mod, load it straight from disk so every save hot-reloads it:
claude --plugin-dir ./mods/notify
claude plugin validate ./mods/notify
claude plugin test ./mods/notify
Mods are an early-access Claude Code surface and the API may change between releases.
Copy .env.example to .env and configure your settings:
cp .env.example .env
For manual installs, start from examples/settings.json and adjust enabledPlugins/hooks to taste.
Contributions are welcome! Please feel free to submit a Pull Request.
See LICENCE for details.
hooks/register.ts 617 lines1import type { EngineInterface, Register } from 'claude-code'
2
3type Platform = 'windows' | 'macos' | 'linux'
4type Kind = 'start' | 'done' | 'fail' | 'permission' | 'error' | 'context'
5type Lang = 'en' | 'zh-TW' | 'zh-CN' | 'ja'
6
7type Note = {
8 kind: Kind
9 title: string
10 body: string
11 /** Small print under the body: turn length, tool count, branch. */
12 meta?: string
13}
14
15const LANGS: readonly Lang[] = ['en', 'zh-TW', 'zh-CN', 'ja']
16
17// Failures often come in bursts (a flaky command retried); one popup per window is enough.
18const FAILURE_COOLDOWN_MS = 10_000
19// `$.store` key: when notifications come back, in ms; absent when they are on.
20const MUTED_UNTIL = 'mutedUntil'
21const FOREVER = Number.MAX_SAFE_INTEGER
22// Context warnings: once at the configured level, once more here; a drop below
23// REARM_PERCENT (a /compact, a /clear) lets them fire again.
24const CONTEXT_CRITICAL_PERCENT = 90
25const REARM_PERCENT = 50
26
27const LOOK: Record<Kind, { emoji: string; macSound: string }> = {
28 start: { emoji: '✳️', macSound: 'Pop' },
29 done: { emoji: '✅', macSound: 'Glass' },
30 fail: { emoji: '💥', macSound: 'Basso' },
31 permission: { emoji: '🔐', macSound: 'Ping' },
32 error: { emoji: '⚠️', macSound: 'Basso' },
33 context: { emoji: '🧠', macSound: 'Funk' },
34}
35
36type Strings = {
37 lines: Record<Kind, readonly string[]>
38 checkItOut: string
39 onBranch: (branch: string) => string
40 failed: string
41 turnError: string
42 tools: (n: number) => string
43 contextBody: (percent: number) => string
44 allow: string
45 deny: string
46 deniedFromPopup: string
47 openFolder: string
48 description: string
49 usage: string
50 statusOn: string
51 statusMuted: (left: string) => string
52 statusOff: string
53 muted: (left: string) => string
54 off: string
55 on: string
56 language: (lang: string) => string
57 badLanguage: string
58 tested: string
59}
60
61const TEXT: Record<Lang, Strings> = {
62 en: {
63 lines: {
64 start: ['Claude is online', 'Ready to roll', 'Standing by'],
65 done: ['All done', 'Wrapped up', 'Mission complete', 'Come take a look'],
66 fail: ['hit a snag', 'tripped up', 'got stuck'],
67 permission: ['needs your OK', 'is waiting on you', 'wants permission'],
68 error: ['The turn stopped', 'Ran into an error'],
69 context: ['Context is filling up', 'Running out of room'],
70 },
71 checkItOut: 'Come check it out~',
72 onBranch: b => `on ${b}`,
73 failed: 'Something went wrong...',
74 turnError: 'The turn ended with an error.',
75 tools: n => `${n} tools`,
76 contextBody: p => `${p}% of the context window is used. Time to /compact or start fresh.`,
77 allow: 'Allow',
78 deny: 'Deny',
79 deniedFromPopup: 'The user denied this from the notification popup.',
80 openFolder: 'Open folder',
81 description: 'Desktop notifications: test, mute, off, on, lang',
82 usage: [
83 '/notify show status',
84 '/notify test [all] show a sample popup',
85 '/notify mute [1h] mute for a while (30m, 2h, ...)',
86 '/notify off | on turn notifications off / back on',
87 '/notify lang <auto|en|zh-TW|zh-CN|ja>',
88 ].join('\n'),
89 statusOn: 'Notifications are on.',
90 statusMuted: left => `Muted for ${left} more.`,
91 statusOff: 'Notifications are off. /notify on brings them back.',
92 muted: left => `Muted for ${left}.`,
93 off: 'Notifications off, in every session, until /notify on.',
94 on: 'Notifications are back on.',
95 language: l => `Language set to ${l}.`,
96 badLanguage: 'Pick one of: auto, en, zh-TW, zh-CN, ja',
97 tested: 'Sent a sample notification.',
98 },
99 'zh-TW': {
100 lines: {
101 start: ['Claude 上線了', '準備開工', '就位,隨時可以開始'],
102 done: ['搞定了', '收工', '任務完成', '交差了,回來看看'],
103 fail: ['翻車了', '出包了', '卡住了'],
104 permission: ['等你點頭', '需要你的授權', 'Claude 在等你'],
105 error: ['這回合中斷了', '遇到錯誤停下來了'],
106 context: ['context 快滿了', '記憶快裝不下了'],
107 },
108 checkItOut: '回來看看吧~',
109 onBranch: b => `在 ${b} 分支`,
110 failed: '出了點問題...',
111 turnError: '這回合因為錯誤而結束了。',
112 tools: n => `${n} 個工具`,
113 contextBody: p => `context 已用 ${p}%,建議 /compact 或開新的 session。`,
114 allow: '允許',
115 deny: '拒絕',
116 deniedFromPopup: 'The user denied this from the notification popup.',
117 openFolder: '開啟資料夾',
118 description: '桌面通知:test、mute、off、on、lang',
119 usage: [
120 '/notify 顯示狀態',
121 '/notify test [all] 跳一張示範通知',
122 '/notify mute [1h] 暫時靜音(30m、2h⋯)',
123 '/notify off | on 關閉/重新開啟通知',
124 '/notify lang <auto|en|zh-TW|zh-CN|ja>',
125 ].join('\n'),
126 statusOn: '通知開啟中。',
127 statusMuted: left => `靜音中,還剩 ${left}。`,
128 statusOff: '通知已關閉。輸入 /notify on 重新開啟。',
129 muted: left => `已靜音 ${left}。`,
130 off: '通知已關閉(所有 session),直到 /notify on。',
131 on: '通知重新開啟了。',
132 language: l => `語言已設為 ${l}。`,
133 badLanguage: '請選擇:auto、en、zh-TW、zh-CN、ja',
134 tested: '已送出一則示範通知。',
135 },
136 'zh-CN': {
137 lines: {
138 start: ['Claude 上线了', '准备开工', '就位,随时可以开始'],
139 done: ['搞定了', '收工', '任务完成', '交差了,回来看看'],
140 fail: ['翻车了', '出问题了', '卡住了'],
141 permission: ['等你点头', '需要你的授权', 'Claude 在等你'],
142 error: ['这一轮中断了', '遇到错误停下来了'],
143 context: ['context 快满了', '快装不下了'],
144 },
145 checkItOut: '回来看看吧~',
146 onBranch: b => `在 ${b} 分支`,
147 failed: '出了点问题...',
148 turnError: '这一轮因为错误而结束了。',
149 tools: n => `${n} 个工具`,
150 contextBody: p => `context 已用 ${p}%,建议 /compact 或开新的会话。`,
151 allow: '允许',
152 deny: '拒绝',
153 deniedFromPopup: 'The user denied this from the notification popup.',
154 openFolder: '打开文件夹',
155 description: '桌面通知:test、mute、off、on、lang',
156 usage: [
157 '/notify 显示状态',
158 '/notify test [all] 弹一张示例通知',
159 '/notify mute [1h] 暂时静音(30m、2h⋯)',
160 '/notify off | on 关闭/重新开启通知',
161 '/notify lang <auto|en|zh-TW|zh-CN|ja>',
162 ].join('\n'),
163 statusOn: '通知已开启。',
164 statusMuted: left => `静音中,还剩 ${left}。`,
165 statusOff: '通知已关闭。输入 /notify on 重新开启。',
166 muted: left => `已静音 ${left}。`,
167 off: '通知已关闭(所有会话),直到 /notify on。',
168 on: '通知重新开启了。',
169 language: l => `语言已设为 ${l}。`,
170 badLanguage: '请选择:auto、en、zh-TW、zh-CN、ja',
171 tested: '已发送一则示例通知。',
172 },
173 ja: {
174 lines: {
175 start: ['Claude がオンラインに', '準備完了', 'いつでもどうぞ'],
176 done: ['できました', '完了です', 'ミッション完了', '見に来てください'],
177 fail: ['でつまずきました', 'で失敗しました', 'で止まりました'],
178 permission: ['許可を待っています', 'あなたの確認が必要です', 'Claude が待っています'],
179 error: ['ターンが中断されました', 'エラーで止まりました'],
180 context: ['コンテキストが残りわずか', 'そろそろ満杯です'],
181 },
182 checkItOut: '見に来てください~',
183 onBranch: b => `${b} ブランチ`,
184 failed: '問題が発生しました...',
185 turnError: 'ターンがエラーで終了しました。',
186 tools: n => `ツール ${n} 回`,
187 contextBody: p => `コンテキストを ${p}% 使用中。/compact か新しいセッションをどうぞ。`,
188 allow: '許可',
189 deny: '拒否',
190 deniedFromPopup: 'The user denied this from the notification popup.',
191 openFolder: 'フォルダを開く',
192 description: 'デスクトップ通知:test、mute、off、on、lang',
193 usage: [
194 '/notify 状態を表示',
195 '/notify test [all] サンプル通知を表示',
196 '/notify mute [1h] しばらくミュート(30m、2h など)',
197 '/notify off | on 通知をオフ/オンにする',
198 '/notify lang <auto|en|zh-TW|zh-CN|ja>',
199 ].join('\n'),
200 statusOn: '通知はオンです。',
201 statusMuted: left => `ミュート中(残り ${left})。`,
202 statusOff: '通知はオフです。/notify on で再開します。',
203 muted: left => `${left} ミュートしました。`,
204 off: 'すべてのセッションで通知をオフにしました(/notify on まで)。',
205 on: '通知を再開しました。',
206 language: l => `言語を ${l} に設定しました。`,
207 badLanguage: 'auto、en、zh-TW、zh-CN、ja から選んでください',
208 tested: 'サンプル通知を送りました。',
209 },
210}
211
212// On Windows the popup is assets/popup.ps1, an animated WPF card. This loader
213// runs it as a script block, so the execution policy has no script file to
214// block, and goes as -EncodedCommand, so the command line strips no quotes.
215// Everything the popup shows travels as JSON in an environment variable: no
216// text the model wrote is ever parsed as script.
217const WINDOWS_LOADER = '. ([scriptblock]::Create([IO.File]::ReadAllText($env:CC_NOTIFY_SCRIPT)))'
218
219const MACOS_NOTIFY = [
220 '-e', 'on run argv',
221 '-e', 'display notification (item 2 of argv) with title (item 1 of argv) subtitle (item 3 of argv) sound name (item 4 of argv)',
222 '-e', 'end run',
223]
224
225const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
226
227/** What powershell.exe -EncodedCommand takes: the script as UTF-16LE, in base64. */
228function encodeCommand(script: string): string {
229 const bytes: number[] = []
230 for (let i = 0; i < script.length; i++) {
231 const unit = script.charCodeAt(i)
232 bytes.push(unit & 0xff, unit >> 8)
233 }
234 let out = ''
235 for (let i = 0; i < bytes.length; i += 3) {
236 const [a = 0, b = 0, c = 0] = [bytes[i], bytes[i + 1], bytes[i + 2]]
237 const n = (a << 16) | (b << 8) | c
238 out += BASE64[(n >> 18) & 63]! + BASE64[(n >> 12) & 63]!
239 out += i + 1 < bytes.length ? BASE64[(n >> 6) & 63]! : '='
240 out += i + 2 < bytes.length ? BASE64[n & 63]! : '='
241 }
242 return out
243}
244
245const WINDOWS_LOADER_ENCODED = encodeCommand(WINDOWS_LOADER)
246
247let platform: Promise<Platform> | undefined
248let language: Promise<Lang> | undefined
249let configuredLanguage = 'en'
250let contextWarning = 80
251let contextWarned = 0
252let permissionButtons = true
253let asking = false
254let lastAskAt = -Infinity
255let cwd = ''
256let project = 'Claude Code'
257let branch: string | undefined
258let toolsThisTurn = 0
259let lastFailureAt = -Infinity
260
261function clip(text: string, max = 120): string {
262 const flat = text.replace(/\s+/g, ' ').trim()
263 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
264}
265
266function pick(lines: readonly string[]): string {
267 return lines[Math.floor(Math.random() * lines.length)] ?? lines[0] ?? ''
268}
269
270function duration(ms: number): string {
271 const s = Math.round(ms / 1000)
272 if (s < 60) return `${s}s`
273 const m = Math.floor(s / 60)
274 return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
275}
276
277/** `30m`, `2h`, `1d`, or a bare number of minutes. */
278function parseDuration(text: string): number | undefined {
279 const match = /^(\d+(?:\.\d+)?)\s*(m|min|h|hr|d)?$/i.exec(text.trim())
280 if (!match) return undefined
281 const unit = (match[2] ?? 'm').toLowerCase()
282 const factor = unit === 'd' ? 86_400_000 : unit.startsWith('h') ? 3_600_000 : 60_000
283 return Number(match[1]) * factor
284}
285
286/** Maps a locale tag (`zh_TW.UTF-8`, `ja-JP`, `zh-Hant`) to a language we speak. */
287function toLang(tag: string | undefined): Lang | undefined {
288 const t = (tag ?? '').toLowerCase().replace('_', '-')
289 if (t.startsWith('zh')) return /tw|hk|mo|hant/.test(t) ? 'zh-TW' : 'zh-CN'
290 if (t.startsWith('ja')) return 'ja'
291 if (t.startsWith('en')) return 'en'
292 return undefined
293}
294
295/** The answer's first real line, without the Markdown around it. */
296function preview(answer: string, t: Strings): string {
297 const line = answer
298 .split('\n')
299 .map(l => l.replace(/^[#>*\-\s|`]+/, '').replace(/[*_`]/g, '').trim())
300 .find(l => l.length > 0)
301 return clip(line ?? t.checkItOut, 110)
302}
303
304function headline(kind: Kind, flavor: string): string {
305 return `${LOOK[kind].emoji} ${project} · ${flavor}`
306}
307
308function fileUri(path: string): string {
309 const slashed = path.replace(/\\/g, '/')
310 return `file:///${slashed.replace(/^\/+/, '')}`
311}
312
313function detect($: EngineInterface): Promise<Platform> {
314 platform ??= (async () => {
315 if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
316 const { stdout } = await $.process.run(['uname', '-s'])
317 return stdout.trim() === 'Darwin' ? 'macos' : 'linux'
318 })()
319 return platform
320}
321
322/** The configured language, or with `auto` the system's, English when unknown. */
323function resolveLang($: EngineInterface): Promise<Lang> {
324 language ??= (async () => {
325 const chosen = toLang(configuredLanguage)
326 if (chosen) return chosen
327 const fromEnv = toLang((await $.env.get('LC_ALL')) || (await $.env.get('LANG')))
328 if (fromEnv) return fromEnv
329 if ((await detect($)) === 'windows') {
330 try {
331 const { stdout } = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-UICulture).Name'])
332 return toLang(stdout.trim()) ?? 'en'
333 } catch {
334 return 'en'
335 }
336 }
337 return 'en'
338 })()
339 return language
340}
341
342async function strings($: EngineInterface): Promise<Strings> {
343 return TEXT[await resolveLang($)]
344}
345
346async function readBranch($: EngineInterface, dir: string): Promise<string | undefined> {
347 try {
348 const { exitCode, stdout } = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, timeoutMs: 5_000 })
349 const name = stdout.trim()
350 return exitCode === 0 && name && name !== 'HEAD' ? name : undefined
351 } catch {
352 return undefined // no git, or not a repository
353 }
354}
355
356/** How much longer notifications stay muted, in ms; 0 when they are on. */
357async function mutedFor($: EngineInterface): Promise<number> {
358 const until = Number((await $.store.get(MUTED_UNTIL)) ?? 0)
359 return Math.max(0, until - (await $.clock.now()))
360}
361
362async function show($: EngineInterface, note: Note, ask?: { detail: string }): Promise<string> {
363 const os = await detect($)
364 const t = await strings($)
365 const icon = `${$.plugin.root}/assets/${note.kind === 'error' ? 'fail' : note.kind}.png`
366 const urgent = note.kind === 'permission' || note.kind === 'fail' || note.kind === 'error' || note.kind === 'context'
367
368 const ran =
369 os === 'windows'
370 ? await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-EncodedCommand', WINDOWS_LOADER_ENCODED], {
371 env: {
372 CC_NOTIFY_SCRIPT: `${$.plugin.root}/assets/popup.ps1`.replace(/\//g, '\\'),
373 CC_NOTIFY_JSON: JSON.stringify({
374 kind: note.kind,
375 title: note.title,
376 body: note.body,
377 meta: note.meta,
378 launch: cwd ? fileUri(cwd) : undefined,
379 openLabel: t.openFolder,
380 ask: ask && { detail: ask.detail, allow: t.allow, deny: t.deny },
381 }),
382 },
383 // A popup may wait its turn for a free spot on screen, and a
384 // permission popup waits on screen for an answer.
385 timeoutMs: note.kind === 'permission' ? 300_000 : 180_000,
386 })
387 : os === 'macos'
388 ? await $.process.run(['osascript', ...MACOS_NOTIFY, note.title, note.body, note.meta ?? '', LOOK[note.kind].macSound])
389 : await $.process.run([
390 'notify-send',
391 '--app-name=Claude Code',
392 `--icon=${icon}`,
393 `--urgency=${urgent ? 'critical' : 'normal'}`,
394 note.title,
395 note.meta ? `${note.body}\n${note.meta}` : note.body,
396 ])
397
398 if (ran.exitCode !== 0) throw new Error(clip(ran.stderr) || `exit ${ran.exitCode}`)
399 return ran.stdout
400}
401
402/** One line on what a tool is about to do: the command, the file, the URL. */
403function describeCall(tool: string, input: unknown): string {
404 const fields = (input ?? {}) as Record<string, unknown>
405 const main = ['command', 'file_path', 'notebook_path', 'url', 'pattern', 'path']
406 .map(key => fields[key])
407 .find((value): value is string => typeof value === 'string')
408 return clip(`${tool}: ${main ?? JSON.stringify(input ?? {})}`, 200)
409}
410
411/**
412 * Asks Allow / Deny on the Windows popup and resolves with the answer, or
413 * undefined when the person closed it, clicked through to the terminal, or let
414 * it time out: then the terminal asks as it always does.
415 */
416async function askPermission($: EngineInterface, tool: string, input: unknown): Promise<'allow' | 'deny' | undefined> {
417 if ((await detect($)) !== 'windows' || (await mutedFor($)) > 0) return undefined
418 const t = await strings($)
419 try {
420 const stdout = await show(
421 $,
422 { kind: 'permission', title: headline('permission', pick(t.lines.permission)), body: '' },
423 { detail: describeCall(tool, input) },
424 )
425 const answer = /^decision: (allow|deny)\s*$/m.exec(stdout)?.[1]
426 return answer === 'allow' || answer === 'deny' ? answer : undefined
427 } catch (error) {
428 $.ui.log(`permission popup failed: ${String(error)}`, { to: 'debug' })
429 return undefined
430 }
431}
432
433// Never hold up the session for a notification: fire it, and fall back to an
434// in-app toast when the desktop one cannot be shown. `/notify test` passes
435// `force` to show one even while muted.
436function notify($: EngineInterface, build: (t: Strings) => Note, force = false) {
437 void (async () => {
438 if (!force && (await mutedFor($)) > 0) return
439 const note = build(await strings($))
440 await show($, note).catch(error => {
441 $.ui.log(`desktop notification failed: ${String(error)}`, { to: 'debug' })
442 $.ui.toast(`${note.title}: ${note.body}`)
443 })
444 })().catch(error => $.ui.log(`notification skipped: ${String(error)}`, { to: 'debug' }))
445}
446
447/** Warns once at the configured fill and once more near the end of the window. */
448async function checkContext($: EngineInterface) {
449 if (contextWarning <= 0) return
450 const percent = await $.session.usage().then(
451 usage => usage.context.percent,
452 () => undefined, // no reading this turn
453 )
454 if (percent === undefined) return
455 if (percent < REARM_PERCENT) {
456 contextWarned = 0
457 return
458 }
459 const level = percent >= CONTEXT_CRITICAL_PERCENT ? 2 : percent >= contextWarning ? 1 : 0
460 if (level <= contextWarned) return
461 contextWarned = level
462 notify($, s => ({ kind: 'context', title: headline('context', pick(s.lines.context)), body: s.contextBody(Math.round(percent)) }))
463}
464
465async function runCommand($: EngineInterface, args: string): Promise<string> {
466 const t = await strings($)
467 const [verb = '', ...rest] = args.trim().split(/\s+/)
468 const arg = rest.join(' ')
469
470 switch (verb.toLowerCase()) {
471 case '':
472 case 'status': {
473 const left = await mutedFor($)
474 const state = left === 0 ? t.statusOn : left > FOREVER / 2 ? t.statusOff : t.statusMuted(duration(left))
475 return `${state}\n\n${t.usage}`
476 }
477 case 'test': {
478 const kinds: readonly Kind[] = arg === 'all' ? ['done', 'fail', 'permission', 'context', 'start'] : ['done']
479 for (const kind of kinds) {
480 notify(
481 $,
482 s => ({
483 kind,
484 title: headline(kind, kind === 'fail' ? `Bash ${pick(s.lines.fail)}` : pick(s.lines[kind])),
485 body: kind === 'fail' ? 'command not found: foo' : kind === 'context' ? s.contextBody(82) : s.checkItOut,
486 meta: `⏱ 1m 05s · 🔧 ${s.tools(3)}${branch ? ` · ⎇ ${branch}` : ''}`,
487 }),
488 true,
489 )
490 }
491 return t.tested
492 }
493 case 'mute': {
494 const ms = parseDuration(arg || '1h')
495 if (ms === undefined) return t.usage
496 await $.store.set(MUTED_UNTIL, (await $.clock.now()) + ms)
497 return t.muted(duration(ms))
498 }
499 case 'off':
500 await $.store.set(MUTED_UNTIL, FOREVER)
501 return t.off
502 case 'on':
503 case 'unmute':
504 await $.store.delete(MUTED_UNTIL)
505 return t.on
506 case 'lang':
507 case 'language': {
508 const wanted = arg === 'auto' ? 'auto' : LANGS.find(l => l.toLowerCase() === arg.toLowerCase())
509 if (!wanted) return t.badLanguage
510 // Saved as the plugin's own setting; the change reloads this module with it.
511 const { deny } = await $.config.set({ key: 'notify.language', value: wanted })
512 if (deny !== undefined) return deny
513 configuredLanguage = wanted
514 language = undefined
515 return (await strings($)).language(wanted)
516 }
517 default:
518 return t.usage
519 }
520}
521
522export const register: Register = (on, options) => {
523 configuredLanguage = typeof options.language === 'string' ? options.language : 'en'
524 contextWarning = typeof options.contextWarning === 'number' ? options.contextWarning : 80
525 permissionButtons = options.permissionButtons !== false
526
527 on('session.start', async ($, e, next) => {
528 const started = await next(e)
529 cwd = e.cwd
530 project = e.cwd.split(/[\\/]/).filter(Boolean).pop() ?? project
531 branch = await readBranch($, e.cwd)
532 const t = await strings($)
533 await $.command.register({
534 name: 'notify',
535 description: t.description,
536 argumentHint: '[test | mute 1h | off | on | lang <code>]',
537 immediate: true,
538 })
539 if (e.isInteractive) {
540 notify($, s => ({ kind: 'start', title: headline('start', pick(s.lines.start)), body: branch ? s.onBranch(branch) : e.cwd }))
541 }
542 return started
543 })
544
545 on('command.run', { command: 'notify' }, async ($, e) => ({ text: await runCommand($, e.args) }))
546
547 on('turn.start', ($, e, next) => {
548 toolsThisTurn = 0
549 return next(e)
550 })
551
552 on('tool.call', async ($, e, next) => {
553 toolsThisTurn += 1
554 const ran = await next(e)
555 if (ran.deny === undefined && ran.isError === true) {
556 const now = await $.clock.now()
557 if (now - lastFailureAt >= FAILURE_COOLDOWN_MS) {
558 lastFailureAt = now
559 notify($, s => ({
560 kind: 'fail',
561 title: headline('fail', `${e.tool} ${pick(s.lines.fail)}`),
562 body: clip(ran.text ?? s.failed),
563 }))
564 }
565 }
566 return ran
567 })
568
569 on('turn.complete', async ($, e, next) => {
570 const done = await next(e)
571 // Subagent turns end inside the main turn; only the main loop's end means "come back".
572 if (e.agentId === undefined && (e.reason === 'answer' || e.reason === 'error')) {
573 const tools = toolsThisTurn
574 notify($, s => {
575 const meta = [`⏱ ${duration(e.durationMs)}`, tools > 0 ? `🔧 ${s.tools(tools)}` : undefined, branch ? `⎇ ${branch}` : undefined]
576 .filter(Boolean)
577 .join(' · ')
578 return e.reason === 'answer'
579 ? { kind: 'done', title: headline('done', pick(s.lines.done)), body: preview(e.answer, s), meta }
580 : { kind: 'error', title: headline('error', pick(s.lines.error)), body: s.turnError, meta }
581 })
582 await checkContext($)
583 }
584 return done
585 })
586
587 // Answer a permission prompt from the popup. Waiting on the popup costs the
588 // hook none of its time budget: a $ call in flight does not count.
589 on('classic.PermissionRequest', async ($, e, next) => {
590 if (!permissionButtons) return next(e)
591 asking = true
592 try {
593 const answer = await askPermission($, e.tool_name, e.tool_input)
594 if (answer === 'allow') return { decision: { behavior: 'allow' } }
595 if (answer === 'deny') return { decision: { behavior: 'deny', message: (await strings($)).deniedFromPopup } }
596 } finally {
597 asking = false
598 lastAskAt = await $.clock.now()
599 }
600 return next(e)
601 })
602
603 // Permission prompts and idle reminders: the moments Claude is waiting on you.
604 on('classic.Notification', async ($, e, next) => {
605 const result = await next(e)
606 const isPermission = e.notification_type === 'permission_prompt'
607 // The popup already asked about this one, Allow / Deny and all.
608 if (isPermission && (asking || (await $.clock.now()) - lastAskAt < 5_000)) return result
609 notify($, s => ({
610 kind: isPermission ? 'permission' : 'start',
611 title: isPermission ? headline('permission', pick(s.lines.permission)) : headline('start', e.title ?? 'Claude Code'),
612 body: clip(e.message),
613 }))
614 return result
615 })
616}
617