SLOPSHOPPER

turn-done-alert

Toasts when a long turn finishes or Claude is waiting on you, and shows a 'working 3m · 14 tools' heartbeat during long turns.

newguardtoaststatustimer
v0.1.0no licenseupdated 2026-10-09zainokta/zstack/mods/turn-done-alert
A shopper browsing a rack in a slop shop
README

turn-done-alert

Tells you when Claude needs you, for when you walk away during a long run.

  • A turn of at least thresholdSeconds (userConfig, default 60) raises a toast on finish: Turn done in 3m 12s · 14 tools (or Turn ended (error) after ...). Interrupted turns stay quiet.
  • An AskUserQuestion call, or a permission prompt (the classic Notification event with permission_prompt), toasts waiting for you: ....
  • Past 2 minutes, a status-line heartbeat working 3m · 14 tools updates every 15 s and clears when the turn ends. Tool counts include subagents' calls.
  • Fail-open: it only observes; nothing it does can block a tool call or a prompt.

Limits: no sound. $.audio.play needs an asset, URL or bytes and plays nothing on a Linux terminal; $.audio.speak needs a platform synthesizer. Toasts stay 30 s but only show on screen. A hot reload mid-turn loses that turn's heartbeat and toast.

Source 1 files
hooks/register.ts 91 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3const HEARTBEAT_AFTER_MS = 120_000
4const TICK_MS = 15_000
5const TOAST_MS = 30_000
6
7// What each classic Notification type means for someone who is not looking.
8const WAITING: Record<string, string> = {
9  permission_prompt: 'waiting for you: permission needed',
10  elicitation_dialog: 'waiting for you: input requested',
11}
12
13export function duration(ms: number): string {
14  const s = Math.round(ms / 1000)
15  if (s < 60) {
16    return `${s}s`
17  }
18  const m = Math.floor(s / 60)
19  return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
20}
21
22export function heartbeat(elapsedMs: number, tools: number): string {
23  return `working ${Math.floor(elapsedMs / 60_000)}m · ${tools} tool${tools === 1 ? '' : 's'}`
24}
25
26// The running turn's bookkeeping. Nothing draws from it, so a hot reload losing
27// it only costs the heartbeat and toast of the turn running at that moment.
28type Live = { startedAt?: number; tools: number; tick?: Timer }
29
30async function beat($: EngineInterface, live: Live): Promise<void> {
31  if (live.startedAt === undefined) {
32    return
33  }
34  const elapsed = (await $.clock.now()) - live.startedAt
35  if (elapsed >= HEARTBEAT_AFTER_MS) {
36    $.ui.status(heartbeat(elapsed, live.tools))
37  }
38}
39
40export const register: Register = (on, options) => {
41  const thresholdMs = Number(options.thresholdSeconds ?? 60) * 1000
42
43  const live: Live = { tools: 0 }
44
45  on('turn.start', async ($, e, next) => {
46    live.startedAt = await $.clock.now()
47    live.tools = 0
48    live.tick?.cancel()
49    live.tick = $.clock.every(TICK_MS, () => {
50      beat($, live).catch(() => {})
51    })
52    return next(e)
53  })
54
55  on('tool.call', ($, e, next) => {
56    if (live.startedAt !== undefined) {
57      live.tools += 1
58    }
59    if (e.tool === 'AskUserQuestion') {
60      $.ui.toast('waiting for you: a question', { timeoutMs: TOAST_MS })
61    }
62    return next(e)
63  }).catch(($, e, next) => next(e))
64
65  on('classic.Notification', ($, e, next) => {
66    const text = WAITING[e.notification_type]
67    if (text !== undefined) {
68      $.ui.toast(text, { timeoutMs: TOAST_MS })
69    }
70    return next(e)
71  }).catch(($, e, next) => next(e))
72
73  on('turn.complete', async ($, e, next) => {
74    // Subagent runs end their own turns inside the main one.
75    if (e.agentId !== undefined) {
76      return next(e)
77    }
78    const count = live.tools
79    live.tick?.cancel()
80    live.tick = undefined
81    live.startedAt = undefined
82    $.ui.status(undefined)
83
84    if (!e.isAborted && e.durationMs >= thresholdMs) {
85      const verb = e.reason === 'answer' ? 'Turn done in' : `Turn ended (${e.reason}) after`
86      $.ui.toast(`${verb} ${duration(e.durationMs)} · ${count} tool${count === 1 ? '' : 's'}`, { timeoutMs: TOAST_MS })
87    }
88    return next(e)
89  })
90}
91