SLOPSHOPPER

session-chime

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

newpaneguardcommandtoastprompt
v0.3.1MITupdated 2026-10-25etopalov/session-chime
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-chime
│ ┃ Sessions waiting ✕ › fix the failing auth test and add an audit log call │ ┃ Sessions waiting[ Mute ] │ ┃ ✓ ⏺ Read(src/auth.ts) │ ┃ You're all caught up ⎿ Read 6 lines │ ┃ Sessions show up here when they finish or ⏺ Update(src/auth.ts) │ ┃ need you. Click Open to jump straight to ⎿ Added 2 lines, removed 1 line │ ┃ one. ⏺ 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 │ │ › /chime │ ⎿ session-chime: Opened the Sessions waiting inbox. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions waiting
Sessions waiting[ Mute ] ✓ You're all caught up Sessions show up here when they finish or need you. Click Open to jump straight to one.
README

session-chime

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.

  • Done chime: a soft two-note chime when a session finishes a turn that ran at least 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.
  • Needs-input ping: a sharper double ping when a session puts a permission dialog, a question, or a plan approval to you. This one always sounds.
  • Inbox pane: opens on a new ping. Sessions that need you sit on top as cards that say what they want ("Wants to run a command", "Has a question for you"), with finished work listed below, newest first. Press Open to jump to that session in the desktop app. Rows clear when you reply to that session, and archived or deleted sessions drop out on their own.
  • /chime: /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.

Install

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.

Source 2 files
hooks/register.tsx 677 lines
1import { 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}
677
types/index.d.ts 27 lines
1export 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