SLOPSHOPPER

needs-you

CC Alerts: pings you (macOS banner + sound) when a session finishes a long turn or sits blocked on a dialog, only as loudly as needed given whether you're…

newbandguardcommandtoastprompt
v0.1.0MITupdated 2026-10-02ozlar34/claude-code-mods/needs-you
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · needs-you
› 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 › /ping ⎿ needs-you: CC Alerts: ON. Host app: unknown. This session: app · feat/auth-refresh · #g7z6. Pings: finished turns ≥ 60s, ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

needs-you ("CC Alerts")

Pings you when a session finishes a turn worth pinging for, or sits blocked on a dialog — and only as loudly as needed given whether you're already looking. Control it with /ping. macOS only (banners, lsappinfo, afplay). Beta.

  • Finished: main-loop turns you started (or a wake-up from your own background task), effective duration (minus dialog waits) ≥ minTurnSeconds. Errors/refusals always ping. Aborted and thinking-only never. Subagent, peer, plugin turns never.
  • Blocked: permission dialog (classic.PermissionRequest), plan approval (ExitPlanMode), questions (AskUserQuestion, which includes outward-gate) unanswered ≥ waitSeconds. One outstanding ping per session, one repeat at repeatMinutes. Note: AskUserQuestion dialogs can self-resolve when the engine decides you're AFK; permission and plan approval never do.
  • Tiers: host app not in front → banner + sound + toast; in front → banner + toast; in front and you interacted <30 s ago → toast only (finished/error only). Non-terminal surface → always tier 1. Headless → nothing, no processes.
  • /ping: on, quiet [dur] (no sound), off [dur] (toast + log only), test, log, name <label>. NEEDS_YOU=off silences one process.
  • Band: hidden at rest; shows CC Alerts: quiet|OFF · <left> [ Back on ] while a mode is active. BAND_ALWAYS in register.tsx is the one-line switch for an always-visible variant.
  • Privacy: bodyMode: full puts the last sentence of the reply in the banner. kindOnlyPaths (comma-separated folders) forces kind-only bodies (Done · 3m) for sessions rooted inside them — set it for a notes vault or anything private. Banner text is redacted for secret-shaped strings either way.
  • Log: ~/.claude/state/needs-you.log (no rotation in v1). Never reply text. Headless sessions write nothing.
  • Sound: macOS's own /System/Library/Sounds/Glass.aiff, played with afplay.
  • Set macOS Notifications → Show Previews → "When Unlocked".

Banner icon (optional)

Out of the box, banners go through osascript and show Script Editor's icon. For a Claude icon, build a renamed copy of terminal-notifier (macOS takes a banner's icon from the sending app); the mod uses ~/Applications/CC Alerts.app when it exists:

brew install terminal-notifier
D="$HOME/Applications/CC Alerts.app"; rm -rf "$D"; cp -R "$(brew --prefix terminal-notifier)/terminal-notifier.app" "$D"; chmod -R u+w "$D"
cp /Applications/Claude.app/Contents/Resources/electron.icns "$D/Contents/Resources/CCAlerts.icns"; rm "$D/Contents/Resources/Terminal.icns"
/usr/libexec/PlistBuddy -c "Set CFBundleIdentifier local.cc-alerts" -c "Set CFBundleName CC\ Alerts" -c "Set CFBundleIconFile CCAlerts" "$D/Contents/Info.plist"
codesign --force --deep -s - "$D"

The cp …electron.icns line needs the Claude desktop app; skip it to keep terminal-notifier's icon. First run asks for notification permission (allow, style Banners). Rebuild after a brew upgrade terminal-notifier.

Files

  • hooks/logic.ts: pure decisions (tiers, thresholds, message bodies, redaction)
  • hooks/register.tsx: hooks, /ping, band, banner/sound delivery
  • tests/needs-you.test.ts: claude plugin test <this folder>
Source 3 files
hooks/register.tsx 520 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderChildren, PluginOptions, Register } from 'claude-code'
3
4import type { NeedsYouWait } from '../types'
5import {
6  ackLine,
7  blockedBody,
8  blockedDetail,
9  channels,
10  errorBody,
11  finishedBody,
12  formatLeft,
13  isUnder,
14  parseDuration,
15  parseFront,
16  pingLine,
17  plain,
18  summarize,
19  sweep,
20  title,
21  turnVerdict,
22} from './logic'
23import type { BodyMode, Kind, Mode, Outcome } from './logic'
24
25// Per-session state lives in $.state so a hot reload keeps waits, the turn flag and the ack.
26const userTurn = atom({ plugin: 'needs-you', key: 'userTurn' } as const, false)
27const dialogMs = atom({ plugin: 'needs-you', key: 'dialogMs' } as const, 0)
28const waits = atom({ plugin: 'needs-you', key: 'waits' } as const, [])
29const lastInteractionAt = atom({ plugin: 'needs-you', key: 'lastInteractionAt' } as const, null)
30const lastPingAt = atom({ plugin: 'needs-you', key: 'lastPingAt' } as const, null)
31const bandAtom = atom({ plugin: 'needs-you', key: 'band' } as const, null)
32const labelAtom = atom({ plugin: 'needs-you', key: 'label' } as const, '')
33const branchAtom = atom({ plugin: 'needs-you', key: 'branch' } as const, null)
34const ackPending = atom({ plugin: 'needs-you', key: 'ackPending' } as const, null)
35
36// Global quiet mode, shared by every session through $.store: one key per concern.
37const MODE_KEY = 'needs-you:mode'
38const UNTIL_KEY = 'needs-you:until'
39
40const LOG = '/.claude/state/needs-you.log'
41/** macOS's own Glass sound, played in place (not bundled: it's Apple's file). */
42const SOUND = '/System/Library/Sounds/Glass.aiff'
43const PERM_ID = 'perm'
44const USER_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
45
46/** One-line switch: true draws the band at rest too (`CC Alerts: on [ Quiet 2h ] [ Off 8h ]`). */
47const BAND_ALWAYS = false
48
49type Config = {
50  minTurnMs: number
51  waitMs: number
52  repeatMs: number
53  repeatMin: number
54  gapMs: number
55  lookingMs: number
56  hostApp: string
57  bodyMode: BodyMode
58  kindOnlyPaths: string
59}
60
61function readConfig(options: PluginOptions): Config {
62  const num = (k: string, d: number) => {
63    const v = options[k]
64    return typeof v === 'number' && v >= 0 ? v : d
65  }
66  const str = (k: string, d: string) => {
67    const v = options[k]
68    return typeof v === 'string' ? v : d
69  }
70  const repeatMin = num('repeatMinutes', 5)
71  return {
72    minTurnMs: num('minTurnSeconds', 60) * 1000,
73    waitMs: num('waitSeconds', 15) * 1000,
74    repeatMs: repeatMin * 60_000,
75    repeatMin,
76    gapMs: num('sessionGapSeconds', 60) * 1000,
77    lookingMs: num('lookingWindowSeconds', 30) * 1000,
78    hostApp: str('hostApp', ''),
79    bodyMode: options.bodyMode === 'kind-only' ? 'kind-only' : 'full',
80    kindOnlyPaths: str('kindOnlyPaths', ''),
81  }
82}
83
84// ---------- plumbing ----------
85
86/** Fire-and-forget with the failure routed to the debug log. */
87function safe($: EngineInterface, p: Promise<unknown>): void {
88  p.catch(err => $.ui.log(`needs-you: ${String(err)}`, { to: 'debug' }))
89}
90
91async function home($: EngineInterface): Promise<string> {
92  return (await $.env.get('HOME')) ?? ''
93}
94
95/** O_APPEND via the shell, as outward-gate does: concurrent sessions can't clobber each other's lines. */
96async function appendLog($: EngineInterface, line: string): Promise<void> {
97  try {
98    await $.process.run(['/bin/sh', '-c', 'mkdir -p "$(dirname "$1")" && printf "%s\\n" "$2" >> "$1"', 'sh', `${await home($)}${LOG}`, line])
99  } catch (err) {
100    $.ui.log(`needs-you: log write failed: ${String(err)}`, { to: 'debug' })
101  }
102}
103
104async function projectName($: EngineInterface): Promise<string> {
105  const root = await $.session.root()
106  return root.split('/').filter(p => p !== '').pop() ?? root
107}
108
109async function bodyModeOf($: EngineInterface, cfg: Config): Promise<BodyMode> {
110  if (cfg.bodyMode === 'kind-only') return 'kind-only'
111  return isUnder(await $.session.root(), cfg.kindOnlyPaths, await home($)) ? 'kind-only' : 'full'
112}
113
114/** Cached per session: the engine's repo info has no branch. */
115async function branchOf($: EngineInterface): Promise<string> {
116  const cached = await read($, branchAtom)
117  if (cached !== null) return cached
118  let branch = 'no-git'
119  try {
120    const r = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 3000 })
121    if (r.exitCode === 0 && r.stdout.trim() !== '') branch = r.stdout.trim()
122  } catch {
123    // Not a repo, or git missing: the title just says no-git.
124  }
125  await update($, branchAtom, () => branch)
126  return branch
127}
128
129async function sessionTitle($: EngineInterface): Promise<string> {
130  return title({ project: await projectName($), branch: await branchOf($), id: await $.session.id(), label: await read($, labelAtom) })
131}
132
133// ---------- quiet mode ----------
134
135type Current = { mode: Mode; until: number | null; source: string }
136
137/** Reads the global mode once, auto-clearing an expired one. */
138async function currentMode($: EngineInterface, now: number): Promise<Current> {
139  const stored = await $.store.get(MODE_KEY)
140  const until = await $.store.get(UNTIL_KEY)
141  let mode: Mode = stored === 'quiet' || stored === 'off' ? stored : 'on'
142  let end = typeof until === 'number' ? until : null
143  if (mode !== 'on' && end !== null && end <= now) {
144    await $.store.delete(MODE_KEY)
145    await $.store.delete(UNTIL_KEY)
146    mode = 'on'
147    end = null
148  }
149  const env = (await $.env.get('NEEDS_YOU'))?.trim().toLowerCase()
150  if (env === 'off') return { mode: 'off', until: null, source: 'NEEDS_YOU=off' }
151  return { mode, until: mode === 'on' ? null : end, source: mode === 'on' ? '' : '/ping' }
152}
153
154/** $.store doesn't re-render: each session mirrors it into $.state on its own sweep (≤ 15 s cross-tab lag). */
155async function mirrorBand($: EngineInterface): Promise<void> {
156  const now = await $.clock.now()
157  const c = await currentMode($, now)
158  const left = c.until === null ? 'until turned on' : `${formatLeft(c.until - now)} left`
159  const prev = await read($, bandAtom)
160  if (prev === null || prev.mode !== c.mode || prev.until !== c.until || prev.left !== left) {
161    await update($, bandAtom, () => ({ mode: c.mode, until: c.until, left }))
162  }
163}
164
165async function setMode($: EngineInterface, mode: Mode, ms: number | null): Promise<void> {
166  if (mode === 'on') {
167    await $.store.delete(MODE_KEY)
168    await $.store.delete(UNTIL_KEY)
169  } else {
170    await $.store.set(MODE_KEY, mode)
171    if (ms === null) await $.store.delete(UNTIL_KEY)
172    else await $.store.set(UNTIL_KEY, (await $.clock.now()) + ms)
173  }
174  await mirrorBand($)
175  await noteInteraction($)
176}
177
178// ---------- interaction + ack ----------
179
180/** Any sign the user is at this session: keystroke, prompt, band press, an answered dialog. */
181async function noteInteraction($: EngineInterface): Promise<void> {
182  const now = await $.clock.now()
183  const last = await read($, lastInteractionAt)
184  if (last === null || now - last >= 1000) await update($, lastInteractionAt, () => now)
185  const ack = await read($, ackPending)
186  if (ack !== null) {
187    await update($, ackPending, () => null)
188    await appendLog($, ackLine({ at: now, id: ack.id, session: await $.session.id(), delayMs: now - ack.at }))
189  }
190}
191
192// ---------- the ping ----------
193
194async function hostIsFront($: EngineInterface, cfg: Config): Promise<boolean> {
195  const host = cfg.hostApp !== '' ? cfg.hostApp : ((await $.env.get('__CFBundleIdentifier')) ?? '')
196  if (host === '') return false
197  try {
198    const r = await $.process.run(['/bin/sh', '-c', 'lsappinfo info -only bundleid "$(lsappinfo front)"'], { timeoutMs: 3000 })
199    return r.exitCode === 0 && parseFront(r.stdout) === host
200  } catch {
201    return false // unknown frontmost counts as not looking: ping
202  }
203}
204
205type PingRequest = { kind: Kind; body: string; seconds: number; force?: boolean }
206
207async function logOnly($: EngineInterface, kind: Kind, outcome: Outcome, seconds: number): Promise<void> {
208  const at = await $.clock.now()
209  const session = await $.session.id()
210  await appendLog($, pingLine({ at, id: `${at.toString(36)}-${kind}`, session, project: await projectName($), kind, outcome, seconds }))
211}
212
213async function ping($: EngineInterface, cfg: Config, isInteractive: boolean, req: PingRequest): Promise<void> {
214  if (!isInteractive) return // headless: never ping, never spawn a process
215  const now = await $.clock.now()
216  const session = await $.session.id()
217  const mode = await currentMode($, now)
218  const nonTerminal = (await $.session.surfaces()).some(s => s !== 'terminal')
219  const hostFront = mode.mode === 'off' || nonTerminal ? false : await hostIsFront($, cfg)
220  const last = await read($, lastInteractionAt)
221  const ch = channels({
222    kind: req.kind,
223    mode: mode.mode,
224    hostFront,
225    interactedRecently: req.force !== true && last !== null && now - last < cfg.lookingMs,
226    nonTerminal,
227  })
228  const heading = await sessionTitle($)
229  const id = `${now.toString(36)}-${req.kind}`
230
231  if (ch.banner) {
232    await banner($, heading, req.body)
233    await update($, ackPending, () => ({ id, at: now }))
234  }
235  if (ch.sound) await $.process.run(['afplay', SOUND], { timeoutMs: 5000 }).catch(err => $.ui.log(`needs-you: sound failed: ${String(err)}`, { to: 'debug' }))
236  if (ch.toast) $.ui.toast(`CC Alerts · ${heading} — ${req.body}`, { timeoutMs: 8000 })
237  await appendLog($, pingLine({ at: now, id, session, project: await projectName($), kind: req.kind, outcome: ch.outcome, seconds: req.seconds }))
238  if ((req.kind === 'finished' || req.kind === 'error') && req.force !== true) await update($, lastPingAt, () => now)
239}
240
241// ---------- banner ----------
242
243/** A copy of terminal-notifier with the Claude icon (see README): macOS takes a banner's icon from the sending app. */
244const NOTIFIER = 'Applications/CC Alerts.app/Contents/MacOS/terminal-notifier'
245
246/** Text always goes in as argv items, never interpolated into a script. Falls back to osascript (Script Editor's icon). */
247async function banner($: EngineInterface, title: string, body: string): Promise<void> {
248  const sent = await $.process
249    .run([`${await home($)}/${NOTIFIER}`, '-title', plain(title), '-message', plain(body)], { timeoutMs: 5000 })
250    .catch(() => undefined)
251  if (sent?.exitCode === 0) return
252  await $.process
253    .run(['osascript', '-e', 'on run argv', '-e', 'display notification (item 1 of argv) with title (item 2 of argv)', '-e', 'end run', '--', body, title], { timeoutMs: 5000 })
254    .catch(err => $.ui.log(`needs-you: banner failed: ${String(err)}`, { to: 'debug' }))
255}
256
257// ---------- blocked waits ----------
258
259async function startWait($: EngineInterface, w: Pick<NeedsYouWait, 'id' | 'kind' | 'tool' | 'detail'>): Promise<void> {
260  const now = await $.clock.now()
261  await update($, waits, ws => (ws.some(x => x.id === w.id) ? ws : [...ws, { ...w, startedAt: now, pinged: false, repeated: false }]))
262}
263
264/** Removes matching waits; their time is subtracted from the turn. `answered` = the user resolved it (not Esc, /clear, abort). */
265async function endWaits($: EngineInterface, match: (w: NeedsYouWait) => boolean, answered: boolean): Promise<void> {
266  if (!(await read($, waits)).some(match)) return
267  let gone: NeedsYouWait[] = []
268  await update($, waits, ws => {
269    gone = ws.filter(match)
270    return ws.filter(w => !match(w))
271  })
272  const now = await $.clock.now()
273  const spent = gone.reduce((sum, w) => sum + Math.max(0, now - w.startedAt), 0)
274  if (spent > 0) await update($, dialogMs, ms => ms + spent)
275  if (answered && gone.length > 0) await noteInteraction($)
276}
277
278/** The sweep: idempotent via per-wait flags, so reload, a 2nd timer or a racing event can't double-ping. */
279async function tick($: EngineInterface, cfg: Config, isInteractive: boolean): Promise<void> {
280  if (!isInteractive) return
281  const list = await read($, waits)
282  if (list.length === 0) return
283  const now = await $.clock.now()
284  const act = sweep(list, now, { waitMs: cfg.waitMs, repeatMs: cfg.repeatMs })
285  const id = act.ping ?? act.repeat
286  if (id === undefined) return
287  const repeat = act.repeat !== undefined
288  let claimed: NeedsYouWait | undefined
289  await update($, waits, ws => {
290    claimed = ws.find(w => w.id === id && (repeat ? !w.repeated : !w.pinged))
291    return claimed === undefined ? ws : ws.map(w => (w.id === id ? (repeat ? { ...w, repeated: true } : { ...w, pinged: true, repeated: now - w.startedAt >= cfg.repeatMs }) : w))
292  })
293  if (claimed === undefined) return
294  await ping($, cfg, isInteractive, {
295    kind: repeat ? 'repeat' : 'blocked',
296    body: blockedBody(claimed.detail, repeat, cfg.repeatMs / 60_000),
297    seconds: (now - claimed.startedAt) / 1000,
298  })
299}
300
301async function watched<R>($: EngineInterface, w: Pick<NeedsYouWait, 'id' | 'kind' | 'tool' | 'detail'>, signal: AbortSignal, run: () => Promise<R>): Promise<R> {
302  await startWait($, w)
303  try {
304    return await run()
305  } finally {
306    await endWaits($, x => x.id === w.id, !signal.aborted)
307  }
308}
309
310// ---------- /ping ----------
311
312const USAGE = 'Usage: /ping [on | quiet [30m|2h] | off [30m|2h] | test | log | name <label>]'
313
314async function statusText($: EngineInterface, cfg: Config): Promise<string> {
315  const now = await $.clock.now()
316  const c = await currentMode($, now)
317  const state = c.mode === 'on' ? 'ON' : `${c.mode.toUpperCase()}${c.until === null ? '' : ` (${formatLeft(c.until - now)} left)`}${c.source === '/ping' ? '' : ` [${c.source}]`}`
318  const detected = (await $.env.get('__CFBundleIdentifier')) ?? 'unknown'
319  const host = cfg.hostApp !== '' ? `${cfg.hostApp} (config override; detected ${detected})` : detected
320  return [
321    `CC Alerts: ${state}.`,
322    `Host app: ${host}.`,
323    `This session: ${await sessionTitle($)}.`,
324    `Pings: finished turns ≥ ${cfg.minTurnMs / 1000}s, blocked waits ≥ ${cfg.waitMs / 1000}s (one repeat at ${cfg.repeatMin}m). Quiet: banner+toast, no sound. Off: toast + log only.`,
325  ].join(' ')
326}
327
328async function logText($: EngineInterface): Promise<string> {
329  const path = `${await home($)}${LOG}`
330  let text = ''
331  try {
332    text = await $.fs.read(path)
333  } catch {
334    return `No log yet at ${path}.`
335  }
336  const lines = text.split('\n').filter(l => l !== '')
337  return `${lines.slice(-20).join('\n')}\n${summarize(lines, await $.clock.now())}`
338}
339
340export const register: Register = (on, options) => {
341  const cfg = readConfig(options)
342  // A fact about this process: session.start re-sets it on every (re)load.
343  let isInteractive = false
344
345  on('session.start', async ($, e, next) => {
346    isInteractive = e.isInteractive
347    await $.command.register({
348      name: 'ping',
349      description: 'CC Alerts: status, on, quiet [2h], off [8h], test, log, name <label>',
350      argumentHint: '[on | quiet [dur] | off [dur] | test | log | name <label>]',
351    })
352    // Timers die on reload; the waits themselves are in $.state, so the new sweep picks them up.
353    $.clock.every(5000, () => safe($, tick($, cfg, isInteractive)))
354    $.clock.every(15_000, () => safe($, mirrorBand($)))
355    await mirrorBand($)
356    safe($, tick($, cfg, isInteractive))
357    return next(e)
358  })
359
360  on('session.end', async ($, e, next) => {
361    if (e.reason === 'clear') {
362      await update($, waits, () => [])
363      await update($, dialogMs, () => 0)
364      await update($, userTurn, () => false)
365      await update($, ackPending, () => null)
366    }
367    return next(e)
368  })
369
370  on('command.run', { command: 'ping' }, async ($, e) => {
371    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
372    const arg = rest.join(' ')
373    if (verb === '') return { text: await statusText($, cfg) }
374    if (verb === 'on') {
375      await setMode($, 'on', null)
376      return { text: 'CC Alerts: ON.' }
377    }
378    if (verb === 'quiet' || verb === 'off') {
379      const ms = arg === '' ? null : parseDuration(arg)
380      if (ms === undefined) return { text: `Can't read "${arg}". Use e.g. /ping ${verb} 2h or /ping ${verb} 30m.` }
381      await setMode($, verb, ms)
382      const what = verb === 'quiet' ? 'no sound (banner + toast)' : 'no banner, no sound (toast + log only)'
383      return { text: `CC Alerts: ${verb.toUpperCase()} — ${what}${ms === null ? ' until /ping on' : ` for ${formatLeft(ms)}`}.` }
384    }
385    if (verb === 'test') {
386      await noteInteraction($)
387      await ping($, cfg, isInteractive, { kind: 'finished', body: 'Done · test · this is a sample ping', seconds: 0, force: true })
388      return { text: 'Sample ping sent through the real path (check the log with /ping log).' }
389    }
390    if (verb === 'log') return { text: await logText($) }
391    if (verb === 'name') {
392      await update($, labelAtom, () => arg)
393      return { text: arg === '' ? 'Session label cleared.' : `Session tag is now: ${await sessionTitle($)}` }
394    }
395    return { text: USAGE }
396  })
397
398  // ---- interaction clock ----
399  on('prompt.edit', async ($, e, next) => {
400    await noteInteraction($)
401    safe($, tick($, cfg, isInteractive)) // evaluate on the next event too, in case a timer was lost to a reload
402    return next(e)
403  })
404
405  on('prompt.submit', async ($, e, next) => {
406    const kind = e.origin.kind
407    const user = USER_ORIGINS.has(kind)
408    // The user's turn: their own prompt, or the wake-up a background task they launched caused. Never peer/plugin turns.
409    if (user || (kind === 'task-notification' && e.turnId === undefined)) await update($, userTurn, () => true)
410    else if (e.turnId === undefined) await update($, userTurn, () => false)
411    if (user) await noteInteraction($)
412    return next(e)
413  })
414
415  // ---- trigger 1: turn finished ----
416  on('turn.complete', async ($, e, next) => {
417    const result = await next(e)
418    if (e.agentId !== undefined) return result // subagent turns never ping
419    await endWaits($, () => true, false)
420    const started = await read($, userTurn)
421    await update($, userTurn, () => false)
422    const spent = await read($, dialogMs)
423    await update($, dialogMs, () => 0)
424    const now = await $.clock.now()
425    const lastPing = await read($, lastPingAt)
426    const effectiveMs = Math.max(0, e.durationMs - spent)
427    const verdict = turnVerdict({
428      reason: e.reason,
429      answer: e.answer,
430      effectiveMs,
431      minMs: cfg.minTurnMs,
432      startedByUser: started,
433      sinceLastPingMs: lastPing === null ? null : now - lastPing,
434      gapMs: cfg.gapMs,
435    })
436    if (verdict === 'none') return result
437    const kind: Kind = e.reason === 'error' || e.reason === 'refusal' ? 'error' : 'finished'
438    // Fire-and-forget: lsappinfo/osascript must not eat the hook's own budget.
439    $.clock.after(0, () => {
440      if (verdict === 'gap') return safe($, isInteractive ? logOnly($, kind, 'gap', effectiveMs / 1000) : Promise.resolve())
441      safe(
442        $,
443        bodyModeOf($, cfg).then(bodyMode =>
444          ping($, cfg, isInteractive, {
445            kind,
446            body: e.reason === 'error' || e.reason === 'refusal' ? errorBody(e.reason) : finishedBody({ answer: e.answer, durationMs: effectiveMs, bodyMode }),
447            seconds: effectiveMs / 1000,
448          }),
449        ),
450      )
451    })
452    return result
453  })
454
455  // ---- trigger 2: blocked waits ----
456  // Permission dialog: classic.PermissionRequest has no tool_use_id, so the wait is keyed by session.
457  on('classic.PermissionRequest', async ($, e, next) => {
458    const detail = blockedDetail({ kind: 'permission', tool: e.tool_name, bodyMode: await bodyModeOf($, cfg) })
459    await startWait($, { id: PERM_ID, kind: 'permission', tool: e.tool_name, detail })
460    safe($, tick($, cfg, isInteractive))
461    return next(e)
462  })
463  on('classic.PostToolUse', async ($, e, next) => {
464    await endWaits($, w => w.kind === 'permission' && w.tool === e.tool_name, true)
465    return next(e)
466  })
467  on('classic.PostToolUseFailure', async ($, e, next) => {
468    await endWaits($, w => w.kind === 'permission' && w.tool === e.tool_name, true)
469    return next(e)
470  })
471
472  // Plan approval and questions (this also covers outward-gate, whose dialog is an AskUserQuestion).
473  on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
474    safe($, tick($, cfg, isInteractive))
475    return watched($, { id: e.tool_use_id, kind: 'plan', tool: '', detail: blockedDetail({ kind: 'plan', bodyMode: 'full' }) }, next.signal, () => next(e))
476  })
477  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
478    safe($, tick($, cfg, isInteractive))
479    const detail = blockedDetail({ kind: 'question', header: e.questions[0]?.header, bodyMode: await bodyModeOf($, cfg) })
480    return watched($, { id: e.tool_use_id, kind: 'question', tool: '', detail }, next.signal, () => next(e))
481  })
482
483  // A new model request means every dialog of the previous step is over: backstop if a reload orphaned a wait.
484  on('turn.step', async function* ($, e, next) {
485    if (e.agentId === undefined) await endWaits($, () => true, false)
486    return yield* next(e)
487  })
488  // Esc / abort cancels pending waits and their repeat.
489  on('turn.abort', async ($, e, next) => {
490    await endWaits($, () => true, false)
491    return next(e)
492  })
493
494  // ---- the band ----
495  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
496    if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
497    const band = await read($, bandAtom)
498    const mode = band?.mode ?? 'on'
499    if (mode === 'on' && !BAND_ALWAYS) return next(e)
500    const { Box, Text, Button } = $.ui.resolve(e)
501    // The band is one site shared by every mod: stack our row on what the mods beneath draw, never replace it.
502    const withBelow = async (mine: RenderChildren) => <Box flexDirection="column">{mine}{await next(e)}</Box>
503    if (mode === 'on') {
504      return withBelow(
505        <Box flexDirection="row" gap={1}>
506          <Text dimColor>CC Alerts: on</Text>
507          <Button key="ping-quiet" label="Quiet 2h" onPress={() => safe($, setMode($, 'quiet', 2 * 3_600_000))} />
508          <Button key="ping-off" label="Off 8h" onPress={() => safe($, setMode($, 'off', 8 * 3_600_000))} />
509        </Box>
510      )
511    }
512    return withBelow(
513      <Box flexDirection="row" gap={1}>
514        <Text color="yellow">{`CC Alerts: ${mode === 'off' ? 'OFF' : 'quiet'} · ${band?.left ?? ''}`}</Text>
515        <Button key="ping-on" label="Back on" onPress={() => safe($, setMode($, 'on', null))} />
516      </Box>
517    )
518  })
519}
520
hooks/logic.ts 269 lines
1import type { NeedsYouWait } from '../types'
2
3export type Kind = 'finished' | 'error' | 'blocked' | 'repeat'
4export type Mode = 'on' | 'quiet' | 'off'
5export type Outcome = 'banner' | 'toast:looking' | 'off' | 'quiet' | 'headless' | 'gap'
6export type BodyMode = 'full' | 'kind-only'
7
8export const TITLE_MAX = 40
9export const BODY_MAX = 80
10export const ACK_WINDOW_MS = 180_000
11
12// ---------- durations ----------
13
14/** "8h" / "30m" / "2d" / "45" (minutes) → ms; undefined when unreadable. */
15export function parseDuration(text: string): number | undefined {
16  const m = /^(\d+(?:\.\d+)?)\s*([mhd]?)$/i.exec(text.trim())
17  if (!m) return undefined
18  const n = Number(m[1])
19  const unit = (m[2] ?? '').toLowerCase()
20  const ms = n * (unit === 'h' ? 3_600_000 : unit === 'd' ? 86_400_000 : 60_000)
21  return ms > 0 ? ms : undefined
22}
23
24export function formatLeft(ms: number): string {
25  const min = Math.max(1, Math.ceil(ms / 60_000))
26  if (min < 60) return `${min}m`
27  const h = Math.floor(min / 60)
28  return min % 60 ? `${h}h${min % 60}m` : `${h}h`
29}
30
31/** A turn's length as "45s", "12m", "1h5m". */
32export function formatDuration(ms: number): string {
33  const s = Math.floor(ms / 1000)
34  if (s < 60) return `${s}s`
35  const min = Math.floor(s / 60)
36  if (min < 60) return `${min}m`
37  const h = Math.floor(min / 60)
38  return min % 60 ? `${h}h${min % 60}m` : `${h}h`
39}
40
41// ---------- text safety: strip → redact → truncate ----------
42
43const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
44
45export function graphemes(text: string): string[] {
46  return Array.from(SEGMENTER.segment(text), s => s.segment)
47}
48
49/** Truncates by grapheme (never splits an emoji or combining mark), ending in "…" when cut. */
50export function truncate(text: string, max: number): string {
51  const g = graphemes(text)
52  return g.length <= max ? text : `${g.slice(0, Math.max(0, max - 1)).join('')}…`
53}
54
55const KEY_SHAPES: readonly RegExp[] = [
56  /\bsk-[A-Za-z0-9_-]{8,}/g,
57  /\bgh[pousr]_[A-Za-z0-9]{10,}/g,
58  /\bxox[abprs]-[A-Za-z0-9-]{8,}/g,
59  /\bAKIA[0-9A-Z]{12,}/g,
60  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_.-]+/g,
61  /[A-Za-z0-9+/_=-]{32,}/g,
62]
63
64export function redact(text: string): string {
65  return KEY_SHAPES.reduce((t, re) => t.replace(re, '[redacted]'), text)
66}
67
68/** Markdown and control characters out; one line. */
69export function stripMarkup(text: string): string {
70  return text
71    .replace(/[\p{Cc}\p{Cf}]/gu, ' ')
72    .replace(/^\s*(?:[-*+>]\s+|\d+\.\s+)/, '')
73    .replace(/^\s*#+\s*/, '')
74    .replace(/\*\*|`+/g, '')
75    .replace(/\s+/g, ' ')
76    .trim()
77}
78
79/** Strip, then redact, then truncate: redaction must see the whole secret before a cut can hide half of it. */
80export function safeText(text: string, max: number): string {
81  return truncate(redact(stripMarkup(text)), max)
82}
83
84/** The last sentence of the reply's last line; '' for a code fence, table row or empty reply. */
85export function lastSentence(answer: string): string {
86  const lines = answer.split('\n').map(l => l.trim()).filter(l => l !== '')
87  const line = lines[lines.length - 1] ?? ''
88  if (line.startsWith('|') || line.startsWith('```')) return ''
89  const sentences = line.split(/(?<=[.!?])\s+/)
90  return stripMarkup(sentences[sentences.length - 1] ?? '')
91}
92
93export function endsWithQuestion(answer: string): boolean {
94  const lines = answer.split('\n').map(l => l.trim()).filter(l => l !== '')
95  return (lines[lines.length - 1] ?? '').endsWith('?')
96}
97
98/** `Done · 12m · sentence` / `Needs answer · …` / kind-only; always ≤ BODY_MAX. */
99export function finishedBody(args: { answer: string; durationMs: number; bodyMode: BodyMode }): string {
100  const head = `${endsWithQuestion(args.answer) ? 'Needs answer' : 'Done'} · ${formatDuration(args.durationMs)}`
101  if (args.bodyMode === 'kind-only') return head
102  const sentence = redact(lastSentence(args.answer))
103  if (sentence.length < 10) return head
104  const room = BODY_MAX - graphemes(head).length - 3
105  return room < 10 ? head : `${head} · ${truncate(sentence, room)}`
106}
107
108export function errorBody(reason: 'error' | 'refusal'): string {
109  return reason === 'refusal' ? 'Error · refusal' : 'Error · API error'
110}
111
112/** Dialog type + header, never question text. */
113export function blockedDetail(args: { kind: NeedsYouWait['kind']; tool?: string; header?: string; bodyMode: BodyMode }): string {
114  if (args.kind === 'plan') return 'Plan approval'
115  if (args.kind === 'permission') {
116    return args.bodyMode === 'kind-only' || !args.tool ? 'Permission' : `${safeText(args.tool, 30)} permission`
117  }
118  return args.bodyMode === 'kind-only' || !args.header ? 'Question' : `Question · ${safeText(args.header, 30)}`
119}
120
121export function blockedBody(detail: string, repeat: boolean, repeatMin: number): string {
122  return truncate(repeat ? `Still waiting · ${repeatMin}m · ${detail}` : `Waiting · ${detail}`, BODY_MAX)
123}
124
125// ---------- title ----------
126
127/** FNV-1a over the session id, 4 base-36 chars: stable across hot reload. */
128export function hash4(id: string): string {
129  let h = 0x811c9dc5
130  for (const ch of id) h = Math.imul(h ^ ch.charCodeAt(0), 0x01000193) >>> 0
131  return h.toString(36).padStart(4, '0').slice(-4)
132}
133
134/** `<project> · <branch> · #hash[ · label]`, ≤ TITLE_MAX: the longest free-text part is cut until it fits. */
135export function title(args: { project: string; branch: string; id: string; label: string }): string {
136  const tag = `#${hash4(args.id)}`
137  const parts = { project: stripMarkup(args.project), branch: stripMarkup(args.branch), label: stripMarkup(args.label) }
138  const join = () => [parts.project, parts.branch, tag, parts.label].filter(p => p !== '').join(' · ')
139  while (graphemes(join()).length > TITLE_MAX) {
140    const longest = (['project', 'branch', 'label'] as const).reduce((a, b) => (graphemes(parts[b]).length > graphemes(parts[a]).length ? b : a))
141    const len = graphemes(parts[longest]).length
142    if (len <= 1) return truncate(join(), TITLE_MAX)
143    parts[longest] = truncate(parts[longest], len - 1)
144  }
145  return join()
146}
147
148// ---------- tiers ----------
149
150export type Channels = { banner: boolean; sound: boolean; toast: boolean; outcome: Outcome }
151
152/**
153 * Three tiers, then the quiet mode on top.
154 * Host not in front → banner+sound+toast. In front → banner+toast. In front and just interacted →
155 * toast only (finished/error only: a blocked wait always gets its banner).
156 */
157export function channels(args: {
158  kind: Kind
159  mode: Mode
160  hostFront: boolean
161  interactedRecently: boolean
162  nonTerminal: boolean
163}): Channels {
164  if (args.mode === 'off') return { banner: false, sound: false, toast: true, outcome: 'off' }
165  const looking = args.hostFront && !args.nonTerminal
166  const toastOnly = looking && args.interactedRecently && (args.kind === 'finished' || args.kind === 'error')
167  if (toastOnly) return { banner: false, sound: false, toast: true, outcome: 'toast:looking' }
168  const sound = !looking
169  if (args.mode === 'quiet') return { banner: true, sound: false, toast: true, outcome: sound ? 'quiet' : 'banner' }
170  return { banner: true, sound, toast: true, outcome: 'banner' }
171}
172
173// ---------- turn rule ----------
174
175export type TurnVerdict = 'none' | 'finished' | 'error' | 'gap'
176
177export function turnVerdict(args: {
178  reason: 'answer' | 'aborted' | 'refusal' | 'error'
179  answer: string
180  effectiveMs: number
181  minMs: number
182  startedByUser: boolean
183  sinceLastPingMs: number | null
184  gapMs: number
185}): TurnVerdict {
186  if (!args.startedByUser || args.reason === 'aborted') return 'none'
187  const kind: TurnVerdict | undefined =
188    args.reason === 'error' || args.reason === 'refusal'
189      ? 'error'
190      : args.answer.trim() !== '' && args.effectiveMs >= args.minMs
191        ? 'finished'
192        : undefined
193  if (kind === undefined) return 'none'
194  return args.sinceLastPingMs !== null && args.sinceLastPingMs < args.gapMs ? 'gap' : kind
195}
196
197// ---------- blocked-wait sweep ----------
198
199/**
200 * What the sweep should do now. One outstanding ping per session: while any wait has pinged, no other
201 * wait pings; that wait gets exactly one repeat once it has been open `repeatMs`.
202 */
203export function sweep(waits: readonly NeedsYouWait[], now: number, cfg: { waitMs: number; repeatMs: number }): { ping?: string; repeat?: string } {
204  const out = waits.find(w => w.pinged)
205  if (out !== undefined) return !out.repeated && now - out.startedAt >= cfg.repeatMs ? { repeat: out.id } : {}
206  const due = waits.filter(w => now - w.startedAt >= cfg.waitMs).sort((a, b) => a.startedAt - b.startedAt)[0]
207  return due === undefined ? {} : { ping: due.id }
208}
209
210// ---------- log ----------
211
212export function localStamp(ms: number): string {
213  const d = new Date(ms)
214  const off = -d.getTimezoneOffset()
215  const pad = (n: number) => String(Math.floor(Math.abs(n))).padStart(2, '0')
216  const sign = off >= 0 ? '+' : '-'
217  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(off / 60)}:${pad(off % 60)}`
218}
219
220/** One ping line. Never reply text, never a prompt-derived tag. */
221export function pingLine(f: { at: number; id: string; session: string; project: string; kind: Kind; outcome: Outcome; seconds: number }): string {
222  return [`${f.at}`, 'ping', `id=${f.id}`, `session=#${hash4(f.session)}`, `project=${stripMarkup(f.project)}`, `kind=${f.kind}`, `outcome=${f.outcome}`, `wait=${Math.round(f.seconds)}s`, localStamp(f.at)].join('\t')
223}
224
225export function ackLine(f: { at: number; id: string; session: string; delayMs: number }): string {
226  return [`${f.at}`, 'ack', `id=${f.id}`, `session=#${hash4(f.session)}`, `delay=${Math.round(f.delayMs / 1000)}s`, localStamp(f.at)].join('\t')
227}
228
229function field(parts: readonly string[], name: string): string | undefined {
230  const hit = parts.find(p => p.startsWith(`${name}=`))
231  return hit === undefined ? undefined : hit.slice(name.length + 1)
232}
233
234/** Last 7 days: banners shown, and how many were acted on (an interaction within 3 min). */
235export function summarize(lines: readonly string[], now: number): string {
236  const since = now - 7 * 86_400_000
237  const acks = new Map<string, number>()
238  const pings: { id: string; outcome: string }[] = []
239  for (const line of lines) {
240    const parts = line.split('\t')
241    const at = Number(parts[0])
242    if (!(at >= since)) continue
243    const id = field(parts, 'id')
244    if (id === undefined) continue
245    if (parts[1] === 'ack') acks.set(id, Number((field(parts, 'delay') ?? '').replace('s', '')) * 1000)
246    else if (parts[1] === 'ping') pings.push({ id, outcome: field(parts, 'outcome') ?? '' })
247  }
248  const bannered = pings.filter(p => p.outcome === 'banner' || p.outcome === 'quiet')
249  const acted = bannered.filter(p => (acks.get(p.id) ?? Infinity) <= ACK_WINDOW_MS)
250  const pct = bannered.length === 0 ? 0 : Math.round((acted.length / bannered.length) * 100)
251  return `Last 7 days: ${pings.length} pings, ${bannered.length} banners, ${acted.length} acted on within 3 min (${pct}%).`
252}
253
254// ---------- misc ----------
255
256export function parseFront(stdout: string): string {
257  return /="([^"]+)"/.exec(stdout)?.[1] ?? 'unknown'
258}
259
260export function isUnder(root: string, paths: string, home: string): boolean {
261  return paths
262    .split(',')
263    .map(p => p.trim().replace(/^~(?=\/|$)/, home).replace(/\/+$/, ''))
264    .some(p => p !== '' && (root === p || root.startsWith(`${p}/`)))
265}
266
267/** terminal-notifier reads a leading "-" as an option and a leading "[" as an escape; a hair space defuses both. */
268export const plain = (text: string): string => (/^[-[]/.test(text) ? `\u200A${text}` : text)
269
types/index.d.ts 42 lines
1/** A dialog the user hasn't answered yet. */
2export type NeedsYouWait = {
3  id: string
4  kind: 'permission' | 'plan' | 'question'
5  /** Tool the permission dialog is for; '' for the other kinds. */
6  tool: string
7  /** Dialog type + header, never the full question text. */
8  detail: string
9  startedAt: number
10  /** The one ping for this wait went out. */
11  pinged: boolean
12  /** The single repeat went out. */
13  repeated: boolean
14}
15
16/** The banner awaiting the user's first interaction (for the `ack` log line). */
17export type NeedsYouAck = { id: string; at: number }
18
19/** Mirror of the global quiet mode in $.store, so the band can draw it. */
20export type NeedsYouBand = { mode: 'on' | 'quiet' | 'off'; until: number | null; left: string }
21
22declare module 'claude-code' {
23  interface PluginState {
24    'needs-you': {
25      /** This turn was started by the user; consumed at turn.complete. */
26      userTurn: boolean
27      /** Dialog wait time accumulated during the running turn. */
28      dialogMs: number
29      waits: NeedsYouWait[]
30      lastInteractionAt: number | null
31      /** Last finished/error ping, for the per-session gap. */
32      lastPingAt: number | null
33      band: NeedsYouBand | null
34      /** `/ping name` label. */
35      label: string
36      /** Cached git branch of the session. */
37      branch: string | null
38      ackPending: NeedsYouAck | null
39    }
40  }
41}
42