Shows your Mac's CPU and memory under the prompt, names the app behind a slowdown, and warns when the Mac is overloaded

Your Mac's CPU and memory, in Claude Code's footer. When the Mac slows down, MacLoad names the app behind it, and it tells you when the Mac is truly overloaded. Made for one Mac shared by several Claude chats, Xcode builds and iOS simulators.
CPU 28% · Memory 56% · 1 sim
CPU ▲ 92% · Memory 56% · Xcode using 8 cores
CPU ◆ 100% · Memory 51% · Simulator using 6 cores
/mac-load: a breakdown by app, with each booted simulator by name, the load over 1, 5 and 15 minutes, and swap. It answers straight away, even while Claude is replying.| Busy ▲ | Overloaded ◆ | |
|---|---|---|
| CPU | 85% or more, until it drops under 70% | 90% or more with a load of 3× the cores, until the CPU drops under 75% or the load under 2× the cores |
| Memory | Memory pressure "warning", yellow in Activity Monitor | Memory pressure "critical", red in Activity Monitor |
| Simulators | 3 or more booted |
A single build that keeps every core busy counts as busy. Overloaded means work is queueing for the cores, as with several builds at once or a runaway simulator. MacLoad doesn't go by the load average alone, because on a Mac it lags and can stay high over idle cores.
Every 30 seconds, one open chat reads the Mac and the others show its reading, so ten open chats cost about as much as one. A reading runs three read-only commands, each by its full path, with no shell and a 10-second timeout:
iostat for the CPU over two secondsps for each process's CPU and the app it belongs tosysctl for the cores, memory, memory pressure, load and swapWhile memory is strained, it also runs top to read each process's memory footprint, the figure Activity Monitor shows. The notification is posted with osascript; the first time, macOS may ask you to allow notifications from Script Editor, which posts them for osascript.
Processes are grouped into apps by where their programs live:
.app it lives in, so all of Claude's helper processes count as Claude. macOS's own processes count as macOS, and other programs under their own names.On a system MacLoad has never read, such as one that isn't a Mac, it gives up after three tries. Once it has worked, a failed reading means the Mac was too busy to answer in time, so it keeps trying and shows the last reading with its age.
macOS and Claude Code. Nothing else: Macs without Xcode work too.
MacLoad installs with the other mods in this repository. See the main README.
claude plugin validate .
claude plugin test .
The parsing, grouping, levels and text live in hooks/format.ts, with no calls into Claude Code, so they can be tested alone. hooks/register.tsx holds the polling, the shared reading, the alerts and the command.
hooks/register.tsx 319 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Levels, Reading } from '../types'
5import {
6 ALERT_COOLDOWN,
7 FRESH_FOR,
8 SYSCTL_NAMES,
9 alertOf,
10 appleScriptString,
11 appsOf,
12 cpuOf,
13 detailsOf,
14 isReading,
15 keepTop,
16 levelsOf,
17 memoryLevelOf,
18 parseIostat,
19 parsePs,
20 parseSimulators,
21 parseSysctl,
22 parseTop,
23 simulatorsOf,
24 statusOf,
25 worstOf,
26} from './format'
27
28const CALM: Levels = { cpu: 'normal', memory: 'normal', simulators: 'normal' }
29
30const readingAtom = atom({ plugin: 'MacLoad', key: 'reading' } as const, null, { shape: 'reading-2' })
31const levelsAtom = atom({ plugin: 'MacLoad', key: 'levels' } as const, CALM)
32const stoppedAtom = atom({ plugin: 'MacLoad', key: 'isStopped' } as const, false)
33const failuresAtom = atom({ plugin: 'MacLoad', key: 'failures' } as const, 0)
34const workedAtom = atom({ plugin: 'MacLoad', key: 'hasWorked' } as const, false)
35const alertedAtAtom = atom({ plugin: 'MacLoad', key: 'alertedAt' } as const, 0)
36
37const POLL_MS = 30_000
38// Room for a struggling Mac to answer: that is when a reading matters most.
39const TIMEOUT_MS = 10_000
40// Failed readings in a row after which the mod gives up, on a system it has never read.
41const MAX_FAILURES = 3
42// The store keys: set once a reading has worked on this Mac, for every later chat; the
43// latest reading, which every open chat shows rather than each reading the Mac itself;
44// and when any chat last alerted.
45const WORKED = 'worked'
46const SHARED = 'reading'
47const ALERTED = 'alertedAt'
48
49// Absolute paths, so no program of the same name earlier on the PATH runs instead; the C
50// locale, so numbers come with decimal points. ps alone runs in UTF-8, which keeps app
51// names such as "Café" whole where C would escape them; its numbers parse either way.
52// Each only reads.
53const IOSTAT = ['/usr/sbin/iostat', '-n0', '-c', '2', '-w', '2']
54const PS = ['/bin/ps', '-A', '-o', 'pid=,ppid=,pcpu=,comm=']
55const SYSCTL = ['/usr/sbin/sysctl', ...SYSCTL_NAMES]
56const TOP = ['/usr/bin/top', '-l', '1', '-o', 'mem', '-stats', 'pid,mem']
57const SIMCTL = ['/usr/bin/xcrun', 'simctl', 'list', 'devices', 'booted', '--json']
58const OSASCRIPT = '/usr/bin/osascript'
59const C = { LC_ALL: 'C' }
60const UTF8 = { LC_ALL: 'en_US.UTF-8' }
61
62// These live as long as this load of the module; a hot reload drops the old timer itself.
63let timer: { cancel: () => void } | undefined
64let isPolling = false
65let shown: string | undefined
66
67// What a command printed, or undefined when it cannot start or runs out of time. It runs
68// in / rather than the session's folder, which may since have been deleted.
69async function run($: EngineInterface, argv: readonly string[], env: Record<string, string> = C) {
70 try {
71 return await $.process.run(argv, { cwd: '/', timeoutMs: TIMEOUT_MS, env })
72 } catch {
73 return undefined
74 }
75}
76
77// Reads the Mac once; undefined when the cores, or the CPU by any means, cannot be read.
78// Each app's memory is read too `withMemory`, or while memory is strained and the culprit
79// is wanted.
80async function measure($: EngineInterface, withMemory: boolean): Promise<Reading | undefined> {
81 const [iostat, ps, sysctl] = await Promise.all([run($, IOSTAT), run($, PS, UTF8), run($, SYSCTL)])
82 // sysctl exits 1 when this Mac lacks one of the names, and writes the others all the same.
83 const { cores, ...system } = parseSysctl(sysctl?.stdout ?? '')
84 const processes = ps?.exitCode === 0 ? parsePs(ps.stdout) : []
85
86 if (cores === undefined) {
87 return undefined
88 }
89
90 // A Mac too busy for iostat's two seconds still answers ps.
91 const cpu = (iostat?.exitCode === 0 ? parseIostat(iostat.stdout) : undefined) ?? cpuOf(processes, cores)
92
93 if (cpu === undefined) {
94 return undefined
95 }
96
97 let footprints: Map<number, number> | undefined
98
99 if (processes.length > 0 && (withMemory || memoryLevelOf(system) !== 'normal')) {
100 const top = await run($, TOP)
101 footprints = top?.exitCode === 0 ? parseTop(top.stdout) : undefined
102 }
103
104 return {
105 at: await $.clock.now(),
106 cpu,
107 cores,
108 ...system,
109 simulators: simulatorsOf(processes),
110 apps: keepTop(appsOf(processes, footprints)),
111 }
112}
113
114function show($: EngineInterface, text: string | undefined) {
115 if (text !== shown) {
116 shown = text
117 $.ui.status(text)
118 }
119}
120
121async function stop($: EngineInterface) {
122 timer?.cancel()
123 timer = undefined
124 // Cleared even when this load of the module drew nothing: an earlier one may have.
125 shown = undefined
126 $.ui.status(undefined)
127 await update($, stoppedAtom, () => true)
128}
129
130// Whether a reading has worked on this Mac before, in any chat: then a failure is a Mac
131// too busy to answer in time, not a system without these commands.
132async function hasWorked($: EngineInterface) {
133 if (await read($, workedAtom)) {
134 return true
135 }
136
137 try {
138 return (await $.store.get(WORKED)) === true
139 } catch {
140 return false
141 }
142}
143
144async function markWorked($: EngineInterface) {
145 if (await read($, workedAtom)) {
146 return
147 }
148
149 await update($, workedAtom, () => true)
150
151 try {
152 await $.store.set(WORKED, true)
153 } catch {
154 // Only a later chat's patience with a slow first reading depends on it.
155 }
156}
157
158// Posts an overload as a macOS notification, which arrives at the top right of the screen
159// as the person's other alerts do, rather than over the transcript. Once per overload
160// across every open chat: the first to see it claims it in the store. Where no
161// notification can be posted, the chat's own toast says it instead.
162async function alert($: EngineInterface, title: string, body: string, now: number) {
163 try {
164 const last = await $.store.get(ALERTED)
165
166 if (typeof last === 'number' && now - last < ALERT_COOLDOWN) {
167 return
168 }
169
170 await $.store.set(ALERTED, now)
171 } catch {
172 // Without the store, this chat's own cooldown still keeps it to one alert.
173 }
174
175 const script = `display notification ${appleScriptString(body)} with title ${appleScriptString(title)}`
176 const posted = await run($, [OSASCRIPT, '-e', script])
177
178 if (posted?.exitCode !== 0) {
179 $.ui.toast(`${title}: ${body}`, { timeoutMs: 10_000 })
180 }
181}
182
183// The reading another chat stored within FRESH_FOR, if any.
184async function sharedReading($: EngineInterface, now: number) {
185 try {
186 const stored = await $.store.get(SHARED)
187
188 return isReading(stored) && now - stored.at < FRESH_FOR ? stored : undefined
189 } catch {
190 return undefined
191 }
192}
193
194async function share($: EngineInterface, reading: Reading) {
195 try {
196 await $.store.set(SHARED, reading)
197 } catch {
198 // Other chats read the Mac themselves.
199 }
200}
201
202async function take($: EngineInterface) {
203 const shared = await sharedReading($, await $.clock.now())
204 const reading = shared ?? (await measure($, false))
205 const now = await $.clock.now()
206
207 if (reading !== undefined && shared === undefined) {
208 await share($, reading)
209 }
210
211 if (reading === undefined) {
212 const failures = (await read($, failuresAtom)) + 1
213 const last = await read($, readingAtom)
214 await update($, failuresAtom, () => failures)
215
216 if (failures >= MAX_FAILURES && !(await hasWorked($))) {
217 await stop($)
218 } else if (last !== null) {
219 // The last reading stays, with its age once it is stale.
220 show($, statusOf(last, await read($, levelsAtom), now))
221 }
222
223 return
224 }
225
226 await update($, failuresAtom, () => 0)
227 await markWorked($)
228
229 const before = await read($, levelsAtom)
230 const levels = levelsOf(reading, before)
231 await update($, readingAtom, () => reading)
232 await update($, levelsAtom, () => levels)
233 show($, statusOf(reading, levels, now))
234
235 const overload = alertOf(reading, levels)
236
237 if (overload !== undefined && worstOf(before) !== 'overloaded' && now - (await read($, alertedAtAtom)) >= ALERT_COOLDOWN) {
238 await update($, alertedAtAtom, () => now)
239 await alert($, overload.title, overload.body, now)
240 }
241}
242
243// Reads the Mac once. Never throws; one reading runs at a time.
244async function poll($: EngineInterface) {
245 if (isPolling || (await read($, stoppedAtom))) {
246 return
247 }
248
249 isPolling = true
250
251 try {
252 await take($)
253 } catch {
254 // The last line stays on screen until the next reading.
255 } finally {
256 isPolling = false
257 }
258}
259
260// Starts the readings once per load of this module: at the session's start, which fires
261// again after a reload, and on each reply in case that start's hook failed.
262function ensurePolling($: EngineInterface) {
263 if (timer !== undefined) {
264 return
265 }
266
267 timer = $.clock.every(POLL_MS, () => void poll($))
268 // Not awaited: a slow command must not hold up the session.
269 void poll($)
270}
271
272export const register: Register = on => {
273 on('session.start', async ($, e, next) => {
274 await $.command.register({
275 name: 'mac-load',
276 description: 'Show what is using your Mac: CPU, memory and simulators, by app',
277 // A slow Mac is when it is wanted, often while a turn is still running.
278 immediate: true,
279 })
280
281 ensurePolling($)
282
283 return next(e)
284 })
285
286 on('session.measure', async ($, e, next) => {
287 if (!(await read($, stoppedAtom))) {
288 ensurePolling($)
289 }
290
291 return next(e)
292 })
293
294 // A /clear ends the session's state but not this module: the line is drawn afresh.
295 on('session.end', async ($, e, next) => {
296 shown = undefined
297
298 return next(e)
299 })
300
301 on('command.run', { command: 'mac-load' }, async $ => {
302 const reading = await measure($, true)
303
304 if (reading === undefined) {
305 return {
306 text: (await hasWorked($))
307 ? 'The Mac did not answer in time. Try again in a moment.'
308 : 'Nothing to read here: mac-load needs macOS.',
309 }
310 }
311
312 const levels = levelsOf(reading, await read($, levelsAtom))
313 const simctl = reading.simulators > 0 ? await run($, SIMCTL) : undefined
314 const simulators = simctl?.exitCode === 0 ? parseSimulators(simctl.stdout) : []
315
316 return { text: detailsOf(reading, levels, simulators) }
317 })
318}
319hooks/format.ts 550 lines1import type { App, Level, Levels, Pressure, Reading, Simulator } from '../types'
2
3// The CPU is busy from `enter` percent of all cores and stays so until it falls under
4// `stay`. Overloaded also needs the load to show work queueing for the cores: `load`
5// times their number to enter, `loadStay` times to stay. A single build keeps every core
6// busy without that queue; parallel builds or a runaway simulator build one.
7export const CPU_BUSY = { enter: 85, stay: 70 }
8export const CPU_OVERLOADED = { enter: 90, stay: 75, load: 3, loadStay: 2 }
9export const SIMULATORS_BUSY = 3
10// Only for a Mac that does not give its memory pressure: the share of memory in use.
11export const MEMORY_BUSY = 85
12export const MEMORY_OVERLOADED = 92
13
14// The least share of the busy cores an app needs to be named as the cause.
15export const CULPRIT_SHARE = 0.25
16
17// A reading older than this is shown with its age: the Mac has not answered in time since.
18export const STALE_AFTER = 75_000
19// A reading another chat took within this long is used as it is, rather than read again.
20export const FRESH_FOR = 25_000
21// One alert per overload: after one, no chat alerts again for this long.
22export const ALERT_COOLDOWN = 10 * 60_000
23
24// What one `sysctl` reads: the cores, the memory, macOS's memory pressure, the load, the swap.
25export const SYSCTL_NAMES = [
26 'hw.logicalcpu',
27 'hw.memsize',
28 'kern.memorystatus_level',
29 'kern.memorystatus_vm_pressure_level',
30 'vm.loadavg',
31 'vm.swapusage',
32]
33
34const NUMBER = /^\d+(?:[.,]\d+)?$/
35const UNITS: Record<string, number> = { B: 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }
36const PRESSURES: Record<string, Pressure> = { '1': 'normal', '2': 'warning', '4': 'critical' }
37
38const number = (text: string) => (NUMBER.test(text) ? Number(text.replace(',', '.')) : NaN)
39const basename = (command: string) => command.slice(command.lastIndexOf('/') + 1)
40
41// "1586.19M", as sysctl and top write sizes.
42function bytesOf(text: string) {
43 const match = /^(\d+(?:[.,]\d+)?)([BKMGT])$/.exec(text)
44 const unit = UNITS[match?.[2] ?? '']
45
46 return match === null || unit === undefined ? undefined : number(match[1] ?? '') * unit
47}
48
49// `iostat -n0 -c 2 -w 2` writes a row since boot, then one for the last two seconds:
50// user, system and idle, then the load. How busy the cores were over those seconds.
51export function parseIostat(text: string) {
52 const rows = text
53 .split('\n')
54 .map(line => line.trim().split(/\s+/))
55 .filter(fields => fields.length >= 3 && fields.slice(0, 3).every(field => NUMBER.test(field)))
56 const idle = number(rows.at(-1)?.[2] ?? '')
57
58 // A lone row counts from boot, which says nothing about now.
59 return rows.length < 2 || !(idle <= 100) ? undefined : 100 - idle
60}
61
62export type System = Omit<Reading, 'at' | 'cpu' | 'cores' | 'simulators' | 'apps'> & { cores?: number }
63
64// `sysctl` with SYSCTL_NAMES writes "name: value" for each this Mac has.
65export function parseSysctl(text: string): System {
66 const values = new Map<string, string>()
67
68 for (const line of text.split('\n')) {
69 const at = line.indexOf(':')
70
71 if (at > 0) {
72 values.set(line.slice(0, at).trim(), line.slice(at + 1).trim())
73 }
74 }
75
76 const system: System = {}
77 const cores = number(values.get('hw.logicalcpu') ?? '')
78 const ram = number(values.get('hw.memsize') ?? '')
79 const available = number(values.get('kern.memorystatus_level') ?? '')
80 const pressure = PRESSURES[values.get('kern.memorystatus_vm_pressure_level') ?? '']
81 const load = /^\{\s*(\S+)\s+(\S+)\s+(\S+)\s*\}$/.exec(values.get('vm.loadavg') ?? '')
82 const swap = /total = (\S+)\s+used = (\S+)/.exec(values.get('vm.swapusage') ?? '')
83
84 if (Number.isInteger(cores) && cores > 0) {
85 system.cores = cores
86 }
87
88 if (ram > 0) {
89 system.ramBytes = ram
90 }
91
92 if (available >= 0 && available <= 100) {
93 system.memory = 100 - available
94 }
95
96 if (pressure !== undefined) {
97 system.pressure = pressure
98 }
99
100 if (load !== null) {
101 const averages = [number(load[1] ?? ''), number(load[2] ?? ''), number(load[3] ?? '')] as [number, number, number]
102
103 if (averages.every(Number.isFinite)) {
104 system.load = averages
105 }
106 }
107
108 const totalBytes = bytesOf(swap?.[1] ?? '')
109 const usedBytes = bytesOf(swap?.[2] ?? '')
110
111 if (totalBytes !== undefined && usedBytes !== undefined && totalBytes > 0) {
112 system.swap = { usedBytes, totalBytes }
113 }
114
115 return system
116}
117
118// One process: its parent, the share of one core it keeps busy, and its executable, by
119// full path where macOS gives one.
120export type Process = { pid: number; ppid: number; cpu: number; command: string }
121
122// `ps -A -o pid=,ppid=,pcpu=,comm=`.
123export function parsePs(text: string): Process[] {
124 const processes: Process[] = []
125
126 for (const line of text.split('\n')) {
127 const match = /^\s*(\d+)\s+(\d+)\s+(\d+(?:[.,]\d+)?)\s+(\S.*?)\s*$/.exec(line)
128
129 if (match !== null) {
130 processes.push({ pid: Number(match[1]), ppid: Number(match[2]), cpu: number(match[3] ?? ''), command: match[4] ?? '' })
131 }
132 }
133
134 return processes
135}
136
137// How busy the cores are by the processes' own figures: the stand-in when iostat does not
138// answer. Less exact, since ps lists neither the kernel nor processes that already ended.
139export function cpuOf(processes: readonly Process[], cores: number) {
140 if (processes.length === 0) {
141 return undefined
142 }
143
144 const busy = processes.reduce((sum, process) => sum + (Number.isFinite(process.cpu) ? process.cpu : 0), 0)
145
146 return Math.min(100, busy / cores)
147}
148
149// `top -l 1 -o mem -stats pid,mem`: the memory footprint of every process, the figure
150// Activity Monitor shows, by process id.
151export function parseTop(text: string) {
152 const footprints = new Map<number, number>()
153
154 for (const line of text.split('\n')) {
155 const match = /^\s*(\d+)\s+(\S+?)[+-]?\s*$/.exec(line)
156 const bytes = bytesOf(match?.[2] ?? '')
157
158 if (match !== null && bytes !== undefined) {
159 footprints.set(Number(match[1]), bytes)
160 }
161 }
162
163 return footprints
164}
165
166// Xcode itself and the processes of a build, by name. Other programs that ship inside the
167// developer tools, such as git, make and python3, count under their own names.
168const XCODE_PROCESSES = new Set([
169 'Xcode',
170 'xcodebuild',
171 'swift',
172 'swiftc',
173 'clang',
174 'clang++',
175 'ld',
176 'ld64',
177 'libtool',
178 'lipo',
179 'ibtool',
180 'ibtoold',
181 'actool',
182 'momc',
183 'mapc',
184 'xctest',
185 'debugserver',
186 'SourceKitService',
187])
188const XCODE_PREFIXES = ['swift-', 'XCB', 'SWB', 'IBAgent', 'com.apple.dt.', 'lldb']
189
190const isDeveloperTool = (command: string) =>
191 /\/Xcode[^/]*\.app\//.test(command) || command.includes('.xctoolchain/') || command.startsWith('/Library/Developer/CommandLineTools/')
192
193// The app a process belongs to, by where its executable lives: the simulators and Xcode's
194// builds first, then the outermost .app bundle, then macOS's own folders.
195export function appOf(command: string) {
196 const name = basename(command)
197
198 if (command.includes('/CoreSimulator') || command.includes('/Simulator.app/') || name === 'launchd_sim') {
199 return 'Simulator'
200 }
201
202 if (isDeveloperTool(command)) {
203 return XCODE_PROCESSES.has(name) || XCODE_PREFIXES.some(prefix => name.startsWith(prefix)) ? 'Xcode' : name
204 }
205
206 // WebKit draws web pages in processes of its own, for Safari and for any app with a web view.
207 if (command.includes('/com.apple.WebKit.')) {
208 return 'Web pages'
209 }
210
211 // A virtual machine's guest (Docker, OrbStack, UTM) runs in a process of Apple's framework.
212 if (command.includes('/com.apple.Virtualization.VirtualMachine')) {
213 return 'Virtual machine'
214 }
215
216 const bundle = /\/([^/]+)\.app\//.exec(command)?.[1]
217
218 if (bundle !== undefined) {
219 // Claude Code's own bundle is lower case; macOS's lower-case bundles are its daemons.
220 if (bundle === 'claude') {
221 return 'Claude'
222 }
223
224 return command.startsWith('/System/') && /^[a-z]/.test(bundle) ? 'macOS' : bundle
225 }
226
227 // Claude Code in a terminal, installed outside any app.
228 if (name === 'claude') {
229 return 'Claude'
230 }
231
232 // /usr/local is Homebrew's on Intel Macs, not macOS's.
233 return /^\/(?:System|bin|sbin|Library\/Apple|usr(?!\/local\/))\//.test(command) ? 'macOS' : name
234}
235
236const isSimulatorLaunchd = (process: Process) => basename(process.command) === 'launchd_sim'
237
238// Each booted simulator runs one launchd_sim: iOS, watchOS, tvOS and visionOS ones, and
239// those Xcode boots for its previews.
240export const simulatorsOf = (processes: readonly Process[]) => processes.filter(isSimulatorLaunchd).length
241
242// Each app's processes together, busiest first, with the busiest two of them by name.
243// A simulator's children count as Simulator even where ps gives them no path. Memory is
244// added up only from `footprints`, when it was read.
245export function appsOf(processes: readonly Process[], footprints?: ReadonlyMap<number, number>): App[] {
246 type Tally = { cores: number; bytes: number; processes: Map<string, { count: number; cores: number }> }
247 const tallies = new Map<string, Tally>()
248 const simulators = new Set(processes.filter(isSimulatorLaunchd).map(process => process.pid))
249
250 for (const process of processes) {
251 const name = simulators.has(process.ppid) ? 'Simulator' : appOf(process.command)
252 const tally = tallies.get(name) ?? { cores: 0, bytes: 0, processes: new Map() }
253 const own = tally.processes.get(basename(process.command)) ?? { count: 0, cores: 0 }
254 const cores = Number.isFinite(process.cpu) ? process.cpu / 100 : 0
255
256 own.count += 1
257 own.cores += cores
258 tally.processes.set(basename(process.command), own)
259 tally.cores += cores
260 tally.bytes += footprints?.get(process.pid) ?? 0
261 tallies.set(name, tally)
262 }
263
264 return [...tallies]
265 .map(([name, tally]) => ({
266 name,
267 cores: tally.cores,
268 ...(footprints === undefined ? {} : { bytes: tally.bytes }),
269 processes: [...tally.processes]
270 .map(([process, own]) => ({ name: process, ...own }))
271 .sort((a, b) => b.cores - a.cores)
272 .slice(0, 2),
273 }))
274 .sort((a, b) => b.cores - a.cores)
275}
276
277const byBytes = (a: App, b: App) => (b.bytes ?? 0) - (a.bytes ?? 0)
278
279// The `count` busiest apps and the `count` largest: all a reading needs to keep.
280export function keepTop(apps: readonly App[], count = 8) {
281 const kept = new Set([...apps.slice(0, count), ...[...apps].sort(byBytes).slice(0, count)])
282
283 return apps.filter(app => kept.has(app))
284}
285
286const RANKS: Record<Level, number> = { normal: 0, busy: 1, overloaded: 2 }
287
288export const worstOf = (levels: Levels): Level =>
289 [levels.cpu, levels.memory, levels.simulators].reduce((worst, level) => (RANKS[level] > RANKS[worst] ? level : worst))
290
291// Where the CPU stands, given where it stood: a level is left only with margin to spare.
292export function cpuLevelOf(reading: Reading, previous: Level): Level {
293 const load = reading.load?.[0]
294 const queues = (times: number) => load !== undefined && load >= times * reading.cores
295
296 if (
297 (reading.cpu >= CPU_OVERLOADED.enter && queues(CPU_OVERLOADED.load))
298 || (previous === 'overloaded' && reading.cpu >= CPU_OVERLOADED.stay && queues(CPU_OVERLOADED.loadStay))
299 ) {
300 return 'overloaded'
301 }
302
303 return reading.cpu >= CPU_BUSY.enter || (previous !== 'normal' && reading.cpu >= CPU_BUSY.stay) ? 'busy' : 'normal'
304}
305
306// macOS's memory pressure, which has margins of its own; the share in use where it gives none.
307export function memoryLevelOf(reading: Pick<Reading, 'memory' | 'pressure'>): Level {
308 if (reading.pressure !== undefined) {
309 return reading.pressure === 'critical' ? 'overloaded' : reading.pressure === 'warning' ? 'busy' : 'normal'
310 }
311
312 if (reading.memory === undefined) {
313 return 'normal'
314 }
315
316 return reading.memory >= MEMORY_OVERLOADED ? 'overloaded' : reading.memory >= MEMORY_BUSY ? 'busy' : 'normal'
317}
318
319export const levelsOf = (reading: Reading, previous: Levels): Levels => ({
320 cpu: cpuLevelOf(reading, previous.cpu),
321 memory: memoryLevelOf(reading),
322 simulators: reading.simulators >= SIMULATORS_BUSY ? 'busy' : 'normal',
323})
324
325const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`
326
327// "3 cores", "1.4 cores", "1 core".
328export function formatCores(cores: number) {
329 const rounded = cores >= 2 ? Math.round(cores) : Math.round(cores * 10) / 10
330
331 return `${rounded} ${rounded === 1 ? 'core' : 'cores'}`
332}
333
334// "2.6 GB", "16 GB", "700 MB".
335export function formatBytes(bytes: number) {
336 if (bytes < 1024 ** 3) {
337 return `${Math.round(bytes / 1024 ** 2)} MB`
338 }
339
340 return `${(bytes / 1024 ** 3).toFixed(1).replace(/\.0$/, '')} GB`
341}
342
343function formatAge(ms: number) {
344 const minutes = Math.max(1, Math.round(ms / 60_000))
345
346 return minutes < 60 ? `${minutes}m` : `${Math.round(minutes / 60)}h`
347}
348
349// The app behind the worst strain: the busiest by CPU when the CPU is at least as strained
350// as memory, else the largest by memory, falling back on the other strain's app when none
351// stands out. For the CPU an app stands out with half a core and a quarter of the busy
352// cores: the kernel's work, which ps never lists, may be the rest. None while nothing is strained.
353export function culpritOf(reading: Reading, levels: Levels) {
354 const cpu = RANKS[levels.cpu]
355 const memory = RANKS[levels.memory]
356 const busiest = reading.apps[0]
357 const largest = [...reading.apps].sort(byBytes)[0]
358 const busyCores = (reading.cpu / 100) * reading.cores
359 const byCpu = cpu === 0 || busiest === undefined || busiest.cores < Math.max(0.5, CULPRIT_SHARE * busyCores)
360 ? undefined
361 : { name: busiest.name, text: `${busiest.name} using ${formatCores(busiest.cores)}` }
362 const byMemory = memory === 0 || largest?.bytes === undefined || largest.bytes === 0
363 ? undefined
364 : { name: largest.name, text: `${largest.name} using ${formatBytes(largest.bytes)}` }
365
366 return cpu >= memory ? byCpu ?? byMemory : byMemory ?? byCpu
367}
368
369const MARKS: Record<Level, string> = { normal: '', busy: '▲ ', overloaded: '◆ ' }
370
371// "CPU ▲ 92% · Memory 56% · Xcode using 8 cores": each strained part marked as the rate
372// limits band marks its windows, then the app behind the worst strain.
373export function statusOf(reading: Reading, levels: Levels, now: number) {
374 const culprit = culpritOf(reading, levels)
375 const parts = [`CPU ${MARKS[levels.cpu]}${Math.round(reading.cpu)}%`]
376
377 if (reading.memory !== undefined) {
378 parts.push(`Memory ${MARKS[levels.memory]}${Math.round(reading.memory)}%`)
379 }
380
381 // The app behind a strain says more than a count of simulators, unless they are the strain.
382 if (reading.simulators > 0 && (culprit === undefined || levels.simulators !== 'normal')) {
383 parts.push(`${MARKS[levels.simulators]}${plural(reading.simulators, 'sim')}`)
384 }
385
386 if (culprit !== undefined) {
387 parts.push(culprit.text)
388 }
389
390 if (now - reading.at >= STALE_AFTER) {
391 parts.push(`as of ${formatAge(now - reading.at)} ago`)
392 }
393
394 return parts.join(' · ')
395}
396
397const ADVICE: Record<string, string> = {
398 Simulator: 'Shut down simulators you are not using.',
399 Xcode: 'Run fewer builds at once.',
400}
401
402const sentence = (text: string) => text.charAt(0).toUpperCase() + text.slice(1)
403
404// What to say as the Mac becomes overloaded: what is strained, the app behind it, and
405// what would help. Nothing while it is not overloaded.
406export function alertOf(reading: Reading, levels: Levels) {
407 const strains = [
408 ...(levels.cpu === 'overloaded' ? [`CPU at ${Math.round(reading.cpu)}%`] : []),
409 ...(levels.memory === 'overloaded' ? ['memory nearly full'] : []),
410 ]
411
412 if (strains.length === 0) {
413 return undefined
414 }
415
416 const culprit = culpritOf(reading, levels)
417 const advice = culprit === undefined ? undefined : ADVICE[culprit.name]
418 const body = `${sentence(strains.join(' and '))}${culprit === undefined ? '' : `, ${culprit.text}`}.${advice === undefined ? '' : ` ${advice}`}`
419
420 return { title: 'Mac is overloaded', body }
421}
422
423// A string as AppleScript writes one: quoted, its backslashes and quotes escaped, and
424// any control character, a line break among them, made a space.
425export function appleScriptString(text: string) {
426 return `"${text.replace(/[\\"]/g, '\\$&').replace(/[\u0000-\u001f\u007f]/g, ' ')}"`
427}
428
429// "com.apple.CoreSimulator.SimRuntime.iOS-26-0" reads as "iOS 26.0".
430export function runtimeName(key: string) {
431 const name = key.slice(key.lastIndexOf('.') + 1)
432 const [platform, ...version] = name.split('-')
433
434 return version.length > 0 ? `${platform} ${version.join('.')}` : name
435}
436
437// The booted devices in `xcrun simctl list devices booted --json`; none for anything unreadable.
438export function parseSimulators(text: string): Simulator[] {
439 let parsed: unknown
440
441 try {
442 parsed = JSON.parse(text)
443 } catch {
444 return []
445 }
446
447 const devices = (parsed as { devices?: unknown } | null)?.devices
448
449 if (typeof devices !== 'object' || devices === null) {
450 return []
451 }
452
453 const booted: Simulator[] = []
454
455 for (const [key, list] of Object.entries(devices)) {
456 if (!Array.isArray(list)) {
457 continue
458 }
459
460 for (const device of list) {
461 if (device?.state === 'Booted' && typeof device.name === 'string') {
462 booted.push({ name: device.name, runtime: runtimeName(key) })
463 }
464 }
465 }
466
467 return booted
468}
469
470const LEVEL_NAMES: Record<Level, string> = { normal: 'fine', busy: 'busy', overloaded: 'overloaded' }
471
472const round = (value: number) => (value >= 100 ? String(Math.round(value)) : value.toFixed(1))
473
474// "(EmojiPosterExtension ×13, KaleidoscopePoster ×7)", or nothing when the app is a lone
475// process of its own name.
476function processesOf(app: App) {
477 const named = app.processes.filter(process => process.cores >= 0.05)
478 const only = named[0]
479
480 if (only === undefined || (named.length === 1 && only.name === app.name && only.count === 1)) {
481 return ''
482 }
483
484 return ` (${named.map(process => (process.count > 1 ? `${process.name} ×${process.count}` : process.name)).join(', ')})`
485}
486
487// The answer to /mac-load: the Mac as a whole, then what is using it, by app.
488export function detailsOf(reading: Reading, levels: Levels, simulators: readonly Simulator[]) {
489 const load = reading.load === undefined ? '' : ` · load ${reading.load.map(round).join(', ')} over 1, 5 and 15 minutes`
490 const lines = [
491 `Mac: ${LEVEL_NAMES[worstOf(levels)]}`,
492 `CPU ${MARKS[levels.cpu]}${Math.round(reading.cpu)}% of ${plural(reading.cores, 'core')}${load}`,
493 ]
494
495 if (reading.memory !== undefined) {
496 const of = reading.ramBytes === undefined ? '' : ` of ${formatBytes(reading.ramBytes)}`
497 const memory = [`Memory ${MARKS[levels.memory]}${Math.round(reading.memory)}% in use${of}`]
498
499 if (reading.pressure !== undefined) {
500 memory.push(`pressure ${reading.pressure}`)
501 }
502
503 if (reading.swap !== undefined) {
504 memory.push(`swap ${formatBytes(reading.swap.usedBytes)} of ${formatBytes(reading.swap.totalBytes)} in use`)
505 }
506
507 lines.push(memory.join(' · '))
508 }
509
510 if (reading.simulators > 0) {
511 // Xcode's previews boot simulators of their own, which simctl does not list.
512 const unnamed = reading.simulators - simulators.length
513 const names = [
514 ...simulators.map(simulator => `${simulator.name} on ${simulator.runtime}`),
515 ...(unnamed > 0 && simulators.length > 0 ? [`${unnamed} more`] : []),
516 ].join(', ')
517 lines.push(`Simulators booted: ${MARKS[levels.simulators]}${reading.simulators}${names === '' ? '' : ` (${names})`}`)
518 }
519
520 const busiest = reading.apps.filter(app => app.cores >= 0.1).slice(0, 5)
521 const largest = [...reading.apps].filter(app => (app.bytes ?? 0) > 0).sort(byBytes).slice(0, 5)
522
523 if (busiest.length > 0) {
524 lines.push('', 'Busiest now', ...busiest.map(app => `• ${app.name}: ${formatCores(app.cores)}${processesOf(app)}`))
525 }
526
527 if (largest.length > 0) {
528 lines.push('', 'Most memory', ...largest.map(app => `• ${app.name}: ${formatBytes(app.bytes ?? 0)}`))
529 }
530
531 if (reading.simulators > 0) {
532 lines.push('', 'Shut down every simulator: xcrun simctl shutdown all')
533 }
534
535 return lines.join('\n')
536}
537
538// Whether a value read back from the store is a reading this version of the mod wrote.
539export function isReading(value: unknown): value is Reading {
540 if (typeof value !== 'object' || value === null) {
541 return false
542 }
543
544 const { at, cpu, cores, simulators, apps } = value as Partial<Reading>
545
546 return [at, cpu, cores, simulators].every(field => typeof field === 'number' && Number.isFinite(field))
547 && Array.isArray(apps)
548 && apps.every(app => typeof app?.name === 'string' && typeof app.cores === 'number' && Array.isArray(app.processes))
549}
550types/index.d.ts 59 lines1// How strained one part of the Mac is: `busy` is marked ▲ on the status line, `overloaded` ◆.
2export type Level = 'normal' | 'busy' | 'overloaded'
3
4// Each part's level, kept between readings so a level is left only with margin to spare.
5export type Levels = { cpu: Level; memory: Level; simulators: Level }
6
7// macOS's own word on its memory, as Activity Monitor colors it: green, yellow, red.
8export type Pressure = 'normal' | 'warning' | 'critical'
9
10// One app's processes together: the cores they keep busy, their memory footprint when it
11// was read, and the busiest of them by name.
12export type App = {
13 name: string
14 cores: number
15 bytes?: number
16 processes: { name: string; count: number; cores: number }[]
17}
18
19// A simulator that is booted right now.
20export type Simulator = { name: string; runtime: string }
21
22// One reading of the Mac.
23export type Reading = {
24 // When it was taken.
25 at: number
26 // How busy all the cores were over the last two seconds, 0 to 100.
27 cpu: number
28 cores: number
29 // The load over 1, 5 and 15 minutes.
30 load?: [number, number, number]
31 // The share of memory in use, 0 to 100: what macOS does not count as available.
32 memory?: number
33 pressure?: Pressure
34 ramBytes?: number
35 swap?: { usedBytes: number; totalBytes: number }
36 simulators: number
37 // The busiest apps and the largest, busiest first.
38 apps: App[]
39}
40
41declare module 'claude-code' {
42 interface PluginState {
43 'MacLoad': {
44 // The latest reading, or null before the first. Shaped: a reload of code that changed
45 // its idea of a reading finds the old one absent rather than misreading it.
46 reading: Shaped<Reading | null>
47 levels: Levels
48 // Set once the mod gives up on a system it has never read: nothing is polled or shown after.
49 isStopped: boolean
50 // Failed readings in a row.
51 failures: number
52 // Whether a reading has worked on this Mac, in this chat or an earlier one.
53 hasWorked: boolean
54 // When this chat last alerted, so an overload that comes and goes alerts once.
55 alertedAt: number
56 }
57 }
58}
59