platform/system-diagnostics/SKILL.md

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. powermetrics needs 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 (U state), do not assume kill -9 will 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 no D state; U is 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 ~100 launchd_sim daemons 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/null is almost always an interactive prompt loop: it reads, gets EOF instantly, fails to match an expected answer, and loops forever at full speed. Stacks in getc/do_eof/read plus a string-compare frame confirm it. These never self-resolve — the parent is usually long dead (PPID 1).
  • cwd and 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 SIGTERM first, then confirm death. A tight spin loop may never service the signal: a hot-polling adb ignored SIGTERM entirely and needed SIGKILL. Escalate only after checking STAT is RSIGKILL will not clear a U-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 in fdevent_context_poll::Looppoll after a device disconnects badly, burning a core for days. Ignores SIGTERM. Killing costs nothing: it respawns on the next adb command.
  • perl /usr/bin/net-snmp-cert — prompt loop with stdin on /dev/null (see Diagnosing a Spinner). Leaves ~/.snmp/tls with a half-written openssl.in and empty cert dirs as its fingerprint.
  • Spotlight storms (mds_stores at 200%+, many mdworker_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:

  • PerformanceStatistics has approximate Device Utilization %, Renderer Utilization %, and Tiler Utilization %.

  • AGCInfo.fLastSubmissionPID is the last process that submitted GPU work. Map it with:

    ps -ww -p <pid> -o pid,ppid,user,%cpu,%mem,etime,args
    
  • If fLastSubmissionPID maps to WindowServer, 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 server is hot, inspect panes before stopping anything. Several active agent panes can explain steady CPU.
  • A transient node spike may disappear between samples. Preserve the parent chain while it is still present with ps -ww -p <pid>,<ppid> -o ... or pgrep -P <pid> -lf.

Report Shape

Give a short, concrete readout:

  1. Current CPU/load/GPU state.
  2. Top live offenders with PID and reason.
  3. Whether high load is real CPU burn or blocked processes.
  4. Immediate cooling actions ranked by impact.
  5. Whether there is hardware-damage concern.

Notes

  • pmset -g thermlog may stream or hang on some systems. Do not wait on it indefinitely; prefer the one-shot pmset -g therm, and use powermetrics with 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.