Tells you when Claude needs you, finishes a long turn, nears a rate limit, finishes a background task or is still working: as a desktop notification (clicking…

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.
Each mod works on its own, and a few work better together. notify tells you when something happens; with say it can tell you out loud, with tldr its turn summaries reuse tldr's line instead of writing their own, and with ghostty it stays quiet while you're looking at the session. In Ghostty, clicking its notification brings you back to the session's tab.
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 inside Claude once a limit passes 90%. (notify also warns, as a desktop notification or out loud, at a threshold you set.)
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).Tells you when something happens in a session: as a desktop notification, out loud through say, or both. With the ghostty mod on, it only tells you when you're away from the session (another app or tab in front), so it never interrupts you while you're watching. Without it, nothing can tell whether you're looking, so it always tells you.
/config under notify: both, banner (the desktop notification), voice (spoken by say) or off. By default the first three are both, and background tasks and check-ins are voice.voice alerts are skipped and the banners still go out.| Command | What it does |
|---|---|
/notify test | Sends a desktop notification right away. macOS can't tell a mod whether your terminal may show notifications, so this is how to check. The first time notify starts, it reminds you once. |
/notify settings | Opens the switch in System Settings: Ghostty's own entry in Ghostty, the Notifications list elsewhere. |
Gives Claude a voice, through murmur. The notify mod decides what gets said and when; say is the voice it speaks in.
/config./config under say.Everything else is one command, /say:
| Command | What it does |
|---|---|
/say | Names this session's voice and plays a short sample of it. |
/say pick | Lists every voice with its grade and switches this session to the one you pick, with a sample in it. The choice sticks to the conversation, so resuming it keeps the voice. |
/say pick <name> | Switches directly, like /say pick emma. Part of the name is enough. |
/say pick random | Switches to a random voice, other than the current one. Better-graded voices come up more often. |
/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 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. For ⌘⌃G to open /goto, run /keybind add super+ctrl+g /goto, or turn on Ghostty: install Claude keybinds in /config. The mod changes your Ghostty config only when you do one of these: it adds one line that loads its own claude-keybinds file.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. If another session is still working or waiting on you, it closes nothing and names them; /workspace close --force closes them anyway. |
/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. Saving never overwrites a different workspace that happens to have the same name: the new one gets a file of its own.Install the mods you want, one line each, at the prompt of a Claude Code session:
/plugin install context-bar --marketplace whilestevego/claude-mods
Answer y to add the marketplace, then pick a scope. The mods are context-bar, rate-limits, session-id, turn-receipt, tldr, notify, say and ghostty.
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./notify test checks; /notify settings opens the page.The first time a mod controls Ghostty, macOS asks for permission. Allow it.
Editing a mod's files reloads it in running sessions. A mod newly added to CLAUDE_CODE_PLUGIN_DIRS loads only in sessions started after that.
claude plugin validate context-bar # check a mod the way Claude Code will load it
claude plugin test context-bar # run its tests
Mods can call each other through nouns on $: say adds $.voice.say({ text }), which notify speaks through, and ghostty adds $.ghostty.isFocused(), which notify asks before alerting. Their types are in each mod's types/index.d.ts.
IDEAS.md has ideas for more mods.
hooks/register.ts 271 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { channel, checkinLine, errorLine, firstSentence, prose, rateLimitLine, summaryPrompt, taskLine } from './lines'
4import type { Channel } from './lines'
5// The contracts this mod reads: tldr's line for a turn, say's voice, and ghostty's focus. A renamed key there fails
6// type-checking here.
7import type {} from '../../tldr/types'
8import type {} from '../../say/types'
9import type {} from '../../ghostty/types'
10
11type Config = {
12 when: string
13 needsYou: Channel
14 turnDone: Channel
15 rateLimit: Channel
16 backgroundTasks: Channel
17 checkins: Channel
18 summaryStyle: string
19 minTurnSeconds: number
20 checkinMinutes: number
21 rateLimitPercent: number
22}
23
24function config(o: PluginOptions): Config {
25 const num = (v: unknown, d: number) => (typeof v === 'number' && v > 0 ? v : d)
26 return {
27 when: o.when === 'always' ? 'always' : 'away',
28 needsYou: channel(o.needsYou, 'both'),
29 turnDone: channel(o.turnDone, 'both'),
30 rateLimit: channel(o.rateLimit, 'both'),
31 backgroundTasks: channel(o.backgroundTasks, 'voice'),
32 checkins: channel(o.checkins, 'voice'),
33 summaryStyle: o.summaryStyle === 'first-sentence' ? 'first-sentence' : 'haiku',
34 minTurnSeconds: num(o.minTurnSeconds, 20),
35 checkinMinutes: num(o.checkinMinutes, 5),
36 rateLimitPercent: num(o.rateLimitPercent, 90),
37 }
38}
39
40/** Rate-limit windows already warned about, as "kind@resetsAt": in $.store, so one warning covers every session. */
41const WARNED = 'warnedLimits'
42/** Set once the /notify test hint has been shown: in $.store, so it shows once, not once per session. */
43const HINTED = 'hintedTest'
44
45/** System Settings' Notifications page: Ghostty's own entry in Ghostty, the whole list elsewhere. */
46const SETTINGS = 'x-apple.systempreferences:com.apple.Notifications-Settings.extension'
47const GHOSTTY_ID = 'com.mitchellh.ghostty'
48
49const HELP = [
50 '/notify test send a desktop notification now, to check they show up',
51 "/notify settings open your terminal's notification settings",
52].join('\n')
53
54// The module's own session state; a reload starts it over.
55let cfg: Config
56/** Claude's own process: its session record says when a permission dialog is up. */
57let pid = ''
58let home = ''
59let inGhostty = false
60/** The session's folder name, heading its banners so you can tell sessions apart. */
61let folder = ''
62let lastRequest = ''
63let checkin: { cancel(): void } | undefined
64let turnStartedAt = 0
65/** Calls the permission check put to you, until they run or are refused. */
66const asking = new Set<string>()
67
68// The pid of Claude itself: the nearest ancestor named claude (the mods' own host process sits between it and this
69// shell, and changes when mods reload). No API names it.
70const FIND_CLAUDE = `p=$$
71while [ "$p" -gt 1 ]; do
72 c=$(ps -o comm= -p "$p"); [ "\${c##*/}" = claude ] && { echo "$p"; exit; }
73 p=$(ps -o ppid= -p "$p" | tr -d ' ')
74done`
75
76async function locate($: EngineInterface) {
77 inGhostty = ((await $.env.get('TERM_PROGRAM')) ?? '').toLowerCase() === 'ghostty'
78 home = (await $.env.get('HOME')) ?? ''
79 pid = (await $.process.run(['sh', '-c', FIND_CLAUDE])).stdout.trim()
80}
81
82// Quiet by default: alert only when this session isn't the one in front of you. The ghostty mod knows; without it
83// nothing can tell, so every alert goes out.
84async function isAway($: EngineInterface): Promise<boolean> {
85 if (cfg.when === 'always') return true
86 try {
87 return !(await $.ghostty.isFocused())
88 } catch {
89 return true
90 }
91}
92
93/**
94 * One alert, on the channels its setting names. The banner goes through Claude Code's notification channel; in
95 * Ghostty a click on it focuses the tab that raised it. The voice is the say mod's, when it's loaded.
96 */
97async function alert($: EngineInterface, to: Channel, text: string) {
98 if (to === 'off' || !text.trim() || !(await isAway($))) return
99 await Promise.all([to !== 'voice' && $.ui.notify(text, { title: title() }).catch(() => null), to !== 'banner' && speak($, text)])
100}
101
102/** Says `text` in this session's voice. Without the say mod there's no $.voice, and the line goes unsaid. */
103async function speak($: EngineInterface, text: string) {
104 try {
105 await $.voice.say({ text })
106 } catch {
107 // say isn't loaded, or murmur failed
108 }
109}
110
111/** Claude Code's record of this session (~/.claude/sessions/<pid>.json) says its permission dialog is up. */
112function asksPermission(record: string): boolean {
113 try {
114 const r = JSON.parse(record) as { status?: unknown; waitingFor?: unknown }
115 return r.status === 'waiting' && typeof r.waitingFor === 'string' && /permission/i.test(r.waitingFor)
116 } catch {
117 return false
118 }
119}
120
121/**
122 * A call that asks waits in Claude Code's dialog, or in auto mode its checker. Only the dialog marks the session
123 * record waiting, so watch it while the call is pending. Mods hear neither classic.PermissionRequest nor
124 * classic.Notification while a dialog is up (checked on 2.1.301), so this file is the only sign.
125 */
126async function watchForDialog($: EngineInterface, id: string, tool: string) {
127 if (!pid || !home) return
128 asking.add(id)
129 // ponytail: polls every half second, 10 minutes at most; an event for the dialog would replace it.
130 for (let i = 0; i < 1200; i++) {
131 await $.clock.sleep(500)
132 if (!asking.has(id)) return
133 if (asksPermission(await $.fs.read(`${home}/.claude/sessions/${pid}.json`).catch(() => ''))) {
134 return alert($, cfg.needsYou, `Claude needs your permission to use ${tool}.`)
135 }
136 }
137}
138
139/** The tldr mod's line for this turn, when tldr is enabled: one summary, shown and told. Empty when it has none. */
140async function tldrLine($: EngineInterface, turnId: string): Promise<string> {
141 const rows = await $.config.list().catch(() => [])
142 if (!rows.some(r => r.key.startsWith('tldr.'))) return ''
143 // tldr publishes after its own Haiku call (8 s at most); a short reply publishes an empty line at once.
144 for (let waited = 0; waited <= 10_000; waited += 250) {
145 const held = await $.state.get({ plugin: 'tldr', key: 'line' })
146 if (held.value?.turnId === turnId) return held.value.text ?? ''
147 await $.clock.sleep(250)
148 }
149 return ''
150}
151
152async function summarize($: EngineInterface, turnId: string, request: string, answer: string): Promise<string> {
153 if (cfg.summaryStyle === 'first-sentence') return firstSentence(answer)
154 const shared = await tldrLine($, turnId)
155 if (shared) return shared
156 const r = await $.model.complete({ model: 'haiku', prompt: summaryPrompt(request, answer), maxTokens: 60 })
157 const line = r.isAnswered ? prose(r.text).replace(/\s+/g, ' ').trim() : ''
158 return line && line.length <= 200 ? line : firstSentence(answer)
159}
160
161/**
162 * macOS can't tell a mod whether it shows the terminal's notifications (Notification Center's prefs are undocumented
163 * and lag behind the toggle), so the reliable check is to send one and look.
164 */
165async function testBanner($: EngineInterface): Promise<{ text: string }> {
166 const sent = await $.ui.notify('This is what notify alerts look like. Click it to come back to this tab.', { title: title() })
167 if (!sent.isSent) return { text: `Couldn't send a notification (${sent.reason}).` }
168 return { text: "🔔 Sent. Didn't see a banner? /notify settings opens the switch for it." }
169}
170
171async function openSettings($: EngineInterface): Promise<{ text: string }> {
172 const r = await $.process.run(['open', inGhostty ? `${SETTINGS}?id=${GHOSTTY_ID}` : SETTINGS])
173 if (r.exitCode !== 0) return { text: `Couldn't open System Settings: ${r.stderr.trim().slice(0, 120) || `exit ${r.exitCode}`}` }
174 return { text: `Opened System Settings. Turn on Allow notifications${inGhostty ? ' for Ghostty' : ''}, then /notify test.` }
175}
176
177const title = () => (folder ? `Claude Code · ${folder}` : 'Claude Code')
178const sendsBanners = () => [cfg.needsYou, cfg.turnDone, cfg.rateLimit, cfg.backgroundTasks, cfg.checkins].some(c => c === 'both' || c === 'banner')
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: an alert must never block work.
188
189 on('session.start', async ($, e, next) => {
190 folder = e.cwd.replace(/\/+$/, '').split('/').pop() ?? ''
191 await locate($)
192 await $.command.register({ name: 'notify', description: 'Desktop notifications: /notify test to check they show up, /notify settings to turn them on' })
193 if (sendsBanners() && (await $.store.get(HINTED)) !== true) {
194 await $.store.set(HINTED, true)
195 $.ui.toast('notify: run /notify test to check desktop notifications show up')
196 }
197 return next(e)
198 })
199
200 on('command.run', { command: 'notify' }, async ($, e) => {
201 switch (e.args.trim().toLowerCase()) {
202 case 'test': return testBanner($)
203 case 'settings': return openSettings($)
204 default: return { text: HELP }
205 }
206 })
207
208 // Every turn starts here: its request, and the check-in timer.
209 on('prompt.submit', async ($, e, next) => {
210 stopCheckins()
211 turnStartedAt = Date.now()
212 if (e.origin.kind === 'task-notification') void alert($, cfg.backgroundTasks, taskLine(e.text))
213 else lastRequest = e.text
214 if (cfg.checkins !== 'off') {
215 checkin = $.clock.every(cfg.checkinMinutes * 60_000, () => {
216 void alert($, cfg.checkins, checkinLine(Math.round((Date.now() - turnStartedAt) / 60000)))
217 })
218 }
219 return next(e)
220 }).catch(($, e, next) => next(e))
221
222 on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => {
223 void alert($, cfg.needsYou, 'Claude has a question for you.')
224 return next(e)
225 }).catch(($, e, next) => next(e))
226
227 on('tool.check', async ($, e, next) => {
228 const r = await next(e)
229 if (cfg.needsYou !== 'off' && r.decision === 'ask' && e.tool_use_id) void watchForDialog($, e.tool_use_id, e.tool).catch(() => {})
230 return r
231 }).catch(($, e, next) => next(e))
232
233 // A call that ran or was refused is no longer waiting on you.
234 on('tool.call', async ($, e, next) => {
235 try {
236 return await next(e)
237 } finally {
238 asking.delete((e as { tool_use_id?: string }).tool_use_id ?? '')
239 }
240 }).catch(($, e, next) => next(e))
241
242 on('turn.complete', async ($, e, next) => {
243 if (e.agentId) return next(e)
244 stopCheckins()
245 // An API error ends the turn (connection lost, the computer slept): say so, in place of a summary.
246 const error = errorLine(e.reason, e.answer)
247 if (error) {
248 void alert($, cfg.needsYou, error)
249 } else if (cfg.turnDone !== 'off' && e.reason === 'answer' && e.durationMs >= cfg.minTurnSeconds * 1000) {
250 void summarize($, e.turnId, lastRequest, e.answer).then(line => alert($, cfg.turnDone, line))
251 }
252 return next(e)
253 })
254
255 // The limits are your account's, not this session's: whichever session sees one cross first tells you.
256 // ponytail: two sessions measuring in the same instant can both alert; a lock if that ever happens.
257 on('session.measure', async ($, e, next) => {
258 const crossed = cfg.rateLimit !== 'off' ? e.rateLimits.filter(w => w.percentUsed >= cfg.rateLimitPercent) : []
259 if (crossed.length) {
260 const stored = await $.store.get(WARNED)
261 const warned = Array.isArray(stored) ? (stored as string[]) : []
262 const fresh = crossed.filter(w => !warned.includes(`${w.kind}@${w.resetsAt ?? ''}`))
263 if (fresh.length) {
264 await $.store.set(WARNED, [...warned, ...fresh.map(w => `${w.kind}@${w.resetsAt ?? ''}`)].slice(-20))
265 for (const w of fresh) void alert($, cfg.rateLimit, rateLimitLine(w.kind, w.percentUsed))
266 }
267 }
268 return next(e)
269 })
270}
271hooks/lines.ts 73 lines1// What each alert says: turning Claude's markdown and events into one line, for a banner or a voice.
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
56/** The progress check-in during a long turn. */
57export function checkinLine(minutes: number): string {
58 return `Still working, ${minutes} minute${minutes === 1 ? '' : 's'} in.`
59}
60
61export function rateLimitLine(kind: string, percent: number): string {
62 const name = ({ five_hour: 'five hour', seven_day: 'weekly', spend_limit: 'spend' } as Record<string, string>)[kind] ?? kind
63 return `Heads up: you've used ${Math.round(percent)} percent of your ${name} limit.`
64}
65
66
67/** Where an alert goes: a banner (the terminal's desktop notification), the say mod's voice, both, or nowhere. */
68export type Channel = 'both' | 'banner' | 'voice' | 'off'
69
70export function channel(option: unknown, fallback: Channel): Channel {
71 return option === 'both' || option === 'banner' || option === 'voice' || option === 'off' ? option : fallback
72}
73