Claude Code speaks through murmur (Kokoro on the Apple Neural Engine): turn summaries, needs-you alerts, background tasks, progress check-ins and rate-limit…

Mods for Claude Code: small plugins that add to its screen and behavior. Each folder is one mod. Turn on the ones you want (see Setup) and change their settings in /config, under the mod's name.
Shows how full Claude's context window is, as a colored bar above the prompt.
/context-bar hides or shows it for this session.Shows how much of your 5-hour and weekly usage limits you've used, as two small meters under the context bar. They turn yellow at 70% and red at 90%, and you get a warning once a limit passes 90%.
Shows this session's ID in the bottom-right corner of the screen.
claude --resume <id> command.Adds a receipt to the line that closes each turn ("✻ Baked for 42s"): how many tools ran, which files were edited, the tokens used and what the turn cost.
Adds a one-sentence summary under Claude's longer replies, written by Claude Haiku.
/config sets how long a reply must be (600 characters by default).Lets you see images Claude reads, right in the conversation.
🖼 name.png · hover to preview. Point at it to show the picture, and move away to hide it.Gives Claude a voice, through murmur. It speaks only when you're away from the session (another app or tab in front), so it never talks over you while you're watching.
/config./config under say.Everything else is one command, /say:
| Command | What it does |
|---|---|
/say | Lists every voice and switches this session to the one you pick, with a quick hello in it. The choice sticks to the conversation, so resuming it keeps the voice. |
/say <name> | Switches directly, like /say emma. Part of the name is enough. |
/say read | Reads Claude's last reply aloud. |
/say tldr | A spoken 2–3 sentence summary of the session so far. |
/say hush | Mutes this session; run it again to unmute. |
/say help | Lists these. |
Ties Claude into the Ghostty terminal. All its settings start with "Ghostty" in /config.
/title gets a new one right away. A resumed session gets its old title back./workspace saves and reopens whole windows of sessions: see Workspaces./goto (or ⌘⌃G) lists your open Claude sessions, the ones waiting on you first, and jumps to the one you pick. /goto next jumps straight to the one waiting longest; /goto <words> jumps to the one whose title matches./keybind add <keys> <text> makes a Ghostty key type something into Claude, like /keybind add super+ctrl+h /say hush for ⌘⌃H. /keybind lists them, and /keybind remove <keys> removes one.A workspace is a Ghostty window of Claude sessions you can put away and bring back later. It remembers every tab and split: each Claude session with its conversation, and each shell with its folder. Reopening it brings back the whole window, with every conversation picking up where it left off.
| Command | What it does |
|---|---|
/workspace save [name] | Saves this window. With no name, it keeps the name the window already had, or Claude Haiku picks one from the tabs. |
/workspace close [name] | Saves this window, then exits every Claude session in it and closes their tabs. Your current tab stays open at the shell, and shell tabs stay open. |
/workspace list | Shows your saved workspaces, newest first. Press a number or Enter to reopen one, or Tab to ✕ delete to remove it. |
/workspace open <name> | Reopens a saved workspace in a new window. Part of the name is enough. |
claude --resume, so they continue their own conversation, with the tab title they had when you saved. A tab saved without a title gets a new one from its conversation. Shell tabs open at the prompt./workspace close. Pick it up tomorrow: /workspace list, then press its number.~/.claude/workspaces/, one JSON file per workspace.CLAUDE_CODE_PLUGIN_DIRS, separated by :, in the env block of ~/.claude/settings.json: "env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods/context-bar:/path/to/claude-mods/say"
}
To try one mod for a single session: claude --plugin-dir /path/to/claude-mods/tldr.
CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 in the same env block, so Claude Code doesn't overwrite the tab title.The first time a mod controls Ghostty, macOS asks for permission. Allow it.
Editing a mod's files reloads it in running sessions.
claude plugin validate context-bar # check a mod the way Claude Code will load it
claude plugin test context-bar # run its tests
IDEAS.md has ideas for more mods.
hooks/register.tsx 389 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { checkinLine, errorLine, firstSentence, prose, rateLimitLine, summaryPrompt, taskLine, voiceList } from './speech'
4import type { Todo } from './speech'
5import { claimVoice, matchVoice, parseCatalog, parseTable } from './voices'
6import type { VoiceInfo } from './voices'
7
8type Config = {
9 speakWhen: string
10 turnSummaries: boolean
11 summaryStyle: string
12 minTurnSeconds: number
13 needsYou: boolean
14 backgroundTasks: boolean
15 checkins: boolean
16 checkinMinutes: number
17 rateLimitWarnings: boolean
18 rateLimitPercent: number
19 speed: number
20 chime: boolean
21 voice: string
22 voices: string[]
23}
24
25function config(o: PluginOptions): Config {
26 const num = (v: unknown, d: number) => (typeof v === 'number' && v > 0 ? v : d)
27 return {
28 speakWhen: o.speakWhen === 'always' ? 'always' : 'away',
29 turnSummaries: o.turnSummaries !== false,
30 summaryStyle: o.summaryStyle === 'first-sentence' ? 'first-sentence' : 'haiku',
31 minTurnSeconds: num(o.minTurnSeconds, 20),
32 needsYou: o.needsYou !== false,
33 backgroundTasks: o.backgroundTasks !== false,
34 checkins: o.checkins !== false,
35 checkinMinutes: num(o.checkinMinutes, 5),
36 rateLimitWarnings: o.rateLimitWarnings !== false,
37 rateLimitPercent: num(o.rateLimitPercent, 90),
38 speed: num(o.speed, 1),
39 chime: o.chime !== false,
40 voice: typeof o.voice === 'string' ? o.voice.trim() : '',
41 voices: typeof o.voices === 'string' ? voiceList(o.voices) : [],
42 }
43}
44
45// The module's own session state (a reload starts it over, which only forgets a /say hush).
46let cfg: Config
47let claude: { tty: string; pid: string } | null = null
48let home = ''
49/** This session's own voice, claimed at start (unused when a fixed voice is set). */
50let sessionVoice = ''
51/** The voice you picked with /say for this conversation: wins over the session's own and the fixed one. */
52let chosen = ''
53/** What the /say picker lists: murmur's voices, and the ones other sessions hold. */
54const PICKER = 'claude-say-picker'
55const currentVoice = () => chosen || cfg.voice || sessionVoice || 'af_heart'
56let picker: { voices: VoiceInfo[]; others: Set<string> } = { voices: [], others: new Set() }
57let inGhostty = false
58let muted = false
59let lastRequest = ''
60let lastAnswer = ''
61let todos: Todo[] = []
62let checkin: { cancel(): void } | undefined
63let turnStartedAt = 0
64/** Calls the permission check put to you, until they run or are refused. */
65const asking = new Set<string>()
66const warned = new Set<string>()
67
68// "<tty> <pid>" of Claude itself: the nearest ancestor named claude (the mods' own host process, between it and
69// this shell, owns the tty too, and changes when mods reload). Its pid keys the session's voice.
70const FIND_CLAUDE = `p=$$
71first=
72while [ "$p" -gt 1 ]; do
73 t=$(ps -o tty= -p "$p" | tr -d ' ')
74 case "$t" in ''|'?'|'??') ;; *) [ -z "$first" ] && first="$t $p"
75 c=$(ps -o comm= -p "$p"); [ "\${c##*/}" = claude ] && { echo "$t $p"; exit; } ;;
76 esac
77 p=$(ps -o ppid= -p "$p" | tr -d ' ')
78done
79echo "$first"`
80
81// The tty of the terminal Ghostty has focused, or nothing when Ghostty isn't the frontmost app.
82const FOCUSED_TTY = `tell application "Ghostty"
83 if frontmost then return tty of focused terminal of selected tab of front window
84 return ""
85end tell`
86
87async function locate($: EngineInterface) {
88 // The environment first: the Claude search exits as soon as it finds it.
89 const found = (await $.process.run(['sh', '-c', `echo "$TERM_PROGRAM"\necho "$HOME"\n${FIND_CLAUDE}`])).stdout.trim().split('\n')
90 inGhostty = (found[0] ?? '').toLowerCase() === 'ghostty'
91 home = found[1] ?? ''
92 const [tty, pid] = (found[2] ?? '').split(' ')
93 claude = tty && pid ? { tty, pid } : null
94}
95
96/** Claude Code's record of this session (~/.claude/sessions/<pid>.json) says its permission dialog is up. */
97function asksPermission(record: string): boolean {
98 try {
99 const r = JSON.parse(record) as { status?: unknown; waitingFor?: unknown }
100 return r.status === 'waiting' && typeof r.waitingFor === 'string' && /permission/i.test(r.waitingFor)
101 } catch {
102 return false
103 }
104}
105
106/**
107 * A call that asks waits in Claude Code's dialog, or in auto mode its checker. Only the dialog marks the session
108 * record waiting, so watch it while the call is pending. (Mods never hear the classic Notification event.)
109 */
110async function watchForDialog($: EngineInterface, id: string, tool: string) {
111 if (!claude || !home) return
112 asking.add(id)
113 // ponytail: polls every half second, 10 minutes at most; an event for the dialog would replace it.
114 for (let i = 0; i < 1200; i++) {
115 await $.clock.sleep(500)
116 if (!asking.has(id)) return
117 if (asksPermission(await $.fs.read(`${home}/.claude/sessions/${claude.pid}.json`).catch(() => ''))) {
118 return say($, `Claude needs your permission to use ${tool}.`)
119 }
120 }
121}
122
123// /say's choice, kept per conversation so resuming it (or reopening its workspace) keeps the voice.
124async function chosenFile($: EngineInterface): Promise<string | undefined> {
125 const id = await $.session.id()
126 return home && /^[0-9a-f-]{8,64}$/i.test(id) ? `${home}/Library/Caches/claude-say/chosen/${id}` : undefined
127}
128
129async function choose($: EngineInterface, v: VoiceInfo): Promise<string> {
130 chosen = v.id
131 const file = await chosenFile($)
132 if (file) {
133 await $.process.run(['mkdir', '-p', file.replace(/\/[^/]*$/, '')])
134 await $.fs.write(file, v.id)
135 }
136 await claimSessionVoice($).catch(() => {})
137 void say($, `Hi, I'm ${v.name}.`, true)
138 return `🔊 This session now speaks as ${v.name} (${v.id}).` + (v.isInstalled ? '' : ' It installs on first use, so the first line may take a moment.')
139}
140
141/** The voices to pick from, and which ones other running sessions hold. */
142async function loadPicker($: EngineInterface) {
143 const r = await $.process.run(['murmur', 'voices'])
144 const table = parseTable(await $.fs.read(`${home}/Library/Caches/claude-say/voices.json`).catch(() => ''))
145 const others = new Set(Object.entries(table).filter(([pid]) => pid !== claude?.pid).map(([, v]) => v))
146 picker = { voices: parseCatalog(r.stdout), others }
147 return picker.voices
148}
149
150// Every running session shares one small table of who speaks with which voice.
151async function claimSessionVoice($: EngineInterface) {
152 if (!claude || !home) return
153 const dir = `${home}/Library/Caches/claude-say`
154 const file = `${dir}/voices.json`
155 const table = parseTable(await $.fs.read(file).catch(() => ''))
156 const pids = Object.keys(table)
157 const alive = pids.length
158 ? (await $.process.run(['sh', '-c', 'for p; do kill -0 "$p" 2>/dev/null && echo "$p"; done', 'sh', ...pids])).stdout.split('\n')
159 : []
160 const claimed = claimVoice(table, claude.pid, new Set(alive), cfg.voices)
161 sessionVoice = claimed.voice
162 if (chosen) claimed.table[claude.pid] = chosen
163 // ponytail: last writer wins; two sessions starting in the same instant can share a voice until one restarts.
164 await $.process.run(['mkdir', '-p', dir])
165 await $.fs.write(file, JSON.stringify(claimed.table))
166}
167
168// Quiet by default: speak only when this terminal isn't the one in front of you.
169async function isAway($: EngineInterface): Promise<boolean> {
170 if (cfg.speakWhen === 'always' || !inGhostty || !claude) return true // can't tell: the other gates decide
171 const r = await $.process.run(['osascript', '-e', FOCUSED_TTY])
172 return r.exitCode !== 0 || r.stdout.trim() !== `/dev/${claude.tty}`
173}
174
175// Interrupt, don't stack: each new line cuts off whatever is still playing.
176async function say($: EngineInterface, text: string, always = false) {
177 if (muted || !text.trim() || (!always && !(await isAway($)))) return
178 // A soft bell first (the mod's chime.wav), so speech never starts out of nowhere. Not awaited: murmur takes
179 // about as long to get ready as the bell rings.
180 if (cfg.chime) void $.process.run(['afplay', `${$.plugin.root}/chime.wav`]).catch(() => null)
181 const argv = ['murmur', '--interrupt', '--speed', String(cfg.speed)]
182 const voice = chosen || cfg.voice || sessionVoice
183 if (voice) argv.push('--voice', voice)
184 const r = await $.process.run([...argv, '--', text]).catch(() => null)
185 if (r && r.exitCode !== 0) $.ui.toast(`murmur: ${r.stderr.trim().slice(0, 120) || 'failed'}`)
186}
187
188/** The tldr mod's line for this turn, when tldr is enabled: one summary, shown and spoken. Empty when it has none. */
189async function tldrLine($: EngineInterface, turnId: string): Promise<string> {
190 const rows = await $.config.list().catch(() => [])
191 if (!rows.some(r => r.key.startsWith('tldr.'))) return ''
192 // tldr publishes after its own Haiku call (8 s at most); a short reply publishes an empty line at once.
193 for (let waited = 0; waited <= 10_000; waited += 250) {
194 const held = (await $.state.get({ plugin: 'tldr', key: 'line' } as never)) as { value?: { turnId?: string; text?: string } | null }
195 if (held.value?.turnId === turnId) return held.value.text ?? ''
196 await $.clock.sleep(250)
197 }
198 return ''
199}
200
201async function summarize($: EngineInterface, turnId: string, request: string, answer: string): Promise<string> {
202 if (cfg.summaryStyle === 'first-sentence') return firstSentence(answer)
203 const shared = await tldrLine($, turnId)
204 if (shared) return shared
205 const r = await $.model.complete({ model: 'haiku', prompt: summaryPrompt(request, answer), maxTokens: 60 })
206 const line = r.isAnswered ? prose(r.text).replace(/\s+/g, ' ').trim() : ''
207 return line && line.length <= 200 ? line : firstSentence(answer)
208}
209
210async function checkIn($: EngineInterface) {
211 const minutes = Math.round((Date.now() - turnStartedAt) / 60000)
212 await say($, checkinLine(todos, minutes))
213}
214
215function stopCheckins() {
216 checkin?.cancel()
217 checkin = undefined
218}
219
220/** /say with no subcommand: the picker, or the voice that matches. */
221async function pickVoice($: EngineInterface, query: string): Promise<{ text: string }> {
222 const voices = await loadPicker($)
223 if (voices.length === 0) return { text: "Couldn't list murmur's voices. Is murmur installed?" }
224 if (!query) {
225 await $.ui.open({ id: PICKER, title: 'Voices', focus: true, closeOnEscape: true, rows: voices.length + new Set(voices.map(v => v.group)).size + 1 })
226 return { text: `Speaking as ${voices.find(v => v.id === currentVoice())?.name ?? currentVoice()}: pick another above.` }
227 }
228 const hits = matchVoice(query, voices)
229 if (hits.length === 1) return { text: await choose($, hits[0]!) }
230 if (hits.length === 0) return { text: `No voice or command called "${query}". /say help lists them.` }
231 return { text: `"${query}" could be ${hits.map(v => `${v.name} (${v.id})`).join(', ')}.` }
232}
233
234const HELP = [
235 '/say pick this session\'s voice from a list',
236 '/say <name> switch to that voice (/say emma)',
237 "/say read read Claude's last answer aloud",
238 '/say tldr hear a 2–3 sentence summary of this session',
239 '/say hush mute or unmute spoken updates for this session',
240].join('\n')
241
242async function hush($: EngineInterface): Promise<{ text: string }> {
243 muted = !muted
244 if (muted) await $.process.run(['murmur', 'stop']).catch(() => null)
245 return { text: muted ? '🔇 Spoken updates muted for this session. /say hush again to unmute.' : '🔊 Spoken updates back on.' }
246}
247
248async function readAloud($: EngineInterface): Promise<{ text: string }> {
249 if (!lastAnswer) return { text: 'Nothing to read yet.' }
250 if (muted) return { text: 'Muted: /say hush to unmute first.' }
251 void say($, prose(lastAnswer, 'Code omitted.'), true)
252 return { text: '🔊 Reading the last answer aloud. /say hush stops it.' }
253}
254
255async function tldrAloud($: EngineInterface): Promise<{ text: string }> {
256 if (muted) return { text: 'Muted: /say hush to unmute first.' }
257 const r = await $.model.fork({
258 prompt: 'In 2 or 3 short sentences meant to be spoken aloud, summarize this session so far: the goal, what is done, and what is next. Plain words only, no markdown or code.',
259 })
260 if (!r.isAnswered) return { text: `Couldn't summarize: ${r.reason}` }
261 const line = prose(r.text).replace(/\s+/g, ' ').trim()
262 void say($, line, true)
263 return { text: `🔊 ${line}` }
264}
265
266export const register: Register = (on, options) => {
267 cfg = config(options)
268 // Hooks that gate a prompt or a tool call pass it on untouched if they fail: speech must never block work.
269
270 on('session.start', async ($, e, next) => {
271 await locate($)
272 const file = await chosenFile($).catch(() => undefined)
273 chosen = file ? (await $.fs.read(file).catch(() => '')).trim() : ''
274 if (!cfg.voice || chosen) await claimSessionVoice($).catch(() => {})
275 await $.command.register({ name: 'say', description: 'Spoken updates: /say to pick a voice, /say <name>, /say read, /say tldr, /say hush, /say help' })
276 return next(e)
277 })
278
279 // Every turn starts here: its request, a fresh todo list, and the check-in timer.
280 on('prompt.submit', async ($, e, next) => {
281 stopCheckins()
282 todos = []
283 turnStartedAt = Date.now()
284 if (e.origin.kind === 'task-notification') {
285 if (cfg.backgroundTasks) void say($, taskLine(e.text))
286 } else {
287 lastRequest = e.text
288 }
289 if (cfg.checkins) checkin = $.clock.every(cfg.checkinMinutes * 60_000, () => void checkIn($))
290 return next(e)
291 }).catch(($, e, next) => next(e))
292
293 on('tool.call', { tool: 'TodoWrite' }, ($, e, next) => {
294 todos = e.todos.map(t => ({ ...t }))
295 return next(e)
296 }).catch(($, e, next) => next(e))
297
298 on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => {
299 if (cfg.needsYou) void say($, 'Claude has a question for you.')
300 return next(e)
301 }).catch(($, e, next) => next(e))
302
303 on('tool.check', async ($, e, next) => {
304 const r = await next(e)
305 if (cfg.needsYou && r.decision === 'ask' && e.tool_use_id) void watchForDialog($, e.tool_use_id, e.tool).catch(() => {})
306 return r
307 }).catch(($, e, next) => next(e))
308
309 // A call that ran or was refused is no longer waiting on you.
310 on('tool.call', async ($, e, next) => {
311 try {
312 return await next(e)
313 } finally {
314 asking.delete((e as { tool_use_id?: string }).tool_use_id ?? '')
315 }
316 }).catch(($, e, next) => next(e))
317
318 on('turn.complete', async ($, e, next) => {
319 if (e.agentId) return next(e)
320 stopCheckins()
321 lastAnswer = e.answer
322 const isLong = e.durationMs >= cfg.minTurnSeconds * 1000
323 // An API error ends the turn (connection lost, the computer slept): say so, in place of a summary.
324 const error = errorLine(e.reason, e.answer)
325 if (error) {
326 if (cfg.needsYou) void say($, error)
327 } else if (cfg.turnSummaries && e.reason === 'answer' && isLong && !muted) {
328 const request = lastRequest
329 void summarize($, e.turnId, request, e.answer).then(line => say($, line))
330 }
331 return next(e)
332 })
333
334 on('session.measure', async ($, e, next) => {
335 if (cfg.rateLimitWarnings) {
336 for (const w of e.rateLimits) {
337 const key = `${w.kind}@${w.resetsAt ?? ''}`
338 if (w.percentUsed >= cfg.rateLimitPercent && !warned.has(key)) {
339 warned.add(key)
340 void say($, rateLimitLine(w.kind, w.percentUsed))
341 }
342 }
343 }
344 return next(e)
345 })
346
347 on('command.run', { command: 'say' }, async ($, e) => {
348 switch (e.args.trim().toLowerCase()) {
349 case 'help': return { text: HELP }
350 case 'hush': return hush($)
351 case 'read': return readAloud($)
352 case 'tldr': return tldrAloud($)
353 default: return pickVoice($, e.args.trim())
354 }
355 })
356
357 on('ui.render', { component: 'Pane', requestId: PICKER }, async ($, e) => {
358 const { Box, Button, Text } = $.ui.resolve(e)
359 const mine = currentVoice()
360 const rows: unknown[] = []
361 let group = ''
362 picker.voices.forEach((v, i) => {
363 if (v.group !== group) {
364 group = v.group
365 rows.push(<Text bold>{group}</Text>)
366 }
367 const note = v.id === mine ? ' · this session' : picker.others.has(v.id) ? ' · in use' : ''
368 rows.push(
369 <Button
370 key={`voice-${i}`}
371 plain
372 label={`${v.isInstalled ? '✓' : '·'} ${v.name.padEnd(9)} ${v.id}${note}`}
373 autoFocus={v.id === mine ? true : undefined}
374 onPress={async () => {
375 await $.ui.close({ id: PICKER })
376 $.ui.toast(await choose($, v))
377 }}
378 />,
379 )
380 })
381 return (
382 <Box flexDirection="column">
383 {rows}
384 <Text dimColor>✓ installed · Enter to speak with it · Esc to close</Text>
385 </Box>
386 )
387 })
388}
389hooks/speech.ts 76 lines1// Turning Claude's markdown into something worth hearing.
2
3/** Markdown → plain spoken prose. Code blocks become `codeAs` (dropped when empty). */
4export function prose(markdown: string, codeAs = ''): string {
5 return markdown
6 .replace(/```[\s\S]*?```/g, codeAs ? ` ${codeAs} ` : ' ')
7 .replace(/`([^`\n]*)`/g, '$1')
8 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
9 .replace(/^\s*[-*+]\s+/gm, '')
10 .replace(/^\s*\|.*\|\s*$/gm, '')
11 .replace(/[*_#>|~]/g, '')
12 .replace(/[ \t]+/g, ' ')
13 .replace(/\n{2,}/g, '\n')
14 .trim()
15}
16
17/** The first sentence when it reads well spoken, else `fallback`. */
18export function firstSentence(markdown: string, fallback = 'Done.'): string {
19 const text = prose(markdown).replace(/\s+/g, ' ')
20 const first = text.match(/^.*?[.!?](?=\s|$)/)?.[0] ?? text
21 return first.length >= 3 && first.length <= 160 ? first : fallback
22}
23
24/** The prompt that turns a finished turn into one spoken line. */
25export function summaryPrompt(request: string, answer: string): string {
26 return [
27 'A coding assistant just finished a task. In ONE short sentence of at most 15 words, meant to be',
28 'spoken aloud to the developer, say what was done and whether anything needs their attention.',
29 'Plain words only: no markdown, code, file paths or quotes.',
30 '',
31 `Request: ${request.slice(0, 1500)}`,
32 `Answer: ${answer.slice(0, 3000)}`,
33 ].join('\n')
34}
35
36/** A background task's notification text → what to say. */
37export function taskLine(text: string): string {
38 const summary = text.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim()
39 const status = text.match(/<status>([\s\S]*?)<\/status>/)?.[1]?.trim()
40 const what = summary ? prose(summary).replace(/\s+/g, ' ').slice(0, 140) : ''
41 if (what) return `Background task: ${what}`
42 return status === 'failed' ? 'A background task failed.' : 'A background task finished.'
43}
44
45/**
46 * A turn an API error ended, as one line ("API Error: Connection lost mid-response. …" → "Claude hit an error:
47 * Connection lost mid-response."); empty for a turn that ended well. Claude Code writes the error as the turn's text.
48 */
49export function errorLine(reason: string, answer: string): string {
50 const said = answer.trim().match(/^API Error:\s*(.*)$/s)?.[1]
51 if (said === undefined && reason !== 'error') return ''
52 const first = said ? firstSentence(said, '') : ''
53 return first ? `Claude hit an error: ${first}` : 'Claude hit an error.'
54}
55
56export type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed'; activeForm: string }
57
58/** The progress check-in: what's in progress and how far along the list is. */
59export function checkinLine(todos: readonly Todo[], minutes: number): string {
60 const elapsed = `Still working, ${minutes} minute${minutes === 1 ? '' : 's'} in.`
61 if (todos.length === 0) return elapsed
62 const done = todos.filter(t => t.status === 'completed').length
63 const now = todos.find(t => t.status === 'in_progress')
64 return `${elapsed} ${now ? `${now.activeForm}. ` : ''}${done} of ${todos.length} steps done.`
65}
66
67export function rateLimitLine(kind: string, percent: number): string {
68 const name = ({ five_hour: 'five hour', seven_day: 'weekly', spend_limit: 'spend' } as Record<string, string>)[kind] ?? kind
69 return `Heads up: you've used ${Math.round(percent)} percent of your ${name} limit.`
70}
71
72/** "af_heart, bm_george" → ["af_heart", "bm_george"]; empty → []. */
73export function voiceList(option: string): string[] {
74 return option.split(',').map(v => v.trim()).filter(Boolean)
75}
76hooks/voices.ts 60 lines1// One voice per running Claude session, claimed from a small table every session shares.
2
3/** Voices handed to sessions in turn: alternating accents and genders so neighbours sound distinct. */
4export const SESSION_VOICES = [
5 'af_heart', 'bm_george', 'af_nicole', 'am_michael', 'bf_emma', 'am_fenrir',
6 'bf_isabella', 'bm_fable', 'af_bella', 'am_santa', 'bf_lily', 'bm_lewis',
7]
8
9/** Session (process id) → voice. */
10export type VoiceTable = Record<string, string>
11
12/**
13 * This session's voice: the one it already holds, else the first in `pool` no other live session holds
14 * (cycling once every voice is taken). Sessions not in `alive` are dropped, freeing their voices.
15 */
16export function claimVoice(table: VoiceTable, me: string, alive: ReadonlySet<string>, pool: readonly string[]) {
17 const live: VoiceTable = Object.fromEntries(Object.entries(table).filter(([pid]) => pid === me || alive.has(pid)))
18 const held = live[me]
19 if (held) return { voice: held, table: live }
20 const choices = pool.length ? pool : SESSION_VOICES
21 const taken = new Set(Object.values(live))
22 const voice = choices.find(v => !taken.has(v)) ?? choices[Object.keys(live).length % choices.length]!
23 return { voice, table: { ...live, [me]: voice } }
24}
25
26/** The shared table file's text → a table; anything unreadable is an empty table. */
27export function parseTable(text: string): VoiceTable {
28 try {
29 const data: unknown = JSON.parse(text)
30 if (!data || typeof data !== 'object' || Array.isArray(data)) return {}
31 return Object.fromEntries(Object.entries(data).filter((kv): kv is [string, string] => typeof kv[1] === 'string'))
32 } catch {
33 return {}
34 }
35}
36
37/** One of murmur's voices, as `murmur voices` lists it. */
38export type VoiceInfo = { id: string; name: string; group: string; isInstalled: boolean }
39
40/** `murmur voices` output → its voices, in its order ("AMERICAN FEMALE" headers, " ✓ af_bella Bella" rows). */
41export function parseCatalog(text: string): VoiceInfo[] {
42 let group = ''
43 const voices: VoiceInfo[] = []
44 for (const line of text.split('\n')) {
45 const header = line.match(/^([A-Z][A-Z ]+)$/)
46 if (header) group = header[1]!.trim()
47 const row = line.match(/^\s+([✓·])\s+([a-z]{2}_[a-z]+)\s+(\S+)/)
48 if (row) voices.push({ id: row[2]!, name: row[3]!, group, isInstalled: row[1] === '✓' })
49 }
50 return voices
51}
52
53/** "emma", "bf_emma", "isa" → the voices it could mean: an exact id or name wins, else every partial match. */
54export function matchVoice(query: string, voices: readonly VoiceInfo[]): VoiceInfo[] {
55 const q = query.trim().toLowerCase()
56 if (!q) return []
57 const exact = voices.filter(v => v.id === q || v.name.toLowerCase() === q)
58 return exact.length ? exact : voices.filter(v => v.id.includes(q) || v.name.toLowerCase().includes(q))
59}
60