Ambient system companions over one privacy-preserving signal daemon (aggregate-only, no keystroke content): a git-driven terminal garden and IOKit hardware collectors. ambient daemon macos privacy terminal

macos-collector/README.md

v1.0.0
ambient-companions/macos-collector/README.md rendered · source · history · blame · raw

113 lines · 5223 bytes

macos-collector

The macOS IOKit hardware collector (spec Phase 3) — a sibling of the Rust workspace that speaks the shared wire protocol.

This is a SwiftPM package, kept deliberately out of the cargo workspace (SwiftPM and cargo do not share a build system). It is not a fifth face; it is one more producer for the same daemon and the same schema. It reads aggregate hardware scalars via IOKit only — no powermetrics, no root — and emits them as signal-schema wire frames, the exact byte format signald already parses.

swift build            # builds the collector
swift test             # the cross-language wire-contract test
swift run macos-collector --once   # one real IOKit read (summary on stderr)

What it reads (IOKit-only, no root)

Metric SignalName Source How
Aggregate CPU load [0,1] cpu_load (10) hardware Mach host_processor_info(PROCESSOR_CPU_LOAD_INFO) tick deltas
Battery percentage [0,100] battery_pct (6) macos IOKit power sources (IOPSCopyPowerSourcesInfo)
Charging (1/0) charging (7) macos IOKit power sources
Battery draw (W) battery_draw_w (9) hardware IORegistry AppleSmartBattery Amperage×Voltage
Thermal state 0..3 thermal_state (8) macos ProcessInfo.thermalState

Intentionally omitted: GPU utilization and fan RPM. They are reachable in principle via IOReport/AppleSMC, but only through chip-generation-specific, undocumented channels. A metric that cannot be read cleanly without root is omitted from the schema rather than gated behind sudo. Battery signals are omitted on a machine with no internal battery (a desktop).

Every value is an aggregate scalar. This collector has no code path that can read user content: it touches only hardware counters and power registers. The f64-only payload and the absence of any input-tap API mean the privacy invariant holds for hardware signals exactly as it does for the terminal collector — and the repo's forbidden_symbol_scan gate scans these .swift files too.

Modes

macos-collector [--once] [--hex] [--interval-ms <n>] [--out <path>]
  • (default) stream raw wire frames to stdout every --interval-ms (default 1000).
  • --once — one tick: a human-readable summary on stderr, the frames on stdout.
  • --hex — emit each frame as a hex line (a bridge decodes; also handy to eyeball).
  • --out <path> — append raw frames to a file (a spool the daemon can tail) instead of stdout.

The Swift ↔ Rust wire contract (the byte format)

Both languages implement the same length-prefixed frame. The authoritative definition lives in Rust (crates/signal-schema/src/lib.rs, module wire); the Swift encoder (Sources/CollectorCore/Wire.swift) mirrors it exactly. All multi-byte integers and the f64 (via its IEEE-754 bit pattern) are little-endian.

frame:
  [u32  body_len]           little-endian length of body
  body:
    [u16 schema_version]    must equal 5 (v5); a reader skips other versions
    [u64 ts]                unix milliseconds
    [u8  source]            0=terminal 1=git 2=macos 3=hardware
    [u8  name]              SignalName discriminant (see table above)
    [f64 value]             the ONLY payload channel — no content field exists
    [u8  tag_present]       0 = no tag, 1 = tag follows
    [u16 tag_len]           present only if tag_present == 1
    [tag_len bytes]         UTF-8 audited identifier; hardware signals never tag

Canonical frame (pinned by tests on both sides)

schema_version=5, ts=0, source=Hardware(3), name=CpuLoad(10), value=0.5, tag=none encodes to these exact 25 bytes:

15 00 00 00                       body_len = 21
05 00                             schema_version = 5
00 00 00 00 00 00 00 00           ts = 0
03                                source = Hardware
0A                                name = CpuLoad (10)
00 00 00 00 00 00 E0 3F           value = 0.5 (f64 LE)
00                                tag_present = 0

This identical literal is asserted in both:

  • Rust — crates/signal-schema/tests/hardware_wire.rs (wire::decode parses it, wire::encode reproduces it — this is signald's own parse path), and
  • Swift — Tests/CollectorCoreTests/WireTests.swift (encode produces it).

Because both sides independently commit to the same bytes, cross-process execution is not required to prove they agree — the shared literal is the proof. (swift run macos-collector --once --hex prints real frames in this layout if you want to check by hand.)

Wiring into signald

signald spawns this binary as a child with --interval-ms <n> and reads the frames it writes to stdout (collectors::hardware in crates/signald), using the same wire::read_frame the socket path uses. It looks for macos-collector on PATH; a dev build is named explicitly:

signald --collector macos-collector/.build/debug/macos-collector

When the binary is absent (Linux, or not on PATH) signald logs that and runs without hardware signals. Stdout is a pipe, so the collector exits on its next write after signald goes away.