macOS notification when a long turn finishes, or when Claude needs your input, in terminal sessions

Claude Code mods: hooks modules that change Claude's interface and behaviour, published as the god-mods plugin marketplace.
In Claude Code:
/plugin marketplace add KZ-HQ/god-mods
/plugin install danger-guard@god-mods
Install the mods you want one by one. Restart Claude Code (a full restart; /reload-plugins does not load new mod code) for them to take effect.
| Mod | What it does |
|---|---|
| agent-spend | /spend [days] [here]: pane of subagent spend by model and agent type, with the costliest agents, from subagent transcripts |
| context-gauge | Band above the prompt: context fill, last turn's cost, session cost and rate limits; /gauge toggles it |
| danger-guard | Holds risky shell commands and writes to protected data, previews what they would change, and asks before they run |
| long-turn-notify | macOS notification when a long turn finishes or Claude needs your input, in terminal sessions |
| merge-gate | Holds gh pr merge until CI passes on the PR head; /gate runs the project's CI steps locally |
| mod-stats | /mod-stats [days]: what each mod did, cost and saved, and how often it faulted |
| mod-faults | Passive: records any god-mod hook that fails, times out or is refused, and toasts at a mod's third fault in a session; /mod-faults. It sees only mods beneath it: put mod-faults@god-mods first in enabledPlugins (that order is the chain order) |
| next-steps | Suggests 2–3 likely next prompts after each turn; press 1/2/3 to draft one; /next off |
| prompt-shaper | Expands your shorthand and attaches git state to each prompt; adds a project-toolchain note to the system prompt |
| secret-scrubber | Masks API keys and tokens in tool output, and refuses to write them into source files |
| test-on-edit | When Claude's answer ends, runs the tests of the files it edited; failures go to Claude with your next prompt |
Installed mods nest in the order of enabledPlugins in ~/.claude/settings.json, first outermost; a plugin can't choose its place. mod-faults only sees faults in mods beneath it, so keep it first:
"enabledPlugins": {
"mod-faults@god-mods": true,
"danger-guard@god-mods": true,
...
}
Plugins installed later are appended, so they land beneath it. Reinstalling mod-faults moves it to the end again; its start-up toast and /mod-faults name any mods it can't see.
Every mod appends local JSON lines to ~/.claude/mods/metrics.jsonl; nothing leaves your machine. Rows (schema v: 2) carry t, mod, event, ver, session and project:
loaded: once per session start, with the mod's versionturn: once per turn, counting each hook's calls, e.g. {"tool.call": 3, "command.run{goal}": 1}verdictRows hold counts, kinds and lengths only, never prompt, command or tool-input text. /mod-stats summarises them. mod-faults adds a fault row per failed hook, naming the mod and event; it no longer writes ~/.claude/logs/god-mods-faults.jsonl, which can be deleted.
Each mod is a folder with .claude-plugin/plugin.json and hooks/register.tsx, plus tests in tests/.
The metrics code lives once in shared/metrics.ts and is inlined into every register.tsx between // <metrics> markers, because the engine only lets $ into functions declared in the same file. Edit the shared file, then:
shared/sync.sh # inline into every mod
shared/sync.sh --check # fail if any copy is stale
Check a mod before committing:
claude plugin validate <mod>
claude plugin test <mod>
Bump the mod's version in its plugin.json and in .claude-plugin/marketplace.json together.
hooks/register.tsx 167 lines1import type { EngineInterface, On, Register } from 'claude-code'
2
3// Failed turns notify once they ran at least this long, whatever the threshold
4const ERROR_MIN_SECONDS = 15
5
6// argv carries the text, so nothing in it is parsed as AppleScript
7const SCRIPT = [
8 'on run argv',
9 'display notification (item 1 of argv) with title (item 2 of argv) subtitle (item 3 of argv) sound name "Glass"',
10 'end run',
11]
12
13const duration = (ms: number) => {
14 const s = Math.round(ms / 1000)
15
16 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
17}
18
19async function notify($: EngineInterface, body: string, title: string, subtitle: string) {
20 try {
21 await $.process.run(['osascript', ...SCRIPT.flatMap(l => ['-e', l]), body, title, subtitle], { timeoutMs: 5000 })
22 } catch {
23 // Not macOS, or osascript refused: the toast below still shows
24 }
25}
26
27// <metrics: generated from shared/metrics.ts by shared/sync.sh>
28// Shared mod metrics (schema v2). Source of truth: ~/.claude/mods/shared/metrics.ts.
29// shared/sync.sh inlines it between the metrics markers in each mod's register.tsx (the engine only lets $
30// into functions declared in the same file). Edit here, run sync.sh, never edit an inlined copy.
31//
32// Rows appended to ~/.claude/mods/metrics.jsonl, all with { v: 2, t, mod, event, ver, session, project }:
33// loaded once per load (logLoaded, or count() in the mod's session.start hook), at session.start: proves which version of the mod is running
34// turn once per turn with activity (logTurns, or count() in the mod's turn.complete hook): { hooks: { <event>: calls } }
35// <other> outcome rows from track(), e.g. merge-gate 'verdict'
36// Privacy: never put prompt, command, tool-input or matched-secret text in a row; lengths, kinds and counts only.
37
38const APPEND = 'mkdir -p "$HOME/.claude/mods" && printf "%s\\n" "$1" >> "$HOME/.claude/mods/metrics.jsonl"'
39
40
41const ctx = { ver: '', session: '', project: '' }
42const stats = new Map<string, number>()
43let writeFailed = false
44
45const message = (err: unknown) => (err instanceof Error ? err.message : String(err)).slice(0, 300)
46
47function write($: EngineInterface, mod: string, event: string, data: Record<string, unknown>): void {
48 void (async () => {
49 const t = new Date(await $.clock.now()).toISOString()
50 if (!ctx.project) ctx.project = (await $.session.cwd()).split('/').filter(Boolean).pop() ?? ''
51 const line = JSON.stringify({ v: 2, t, mod, event, ver: ctx.ver, session: ctx.session, project: ctx.project, ...data })
52 const { exitCode, stderr } = await $.process.run(['/bin/sh', '-c', APPEND, 'mod-metrics', line], { timeoutMs: 3000 })
53 if (exitCode !== 0) throw new Error(stderr.trim() || `exit ${exitCode}`)
54 })().catch((err: unknown) => {
55 if (writeFailed) return
56 writeFailed = true
57 $.ui.toast(`${mod}: metrics write failed (${message(err)})`)
58 })
59}
60
61// An outcome row: something the mod decided or did. Fire-and-forget.
62function track($: EngineInterface, mod: string, event: string, data: Record<string, unknown> = {}): void {
63 write($, mod, event, data)
64}
65
66async function loaded($: EngineInterface): Promise<void> {
67 const mod = $.plugin.name
68 ctx.session = await $.session.id().catch(() => '')
69 ctx.project = ''
70 try {
71 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }
72 ctx.ver = manifest.version ?? ''
73 } catch {
74 ctx.ver = '?'
75 }
76 write($, mod, 'loaded', {})
77}
78
79function flush($: EngineInterface): void {
80 const mod = $.plugin.name
81 if (!stats.size) return
82 const hooks = Object.fromEntries(stats)
83 stats.clear()
84 write($, mod, 'turn', { hooks })
85}
86
87// The engine allows one hook per event per plugin, and counts them by spelling. A mod that hooks
88// session.start or turn.complete itself calls count() in that hook, which writes the row; otherwise
89// it calls the matching helper once in register.
90function logLoaded(on: On): void {
91 on('session.start', async ($, e, next) => {
92 await loaded($)
93
94 return next(e)
95 })
96}
97
98function logTurns(on: On): void {
99 on('turn.complete', async ($, e, next) => {
100 flush($)
101
102 return next(e)
103 })
104}
105
106// First statement of every hook: counts the call into this turn's row. The engine only accepts
107// function literals as hooks, so calls are counted from inside rather than by wrapping.
108// In a session.start hook it also writes the loaded row; in a turn.complete hook it writes the turn row.
109function count($: EngineInterface, event: string): void {
110 stats.set(event, (stats.get(event) ?? 0) + 1)
111 if (event === 'session.start') void loaded($)
112 if (event === 'turn.complete') flush($)
113}
114// </metrics>
115
116// <text: generated from shared/text.ts by shared/sync.sh>
117// The first non-blank line, trimmed, cut to max characters with an ellipsis
118function firstLine(text: string, max = 90): string {
119 const line = text.split('\n').map(l => l.trim()).find(Boolean) ?? ''
120
121 return line.length > max ? `${line.slice(0, max - 1)}…` : line
122}
123// </text>
124
125// The reply's first line without markdown marks, for a notification
126const summaryLine = (text: string) => firstLine(text.replace(/[#*`>_]/g, ''), 180) || 'Done.'
127
128export const register: Register = (on, options) => {
129 logLoaded(on)
130
131 const threshold = Number(options.thresholdSeconds ?? 60)
132
133 on('turn.complete', async ($, e, next) => {
134 count($, 'turn.complete')
135 const done = await next(e)
136 if (e.agentId !== undefined || e.isAborted) return done
137
138 const seconds = e.durationMs / 1000
139 const isError = e.reason === 'error' || e.reason === 'refusal'
140 if (seconds < (isError ? ERROR_MIN_SECONDS : threshold)) return done
141
142 const project = (await $.session.cwd()).split('/').filter(Boolean).pop() ?? 'Claude Code'
143 const subtitle = `${isError ? 'Stopped with an error' : 'Finished'} after ${duration(e.durationMs)}`
144 const body = isError ? 'The turn ended with an error. Check the session.' : summaryLine(e.answer)
145
146 // The desktop app already sends its own "task complete" banner
147 const surfaces = await $.session.surfaces()
148 if (!surfaces.includes('desktop')) await notify($, body, `Claude · ${project}`, subtitle)
149 $.ui.toast(subtitle)
150 track($, 'long-turn-notify', 'notified', { seconds: Math.round(seconds), isError })
151
152 return done
153 })
154
155 // Claude is blocked on you (a permission prompt, a question, idle input): the desktop app has its own banners
156 on('classic.Notification', async ($, e, next) => {
157 count($, 'classic.Notification')
158 const done = await next(e)
159 const surfaces = await $.session.surfaces()
160 if (surfaces.includes('desktop')) return done
161 const project = (await $.session.cwd()).split('/').filter(Boolean).pop() ?? 'Claude Code'
162 await notify($, e.message, `Claude · ${project}`, e.title ?? 'Needs you')
163
164 return done
165 })
166}
167