SLOPSHOPPER

session-pings

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…

newguardtoastmodelprocesstimer
A shopper browsing a rack in a slop shop
README

session-pings: Claude Code notifications that say what Claude needs

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:

WhenThe message says
Claude finishedDone: pricing table stacks on mobile (AI summary, at most 6 words)
Claude finished but is waiting on youNeeds input: pick webhook retry plan
Claude asks a questionNeeds input: Postgres or SQLite?
Claude needs permissionNeeds input: approve npm install stripe
Still unanswered after 5 minutesStill needs input: … (once)
The turn failedStopped: 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.

Install

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.

  • Desktop app: quit it fully (Cmd+Q) and reopen. All sessions come back with their history and load the mod. Wait until none is mid-task, since quitting interrupts a running turn.
  • Terminal: exit and resume with 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.

Check it works

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.

Settings

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"
      }
    }
  }
}
SettingDefaultWhat it does
remindAfterMinutes5Remind once if a question or permission is still unanswered. 0 turns reminders off.
aiSummariestrueUses 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.
doneSoundGlassmacOS sound when Claude is done.
attentionSoundPingmacOS sound for questions, permissions and reminders.

Sound names: Basso, Blow, Bottle, Frog, Funk, Glass, Hero, Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink.

Troubleshooting

  • No notifications at all. Did you restart the session after installing? Check claude plugin list shows session-pings enabled. Check macOS Focus / Do Not Disturb, and the app's notification permission in System Settings.
  • Notifications vanish after a few seconds. That's macOS's Temporary/Banners style; set Persistent (step 4). Missed ones are in Notification Center (click the clock in the menu bar).
  • Clicking opens Script Editor. terminal-notifier isn't installed or isn't found; run step 2.
  • Title is "Claude Code" or looks odd. The session has no name in the app yet; the mod names it from your first message and keeps that name.
  • Still stuck. Start Claude Code with claude --debug and look for lines starting with session-pings:; send them along with your feedback.

Uninstall

claude plugin uninstall session-pings@session-pings

Feedback

Open an issue in this repo:

  • Did the notifications arrive when you expected? Any you missed, or ones you didn't want?
  • Is the session name enough to know which work it's about?
  • Is the one-line message useful, too long, or wrong?
  • Desktop app, terminal, or both? Which terminal app?

How it works

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.

Source 1 files
hooks/register.ts 270 lines
1import 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