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…

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.
minTurnSeconds. Errors/refusals always ping. Aborted and thinking-only never. Subagent, peer, plugin turns never.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./ping: on, quiet [dur] (no sound), off [dur] (toast + log only), test, log, name <label>. NEEDS_YOU=off silences one process.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.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.~/.claude/state/needs-you.log (no rotation in v1). Never reply text. Headless sessions write nothing./System/Library/Sounds/Glass.aiff, played with afplay.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.
hooks/logic.ts: pure decisions (tiers, thresholds, message bodies, redaction)hooks/register.tsx: hooks, /ping, band, banner/sound deliverytests/needs-you.test.ts: claude plugin test <this folder>hooks/register.tsx 520 lines1import { 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}
520hooks/logic.ts 269 lines1import 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)
269types/index.d.ts 42 lines1/** 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