SLOPSHOPPER

voice

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

newguardcommandtoastpromptmodel
v0.2.0no licenseupdated 2026-10-09whilestevego/claude-mods/voice
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · voice
› 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 › /hush ⎿ voice: 🔇 Spoken updates muted for this session. /hush again to unmute. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

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.

The mods

context-bar

Shows how full Claude's context window is, as a colored bar above the prompt.

  • Point at the bar to see what's using the space (system prompt, messages, tools…).
  • /context-bar hides or shows it for this session.

rate-limits

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%.

  • Nothing to do: it appears on its own when you're on a subscription plan.

session-id

Shows this session's ID in the bottom-right corner of the screen.

  • Click it to copy the ID.
  • Ctrl-click it to copy the full claude --resume <id> command.

turn-receipt

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.

  • Nothing to do: it appears after every turn.

tldr

Adds a one-sentence summary under Claude's longer replies, written by Claude Haiku.

  • Nothing to do. TL;DR: long reply in /config sets how long a reply must be (600 characters by default).

previews

Lets you see images Claude reads, right in the conversation.

  • Under each image Claude opens, a line reads 🖼 name.png · hover to preview. Point at it to show the picture, and move away to hide it.

voice

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.

  • It says what a long turn did, when Claude needs your permission or has a question, when a background task finishes, how a long task is going every few minutes, and when you're close to a usage limit.
  • /read reads Claude's last reply aloud.
  • /tldr-aloud gives a spoken 2–3 sentence summary of the session so far.
  • A soft chime plays before it speaks, so a voice never starts out of nowhere. Turn it off with Chime in /config.
  • /hush mutes this session; run it again to unmute.
  • Each open session gets its own voice. Pick one fixed voice, or the list sessions choose from, in /config under voice.

ghostty

Ties Claude into the Ghostty terminal. All its settings start with "Ghostty" in /config.

  • Tab title: a 4-word summary of what the session is about, from your last 10 messages and Claude's latest reply, updated every few minutes as it goes. /title gets a new one right away. A resumed session gets its old title back.
  • Tab progress bar: shows while Claude works, fills in as its to-do list gets done, and turns red when a command fails.
  • ❓ waiting on you: a ❓ in the tab title and a desktop notification when Claude needs a permission or an answer. If voice is on, it speaks instead of notifying.
  • /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 /hush for ⌘⌃H. /keybind lists them, and /keybind remove <keys> removes one.
Workspaces

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.

CommandWhat 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 listShows 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.
  • Reopening puts each tab back in its folder. Claude tabs run 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.
  • Put away a project for the day: /workspace close. Pick it up tomorrow: /workspace list, then press its number.
  • What doesn't come back: programs that were running (a dev server, a test watcher), and the exact split layout. Splits reopen side by side.
  • Where they're kept: ~/.claude/workspaces/, one JSON file per workspace.

Setup

  1. Clone this repo.
  2. List the mods you want in 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/voice"
   }
  1. Start a new Claude session.

To try one mod for a single session: claude --plugin-dir /path/to/claude-mods/tldr.

Requirements

  • ghostty needs Ghostty 1.3 or newer on macOS. Set CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 in the same env block, so Claude Code doesn't overwrite the tab title.
  • voice needs murmur installed, which runs on Apple Silicon Macs.
  • previews needs a terminal that can draw images, like Ghostty or Kitty.
  • session-id's pointing-hand cursor works in Ghostty, Kitty and foot.

The first time a mod controls Ghostty, macOS asks for permission. Allow it.

Development

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.

Source 3 files
hooks/register.ts 286 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { checkinLine, firstSentence, prose, rateLimitLine, summaryPrompt, taskLine, voiceList } from './speech'
4import type { Todo } from './speech'
5import { claimVoice, parseTable } from './voices'
6
7type Config = {
8  speakWhen: string
9  turnSummaries: boolean
10  summaryStyle: string
11  minTurnSeconds: number
12  needsYou: boolean
13  backgroundTasks: boolean
14  checkins: boolean
15  checkinMinutes: number
16  rateLimitWarnings: boolean
17  rateLimitPercent: number
18  speed: number
19  chime: boolean
20  voice: string
21  voices: string[]
22}
23
24function config(o: PluginOptions): Config {
25  const num = (v: unknown, d: number) => (typeof v === 'number' && v > 0 ? v : d)
26  return {
27    speakWhen: o.speakWhen === 'always' ? 'always' : 'away',
28    turnSummaries: o.turnSummaries !== false,
29    summaryStyle: o.summaryStyle === 'first-sentence' ? 'first-sentence' : 'haiku',
30    minTurnSeconds: num(o.minTurnSeconds, 20),
31    needsYou: o.needsYou !== false,
32    backgroundTasks: o.backgroundTasks !== false,
33    checkins: o.checkins !== false,
34    checkinMinutes: num(o.checkinMinutes, 5),
35    rateLimitWarnings: o.rateLimitWarnings !== false,
36    rateLimitPercent: num(o.rateLimitPercent, 90),
37    speed: num(o.speed, 1),
38    chime: o.chime !== false,
39    voice: typeof o.voice === 'string' ? o.voice.trim() : '',
40    voices: typeof o.voices === 'string' ? voiceList(o.voices) : [],
41  }
42}
43
44// The module's own session state (a reload starts it over, which only forgets a /hush).
45let cfg: Config
46let claude: { tty: string; pid: string } | null = null
47let home = ''
48/** This session's own voice, claimed at start (unused when a fixed voice is set). */
49let sessionVoice = ''
50let inGhostty = false
51let muted = false
52let lastRequest = ''
53let lastAnswer = ''
54let todos: Todo[] = []
55let checkin: { cancel(): void } | undefined
56let turnStartedAt = 0
57/** Calls the permission check put to you, until they run or are refused. */
58const asking = new Set<string>()
59const warned = new Set<string>()
60
61// "<tty> <pid>" of Claude itself: the nearest ancestor named claude (the mods' own host process, between it and
62// this shell, owns the tty too, and changes when mods reload). Its pid keys the session's voice.
63const FIND_CLAUDE = `p=$$
64first=
65while [ "$p" -gt 1 ]; do
66  t=$(ps -o tty= -p "$p" | tr -d ' ')
67  case "$t" in ''|'?'|'??') ;; *) [ -z "$first" ] && first="$t $p"
68    c=$(ps -o comm= -p "$p"); [ "\${c##*/}" = claude ] && { echo "$t $p"; exit; } ;;
69  esac
70  p=$(ps -o ppid= -p "$p" | tr -d ' ')
71done
72echo "$first"`
73
74// The tty of the terminal Ghostty has focused, or nothing when Ghostty isn't the frontmost app.
75const FOCUSED_TTY = `tell application "Ghostty"
76  if frontmost then return tty of focused terminal of selected tab of front window
77  return ""
78end tell`
79
80async function locate($: EngineInterface) {
81  // The environment first: the Claude search exits as soon as it finds it.
82  const found = (await $.process.run(['sh', '-c', `echo "$TERM_PROGRAM"\necho "$HOME"\n${FIND_CLAUDE}`])).stdout.trim().split('\n')
83  inGhostty = (found[0] ?? '').toLowerCase() === 'ghostty'
84  home = found[1] ?? ''
85  const [tty, pid] = (found[2] ?? '').split(' ')
86  claude = tty && pid ? { tty, pid } : null
87}
88
89/** Claude Code's record of this session (~/.claude/sessions/<pid>.json) says its permission dialog is up. */
90function asksPermission(record: string): boolean {
91  try {
92    const r = JSON.parse(record) as { status?: unknown; waitingFor?: unknown }
93    return r.status === 'waiting' && typeof r.waitingFor === 'string' && /permission/i.test(r.waitingFor)
94  } catch {
95    return false
96  }
97}
98
99/**
100 * A call that asks waits in Claude Code's dialog, or in auto mode its checker. Only the dialog marks the session
101 * record waiting, so watch it while the call is pending. (Mods never hear the classic Notification event.)
102 */
103async function watchForDialog($: EngineInterface, id: string, tool: string) {
104  if (!claude || !home) return
105  asking.add(id)
106  // ponytail: polls every half second, 10 minutes at most; an event for the dialog would replace it.
107  for (let i = 0; i < 1200; i++) {
108    await $.clock.sleep(500)
109    if (!asking.has(id)) return
110    if (asksPermission(await $.fs.read(`${home}/.claude/sessions/${claude.pid}.json`).catch(() => ''))) {
111      return say($, `Claude needs your permission to use ${tool}.`)
112    }
113  }
114}
115
116// Every running session shares one small table of who speaks with which voice.
117async function claimSessionVoice($: EngineInterface) {
118  if (!claude || !home) return
119  const dir = `${home}/Library/Caches/claude-voice`
120  const file = `${dir}/voices.json`
121  const table = parseTable(await $.fs.read(file).catch(() => ''))
122  const pids = Object.keys(table)
123  const alive = pids.length
124    ? (await $.process.run(['sh', '-c', 'for p; do kill -0 "$p" 2>/dev/null && echo "$p"; done', 'sh', ...pids])).stdout.split('\n')
125    : []
126  const claimed = claimVoice(table, claude.pid, new Set(alive), cfg.voices)
127  sessionVoice = claimed.voice
128  // ponytail: last writer wins; two sessions starting in the same instant can share a voice until one restarts.
129  await $.process.run(['mkdir', '-p', dir])
130  await $.fs.write(file, JSON.stringify(claimed.table))
131}
132
133// Quiet by default: speak only when this terminal isn't the one in front of you.
134async function isAway($: EngineInterface): Promise<boolean> {
135  if (cfg.speakWhen === 'always' || !inGhostty || !claude) return true  // can't tell: the other gates decide
136  const r = await $.process.run(['osascript', '-e', FOCUSED_TTY])
137  return r.exitCode !== 0 || r.stdout.trim() !== `/dev/${claude.tty}`
138}
139
140// Interrupt, don't stack: each new line cuts off whatever is still playing.
141async function say($: EngineInterface, text: string, always = false) {
142  if (muted || !text.trim() || (!always && !(await isAway($)))) return
143  // A soft bell first (the mod's chime.wav), so speech never starts out of nowhere. Not awaited: murmur takes
144  // about as long to get ready as the bell rings.
145  if (cfg.chime) void $.process.run(['afplay', `${$.plugin.root}/chime.wav`]).catch(() => null)
146  const argv = ['murmur', '--interrupt', '--speed', String(cfg.speed)]
147  const voice = cfg.voice || sessionVoice
148  if (voice) argv.push('--voice', voice)
149  const r = await $.process.run([...argv, '--', text]).catch(() => null)
150  if (r && r.exitCode !== 0) $.ui.toast(`murmur: ${r.stderr.trim().slice(0, 120) || 'failed'}`)
151}
152
153/** The tldr mod's line for this turn, when tldr is enabled: one summary, shown and spoken. Empty when it has none. */
154async function tldrLine($: EngineInterface, turnId: string): Promise<string> {
155  const rows = await $.config.list().catch(() => [])
156  if (!rows.some(r => r.key.startsWith('tldr.'))) return ''
157  // tldr publishes after its own Haiku call (8 s at most); a short reply publishes an empty line at once.
158  for (let waited = 0; waited <= 10_000; waited += 250) {
159    const held = (await $.state.get({ plugin: 'tldr', key: 'line' } as never)) as { value?: { turnId?: string; text?: string } | null }
160    if (held.value?.turnId === turnId) return held.value.text ?? ''
161    await $.clock.sleep(250)
162  }
163  return ''
164}
165
166async function summarize($: EngineInterface, turnId: string, request: string, answer: string): Promise<string> {
167  if (cfg.summaryStyle === 'first-sentence') return firstSentence(answer)
168  const shared = await tldrLine($, turnId)
169  if (shared) return shared
170  const r = await $.model.complete({ model: 'haiku', prompt: summaryPrompt(request, answer), maxTokens: 60 })
171  const line = r.isAnswered ? prose(r.text).replace(/\s+/g, ' ').trim() : ''
172  return line && line.length <= 200 ? line : firstSentence(answer)
173}
174
175async function checkIn($: EngineInterface) {
176  const minutes = Math.round((Date.now() - turnStartedAt) / 60000)
177  await say($, checkinLine(todos, minutes))
178}
179
180function stopCheckins() {
181  checkin?.cancel()
182  checkin = undefined
183}
184
185export const register: Register = (on, options) => {
186  cfg = config(options)
187  // Hooks that gate a prompt or a tool call pass it on untouched if they fail: speech must never block work.
188
189  on('session.start', async ($, e, next) => {
190    await locate($)
191    if (!cfg.voice) await claimSessionVoice($).catch(() => {})
192    await $.command.register({ name: 'hush', description: 'Mute or unmute spoken updates for this session' })
193    await $.command.register({ name: 'read', description: "Read Claude's last answer aloud" })
194    await $.command.register({ name: 'tldr-aloud', description: 'Hear a 2–3 sentence summary of this session' })
195    return next(e)
196  })
197
198  // Every turn starts here: its request, a fresh todo list, and the check-in timer.
199  on('prompt.submit', async ($, e, next) => {
200    stopCheckins()
201    todos = []
202    turnStartedAt = Date.now()
203    if (e.origin.kind === 'task-notification') {
204      if (cfg.backgroundTasks) void say($, taskLine(e.text))
205    } else {
206      lastRequest = e.text
207    }
208    if (cfg.checkins) checkin = $.clock.every(cfg.checkinMinutes * 60_000, () => void checkIn($))
209    return next(e)
210  }).catch(($, e, next) => next(e))
211
212  on('tool.call', { tool: 'TodoWrite' }, ($, e, next) => {
213    todos = e.todos.map(t => ({ ...t }))
214    return next(e)
215  }).catch(($, e, next) => next(e))
216
217  on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => {
218    if (cfg.needsYou) void say($, 'Claude has a question for you.')
219    return next(e)
220  }).catch(($, e, next) => next(e))
221
222  on('tool.check', async ($, e, next) => {
223    const r = await next(e)
224    if (cfg.needsYou && r.decision === 'ask' && e.tool_use_id) void watchForDialog($, e.tool_use_id, e.tool).catch(() => {})
225    return r
226  }).catch(($, e, next) => next(e))
227
228  // A call that ran or was refused is no longer waiting on you.
229  on('tool.call', async ($, e, next) => {
230    try {
231      return await next(e)
232    } finally {
233      asking.delete((e as { tool_use_id?: string }).tool_use_id ?? '')
234    }
235  }).catch(($, e, next) => next(e))
236
237  on('turn.complete', async ($, e, next) => {
238    if (e.agentId) return next(e)
239    stopCheckins()
240    lastAnswer = e.answer
241    const isLong = e.durationMs >= cfg.minTurnSeconds * 1000
242    if (cfg.turnSummaries && e.reason === 'answer' && isLong && !muted) {
243      const request = lastRequest
244      void summarize($, e.turnId, request, e.answer).then(line => say($, line))
245    }
246    return next(e)
247  })
248
249  on('session.measure', async ($, e, next) => {
250    if (cfg.rateLimitWarnings) {
251      for (const w of e.rateLimits) {
252        const key = `${w.kind}@${w.resetsAt ?? ''}`
253        if (w.percentUsed >= cfg.rateLimitPercent && !warned.has(key)) {
254          warned.add(key)
255          void say($, rateLimitLine(w.kind, w.percentUsed))
256        }
257      }
258    }
259    return next(e)
260  })
261
262  on('command.run', { command: 'hush' }, async $ => {
263    muted = !muted
264    if (muted) await $.process.run(['murmur', 'stop']).catch(() => null)
265    return { text: muted ? '🔇 Spoken updates muted for this session. /hush again to unmute.' : '🔊 Spoken updates back on.' }
266  })
267
268  on('command.run', { command: 'read' }, async $ => {
269    if (!lastAnswer) return { text: 'Nothing to read yet.' }
270    if (muted) return { text: 'Muted: /hush to unmute first.' }
271    void say($, prose(lastAnswer, 'Code omitted.'), true)
272    return { text: '🔊 Reading the last answer aloud. /hush stops it.' }
273  })
274
275  on('command.run', { command: 'tldr-aloud' }, async $ => {
276    if (muted) return { text: 'Muted: /hush to unmute first.' }
277    const r = await $.model.fork({
278      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.',
279    })
280    if (!r.isAnswered) return { text: `Couldn't summarize: ${r.reason}` }
281    const line = prose(r.text).replace(/\s+/g, ' ').trim()
282    void say($, line, true)
283    return { text: `🔊 ${line}` }
284  })
285}
286
hooks/speech.ts 65 lines
1// 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
45export type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed'; activeForm: string }
46
47/** The progress check-in: what's in progress and how far along the list is. */
48export function checkinLine(todos: readonly Todo[], minutes: number): string {
49  const elapsed = `Still working, ${minutes} minute${minutes === 1 ? '' : 's'} in.`
50  if (todos.length === 0) return elapsed
51  const done = todos.filter(t => t.status === 'completed').length
52  const now = todos.find(t => t.status === 'in_progress')
53  return `${elapsed} ${now ? `${now.activeForm}. ` : ''}${done} of ${todos.length} steps done.`
54}
55
56export function rateLimitLine(kind: string, percent: number): string {
57  const name = ({ five_hour: 'five hour', seven_day: 'weekly', spend_limit: 'spend' } as Record<string, string>)[kind] ?? kind
58  return `Heads up: you've used ${Math.round(percent)} percent of your ${name} limit.`
59}
60
61/** "af_heart, bm_george" → ["af_heart", "bm_george"]; empty → []. */
62export function voiceList(option: string): string[] {
63  return option.split(',').map(v => v.trim()).filter(Boolean)
64}
65
hooks/voices.ts 36 lines
1// 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