---
title: "feat: Apple Silicon thermal + memory-pressure menubar monitor"
type: feat
date: 2026-06-16
status: ready
depth: standard
target_repo: CPUTempMemoryPressure
---

# feat: Apple Silicon thermal + memory-pressure menubar monitor

> All file paths in this plan are repo-relative to the `CPUTempMemoryPressure/` project directory.
> Working product name: **SiliconPulse** (placeholder — see Open Questions). Targets named `…Core`, `…App`, `…CLI` below assume this name.

## Summary

A native SwiftUI + AppKit menubar app for an Apple Silicon Mac Studio (M4 Max class) that surfaces **thermal** and **memory-pressure** health at a glance. The menubar item is a small circular gauge whose **ring color encodes a safety bucket** (green → amber → orange → red) so it requires no prior knowledge of "what counts as hot." Clicking opens a popover with per-domain gauges (CPU temp, GPU temp, memory pressure) plus the actual numeric readings for those who want them.

The headline numbers (CPU/GPU °C) come from the Apple Silicon HID sensor hub via `IOHIDEventSystemClient` (no sudo). The **safety color** is driven by the OS's own authoritative judgments — `ProcessInfo.thermalState` and the kernel memory-pressure level — rather than hand-picked °C thresholds. That split is the core idea: real numbers for the curious, OS-graded color for the glance.

This is a **passive, display-only** tool. It takes no autonomous action and runs no optimization loop, so the monorepo's five-precondition autonomous-loop gate does not apply (see Primary Directive note under Risks). Verification is anchored on a live-snapshot plausibility check on the target hardware.

---

## Problem Frame

The user wants to know, at a glance, whether their Mac Studio is thermally stressed or under memory pressure, without having to open Activity Monitor or know what a "normal" temperature is. Raw numbers fail the "no prior knowledge" bar: 82°C means nothing without context. The OS already grades both signals into qualitative buckets (thermal: nominal/fair/serious/critical; memory pressure: normal/warn/critical) — the app's job is to make that grading ambient in the menubar and let the user drill into specifics on demand.

The minimalist menubar aesthetic is a hard product constraint: the always-visible surface is one small gauge, not a row of numbers.

---

## Requirements

- **R1** — Show a single always-visible menubar gauge whose color reflects overall system health (worst of thermal + memory pressure).
- **R2** — The menubar gauge must be interpretable with zero prior knowledge: color alone communicates "fine / watch / hot."
- **R3** — Read actual CPU and GPU die temperatures (°C) from Apple Silicon sensors without requiring sudo or a helper daemon.
- **R4** — When multiple temperature sensors exist per domain (multi-cluster GPU, multi-core CPU), surface the **highest** value per domain.
- **R5** — Show an indication of memory pressure that matches what the OS/Activity Monitor considers pressure (not just "free RAM").
- **R6** — A click-through popover shows per-domain detail: CPU temp, GPU temp, memory pressure — each with its own gauge and numeric value.
- **R7** — Readings refresh continuously while the app runs (target ~2s cadence) without meaningful CPU/energy cost.
- **R8** — Run as a background/agent app (no Dock icon, no main window), launchable at login.
- **R9** — Sensor reading must degrade gracefully: if a sensor/domain is unavailable on this or a future OS, the app shows what it can and never crashes.

**Success criteria:** On the target Mac Studio, the menubar gauge tracks real load (turns amber/red under a stress test, returns to green at idle), CPU/GPU °C match a reference tool (e.g., `Stats`/`TG Pro`) within a few degrees, and the memory gauge moves with real memory pressure.

---

## Key Technical Decisions

- **KTD1 — Hybrid signal model: real °C for the number, OS grade for the color.** The gauge fill/label uses measured °C and memory-used fraction; the ring **color bucket** comes from `ProcessInfo.processInfo.thermalState` (thermal domains) and the kernel memory-pressure level (memory domain). Rationale: mapping raw °C to a color requires baked-in thresholds the user explicitly does not want to reason about; `thermalState` is Apple's own load-aware judgment and needs no prior knowledge. (Resolves the Phase 0 "temperature source" fork — hybrid was chosen over qualitative-only and raw-threshold-only.)

- **KTD2 — Temperatures via `IOHIDEventSystemClient` HID sensor hub, dynamic enumeration.** Enumerate available thermal HID services and read their °C values, classifying by name into CPU / GPU buckets and taking the max per bucket (R4). Rationale: confirmed sudo-free path used by mactop / MacMonitor / iSMC; sensor **key names vary by chip** and a hardcoded GPU float key recently broke btop on M5, so we discover sensors at runtime rather than hardcoding. Fall back cleanly when a bucket has no sensors (R9).

- **KTD3 — Memory pressure from `host_statistics64` + kernel pressure level.** Compute a continuous "pressure" fraction from `vm_statistics64` (wired + active + compressed + speculative against total, approximating Activity Monitor's pressure) for the gauge fill, and read the kernel memory-pressure level (normal/warn/critical, via `DispatchSource.makeMemoryPressureSource` and/or `kern.memorystatus_vm_pressure_level`) for the color bucket (R5, KTD1). Rationale: free RAM alone misrepresents pressure on a compressed-memory OS.

- **KTD4 — Package shape mirrors CodexBar: `Core` library + `App` executable + `CLI` executable.** All sensor/aggregation/bucketing logic lives in a platform-light `…Core` target with unit tests; the macOS `…App` target is thin UI; a `…CLI` target prints a snapshot for live verification. Rationale: matches the user's existing menubar-app conventions (Swift 6.2 SPM, StrictConcurrency, testable core, CLI for hardware-dependent verification) and keeps the risky IOKit code behind a tested boundary. (see pattern: `CodexBar/Package.swift`, `CodexBar/Sources/CodexBarCore`, `CodexBar/Sources/CodexBarCLI`)

- **KTD5 — Menubar via AppKit `NSStatusItem` with a rendered gauge image, not `MenuBarExtra`.** A `StatusItemController` owns the `NSStatusItem`; each refresh renders the gauge SwiftUI view to an `NSImage` (via `ImageRenderer`) and assigns it to `statusItem.button.image` with `isTemplate = false` (color must survive). Rationale: mirrors `CodexBar/Sources/CodexBar/StatusItemController.swift`; `MenuBarExtra`'s text/label model fights a custom color gauge and a non-template colored image, and CodexBar already proved the `NSStatusItem` + rendered-image path on this machine.

- **KTD6 — Personal, unsigned, non-sandboxed local build.** No App Store, no hardened-runtime sandbox. Rationale: confirmed in Phase 0 — this unlocks the private `IOHIDEventSystemClient` sensor APIs (KTD2) that sandboxed/App-Store builds cannot use. If distribution is ever wanted, KTD2 becomes risky and the app would fall back toward qualitative-only thermal.

---

## High-Level Technical Design

*Directional — conveys component boundaries and the color-bucketing logic, not implementation specification.*

Component / data-flow shape:

```mermaid
flowchart TD
    subgraph Core["…Core (tested, UI-free)"]
        TS[ThermalSensorReader\nIOHIDEventSystemClient] --> SNAP
        MEM[MemoryPressureReader\nhost_statistics64 + pressure level] --> SNAP
        OS[ProcessInfo.thermalState] --> SNAP
        SNAP[SystemSnapshot\nCPU°C max / GPU°C max / mem% / OS grades]
        SNAP --> HM[HealthModel\npure: snapshot → gauge view-models]
    end
    subgraph App["…App (thin UI)"]
        REFRESH[RefreshCoordinator\n~2s timer + thermalState/pressure notifications] --> SNAP
        HM --> GAUGE[GaugeView SwiftUI]
        GAUGE --> RENDER[ImageRenderer → NSImage]
        RENDER --> SI[StatusItemController\nNSStatusItem button image]
        HM --> POPOVER[Detail popover:\nCPU / GPU / Memory gauges + numbers]
        SI --> POPOVER
    end
    CLI[…CLI snapshot command] --> SNAP
```

Color-bucketing decision (the "no prior knowledge" core, KTD1) — menubar shows the worst domain:

```mermaid
flowchart TD
    A[SystemSnapshot] --> B{Memory pressure level}
    B -->|critical| R[RED]
    B -->|warn| Y1[AMBER]
    B -->|normal| C{thermalState}
    C -->|critical| R
    C -->|serious| O[ORANGE]
    C -->|fair| Y2[AMBER]
    C -->|nominal| G[GREEN]
    R --> W[Menubar = worst of all domains]
    O --> W
    Y1 --> W
    Y2 --> W
    G --> W
```

---

## Output Structure

Greenfield SPM package mirroring CodexBar's layout (trimmed to essentials):

```
CPUTempMemoryPressure/
├── Package.swift
├── README.md
├── Sources/
│   ├── SiliconPulseCore/
│   │   ├── Sensors/
│   │   │   ├── ThermalSensorReader.swift      # IOHIDEventSystemClient enumeration
│   │   │   └── MemoryPressureReader.swift      # host_statistics64 + pressure level
│   │   ├── Model/
│   │   │   ├── SystemSnapshot.swift            # readings + OS grades
│   │   │   └── HealthModel.swift               # pure: snapshot → gauge view-models
│   │   └── IOKitShims/ (module map if a C shim is needed for IOHID symbols)
│   ├── SiliconPulseApp/
│   │   ├── SiliconPulseApp.swift               # @main agent app, LSUIElement
│   │   ├── StatusItemController.swift          # NSStatusItem + rendered gauge
│   │   ├── RefreshCoordinator.swift            # timer + OS notifications
│   │   ├── GaugeView.swift                     # SwiftUI circular gauge
│   │   └── DetailPopoverView.swift             # 3 per-domain gauges + numbers
│   └── SiliconPulseCLI/
│       └── main.swift                          # `snapshot` command
└── Tests/
    └── SiliconPulseCoreTests/
        ├── HealthModelTests.swift
        ├── ThermalAggregationTests.swift
        └── MemoryPressureTests.swift
```

The tree is a scope declaration, not a constraint — the implementer may adjust layout if a better one emerges. Per-unit `Files` lists remain authoritative.

---

## Implementation Units

### U1. Project scaffold and agent-app shell

- **Goal:** Buildable SPM package with `Core` library, macOS `App` executable (background/agent app, no Dock icon, empty menubar item), and `CLI` executable. App launches and places a placeholder status item.
- **Requirements:** R8
- **Dependencies:** none
- **Files:** `Package.swift`, `Sources/SiliconPulseApp/SiliconPulseApp.swift`, `Sources/SiliconPulseApp/StatusItemController.swift`, `Sources/SiliconPulseCLI/main.swift`, `Sources/SiliconPulseCore/Model/SystemSnapshot.swift` (empty struct placeholder), `README.md`
- **Approach:** Mirror `CodexBar/Package.swift` shape (Swift 6.2 tools, `.macOS(.v14)`+, StrictConcurrency upcoming feature, Core library + executable targets). App is an agent app via `LSUIElement`/`NSApplication.setActivationPolicy(.accessory)`; create `NSStatusItem` with a static SF Symbol placeholder. No third-party deps required for v1 (skip Sparkle/macros/KeyboardShortcuts from CodexBar unless wanted later).
- **Patterns to follow:** `CodexBar/Package.swift`, `CodexBar/Sources/CodexBar/StatusItemController.swift`, `CodexBar/Sources/CodexBar/CodexbarApp.swift`
- **Test scenarios:** `Test expectation: none -- scaffolding/no behavior.` Verification only: `swift build` succeeds; running the app shows a placeholder menubar item and no Dock icon.
- **Verification:** App builds and launches; status item visible; no Dock icon / no window.

### U2. Sensor acquisition layer (Core)

- **Goal:** Read real CPU/GPU temperatures and memory stats into a `SystemSnapshot`. Dynamically enumerate HID thermal sensors, classify into CPU/GPU buckets, take the max per bucket; compute memory pressure fraction + kernel pressure level; capture `ProcessInfo.thermalState`.
- **Requirements:** R3, R4, R5, R9
- **Dependencies:** U1
- **Files:** `Sources/SiliconPulseCore/Sensors/ThermalSensorReader.swift`, `Sources/SiliconPulseCore/Sensors/MemoryPressureReader.swift`, `Sources/SiliconPulseCore/Model/SystemSnapshot.swift`, `Sources/SiliconPulseCore/IOKitShims/` (if a small C module map is needed to expose `IOHIDEventSystemClient*` symbols), `Sources/SiliconPulseCLI/main.swift` (wire a `snapshot` command), `Tests/SiliconPulseCoreTests/ThermalAggregationTests.swift`, `Tests/SiliconPulseCoreTests/MemoryPressureTests.swift`
- **Approach:** `ThermalSensorReader` creates an `IOHIDEventSystemClient`, matches thermal sensor services, reads temperature events, and exposes named readings; a pure classifier+aggregator maps reading names → {CPU, GPU, other} and returns the max per bucket (testable independent of IOKit). `MemoryPressureReader` calls `host_statistics64` for `vm_statistics64`, computes the pressure fraction (KTD3), and reads the kernel pressure level. Wrap all IOKit/Mach calls so missing sensors yield `nil`/empty, never a crash (R9). Treat sensor key names as data, not constants (KTD2).
- **Execution note:** Implement the name→bucket classifier and max-per-bucket aggregation test-first (pure logic); the live IOKit read is verified via the CLI, not unit tests.
- **Patterns to follow:** sudo-free HID approach from mactop / iSMC / MacMonitor (Sources & Research); `CodexBarCore` test layout under `CodexBar/Tests`.
- **Test scenarios:**
  - Aggregation: given readings `[CPU0=70, CPU1=75, GPU0=60, GPU1=64]` → CPU max 75, GPU max 64. *(R4)*
  - Aggregation: given GPU has zero readings → GPU temp is `nil` and snapshot still builds. *(R9)*
  - Classifier: known CPU/GPU/efficiency-core sensor name strings route to the correct bucket; an unrecognized name routes to `other` and is excluded from CPU/GPU max.
  - Memory: given a synthetic `vm_statistics64` (known wired/active/compressed/free + page size + total RAM) → pressure fraction matches hand-computed value within tolerance and clamps to 0–1. *(R5)*
  - Memory: pressure level enum maps kernel values to normal/warn/critical.
  - Live (CLI, target hardware): `snapshot` prints CPU °C in a plausible 20–110 range, GPU °C plausible, memory % in 0–100, and a thermalState — and matches a reference tool within a few degrees.
- **Verification:** Unit tests green; CLI `snapshot` on the Mac Studio prints plausible, reference-cross-checked values.

### U3. Health model — snapshot → gauge view-models (Core, pure)

- **Goal:** Pure function turning a `SystemSnapshot` into gauge view-models: per-domain {color bucket, fill fraction 0–1, display value string} and the single "worst domain" view-model for the menubar.
- **Requirements:** R1, R2, R4, R5
- **Dependencies:** U2
- **Files:** `Sources/SiliconPulseCore/Model/HealthModel.swift`, `Tests/SiliconPulseCoreTests/HealthModelTests.swift`
- **Approach:** Color bucket per KTD1 — memory domain color from pressure level, thermal domains from `thermalState`; numeric °C / mem% drive only fill + label, never color. "Worst domain" = highest-severity bucket across domains (ties broken deterministically, e.g., memory > GPU > CPU). Pure and synchronous — no IOKit, no UI.
- **Patterns to follow:** plain value-type model + exhaustive enum switches.
- **Test scenarios:**
  - Each `thermalState` (nominal/fair/serious/critical) → expected color (green/amber/orange/red). *(R2)*
  - Memory pressure `critical` → red menubar **regardless** of a cool thermalState (worst-of wins). *(R1, R5)*
  - Worst-domain selection picks the higher-severity domain across mixed inputs; deterministic tie-break verified.
  - Fill fraction clamps to 0–1 for out-of-range / nil inputs; nil temp domain renders a defined "no data" view-model rather than a misleading 0.
  - Display value formatting: °C rounded to integer with unit; memory as percent.
- **Verification:** Unit tests green covering every bucket and the worst-of precedence.

### U4. Circular gauge rendering

- **Goal:** A SwiftUI circular gauge view rendering a gauge view-model, plus a path to rasterize it to a menubar-sized `NSImage` that reads correctly in light and dark menubars.
- **Requirements:** R2, R6
- **Dependencies:** U3
- **Files:** `Sources/SiliconPulseApp/GaugeView.swift`, `Sources/SiliconPulseApp/DetailPopoverView.swift`
- **Approach:** `GaugeView` draws a ring (track + colored progress arc from fill fraction) sized for the menubar (~18–22pt) and a larger variant for the popover with the numeric value centered. Rasterize via `ImageRenderer` at the correct scale; assign with `isTemplate = false` so color survives (KTD5). Ensure contrast in both menubar appearances (e.g., subtle track + halo). `DetailPopoverView` lays out three gauges (CPU/GPU/memory) with labels + numbers.
- **Patterns to follow:** `CodexBar/Sources/CodexBar/StatusItemController+Animation.swift` for rendered-image-into-status-item handling.
- **Test scenarios:**
  - Render test: `ImageRenderer` on a given view-model returns a non-nil `NSImage` of the expected pixel size at menubar scale.
  - Render test: full (red) vs empty (green) vs nil-data view-models each produce a distinct image (non-equal bitmap or distinct dominant color).
  - Visual artifact (manual): capture the gauge in light and dark menubar; confirm legibility and that color matches the bucket.
- **Verification:** Render tests green; captured light/dark menubar screenshots show a legible, correctly colored gauge.

### U5. Refresh loop + menubar/popover integration

- **Goal:** Wire it together: a refresh coordinator drives ~2s snapshots, updates the menubar gauge image, and opens a detail popover on click. Immediate refresh on `thermalState` / memory-pressure change notifications.
- **Requirements:** R1, R6, R7
- **Dependencies:** U2, U3, U4
- **Files:** `Sources/SiliconPulseApp/RefreshCoordinator.swift`, `Sources/SiliconPulseApp/StatusItemController.swift` (extend U1 stub), `Sources/SiliconPulseApp/SiliconPulseApp.swift`, `Tests/SiliconPulseCoreTests/` (only if refresh-scheduling logic is extracted to Core)
- **Approach:** `RefreshCoordinator` owns a ~2s timer (coalesced, tolerant) that builds a `SystemSnapshot` off the main actor, maps via `HealthModel`, and publishes to the UI on the main actor. Subscribe to `ProcessInfo.thermalStateDidChangeNotification` and the memory-pressure `DispatchSource` to refresh immediately on transitions (R7 responsiveness without a fast poll). `StatusItemController` re-renders the menubar image and toggles the popover on button click. Keep per-tick work cheap (R7); consider pausing/slowing the timer when the popover is closed and the system is idle.
- **Execution note:** If refresh cadence/coalescing logic is non-trivial, extract the schedule decision into a pure, testable Core helper and test it; otherwise verify via the running app.
- **Patterns to follow:** `CodexBar/Sources/CodexBar/StatusItemController.swift` (status item + popover lifecycle), `RefreshCoordinator` analog to CodexBar's provider refresh.
- **Test scenarios:**
  - (If schedule logic extracted) Given the popover is closed and system idle, when computing the next interval, then it backs off; when a pressure notification fires, then an immediate refresh is requested. *(R7)*
  - Integration/manual: Given the app running, when a CPU/GPU stress test runs, then within ~1 refresh the menubar gauge moves toward amber/red and the popover numbers rise; when load stops, it returns to green. *(R1, R6)*
  - Integration/manual: clicking the menubar item opens/closes the popover; popover shows three gauges with live numbers. *(R6)*
- **Verification:** Run the app on the Mac Studio; observe gauge tracking a stress test and returning to idle; popover shows correct per-domain detail. `Energy`/CPU cost from the app stays negligible at idle.

---

## Scope Boundaries

### In scope
- Menubar color gauge (worst-of), per-domain popover, real CPU/GPU °C, memory pressure, continuous refresh, agent app.

### Deferred to Follow-Up Work
- **Desktop widget.** Deferred by Phase 0 decision: WidgetKit timelines refresh on a tight system budget (not live), so a gauge widget would show stale readings — a poor fit for a real-time monitor. If pursued later, read `second-brain/wiki/concepts/WidgetKit Engineering Pitfalls.md` and `Apple Widget Rendering Modes.md` first, and treat it as a coarse "last-known state" surface, not live. The `…Core` snapshot API is the seam a widget extension would reuse.
- **Fan RPM / power (watts).** Mac Studio fans and IOReport power draw are readable via the same stack (SMC/IOReport) but out of v1's temp+memory scope.
- **Historical charts / logging / sparklines.**
- **Notifications / alerting** on crossing critical (ntfy.sh would be the monorepo-standard channel if added).
- **Launch-at-login UI / preferences window** (can ship as a manual `launchctl`/login-item step in v1).
- **Per-process attribution** (which app is driving heat/pressure).

### Out of scope (non-goals)
- App Store distribution / sandbox / notarization (KTD6 — would forfeit the private sensor APIs).
- Cross-platform / Intel Mac support (target is Apple Silicon Mac Studio).

---

## Risks & Dependencies

- **Private/undocumented sensor surface (highest risk).** `IOHIDEventSystemClient` thermal access is undocumented and sensor key names vary by chip and can change across macOS releases (e.g., the M5 GPU `flt` key that broke btop). *Mitigation:* dynamic enumeration + name classification (KTD2), graceful per-domain fallback (R9), and the CLI `snapshot` as a fast canary when a future OS update shifts keys.
- **macOS 27 newness.** The machine runs macOS 27.0 (build 26A5353q); sensor naming/availability there is less battle-tested than on shipping releases. *Mitigation:* same dynamic-enumeration mitigation; cross-check against a reference tool during U2 verification.
- **Symbol exposure to Swift.** `IOHIDEventSystemClientCreate` and friends may need a small C shim / module map to be callable from Swift. *Mitigation:* `IOKitShims` target noted in U2 files; low effort, well-precedented.
- **Menubar color rendering.** Colored (non-template) status images must stay legible across light/dark menubars and Reduce Transparency. *Mitigation:* U4 light/dark artifact check.
- **Energy cost (R7).** A 2s poll that does heavy IOKit work could be wasteful. *Mitigation:* cheap per-tick reads, notification-driven immediate refresh, optional idle back-off.

**Primary Directive (five preconditions) — explicit flag.** This is a passive monitor: its **action space is empty** (it only displays). There is no metric it autonomously optimizes and no rollback/guardrail loop to define, so the autonomous-loop gate in the monorepo `CLAUDE.md` does not apply by design. The relevant discipline here is *self-verification of displayed values* — covered by the live CLI `snapshot` plausibility/reference cross-check (U2) and the stress-test tracking check (U5). If the app later grows alerting or auto-throttling suggestions, revisit the five preconditions then.

**Dependencies:** Swift 6.2 toolchain (present, per CodexBar); no required third-party packages for v1.

---

## Open Questions

- **Product name.** "SiliconPulse" is a placeholder; the directory is `CPUTempMemoryPressure`. Final menubar/app name? *(Does not block U1–U4; only naming.)*
- **Idle behavior.** Should refresh pause entirely when the display sleeps / no popover open, or just slow down? Defaulting to "slow down + immediate on notification" in U5 unless told otherwise.
- **Gauge style.** Single worst-of ring in the menubar (planned) vs. a tiny dual-arc (temp + memory) — the latter is denser but less minimalist. Defaulting to single ring per the stated aesthetic.

---

## Sources & Research

- Existing convention to mirror: `CodexBar/` (SPM Swift 6.2 menubar app — `CodexBarCore` library + `CodexBar` app + `CodexBarCLI` + `CodexBarWidget`, `StatusItemController` over `NSStatusItem`).
- Apple Silicon sensor reading (sudo-free HID sensor hub via `IOHIDEventSystemClient`; GPU temp as `flt` over SMC; key names vary by chip):
  - mactop — Apple Silicon monitor using SMC / IOReport / IOKit / IOHIDEventSystemClient: https://github.com/metaspartan/mactop
  - MacMonitor — menubar + widget Apple Silicon monitor (IOReport / AppleSMC / IOHIDEventSystem): https://github.com/ryyansafar/MacMonitor
  - iSMC — HID sensor hub temperature/voltage/current on M1–M5: https://github.com/dkorunic/iSMC
  - btop M5 GPU `flt` parsing bug (key-format fragility): https://github.com/aristocratos/btop/issues/1653
  - Hot — minimalist thermal menubar app (prior art for the aesthetic): https://github.com/macmade/Hot
- OS-graded signals: `ProcessInfo.thermalState` / `thermalStateDidChangeNotification`; `DispatchSource.makeMemoryPressureSource` and kernel memory-pressure level; `host_statistics64` / `vm_statistics64`.
