Watches your Modal apps from the CLI: running apps and containers on the status line, a toast when one has been up too long, today's spend where the CLI…

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.tsx 383 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ModalMeter } from '../types'
5import {
6 activeApps,
7 alertText,
8 cliCandidates,
9 dueAlerts,
10 firstLine,
11 isAuthProblem,
12 isMissingModule,
13 kindOf,
14 lacksBilling,
15 parseAppList,
16 parseSpend,
17 rowText,
18 statusText,
19 summaryText,
20 trackApps,
21 usd,
22 utcDay,
23 wantsYes,
24} from './meter'
25
26type Engine = EngineInterface
27
28const PANE = 'modal-meter'
29const TITLE = 'Modal'
30const PROBE_TIMEOUT_MS = 30_000
31const LIST_TIMEOUT_MS = 60_000
32const STOP_TIMEOUT_MS = 120_000
33/** Spend is asked this often at most (a poll is every minute; spend moves slower). */
34const SPEND_EVERY_MS = 5 * 60_000
35/** After `billing report` failed for a reason other than not existing, wait this long to ask again. */
36const SPEND_RETRY_MS = 60 * 60_000
37/** Plain output: no ANSI styling from rich (FORCE_COLOR still adds bold, which the parser strips). */
38const PLAIN_ENV = { NO_COLOR: '1', TERM: 'dumb' }
39
40const EMPTY: ModalMeter = { cli: null, problem: null, apps: [], polledAt: null, spendToday: null, spendNote: null }
41
42const meter = atom({ plugin: 'modal-meter', key: 'meter' } as const, EMPTY)
43const tracked = atom({ plugin: 'modal-meter', key: 'tracked' } as const, {})
44const confirming = atom({ plugin: 'modal-meter', key: 'confirming' } as const, null)
45const stopping = atom({ plugin: 'modal-meter', key: 'stopping' } as const, null)
46const budgetDay = atom({ plugin: 'modal-meter', key: 'budgetDay' } as const, null)
47
48type Config = { command: string; pollMs: number; alertMs: number; budget: number }
49
50let config: Config = { command: '', pollMs: 60_000, alertMs: 30 * 60_000, budget: 0 }
51let timer: { cancel: () => void } | null = null
52/** The Modal CLI argv that answered `--version`: undefined until tried, null when none did. */
53let cli: string[] | null | undefined
54let inflight: Promise<void> | null = null
55let spendAskedAt: number | null = null
56let spendWaitMs = SPEND_EVERY_MS
57let hasBilling = true
58
59type Ran = { started: boolean; code: number; stdout: string; stderr: string }
60
61async function run($: Engine, argv: readonly string[], timeoutMs: number): Promise<Ran> {
62 try {
63 const out = await $.process.run(argv, { timeoutMs, env: PLAIN_ENV })
64 return { started: true, code: out.exitCode, stdout: out.stdout, stderr: out.stderr }
65 } catch (error) {
66 return { started: false, code: -1, stdout: '', stderr: String(error) }
67 }
68}
69
70/**
71 * Whether `exe` can start: a path is checked as given, a bare name against each `PATH` folder. With no
72 * `PATH` to read it is assumed present, so the probe still runs. Checking first spares a process that
73 * can only fail (`modal` is often not on `PATH` when Modal runs as `python3 -m modal`).
74 */
75async function canStart($: Engine, exe: string): Promise<boolean> {
76 try {
77 if (exe.includes('/')) {
78 return await $.fs.exists(exe)
79 }
80 const folders = ((await $.env.get('PATH')) ?? '').split(':').filter(Boolean)
81 if (folders.length === 0) {
82 return true
83 }
84 for (const folder of folders) {
85 if (await $.fs.exists(`${folder.replace(/\/+$/, '')}/${exe}`)) {
86 return true
87 }
88 }
89 return false
90 } catch {
91 return true
92 }
93}
94
95/** The first candidate that answers `--version`: the option as given, else `modal`, then `python3 -m modal`. */
96async function resolveCli($: Engine): Promise<string[] | null> {
97 for (const argv of cliCandidates(config.command)) {
98 if (!(await canStart($, argv[0] ?? ''))) {
99 continue
100 }
101 const out = await run($, [...argv, '--version'], PROBE_TIMEOUT_MS)
102 if (out.started && out.code === 0 && !isMissingModule(`${out.stderr}\n${out.stdout}`)) {
103 return argv
104 }
105 }
106 return null
107}
108
109function notFound(): string {
110 const own = config.command.trim()
111 return own
112 ? `the Modal CLI did not run as \`${own}\` (the modalCommand option)`
113 : 'no Modal CLI found (tried `modal` and `python3 -m modal`); set the modalCommand option to the command you run Modal with'
114}
115
116/** Nothing to show: no status line, no rows, the reason kept for /modal and the pane. */
117async function silence($: Engine, problem: string, label: string | null) {
118 await update($, meter, () => ({ ...EMPTY, cli: label, problem }))
119 $.ui.status(undefined)
120}
121
122async function checkBudget($: Engine, spent: number, now: number) {
123 if (config.budget <= 0 || spent < config.budget) {
124 return
125 }
126 const day = utcDay(now)
127 if ((await read($, budgetDay)) === day) {
128 return
129 }
130 await update($, budgetDay, () => day)
131 $.ui.toast(
132 `Modal spend today is ${usd(spent)}, past your ${usd(config.budget)} daily budget — /modal to see what is running`,
133 { timeoutMs: 15_000 },
134 )
135}
136
137type SpendPart = Pick<ModalMeter, 'spendToday' | 'spendNote'>
138
139/** Today's spend from `modal billing report --for today --json`, where this CLI has it. */
140async function readSpend(
141 $: Engine,
142 argv: readonly string[],
143 now: number,
144 isForced: boolean,
145 kept: SpendPart,
146): Promise<SpendPart> {
147 if (!hasBilling) {
148 return kept
149 }
150 if (!isForced && spendAskedAt !== null && now - spendAskedAt < spendWaitMs) {
151 return kept
152 }
153 spendAskedAt = now
154 const out = await run($, [...argv, 'billing', 'report', '--for', 'today', '--json'], LIST_TIMEOUT_MS)
155 const text = `${out.stderr}\n${out.stdout}`
156 const spent = out.started && out.code === 0 ? parseSpend(out.stdout) : null
157 if (spent !== null) {
158 spendWaitMs = SPEND_EVERY_MS
159 await checkBudget($, spent, now)
160 return { spendToday: spent, spendNote: null }
161 }
162 if (lacksBilling(text)) {
163 hasBilling = false
164 return { spendToday: null, spendNote: 'this Modal CLI has no `billing report` command; Modal 1.3.3 and later have one' }
165 }
166 spendWaitMs = SPEND_RETRY_MS
167 return { spendToday: null, spendNote: `\`billing report\` failed: ${firstLine(text)}` }
168}
169
170async function pollOnce($: Engine, isForced: boolean) {
171 if (cli === undefined) {
172 cli = await resolveCli($)
173 }
174 if (cli === null) {
175 await silence($, notFound(), null)
176 return
177 }
178 const label = cli.join(' ')
179 const listed = await run($, [...cli, 'app', 'list', '--json'], LIST_TIMEOUT_MS)
180 const text = `${listed.stderr}\n${listed.stdout}`
181 if (!listed.started) {
182 await silence($, `\`${label} app list\` could not run: ${firstLine(listed.stderr)}`, label)
183 return
184 }
185 if (listed.code !== 0) {
186 await silence(
187 $,
188 isAuthProblem(text)
189 ? `Modal has no profile or token set up on this machine (run \`${label} setup\`)`
190 : `\`${label} app list\` failed: ${firstLine(text)}`,
191 label,
192 )
193 return
194 }
195 const apps = parseAppList(listed.stdout)
196 if (apps === null) {
197 await silence($, `\`${label} app list --json\` printed no JSON list`, label)
198 return
199 }
200
201 const now = await $.clock.now()
202 let next = trackApps(await read($, tracked), apps, now)
203 for (const app of dueAlerts(apps, next, now, config.alertMs)) {
204 const since = next[app.id]?.busySince ?? now
205 $.ui.toast(alertText(app, now - since), { timeoutMs: 15_000 })
206 next = { ...next, [app.id]: { busySince: since, alertedAt: now } }
207 }
208 await update($, tracked, () => next)
209
210 const previous = await read($, meter)
211 const spend = await readSpend($, cli, now, isForced, {
212 spendToday: previous.spendToday,
213 spendNote: previous.spendNote,
214 })
215 await update($, meter, () => ({ cli: label, problem: null, apps, polledAt: now, ...spend }))
216 $.ui.status(statusText(apps, false))
217}
218
219/** One poll at a time: a call while one runs shares it. */
220function poll($: Engine, isForced: boolean): Promise<void> {
221 if (!inflight) {
222 inflight = pollOnce($, isForced)
223 .catch(error => $.ui.log(`modal-meter: poll failed: ${String(error)}`, { to: 'debug' }))
224 .finally(() => {
225 inflight = null
226 })
227 }
228 return inflight
229}
230
231/** A poll that starts after any running one, so it sees what happened since. */
232async function refresh($: Engine, isForced: boolean) {
233 if (inflight) {
234 await inflight
235 }
236 await poll($, isForced)
237}
238
239function ensureTimer($: Engine) {
240 if (!timer) {
241 timer = $.clock.every(config.pollMs, () => void poll($, false))
242 }
243}
244
245/** The confirmed Stop: `modal app stop <id>` (again with `--yes` where the CLI asks for it), a fresh poll, a toast. */
246async function stopApp($: Engine, id: string) {
247 if ((await read($, stopping)) !== null) {
248 return
249 }
250 const name = (await read($, meter)).apps.find(app => app.id === id)?.name ?? id
251 await update($, confirming, () => null)
252 await update($, stopping, () => id)
253 let out: Ran = { started: false, code: -1, stdout: '', stderr: 'no Modal CLI' }
254 try {
255 if (cli === undefined) {
256 cli = await resolveCli($)
257 }
258 if (cli) {
259 out = await run($, [...cli, 'app', 'stop', id], STOP_TIMEOUT_MS)
260 if (out.started && out.code !== 0 && wantsYes(`${out.stderr}\n${out.stdout}`)) {
261 out = await run($, [...cli, 'app', 'stop', '--yes', id], STOP_TIMEOUT_MS)
262 }
263 }
264 } finally {
265 await update($, stopping, () => null)
266 }
267 await refresh($, false)
268 $.ui.toast(
269 out.started && out.code === 0
270 ? `Stopped Modal app ${name}`
271 : `Could not stop Modal app ${name}: ${firstLine(`${out.stderr}\n${out.stdout}`)}`,
272 { timeoutMs: 10_000 },
273 )
274}
275
276const positive = (value: unknown, fallback: number): number => {
277 const n = Number(value)
278 return Number.isFinite(n) && n > 0 ? n : fallback
279}
280
281export const register: Register = (on, options) => {
282 config = {
283 command: String(options.modalCommand ?? '').trim(),
284 pollMs: Math.max(10, positive(options.pollSeconds, 60)) * 1000,
285 alertMs: positive(options.alertMinutes, 30) * 60_000,
286 budget: Math.max(0, Number(options.budgetToday ?? 0) || 0),
287 }
288 timer = null
289 cli = undefined
290 inflight = null
291 spendAskedAt = null
292 spendWaitMs = SPEND_EVERY_MS
293 hasBilling = true
294
295 on('session.start', async ($, e, next) => {
296 await $.command.register({
297 name: 'modal',
298 description: 'Open the Modal pane: running apps, containers, spend; stop an app',
299 })
300 await update($, stopping, () => null)
301 ensureTimer($)
302 void poll($, false)
303 return next(e)
304 })
305
306 on('command.run', { command: 'modal' }, async $ => {
307 ensureTimer($)
308 if (cli === null) {
309 // Not found before: look again, in case Modal was installed since.
310 cli = undefined
311 }
312 await refresh($, true)
313 await $.ui.open({ id: PANE, title: TITLE })
314 return { text: summaryText(await read($, meter), await read($, tracked), await $.clock.now(), config.budget) }
315 })
316
317 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
318 const { Box, Text, Button } = $.ui.resolve(e)
319 const shown = await read($, meter)
320 const tracking = await read($, tracked)
321 const asked = await read($, confirming)
322 const busyId = await read($, stopping)
323 const now = await $.clock.now()
324 const apps = activeApps(shown.apps)
325 const notice =
326 shown.problem !== null
327 ? `modal-meter is silent: ${shown.problem}.`
328 : shown.polledAt === null
329 ? 'Reading Modal…'
330 : apps.length === 0
331 ? 'No Modal apps running or deployed.'
332 : null
333 const spend =
334 shown.spendToday === null
335 ? null
336 : `Spend today (UTC): ${usd(shown.spendToday)}${config.budget > 0 ? ` of ${usd(config.budget)}` : ''}`
337
338 return (
339 <Box flexDirection="column" gap={1}>
340 {notice !== null && <Text dimColor>{notice}</Text>}
341 {apps.map(app => {
342 const isIdle = kindOf(app) === 'idle'
343 if (busyId === app.id) {
344 return (
345 <Box key={`row-${app.id}`} flexDirection="row">
346 <Text color="yellow">{`Stopping ${app.name}…`}</Text>
347 </Box>
348 )
349 }
350 if (asked === app.id) {
351 return (
352 <Box key={`row-${app.id}`} flexDirection="row" gap={1}>
353 <Text bold>{`Stop ${app.name}?`}</Text>
354 <Button
355 key={`confirm-${app.id}`}
356 label="Confirm"
357 variant="primary"
358 onPress={() => stopApp($, app.id)}
359 />
360 <Button key={`cancel-${app.id}`} label="Cancel" onPress={() => update($, confirming, () => null)} />
361 </Box>
362 )
363 }
364 return (
365 <Box key={`row-${app.id}`} flexDirection="row" justifyContent="space-between" gap={1}>
366 <Text dimColor={isIdle} wrap="truncate-end">
367 {rowText(app, tracking[app.id], now)}
368 </Text>
369 <Button
370 key={`stop-${app.id}`}
371 label="Stop"
372 dimColor={isIdle}
373 onPress={() => update($, confirming, () => app.id)}
374 />
375 </Box>
376 )
377 })}
378 {spend !== null && <Text dimColor>{spend}</Text>}
379 </Box>
380 )
381 })
382}
383hooks/meter.ts 327 lines1import type { ModalApp, ModalAppKind, ModalMeter, ModalTracked } from '../types'
2
3const ANSI = /\u001b\[[0-9;?]*[A-Za-z]/g
4
5/** The command candidates to try, in order: the option split into argv, else `modal` then `python3 -m modal`. */
6export const cliCandidates = (option: string): string[][] => {
7 const own = splitCommand(option)
8 return own.length > 0 ? [own] : [['modal'], ['python3', '-m', 'modal']]
9}
10
11/** Splits a command line on spaces, keeping "quoted words" whole. */
12export const splitCommand = (text: string): string[] =>
13 text.match(/"[^"]*"|'[^']*'|\S+/g)?.map(word => word.replace(/^(["'])(.*)\1$/, '$2')) ?? []
14
15/** A Modal CLI JSON key in one spelling: `App ID` (CLI before 1.5) and `app_id` (1.5 on) both read `app_id`. */
16export const jsonKey = (key: string): string =>
17 key
18 .replace(/[^a-zA-Z0-9]+/g, '_')
19 .toLowerCase()
20 .replace(/^_+|_+$/g, '')
21
22/** The first JSON array in `text`, ANSI styling stripped (rich adds it when FORCE_COLOR is set); null when there is none. */
23const jsonArray = (text: string): unknown[] | null => {
24 const plain = text.replace(ANSI, '')
25 const start = plain.indexOf('[')
26 const end = plain.lastIndexOf(']')
27 if (start < 0 || end < start) {
28 return null
29 }
30 try {
31 const parsed: unknown = JSON.parse(plain.slice(start, end + 1))
32 return Array.isArray(parsed) ? parsed : null
33 } catch {
34 return null
35 }
36}
37
38const normalized = (row: unknown): Record<string, unknown> | null => {
39 if (!row || typeof row !== 'object' || Array.isArray(row)) {
40 return null
41 }
42 const out: Record<string, unknown> = {}
43 for (const [key, value] of Object.entries(row)) {
44 out[jsonKey(key)] = value
45 }
46 return out
47}
48
49const pick = (row: Record<string, unknown>, keys: readonly string[]): unknown =>
50 keys.map(key => row[key]).find(value => value !== undefined && value !== null && value !== '')
51
52/**
53 * A Modal timestamp in epoch ms: `2026-10-07 07:50:00-07:00` (the CLI's JSON), any ISO
54 * string, or epoch seconds or ms. Null when absent or unreadable.
55 */
56export const parseTimestamp = (value: unknown): number | null => {
57 if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
58 return value < 1e12 ? value * 1000 : value
59 }
60 if (typeof value !== 'string' || !value.trim()) {
61 return null
62 }
63 const text = value.trim()
64 if (/^\d+(\.\d+)?$/.test(text)) {
65 return parseTimestamp(Number(text))
66 }
67 const ms = Date.parse(text.replace(/^(\d{4}-\d{2}-\d{2}) (\d)/, '$1T$2'))
68 return Number.isFinite(ms) ? ms : null
69}
70
71/** Modal's state label in one short word: `ephemeral (detached)` → `detached`, `initializing...` → `initializing`. */
72export const normalizeState = (value: unknown): string => {
73 const text = String(value ?? '')
74 .toLowerCase()
75 .replace(/\.+$|…$/g, '')
76 .trim()
77 if (!text) {
78 return 'unknown'
79 }
80 return /detached/.test(text) ? 'detached' : text
81}
82
83const count = (value: unknown): number => {
84 const n = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10)
85 return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
86}
87
88/**
89 * The apps in `modal app list --json`, whatever the CLI's key spelling (`App ID`,
90 * `Description`, `State`, `Tasks`, `Created at`, `Stopped at`; snake_case from 1.5 on).
91 * Rows with no id are dropped. Null when the text holds no JSON array.
92 */
93export const parseAppList = (text: string): ModalApp[] | null => {
94 const rows = jsonArray(text)
95 if (rows === null) {
96 return null
97 }
98 const apps: ModalApp[] = []
99 for (const raw of rows) {
100 const row = normalized(raw)
101 if (!row) {
102 continue
103 }
104 const id = pick(row, ['app_id', 'id', 'appid'])
105 if (typeof id !== 'string' || !id.trim()) {
106 continue
107 }
108 const name = pick(row, ['description', 'name', 'app_name'])
109 apps.push({
110 id: id.trim(),
111 name: typeof name === 'string' && name.trim() ? name.trim() : id.trim(),
112 state: normalizeState(pick(row, ['state', 'status'])),
113 containers: count(pick(row, ['tasks', 'n_running_tasks', 'running_tasks', 'containers'])),
114 createdAt: parseTimestamp(pick(row, ['created_at', 'created'])),
115 stoppedAt: parseTimestamp(pick(row, ['stopped_at', 'stopped'])),
116 })
117 }
118 return apps
119}
120
121/** States a `modal run` is in while its local process lives: worth counting even before containers start. */
122const LIVE_STATES = new Set(['ephemeral', 'detached', 'initializing', 'running'])
123const GONE_STATES = new Set(['stopped', 'disabled'])
124
125export const kindOf = (app: ModalApp): ModalAppKind => {
126 if (app.containers > 0) {
127 return 'running'
128 }
129 if (GONE_STATES.has(app.state) || app.state === 'stopping' || app.stoppedAt !== null) {
130 return 'stopped'
131 }
132 return LIVE_STATES.has(app.state) ? 'running' : 'idle'
133}
134
135/** The apps worth a row: everything not stopped, running ones first, most containers first, then by name. */
136export const activeApps = (apps: readonly ModalApp[]): ModalApp[] =>
137 apps
138 .filter(app => kindOf(app) !== 'stopped')
139 .sort(
140 (a, b) =>
141 Number(kindOf(b) === 'running') - Number(kindOf(a) === 'running') ||
142 b.containers - a.containers ||
143 a.name.localeCompare(b.name),
144 )
145
146const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
147
148export const containersText = (n: number): string => plural(n, 'container')
149
150/** `Modal: 1 running (2 containers) · 1 deployed`; undefined when nothing is active. */
151/**
152 * `Modal: 1 running (2 containers) · 1 deployed`; with `includeIdle` false (the status line),
153 * deployed apps with no containers are left out, since they cost nothing.
154 */
155export const statusText = (apps: readonly ModalApp[], includeIdle = true): string | undefined => {
156 const active = activeApps(apps)
157 const running = active.filter(app => kindOf(app) === 'running')
158 const idle = active.length - running.length
159 const containers = running.reduce((sum, app) => sum + app.containers, 0)
160 const parts: string[] = []
161 if (running.length > 0) {
162 parts.push(`${running.length} running${containers > 0 ? ` (${containersText(containers)})` : ''}`)
163 }
164 if (idle > 0 && (includeIdle || running.length > 0)) {
165 parts.push(`${idle} deployed`)
166 }
167 return parts.length > 0 ? `Modal: ${parts.join(' · ')}` : undefined
168}
169
170/**
171 * Since when an app with containers counts as up: a `modal run` app from its creation;
172 * a deployed app from when the meter first saw it with containers (its creation is the
173 * deploy, which can be weeks old).
174 */
175const busyStart = (app: ModalApp, now: number): number =>
176 LIVE_STATES.has(app.state) && app.createdAt !== null && app.createdAt <= now ? app.createdAt : now
177
178/**
179 * The tracking carried to this poll: every listed app that is not stopped keeps its
180 * last alert time; `busySince` starts when containers appear and clears when they go.
181 */
182export const trackApps = (
183 previous: Readonly<Record<string, ModalTracked>>,
184 apps: readonly ModalApp[],
185 now: number,
186): Record<string, ModalTracked> => {
187 const next: Record<string, ModalTracked> = {}
188 for (const app of apps) {
189 if (kindOf(app) === 'stopped') {
190 continue
191 }
192 const before = previous[app.id]
193 next[app.id] = {
194 busySince: app.containers > 0 ? (before?.busySince ?? busyStart(app, now)) : null,
195 alertedAt: before?.alertedAt ?? null,
196 }
197 }
198 return next
199}
200
201/**
202 * The apps due a long-running toast: containers up for longer than `alertMs`, and no
203 * toast for them in the last `alertMs`.
204 */
205export const dueAlerts = (
206 apps: readonly ModalApp[],
207 tracked: Readonly<Record<string, ModalTracked>>,
208 now: number,
209 alertMs: number,
210): ModalApp[] =>
211 apps.filter(app => {
212 const one = tracked[app.id]
213 if (!one || one.busySince === null || app.containers === 0) {
214 return false
215 }
216 return now - one.busySince > alertMs && (one.alertedAt === null || now - one.alertedAt >= alertMs)
217 })
218
219/** `45s`, `45m`, `2h 5m`, `3d 4h`. */
220export const formatDuration = (ms: number): string => {
221 const seconds = Math.max(0, Math.floor(ms / 1000))
222 if (seconds < 60) {
223 return `${seconds}s`
224 }
225 const minutes = Math.floor(seconds / 60)
226 if (minutes < 60) {
227 return `${minutes}m`
228 }
229 const hours = Math.floor(minutes / 60)
230 if (hours < 24) {
231 return `${hours}h${minutes % 60 ? ` ${minutes % 60}m` : ''}`
232 }
233 const days = Math.floor(hours / 24)
234 return `${days}d${hours % 24 ? ` ${hours % 24}h` : ''}`
235}
236
237export const alertText = (app: ModalApp, upMs: number): string =>
238 `Modal app ${app.name} has run ${formatDuration(upMs)} with ${containersText(app.containers)} — /modal to stop it`
239
240/** How long an app has been up: since its containers started, else since it was created. */
241export const upFor = (app: ModalApp, tracked: ModalTracked | undefined, now: number): number | null => {
242 const since = tracked?.busySince ?? app.createdAt
243 return since === null ? null : Math.max(0, now - since)
244}
245
246/** `image-worker · ephemeral · 2 containers · up 45m`. */
247export const rowText = (app: ModalApp, tracked: ModalTracked | undefined, now: number): string => {
248 const up = upFor(app, tracked, now)
249 return [app.name, app.state, containersText(app.containers), up === null ? null : `up ${formatDuration(up)}`]
250 .filter(Boolean)
251 .join(' · ')
252}
253
254/** `$1.23`. */
255export const usd = (amount: number): string => `$${amount.toFixed(2)}`
256
257/**
258 * Today's spend from `modal billing report --for today --json`: the sum of its rows'
259 * `cost` (a decimal string). Null when the text holds no JSON array.
260 */
261export const parseSpend = (text: string): number | null => {
262 const rows = jsonArray(text)
263 if (rows === null) {
264 return null
265 }
266 let total = 0
267 for (const raw of rows) {
268 const row = normalized(raw)
269 const cost = row ? Number(pick(row, ['cost', 'total_cost', 'amount'])) : Number.NaN
270 if (Number.isFinite(cost)) {
271 total += cost
272 }
273 }
274 return Math.round(total * 1e6) / 1e6
275}
276
277/** The CLI said it has no `billing` command (Modal before 1.3.3). */
278export const lacksBilling = (output: string): boolean => /no such command\W+billing/i.test(output)
279
280/** The CLI could not authenticate: no profile or token set up on this machine. */
281export const isAuthProblem = (output: string): boolean =>
282 /token|authenticat|credential|not logged in|modal setup|no modal profile|profile.*not found/i.test(output)
283
284/** A Modal that runs at all but has no `modal` package behind `python3 -m modal`. */
285export const isMissingModule = (output: string): boolean => /no module named '?modal/i.test(output)
286
287/** `modal app stop` refused to run without a terminal and asked for `--yes` (Modal 1.4.2 on). */
288export const wantsYes = (output: string): boolean => /--yes\b/.test(output)
289
290/** The first line worth showing from a failed command's output. */
291export const firstLine = (output: string): string =>
292 output
293 .replace(ANSI, '')
294 .split('\n')
295 .map(line => line.replace(/^[\s│╭╰─┃|]+|[\s│╮╯─┃|]+$/g, '').trim())
296 .find(line => line && !/^(error|usage:.*|try '.*)$/i.test(line))
297 ?.slice(0, 160) ?? 'no output'
298
299/** The UTC day of `ms`, `2026-10-07`: the day Modal's `--for today` reports. */
300export const utcDay = (ms: number): string => new Date(ms).toISOString().slice(0, 10)
301
302/** The meter's state in words: what `/modal` answers. */
303export const summaryText = (
304 meter: ModalMeter,
305 tracked: Readonly<Record<string, ModalTracked>>,
306 now: number,
307 budget: number,
308): string => {
309 if (meter.problem !== null) {
310 return `modal-meter is silent: ${meter.problem}.`
311 }
312 if (meter.polledAt === null) {
313 return 'modal-meter has not read Modal yet.'
314 }
315 const apps = activeApps(meter.apps)
316 const lines = [`${statusText(meter.apps) ?? 'Modal: nothing running or deployed'} (read with \`${meter.cli ?? 'modal'}\`)`]
317 for (const app of apps) {
318 lines.push(` ${rowText(app, tracked[app.id], now)}${kindOf(app) === 'idle' ? ' (idle)' : ''}`)
319 }
320 lines.push(
321 meter.spendToday !== null
322 ? `Spend today (UTC): ${usd(meter.spendToday)}${budget > 0 ? ` of a ${usd(budget)} daily budget` : ''}`
323 : `Spend: not shown (${meter.spendNote ?? 'not read yet'})`,
324 )
325 return lines.join('\n')
326}
327types/index.d.ts 59 lines1/** One row of `modal app list --json`, read defensively. */
2export type ModalApp = {
3 /** The app id (`ap-…`), what `modal app stop` takes. */
4 id: string
5 /** The app's description: its name, or the id when Modal gave none. */
6 name: string
7 /**
8 * Modal's state, lowercased and shortened: `deployed`, `ephemeral`, `detached`
9 * (ephemeral, detached), `initializing`, `stopping`, `stopped`, `disabled`, or
10 * whatever word a newer CLI prints.
11 */
12 state: string
13 /** Running tasks (containers). */
14 containers: number
15 /** When the app was created, in epoch ms; null when the CLI did not say. */
16 createdAt: number | null
17 /** When the app stopped, in epoch ms; null while it has not. */
18 stoppedAt: number | null
19}
20
21/** `running`: containers up or a live `modal run`; `idle`: deployed with no containers; `stopped`: gone. */
22export type ModalAppKind = 'running' | 'idle' | 'stopped'
23
24/** What the meter remembers about one listed app between polls. */
25export type ModalTracked = {
26 /** Since when it has had containers (null while it has none). */
27 busySince: number | null
28 /** When it last raised the long-running toast. */
29 alertedAt: number | null
30}
31
32export type ModalMeter = {
33 /** The CLI in use, as typed (`python3 -m modal`); null when none ran. */
34 cli: string | null
35 /** Why the meter is silent; null while it is reading Modal. */
36 problem: string | null
37 apps: ModalApp[]
38 polledAt: number | null
39 /** Today's spend in USD (Modal's UTC day); null when not known. */
40 spendToday: number | null
41 /** Why spend is not shown; null while it is (or before it was asked). */
42 spendNote: string | null
43}
44
45declare module 'claude-code' {
46 interface PluginState {
47 'modal-meter': {
48 meter: ModalMeter
49 tracked: Record<string, ModalTracked>
50 /** The app whose Stop was pressed and now waits for Confirm. */
51 confirming: string | null
52 /** The app a confirmed stop is running for. */
53 stopping: string | null
54 /** The UTC day the budget toast was shown for. */
55 budgetDay: string | null
56 }
57 }
58}
59