name: system-diagnostics description: >- Diagnose local macOS CPU, GPU, thermal, process, stuck load-average, and fan issues. Use when the user asks what is eating CPU/GPU, why fans are loud, why a Mac is hot or sluggish, whether the laptop can be damaged by heat, or what process is causing high load.
system-diagnostics
Low-privilege macOS system triage for heat, fan, CPU, GPU, memory pressure, and load-average mysteries. Start read-only and explain what is actually observed before recommending kills, reboots, or app shutdowns.
When to use this
Use this when the user asks about:
- Loud fans, hot laptop, thermal pressure, battery/heat damage risk.
- "What is using all my CPU/GPU?"
- High load average with unclear CPU usage.
- Sluggish macOS UI, Simulator, Electron, browser, or agent workloads.
- Process triage before stopping local dev servers, agents, Simulators, or apps.
Safety Rules
- Inspect first. Do not kill processes, quit apps, reboot, or unload services unless the user explicitly asks.
- Prefer user-level, read-only commands.
powermetricsneeds sudo; ask before using it and do not treat sudo failure as a blocker for basic triage. - Do not equate fan noise with hardware damage. Modern MacBooks ramp fans, throttle, and can sleep/shutdown before silicon damage. Long, repeated heat can age the battery faster; stop using the machine if it is too hot to touch, smells odd, shuts down from heat, or shows battery swelling.
- If many processes are in uninterruptible sleep (
Ustate), do not assumekill -9will work. They often inflate load average without burning CPU and may clear only when the blocked kernel/resource condition resolves, or after a reboot.
Quick Snapshot
Run these in parallel when possible:
ps -arcwwwxo pid,ppid,user,%cpu,%mem,stat,etime,command | head -40
top -l 2 -s 2 -o cpu -stats pid,command,cpu,mem,threads,state,time -n 30
sysctl -n vm.loadavg
memory_pressure
pmset -g therm
pmset -g therm is the one-shot, no-sudo throttle check: a recorded thermal or
performance warning level means the machine is actually throttling, not just
warm.
Trust top, not ps, for "what is hot right now." ps %CPU is a decaying
average over the process's lifetime, not an instantaneous sample. The two
routinely disagree by orders of magnitude. Use ps to enumerate and to read
etime/STAT/args; use top -l 2 or more to decide what is actually
burning CPU. Never report a ps %CPU number as the current load.
Cross-check TIME against ELAPSED. That ratio is what exposes a long-running
spinner: 59h of CPU over 3d of elapsed time is ~82% of a core, sustained, and no
legitimate background task looks like that.
Look for disagreement between CPU and load:
- High CPU and high load: identify the busy processes.
- Low CPU/mostly idle but high load: look for blocked
U-state processes (macOS has noDstate;Uis uninterruptible wait). - High load, low CPU, and no
U-state processes: suspect a spawn stampede. Sum the sampled CPU and compare against core count. A load of 138 alongside only ~3.5 of 18 cores actually burning means many processes are briefly runnable at once, not that anything is wrong. Booting an iOS Simulator makes ~100launchd_simdaemons runnable at once and can spike load into the triple digits; it decays on its own within minutes. Report the sampled-CPU total, not the load number. - High
WindowServer: suspect compositor/GPU pressure from visible UI, Simulators, screen sharing, Discord/video, Electron apps, browsers, or external displays.
A high process count is not evidence by itself. Check %CPU and etime and
sample the count twice a few seconds apart: churn (PIDs recycling) is the
signal; a stable count of idle XPC services alive since boot is a red herring.
Stuck Process Check
Use this when load average looks worse than CPU usage:
ps -axo stat,pid,ppid,user,%cpu,%mem,etime,comm,args | grep '^U' | head -80
ps -axo stat,args | grep -c '^U.*lsof'
Use grep, not awk, in this skill: the harness substitutes $0/$1-style
tokens in skill bodies with invocation args, which silently corrupts awk
positional variables.
macOS developer workflows can accumulate stuck lsof probes against ports such
as Expo Metro or local agents (8081, 8082, 2000). These show up as
lsof -nP -iTCP:<port> or lsof -ti :<port>, may sit in U state for hours,
and can make load average look alarming even when CPU is mostly idle.
Memory Pressure
Read memory_pressure carefully: "System-wide memory free percentage" counts
inactive pages as free, so it can report 84% free on a machine with 111 GB of
128 GB in use, a 9 GB compressor, and swap in play. Judge pressure from the
compressor size and sysctl vm.swapusage instead — a swap file that macOS has
grown and filled (e.g. 2.1 GB used of 3.0 GB) means real pressure accumulated,
even when nothing is currently thrashing.
Rank memory holders by RSS, not %MEM (%MEM is derived from RSS, so it is not
an independent check):
ps -axo rss,pid,etime,comm | sort -rn | head -20
Long-uptime system daemons can dominate — dasd (Duet Activity Scheduler) can
grow to many GB over weeks of uptime against a normal footprint of tens of MB.
Idle JVM daemons (Gradle/Kotlin) and Virtualization.framework VMs are the
other usual multi-GB holders that cost nothing in CPU and are easy to miss.
You cannot break down a root-owned process's memory without sudo. Both
footprint -p <pid> and vmmap -summary <pid> fail with a privileges error, so
the private-vs-shared split for a daemon like dasd is unavailable in read-only
triage. Report the RSS with that caveat rather than asserting a leak, and offer
sudo as the confirmation step. Such memory only returns on reboot or when the
daemon is restarted.
Diagnosing a Spinner
When a process is pegged and R (running, not blocked), these two together turn
"it's spinning" into a root cause:
lsof -p <pid> # fd 0/1/2, cwd, and what it has open
sample <pid> 2 -mayDie # where it actually is in its own code
- Check fd 0 first. A CLI stuck at ~100% with stdin on
/dev/nullis almost always an interactive prompt loop: it reads, gets EOF instantly, fails to match an expected answer, and loops forever at full speed. Stacks ingetc/do_eof/readplus a string-compare frame confirm it. These never self-resolve — the parent is usually long dead (PPID 1). cwdand open files date the failure. A work directory created at the process's start time, still holding empty output dirs, proves it never made progress.
Scripts in SIP-protected paths report code object is not signed at all under
codesign. That is normal for a shell or Perl script and is not a malware
signal. Root ownership under a SIP-protected path (csrutil status) is the
authenticity check that matters.
Killing Safely
-
Re-verify the PID maps to the expected process immediately before killing — PIDs recycle, and triage can take minutes:
ps -ww -p <pid> -o pid,ppid,user,%cpu,etime,args -
Try
SIGTERMfirst, then confirm death. A tight spin loop may never service the signal: a hot-pollingadbignoredSIGTERMentirely and neededSIGKILL. Escalate only after checkingSTATisR—SIGKILLwill not clear aU-state process. -
Always confirm the kill landed (
ps -p <pid>) and re-read load afterward rather than assuming.
Known Repeat Offenders
adb -L tcp:5037 fork-server— spins infdevent_context_poll::Loop→pollafter a device disconnects badly, burning a core for days. IgnoresSIGTERM. Killing costs nothing: it respawns on the nextadbcommand.perl /usr/bin/net-snmp-cert— prompt loop with stdin on/dev/null(see Diagnosing a Spinner). Leaves~/.snmp/tlswith a half-writtenopenssl.inand empty cert dirs as its fingerprint.- Spotlight storms (
mds_storesat 200%+, manymdworker_shared) are usually legitimate and subside within minutes. Leave them alone unless they persist for hours, which points at a corrupt index.
See references/devlog.md for the full incident history behind these.
GPU / WindowServer
Raw ioreg GPU output is huge. Extract only the useful counters:
ioreg -r -c AGXAccelerator -d 1 | /usr/bin/python3 -c 'import re,sys; s=sys.stdin.read(); a=re.search(r"AGCInfo\" = \{([^}]*)\}", s); p=re.search(r"PerformanceStatistics\" = \{([^}]*)\}", s); print(("AGCInfo = {"+a.group(1)+"}") if a else "AGCInfo not found"); print(("PerformanceStatistics = {"+p.group(1)+"}") if p else "PerformanceStatistics not found")'
Interpretation:
-
PerformanceStatisticshas approximateDevice Utilization %,Renderer Utilization %, andTiler Utilization %. -
AGCInfo.fLastSubmissionPIDis the last process that submitted GPU work. Map it with:ps -ww -p <pid> -o pid,ppid,user,%cpu,%mem,etime,args -
If
fLastSubmissionPIDmaps toWindowServer, do not report that as a single guilty app. It means the compositor is submitting GPU work. Check the visible workload: Simulators, screen/video capture, Discord GPU helper, Electron apps, browsers, external displays, and animation-heavy UIs.
For better per-process power, fan RPM, and thermal data, use only with user
consent (the smc sampler reports fan speed and temperature sensors):
sudo powermetrics --samplers cpu_power,gpu_power,thermal,smc -n 1 -i 1000
Developer Workstation Follow-Ups
Check these only when the snapshot points at them:
xcrun simctl list devices booted
herdr pane list
herdr session list
pgrep -lf "node|herdr|clanky|claude|codex|electron|chrome|Discord|Safari|Wispr"
Notes:
- Booted iOS Simulators can drive
WindowServer,diagnosticd, SpringBoard, and app CPU/GPU. Shutting down unused Simulators is often the fastest cooling move. - If
herdr serveris hot, inspect panes before stopping anything. Several active agent panes can explain steady CPU. - A transient
nodespike may disappear between samples. Preserve the parent chain while it is still present withps -ww -p <pid>,<ppid> -o ...orpgrep -P <pid> -lf.
Report Shape
Give a short, concrete readout:
- Current CPU/load/GPU state.
- Top live offenders with PID and reason.
- Whether high load is real CPU burn or blocked processes.
- Immediate cooling actions ranked by impact.
- Whether there is hardware-damage concern.
Notes
pmset -g thermlogmay stream or hang on some systems. Do not wait on it indefinitely; prefer the one-shotpmset -g therm, and usepowermetricswith sudo when the user wants deeper thermal detail.- Keep this skill about diagnostics and interpretation. Do not turn it into a cleanup script without a separate approval-oriented workflow.
