Tells you when a scheduled routine (a scheduled-task run) stops to wait on you: a Mac notification, an optional phone push and a status line when it asks for…

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, memory_pressure, ioreg).
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.
/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>/.Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).servers: :4000 :5173.lsof and ps every 15s.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.
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|done|all> removes jobs.tail, checks processes with ps, and runs df.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. It refuses heavy local jobs when the Mac can't take them.
RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%./busy.When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.
/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. /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.
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, 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 lists watched PRs. /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)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./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 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.~/.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./recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes./usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), 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 blockedmain ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it./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.
AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.Routine daily-report finished after 23m · waited on you 2 times.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 use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed./routine shows the routine's name, how long it has run, its waits, and the settings.A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.
/second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters./second-opinion commits 5/second-opinion diff (uncommitted changes)/second-opinion file docs/ADR-007.md/second-opinion <question>: adds a question for Fable to answer first.second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).
Keeps your "always / never / don't / from now on" instructions alive across compaction.
Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session./clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message./orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.
$NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME./secrets test <text> output is masked too./secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).
Makes Claude's open commands hand 3D files to the right slicer.
full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], orAn open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.
/slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.Settings:
closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.fullSpectrumPattern (the regex above)checkContents (on)The quit request goes out when Claude issues the command, before any permission prompt for it.
--plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.
claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)hooks/register.ts 296 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { RoutineRun, RoutineSession, RoutineSettings } from '../types'
4import {
5 commandArgv,
6 describeCall,
7 describeRun,
8 detectRoutine,
9 finishMessage,
10 firstQuestion,
11 osascriptArgv,
12 statusLine,
13} from './routine'
14
15type Engine = EngineInterface
16
17const SESSION = { plugin: 'routine-watch', key: 'session' } as const
18const UNDECIDED: RoutineSession = { isDecided: false, run: null }
19
20/** The read-only web tools `allowWebReads` lets a routine run without asking; nothing else. */
21const WEB_READS = new Set(['WebFetch', 'WebSearch'])
22/** Prompts that never count as the session's first: machine-injected ones. */
23const NOT_FIRST = new Set(['plugin', 'task-notification', 'observer', 'observer-activity'])
24/** How often the status line's "waiting on you" time is refreshed. */
25const TICK_MS = 15_000
26/** How long a notification command may take. */
27const NOTIFY_TIMEOUT_MS = 15_000
28
29let config: RoutineSettings = { notifyMac: true, allowWebReads: false, notifyCommand: '', notifyOnFinish: true }
30/**
31 * What the session is, kept here and changed synchronously so concurrent hooks
32 * never race; mirrored to `$.state` only so a hot reload can pick it up again.
33 * (A dispatch's `$.state.get` reads one moment, so a hook that waits on a tool
34 * could not see a wait another hook opened meanwhile.)
35 */
36let memory: RoutineSession = UNDECIDED
37let restoring: Promise<void> | null = null
38let ticker: { cancel: () => void } | null = null
39/** The status line last shown, so an unchanged line is not set again. */
40let shownStatus: string | undefined = undefined
41
42async function debug($: Engine, line: string) {
43 try {
44 await $.ui.log(`routine-watch: ${line}`, { to: 'debug' })
45 } catch {
46 // The debug log is best effort.
47 }
48}
49
50async function restore($: Engine) {
51 try {
52 const { value } = await $.state.get(SESSION)
53 if (value && !memory.isDecided) {
54 memory = value
55 }
56 } catch (error) {
57 await debug($, `could not restore the session: ${String(error)}`)
58 }
59}
60
61/** The session as known, restored once after a reload. */
62async function load($: Engine): Promise<RoutineSession> {
63 restoring ??= restore($)
64 await restoring
65 return memory
66}
67
68/** Takes the new value at once, then mirrors it for a reload. */
69async function save($: Engine, value: RoutineSession) {
70 memory = value
71 try {
72 await $.state.set(SESSION, value)
73 } catch (error) {
74 await debug($, `could not keep the session: ${String(error)}`)
75 }
76}
77
78async function currentRun($: Engine): Promise<RoutineRun | null> {
79 return (await load($)).run
80}
81
82async function showStatus($: Engine) {
83 const run = await currentRun($)
84 const line = run ? statusLine(run, await $.clock.now()) : undefined
85 if (line !== shownStatus) {
86 shownStatus = line
87 $.ui.status(line)
88 }
89}
90
91function stopTicker() {
92 ticker?.cancel()
93 ticker = null
94}
95
96async function tick($: Engine) {
97 await showStatus($)
98 const run = await currentRun($)
99 if (!run || Object.keys(run.waiting).length === 0) {
100 stopTicker()
101 }
102}
103
104function ensureTicker($: Engine) {
105 if (!ticker) {
106 ticker = $.clock.every(TICK_MS, () => void tick($))
107 }
108}
109
110/** Runs one notification command; a failure goes to the debug log, never to the routine. */
111async function runQuietly($: Engine, argv: readonly string[]) {
112 try {
113 const out = await $.process.run(argv, { timeoutMs: NOTIFY_TIMEOUT_MS })
114 if (out.exitCode !== 0) {
115 await debug($, `${argv[0] ?? 'command'} exited ${out.exitCode}: ${out.stderr.trim().slice(0, 200)}`)
116 }
117 } catch (error) {
118 await debug($, `${argv[0] ?? 'command'} failed: ${String(error)}`)
119 }
120}
121
122/** The Mac notification and the person's push command, run side by side. */
123async function sendOut($: Engine, run: RoutineRun, message: string) {
124 const title = `Claude routine: ${run.name}`
125 const jobs: Promise<void>[] = []
126 if (config.notifyMac) {
127 jobs.push(runQuietly($, osascriptArgv(title, message)))
128 }
129 const push = commandArgv(config.notifyCommand, { title, message })
130 if (push) {
131 jobs.push(runQuietly($, push))
132 }
133 await Promise.all(jobs)
134}
135
136/**
137 * Tells the person: a toast now, and the notifications in the background, so
138 * the dialog the routine waits on is never held up by them.
139 */
140function notify($: Engine, run: RoutineRun, message: string) {
141 $.ui.toast(message, { timeoutMs: 10_000 })
142 void sendOut($, run, message)
143}
144
145/** Opens a wait for one tool call and tells the person, once per call. */
146async function beginWait($: Engine, id: string, label: string, message: string) {
147 const now = await $.clock.now()
148 const { run } = await load($)
149 if (!run || run.waiting[id] !== undefined) {
150 return
151 }
152 const waiting = { ...run.waiting, [id]: { since: now, label } }
153 const opened: RoutineRun = { ...run, waits: run.waits + 1, waiting }
154 await save($, { ...memory, run: opened })
155 ensureTicker($)
156 await showStatus($)
157 notify($, opened, message)
158}
159
160/** Closes the waits named (every open one when none are named). */
161async function endWaits($: Engine, ids?: readonly string[]) {
162 const { run } = await load($)
163 const open = run ? Object.keys(run.waiting) : []
164 const closing = ids ? open.filter(id => ids.includes(id)) : open
165 if (!run || closing.length === 0) {
166 return
167 }
168 const waiting = { ...run.waiting }
169 for (const id of closing) {
170 delete waiting[id]
171 }
172 await save($, { ...memory, run: { ...run, waiting } })
173 await showStatus($)
174}
175
176export const register: Register = (on, options) => {
177 config = {
178 notifyMac: options.notifyMac !== false,
179 allowWebReads: options.allowWebReads === true,
180 notifyCommand: typeof options.notifyCommand === 'string' ? options.notifyCommand : '',
181 notifyOnFinish: options.notifyOnFinish !== false,
182 }
183 memory = UNDECIDED
184 restoring = null
185 ticker = null
186 shownStatus = undefined
187
188 on('session.start', async ($, e, next) => {
189 await $.command.register({
190 name: 'routine',
191 description: 'This routine run: its name, when it started, how often it waited on you, the settings',
192 immediate: true,
193 })
194 return next(e)
195 })
196
197 // The session's first prompt decides whether it is a routine run.
198 on('prompt.submit', async ($, e, next) => {
199 try {
200 const known = await load($)
201 if (!known.isDecided && !NOT_FIRST.has(e.origin.kind)) {
202 const name = detectRoutine(e.text, e.origin.kind)
203 const now = await $.clock.now()
204 if (!memory.isDecided) {
205 await save($, { isDecided: true, run: name ? { name, startedAt: now, waits: 0, waiting: {} } : null })
206 if (name) {
207 await showStatus($)
208 }
209 }
210 }
211 } catch (error) {
212 await debug($, `could not read the first prompt: ${String(error)}`)
213 }
214 return next(e)
215 })
216
217 // A permission ask in a routine is a run stopped until the person answers.
218 on('tool.check', async ($, e, next) => {
219 const verdict = await next(e)
220 if (verdict.decision !== 'ask') {
221 return verdict
222 }
223 const run = await currentRun($)
224 if (!run) {
225 return verdict
226 }
227 if (config.allowWebReads && WEB_READS.has(e.tool)) {
228 return { decision: 'allow', reason: 'routine-watch: web reads are allowed in routine runs (allowWebReads)' }
229 }
230 // A query (`$.tool.check`) carries no call id: nothing waits on it.
231 if (e.tool_use_id !== undefined) {
232 const label = describeCall(e.tool, e.input)
233 await beginWait($, e.tool_use_id, label, `Waiting for your OK: ${label}`)
234 }
235 return verdict
236 })
237
238 // A question for the person stops the routine too; every call's end closes its wait.
239 on('tool.call', async ($, e, next) => {
240 const id = e.tool_use_id
241 const run = await currentRun($)
242 if (!run || id === undefined) {
243 return next(e)
244 }
245 if (e.tool === 'AskUserQuestion') {
246 await beginWait($, id, 'AskUserQuestion', `Question for you: ${firstQuestion(e)}`)
247 }
248 try {
249 return await next(e)
250 } finally {
251 await endWaits($, [id])
252 }
253 })
254
255 on('turn.complete', async ($, e, next) => {
256 const done = await next(e)
257 if (e.agentId === undefined) {
258 const run = await currentRun($)
259 if (run) {
260 await endWaits($)
261 if (e.reason === 'error') {
262 notify($, run, 'The run stopped: its turn ended on an error.')
263 }
264 }
265 }
266 return done
267 })
268
269 on('session.end', async ($, e, next) => {
270 const { isDecided, run } = await load($)
271 if (run) {
272 stopTicker()
273 if (config.notifyOnFinish) {
274 const message = finishMessage(run, await $.clock.now())
275 $.ui.toast(message)
276 // Awaited: the session's end allows its hooks a short while, and the notice should land in it.
277 await sendOut($, run, message)
278 }
279 }
280 // A /clear goes on in this process as a fresh conversation, decided again by its first prompt.
281 if (isDecided) {
282 await save($, UNDECIDED)
283 }
284 if (shownStatus !== undefined) {
285 shownStatus = undefined
286 $.ui.status(undefined)
287 }
288 return next(e)
289 })
290
291 on('command.run', { command: 'routine' }, async $ => {
292 const run = await currentRun($)
293 return { text: describeRun(run, config, await $.clock.now()) }
294 })
295}
296hooks/routine.ts 184 lines1import type { RoutineRun, RoutineSettings } from '../types'
2
3/** The name a routine gets when its prompt carries no name of its own. */
4export const UNNAMED = 'scheduled task'
5
6const TAG = /<scheduled-task\b/
7const NAME = /<scheduled-task\b[^>]*?\bname\s*=\s*(?:"([^"]*)"|'([^']*)')/
8
9const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'" }
10
11/** Control characters (C0, DEL) and the Unicode line and paragraph separators. */
12const isControl = (code: number): boolean => code < 0x20 || code === 0x7f || code === 0x2028 || code === 0x2029
13
14/** Collapses runs of whitespace and control characters to one space. */
15const oneLine = (text: string): string =>
16 Array.from(text, char => (isControl(char.charCodeAt(0)) ? ' ' : char))
17 .join('')
18 .replace(/\s+/g, ' ')
19 .trim()
20
21/** Cuts text to `max` characters, marking the cut with an ellipsis. */
22export const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max - 1)}…`)
23
24/**
25 * The routine's name when `text` is a scheduled task's prompt
26 * (`<scheduled-task name="daily-report" file="…">`), else null. A tag with no
27 * name is still a routine, called "scheduled task".
28 */
29export const routineName = (text: string): string | null => {
30 if (!TAG.test(text)) {
31 return null
32 }
33 const match = NAME.exec(text)
34 const raw = match?.[1] ?? match?.[2] ?? ''
35 const name = oneLine(raw.replace(/&(amp|lt|gt|quot|apos|#39);/g, (_all, entity: string) => ENTITIES[entity] ?? ''))
36 return name ? clip(name, 60) : UNNAMED
37}
38
39/**
40 * Whether the session's first prompt starts a routine, and its name: the
41 * scheduled-task tag in the text, or else a prompt the engine says a schedule
42 * fired (`scheduled-trigger`).
43 */
44export const detectRoutine = (text: string, originKind: string): string | null =>
45 routineName(text) ?? (originKind === 'scheduled-trigger' ? UNNAMED : null)
46
47/**
48 * `text` as an AppleScript string literal: backslashes and double quotes
49 * escaped, control characters and line breaks turned into spaces.
50 */
51export const appleScriptString = (text: string): string =>
52 `"${oneLine(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
53
54/** The argv that posts a macOS notification with a sound; argv, so no shell reads it. */
55export const osascriptArgv = (title: string, message: string): string[] => [
56 'osascript',
57 '-e',
58 `display notification ${appleScriptString(clip(message, 240))} with title ${appleScriptString(clip(title, 100))} sound name "Glass"`,
59]
60
61/**
62 * The argv for the person's push command: the template split on whitespace,
63 * then `{title}` and `{message}` filled into the words that name them, so each
64 * value stays inside one argument whatever it holds. Null when no command is set.
65 */
66export const commandArgv = (template: string, values: { title: string; message: string }): string[] | null => {
67 const words = template.trim().split(/\s+/).filter(word => word !== '')
68 if (words.length === 0) {
69 return null
70 }
71 return words.map(word =>
72 word.replace(/\{(title|message)\}/g, (_all, key: string) => (key === 'title' ? values.title : values.message)),
73 )
74}
75
76/** The host a URL names, without `www.`; the URL itself, cut short, when it names none. */
77export const hostOf = (url: string): string => {
78 const match = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?#]*@)?(\[[^\]]*\]|[^:/?#]*)/i.exec(url.trim())
79 const host = match?.[1]?.toLowerCase().replace(/^www\./, '') ?? ''
80 return host || clip(oneLine(url), 60)
81}
82
83const fileName = (path: string): string => path.replace(/\/+$/, '').split('/').pop() || path
84
85const field = (input: unknown, key: string): string | null => {
86 if (typeof input !== 'object' || input === null) {
87 return null
88 }
89 const value = (input as Record<string, unknown>)[key]
90 return typeof value === 'string' && value.trim() !== '' ? value : null
91}
92
93/** A short label for a tool call: the tool and what it touches (`WebFetch example.com`). */
94export const describeCall = (tool: string, input: unknown): string => {
95 const url = field(input, 'url')
96 const query = field(input, 'query')
97 const command = field(input, 'command')
98 const path = field(input, 'file_path') ?? field(input, 'notebook_path') ?? field(input, 'path')
99 const detail = url
100 ? hostOf(url)
101 : query
102 ? `"${clip(oneLine(query), 60)}"`
103 : command
104 ? clip(oneLine(command), 60)
105 : path
106 ? fileName(path)
107 : ''
108 return detail ? `${tool} ${detail}` : tool
109}
110
111/** The first question an AskUserQuestion call puts to the person, cut short. */
112export const firstQuestion = (input: unknown): string => {
113 const questions = typeof input === 'object' && input !== null ? (input as { questions?: unknown }).questions : undefined
114 const first: unknown = Array.isArray(questions) ? questions[0] : undefined
115 const text = field(first, 'question')
116 return text ? clip(oneLine(text), 120) : 'a question'
117}
118
119/** A duration as people say it: `45s`, `23m`, `1h05m`. */
120export const formatDuration = (ms: number): string => {
121 const seconds = Math.max(0, Math.floor(ms / 1000))
122 if (seconds < 60) {
123 return `${seconds}s`
124 }
125 const minutes = Math.floor(seconds / 60)
126 if (minutes < 60) {
127 return `${minutes}m`
128 }
129 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
130}
131
132/** The open wait that began first, or null. */
133export const oldestWait = (run: RoutineRun): { since: number; label: string } | null => {
134 let oldest: { since: number; label: string } | null = null
135 for (const wait of Object.values(run.waiting)) {
136 if (!oldest || wait.since < oldest.since) {
137 oldest = wait
138 }
139 }
140 return oldest
141}
142
143/** `routine: <name>`, or `routine: <name> · waiting on you 3m` while a wait is open. */
144export const statusLine = (run: RoutineRun, now: number): string => {
145 const wait = oldestWait(run)
146 if (!wait) {
147 return `routine: ${run.name}`
148 }
149 const waited = now - wait.since
150 return `routine: ${run.name} · waiting on you ${waited < 60_000 ? '<1m' : formatDuration(waited)}`
151}
152
153const times = (count: number): string => (count === 1 ? '1 time' : `${count} times`)
154
155/** The notification a routine's end sends: how long it ran and how often it waited on the person. */
156export const finishMessage = (run: RoutineRun, now: number): string =>
157 `Routine ${run.name} finished after ${formatDuration(now - run.startedAt)} · ` +
158 (run.waits === 0 ? 'never waited on you' : `waited on you ${times(run.waits)}`)
159
160/** The settings as one line, the push command's text left out (it may carry a private topic). */
161export const describeSettings = (settings: RoutineSettings): string =>
162 [
163 `Mac notifications ${settings.notifyMac ? 'on' : 'off'}`,
164 settings.allowWebReads ? 'web reads allowed (allowWebReads on)' : 'web reads ask (allowWebReads off)',
165 `phone push ${settings.notifyCommand.trim() ? 'on' : 'off'}`,
166 `finish notice ${settings.notifyOnFinish ? 'on' : 'off'}`,
167 ].join(' · ')
168
169/** What /routine prints. */
170export const describeRun = (run: RoutineRun | null, settings: RoutineSettings, now: number): string => {
171 const settingsLine = `Settings: ${describeSettings(settings)}`
172 if (!run) {
173 return ['Not a routine session: routine-watch acts only in scheduled-task runs.', settingsLine].join('\n')
174 }
175 const wait = oldestWait(run)
176 const waits = run.waits === 0 ? 'Has not waited on you' : `Waited on you ${times(run.waits)}`
177 return [
178 `Routine: ${run.name}`,
179 `Started ${formatDuration(now - run.startedAt)} ago`,
180 wait ? `${waits} · waiting now for ${formatDuration(now - wait.since)}: ${wait.label}` : waits,
181 settingsLine,
182 ].join('\n')
183}
184types/index.d.ts 40 lines1/** One tool call the routine is stopped on: a permission ask or a question for the person. */
2export type RoutineWait = {
3 /** When the wait began, in `$.clock.now()` milliseconds. */
4 since: number
5 /** What it waits on, e.g. `WebFetch example.com`. */
6 label: string
7}
8
9/** A scheduled-task run in progress in this session. */
10export type RoutineRun = {
11 /** The task's name, from `<scheduled-task name="…">`. */
12 name: string
13 /** When the routine's first prompt arrived. */
14 startedAt: number
15 /** How many times it has stopped to wait on the person. */
16 waits: number
17 /** The waits still open, by tool_use_id. */
18 waiting: Record<string, RoutineWait>
19}
20
21/** What the session is: undecided until its first prompt, then a routine run or not. */
22export type RoutineSession = {
23 isDecided: boolean
24 run: RoutineRun | null
25}
26
27/** The plugin's settings, as /routine describes them. */
28export type RoutineSettings = {
29 notifyMac: boolean
30 allowWebReads: boolean
31 notifyCommand: string
32 notifyOnFinish: boolean
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 'routine-watch': { session: RoutineSession }
38 }
39}
40