A notification when a long turn finishes or Claude waits on you (permission or a question): the desktop app's own notification where it can, else a macOS…

Sends a notification when a long turn finishes or when Claude is waiting for your permission or your answer, so you can look away while it works.
/plugin marketplace add partalemre/MODZ
/plugin install done-ping@modz
Or in one step: /plugin install done-ping --marketplace partalemre/MODZ.
Every ping has a title, the project name, and a short body of facts. It never carries code, a command or the text of a reply, so nothing sensitive leaks through it.
tests passed · 3 files, with the test result first. The tests fact comes from a test command Claude ran in that turn. With neither fact, the body says how long it took (took 1m 1s).Edit · app.ts, WebFetch · docs.example.com). A command is never shown; it says Bash command.Where a ping goes, in order:
terminal-notifier, if you already have it installed in /opt/homebrew/bin or /usr/local/bin. Nothing is installed for you.osascript, with the sound you chose.One waiting ping is sent per 15-second burst, and any two pings are at least 10 seconds apart.
Off by default. Set soundPack to chime, soft or retro and each ping also plays one of the mod's own short sounds (under 1.2 seconds): one for a finished turn, another for Claude waiting on you. A pack replaces the banner's own sound, so a ping never rings twice, and it stays quiet whenever the banner does (while you are looking at the session). Where the host has no player (a Linux or Windows terminal) nothing plays and nothing is shown; the debug log says why.
The sounds are synthesized from plain sine and triangle tones by tools/sounds/make.py in this repository, with no samples from anywhere else. They are this repository's own work under its MIT licence. Run python3 -I tools/sounds/make.py to make them again; the output is the same every time.
/done-ping turns the ping on or off (remembered)./done-ping 90, /done-ping 2m or /done-ping 3min sets the threshold (remembered)./done-ping test sends a sample ping, ignoring the focus rule.| Option | Default | Meaning |
|---|---|---|
thresholdSeconds | 60 | A finished turn pings only when it ran at least this long. |
pingOnPermission | true | Also ping when Claude asks for permission or asks you a question. |
sound | Glass | The banner's sound: a macOS system sound name (Glass, Hero, Ping, Purr, Submarine, Funk, Pop), a path to a sound file, or none. A sound pack replaces it. |
soundPack | off | chime, soft or retro plays the mod's own sounds, one for done and one for waiting. |
quietWhenFocused | true | Skip the banner while the app this session runs in is the frontmost app. |
appNotification | true | In the desktop app, use Claude's own notification when it is available without a prompt. |
Set MODZ_THEME=light or MODZ_THEME=dark in the env block of ~/.claude/settings.json to pin the theme of the notice line.
osascript banners carry the Script Editor icon, and macOS may ask you to allow its notifications the first time. terminal-notifier or the desktop app's own notification show the right icon.Uses no model tokens.
The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.
hooks/register.tsx 249 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { pingRow } from './band'
5import { configFrom, donePing, editedPath, isTestCommand, parseSeconds, permissionPing, projectOf, questionPing } from './ping'
6import type { Ping, PingConfig, TestsSeen } from './ping'
7import { AUTO, themeFrom } from './tidemark'
8import { ring } from './ring'
9import type { RingIo, Rung } from './ring'
10import { packAsset } from './pack'
11import { pinned } from './pin'
12import { setLang } from './lang'
13import { words } from './words'
14import type { PingNotice } from '../types'
15
16const COMMAND = 'done-ping'
17const ENABLED_KEY = `${COMMAND}.enabled`
18const THRESHOLD_KEY = `${COMMAND}.thresholdSeconds`
19const MS_PER_SECOND = 1_000
20// One "waiting on you" ping per stretch: a burst of permission asks rings once.
21const WAIT_GAP_MS = 15_000
22// Any two pings stand at least this far apart, so a question right before the end rings once.
23const PING_GAP_MS = 10_000
24const DISMISS_AFTER_MS = 20_000
25// What `/done-ping test` shows: a turn of 2dk 14sn that changed 3 files and passed its tests.
26const SAMPLE = { durationMs: 134_000, files: 3, tests: 'pass' as const }
27// Notices that outrank this one on the shared line above the prompt.
28const cacheTaxNotice = { plugin: 'cache-tax', key: 'notice' } as const
29const knowNotice = { plugin: 'you-should-know', key: 'notice' } as const
30const reflectNotice = { plugin: 'reflect', key: 'notice' } as const
31const budgetNotice = { plugin: 'session-budget', key: 'notice' } as const
32const testBadgeNotice = { plugin: 'test-badge', key: 'notice' } as const
33const handoffNotice = { plugin: 'handoff', key: 'notice' } as const
34const planNotice = { plugin: 'plan-steps', key: 'notice' } as const
35
36const enabledAtom = atom({ plugin: 'done-ping', key: 'isEnabled' } as const, true)
37const thresholdAtom = atom({ plugin: 'done-ping', key: 'thresholdSeconds' } as const, null)
38const noticeAtom = atom({ plugin: 'done-ping', key: 'notice' } as const, null)
39const themeAtom = atom({ plugin: 'done-ping', key: 'theme' } as const, AUTO)
40
41const session = {
42 project: 'Claude', lastWaitAt: -WAIT_GAP_MS, lastPingAt: -PING_GAP_MS, files: new Set<string>(), tests: null as TestsSeen, noticeId: 0,
43}
44
45async function showNotice($: EngineInterface, ping: Ping): Promise<void> {
46 session.noticeId += 1
47 const id = session.noticeId
48 const expiresAt = (await $.clock.now()) + DISMISS_AFTER_MS
49 await update($, noticeAtom, () => ({ id, kind: ping.kind, title: ping.title, detail: ping.detail, expiresAt }))
50 $.clock.after(DISMISS_AFTER_MS, () => {
51 void update($, noticeAtom, current => (current !== null && current.id === id ? null : current))
52 })
53}
54
55function ioOf($: EngineInterface): RingIo {
56 return {
57 surfaces: () => $.session.surfaces(),
58 toolNames: async () => (await $.tool.list()).map(tool => tool.name),
59 checkTool: async (tool, input) => (await $.tool.check({ tool, input } as never)).decision,
60 callTool: (tool, input) => $.tool.call({ tool, ...input } as never),
61 // The bundle id macOS gives the app that launched this session (Claude, Terminal, Ghostty).
62 hostBundle: () => $.env.get('__CFBundleIdentifier'),
63 run: (argv, init) => $.process.run(argv, init),
64 exists: path => $.fs.exists(path),
65 log: text => $.ui.log(text, { to: 'debug' }),
66 }
67}
68
69// The pack's clip for this ping; where the surface has no player it plays nothing, said in the debug log alone.
70async function playPack($: EngineInterface, ping: Ping, config: PingConfig): Promise<void> {
71 const asset = packAsset(config.soundPack, ping.kind)
72 if (asset === null) return
73 try {
74 await $.audio.play({ asset })
75 } catch (error) {
76 $.ui.log(`done-ping: sound did not play (${error instanceof Error ? error.message : 'unknown'})`, { to: 'debug' })
77 }
78}
79
80// The app's notification or a macOS banner; where neither can show, the shared notice line says it.
81// The pack sounds with whatever showed, and stays quiet with it while you are looking.
82async function deliver($: EngineInterface, ping: Ping, spec: { config: PingConfig; isForced: boolean }): Promise<void> {
83 let rung: Rung = 'failed'
84 try {
85 rung = await ring(ioOf($), { ping, ...spec })
86 } catch (error) {
87 $.ui.log(`done-ping: ping failed (${error instanceof Error ? error.message : 'unknown'})`, { to: 'debug' })
88 }
89 if (rung === 'failed') await showNotice($, ping)
90 if (rung !== 'quiet') await playPack($, ping, spec.config)
91}
92
93// Rung after the hook's own dispatch so the notification never delays the turn.
94async function later($: EngineInterface, ping: Ping, spec: { config: PingConfig; isForced: boolean }): Promise<void> {
95 const nowMs = await $.clock.now()
96 if (!spec.isForced && nowMs - session.lastPingAt < PING_GAP_MS) return
97 session.lastPingAt = nowMs
98 $.clock.after(1, () => void deliver($, ping, spec))
99}
100
101async function waitingOnUser($: EngineInterface, ping: Ping, config: PingConfig): Promise<void> {
102 if (!config.isPermissionPing || !(await read($, enabledAtom))) return
103 const nowMs = await $.clock.now()
104 if (nowMs - session.lastWaitAt < WAIT_GAP_MS) return
105 session.lastWaitAt = nowMs
106 await later($, ping, { config, isForced: false })
107}
108
109async function thresholdOf($: EngineInterface, config: PingConfig): Promise<number> {
110 return (await read($, thresholdAtom)) ?? config.thresholdSeconds
111}
112
113async function runCommand($: EngineInterface, args: string, config: PingConfig): Promise<{ text: string }> {
114 const word = args.trim().toLowerCase()
115 if (word === 'test') {
116 const ping = donePing({ project: session.project, ...SAMPLE })
117 await later($, ping, { config, isForced: true })
118
119 return { text: words().testSent }
120 }
121 const seconds = word === '' ? null : parseSeconds(word)
122 if (word !== '' && seconds === null) return { text: words().usage }
123 if (seconds !== null) {
124 await update($, thresholdAtom, () => seconds)
125 await $.store.set(THRESHOLD_KEY, seconds)
126
127 return { text: words().thresholdSet(seconds) }
128 }
129 const isOn = !(await read($, enabledAtom))
130 await update($, enabledAtom, () => isOn)
131 await $.store.set(ENABLED_KEY, isOn)
132
133 return { text: words().switched(isOn, await thresholdOf($, config)) }
134}
135
136async function hasLouderNotice($: EngineInterface): Promise<boolean> {
137 const others: { value?: unknown }[] = [
138 await $.state.get(cacheTaxNotice as never),
139 await $.state.get(knowNotice as never),
140 await $.state.get(reflectNotice as never),
141 await $.state.get(budgetNotice as never),
142 await $.state.get(testBadgeNotice as never),
143 await $.state.get(handoffNotice as never),
144 await $.state.get(planNotice as never),
145 ]
146
147 return others.some(other => other.value !== undefined && other.value !== null)
148}
149
150async function loadSettings($: EngineInterface, cwd: string): Promise<void> {
151 session.project = projectOf(cwd)
152 const saved = await $.store.get(ENABLED_KEY)
153 await update($, enabledAtom, () => saved !== false)
154 const threshold = await $.store.get(THRESHOLD_KEY)
155 if (typeof threshold === 'number') await update($, thresholdAtom, () => threshold)
156 const pinned = await $.env.get('MODZ_THEME').catch(() => undefined)
157 await update($, themeAtom, () => themeFrom(pinned))
158 await $.command.register({ name: COMMAND, description: words().help })
159}
160
161async function turnEnded($: EngineInterface, e: { durationMs: number; isAborted: boolean; agentId?: string }, config: PingConfig): Promise<void> {
162 const isLong = e.durationMs >= (await thresholdOf($, config)) * MS_PER_SECOND
163 if (e.agentId !== undefined || e.isAborted || !isLong || !(await read($, enabledAtom))) return
164 const ping = donePing({ project: session.project, durationMs: e.durationMs, files: session.files.size, tests: session.tests })
165 await later($, ping, { config, isForced: false })
166}
167
168type On = Parameters<Register>[0]
169
170// Asks that wait on the person (a question, a permission dialog) and the files a turn changes.
171function registerWaiting(on: On, config: PingConfig): void {
172 on('tool.call', async ($, e, next) => {
173 if (e.tool === 'AskUserQuestion') {
174 const ping = questionPing(session.project, e as unknown as Record<string, unknown>)
175 await waitingOnUser($, ping, config).catch(() => $.ui.log('done-ping: question ping failed', { to: 'debug' }))
176 }
177 const ran = await next(e)
178 const path = editedPath(e as unknown as Record<string, unknown>)
179 const isRun = ran.deny === undefined
180 if (path !== null && isRun && ran.isError !== true) session.files.add(path)
181 if (e.tool === 'Bash' && isRun && isTestCommand((e as { command?: unknown }).command)) session.tests = ran.isError === true ? 'fail' : 'pass'
182
183 return ran
184 }).catch(async ($, e, next) => next(e))
185
186 on('tool.check', async ($, e, next) => {
187 const verdict = await next(e)
188 if (verdict.decision === 'ask' && e.tool_use_id !== undefined) {
189 const ping = permissionPing(session.project, String(e.tool), (e.input ?? {}) as Record<string, unknown>)
190 await waitingOnUser($, ping, config).catch(() => $.ui.log('done-ping: permission ping failed', { to: 'debug' }))
191 }
192
193 return verdict
194 }).catch(async ($, e, next) => next(e))
195}
196
197export const register: Register = (on, options) => {
198 const config = configFrom(options)
199
200 on('session.start', async ($, e, next) => {
201 setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
202 await loadSettings($, e.cwd).catch(() => $.ui.log('done-ping: could not load settings', { to: 'debug' }))
203
204 return next(e)
205 })
206
207 on('command.run', { command: COMMAND }, async ($, e) => runCommand($, e.args, config))
208
209 on('turn.start', async ($, e, next) => {
210 session.files.clear()
211 session.tests = null
212
213 return next(e)
214 })
215
216 registerWaiting(on, config)
217
218 on('turn.complete', async ($, e, next) => {
219 await turnEnded($, e, config).catch(() => $.ui.log('done-ping: done ping failed', { to: 'debug' }))
220
221 return next(e)
222 })
223
224 registerBand(on)
225}
226
227// The fallback row: drawn above the others, and stepping aside once the person writes again.
228function registerBand(on: On): void {
229 on('prompt.submit', async ($, e, next) => {
230 if ((await read($, noticeAtom)) !== null) await update($, noticeAtom, () => null)
231
232 return next(e)
233 }).catch(($, e, next) => next(e))
234
235 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
236 const theirs = await next(e)
237 const notice: PingNotice | null = await read($, noticeAtom)
238 if (notice === null || e.props.hasSurvey || (await hasLouderNotice($))) return theirs
239 const band = {
240 notice, columns: e.props.bodyColumns, theme: await read($, themeAtom), isRich: e.surface !== 'terminal',
241 onDismiss: () => void update($, noticeAtom, () => null),
242 }
243
244 const ui = $.ui.resolve(e)
245
246 return pinned(e, pingRow(ui, band, theirs))
247 })
248}
249hooks/band.tsx 61 lines1import { iconTile, measure, runs, svgDoc, svgWidth } from './svg'
2import type { Theme } from './tidemark'
3import type { PingNotice } from '../types'
4
5// The in-app fallback, one row in the notice family: a bell tile (Moss done, Flare done with failing
6// tests, Amber waiting), the outcome in text.hi, its facts in text.mid, then ✕. It clears on its own,
7// on ✕, or on the next prompt.
8type Ui = Record<string, any>
9
10export type PingBand = { notice: PingNotice; columns: number; theme: Theme; isRich: boolean; onDismiss: () => void }
11
12const ROW_H = 28
13const TILE = 24
14const TEXT_X = 34
15const TITLE = { size: 13, weight: 650 }
16const DETAIL = { size: 12.5 }
17// Columns the ✕ button takes beside the row.
18const DISMISS_RESERVE = 6
19
20const SEP = ' · '
21const ROLE = { done: 'moss', fail: 'flare', wait: 'amber' } as const
22const MARK = { done: { glyph: '✓', color: 'success' }, fail: { glyph: '✕', color: 'error' }, wait: { glyph: '!', color: 'warning' } } as const
23
24// Facts drop from the end until the rest fits: each one is shown whole or not at all.
25function factsThatFit(detail: string, room: number): string {
26 const facts = detail === '' ? [] : detail.split(SEP)
27 while (facts.length > 0 && measure(SEP + facts.join(SEP), DETAIL) > room) facts.pop()
28
29 return facts.length === 0 ? '' : SEP + facts.join(SEP)
30}
31
32function rowSvg(band: PingBand): { source: string; width: number } {
33 const { notice } = band
34 const room = svgWidth(band.columns, { reserve: DISMISS_RESERVE, min: 200, max: 1200 })
35 const rest = factsThatFit(notice.detail, room - TEXT_X - measure(notice.title, TITLE))
36 const width = Math.min(room, Math.ceil(TEXT_X + measure(notice.title, TITLE) + measure(rest, DETAIL) + 4))
37 const body = iconTile({ x: 0, y: 2, size: TILE, role: ROLE[notice.kind], icon: 'bell' })
38 + runs({ x: TEXT_X, y: 18.5, size: DETAIL.size, parts: [{ text: notice.title, ink: 'hi', weight: TITLE.weight }, { text: rest, ink: 'mid' }] })
39
40 return { source: svgDoc({ width, height: ROW_H, mode: band.theme.mode, body }), width }
41}
42
43export function pingRow(ui: Ui, band: PingBand, theirs: unknown) {
44 const { Box, Text, Button, Svg } = ui
45 const { notice } = band
46 const mark = MARK[notice.kind]
47 const text = notice.detail === '' ? notice.title : `${notice.title} · ${notice.detail}`
48 const drawn = band.isRich ? rowSvg(band) : null
49
50 return (
51 <Box flexDirection="column">
52 <Box flexDirection="row" alignItems="center" columnGap={1}>
53 {drawn === null && <Text color={mark.color}>{mark.glyph}</Text>}
54 {drawn === null ? <Text wrap="truncate">{text}</Text> : <Svg source={drawn.source} alt={text} width={drawn.width} height={ROW_H} />}
55 <Button key="done-ping-dismiss" plain dimColor role="dismiss" label="✕" onPress={band.onDismiss} />
56 </Box>
57 {theirs}
58 </Box>
59 )
60}
61hooks/ping.ts 163 lines1import { packOf } from './pack'
2import type { SoundPack } from './pack'
3import { words } from './words'
4
5// `fail`: a finished turn whose own test command failed, the one finish worth a Flare look.
6export type PingKind = 'done' | 'fail' | 'wait'
7
8// One notification (RESEARCH.md §c): `title` the outcome in at most four words, `subtitle` the
9// project, `body` facts only (files, tests); `detail` is the in-app row's line. Never code or secrets.
10export type Ping = { kind: PingKind; project: string; title: string; subtitle: string; body: string; detail: string }
11
12// What the turn's own test commands said: the last one that ran wins.
13export type TestsSeen = 'pass' | 'fail' | null
14
15export type PingConfig = {
16 thresholdSeconds: number
17 isPermissionPing: boolean
18 sound: string
19 soundPack: SoundPack
20 isQuietWhenFocused: boolean
21 isAppNotification: boolean
22}
23
24const MS_PER_SECOND = 1_000
25const SECONDS_PER_MINUTE = 60
26const MINUTES_PER_HOUR = 60
27const DEFAULT_THRESHOLD = 60
28const DEFAULT_SOUND = 'Glass'
29const SILENT = 'none'
30const BODY_CHARS = 80
31const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
32const PATH_KEYS = ['file_path', 'notebook_path', 'path']
33const FILE_KEYS = ['file_path', 'notebook_path', 'path']
34const TEST_COMMAND = /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test|vitest|jest|pytest|mocha|playwright\s+test|go\s+test|cargo\s+test|flutter\s+test|dart\s+test|phpunit|rspec)\b/
35
36export function configFrom(options: Record<string, unknown>): PingConfig {
37 const threshold = Number(options.thresholdSeconds)
38 const soundPack = packOf(options.soundPack)
39 // A pack takes the banner's own sound's place, so one ping never rings twice.
40 const banner = typeof options.sound === 'string' ? options.sound.trim() : DEFAULT_SOUND
41 const sound = soundPack === 'off' ? banner : SILENT
42
43 return {
44 thresholdSeconds: Number.isFinite(threshold) && threshold >= 0 ? threshold : DEFAULT_THRESHOLD,
45 isPermissionPing: options.pingOnPermission !== false,
46 sound,
47 soundPack,
48 isQuietWhenFocused: options.quietWhenFocused !== false,
49 isAppNotification: options.appNotification !== false,
50 }
51}
52
53export function durationText(ms: number): string {
54 const seconds = Math.max(0, Math.round(ms / MS_PER_SECOND))
55 const minutes = Math.floor(seconds / SECONDS_PER_MINUTE)
56 const hours = Math.floor(minutes / MINUTES_PER_HOUR)
57 if (hours > 0) return words().hours(hours, minutes % MINUTES_PER_HOUR)
58 if (minutes > 0) return words().minutes(minutes, seconds % SECONDS_PER_MINUTE)
59
60 return words().seconds(seconds)
61}
62
63export function projectOf(cwd: string): string {
64 const parts = cwd.split('/').filter(part => part !== '')
65
66 return parts[parts.length - 1] ?? 'Claude'
67}
68
69export function editedPath(input: Record<string, unknown>): string | null {
70 if (typeof input.tool !== 'string' || !EDIT_TOOLS.includes(input.tool)) return null
71 for (const key of PATH_KEYS) {
72 const value = input[key]
73 if (typeof value === 'string' && value !== '') return value
74 }
75
76 return null
77}
78
79export function clip(text: string, max = BODY_CHARS): string {
80 const flat = text.replace(/\s+/g, ' ').trim()
81
82 return flat.length <= max ? flat : `${flat.slice(0, max - 1).trimEnd()}…`
83}
84
85// The opening words of a reply, without markdown marks: what a banner can show.
86export function plainSnippet(markdown: string): string {
87 const text = markdown
88 .replace(/```[\s\S]*?(```|$)/g, ' ')
89 .replace(/`[^`\n]*`/g, ' ')
90 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
91 .replace(/^\s{0,3}(#{1,6}|[-*+>]|\d+\.)\s+/gm, '')
92 .replace(/[*_`~|]/g, '')
93
94 return clip(text)
95}
96
97export const isTestCommand = (command: unknown): boolean => typeof command === 'string' && TEST_COMMAND.test(command)
98
99function testsText(tests: TestsSeen): string {
100 if (tests === null) return ''
101
102 return tests === 'pass' ? words().testsPassed : words().testsFailed
103}
104
105// "Tur bitti" · project · "testler geçti · 3 dosya"; with neither fact, how long it took. Facts lead with
106// what matters most on return (the tests), so a narrow row drops the duration first.
107export function donePing(spec: { project: string; durationMs: number; files: number; tests: TestsSeen }): Ping {
108 const took = durationText(spec.durationMs)
109 const tests = testsText(spec.tests)
110 const says = words()
111 const hasFiles = spec.files > 0
112 const body = [tests, hasFiles ? says.files(spec.files) : ''].filter(part => part !== '').join(' · ') || says.took(took)
113 const detail = [tests, hasFiles ? says.filesChanged(spec.files) : '', took].filter(part => part !== '').join(' · ')
114 const kind = spec.tests === 'fail' ? 'fail' : 'done'
115
116 return { kind, project: spec.project, title: says.done, subtitle: spec.project, body, detail }
117}
118
119function baseName(path: string): string {
120 return path.split('/').filter(part => part !== '').pop() ?? path
121}
122
123// What the tool wants, without its input: a command is never shown, a file by its name, a page by its host.
124export function toolDetail(tool: string, input: Record<string, unknown>): string {
125 if (tool === 'Bash') return words().bashCommand
126 for (const key of FILE_KEYS) {
127 const value = input[key]
128 if (typeof value === 'string' && value.trim() !== '') return `${tool} · ${clip(baseName(value), BODY_CHARS)}`
129 }
130 const host = typeof input.url === 'string' ? /^https?:\/\/([^/?#]+)/.exec(input.url)?.[1] : undefined
131
132 return host === undefined ? words().toolPermission(tool) : `${tool} · ${host}`
133}
134
135export function questionOf(input: Record<string, unknown>): string {
136 const questions = Array.isArray(input.questions) ? input.questions : []
137 const first = questions[0] as { question?: unknown } | undefined
138
139 return typeof first?.question === 'string' ? plainSnippet(first.question) : ''
140}
141
142export function permissionPing(project: string, tool: string, input: Record<string, unknown>): Ping {
143 const body = toolDetail(tool, input)
144
145 return { kind: 'wait', project, title: words().waitingApproval, subtitle: project, body, detail: body }
146}
147
148export function questionPing(project: string, input: Record<string, unknown>): Ping {
149 const body = questionOf(input) || words().hasQuestion
150
151 return { kind: 'wait', project, title: words().waitingAnswer, subtitle: project, body, detail: body }
152}
153
154export function parseSeconds(text: string): number | null {
155 const match = /^(\d+)\s*(s|sn|sec|secs|seconds|m|dk|min|mins|minutes)?$/i.exec(text.trim())
156 if (match === null) return null
157 const value = Number(match[1])
158 const unit = (match[2] ?? '').toLowerCase()
159 const isMinutes = unit.startsWith('m') || unit === 'dk'
160
161 return isMinutes ? value * SECONDS_PER_MINUTE : value
162}
163hooks/tidemark.ts 61 lines1// Tidemark tokens (DESIGN.md §2) in one file, so the shared kit can replace it as a whole.
2// Svg reads them as CSS variables of the root's theme class; Box and Text read them through
3// `boxColor`, which falls back to the host's theme keys while the theme is not known.
4
5export type Mode = 'dark' | 'light' | 'auto'
6export type Role = 'tide' | 'kiln' | 'iris' | 'moss' | 'amber' | 'flare' | 'orchid'
7export type Neutral = 'card' | 'inset' | 'line' | 'strong' | 'track' | 'hi' | 'mid' | 'lo'
8export type Theme = { mode: Mode; isLight: boolean }
9
10type Swatch = { fg: string; tint: string; line: string }
11type Palette = Record<Neutral, string> & Record<Role, Swatch>
12
13// `MODZ_THEME=light|dark` (read by each mod with a literal $.env.get) pins the theme;
14// otherwise Svg follows the surface's color scheme.
15export const AUTO: Theme = { mode: 'auto', isLight: false }
16
17const NEUTRALS: Neutral[] = ['card', 'inset', 'line', 'strong', 'track', 'hi', 'mid', 'lo']
18const ROLES: Role[] = ['tide', 'kiln', 'iris', 'moss', 'amber', 'flare', 'orchid']
19
20const DARK: Palette = {
21 card: '#1F1F1C', inset: '#171715', line: '#2E2E2A', strong: '#45443F', track: '#34332F',
22 hi: '#F0EEE8', mid: '#C2BEB4', lo: '#8E8A80',
23 tide: { fg: '#7FB6E8', tint: '#1E2B37', line: '#2F4A63' },
24 kiln: { fg: '#E58A5F', tint: '#38251D', line: '#6A3E2D' },
25 iris: { fg: '#B19CF3', tint: '#29243D', line: '#4A3F75' },
26 moss: { fg: '#6FC593', tint: '#1B2F25', line: '#2F5A42' },
27 amber: { fg: '#E9B949', tint: '#332D19', line: '#66552A' },
28 flare: { fg: '#F2727F', tint: '#3B2023', line: '#74343C' },
29 orchid: { fg: '#E07BC0', tint: '#351F2E', line: '#6B3A5C' },
30}
31
32const LIGHT: Palette = {
33 card: '#FFFFFF', inset: '#F6F4EF', line: '#E4E0D6', strong: '#CFC9BC', track: '#ECE8DF',
34 hi: '#1C1B19', mid: '#4F4B44', lo: '#77726A',
35 tide: { fg: '#2464A0', tint: '#E7F0F9', line: '#B9D3EC' },
36 kiln: { fg: '#A94A26', tint: '#FBEDE6', line: '#EFC6B4' },
37 iris: { fg: '#6243C4', tint: '#F0ECFC', line: '#CFC3F3' },
38 moss: { fg: '#1F6E43', tint: '#E4F4EA', line: '#B5DEC4' },
39 amber: { fg: '#8A6000', tint: '#FBF2D9', line: '#EBD48F' },
40 flare: { fg: '#C0263A', tint: '#FCE8EA', line: '#F1B9C0' },
41 orchid: { fg: '#A8337F', tint: '#FBE9F4', line: '#EDBFDC' },
42}
43
44export function themeFrom(value: unknown): Theme {
45 const name = typeof value === 'string' ? value.trim().toLowerCase() : ''
46 if (name === 'light') return { mode: 'light', isLight: true }
47 if (name === 'dark') return { mode: 'dark', isLight: false }
48
49 return AUTO
50}
51
52const varsOf = (palette: Palette): string => [
53 ...NEUTRALS.map(name => `--${name}:${palette[name]}`),
54 ...ROLES.flatMap(role => [`--${role}:${palette[role].fg}`, `--${role}-t:${palette[role].tint}`, `--${role}-l:${palette[role].line}`]),
55].join(';')
56
57// Dark by default; a pinned light theme, or an unpinned one on a light surface, swaps the set.
58export function themeCss(): string {
59 return `svg,.t-dark{${varsOf(DARK)}}.t-light{${varsOf(LIGHT)}}@media (prefers-color-scheme: light){.t-auto{${varsOf(LIGHT)}}}`
60}
61hooks/ring.ts 116 lines1import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3import { afplayArgv, asnOf, frontBundleOf, NOTIFIER_PATHS, osascriptArgv, soundOf, terminalNotifierArgv } from './notifier'
4import type { Sound } from './notifier'
5import type { Ping, PingConfig } from './ping'
6
7// Where a ping went: the app's own notification, a macOS banner, nowhere on purpose, or nowhere.
8export type Rung = 'app' | 'system' | 'quiet' | 'failed'
9
10export type RingSpec = { ping: Ping; config: PingConfig; isForced: boolean }
11
12// What ringing reaches the host through; the hooks module builds it from `$`.
13export type RingIo = {
14 surfaces: () => Promise<readonly string[]>
15 toolNames: () => Promise<string[]>
16 checkTool: (tool: string, input: Record<string, unknown>) => Promise<string>
17 callTool: (tool: string, input: Record<string, unknown>) => Promise<unknown>
18 hostBundle: () => Promise<string | undefined>
19 run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
20 exists: (path: string) => Promise<boolean>
21 log: (text: string) => void
22}
23
24type AppAnswer = 'sent' | 'present' | 'unavailable'
25
26type PushResult = { localSent?: boolean; pushSent?: boolean; disabledReason?: string }
27
28const PUSH_TOOL = 'PushNotification'
29const RUN_TIMEOUT_MS = 5_000
30
31export function appMessage(ping: Ping): string {
32 return `${ping.title} · ${ping.project}\n${ping.body}`
33}
34
35// The desktop app's own notification (its icon, its focus rule) through the PushNotification tool,
36// used only where the tool exists and runs without asking.
37async function appNotify(io: RingIo, spec: RingSpec): Promise<AppAnswer> {
38 const surfaces = await io.surfaces()
39 if (!surfaces.includes('desktop')) return 'unavailable'
40 if (!(await io.toolNames()).includes(PUSH_TOOL)) return 'unavailable'
41 const input = { message: appMessage(spec.ping), status: 'proactive' }
42 if ((await io.checkTool(PUSH_TOOL, input)) !== 'allow') return 'unavailable'
43 const ran = await io.callTool(PUSH_TOOL, input)
44 const result = (ran as { result?: PushResult } | null)?.result ?? {}
45 if (result.localSent === true || result.pushSent === true) return 'sent'
46 if (result.disabledReason === 'user_present' && !spec.isForced) return 'present'
47
48 return 'unavailable'
49}
50
51async function hostBundle(io: RingIo): Promise<string | null> {
52 const value = await io.hostBundle()
53
54 return value === undefined || value.trim() === '' ? null : value.trim()
55}
56
57// True while the app this session runs in (the desktop app, the terminal) is the frontmost one.
58async function isHostFrontmost(io: RingIo, host: string | null): Promise<boolean> {
59 if (host === null) return false
60 const front = await io.run(['lsappinfo', 'front'], { timeoutMs: RUN_TIMEOUT_MS })
61 const asn = asnOf(front.stdout)
62 if (front.exitCode !== 0 || asn === null) return false
63 const info = await io.run(['lsappinfo', 'info', '-only', 'bundleid', asn], { timeoutMs: RUN_TIMEOUT_MS })
64
65 return frontBundleOf(info.stdout) === host
66}
67
68async function notifierPath(io: RingIo): Promise<string | null> {
69 for (const path of NOTIFIER_PATHS) {
70 if (await io.exists(path)) return path
71 }
72
73 return null
74}
75
76async function banner(io: RingIo, note: { ping: Ping; sound: Sound; host: string | null }): Promise<boolean> {
77 const { ping, sound, host } = note
78 const notifier = await notifierPath(io)
79 if (notifier !== null) {
80 const argv = terminalNotifierArgv({ path: notifier, ping, sound, sender: host })
81 const shown = await io.run(argv, { timeoutMs: RUN_TIMEOUT_MS })
82 if (shown.exitCode === 0) return true
83 }
84 const shown = await io.run(osascriptArgv(ping, sound), { timeoutMs: RUN_TIMEOUT_MS })
85
86 return shown.exitCode === 0
87}
88
89async function tryApp(io: RingIo, spec: RingSpec): Promise<AppAnswer> {
90 if (!spec.config.isAppNotification) return 'unavailable'
91 try {
92 return await appNotify(io, spec)
93 } catch (error) {
94 io.log(`done-ping: app notification unavailable (${error instanceof Error ? error.message : 'unknown'})`)
95
96 return 'unavailable'
97 }
98}
99
100export async function ring(io: RingIo, spec: RingSpec): Promise<Rung> {
101 const app = await tryApp(io, spec)
102 if (app === 'sent') return 'app'
103 if (app === 'present') return 'quiet'
104 const host = await hostBundle(io)
105 const shouldCheckFocus = spec.config.isQuietWhenFocused && !spec.isForced
106 if (shouldCheckFocus && (await isHostFrontmost(io, host).catch(() => false))) return 'quiet'
107 const sound = soundOf(spec.config.sound)
108 const isShown = await banner(io, { ping: spec.ping, sound, host }).catch(() => false)
109 const play = afplayArgv(sound)
110 if (isShown && play !== null) {
111 await io.run(play, { timeoutMs: RUN_TIMEOUT_MS }).catch(() => io.log('done-ping: afplay failed'))
112 }
113
114 return isShown ? 'system' : 'failed'
115}
116hooks/pack.ts 19 lines1import type { PingKind } from './ping'
2
3// The optional sound packs: two clips each under sounds/<pack>/, made by tools/sounds/make.py.
4// A finished turn (passed or failed) plays `done`; Claude waiting on you plays `wait`.
5export const SOUND_PACKS = ['off', 'chime', 'soft', 'retro'] as const
6
7export type SoundPack = (typeof SOUND_PACKS)[number]
8
9export function packOf(value: unknown): SoundPack {
10 return SOUND_PACKS.find(pack => pack === value) ?? 'off'
11}
12
13/** The clip a ping plays, as `$.audio.play({ asset })` takes it (relative to the mod's folder), or null when off. */
14export function packAsset(pack: SoundPack, kind: PingKind): string | null {
15 if (pack === 'off') return null
16
17 return `sounds/${pack}/${kind === 'wait' ? 'wait' : 'done'}.wav`
18}
19hooks/pin.ts 48 lines1// MODZ kit — pinned press handles. Every element the JSX runtime builds gets a fresh handle, and the
2// host releases a drawing's old handles the moment it redraws. A desktop holds the tree it last
3// painted, so a click that lands just after any redraw (a state write, a focus flip, a poll) names a
4// released handle and the engine drops it ("ui_press not handled"). The host keeps a handle alive
5// across redraws when the new tree carries the same one, so each element here keeps the first handle
6// its site and key ever got: every tree a surface still holds stays pressable, with the latest closure.
7import type { RenderElement } from 'claude-code'
8
9export type PinSite = { surface: string; component?: string; requestId?: string }
10
11type Node = { type?: string; props?: { key?: unknown }; press?: { plugin: string; handle: number }; children?: unknown[] }
12
13// Far above the runtime's own counter, which starts at 1 and climbs by one per element drawn.
14const FIRST_PINNED = 1_000_000_000
15const pins = new Map<string, number>()
16
17function handleFor(id: string): number {
18 let handle = pins.get(id)
19 if (handle === undefined) {
20 handle = FIRST_PINNED + pins.size
21 pins.set(id, handle)
22 }
23
24 return handle
25}
26
27function pinNode(node: unknown, ctx: { site: string; seen: Map<string, number> }): unknown {
28 if (typeof node !== 'object' || node === null) return node
29 const el = node as Node
30 const children = el.children?.map(child => pinNode(child, ctx))
31 const withKids = children === undefined ? el : { ...el, children }
32 // Only this plugin's own elements: the runtime names the plugin of every tree `next` returned.
33 if (el.press?.plugin !== '' || typeof el.props?.key !== 'string') return withKids
34 const base = `${ctx.site}\u0000${el.type}\u0000${el.props.key}`
35 const nth = ctx.seen.get(base) ?? 0
36 ctx.seen.set(base, nth + 1)
37
38 return { ...withKids, press: { plugin: '', handle: handleFor(`${base}\u0000${nth}`) } }
39}
40
41// Return a render hook's tree through this: `return pinned(e, tree)`.
42export function pinned<T extends RenderElement | null | undefined>(site: PinSite, tree: T): T {
43 if (tree === null || tree === undefined) return tree
44 const ctx = { site: `${site.surface}\u0000${site.component ?? ''}\u0000${site.requestId ?? ''}`, seen: new Map<string, number>() }
45
46 return pinNode(tree, ctx) as T
47}
48hooks/lang.ts 24 lines1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12 current = langOf(value)
13
14 return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24hooks/words.ts 55 lines1import { pick } from './lang'
2
3// Every word the notifications, the fallback band and the command answers show, in English and Turkish.
4// Read through `words()` when a ping is built or a command answers: the language is set at session.start.
5
6const plural = (count: number, one: string, many: string): string => `${count} ${count === 1 ? one : many}`
7
8const TR = {
9 hours: (hours: number, minutes: number) => `${hours}sa ${minutes}dk`,
10 minutes: (minutes: number, seconds: number) => `${minutes}dk ${seconds}sn`,
11 seconds: (seconds: number) => `${seconds}sn`,
12 testsPassed: 'testler geçti',
13 testsFailed: 'testler kaldı',
14 files: (count: number) => `${count} dosya`,
15 filesChanged: (count: number) => `${count} dosya değişti`,
16 took: (duration: string) => `${duration} sürdü`,
17 done: 'Tur bitti',
18 bashCommand: 'Bash komutu',
19 toolPermission: (tool: string) => `${tool} izni`,
20 waitingApproval: 'Onay bekliyor',
21 waitingAnswer: 'Cevap bekliyor',
22 hasQuestion: 'Bir sorusu var',
23 testSent: 'done-ping: deneme bildirimi gönderildi',
24 usage: 'Kullanım: /done-ping [saniye | 2m | test]',
25 thresholdSet: (seconds: number) => `done-ping: ${seconds}sn üstü turlarda bildirir`,
26 switched: (isOn: boolean, seconds: number) => `done-ping ${isOn ? 'açık' : 'kapalı'} · eşik ${seconds}sn`,
27 help: 'Bildirimi aç/kapat; "/done-ping 90" eşiği ayarlar, "test" bir kez çalar',
28}
29
30type Words = typeof TR
31
32const EN: Words = {
33 hours: (hours, minutes) => `${hours}h ${minutes}m`,
34 minutes: (minutes, seconds) => `${minutes}m ${seconds}s`,
35 seconds: seconds => `${seconds}s`,
36 testsPassed: 'tests passed',
37 testsFailed: 'tests failed',
38 files: count => plural(count, 'file', 'files'),
39 filesChanged: count => `${plural(count, 'file', 'files')} changed`,
40 took: duration => `took ${duration}`,
41 done: 'Turn finished',
42 bashCommand: 'Bash command',
43 toolPermission: tool => `${tool} permission`,
44 waitingApproval: 'Waiting for approval',
45 waitingAnswer: 'Waiting for an answer',
46 hasQuestion: 'Has a question',
47 testSent: 'done-ping: test notification sent',
48 usage: 'Usage: /done-ping [seconds | 2m | test]',
49 thresholdSet: seconds => `done-ping: pings after turns longer than ${seconds}s`,
50 switched: (isOn, seconds) => `done-ping ${isOn ? 'on' : 'off'} · threshold ${seconds}s`,
51 help: 'Turn the ping on or off; "/done-ping 90" sets the threshold, "test" rings once',
52}
53
54export const words = (): Words => pick({ en: EN, tr: TR })
55hooks/svg.ts 128 lines1import { themeCss } from './tidemark'
2import type { Mode, Neutral, Role } from './tidemark'
3
4// Svg building blocks of the Tidemark language (DESIGN.md §3–§5): text, shapes, icons and icon
5// tiles. Every color is a CSS variable, so one drawing serves both themes.
6
7export type Ink = Neutral | Role | `${Role}-t` | `${Role}-l`
8export type Font = { size: number; weight?: number; isMono?: boolean; spacing?: number }
9export type IconName = 'check' | 'x' | 'bell' | 'history' | 'flask' | 'clock' | 'play'
10
11export const SANS = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, system-ui, sans-serif`
12export const MONO = `ui-monospace, 'SF Mono', Menlo, monospace`
13// Roughly how wide a reported column of the desktop surface is, erring small so nothing overflows.
14export const PX_PER_COLUMN = 7
15
16const MONO_EM = 0.6
17const BOLD_FROM = 600
18const BOLD_WIDEN = 1.06
19const ICON_GRID = 24
20const ENTITIES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' }
21const MOTION = [
22 '@keyframes tm-breathe{0%,100%{opacity:1}50%{opacity:.45}}.breathe{animation:tm-breathe 1.8s ease-in-out infinite}',
23 '@keyframes tm-shimmer{0%,100%{opacity:.5}50%{opacity:1}}.shimmer{animation:tm-shimmer 1.4s linear infinite}',
24 '@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}',
25].join('')
26
27const ICONS: Record<IconName, string> = {
28 check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
29 x: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
30 bell: '<path d="M18 16.5V11a6 6 0 1 0-12 0v5.5l-1.5 2h15z"/><path d="M10 21.2a2.3 2.3 0 0 0 4 0"/>',
31 history: '<path d="M4.6 12.4a7.5 7.5 0 1 0 2.3-5.6"/><path d="M4.5 4.2v4.3h4.3"/><path d="M12 8.2V12l2.8 1.8"/>',
32 flask: '<path d="M9.5 3.5h5M10.5 3.5v6.2L5.3 18.4a1.5 1.5 0 0 0 1.3 2.1h10.8a1.5 1.5 0 0 0 1.3-2.1l-5.2-8.7V3.5"/><path d="M7.6 15h8.8"/>',
33 clock: '<circle cx="12" cy="12" r="8"/><path d="M12 7.8V12l3 2"/>',
34 play: '<path d="M8 5.5v13l10.5-6.5z"/>',
35}
36
37export const esc = (text: string): string => text.replace(/[&<>"]/g, ch => ENTITIES[ch] ?? ch)
38const px = (value: number): string => String(Math.round(value * 10) / 10)
39const NARROW_CHAR = /[\s.,:;'|!il1()[\]ıjtf`]/
40const WIDE_CHAR = /[A-ZÇĞİÖŞÜmwMW@%]/
41const NARROW_EM = 0.3
42const WIDE_EM = 0.72
43const PLAIN_EM = 0.56
44
45function charEm(ch: string): number {
46 if (NARROW_CHAR.test(ch)) return NARROW_EM
47
48 return WIDE_CHAR.test(ch) ? WIDE_EM : PLAIN_EM
49}
50
51export function measure(text: string, font: Font): number {
52 const chars = [...text]
53 const em = font.isMono === true ? chars.length * MONO_EM : chars.reduce((sum, ch) => sum + charEm(ch), 0)
54 const widen = (font.weight ?? 0) >= BOLD_FROM ? BOLD_WIDEN : 1
55
56 return em * font.size * widen + chars.length * (font.spacing ?? 0)
57}
58
59export function svgWidth(columns: number, range: { reserve?: number; min: number; max: number }): number {
60 const room = (columns - (range.reserve ?? 0)) * PX_PER_COLUMN
61
62 return Math.round(Math.max(range.min, Math.min(range.max, room)))
63}
64
65export function svgDoc(spec: { width: number; height: number; mode: Mode; body: string }): string {
66 const { width, height } = spec
67 const style = `${themeCss()}text{font-family:${SANS};font-variant-numeric:tabular-nums}.mono{font-family:${MONO}}${MOTION}`
68
69 return `<svg xmlns="http://www.w3.org/2000/svg" class="t-${spec.mode}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${style}</style>${spec.body}</svg>`
70}
71
72export type TextSpec = Font & { x: number; y: number; text: string; ink: Ink; anchor?: 'start' | 'middle' | 'end' }
73
74export function text(spec: TextSpec): string {
75 const attrs = [`x="${px(spec.x)}"`, `y="${px(spec.y)}"`, `font-size="${spec.size}"`]
76 if (spec.weight !== undefined) attrs.push(`font-weight="${spec.weight}"`)
77 if (spec.anchor !== undefined && spec.anchor !== 'start') attrs.push(`text-anchor="${spec.anchor}"`)
78 if (spec.spacing !== undefined) attrs.push(`letter-spacing="${spec.spacing}"`)
79 if (spec.isMono === true) attrs.push('class="mono"')
80
81 return `<text ${attrs.join(' ')} style="fill:var(--${spec.ink})">${esc(spec.text)}</text>`
82}
83
84export type Run = { text: string; ink: Ink; weight?: number; isMono?: boolean }
85
86// One line of differently inked runs; the renderer places each run after the last.
87export function runs(spec: { x: number; y: number; size: number; parts: Run[] }): string {
88 const spans = spec.parts.map(part => {
89 const weight = part.weight === undefined ? '' : ` font-weight="${part.weight}"`
90 const mono = part.isMono === true ? ' class="mono"' : ''
91
92 return `<tspan${weight}${mono} style="fill:var(--${part.ink})">${esc(part.text)}</tspan>`
93 })
94
95 return `<text x="${px(spec.x)}" y="${px(spec.y)}" font-size="${spec.size}">${spans.join('')}</text>`
96}
97
98export function rect(spec: { x: number; y: number; w: number; h: number; rx?: number; fill?: Ink; stroke?: Ink; className?: string }): string {
99 const fill = spec.fill === undefined ? 'fill:none' : `fill:var(--${spec.fill})`
100 const stroke = spec.stroke === undefined ? '' : `;stroke:var(--${spec.stroke})`
101 const cls = spec.className === undefined ? '' : ` class="${spec.className}"`
102
103 return `<rect${cls} x="${px(spec.x)}" y="${px(spec.y)}" width="${px(spec.w)}" height="${px(spec.h)}" rx="${spec.rx ?? 0}" style="${fill}${stroke}"/>`
104}
105
106export function line(spec: { x1: number; y1: number; x2: number; y2: number; ink: Ink; dash?: string }): string {
107 const dash = spec.dash === undefined ? '' : ` stroke-dasharray="${spec.dash}"`
108
109 return `<line x1="${px(spec.x1)}" y1="${px(spec.y1)}" x2="${px(spec.x2)}" y2="${px(spec.y2)}" stroke-width="1"${dash} style="stroke:var(--${spec.ink})"/>`
110}
111
112export function icon(name: IconName, at: { x: number; y: number; size: number; ink: Ink; stroke?: number }): string {
113 const scale = at.size / ICON_GRID
114 const width = (at.stroke ?? 1.75) / scale
115
116 return `<g transform="translate(${px(at.x)} ${px(at.y)}) scale(${scale})" fill="none" stroke-width="${px(width)}" stroke-linecap="round" stroke-linejoin="round" style="stroke:var(--${at.ink})">${ICONS[name]}</g>`
117}
118
119// A rounded tint square with a line icon: the identity of a card or a notice row.
120export function iconTile(spec: { x: number; y: number; size: number; role: Role; icon: IconName }): string {
121 const glyph = Math.round(spec.size * 0.56)
122 const inset = (spec.size - glyph) / 2
123
124 return rect({ x: spec.x, y: spec.y, w: spec.size, h: spec.size, rx: Math.round(spec.size * 0.27), fill: `${spec.role}-t` })
125 + icon(spec.icon, { x: spec.x + inset, y: spec.y + inset, size: glyph, ink: spec.role })
126}
127
128hooks/notifier.ts 56 lines1import type { Ping } from './ping'
2
3// How a sound setting plays: a macOS system sound by name, a file through afplay, or none.
4export type Sound = { kind: 'name'; name: string } | { kind: 'file'; path: string } | { kind: 'none' }
5
6const SYSTEM_SOUNDS = /^\/System\/Library\/Sounds\/([A-Za-z]+)\.aiff$/
7const SILENT = ['', 'none', 'off', 'yok', 'sessiz']
8
9export const NOTIFIER_PATHS = ['/opt/homebrew/bin/terminal-notifier', '/usr/local/bin/terminal-notifier']
10
11export function soundOf(setting: string): Sound {
12 const text = setting.trim()
13 if (SILENT.includes(text.toLowerCase())) return { kind: 'none' }
14 const system = SYSTEM_SOUNDS.exec(text)
15 if (system?.[1] !== undefined) return { kind: 'name', name: system[1] }
16 if (text.includes('/')) return { kind: 'file', path: text }
17
18 return { kind: 'name', name: text }
19}
20
21// The text travels as argv items, never spliced into the script, so quotes in it cannot break out.
22export function osascriptArgv(ping: Ping, sound: Sound): string[] {
23 const withSound = sound.kind === 'name' ? ' sound name (item 4 of argv)' : ''
24 const script = `display notification (item 1 of argv) with title (item 2 of argv) subtitle (item 3 of argv)${withSound}`
25 const argv = ['osascript', '-e', 'on run argv', '-e', script, '-e', 'end run', ping.body, ping.title, ping.subtitle]
26
27 return sound.kind === 'name' ? [...argv, sound.name] : argv
28}
29
30// terminal-notifier draws the host app's icon (`-sender`) and replaces the project's previous banner (`-group`).
31export function terminalNotifierArgv(spec: { path: string; ping: Ping; sound: Sound; sender: string | null }): string[] {
32 const { ping, sound, sender } = spec
33 const argv = [spec.path, '-title', ping.title, '-subtitle', ping.subtitle, '-message', ping.body, '-group', `done-ping:${ping.project}`]
34 if (sender !== null) argv.push('-sender', sender, '-activate', sender)
35 if (sound.kind === 'name') argv.push('-sound', sound.name)
36
37 return argv
38}
39
40export function afplayArgv(sound: Sound): string[] | null {
41 return sound.kind === 'file' ? ['afplay', sound.path] : null
42}
43
44// `lsappinfo info -only bundleid <asn>` prints `"CFBundleIdentifier"="id"` or `bundleID="id"`.
45export function frontBundleOf(lsappinfo: string): string | null {
46 const match = /(?:"CFBundleIdentifier"|bundleID)\s*=\s*"([^"]+)"/i.exec(lsappinfo)
47
48 return match?.[1] ?? null
49}
50
51export function asnOf(front: string): string | null {
52 const match = /ASN:[0-9a-fx-]+/i.exec(front)
53
54 return match?.[0] ?? null
55}
56types/index.d.ts 24 lines1export type PingNotice = {
2 id: number
3 // `done` draws the Moss bell tile, `fail` (the turn's tests failed) the Flare one, `wait` the Amber one.
4 kind: 'done' | 'fail' | 'wait'
5 // The outcome ("Tur bitti") and its facts ("testler geçti · 3 dosya değişti · 2dk 14sn").
6 title: string
7 detail: string
8 expiresAt: number
9}
10
11export type PingTheme = { mode: 'dark' | 'light' | 'auto'; isLight: boolean }
12
13declare module 'claude-code' {
14 interface PluginState {
15 'done-ping': {
16 isEnabled: boolean
17 // Seconds a turn must run before its end pings; null keeps the configured default.
18 thresholdSeconds: number | null
19 notice: PingNotice | null
20 theme: PingTheme
21 }
22 }
23}
24