Chimes when a session finishes or needs you, and keeps an inbox of waiting sessions you can click to switch to.

A Claude Code mod that chimes when one of your sessions finishes its work or needs you, and keeps a Sessions waiting inbox beside the chat so you can see every session that's done and jump straight to it.
minSeconds (default 15). Time spent waiting on your answers doesn't count, and quick replies, interrupted turns and subagent turns stay quiet. While background agents or workflows are still running it waits, then chimes when the session wraps up; a long background shell chimes when it ends./chime opens the inbox, /chime test plays both sounds, /chime mute and /chime unmute apply to every session, /chime volume <0-200> sets the volume, and /chime clear empties the inbox.Options (minSeconds, autoOpenInbox) are in the config menu. Sessions share the inbox through files under ~/.claude/session-chime/. Sounds play with afplay, so they work on macOS only.
At the prompt of a terminal session:
/plugin install session-chime --marketplace etopalov/session-chime
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
From a shell:
claude plugin install session-chime --marketplace etopalov/session-chime
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with etopalov/session-chime, then Add plugin.
hooks/register.tsx 677 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry, EntryKind, Inbox, Prefs } from '../types'
5
6type Engine = EngineInterface
7type Sound = 'done' | 'attention'
8// A tool call in flight: what tool.check said of it, whether it put a dialog to the person,
9// and when that was answered.
10type Call = {
11 agentId: string | undefined
12 tool: string
13 isAsk: boolean
14 mayAsk: boolean
15 checked: string | undefined
16 askedAt: number
17 answeredAt: number
18}
19
20const PANE = 'chime-inbox'
21const PANE_TITLE = 'Sessions waiting'
22const POLL_MS = 2000
23const LIVENESS_MS = 60_000
24const MAX_AGE_MS = 24 * 60 * 60 * 1000
25const DEDUPE_MS = 3000
26const TITLE_WAIT_MS = 1500
27const LINK = 'claude://claude.ai/epitaxy/'
28const SOUND: Record<Sound, string> = { done: 'sounds/done.wav', attention: 'sounds/attention.wav' }
29// Tools that always stop and wait for the person when the main loop calls them.
30const PERSON_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
31// Prompts the person sent: typed in the app or a terminal, or from a phone.
32const PERSON_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
33// Background work that may never end on its own: never wait for it before chiming.
34const OPEN_ENDED = new Set(['shell', 'monitor'])
35// Each status as the desktop draws it (a stroke icon in a mid tone that reads on light and
36// dark alike) and as the terminal does (a glyph in a theme color).
37const ICON = {
38 done: { label: 'Finished', mark: '✓', theme: 'success', stroke: '#3E9E6F', paths: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.6 2.6L16 9.6"/>' },
39 input: { label: 'Needs your input', mark: '●', theme: 'permission', stroke: '#5B78EE', paths: '<path d="M6 16v-5a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/>' },
40 error: { label: 'Stopped', mark: '✕', theme: 'error', stroke: '#D9574C', paths: '<path d="M12 3.5l9 16H3z"/><path d="M12 10v4"/><path d="M12 17.2v.1"/>' },
41 muted: { label: 'Muted', mark: '♪', theme: 'inactive', stroke: '#8A887F', paths: '<path d="M11 5L6 9H3v6h3l5 4z"/><path d="M22 9l-6 6"/><path d="M16 9l6 6"/>' },
42 clear: { label: 'Nothing waiting', mark: '✓', theme: 'inactive', stroke: '#8A887F', paths: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.6 2.6L16 9.6"/>' },
43} as const
44const USAGE = [
45 '/chime open the Sessions waiting inbox',
46 '/chime test play both sounds',
47 '/chime mute | unmute silence or restore chimes in every session',
48 '/chime volume <0-200> set the volume, in percent',
49 '/chime clear empty the inbox',
50].join('\n')
51
52const inbox = atom({ plugin: 'session-chime', key: 'inbox' } as const, { entries: [], now: 0 })
53const prefs = atom({ plugin: 'session-chime', key: 'prefs' } as const, {
54 isMuted: false,
55 volume: 100,
56})
57const title = atom({ plugin: 'session-chime', key: 'title' } as const, '')
58
59// This copy's own bookkeeping; a reload starts it over from session.start.
60// Sessions share what they post through files under ~/.claude/session-chime.
61const local = {
62 minMs: 15_000,
63 shouldAutoOpen: true,
64 // A `claude` run from inside another session's shell: the mod stays out of it.
65 isOff: false,
66 dir: '',
67 id: '',
68 link: null as string | null,
69 cwd: '',
70 lastSeenAt: 0,
71 lastPingAt: 0,
72 // Where a stretch of work began, while it waits on background agents or workflows.
73 heldSince: undefined as number | undefined,
74 // The person's latest answer to a dialog in this turn: the work before it is theirs to see.
75 answeredAt: 0,
76 awaitsBackground: false,
77 // Whether the person started this turn (and the next), so a shell's finish needs no chime.
78 isTurnByPerson: false,
79 isNextTurnByPerson: false,
80 // Prompts the person typed over a running turn, to know their turn when it starts.
81 typedOver: new Set<string>(),
82 // Background shells in flight, by id, since when; and the longest that ended this turn.
83 shells: new Map<string, number>(),
84 shellRanMs: 0,
85 calls: new Map<string, Call>(),
86 writes: Promise.resolve(),
87 cache: new Map<string, { mtimeMs: number; value: unknown }>(),
88}
89
90export const register: Register = (on, options) => {
91 local.minMs = Number(options.minSeconds ?? 15) * 1000
92 local.shouldAutoOpen = options.autoOpenInbox !== false
93
94 on('session.start', async ($, e, next) => {
95 const started = await next(e)
96 // The engine's shell tool sets these for the processes it starts; a session's own engine has neither.
97 const isLeaked =
98 (await $.env.get('CLAUDECODE')) !== undefined || (await $.env.get('CLAUDE_CODE_SESSION_ID')) !== undefined
99 // A `claude -p` or SDK run from a session's shell stays out. A person's own terminal session in a
100 // tmux or editor that inherited those stays on, under its own id rather than the parent's.
101 local.isOff = isLeaked && !e.isInteractive
102 if (local.isOff) return started
103 const home = (await $.env.get('HOME')) ?? ''
104 const hostId = isLeaked ? undefined : await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')
105 local.dir = `${home}/.claude/session-chime`
106 local.id = hostId ?? `cli_${await $.session.id()}`
107 local.link = hostId === undefined ? null : LINK + hostId
108 local.cwd = e.cwd
109 local.lastSeenAt = await $.clock.now()
110 await $.command.register({
111 name: 'chime',
112 description: 'Session chime: open the inbox, test, mute, unmute, volume, clear',
113 argumentHint: '[test|mute|unmute|volume <0-200>|clear]',
114 immediate: true,
115 })
116 await poll($)
117 $.clock.every(POLL_MS, () => void poll($))
118 $.clock.every(LIVENESS_MS, () => void prune($))
119 void refreshTitle($)
120 return started
121 })
122
123 on('session.end', async ($, e, next) => {
124 // A dialog dies with its process; a terminal session or a /clear leaves nothing to come back to.
125 const kind = local.link === null || e.reason === 'clear' ? undefined : 'input'
126 if (!local.isOff) await enqueue(() => clearOwn($, kind, false))
127 return next(e)
128 })
129
130 on('classic.UserPromptSubmit', async ($, e, next) => {
131 const named = e.session_title
132 if (!local.isOff && named !== undefined && named !== '') await update($, title, () => named)
133 return next(e)
134 })
135
136 // The person is back: this session no longer waits on them. System-started turns leave the row be.
137 on('prompt.submit', async ($, e, next) => {
138 if (local.isOff || !PERSON_ORIGINS.has(e.origin.kind) || e.isMeta === true) return next(e)
139 void enqueue(() => clearOwn($))
140 void refreshTitle($)
141 if (e.turnId === undefined) {
142 local.isNextTurnByPerson = true
143 return next(e)
144 }
145 // Typed over a running turn: folded into it, or queued to run as a turn of its own.
146 const submitted = await next(e)
147 if (submitted.drop === undefined) local.typedOver.add(submitted.text)
148 return submitted
149 })
150
151 on('turn.start', async ($, e, next) => {
152 if (local.isOff || e.agentId !== undefined) return next(e)
153 local.awaitsBackground = false
154 local.answeredAt = 0
155 local.isTurnByPerson = local.isNextTurnByPerson
156 local.isNextTurnByPerson = false
157 // A prompt the person queued during the last turn runs now as its own: they are back.
158 const isQueued = [...local.typedOver].some(text => text !== '' && e.text.includes(text))
159 local.typedOver.clear()
160 if (isQueued) {
161 local.isTurnByPerson = true
162 void enqueue(() => clearOwn($))
163 }
164 return next(e)
165 })
166
167 // Raised before the turn ends: what background work is still in flight.
168 on('classic.Stop', async ($, e, next) => {
169 if (!local.isOff && e.agent_id === undefined) {
170 const tasks = e.background_tasks ?? []
171 const now = await $.clock.now()
172 local.awaitsBackground = tasks.some(task => !OPEN_ENDED.has(task.type))
173 // Shells alone: a monitor that times out or is re-armed has finished nothing.
174 const shells = new Map(
175 tasks.filter(task => task.type === 'shell').map(task => [task.id, local.shells.get(task.id) ?? now] as const),
176 )
177 for (const [id, since] of local.shells) {
178 if (!shells.has(id)) local.shellRanMs = Math.max(local.shellRanMs, now - since)
179 }
180 local.shells = shells
181 }
182 return next(e)
183 })
184
185 on('turn.complete', async ($, e, next) => {
186 const result = await next(e)
187 if (local.isOff || e.agentId !== undefined) return result
188 const now = await $.clock.now()
189 const agents = await $.agent.list()
190 const isBusy =
191 local.awaitsBackground || agents.some(agent => agent.status === 'running' || agent.status === 'pending')
192 local.awaitsBackground = false
193 // A shell that finished while the person was away counts as the work it did.
194 const shellRanMs = local.isTurnByPerson ? 0 : local.shellRanMs
195 local.shellRanMs = 0
196 if (e.reason === 'aborted') {
197 // The person stopped it, so they are here; background work still running counts from now.
198 local.heldSince = isBusy ? now : undefined
199 return result
200 }
201 const startedAt = Math.max(local.heldSince ?? now - e.durationMs, local.answeredAt)
202 if (isBusy) {
203 // Not done yet: the session wakes when that work finishes, and that turn chimes.
204 local.heldSince = startedAt
205 return result
206 }
207 local.heldSince = undefined
208 const ranMs = Math.max(now - startedAt, shellRanMs)
209 if (ranMs < local.minMs) return result
210 const kind: EntryKind = e.reason === 'answer' ? 'done' : 'error'
211 const detail = e.reason === 'refusal' ? 'Stopped: the model declined' : kind === 'error' ? 'Stopped on an error' : null
212 await chime($, kind === 'done' ? 'done' : 'attention')
213 await refreshTitle($)
214 await enqueue(() => post($, kind, ranMs, detail))
215 return result
216 })
217
218 on('tool.call', async ($, e, next) => {
219 if (local.isOff) return next(e)
220 const call: Call = {
221 agentId: e.agentId,
222 tool: String(e.tool),
223 isAsk: false,
224 mayAsk: false,
225 checked: undefined,
226 askedAt: 0,
227 answeredAt: 0,
228 }
229 local.calls.set(e.tool_use_id, call)
230 if (PERSON_TOOLS.has(call.tool) && call.agentId === undefined) {
231 call.askedAt = await $.clock.now()
232 void needsYou($, waitingOn(call.tool))
233 }
234 try {
235 return await next(e)
236 } finally {
237 local.calls.delete(e.tool_use_id)
238 if (call.askedAt > 0) {
239 // Refused, or a question answered: the call's end is the answer.
240 const answeredAt = call.answeredAt || (await $.clock.now())
241 local.answeredAt = Math.max(local.answeredAt, answeredAt)
242 if (![...local.calls.values()].some(other => other.askedAt > 0)) {
243 void enqueue(() => clearOwn($, 'input'))
244 }
245 }
246 }
247 })
248
249 // What the permission check made of each call, so a dialog can be matched to its own call.
250 on('tool.check', async ($, e, next) => {
251 const verdict = await next(e)
252 const call = e.tool_use_id === undefined ? undefined : local.calls.get(e.tool_use_id)
253 if (call !== undefined) {
254 call.isAsk = verdict.decision === 'ask'
255 // A background agent's ask has nobody to answer it.
256 call.mayAsk = call.isAsk && e.approver !== undefined
257 call.checked = JSON.stringify(e.input)
258 }
259 return verdict
260 }).catch(($, e, next) => next(e))
261
262 // Raised before a permission dialog shows; the hooks beneath may still settle it.
263 on('classic.PermissionRequest', async ($, e, next) => {
264 const answer = await next(e)
265 if (local.isOff || answer.decision !== undefined || answer.block !== undefined) return answer
266 const call = askedBy(e.tool_name, e.agent_id, JSON.stringify(e.tool_input))
267 // No open call: already announced, already answered, or a background ask nobody sees.
268 if (call === undefined) return answer
269 call.askedAt = await $.clock.now()
270 void needsYou($, waitingOn(e.tool_name))
271 return answer
272 }).catch(($, e, next) => next(e))
273
274 // The tool ran, or ran and failed: it was approved when it started, `duration_ms` before now.
275 on('classic.PostToolUse', async ($, e, next) => {
276 await markAnswered($, e.tool_use_id, e.duration_ms)
277 return next(e)
278 })
279
280 on('classic.PostToolUseFailure', async ($, e, next) => {
281 await markAnswered($, e.tool_use_id, e.duration_ms)
282 return next(e)
283 })
284
285 on('command.run', { command: 'chime' }, async ($, e) => {
286 const [verb = '', value] = e.args.trim().toLowerCase().split(/\s+/)
287 switch (verb) {
288 case '':
289 await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
290 return { text: 'Opened the Sessions waiting inbox.' }
291 case 'test': {
292 const { volume } = await read($, prefs)
293 play($, 'done', volume)
294 $.clock.after(1600, () => play($, 'attention', volume))
295 return { text: 'Playing the done chime, then the needs-input ping.' }
296 }
297 case 'mute':
298 await setPrefs($, current => ({ ...current, isMuted: true }))
299 return { text: 'Chimes muted in every session. /chime unmute turns them back on.' }
300 case 'unmute':
301 await setPrefs($, current => ({ ...current, isMuted: false }))
302 return { text: 'Chimes are back on in every session.' }
303 case 'volume': {
304 const percent = Math.round(Number(value))
305 if (value === undefined || !Number.isFinite(percent) || percent < 0 || percent > 200) {
306 const { volume } = await read($, prefs)
307 return { text: `Volume is ${volume}%. Set it with /chime volume <0-200>.` }
308 }
309 await setPrefs($, current => ({ ...current, volume: percent }))
310 play($, 'done', percent)
311 return { text: `Volume set to ${percent}% in every session.` }
312 }
313 case 'clear': {
314 const { entries } = await read($, inbox)
315 await dismiss($, entries)
316 return { text: `Cleared ${entries.length} from the inbox.` }
317 }
318 default:
319 return { text: USAGE }
320 }
321 })
322
323 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
324 const table = $.ui.resolve(e)
325 const { Box, Button, Text } = table
326 const { entries, now } = await read($, inbox)
327 const { isMuted } = await read($, prefs)
328 const waiting = entries.filter(entry => entry.kind !== 'done')
329 const finished = entries.filter(entry => entry.kind === 'done')
330 // The terminal draws no Svg: there the status is a glyph.
331 const icon = (status: keyof typeof ICON, size = 16) =>
332 'Svg' in table ? (
333 <table.Svg source={svg(ICON[status])} alt={ICON[status].label} width={size} height={size} />
334 ) : (
335 <Text color={ICON[status].theme}>{ICON[status].mark}</Text>
336 )
337 const text = (entry: Entry, isUrgent: boolean) => (
338 <Box flexDirection="column" flexGrow={1} minWidth={0}>
339 <Text weight="semibold" wrap="truncate">{entry.title}</Text>
340 {isUrgent && (
341 <Text size="footnote" weight="medium" color={ICON[entry.kind].theme}>{detailOf(entry)}</Text>
342 )}
343 <Text size="footnote" dimColor>{metaOf(entry, now)}</Text>
344 </Box>
345 )
346
347 return (
348 <Box flexDirection="column" gap="md">
349 <Box flexDirection="row" gap="sm" alignItems="center">
350 <Text weight="semibold">Sessions waiting</Text>
351 {entries.length > 0 && <Text weight="semibold" dimColor>{String(entries.length)}</Text>}
352 <Box flexGrow={1} />
353 <Button
354 key="mute"
355 label={isMuted ? 'Unmute' : 'Mute'}
356 variant={isMuted ? 'primary' : 'secondary'}
357 onPress={() => setPrefs($, current => ({ ...current, isMuted: !current.isMuted }))}
358 />
359 </Box>
360 {isMuted && (
361 <Box flexDirection="row" gap="sm" alignItems="center" borderStyle="round" borderDimColor paddingX="sm" paddingY="xs">
362 {icon('muted')}
363 <Text size="footnote">Chimes are off in every session. The inbox still fills.</Text>
364 </Box>
365 )}
366 {entries.length === 0 && (
367 <Box flexDirection="column" alignItems="center" gap="xs" paddingY="lg">
368 {icon('clear', 32)}
369 <Text weight="semibold">You're all caught up</Text>
370 <Text size="footnote" dimColor>
371 Sessions show up here when they finish or need you. Click Open to jump straight to one.
372 </Text>
373 </Box>
374 )}
375 {waiting.length > 0 && (
376 <Box flexDirection="column" gap="xs">
377 <Text size="caption" weight="semibold" dimColor>{`NEEDS YOU · ${waiting.length}`}</Text>
378 {waiting.map(entry => (
379 <Box flexDirection="row" gap="sm" alignItems="flex-start" borderStyle="round" borderDimColor paddingX="sm" paddingY="xs">
380 {icon(entry.kind)}
381 {text(entry, true)}
382 <Button key={entry.id} label="Open" variant="primary" onPress={() => jump($, entry)} />
383 </Box>
384 ))}
385 </Box>
386 )}
387 {finished.length > 0 && (
388 <Box flexDirection="column" gap="xs">
389 <Text size="caption" weight="semibold" dimColor>{`FINISHED · ${finished.length}`}</Text>
390 {finished.map(entry => (
391 <Box flexDirection="row" gap="sm" alignItems="flex-start" paddingY="xs">
392 {icon(entry.kind)}
393 {text(entry, false)}
394 <Button key={entry.id} label="Open" variant="secondary" onPress={() => jump($, entry)} />
395 </Box>
396 ))}
397 </Box>
398 )}
399 {entries.length > 0 && (
400 <Box flexDirection="row" gap="sm" alignItems="center">
401 <Button key="clear" label="Clear all" onPress={() => dismiss($, entries)} />
402 <Text size="footnote" dimColor>Rows clear when you reply.</Text>
403 </Box>
404 )}
405 </Box>
406 )
407 })
408}
409
410// The open call a permission dialog is about: its own loop's, by its arguments first.
411function askedBy(tool: string, agentId: string | undefined, input: string): Call | undefined {
412 const own = [...local.calls.values()].filter(
413 call => call.askedAt === 0 && call.tool === tool && call.agentId === agentId,
414 )
415 const call =
416 own.find(one => one.isAsk && one.checked === input) ??
417 own.find(one => one.checked === input) ??
418 own.find(one => one.isAsk) ??
419 own[0]
420 // A subagent's dialog reaches a person only where tool.check saw someone to answer it.
421 return call !== undefined && (call.agentId === undefined || call.mayAsk) ? call : undefined
422}
423
424// Writes to this session's own entry run one at a time, in the order asked.
425function enqueue(work: () => Promise<void>): Promise<void> {
426 const run = local.writes.then(work)
427 local.writes = run.catch(() => undefined)
428 return run
429}
430
431async function readJson<T>($: Engine, path: string): Promise<T | undefined> {
432 try {
433 return JSON.parse(String(await $.fs.read(path))) as T
434 } catch {
435 return undefined
436 }
437}
438
439// Every *.json under `sub`, read again only when its mtime moved.
440async function listJson<T>($: Engine, sub: string): Promise<Map<string, T>> {
441 const found = new Map<string, T>()
442 let files
443 try {
444 files = await $.fs.list(`${local.dir}/${sub}`)
445 } catch {
446 return found
447 }
448 for (const file of files) {
449 if (file.kind !== 'file' || !file.name.endsWith('.json')) continue
450 const path = `${local.dir}/${sub}/${file.name}`
451 const hit = local.cache.get(path)
452 if (hit?.mtimeMs === file.mtimeMs) {
453 found.set(file.name, hit.value as T)
454 continue
455 }
456 const value = await readJson<T>($, path)
457 if (value === undefined) continue
458 local.cache.set(path, { mtimeMs: file.mtimeMs, value })
459 found.set(file.name, value)
460 }
461 return found
462}
463
464async function loadEntries($: Engine): Promise<Entry[]> {
465 const posted = await listJson<Entry>($, 'sessions')
466 const dismissed = await listJson<{ at: number }>($, 'dismissed')
467 const now = await $.clock.now()
468 return [...posted]
469 .filter(([name, entry]) => {
470 const isDismissed = (dismissed.get(name)?.at ?? -1) >= entry.at
471 return !entry.isCleared && !isDismissed && now - entry.at < MAX_AGE_MS
472 })
473 .map(([, entry]) => entry)
474 .sort((a, b) => b.at - a.at)
475}
476
477async function poll($: Engine) {
478 const entries = await loadEntries($)
479 const shared = await readJson<Prefs>($, `${local.dir}/prefs.json`)
480 const now = await $.clock.now()
481 const current = await read($, inbox)
482 // Redraw when the rows change or any row's age would read differently.
483 const isSame =
484 JSON.stringify(entries) === JSON.stringify(current.entries) &&
485 entries.every(entry => metaOf(entry, now) === metaOf(entry, current.now))
486 if (!isSame) await update($, inbox, () => ({ entries, now }))
487 const mine = await read($, prefs)
488 if (shared && (shared.isMuted !== mine.isMuted || shared.volume !== mine.volume)) {
489 await update($, prefs, () => shared)
490 }
491 const newest = Math.max(0, ...entries.map(entry => entry.at))
492 if (newest > local.lastSeenAt) {
493 local.lastSeenAt = newest
494 if (local.shouldAutoOpen) await $.ui.open({ id: PANE, title: PANE_TITLE })
495 }
496}
497
498// Rows of sessions that were archived or deleted leave every inbox.
499async function prune($: Engine) {
500 const { entries } = await read($, inbox)
501 for (const entry of entries) {
502 if (entry.link === null || entry.id === local.id) continue
503 const found = await ask($, entry.id)
504 if (found === undefined) continue
505 const isGone = found.isError ? /not found/i.test(found.text) : parseField(found.text, 'isArchived') === true
506 if (isGone) await $.fs.write(`${local.dir}/dismissed/${entry.id}.json`, JSON.stringify({ at: entry.at }))
507 }
508}
509
510// The desktop's record of a session, or undefined when it does not answer in time.
511async function ask($: Engine, sessionId: string): Promise<{ isError: boolean; text: string } | undefined> {
512 const stop = new AbortController()
513 const found = await Promise.race([
514 $.mcp.call('ccd_session_mgmt', 'get_session', { session_id: sessionId }).catch(() => undefined),
515 $.clock.sleep(TITLE_WAIT_MS, { signal: stop.signal }).then(
516 () => undefined,
517 () => undefined,
518 ),
519 ])
520 stop.abort()
521 if (found === undefined) return undefined
522 return { isError: found.isError, text: found.content.find(block => block.type === 'text')?.text ?? '' }
523}
524
525function parseField(text: string, field: string): unknown {
526 try {
527 return (JSON.parse(text) as Record<string, unknown>)[field]
528 } catch {
529 return undefined
530 }
531}
532
533// The desktop's own title for this session, kept for posts that cannot wait for it.
534async function refreshTitle($: Engine) {
535 if (local.link === null) return
536 const found = await ask($, 'self')
537 const named = found === undefined || found.isError ? undefined : parseField(found.text, 'title')
538 if (typeof named === 'string' && named !== '') await update($, title, () => named)
539}
540
541function play($: Engine, sound: Sound, volume: number) {
542 void $.audio.play({ asset: SOUND[sound] }, { gain: volume / 100 }).catch((error: unknown) => {
543 $.ui.log(`chime failed: ${String(error)}`, { to: 'debug' })
544 })
545}
546
547async function chime($: Engine, sound: Sound) {
548 const { isMuted, volume } = await read($, prefs)
549 if (!isMuted) play($, sound, volume)
550}
551
552async function setPrefs($: Engine, change: (current: Prefs) => Prefs) {
553 const changed = change(await read($, prefs))
554 await $.fs.write(`${local.dir}/prefs.json`, JSON.stringify(changed))
555 await update($, prefs, () => changed)
556}
557
558function ownPath(): string {
559 return `${local.dir}/sessions/${local.id}.json`
560}
561
562async function post($: Engine, kind: EntryKind, durationMs: number, detail: string | null = null) {
563 const entry: Entry = {
564 id: local.id,
565 title: (await read($, title)) || local.cwd.split('/').filter(Boolean).at(-1) || 'A session',
566 cwd: local.cwd,
567 kind,
568 at: await $.clock.now(),
569 durationMs,
570 isCleared: false,
571 link: local.link,
572 detail,
573 }
574 await $.fs.write(ownPath(), JSON.stringify(entry))
575 await poll($)
576}
577
578async function clearOwn($: Engine, onlyKind?: EntryKind, shouldPoll = true) {
579 const entry = await readJson<Entry>($, ownPath())
580 if (entry === undefined || entry.isCleared) return
581 if (onlyKind !== undefined && entry.kind !== onlyKind) return
582 await $.fs.write(ownPath(), JSON.stringify({ ...entry, isCleared: true }))
583 if (shouldPoll) await poll($)
584}
585
586async function dismiss($: Engine, entries: readonly Entry[]) {
587 for (const entry of entries) {
588 await $.fs.write(`${local.dir}/dismissed/${entry.id}.json`, JSON.stringify({ at: entry.at }))
589 }
590 await poll($)
591}
592
593// A dialog is up for the person: ping (once for a burst of them) and post the row.
594async function needsYou($: Engine, detail: string) {
595 const now = await $.clock.now()
596 if (now - local.lastPingAt >= DEDUPE_MS) {
597 local.lastPingAt = now
598 await chime($, 'attention')
599 }
600 await enqueue(() => post($, 'input', 0, detail))
601 // A new session is named only after its first prompt: put the name on the row once it lands.
602 await refreshTitle($)
603 await enqueue(() => retitleOwn($))
604}
605
606async function markAnswered($: Engine, toolUseId: string, durationMs: number | undefined) {
607 const call = local.calls.get(toolUseId)
608 if (call !== undefined && call.askedAt > 0) call.answeredAt = (await $.clock.now()) - (durationMs ?? 0)
609}
610
611// Keeps the row's time, so no pane pops open again and a dismissal still holds.
612async function retitleOwn($: Engine) {
613 const named = await read($, title)
614 const entry = await readJson<Entry>($, ownPath())
615 if (named === '' || entry === undefined || entry.isCleared || entry.title === named) return
616 await $.fs.write(ownPath(), JSON.stringify({ ...entry, title: named }))
617 await poll($)
618}
619
620async function jump($: Engine, entry: Entry) {
621 await dismiss($, [entry])
622 if (entry.link === null) {
623 $.ui.toast(`${entry.title} runs in a terminal; switch to it there.`)
624 return
625 }
626 const opened = await $.process.run(['open', entry.link], { cwd: '/' }).catch(() => undefined)
627 if (opened?.exitCode !== 0) {
628 $.ui.toast(`Couldn't switch to ${entry.title}. Open it from the sidebar.`)
629 }
630}
631
632// What a waiting session wants, in the words its row shows.
633function waitingOn(tool: string): string {
634 if (tool === 'AskUserQuestion') return 'Has a question for you'
635 if (tool === 'ExitPlanMode') return 'Has a plan for you to approve'
636 if (tool === 'Bash') return 'Wants to run a command'
637 if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(tool)) return 'Wants to edit files'
638 if (tool === 'WebFetch' || tool === 'WebSearch') return 'Wants to go online'
639 const mcp = /^mcp__.+?__(.+)$/.exec(tool)
640 return `Wants to use ${mcp?.[1] ?? tool}`
641}
642
643// Entries posted before 0.3.0 carry no detail.
644function detailOf(entry: Entry): string {
645 return entry.detail ?? (entry.kind === 'input' ? 'Needs your input' : 'Stopped on an error')
646}
647
648// The folder (unless the title already says it), the age and, for finished work, how long it ran.
649function metaOf(entry: Entry, now: number): string {
650 const folder = entry.cwd.split('/').filter(Boolean).at(-1)
651 return [
652 folder === entry.title ? undefined : folder,
653 agoText(now - entry.at),
654 entry.kind === 'input' ? undefined : `ran ${ranText(entry.durationMs)}`,
655 ]
656 .filter(part => part !== undefined)
657 .join(' · ')
658}
659
660function svg(icon: { stroke: string; paths: string }): string {
661 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${icon.stroke}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icon.paths}</svg>`
662}
663
664function agoText(ms: number): string {
665 if (ms < 60_000) return 'just now'
666 if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`
667 return `${Math.floor(ms / 3_600_000)}h ago`
668}
669
670function ranText(ms: number): string {
671 const seconds = Math.round(ms / 1000)
672 if (seconds < 60) return `${seconds}s`
673 const minutes = Math.floor(seconds / 60)
674 if (minutes < 60) return `${minutes}m ${seconds % 60}s`
675 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
676}
677types/index.d.ts 27 lines1export type EntryKind = 'done' | 'input' | 'error'
2
3/** One session's latest ping, as ~/.claude/session-chime/sessions/<id>.json holds it. */
4export type Entry = {
5 id: string
6 title: string
7 cwd: string
8 kind: EntryKind
9 at: number
10 durationMs: number
11 isCleared: boolean
12 link: string | null
13 /** What a waiting session wants ("Wants to run a command"); null for finished work. */
14 detail?: string | null
15}
16
17/** The waiting entries, newest first, and the time their ages were drawn at. */
18export type Inbox = { entries: Entry[]; now: number }
19
20export type Prefs = { isMuted: boolean; volume: number }
21
22declare module 'claude-code' {
23 interface PluginState {
24 'session-chime': { inbox: Inbox; prefs: Prefs; title: string }
25 }
26}
27