SLOPSHOPPER

done-ping

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…

newbandguardcommandpromptprocess
★ 2v?MITupdated 2026-10-13partalemre/MODZ/mods/done-ping
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · done-ping
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /done-ping ⎿ done-ping: done-ping off · threshold 60s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

done-ping

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.

Install

/plugin marketplace add partalemre/MODZ
/plugin install done-ping@modz

Or in one step: /plugin install done-ping --marketplace partalemre/MODZ.

What you see

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.

  • Turn finished (main loop, not interrupted, ran at least the threshold): body such as 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).
  • Waiting for approval: the tool and at most a file name or host (Edit · app.ts, WebFetch · docs.example.com). A command is never shown; it says Bash command.
  • Waiting for an answer: the first question Claude asked you, without markdown or code.

Where a ping goes, in order:

  1. In the desktop app, Claude's own notification (with the Claude icon), when the app allows it without a prompt. If the app reports that you are present, nothing is shown.
  2. If the app this session runs in (Claude, Terminal, iTerm, Ghostty) is the frontmost app, no banner is shown.
  3. terminal-notifier, if you already have it installed in /opt/homebrew/bin or /usr/local/bin. Nothing is installed for you.
  4. A macOS banner through osascript, with the sound you chose.
  5. If no banner could be shown, a one-line notice above the prompt: a bell tile (green when done, red if the turn's tests failed, amber when waiting), the title, then the facts. It clears itself after 20 seconds or when you send the next prompt. A live notice from session-budget or test-badge takes that line first.

One waiting ping is sent per 15-second burst, and any two pings are at least 10 seconds apart.

Sound packs

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.

Commands

  • /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.

Settings

OptionDefaultMeaning
thresholdSeconds60A finished turn pings only when it ran at least this long.
pingOnPermissiontrueAlso ping when Claude asks for permission or asks you a question.
soundGlassThe 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.
soundPackoffchime, soft or retro plays the mod's own sounds, one for done and one for waiting.
quietWhenFocusedtrueSkip the banner while the app this session runs in is the frontmost app.
appNotificationtrueIn 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.

Limits

  • 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.
  • Another session's tab in the same app counts as "looking at the session".
  • In auto mode a classifier can settle a permission ask without showing you a dialog, so a waiting ping can occasionally fire with nothing visible to answer.
  • Outside the desktop app the banner is macOS only.

Uses no model tokens.

Language

The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.

Source 12 files
hooks/register.tsx 249 lines
1import { 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}
249
hooks/band.tsx 61 lines
1import { 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}
61
hooks/ping.ts 163 lines
1import { 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}
163
hooks/tidemark.ts 61 lines
1// 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}
61
hooks/ring.ts 116 lines
1import 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}
116
hooks/pack.ts 19 lines
1import 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}
19
hooks/pin.ts 48 lines
1// 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}
48
hooks/lang.ts 24 lines
1// 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)
24
hooks/words.ts 55 lines
1import { 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 })
55
hooks/svg.ts 128 lines
1import { 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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }
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
128
hooks/notifier.ts 56 lines
1import 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}
56
types/index.d.ts 24 lines
1export 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