SLOPSHOPPER

stall-alarm

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…

newguardcommandtoaststatusprompt
★ 1v0.1.0no licenseupdated 2026-10-12joeldg/claude-mods/stall-alarm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stall-alarm
› 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 › /stalls ⎿ stall-alarm: No open waits. ⎿ stall-alarm: No alarms yet. ⎿ stall-alarm: Settings: stall after 5m · repeat every 30m · Mac notifications on · phone push off · any error off · routines ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

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

dev-servers

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:
  • running servers whose working folder is in this repo: name, port, pid and uptime, with Restart, Stop and Log
  • known start commands that aren't running, with Start: package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile
  • a count of other listeners on the machine
  • Only button presses start or stop anything:
  • Start runs the command detached, logging to ~/.claude/dev-servers/<project>/.
  • Stop sends SIGTERM. If the server ignores it, pressing again within 10s force-stops it.
  • Restart stops the server, waits for the port to free up, then starts it.
  • Before any signal, it checks the pid still runs the same command.
  • Claude gets a read-only 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.
  • When a command fails with "address already in use", Claude gets a note (and you a toast) naming the holder, e.g. Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).
  • Status line: servers: :4000 :5173.
  • Makes no model calls. The port scan is 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.

downloads-drop

Puts files you just downloaded into your prompt with one click.

  • Watches ~/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].
  • Attach puts @"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files.
  • Waits until a file has finished downloading (skips partial downloads and files still growing), and ignores hidden and zero-byte files.
  • /downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.
  • Stacks with other mods' bands (repo-brief, standing-orders, secret-guard) instead of hiding them.
  • Makes no model calls.

Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).

effort-router

Sets effort per message, so you don't have to switch it by hand.

  • Git chores ("merged", "#219 merged", "commit and push", "push it", "open a PR", "close the issue") run at low effort and come back faster.
  • Deep asks (audit, review, plan, design, investigate, root cause, "why does…", "figure out") run at max.
  • Everything else, including approvals like "yes", "go ahead" and "continue" and anything that starts new work ("merged 219, go ahead with #214"), keeps your session's own effort.
  • Prompt cache: changing effort makes the whole conversation get cached again. So it never switches mid-turn, raises effort at once, and over a large, warm cache lowers it only after 2 routine turns in a row. In small contexts, or once the cache has lapsed, it switches right away.
  • Model guard: set 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.
  • Status line while a turn is routed: effort: low (routine).
  • Makes no model calls.

Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).

job-watch

A Jobs pane for long-running work: training runs, downloads, extractions — and three tools so Claude follows them instead of polling.

  • Picks up background Bash tasks and detached nohup … > log & launches by itself.
  • /watch <log> [label] adds any other log file.
  • Shows progress, ETA and the last log line, and flags a job as stalled when its log goes quiet; a background task's exit code is read from the end of its output.
  • Shows free space on / and /Volumes/* (the NAS).
  • Toasts when a job finishes or stalls. The status line shows jobs: 2 running · 1 stalled.
  • /jobs opens the pane, /unwatch <label|id|done|all> removes jobs.
  • Tools for Claude: 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.
  • Makes no model calls: it reads logs with 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.

machine-guard

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.

  • Status line: 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.
  • It reads the kernel's counters only (two 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.
  • Parked cores: every sample also reads 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.
  • The process table (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.
  • It refuses heavy jobs (training, inference, rendering, extraction, Blender, Docker, ffmpeg) when:
  • macOS reports critical memory pressure (kernel level 4, or under 10% free with the swap 95% full), or
  • the Mac is reserved with /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.
  • Remote runs (modal run, ssh), tests (pytest) and installs are never treated as heavy.
  • Add your own heavy commands with the "Also heavy" setting (a regex), e.g. overnight_|nightly_run\.sh.

mod-monitor

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.

  • Failures: any mod hook that throws, times out or rejects, read from the hook chain's results (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.
  • What each mod did: its toasts ("#219 merged → …", "Blocked: …"), status-line changes, the mod commands you used (never their arguments), and failed subprocesses (a burst of 5 in 10 minutes raises a toast). Git checks run outside a repository are logged as expected, not as failures. It also records model calls (the only usage the mods cost: /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.
  • Logs: ~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.
  • Makes no model calls and adds no measurable latency.
  • Error lines mods log themselves ($.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.
  • Transcript-row hooks (secret-guard masks /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).

modal-meter

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.

  • Watches a project only when it uses Modal: a .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.
  • One session polls for all of them: whoever holds ~/.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.
  • Backs off while Modal is slow: an app list over 2 s or a timeout doubles the interval (up to 10 minutes); a fast answer brings it back.
  • Status line while containers run: Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.
  • A toast when an app has had containers up longer than 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.
  • Shows today's spend and alerts on a budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.
  • Finds the CLI as 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.
  • Makes no model calls: only the Modal CLI, never at session start — the first read runs in the background.

pr-autopilot

Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.

  • Watches your open PRs in the session's repo: it adopts them at session start, and picks up every gh pr create Claude runs. It polls gh pr view every 60s.
  • Status line: PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.
  • When a PR merges, it cleans up with plain local git, then toasts the outcome and suggests carrying on (Tab to accept):
  • git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.
  • Never with uncommitted changes, never --force, never other branches.
  • Deletes the local branch only if it points at exactly the commit GitHub merged, so nothing local is lost. It also leaves a branch checked out in another worktree alone.
  • A merge seen mid-turn is cleaned up when the turn ends, so git never races Claude.
  • When you mention failing CI ("#258 is failing", "CI failed, fix it"), your message goes to Claude with gh pr checks and the tail of the failed log attached, so you don't paste it.
  • Claude reads the watched PRs with a tool, 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.
  • When gh can't read the checks (a fine-grained token without "Checks: read" — the cause of 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.
  • Makes no model calls: only 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.

recall

Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.

  • What it searches: Claude Code sessions, Codex sessions, subagent and workflow runs, Claude's memory files, standing orders, second-opinion reviews, and your /remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.
  • What it keeps: prompts, answers, compaction summaries, session titles, commands, files touched, commits, PRs, issues, URLs, tasks and decisions (what you approved or ruled out). Read-only look-ups like grep and cat are kept but ranked low.
  • History survives cleanup: extracts stay searchable after Claude Code deletes old transcripts.
  • Claude searches it itself with four read-only tools, 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.
  • The ledger survives compaction: as a session goes, recall keeps what it has settled — your /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.
  • Commands:
  • /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>.
  • Bands:
  • Once per session: Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].
  • When a prompt mentions #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.
  • Query syntax: words must all match. 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.
  • Privacy:
  • The index lives at ~/.claude/recall/index.db, readable only by you, and never goes in a repo. The ledgers beside it are kept the same way.
  • Secrets are masked before anything is stored: known token shapes, labelled values ("password: …"), the values of secret-named exports in ~/.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.
  • Cost: no model calls except /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.
  • Requires macOS's /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).

repo-brief

Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".

  • Gathers in the background at session start:
  • branch, ahead/behind and uncommitted files
  • the last 8 commits
  • open PRs with CI ✓/✗/…
  • issues labelled owner, todo, P0 or blocked
  • stale branches (merged into the default branch, or upstream gone). The default branch is what origin/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.
  • A one-line band above the prompt, e.g. main ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it.
  • Claude gets the same summary once, in its first message, so the prompt cache stays warm. It refreshes after compaction. The gather is started, not awaited, at session start: the first message uses it if it has landed, otherwise waits up to 5 s for a quick one, otherwise goes without.
  • When gh's token can't read the PRs (a fine-grained token without "Checks: read", or no login), the brief says 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.
  • Makes no model calls: only git and gh. The band refreshes after a turn at most every 2 minutes.

Settings: focus labels, refresh minutes, and whether to brief Claude.

routine-watch

Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.

  • Knows a session is a routine from its scheduled-task prompt, and does nothing in your other sessions.
  • When a routine stops to wait for your OK on a permission prompt, you get a Mac notification and a toast, and the status line shows 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.
  • An AskUserQuestion is always for you, so it is reported at once.
  • When a turn ends in an error, or the routine finishes, you get a notification: Routine daily-report finished after 23m · waited on you 2 times (the waits you were told of).
  • Phone push (optional): 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 u
Source 3 files
hooks/register.ts 356 lines
1import 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}
356
hooks/stall.ts 234 lines
1import 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}
234
types/index.d.ts 50 lines
1/** 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