Mac notifications titled with the session name, saying what Claude needs from you: done, a question, or a permission. Works out of the box; install…

A Claude Code mod that sends Mac notifications telling you which session needs you and what it needs: done, a question, or a permission to approve. Built for running several Claude Code sessions at once.
User activity and integration status · fixter-observability Needs input: approve npm install stripe
Instead of "landing page finished", the title is the session's name (the same one the Claude app lists it under) and the project, and the message is a short status in the app's own words:
| When | The message says |
|---|---|
| Claude finished | Done: pricing table stacks on mobile (AI summary, at most 6 words) |
| Claude finished but is waiting on you | Needs input: pick webhook retry plan |
| Claude asks a question | Needs input: Postgres or SQLite? |
| Claude needs permission | Needs input: approve npm install stripe |
| Still unanswered after 5 minutes | Still needs input: … (once) |
| The turn failed | Stopped: API error |
Sessions without a project folder show only the session's name. Each session keeps one notification at a time: a newer one replaces its older one.
It stays quiet for turns you stopped yourself and for background helper agents. It only watches: it never blocks, changes or answers anything in your session.
Works in the Claude desktop app (Code tab) and in the terminal.
Status: v0.1. Feedback welcome (see the end).
Why a mod instead of a Stop or Notification hook? A hook starts fresh every time; a mod stays running for the whole session, so it can pair your request with Claude's answer, tell "done" from "waiting on you", and cancel its reminder the moment you answer. The long version: Claude Code notifications: why a mod beats a hook. New to mods? Claude Code mods, explained simply.
You need Claude Code 2.1.287 or newer, the version where mods are on by default. Check with claude --version.
1. Add the plugin. In a terminal:
claude plugin marketplace add fixter-dev/session-pings
claude plugin install session-pings@session-pings
2. Make notifications clickable (recommended). Without this, notifications still work, but clicking one does nothing useful. With it, a click opens that exact chat in the Claude app, or brings your terminal forward for terminal sessions:
brew install terminal-notifier
3. Restart your sessions. A running session only picks up new plugins when it starts.
claude --continue (latest session) or claude --resume (pick one).4. Allow notifications. The first notification may make macOS ask whether to allow notifications from terminal-notifier (or Script Editor, without step 2). Allow it. Then in System Settings → Notifications → terminal-notifier, set the alert style to Persistent (called Alerts on older macOS), so notifications stay until you dismiss them instead of vanishing after ~5 seconds.
5. Turn off old notification hooks. If you already had Stop / Notification hooks in ~/.claude/settings.json that show notifications, remove them, or you'll get two of everything.
In any session, ask Claude: "Ask me a test question with AskUserQuestion." A notification titled with the session's name should say Needs input: …. When you answer and the turn ends, a Done: … notification follows a second or two later.
All optional. Change them in Claude Code's config menu under session-pings, or in ~/.claude/settings.json:
{
"pluginConfigs": {
"session-pings": {
"options": {
"remindAfterMinutes": 5,
"aiSummaries": true,
"doneSound": "Glass",
"attentionSound": "Ping"
}
}
}
}
| Setting | Default | What it does |
|---|---|---|
remindAfterMinutes | 5 | Remind once if a question or permission is still unanswered. 0 turns reminders off. |
aiSummaries | true | Uses Haiku on your own Claude Code login for the one-line summary (and a session name when the app has none yet). A tiny bit of usage per notification. Off: uses the first sentence of Claude's reply. |
doneSound | Glass | macOS sound when Claude is done. |
attentionSound | Ping | macOS sound for questions, permissions and reminders. |
Sound names: Basso, Blow, Bottle, Frog, Funk, Glass, Hero, Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink.
claude plugin list shows session-pings enabled. Check macOS Focus / Do Not Disturb, and the app's notification permission in System Settings.claude --debug and look for lines starting with session-pings:; send them along with your feedback.claude plugin uninstall session-pings@session-pings
Open an issue in this repo:
session-pings is a Claude Code mod: a plugin whose behaviour lives in one module, plugins/session-pings/hooks/register.ts. It listens for a turn ending, AskUserQuestion, and permission requests, reads the session title, and shows the notification with terminal-notifier (falling back to macOS's built-in osascript, and notify-send on Linux).
Made by Fixter, monitoring for teams that build with coding agents.
hooks/register.ts 270 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// session-pings: a Mac notification whenever Claude needs you, titled with the
4// session's title (the same name the app lists it under) and saying what it
5// needs, in the app's own words ("Done: ...", "Needs input: ..."). It only observes:
6// every hook calls next(e).
7
8type Pending = { id: number; tool: string; title: string; body: string; timer?: { cancel: () => void } }
9
10const TERMINAL_APPS: Record<string, string> = {
11 Apple_Terminal: 'com.apple.Terminal',
12 'iTerm.app': 'com.googlecode.iterm2',
13 ghostty: 'com.mitchellh.ghostty',
14 WarpTerminal: 'dev.warp.Warp-Stable',
15 vscode: 'com.microsoft.VSCode',
16 WezTerm: 'com.github.wez.wezterm',
17}
18const NOTIFIER_PATHS = ['/opt/homebrew/bin/terminal-notifier', '/usr/local/bin/terminal-notifier']
19
20// Module state: a hot reload starts it over, which only loses an unsent reminder.
21const cfg = { remindMs: 5 * 60_000, useAi: true, doneSound: 'Glass', attentionSound: 'Ping' }
22const group = `session-pings-${crypto.randomUUID()}`
23// The session's title as the app shows it; until the app has one, a name the
24// mod writes once from the first request and keeps.
25let appTitle: string | undefined
26let ownTitle: Promise<string> | undefined
27let lastPrompt = ''
28let transcriptPath: string | undefined
29let pending: Pending | undefined
30let nextId = 0
31let notifier: Promise<string | undefined> | undefined
32let appId: Promise<string | undefined> | undefined
33let project: Promise<string> | undefined
34let chatLink: Promise<string | undefined> | undefined
35
36function oneLine(s: string, max: number) {
37 const t = s.replace(/\s+/g, ' ').trim()
38 return t.length > max ? `${t.slice(0, max - 1)}…` : t
39}
40
41function firstWords(s: string, n: number) {
42 return oneLine(s, 200).split(' ').slice(0, n).join(' ')
43}
44
45async function ask($: EngineInterface, prompt: string, maxTokens: number) {
46 if (!cfg.useAi) return undefined
47 try {
48 const r = await $.model.complete({ model: 'haiku', prompt, maxTokens, effort: 'low' })
49 return r.isAnswered ? oneLine(r.text.replace(/^["'“]|["'”.]$/g, ''), 120) || undefined : undefined
50 } catch {
51 return undefined
52 }
53}
54
55// What the person typed: the app prepends notes of its own in tags
56// (<system-reminder>, a command's record), which are no part of the request.
57function typedText(text: string) {
58 return text
59 .replace(/<([a-z]+-[\w-]+)>[\s\S]*?<\/\1>/gi, ' ')
60 .replace(/\s+/g, ' ')
61 .trim()
62}
63
64async function nameSession($: EngineInterface, text: string) {
65 const name = await ask(
66 $,
67 `Give a short title, 3 to 6 words in sentence case, for a work session that starts with the ` +
68 `request inside <request>. Reply with the title only.\n\n<request>\n${text.slice(0, 3000)}\n</request>`,
69 24,
70 )
71 const words = name?.split(' ').length ?? 0
72 return name && words <= 8 && !/\btitle\b/i.test(name) ? name : firstWords(text, 5) || 'Claude Code'
73}
74
75// The app keeps the session's title in its transcript and changes it as the
76// session goes on, so the latest one is read there each time.
77async function readAppTitle($: EngineInterface) {
78 if (!transcriptPath) return undefined
79 const r = await $.process
80 .run(['/bin/sh', '-c', `grep -o '"customTitle":"[^"]*"' "$1" | tail -1`, 'sh', transcriptPath])
81 .catch(() => undefined)
82 const m = r?.stdout.match(/"customTitle":"([^"]*)"/)
83 return m?.[1] ? oneLine(m[1], 80) : undefined
84}
85
86async function sessionTitle($: EngineInterface) {
87 const latest = await readAppTitle($)
88 if (latest) appTitle = latest
89 return appTitle ?? (await ownTitle) ?? 'Claude Code'
90}
91
92function noteSession(e: { session_title?: string; transcript_path?: string }) {
93 if (e.session_title?.trim()) appTitle = oneLine(e.session_title, 80)
94 if (e.transcript_path) transcriptPath = e.transcript_path
95}
96
97async function summarize($: EngineInterface, reason: string, answer: string) {
98 if (reason === 'error') return 'Stopped: API error'
99 if (reason === 'refusal') return 'Stopped: declined'
100 const line = await ask(
101 $,
102 `Below is a request and the assistant's final reply. Write a status line: "Done: " if the work ` +
103 `is finished, or "Needs input: " if the reply asks the user something or waits on them, then at ` +
104 `most 6 words, like a commit message. Reply with the line only.` +
105 `\n\nRequest:\n${lastPrompt.slice(0, 1500)}\n\nReply:\n${answer.slice(-4000)}`,
106 60,
107 )
108 if (line) return oneLine(line, 70)
109 return `Done: ${oneLine(answer.split(/(?<=[.!?])\s/)[0] || 'finished', 60)}`
110}
111
112async function locateNotifier($: EngineInterface) {
113 for (const p of NOTIFIER_PATHS) if (await $.fs.exists(p).catch(() => false)) return p
114 const r = await $.process.run(['/bin/sh', '-lc', 'command -v terminal-notifier']).catch(() => undefined)
115 return r?.exitCode === 0 ? r.stdout.trim() || undefined : undefined
116}
117
118// The app to bring forward on click: macOS tells child processes which app
119// launched them; terminals also say who they are in TERM_PROGRAM.
120async function locateApp($: EngineInterface) {
121 const bundle = await $.env.get('__CFBundleIdentifier')
122 if (bundle) return bundle
123 const term = await $.env.get('TERM_PROGRAM')
124 return term ? TERMINAL_APPS[term] : undefined
125}
126
127// The project's name, after the session title. A worktree is named after
128// the repository it belongs to, not after the worktree's own folder.
129async function locateProject($: EngineInterface) {
130 const root = await $.session.root().catch(() => '')
131 // A desktop session with no folder runs in a scratch folder the app made: no project to name.
132 if (!root || root.includes('/scratch-workspaces/')) return ''
133 const git = await $.process
134 .run(['git', '-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir'])
135 .catch(() => undefined)
136 const repo = git?.exitCode === 0 ? git.stdout.trim().replace(/\/\.git\/?$/, '') : ''
137 return (repo || root).split('/').filter(Boolean).pop() ?? ''
138}
139
140// A desktop app session can be opened by link; terminals have no such link, so
141// a click there brings the terminal forward instead.
142async function locateChatLink($: EngineInterface) {
143 const id = await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')
144 return id && /^local_[A-Za-z0-9-]{1,64}$/.test(id)
145 ? `claude://code/continue?session=${id}&source=url_external`
146 : undefined
147}
148
149async function hintOnce($: EngineInterface) {
150 if (await $.store.get('notifierHintShown')) return
151 await $.store.set('notifierHintShown', true)
152 $.ui.toast('session-pings: run `brew install terminal-notifier` to make notifications clickable', {
153 timeoutMs: 12_000,
154 })
155}
156
157async function notify($: EngineInterface, title: string, body: string, sound: string) {
158 notifier ??= locateNotifier($)
159 project ??= locateProject($)
160 const [tn, name] = await Promise.all([notifier, project])
161 const heading = name ? `${title} · ${name}` : title
162 if (tn) {
163 appId ??= locateApp($)
164 chatLink ??= locateChatLink($)
165 const [app, link] = await Promise.all([appId, chatLink])
166 const argv = [tn, '-title', heading, '-message', body, '-sound', sound, '-group', group]
167 const r = await $.process.run(link ? [...argv, '-open', link] : app ? [...argv, '-activate', app] : argv).catch(() => undefined)
168 if (r?.exitCode === 0) return
169 }
170 const script = [
171 'on run argv',
172 'display notification (item 2 of argv) with title (item 1 of argv) sound name (item 3 of argv)',
173 'end run',
174 ]
175 const r = await $.process
176 .run(['osascript', ...script.flatMap(l => ['-e', l]), heading, body, sound])
177 .catch(() => undefined)
178 if (r?.exitCode !== 0) await $.process.run(['notify-send', heading, body]).catch(() => undefined)
179 if (!tn) await hintOnce($)
180}
181
182function clearPending() {
183 pending?.timer?.cancel()
184 pending = undefined
185}
186
187async function waitOnUser($: EngineInterface, tool: string, body: string) {
188 clearPending()
189 const p: Pending = { id: ++nextId, tool, title: await sessionTitle($), body }
190 pending = p
191 await notify($, p.title, p.body, cfg.attentionSound)
192 if (cfg.remindMs > 0 && pending?.id === p.id) p.timer = $.clock.after(cfg.remindMs, () => remind($, p.id))
193}
194
195function remind($: EngineInterface, id: number) {
196 if (pending?.id !== id) return
197 void notify($, pending.title, pending.body.replace(/^Needs input/, 'Still needs input'), cfg.attentionSound).catch(() => {})
198}
199
200async function notifyDone($: EngineInterface, reason: string, answer: string) {
201 const title = await sessionTitle($)
202 const body = await summarize($, reason, answer)
203 await notify($, title, body, /^Needs input/.test(body) ? cfg.attentionSound : cfg.doneSound)
204}
205
206function describePermission(tool: string, input: unknown) {
207 const i = (input ?? {}) as Record<string, unknown>
208 const str = (k: string) => (typeof i[k] === 'string' ? (i[k] as string) : '')
209 const base = (p: string) => p.split('/').pop() || p
210 if (tool === 'Bash') return str('command')
211 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit')
212 return `edit ${base(str('file_path') || str('notebook_path'))}`
213 if (tool === 'WebFetch') return `open ${str('url').replace(/^https?:\/\//, '')}`
214 if (tool.startsWith('mcp__')) return `use ${tool.split('__').slice(1).join(' ')}`
215 return `use ${tool}`
216}
217
218export const register: Register = (on, options) => {
219 cfg.remindMs = Number(options.remindAfterMinutes ?? 5) * 60_000
220 cfg.useAi = options.aiSummaries !== false
221 cfg.doneSound = String(options.doneSound || 'Glass')
222 cfg.attentionSound = String(options.attentionSound || 'Ping')
223
224 on('turn.start', async ($, e, next) => {
225 clearPending()
226 const typed = typedText(e.text)
227 if (typed) {
228 lastPrompt = typed
229 if (!appTitle && !ownTitle) ownTitle = nameSession($, typed)
230 }
231 return next(e)
232 })
233
234 on('classic.SessionStart', async ($, e, next) => {
235 noteSession(e)
236 return next(e)
237 })
238
239 on('classic.UserPromptSubmit', async ($, e, next) => {
240 noteSession(e)
241 return next(e)
242 })
243
244 // Notifications are fired and forgotten: they never hold up the session.
245 on('tool.call', async ($, e, next) => {
246 if (e.tool === 'AskUserQuestion' && !e.agentId) {
247 const q = (e.questions?.[0] ?? {}) as { question?: string }
248 const body = `Needs input: ${oneLine(q.question ?? 'a question', 60)}`
249 void waitOnUser($, e.tool, body).catch(() => {})
250 }
251 const result = await next(e)
252 if (pending?.tool === e.tool) clearPending()
253 return result
254 })
255
256 on('classic.PermissionRequest', async ($, e, next) => {
257 const body = `Needs input: approve ${oneLine(describePermission(e.tool_name, e.tool_input), 50)}`
258 void waitOnUser($, e.tool_name, body).catch(() => {})
259 return next(e)
260 })
261
262 on('turn.complete', async ($, e, next) => {
263 const result = await next(e)
264 if (e.agentId || e.isAborted) return result
265 clearPending()
266 void notifyDone($, e.reason, e.answer).catch(() => {})
267 return result
268 })
269}
270