Memory, swap and GPU on the status line; refuses heavy local jobs (training, inference, rendering, Docker) while memory is critical or the Mac is reserved with…

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.
Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, memory_pressure, ioreg).
git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods
A pane of your project's running dev servers, so you don't have to ask Claude to restart them.
/servers opens the Servers pane:package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile~/.claude/dev-servers/<project>/.Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).servers: :4000 :5173.lsof and ps every 15s.Puts files you just downloaded into your prompt with one click.
~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].@"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files./downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).
Sets effort per message, so you don't have to switch it by hand.
avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included./route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.effort: low (routine).Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).
A Jobs pane for long-running work: training runs, downloads, extractions.
nohup … > log & launches by itself./watch <log> [label] adds any other log file./ and /Volumes/* (the NAS).jobs: 2 running · 1 stalled./jobs opens the pane, /unwatch <label|done|all> removes jobs.tail, checks processes with ps, and runs df.Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.
Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.
RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%./busy.When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.
/busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``/guard shows what it sees. /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.modal run, ssh), tests (pytest) and installs are never treated as heavy.overnight_|nightly_run\.sh.Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.
next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast./second-opinion, /recall ask) and file writes (folders only, never contents)./mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it./mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read./mods failures [7d]: failures and failed subprocesses only.~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.$.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged./secrets records there) are watched for failures and slow runs, but not counted per run.Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).
Keeps an eye on Modal so idle GPU containers don't burn credits.
Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.alertMinutes (30), repeated at most every 30 minutes./modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, and stays silent when Modal isn't set up.Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.
gh pr create Claude runs. It polls gh pr view every 60s.PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.--force, never other branches.gh pr checks and the tail of the failed log attached, so you don't paste it./prs lists watched PRs. /prs watch <n|url> and /prs forget <n|all> add and remove them.gh and git, at about one GitHub API call per open PR per minute.Settings:
pollSeconds (60)attachCiLogs (on)logLines (120)deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.It never closes issues; put "Closes #N" in PR bodies for that.
Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.
/remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.grep and cat are kept but ranked low.search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled./recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>./recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it./recall timeline [7d|30d|90d] [all]/recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]/recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model./recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help./remember <fact>, /remember list, /remember forget <ref>.Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].#214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.~/.claude/recall/index.db, readable only by you, and never goes in a repo.~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update./recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes./usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).
Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".
owner, todo, P0 or blockedmain ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it./brief re-gathers now and prints the full summary.git and gh. The band refreshes after a turn at most every 2 minutes.Settings: focus labels, refresh minutes, and whether to brief Claude.
Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.
AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.Routine daily-report finished after 23m · waited on you 2 times.notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.allowWebReads (off by default): lets routines use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed./routine shows the routine's name, how long it has run, its waits, and the settings.A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.
/second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters./second-opinion commits 5/second-opinion diff (uncommitted changes)/second-opinion file docs/ADR-007.md/second-opinion <question>: adds a question for Fable to answer first.second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).
Keeps your "always / never / don't / from now on" instructions alive across compaction.
Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session./clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message./orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.
$NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME./secrets test <text> output is masked too./secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).
Makes Claude's open commands hand 3D files to the right slicer.
full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], orAn open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.
/slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.Settings:
closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.fullSpectrumPattern (the regex above)checkContents (on)The quit request goes out when Claude issues the command, before any permission prompt for it.
--plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.
claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)hooks/register.ts 256 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Reservation, Snapshot } from '../types'
4import {
5 criticalDenial,
6 formatLeft,
7 isHeavy,
8 parseDuration,
9 parseFreePct,
10 parseGpu,
11 parseReservation,
12 parseSysctl,
13 parseTopApps,
14 pressureOf,
15 reservationDenial,
16 statusLine,
17 warnContext,
18} from './probe'
19
20type Engine = EngineInterface
21
22const SNAPSHOT = { plugin: 'machine-guard', key: 'snapshot' } as const
23const PAUSED = { plugin: 'machine-guard', key: 'pausedUntil' } as const
24/** A guard decision samples afresh when the last reading is older than this. */
25const STALE_MS = 30_000
26
27type Config = { sampleMs: number; blockOnCritical: boolean; busyMs: number; extra: RegExp | null }
28
29let config: Config = { sampleMs: 15_000, blockOnCritical: true, busyMs: 4 * 3_600_000, extra: null }
30let timer: { cancel: () => void } | null = null
31let isSampling = false
32
33async function run($: Engine, argv: readonly string[]): Promise<string | null> {
34 const out = await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => null)
35 return out && out.exitCode === 0 ? out.stdout : null
36}
37
38async function reservationPath($: Engine): Promise<string | null> {
39 const home = await $.env.get('HOME')
40 return home ? `${home}/.claude/machine-guard.json` : null
41}
42
43async function readReservation($: Engine, now: number): Promise<Reservation | null> {
44 const path = await reservationPath($)
45 if (!path) {
46 return null
47 }
48 const text = await $.fs.read(path).catch(() => null)
49 return parseReservation(text, now)
50}
51
52async function pausedLeft($: Engine, now: number): Promise<number> {
53 const { value = 0 } = await $.state.get(PAUSED)
54 return Math.max(0, value - now)
55}
56
57async function showStatus($: Engine, snapshot: Snapshot | null, now: number) {
58 const reservation = await readReservation($, now)
59 $.ui.status(statusLine(snapshot, reservation, await pausedLeft($, now)) || undefined)
60}
61
62async function sample($: Engine): Promise<Snapshot | null> {
63 const { value: previous = null } = await $.state.get(SNAPSHOT)
64 if (isSampling) {
65 return previous
66 }
67 isSampling = true
68 try {
69 const [sysctl, free, ps, gpu] = await Promise.all([
70 run($, ['sysctl', '-n', 'kern.memorystatus_vm_pressure_level', 'vm.swapusage']),
71 run($, ['memory_pressure', '-Q']),
72 run($, ['ps', '-axo', 'rss=,comm=']),
73 run($, ['ioreg', '-r', '-d', '1', '-w', '0', '-c', 'IOAccelerator']),
74 ])
75 if (sysctl === null && free === null) {
76 return previous
77 }
78 const memory = parseSysctl(sysctl ?? '')
79 const freePct = parseFreePct(free ?? '')
80 const now = await $.clock.now()
81 const snapshot: Snapshot = {
82 at: now,
83 pressure: pressureOf(memory.level, freePct, memory.swapUsedGB, memory.swapTotalGB),
84 freePct,
85 swapUsedGB: memory.swapUsedGB,
86 swapTotalGB: memory.swapTotalGB,
87 gpuPct: parseGpu(gpu ?? ''),
88 top: parseTopApps(ps ?? ''),
89 }
90 await $.state.set(SNAPSHOT, snapshot)
91 if (snapshot.pressure === 'critical' && previous?.pressure !== 'critical') {
92 const top = snapshot.top.map(app => `${app.name} ${app.gb} GB`).join(', ')
93 $.ui.toast(
94 `Memory critical: ${snapshot.freePct ?? '?'}% free, swap ${snapshot.swapUsedGB}/${snapshot.swapTotalGB} GB. Biggest: ${top}.` +
95 (config.blockOnCritical ? ' Heavy jobs are blocked.' : ''),
96 { timeoutMs: 12_000 },
97 )
98 }
99 await showStatus($, snapshot, now)
100 return snapshot
101 } finally {
102 isSampling = false
103 }
104}
105
106function ensureTimer($: Engine) {
107 if (!timer) {
108 timer = $.clock.every(config.sampleMs, () => void sample($))
109 }
110}
111
112async function freshSnapshot($: Engine, now: number): Promise<Snapshot | null> {
113 const { value = null } = await $.state.get(SNAPSHOT)
114 return value && now - value.at <= STALE_MS ? value : sample($)
115}
116
117async function describe($: Engine): Promise<string> {
118 const snapshot = await sample($)
119 const now = await $.clock.now()
120 const reservation = await readReservation($, now)
121 const paused = await pausedLeft($, now)
122 const lines = snapshot
123 ? [
124 `Memory pressure: ${snapshot.pressure}${snapshot.freePct === null ? '' : ` (${snapshot.freePct}% free)`}`,
125 `Swap: ${snapshot.swapUsedGB} of ${snapshot.swapTotalGB} GB`,
126 `GPU: ${snapshot.gpuPct === null ? 'unknown' : `${snapshot.gpuPct}%`}`,
127 `Biggest: ${snapshot.top.map(app => `${app.name} ${app.gb} GB`).join(', ')}`,
128 ]
129 : ['Memory could not be read on this machine.']
130 lines.push(
131 reservation
132 ? `Reserved for "${reservation.reason}" for ${formatLeft(reservation.until - now)} more (/busy off lifts it)`
133 : 'Not reserved (/busy [2h] [reason] reserves it)',
134 paused > 0 ? `Guard paused for ${formatLeft(paused)} (/guard on resumes)` : 'Guard on (/guard pause 15m pauses it)',
135 )
136 return lines.join('\n')
137}
138
139export const register: Register = (on, options) => {
140 let extra: RegExp | null = null
141 try {
142 extra = options.extraHeavy ? new RegExp(String(options.extraHeavy), 'i') : null
143 } catch {
144 extra = null
145 }
146 config = {
147 sampleMs: Math.max(5, Number(options.sampleSeconds ?? 15)) * 1000,
148 blockOnCritical: options.blockOnCritical !== false,
149 busyMs: Math.max(0.1, Number(options.busyHours ?? 4)) * 3_600_000,
150 extra,
151 }
152 timer = null
153
154 on('session.start', async ($, e, next) => {
155 await $.command.register({
156 name: 'busy',
157 description: 'Reserve this Mac: block heavy local jobs in every Claude session',
158 argumentHint: '[2h] [reason] | off',
159 immediate: true,
160 })
161 await $.command.register({
162 name: 'guard',
163 description: 'Show memory, swap, GPU and the guard; pause or resume it',
164 argumentHint: '[pause 15m | on]',
165 immediate: true,
166 })
167 ensureTimer($)
168 const now = await $.clock.now()
169 const reservation = await readReservation($, now)
170 if (reservation) {
171 $.ui.toast(`This Mac is reserved for "${reservation.reason}" for ${formatLeft(reservation.until - now)}: heavy jobs are blocked.`)
172 }
173 void sample($)
174 return next(e)
175 })
176
177 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
178 if (!isHeavy(e.command, config.extra)) {
179 return next(e)
180 }
181 ensureTimer($)
182 const now = await $.clock.now()
183 if ((await pausedLeft($, now)) > 0) {
184 return next(e)
185 }
186 const reservation = await readReservation($, now)
187 if (reservation) {
188 $.ui.toast(`machine-guard blocked a heavy job: the Mac is reserved for "${reservation.reason}"`)
189 return { deny: reservationDenial(reservation, e.command, now) }
190 }
191 const snapshot = await freshSnapshot($, now)
192 if (snapshot?.pressure === 'critical' && config.blockOnCritical) {
193 $.ui.toast('machine-guard blocked a heavy job: memory is critical')
194 return { deny: criticalDenial(snapshot, e.command) }
195 }
196 const ran = await next(e)
197 if (snapshot?.pressure === 'warn' && ran.deny === undefined) {
198 return { ...ran, context: [...(ran.context ?? []), warnContext(snapshot)] }
199 }
200 return ran
201 })
202
203 on('command.run', { command: 'busy' }, async ($, e) => {
204 const now = await $.clock.now()
205 const args = e.args.trim()
206 const path = await reservationPath($)
207 if (!path) {
208 return { text: 'machine-guard: HOME is not set, so the reservation cannot be saved.' }
209 }
210 const { value: snapshot = null } = await $.state.get(SNAPSHOT)
211 if (args === '') {
212 const reservation = await readReservation($, now)
213 return {
214 text: reservation
215 ? `Reserved for "${reservation.reason}" for ${formatLeft(reservation.until - now)} more. /busy off lifts it.`
216 : 'Not reserved. /busy [2h] [reason] reserves this Mac.',
217 }
218 }
219 if (/^(off|done|clear|free)$/i.test(args)) {
220 await $.fs.write(path, '{}\n')
221 await showStatus($, snapshot, now)
222 return { text: 'Reservation lifted: heavy jobs can run again.' }
223 }
224 const [first = '', ...rest] = args.split(/\s+/)
225 const duration = parseDuration(first)
226 const reason = (duration === null ? args : rest.join(' ')) || 'busy'
227 const reservation: Reservation = { reason, until: now + (duration ?? config.busyMs), setAt: now }
228 await $.fs.write(path, `${JSON.stringify(reservation, null, 2)}\n`)
229 await showStatus($, snapshot, now)
230 return {
231 text: `Reserved this Mac for "${reason}" for ${formatLeft(reservation.until - now)}. Heavy local jobs are blocked in every Claude session until then; /busy off lifts it.`,
232 }
233 })
234
235 on('command.run', { command: 'guard' }, async ($, e) => {
236 const [verb = '', amount = ''] = e.args.trim().split(/\s+/)
237 const now = await $.clock.now()
238 const { value: snapshot = null } = await $.state.get(SNAPSHOT)
239 if (verb === 'pause') {
240 const ms = parseDuration(amount || '15m')
241 if (ms === null) {
242 return { text: 'Usage: /guard pause 15m (or 2h)' }
243 }
244 await $.state.set(PAUSED, now + ms)
245 await showStatus($, snapshot, now)
246 return { text: `Guard paused for ${formatLeft(ms)} in this session.` }
247 }
248 if (verb === 'on' || verb === 'resume') {
249 await $.state.set(PAUSED, 0)
250 await showStatus($, snapshot, now)
251 return { text: 'Guard on.' }
252 }
253 return { text: await describe($) }
254 })
255}
256hooks/probe.ts 202 lines1import type { AppMemory, Pressure, Reservation, Snapshot } from '../types'
2
3/** `sysctl -n kern.memorystatus_vm_pressure_level vm.swapusage`: the level, then the swap line. */
4export const parseSysctl = (output: string): { level: number | null; swapUsedGB: number; swapTotalGB: number } => {
5 const [levelLine = '', ...rest] = output.trim().split('\n')
6 const swap = rest.join(' ')
7 const megabytes = (name: string) => Number(swap.match(new RegExp(`${name} = ([\\d.]+)M`))?.[1] ?? 0)
8 const level = /^\d+$/.test(levelLine.trim()) ? Number(levelLine.trim()) : null
9 return {
10 level,
11 swapUsedGB: Math.round((megabytes('used') / 1024) * 10) / 10,
12 swapTotalGB: Math.round((megabytes('total') / 1024) * 10) / 10,
13 }
14}
15
16/** `memory_pressure -Q`: "System-wide memory free percentage: 37%". */
17export const parseFreePct = (output: string): number | null => {
18 const found = output.match(/free percentage:\s*(\d+)%/)
19 return found ? Number(found[1]) : null
20}
21
22/** `ioreg -r -d 1 -w 0 -c IOAccelerator`: the GPU's "Device Utilization %". */
23export const parseGpu = (output: string): number | null => {
24 const found = output.match(/"Device Utilization %"\s*=\s*(\d+)/)
25 return found ? Number(found[1]) : null
26}
27
28const appName = (command: string): string => {
29 const bundle = command.match(/\/([^/]+)\.app\//)
30 if (bundle?.[1]) {
31 return bundle[1]
32 }
33 const base = command.trim().split('/').pop() ?? command
34 return base.replace(/^(python)\d[\d.]*$/, '$1')
35}
36
37/** `ps -axo rss=,comm=`: memory summed per app (an app's helpers count as the app), biggest first. */
38export const parseTopApps = (output: string, count = 3): AppMemory[] => {
39 const totals = new Map<string, number>()
40 for (const line of output.split('\n')) {
41 const found = line.match(/^\s*(\d+)\s+(.+)$/)
42 if (!found) {
43 continue
44 }
45 const name = appName(found[2] ?? '')
46 totals.set(name, (totals.get(name) ?? 0) + Number(found[1]))
47 }
48 return [...totals.entries()]
49 .sort((a, b) => b[1] - a[1])
50 .slice(0, count)
51 .map(([name, kilobytes]) => ({ name, gb: Math.round((kilobytes / 1024 / 1024) * 10) / 10 }))
52}
53
54/**
55 * macOS's level (1 normal, 2 warn, 4 critical), raised one step when free memory and
56 * swap say worse than the kernel's level does.
57 */
58export const pressureOf = (level: number | null, freePct: number | null, swapUsedGB: number, swapTotalGB: number): Pressure => {
59 const isSwapFull = swapTotalGB > 0 && swapUsedGB / swapTotalGB >= 0.95
60 if (level === 4 || (freePct !== null && freePct < 10 && isSwapFull)) {
61 return 'critical'
62 }
63 if (level === 2 || (freePct !== null && freePct < 20)) {
64 return 'warn'
65 }
66 return 'normal'
67}
68
69const LAUNCHERS = /^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+|nohup\s+|nice\s+(?:-n\s*\d+\s+)?|caffeinate\s+(?:-\S+\s+)*|time\s+|exec\s+)*/
70const REMOTE = /^(?:modal\s+(?:run|deploy|serve|shell)|ssh|gh|git|curl|scp|rsync)\b/
71/** Tools that only read, edit or move text and files: a script name in their arguments is not a launch. */
72const TEXT_TOOLS =
73 /^(?:sed|rg|grep|egrep|awk|cat|head|tail|less|echo|printf|ls|find|fd|jq|wc|sort|cut|tr|diff|cp|mv|rm|mkdir|touch|chmod|ln|tee|git|gh|claude|open|which|file|stat|du|df|ps|pgrep|kill|pkill|sleep|test|\[)\b/
74const NOT_HEAVY = /\b(?:pytest|ruff|mypy|black|flake8|pip3?\s+(?:install|download|show|list)|--help|--version)\b/
75const HEAVY: readonly RegExp[] = [
76 /\b(?:python[\d.]*|uv\s+run|poetry\s+run|conda\s+run)\b.*\b(?:train|finetune|fine_tune|fit|infer|inference|predict|generate|render|bake|reconstruct|extract|eval|evaluate|benchmark|bench|dedup|mesh_build|build_\w*data)/i,
77 /\b\w*(?:train|render|bake|infer)\w*\.(?:py|sh)\b/i,
78 /\b(?:torchrun|deepspeed|accelerate\s+launch)\b/,
79 /(?:^|\/|\s)blender(?:\s|$)|Blender\.app/i,
80 /\bdocker\s+(?:run|build|compose\s+up)\b/,
81 /\bffmpeg\b/,
82 /\b(?:ollama\s+(?:run|serve)|llama-server|llama-cli|mlx_lm\.\w+)\b/,
83]
84
85/**
86 * The command with what is only data taken out: heredoc bodies and single-quoted text dropped,
87 * double-quoted text kept but never split on its `|`, `;` or `&`.
88 */
89const withoutLiterals = (command: string): string => {
90 const noHeredocs = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(?=\n|$)/g, ' ')
91 let out = ''
92 let quote: string | null = null
93 for (const ch of noHeredocs) {
94 if (quote !== null) {
95 if (ch === quote) {
96 quote = null
97 } else if (quote === '"') {
98 out += /[|;&\n]/.test(ch) ? ' ' : ch
99 }
100 continue
101 }
102 if (ch === "'" || ch === '"') {
103 quote = ch
104 out += ' '
105 continue
106 }
107 out += ch
108 }
109 return out
110}
111
112/** Whether a Bash command starts something heavy on this Mac (not remotely, not a test run). */
113export const isHeavy = (command: string, extra: RegExp | null = null): boolean =>
114 withoutLiterals(command)
115 .split(/\s*(?:&&|\|\||;|\||\n)\s*/)
116 .map(segment => segment.trim().replace(/^cd\s+\S+$/, ''))
117 .filter(Boolean)
118 .some(segment => {
119 const body = segment.replace(LAUNCHERS, '')
120 if (REMOTE.test(body) || TEXT_TOOLS.test(body) || NOT_HEAVY.test(body)) {
121 return false
122 }
123 return HEAVY.some(pattern => pattern.test(body)) || (extra?.test(body) ?? false)
124 })
125
126/** `30m`, `2h`, `1.5h`, `1d` in milliseconds; null for anything else. */
127export const parseDuration = (text: string): number | null => {
128 const found = text.match(/^(\d+(?:\.\d+)?)(m|h|d)$/)
129 if (!found) {
130 return null
131 }
132 const unit = found[2] === 'm' ? 60_000 : found[2] === 'h' ? 3_600_000 : 86_400_000
133 return Math.round(Number(found[1]) * unit)
134}
135
136export const formatLeft = (ms: number): string => {
137 const minutes = Math.max(1, Math.round(ms / 60_000))
138 return minutes < 90 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
139}
140
141/** A reservation read from its file, or null when there is none, it is malformed, or it ran out. */
142export const parseReservation = (text: string | null, now: number): Reservation | null => {
143 if (!text) {
144 return null
145 }
146 try {
147 const value = JSON.parse(text) as Partial<Reservation>
148 return typeof value.until === 'number' && value.until > now
149 ? { reason: String(value.reason ?? 'busy'), until: value.until, setAt: Number(value.setAt ?? now) }
150 : null
151 } catch {
152 return null
153 }
154}
155
156const biggest = (snapshot: Snapshot): string =>
157 snapshot.top.map(app => `${app.name} ${app.gb} GB`).join(', ')
158
159/** The status line: memory first, GPU, and the reservation or pause when there is one. */
160export const statusLine = (snapshot: Snapshot | null, reservation: Reservation | null, pausedLeftMs: number): string => {
161 const parts: string[] = []
162 if (snapshot) {
163 const free = snapshot.freePct === null ? '' : `${snapshot.freePct}% free`
164 const swap = snapshot.swapTotalGB > 0 ? `swap ${snapshot.swapUsedGB}/${snapshot.swapTotalGB}G` : ''
165 const head =
166 snapshot.pressure === 'critical' ? 'RAM CRITICAL' : snapshot.pressure === 'warn' ? 'RAM tight' : 'RAM'
167 parts.push([head, free].filter(Boolean).join(' '))
168 if (swap) {
169 parts.push(swap)
170 }
171 if (snapshot.pressure !== 'normal' && snapshot.top[0]) {
172 parts.push(`top ${snapshot.top[0].name} ${snapshot.top[0].gb}G`)
173 }
174 if (snapshot.gpuPct !== null) {
175 parts.push(`GPU ${snapshot.gpuPct}%`)
176 }
177 }
178 if (reservation) {
179 parts.push(`reserved ${formatLeft(reservation.until - (snapshot?.at ?? reservation.setAt))}: ${reservation.reason}`)
180 }
181 if (pausedLeftMs > 0) {
182 parts.push(`guard paused ${formatLeft(pausedLeftMs)}`)
183 }
184 return parts.join(' · ')
185}
186
187const quoted = (command: string): string => {
188 const line = command.replace(/\s+/g, ' ').trim()
189 return `\`${line.length > 120 ? `${line.slice(0, 119)}…` : line}\``
190}
191
192export const criticalDenial = (snapshot: Snapshot, command: string): string =>
193 `machine-guard: memory pressure is critical on this Mac (${snapshot.freePct ?? '?'}% free, swap ${snapshot.swapUsedGB} of ${snapshot.swapTotalGB} GB; biggest: ${biggest(snapshot)}), so this heavy job was not started: ${quoted(command)}. ` +
194 'Wait for a running job to finish or free memory and retry, run it remotely (e.g. Modal), or ask the user, who can override with /guard pause 15m.'
195
196export const reservationDenial = (reservation: Reservation, command: string, now: number): string =>
197 `machine-guard: the user has reserved this Mac for "${reservation.reason}" for ${formatLeft(reservation.until - now)} more, so heavy local jobs are blocked: ${quoted(command)}. ` +
198 'Do other work meanwhile, run it remotely (e.g. Modal), or ask the user, who can lift the reservation with /busy off.'
199
200export const warnContext = (snapshot: Snapshot): string =>
201 `machine-guard: memory is tight on this Mac (${snapshot.freePct ?? '?'}% free, swap ${snapshot.swapUsedGB} of ${snapshot.swapTotalGB} GB; biggest: ${biggest(snapshot)}). Avoid starting more heavy jobs in parallel; prefer running one at a time.`
202types/index.d.ts 25 lines1export type Pressure = 'normal' | 'warn' | 'critical'
2
3export type AppMemory = { name: string; gb: number }
4
5export type Snapshot = {
6 at: number
7 pressure: Pressure
8 /** macOS's "System-wide memory free percentage"; null when it could not be read. */
9 freePct: number | null
10 swapUsedGB: number
11 swapTotalGB: number
12 gpuPct: number | null
13 /** The apps holding the most memory, helpers summed into their app. */
14 top: AppMemory[]
15}
16
17/** A reservation made with /busy, kept in ~/.claude/machine-guard.json so every session sees it. */
18export type Reservation = { reason: string; until: number; setAt: number }
19
20declare module 'claude-code' {
21 interface PluginState {
22 'machine-guard': { snapshot: Snapshot | null; pausedUntil: number }
23 }
24}
25