Raises the alarm when a long-running session stalls while you are away: a permission dialog or a question that has waited for you past a threshold (toast, Mac…

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.
Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, vm_stat, ioreg, ps).
git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods
A pane of your project's running dev servers, so you don't have to ask Claude to restart them — and a tool so Claude doesn't have to run lsof to find out what's listening.
/servers opens the Servers pane:package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile~/.claude/dev-servers/<project>/.dev_servers tool: this project's running servers (name, port, pid, uptime, and the log when it was started from the pane), the start commands that aren't running, and with all: true every other listener on the machine (port, process). Its description tells Claude to use it instead of lsof/netstat/ps. It never starts or stops anything.Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).servers: :4000 :5173.netstat -anv -p tcp (macOS) or ss -ltnp (Linux), never a bare lsof; ps and lsof -d cwd run once per new pid, one pid at a time. One open session scans per interval (15s by default) and writes ~/.claude/dev-servers/scan-cache.json for the others; a slow scan doubles the interval, up to 5 minutes.Puts files you just downloaded into your prompt with one click.
~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].@"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files./downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).
Sets effort per message, so you don't have to switch it by hand.
avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included./route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.effort: low (routine).Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).
A Jobs pane for long-running work: training runs, downloads, extractions — and three tools so Claude follows them instead of polling.
nohup … > log & launches by itself./watch <log> [label] adds any other log file./ and /Volumes/* (the NAS).jobs: 2 running · 1 stalled./jobs opens the pane, /unwatch <label|id|done|all> removes jobs.jobs_list (every watched job's state, pid, progress, ETA, last-output age, exit code and log path), jobs_watch ({ pid?, log?, name?, quietMinutes? }: follow a process or log started elsewhere; it never starts commands) and jobs_unwatch ({ name }). The first message of each conversation says jobs are watched and names them, so Claude stops writing until … sleep loops and running ps. They need no permission prompt: they only read the mod's own list.tail, checks processes with ps (one listing, or a per-pid lookup when only pids are watched), and runs df. A probe that times out or takes over 2 s is retried at a doubling interval, up to 5 minutes, until it is fast again.Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.
Memory, swap and GPU on the status line, a machine_status tool for Claude, and a guard that refuses heavy local jobs when the Mac can't take them.
RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%, and CPU 4/16 cores whenever macOS has parked the performance cores. "Free" is unused memory (vm_stat's free + inactive + speculative pages): what a new job can take without pushing anyone out. macOS's own "memory free percentage" counts active pages too, which is how a Mac can be "55% free" with the swap nearly full.sysctl calls and vm_stat), every 15 s by default; the GPU (ioreg) on every third sample, never right after a slow one. One session samples and writes ~/.claude/machine-guard/sample.json (with a lock.json while it probes); every other session shows the same numbers instead of probing too. When probes are slow or time out (10–40 s while the Mac swaps), the interval doubles up to 2 min and resets once they are fast again.kern.sched_recommended_cores, the bitmask of CPUs the scheduler may use. When macOS's performance controller parks the performance cores (after a thermal event, or stuck until a sleep/wake or reboot), the status line shows CPU 4/16 cores, machine_status and /guard report it with the parked count, you get one toast once at least half the cores have been parked for a minute, or any parking has lasted five minutes (masks blip for seconds under GPU load), and one when they come back, and Claude gets a note when it starts heavy work so it keeps worker counts small. The guard never blocks on it.ps, the slowest probe) is taken only on demand (/guard, machine_status) or when a sample shows pressure, at most once a minute; "biggest" means the apps from a table taken in the last 5 minutes./busy.When memory is only tight (under 20% free, or kernel level 2), the job runs and Claude gets a note to start one heavy job at a time.
machine_status is a tool Claude can call (no input, allowed without asking): the sample's age, RAM free, swap, GPU, the pressure level, the biggest apps (or how to get a table), the guard's verdict and its rules. Claude is told to use it instead of ps, top, vm_stat, memory_pressure or project scripts before launching heavy work./busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``/guard shows what it sees (and takes a process table when one is due). /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.modal run, ssh), tests (pytest) and installs are never treated as heavy.overnight_|nightly_run\.sh.Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.
next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast./second-opinion, /recall ask) and file writes (folders only, never contents)./mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it./mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read./mods failures [7d]: failures and failed subprocesses only.~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.$.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged./secrets records there) are watched for failures and slow runs, but not counted per run.Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).
Keeps an eye on Modal so idle GPU containers don't burn credits — in the projects that use Modal, with one CLI run per minute for the whole machine.
.py file with import modal / from modal import … (one rg run, 5 s at most), or modal named in pyproject.toml, requirements*.txt, uv.lock or poetry.lock. The answer is remembered per project for a day. Running /modal, or any modal … command in Bash, switches it on for the session; alwaysPoll (off) watches every project.~/.claude/modal-meter/lock.json runs modal app list --json every pollSeconds (60) and writes cache.json; the other open sessions read the cache and refresh their own status line and pane from it. Once one session learns the CLI has no billing report, none tries it again for a day.app list over 2 s or a timeout doubles the interval (up to 10 minutes); a fast answer brings it back.Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.alertMinutes (30), repeated at most every 30 minutes./modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, never retries one that isn't there, and stays silent when Modal isn't set up.Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.
gh pr create Claude runs. It polls gh pr view every 60s.PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.--force, never other branches.gh pr checks and the tail of the failed log attached, so you don't paste it.prs_status (number, title, state, branch, CI, URL, and how old the data is), instead of running gh pr view or gh pr checks itself: no API call, and it refreshes first when the data is older than a poll.Resource not accessible by personal access token), it tells you once with the fix (gh auth login --web, or grant the token "Checks: read"), keeps polling the PRs without their checks so merges are still handled, shows them as #219 CI?, and tries the checks again after 30 minutes or when you run /prs. If gh can't read PRs at all (not logged in), the status line says PRs: gh can't read them and nothing is polled until then. A GitHub rate limit pauses polling for 5 minutes; any other failure doubles the wait up to 10 minutes, back to normal on the next success. Nothing retries every minute forever; the debug log gets one line per change./prs lists watched PRs and asks gh again at once. /prs watch <n|url> and /prs forget <n|all> add and remove them.gh and git, at about one GitHub API call per open PR per minute.Settings:
pollSeconds (60)authRetryMinutes (30): how long before the checks are tried again after gh refused them over auth.attachCiLogs (on)logLines (120)deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.It never closes issues; put "Closes #N" in PR bodies for that.
Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.
/remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.grep and cat are kept but ranked low.search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled./goal (or the Stop hook's condition), open tasks with a count of the done ones, the questions you asked with the first line of each answer, the files touched, the commits made and PRs opened. After a compaction it goes back into Claude's context as Ledger (this session, before compaction) (about 40 lines at most), so you don't have to say "don't lose track of where we were". /recall ledger prints it any time; /clear starts a fresh one. It is written to ~/.claude/recall/ledger/<session>.json (readable only by you, secrets masked) so a reload keeps it./recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>./recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it./recall ledger prints this session's ledger (above)./recall timeline [7d|30d|90d] [all]/recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]/recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model./recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help./remember <fact>, /remember list, /remember forget <ref>.Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].#214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.~/.claude/recall/index.db, readable only by you, and never goes in a repo. The ledgers beside it are kept the same way.~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update. The ledger is masked the same way before it is written or shown./recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally at session start, every 10 minutes and at session end — but only when no other session finished an update in the last 10 minutes (freshMinutes): with several sessions open, one update serves them all, and the engine leaves ~/.claude/recall/last-update.json to say so. Updates run under nice -n 19, so a loaded Mac stays responsive; searches don't. /recall reindex always runs./usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), freshMinutes (10; 0 never skips), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).
Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".
owner, todo, P0 or blockedorigin/HEAD points to, else origin/main, origin/master, main, master, else the branch you are on — found with one git for-each-ref read, so a clone made without origin/HEAD costs no failing command.main ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it.PRs: gh token can't read them in their place, keeps the issues it could read, and leaves gh alone for 30 minutes instead of failing at every refresh. /brief asks again at once./brief re-gathers now and prints the full summary.git and gh. The band refreshes after a turn at most every 2 minutes.Settings: focus labels, refresh minutes, and whether to brief Claude.
Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.
routine: daily-report · waiting on you 3m — but only once the wait has stood 20 s (waitToastAfterSeconds). In auto mode the engine decides most permission asks by itself within a few seconds; those are never reported or counted. The notification, the status line and the wait count all change at that same moment, once per wait.AskUserQuestion is always for you, so it is reported at once.Routine daily-report finished after 23m · waited on you 2 times (the waits you were told of).notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.allowWebReads (off by default): lets routines uhooks/register.ts 356 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { StallSession, StallSettings, StallWait } from '../types'
4import {
5 alarmTitle,
6 classifyError,
7 commandArgv,
8 describeCall,
9 describeStalls,
10 dueAlarms,
11 errorMessage,
12 firstQuestion,
13 folderName,
14 isRoutinePrompt,
15 osascriptArgv,
16 statusLine,
17 waitMessage,
18} from './stall'
19
20type Engine = EngineInterface
21
22const SESSION = { plugin: 'stall-alarm', key: 'session' } as const
23const FRESH: StallSession = { isDecided: false, isRoutine: false, isOff: false, folder: '', waiting: {}, alarms: [] }
24const DEFAULTS: StallSettings = {
25 stallMinutes: 5,
26 repeatMinutes: 30,
27 notifyMac: true,
28 notifyCommand: '',
29 alarmOnAnyError: false,
30 skipRoutines: true,
31}
32
33/** Prompts that never count as the session's first: machine-injected ones. */
34const NOT_FIRST = new Set(['plugin', 'task-notification', 'observer', 'observer-activity'])
35/** How often the open waits are held against the threshold and the status line refreshed. */
36const TICK_MS = 15_000
37/** How long a notification command may take. */
38const NOTIFY_TIMEOUT_MS = 15_000
39/** How many alarms /stalls remembers. */
40const KEPT_ALARMS = 10
41
42let config: StallSettings = DEFAULTS
43/**
44 * The session, kept here and changed synchronously so concurrent hooks never
45 * race; mirrored to `$.state` only so a hot reload can pick it up again.
46 */
47let memory: StallSession = FRESH
48let restoring: Promise<void> | null = null
49let ticker: { cancel: () => void } | null = null
50/** The status line last shown, so an unchanged line is not set again. */
51let shownStatus: string | undefined = undefined
52/**
53 * The error that ended the turn under way, as the first of the classic
54 * StopFailure hook and turn.complete to hear of it recorded it; the other then
55 * raises no second alarm. Cleared when the next turn starts.
56 */
57let failed: { isAlarmed: boolean } | null = null
58
59const stallMs = (): number => Math.round(config.stallMinutes * 60_000)
60const repeatMs = (): number => Math.round(config.repeatMinutes * 60_000)
61
62/** A minutes option: a finite number of at least `least`, else the default. */
63const minutesOption = (value: unknown, fallback: number, least: number): number =>
64 typeof value === 'number' && Number.isFinite(value) && value >= least ? value : fallback
65
66/** Whether this session is left alone: a scheduled routine, which routine-watch watches. */
67const isQuiet = (session: StallSession): boolean => config.skipRoutines && session.isRoutine
68
69async function debug($: Engine, line: string) {
70 try {
71 await $.ui.log(`stall-alarm: ${line}`, { to: 'debug' })
72 } catch {
73 // The debug log is best effort.
74 }
75}
76
77async function restore($: Engine) {
78 try {
79 const { value } = await $.state.get(SESSION)
80 if (value && !memory.isDecided) {
81 memory = value
82 }
83 } catch (error) {
84 await debug($, `could not restore the session: ${String(error)}`)
85 }
86}
87
88/** The session as known, restored once after a reload. */
89async function load($: Engine): Promise<StallSession> {
90 restoring ??= restore($)
91 await restoring
92 return memory
93}
94
95/** Takes the new value at once, then mirrors it for a reload. */
96async function save($: Engine, value: StallSession) {
97 memory = value
98 try {
99 await $.state.set(SESSION, value)
100 } catch (error) {
101 await debug($, `could not keep the session: ${String(error)}`)
102 }
103}
104
105async function showStatus($: Engine) {
106 const session = await load($)
107 const line = session.isOff || isQuiet(session) ? undefined : statusLine(session.waiting, await $.clock.now(), stallMs())
108 if (line !== shownStatus) {
109 shownStatus = line
110 $.ui.status(line)
111 }
112}
113
114function stopTicker() {
115 ticker?.cancel()
116 ticker = null
117}
118
119function ensureTicker($: Engine) {
120 if (!ticker) {
121 ticker = $.clock.every(TICK_MS, () => void tick($))
122 }
123}
124
125/** Runs one notification command; a failure goes to the debug log, never to the session. */
126async function runQuietly($: Engine, argv: readonly string[]) {
127 try {
128 const out = await $.process.run(argv, { timeoutMs: NOTIFY_TIMEOUT_MS })
129 if (out.exitCode !== 0) {
130 await debug($, `${argv[0] ?? 'command'} exited ${out.exitCode}: ${out.stderr.trim().slice(0, 200)}`)
131 }
132 } catch (error) {
133 await debug($, `${argv[0] ?? 'command'} failed: ${String(error)}`)
134 }
135}
136
137/** The Mac notification and the person's push command, run side by side. */
138async function sendOut($: Engine, title: string, message: string) {
139 const jobs: Promise<void>[] = []
140 if (config.notifyMac) {
141 jobs.push(runQuietly($, osascriptArgv(title, message)))
142 }
143 const push = commandArgv(config.notifyCommand, { title, message })
144 if (push) {
145 jobs.push(runQuietly($, push))
146 }
147 await Promise.all(jobs)
148}
149
150/**
151 * Raises one alarm: a toast now, the notifications in the background (so the
152 * dialog Claude waits on is never held up by them), and a line for /stalls.
153 */
154async function alarm($: Engine, head: string, message: string) {
155 const now = await $.clock.now()
156 await save($, { ...memory, alarms: [...memory.alarms, { at: now, message }].slice(-KEPT_ALARMS) })
157 $.ui.toast(message, { timeoutMs: 10_000 })
158 void sendOut($, alarmTitle(head, memory.folder), message)
159}
160
161/** Alarms for each open wait that is due one at `now`: first at the threshold, then once per repeat period. */
162async function alarmStalls($: Engine, now: number) {
163 const session = await load($)
164 if (session.isOff) {
165 return
166 }
167 for (const id of dueAlarms(session.waiting, now, stallMs(), repeatMs())) {
168 const wait = memory.waiting[id]
169 if (!wait) {
170 continue
171 }
172 const alarmed: StallWait = { ...wait, alarmedAt: now, alarms: wait.alarms + 1 }
173 await save($, { ...memory, waiting: { ...memory.waiting, [id]: alarmed } })
174 await alarm($, 'Claude is waiting', waitMessage(wait, now))
175 }
176}
177
178async function tick($: Engine) {
179 await alarmStalls($, await $.clock.now())
180 await showStatus($)
181 if (Object.keys(memory.waiting).length === 0) {
182 stopTicker()
183 }
184}
185
186/** Opens a wait for one tool call, once per call; the ticker holds it against the threshold. */
187async function beginWait($: Engine, id: string, kind: StallWait['kind'], label: string) {
188 const now = await $.clock.now()
189 const session = await load($)
190 if (isQuiet(session) || session.waiting[id] !== undefined) {
191 return
192 }
193 const wait: StallWait = { since: now, kind, label, alarmedAt: null, alarms: 0 }
194 await save($, { ...memory, waiting: { ...memory.waiting, [id]: wait } })
195 ensureTicker($)
196}
197
198/** Closes the waits named (every open one when none are named). */
199async function endWaits($: Engine, ids?: readonly string[]) {
200 const session = await load($)
201 const open = Object.keys(session.waiting)
202 const closing = ids ? open.filter(id => ids.includes(id)) : open
203 if (closing.length === 0) {
204 return
205 }
206 const waiting = { ...memory.waiting }
207 for (const id of closing) {
208 delete waiting[id]
209 }
210 await save($, { ...memory, waiting })
211 if (Object.keys(waiting).length === 0) {
212 stopTicker()
213 }
214 await showStatus($)
215}
216
217/**
218 * A turn stopped on an error, as the classic StopFailure hook or turn.complete
219 * hears of it, whichever first: alarms once per turn when the error is one the
220 * person must act on (login, limits, billing), or any error with alarmOnAnyError.
221 */
222async function stopped($: Engine, text: string, kind: string | null) {
223 const session = await load($)
224 if (isQuiet(session) || session.isOff) {
225 return
226 }
227 const known = failed ?? { isAlarmed: false }
228 failed = known
229 if (known.isAlarmed || (!classifyError(text, kind).isKnown && !config.alarmOnAnyError)) {
230 return
231 }
232 known.isAlarmed = true
233 await alarm($, 'Claude stopped', errorMessage(text, kind))
234}
235
236export const register: Register = (on, options) => {
237 config = {
238 stallMinutes: minutesOption(options.stallMinutes, DEFAULTS.stallMinutes, 0.25),
239 repeatMinutes: minutesOption(options.repeatMinutes, DEFAULTS.repeatMinutes, 0),
240 notifyMac: options.notifyMac !== false,
241 notifyCommand: typeof options.notifyCommand === 'string' ? options.notifyCommand : '',
242 alarmOnAnyError: options.alarmOnAnyError === true,
243 skipRoutines: options.skipRoutines !== false,
244 }
245 memory = FRESH
246 restoring = null
247 ticker = null
248 shownStatus = undefined
249 failed = null
250
251 on('session.start', async ($, e, next) => {
252 await save($, { ...(await load($)), folder: folderName(e.cwd) })
253 await $.command.register({
254 name: 'stalls',
255 description: 'What Claude is waiting on you for and for how long, the last alarms, the settings; off|on for the session',
256 argumentHint: '[off|on]',
257 immediate: true,
258 })
259 return next(e)
260 })
261
262 // The session's first prompt decides whether it is a routine run, left to routine-watch.
263 on('prompt.submit', async ($, e, next) => {
264 try {
265 const known = await load($)
266 if (!known.isDecided && !NOT_FIRST.has(e.origin.kind) && !memory.isDecided) {
267 await save($, { ...memory, isDecided: true, isRoutine: isRoutinePrompt(e.text, e.origin.kind) })
268 }
269 } catch (error) {
270 await debug($, `could not read the first prompt: ${String(error)}`)
271 }
272 return next(e)
273 })
274
275 // A permission dialog is a wait from the ask until the call runs, is refused, or the turn ends.
276 on('tool.check', async ($, e, next) => {
277 const verdict = await next(e)
278 // A query (`$.tool.check`) carries no call id: nothing waits on it.
279 if (verdict.decision === 'ask' && e.tool_use_id !== undefined) {
280 await beginWait($, e.tool_use_id, 'ok', describeCall(e.tool, e.input))
281 }
282 return verdict
283 })
284
285 // A question for the person is a wait too; every call's end closes its wait.
286 on('tool.call', async ($, e, next) => {
287 const id = e.tool_use_id
288 if (id === undefined) {
289 return next(e)
290 }
291 if (e.tool === 'AskUserQuestion') {
292 await beginWait($, id, 'question', firstQuestion(e))
293 }
294 try {
295 return await next(e)
296 } finally {
297 await endWaits($, [id])
298 }
299 })
300
301 on('turn.start', ($, e, next) => {
302 failed = null
303 return next(e)
304 })
305
306 // The API error that ended a turn, with its text; a subagent's is the main loop's soon enough.
307 on('classic.StopFailure', async ($, e, next) => {
308 if (e.agent_id === undefined) {
309 await stopped($, e.error_details ?? e.last_assistant_message ?? '', e.error)
310 }
311 return next(e)
312 })
313
314 on('turn.complete', async ($, e, next) => {
315 const done = await next(e)
316 if (e.agentId === undefined) {
317 await endWaits($)
318 if (e.reason === 'error') {
319 await stopped($, e.answer, null)
320 }
321 }
322 return done
323 })
324
325 on('session.end', async ($, e, next) => {
326 stopTicker()
327 const session = await load($)
328 // A /clear goes on in this process as a fresh conversation, decided again by its first prompt.
329 if (session.isDecided || Object.keys(session.waiting).length > 0) {
330 await save($, { ...FRESH, folder: session.folder })
331 }
332 if (shownStatus !== undefined) {
333 shownStatus = undefined
334 $.ui.status(undefined)
335 }
336 failed = null
337 return next(e)
338 })
339
340 on('command.run', { command: 'stalls' }, async ($, e) => {
341 const session = await load($)
342 const word = e.args.trim().toLowerCase()
343 if (word === 'off' || word === 'on') {
344 await save($, { ...session, isOff: word === 'off' })
345 await showStatus($)
346 return {
347 text: word === 'off' ? 'stall-alarm: alarms are off for this session; /stalls on turns them back on.' : 'stall-alarm: alarms are on.',
348 }
349 }
350 if (word !== '') {
351 return { text: 'Usage: /stalls, /stalls off, /stalls on' }
352 }
353 return { text: describeStalls(session, config, await $.clock.now()) }
354 })
355}
356hooks/stall.ts 234 lines1import type { StallSession, StallSettings, StallWait } from '../types'
2
3/** The tag a scheduled task's prompt carries; routine-watch's own test for a routine run. */
4const TAG = /<scheduled-task\b/
5
6/** Control characters (C0, DEL) and the Unicode line and paragraph separators. */
7const isControl = (code: number): boolean => code < 0x20 || code === 0x7f || code === 0x2028 || code === 0x2029
8
9/** Collapses runs of whitespace and control characters to one space. */
10const oneLine = (text: string): string =>
11 Array.from(text, char => (isControl(char.charCodeAt(0)) ? ' ' : char))
12 .join('')
13 .replace(/\s+/g, ' ')
14 .trim()
15
16/** Cuts text to `max` characters, marking the cut with an ellipsis. */
17export const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max - 1)}…`)
18
19/**
20 * Whether the session's first prompt starts a scheduled routine, decided the
21 * way routine-watch decides it: the scheduled-task tag in the text, or a prompt
22 * the engine says a schedule fired. Those sessions are routine-watch's to watch.
23 */
24export const isRoutinePrompt = (text: string, originKind: string): boolean =>
25 TAG.test(text) || originKind === 'scheduled-trigger'
26
27/**
28 * `text` as an AppleScript string literal: backslashes and double quotes
29 * escaped, control characters and line breaks turned into spaces.
30 */
31export const appleScriptString = (text: string): string =>
32 `"${oneLine(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
33
34/** The argv that posts a macOS notification with a sound; argv, so no shell reads it. */
35export const osascriptArgv = (title: string, message: string): string[] => [
36 'osascript',
37 '-e',
38 `display notification ${appleScriptString(clip(message, 240))} with title ${appleScriptString(clip(title, 100))} sound name "Glass"`,
39]
40
41/**
42 * The argv for the person's push command: the template split on whitespace,
43 * then `{title}` and `{message}` filled into the words that name them, so each
44 * value stays inside one argument whatever it holds. Null when no command is set.
45 */
46export const commandArgv = (template: string, values: { title: string; message: string }): string[] | null => {
47 const words = template.trim().split(/\s+/).filter(word => word !== '')
48 if (words.length === 0) {
49 return null
50 }
51 return words.map(word =>
52 word.replace(/\{(title|message)\}/g, (_all, key: string) => (key === 'title' ? values.title : values.message)),
53 )
54}
55
56/** The host a URL names, without `www.`; the URL itself, cut short, when it names none. */
57export const hostOf = (url: string): string => {
58 const match = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?#]*@)?(\[[^\]]*\]|[^:/?#]*)/i.exec(url.trim())
59 const host = match?.[1]?.toLowerCase().replace(/^www\./, '') ?? ''
60 return host || clip(oneLine(url), 60)
61}
62
63const fileName = (path: string): string => path.replace(/\/+$/, '').split('/').pop() || path
64
65const field = (input: unknown, key: string): string | null => {
66 if (typeof input !== 'object' || input === null) {
67 return null
68 }
69 const value = (input as Record<string, unknown>)[key]
70 return typeof value === 'string' && value.trim() !== '' ? value : null
71}
72
73/** A short label for a tool call: the tool and what it touches (`Bash git push origin main`). */
74export const describeCall = (tool: string, input: unknown): string => {
75 const url = field(input, 'url')
76 const query = field(input, 'query')
77 const command = field(input, 'command')
78 const path = field(input, 'file_path') ?? field(input, 'notebook_path') ?? field(input, 'path')
79 const detail = url
80 ? hostOf(url)
81 : query
82 ? `"${clip(oneLine(query), 60)}"`
83 : command
84 ? clip(oneLine(command), 60)
85 : path
86 ? fileName(path)
87 : ''
88 return detail ? `${tool} ${detail}` : tool
89}
90
91/** The first question an AskUserQuestion call puts to the person, cut short. */
92export const firstQuestion = (input: unknown): string => {
93 const questions = typeof input === 'object' && input !== null ? (input as { questions?: unknown }).questions : undefined
94 const first: unknown = Array.isArray(questions) ? questions[0] : undefined
95 const text = field(first, 'question')
96 return text ? clip(oneLine(text), 120) : 'a question'
97}
98
99/** A duration as people say it: `45s`, `23m`, `1h05m`. */
100export const formatDuration = (ms: number): string => {
101 const seconds = Math.max(0, Math.floor(ms / 1000))
102 if (seconds < 60) {
103 return `${seconds}s`
104 }
105 const minutes = Math.floor(seconds / 60)
106 if (minutes < 60) {
107 return `${minutes}m`
108 }
109 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
110}
111
112/** The last part of a folder's path (`example-app`); '' for none. */
113export const folderName = (cwd: string): string => cwd.replace(/\/+$/, '').split('/').pop() ?? ''
114
115/** The notification's title: what happened, and which project when known, since several sessions run at once. */
116export const alarmTitle = (head: string, folder: string): string => (folder ? `${head} · ${folder}` : head)
117
118const forWhat = (wait: StallWait): string => (wait.kind === 'ok' ? 'your OK' : 'your answer')
119
120/** `Claude has been waiting 5m for your OK: Bash git push origin main`. */
121export const waitMessage = (wait: StallWait, now: number): string =>
122 `Claude has been waiting ${formatDuration(now - wait.since)} for ${forWhat(wait)}: ${wait.label}`
123
124/**
125 * The waits due an alarm at `now`, oldest first: past the threshold and never
126 * alarmed, or alarmed a whole repeat period ago (never again when `repeatMs` is 0).
127 */
128export const dueAlarms = (waiting: Record<string, StallWait>, now: number, stallMs: number, repeatMs: number): string[] =>
129 Object.entries(waiting)
130 .filter(([, wait]) =>
131 wait.alarmedAt === null ? now - wait.since >= stallMs : repeatMs > 0 && now - wait.alarmedAt >= repeatMs,
132 )
133 .sort(([, a], [, b]) => a.since - b.since)
134 .map(([id]) => id)
135
136/** The open wait that began first, or null. */
137export const oldestWait = (waiting: Record<string, StallWait>): StallWait | null => {
138 let oldest: StallWait | null = null
139 for (const wait of Object.values(waiting)) {
140 if (!oldest || wait.since < oldest.since) {
141 oldest = wait
142 }
143 }
144 return oldest
145}
146
147/** `waiting on you 7m` once the oldest open wait is past the threshold; nothing before. */
148export const statusLine = (waiting: Record<string, StallWait>, now: number, stallMs: number): string | undefined => {
149 const wait = oldestWait(waiting)
150 return wait && now - wait.since >= stallMs ? `waiting on you ${formatDuration(now - wait.since)}` : undefined
151}
152
153/** Errors that stop a run until the person acts, in the words the API and the CLI use. */
154const KNOWN_ERROR = /OAuth|re-authenticate|\/login|not logged in|usage limit|rate limit|credit balance|overloaded|billing/i
155const LOGIN_ERROR = /OAuth|re-authenticate|\/login|not logged in/i
156const BILLING_ERROR = /credit balance|billing/i
157/** The same errors as Claude Code classifies them (a StopFailure's `error`). */
158const LOGIN_KINDS = new Set(['authentication_failed', 'oauth_org_not_allowed'])
159const BILLING_KINDS = new Set(['billing_error', 'account_on_hold'])
160const OTHER_KNOWN_KINDS = new Set(['rate_limit', 'overloaded', 'verification_required'])
161
162export type ErrorClass = {
163 /** Whether it is one the person must act on, so it alarms without alarmOnAnyError. */
164 isKnown: boolean
165 /** What fixes it, when known: `run /login`, `check billing`. */
166 fix: string | null
167}
168
169/** What an error that ended a turn is: its text, and its kind when the engine classified it. */
170export const classifyError = (text: string, kind: string | null): ErrorClass => {
171 const fix =
172 LOGIN_ERROR.test(text) || (kind !== null && LOGIN_KINDS.has(kind))
173 ? 'run /login'
174 : BILLING_ERROR.test(text) || (kind !== null && BILLING_KINDS.has(kind))
175 ? 'check billing'
176 : null
177 const isKnown = fix !== null || KNOWN_ERROR.test(text) || (kind !== null && OTHER_KNOWN_KINDS.has(kind))
178 return { isKnown, fix }
179}
180
181/** `Claude stopped: <the error's first line>`, and the fix when known, unless the line already says to log in. */
182export const errorMessage = (text: string, kind: string | null): string => {
183 const { fix } = classifyError(text, kind)
184 const line = clip(oneLine(text.split(/\r?\n/).find(part => part.trim() !== '') ?? ''), 160)
185 const said = line || (kind ? kind.replace(/_/g, ' ') : 'its turn ended on an error')
186 const isSaid = fix === 'run /login' && /\/login/i.test(said)
187 return `Claude stopped: ${said}${fix !== null && !isSaid ? ` · ${fix}` : ''}`
188}
189
190const times = (count: number): string => (count === 1 ? '1 time' : `${count} times`)
191
192/** The settings as one line, the push command's text left out (it may carry a private topic). */
193export const describeSettings = (settings: StallSettings): string =>
194 [
195 `stall after ${formatDuration(settings.stallMinutes * 60_000)}`,
196 settings.repeatMinutes > 0 ? `repeat every ${formatDuration(settings.repeatMinutes * 60_000)}` : 'no repeat',
197 `Mac notifications ${settings.notifyMac ? 'on' : 'off'}`,
198 `phone push ${settings.notifyCommand.trim() ? 'on' : 'off'}`,
199 `any error ${settings.alarmOnAnyError ? 'on' : 'off'}`,
200 settings.skipRoutines ? 'routines skipped' : 'routines watched too',
201 ].join(' · ')
202
203/** What /stalls prints: the open waits with their ages, the last alarms, the settings. */
204export const describeStalls = (session: StallSession, settings: StallSettings, now: number): string => {
205 const lines: string[] = []
206 if (session.isRoutine && settings.skipRoutines) {
207 lines.push('Routine session: left to routine-watch (skipRoutines on).')
208 }
209 if (session.isOff) {
210 lines.push('Alarms are off for this session; /stalls on turns them back on.')
211 }
212 const open = Object.values(session.waiting).sort((a, b) => a.since - b.since)
213 if (open.length === 0) {
214 lines.push('No open waits.')
215 } else {
216 lines.push(
217 'Open waits:',
218 ...open.map(
219 wait =>
220 `- ${formatDuration(now - wait.since)} for ${forWhat(wait)}: ${wait.label}` +
221 (wait.alarms > 0 ? ` · alarmed ${times(wait.alarms)}` : ''),
222 ),
223 )
224 }
225 const alarms = session.alarms.slice(-5).reverse()
226 if (alarms.length === 0) {
227 lines.push('No alarms yet.')
228 } else {
229 lines.push('Last alarms:', ...alarms.map(alarm => `- ${formatDuration(now - alarm.at)} ago · ${alarm.message}`))
230 }
231 lines.push(`Settings: ${describeSettings(settings)}`)
232 return lines.join('\n')
233}
234types/index.d.ts 50 lines1/** One tool call Claude is stopped on: a permission dialog (`ok`) or a question for the person (`question`). */
2export type StallWait = {
3 /** When the wait began, in `$.clock.now()` milliseconds. */
4 since: number
5 kind: 'ok' | 'question'
6 /** What it waits on: `Bash git push origin main`, or the question asked. */
7 label: string
8 /** When its last alarm fired; null until the first. */
9 alarmedAt: number | null
10 /** How many alarms it has raised. */
11 alarms: number
12}
13
14/** One alarm raised in this session, as /stalls lists them. */
15export type StallAlarm = {
16 at: number
17 message: string
18}
19
20/** What the session is and what it waits on. */
21export type StallSession = {
22 /** Decided by the first prompt: whether this is a scheduled routine run. */
23 isDecided: boolean
24 isRoutine: boolean
25 /** `/stalls off` for this session. */
26 isOff: boolean
27 /** The name of the folder the session runs in, for the notification's title; '' when unknown. */
28 folder: string
29 /** The waits still open, by tool_use_id. */
30 waiting: Record<string, StallWait>
31 /** The last alarms, oldest first, at most 10. */
32 alarms: StallAlarm[]
33}
34
35/** The plugin's settings, as /stalls describes them. */
36export type StallSettings = {
37 stallMinutes: number
38 repeatMinutes: number
39 notifyMac: boolean
40 notifyCommand: string
41 alarmOnAnyError: boolean
42 skipRoutines: boolean
43}
44
45declare module 'claude-code' {
46 interface PluginState {
47 'stall-alarm': { session: StallSession }
48 }
49}
50