SLOPSHOPPER

modal-meter

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…

newpanecommandtoaststatusprocess
v0.1.0no licenseupdated 2026-10-08joeldg/claude-mods/modal-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · modal-meter
│ ┃ Modal ✕ › fix the failing auth test and add an audit log call │ ┃ modal-meter is silent: no Modal CLI found │ ┃ (tried `modal` and `python3 -m modal`); set ⏺ Read(src/auth.ts) │ ┃ the modalCommand option to the command you ⎿ Read 6 lines │ ┃ run Modal with. ⏺ 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 │ │ › /modal │ ⎿ modal-meter: modal-meter is silent: no Modal CLI found (tried `m │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Modal
modal-meter is silent: no Modal CLI found (tried `modal` and `python3 -m modal`); set the modalCommand option to the command you run Modal with.
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, memory_pressure, ioreg).

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.

  • /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 Mac
  • 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.
  • 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: lsof and ps every 15s.

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.

  • 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.
  • 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|done|all> removes jobs.
  • Makes no model calls: it reads logs with 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.

machine-guard

Memory, swap and GPU on the status line. It 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%.
  • It refuses heavy jobs (training, inference, rendering, extraction, Blender, Docker, ffmpeg) when:
  • macOS reports critical memory pressure, or
  • the Mac is reserved with /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.
  • 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.

  • 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, and stays silent when Modal isn't set up.
  • Makes no model calls: only the Modal CLI, every 60s.

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.
  • /prs lists watched PRs. /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)
  • 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.
  • 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 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.
  • 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.
  • 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 (about 1s) at session start and every 10 minutes.
  • 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), 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, or upstream gone)
  • 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.
  • /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 or an AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.
  • 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.
  • 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 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.
  • Makes no model calls.

second-opinion

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.
  • Other forms:
  • /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.
  • The command returns at once, and the status line shows 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.
  • Send to Claude attaches the review to your next prompt (once) and drafts "What do you agree with, and what would you act on?".
  • Reviews are saved in ~/.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).

standing-orders

Keeps your "always / never / don't / from now on" instructions alive across compaction.

  • When you write an instruction like "never open bambu with full spectrum files", a band asks: Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.
  • Project orders live in ~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session.
  • Claude gets them at the start of every conversation and again after each compaction or /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).
  • Makes no model calls.

secret-guard

Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.

  • Catches known token shapes: AWS, GitHub, Anthropic, OpenAI, Slack, Google, Hugging Face, GitLab, npm, Stripe, private keys and bearer tokens.
  • Also catches labelled values ("password: …", "api key = …", "the wifi password is …") and the two-line "Access Key ID / Secret Access Key" paste.
  • Leaves alone $NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.
  • On a hit, the prompt isn't sent and goes back in the box. A band shows the secret masked (…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:
  • Save as env var appends export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME.
  • Send anyway lets exactly that text through once.
  • Edit dismisses the band.
  • The value is never shown in toasts, status, state or the transcript, and /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.
  • Makes no model calls.

Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).

slicer-handoff

Makes Claude's open commands hand 3D files to the right slicer.

  • Full-spectrum files go to Snapmaker Orca. Bambu Studio and OrcaSlicer can't open them. A file counts as full-spectrum when:
  • its name or folder matches full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], or
  • its 3MF names a Full Spectrum filament profile.

An 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.

  • Earlier windows close first. Before opening a file, it asks the running slicer to quit (a normal quit, never forced), so windows don't pile up. If one won't close, for example because it's waiting on a save prompt, it stops trying and tells Claude to leave it alone.
  • /slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.
  • Recognizes 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.
  • Makes no model calls.

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.

Loading

  • One session from a terminal: pass --plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot
  • Every session, including the desktop app: add to ~/.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" } } ``

Checking

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)
Source 3 files
hooks/register.tsx 383 lines
1import { 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}
383
hooks/meter.ts 327 lines
1import 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}
327
types/index.d.ts 59 lines
1/** 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