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

ambient-companions

Five faces over one backend.

One signal-collection daemon; five thin renderers that subscribe to it. This is closer to one project with five faces than five projects: the backend is the project, and each face is thin, disposable, and deletable without touching the others.

One backend, five faces

                signald  (user LaunchAgent)
  zsh hooks ───▶ terminal collector ─┐
  git/fsevents ▶ git collector ──────┼─▶ normalizer ─▶ ring buffer
  IOKit/AppKit ▶ system+hw collector ┘        │
                              publish: Unix socket + SQLite WAL (history)
        ┌───────────┬───────────┼───────────┬───────────────┐
        ▼           ▼           ▼           ▼               ▼
   terminal-pet  garden    menubar-pet  sonifier      wallpaperd
    (TUI)        (TUI)     (SwiftUI)    (AVAudio)     (image/window)

All five draw from the same signal bus. Renderers never poll hardware, never read the shell — they subscribe over a local Unix socket ($XDG_RUNTIME_DIR/signald.sock). That buys one privacy boundary to defend instead of five, cheap renderers, and reuse of the collection layer into non-toy outputs (e-ink dashboards, printed posters).

The privacy boundary, enforced by construction

Reading typing during real work is a keylogger unless it is structurally content-free — and unless that is a test, not a sentence in a README.

The invariant (spec §1.5):

No process persists, transmits, or exposes any representation from which the content or identity of an individual keystroke, command argument, or typed character can be recovered. Only order-free aggregates (counts, rates, durations, codes) leave the terminal collector.

Made true by construction:

  • No content channel exists in the wire format. The only payload channel is Value, a newtype over f64. There is no text, bytes, or payload field — a collector cannot emit typed content because the record has nowhere to put it. The two audited exceptions are non-content identifiers in tag (bundle id, repo path, ssh host), allow-listed per metric name.
  • The key counter never stores the key, there is no input tap anywhere (CGEventTap, IOHIDManager keyboard usage, accessibility observation are all forbidden), and aggregation happens before transport (the shell emits counts on precmd, never per-key events).

And it is tested (crates/signal-schema/tests/privacy_invariant.rs):

  • value_channel_is_exactly_f64 — the payload is an f64, nothing wider.
  • wire_format_has_no_content_field — the Signal type declares no content-carrying field beyond the audited tag.
  • forbidden_symbol_scan — the tree contains none of the banned keylogger APIs or shell line-buffer references (the static CI gate).
  • differential_secret_typingactive (the ship gate). It drives the real hook (shell-hooks/signald-hooks.zsh) through a real interactive zsh under a real pseudo-terminal (zsh's own zsh/zpty — no extra dependency), typing a planted secret, and asserts the secret never appears — plain, reversed, hex, or base64 — in the shell spool or the f64-only wire encoding. The full pipeline version (same real typing driven through the terminal collector, the SQLite history store, and the hub) is crates/signald/tests/differential_secret_typing.rs. Real zle keystroke counting increments a number per key and discards the key, so the only thing the pipeline ever receives about the typing is a count — the tests prove that empirically across every downstream artifact.

Build order

Build the backend to the point each renderer needs, then build the renderer that is cheapest and most valuable given what exists:

  1. garden — git is the cleanest signal (discrete, no privacy questions), so it proves the bus first. (recommended first face)
  2. gated collector + pet — add the sensitive terminal collector only after the privacy tests are green and extended to cover it.
  3. menu-bar permadeath pet — first macOS-native face; ships the one-life variant (ages in wall-clock time, dies permanently) — the emotional hook.
  4. SSH-sonification — unexpected access becomes audible; the utility sonification before the ambient one.
  5. homelab wallpaper — desktop-as-status-board (Path A: render-to-image + setDesktopImageURL); the same frame pipeline later feeds e-ink and posters.

macOS collector constraint

The system + hardware collector is IOKit-only: no root, no powermetrics. powermetrics wants root and would make the suite un-shippable as a plain user agent. Signals come from IOKit (IOPMPowerSource / power sources, IORegistry AppleSmartBattery), ProcessInfo, and Mach host_processor_info. Where a metric can't be reached without root, it is simply absent from the schema rather than gated behind sudo. Accessibility permission is never requested.

It ships in v0.3 as macos-collector/, a sibling Swift package (built with swift build, kept out of the cargo workspace — SwiftPM and cargo do not share a build system). It reads aggregate CPU load, battery %, charging, battery draw (W), and thermal state, and emits them as signal-schema wire frames — the same byte format signald parses. GPU/fan are deliberately omitted (no clean root-free IOKit channel). See macos-collector/README.md for the full Swift ↔ Rust wire contract (the byte layout) and the shared canonical-frame test that pins both sides to the same bytes.

Workspace layout

ambient-companions/
├── Cargo.toml                      # Rust workspace
├── crates/
│   ├── signal-schema/              # shared wire format (the privacy boundary)
│   │   ├── src/lib.rs
│   │   └── tests/privacy_invariant.rs
│   ├── signald/                    # the daemon (collectors + live fan-out)
│   │   ├── src/lib.rs              # git + terminal collectors, publish
│   │   ├── src/history.rs         # SQLite (WAL) history store + recent() query
│   │   ├── src/hub.rs            # last-value cache + live fan-out
│   │   ├── src/main.rs          # CLI, producer loop, self-attestation
│   │   ├── tests/git_collector.rs           # aggregates vs a temp git repo
│   │   ├── tests/streaming.rs               # last-value cache + live update
│   │   └── tests/differential_secret_typing.rs  # the full privacy ship-gate
│   └── terminal-garden/            # first renderer: a socket subscriber
│       ├── src/lib.rs             # signals → plots → render (unit-tested)
│       └── src/main.rs           # live subscribe + redraw loop
├── shell-hooks/                    # zsh hooks: aggregate-only terminal collector
│   ├── signald-hooks.zsh
│   └── README.md
└── macos-collector/                # SwiftPM sibling (NOT in the cargo workspace):
    ├── Package.swift              #   macOS IOKit hardware collector (Phase 3)
    ├── README.md                 #   the Swift↔Rust wire contract (byte layout)
    ├── Sources/CollectorCore/    #   wire encoder + IOKit reads (host_processor_
    │                            #     info, power sources, AppleSmartBattery)
    ├── Sources/macos-collector/  #   thin CLI: --once / stream / --hex / --out
    └── Tests/                    #   shared canonical-frame wire-contract test

Language note

The daemon is written in Rust. The spec allows Rust or Go; Rust is chosen because it makes the "no content field exists" guarantee enforceable in the type system (the f64-only Value payload), which is the whole point of the privacy boundary.

Dependencies

signal-schema is dependency-free by design — the wire format is the privacy boundary and carries no third-party code. signald has one dependency, rusqlite (with the bundled feature, so SQLite is compiled in-tree and there is no system-library requirement), for the WAL history store. The terminal collector's shell side needs only zsh (the zle, datetime, and — for the differential test — zpty modules ship with zsh).

Phase plan

  • M0 — backend skeleton + schema + privacy invariant test. ✅ Done. Workspace builds; the structural privacy tests pass.
  • Phase 1 — git collector + garden. ✅ Done (v0.1). The wire format (encode/decode + length-prefixed framing) is real and f64-only. signald's git collector shells out to git for aggregate scalars — commits-in-window, commits-today, branch count, days-since-last- commit — tagged by the audited repo path. terminal-garden subscribes and renders each repo as a plot: growth (🌱→🌿→🌳) tracks recent commits, wilt (🥀→🍂) tracks staleness.
  • Persistence + live streaming. ✅ Done (v0.2).
    • SQLite (WAL) history (signald/src/history.rs): every published signal is persisted (aggregate scalars only — same privacy constraints as the wire), and recent() / recent_named() let a renderer read recent history, not just the live snapshot.
    • Live streaming + last-value cache (signald/src/hub.rs, publish::serve): the daemon runs a producer loop and continuously publishes. On connect a subscriber is handed the current value of every metric immediately, then streams updates as they change (no longer snapshot-then-close). terminal-garden now redraws live.
  • Phase 2 — terminal collector. ✅ Done (v0.2). The aggregate-only terminal path is real: shell-hooks/signald-hooks.zsh counts keystrokes with a zle widget that increments a number and discards the key, and appends <epoch_ms> <keys> <session_seconds> count records (numbers only) to a spool; collectors::terminal reads the spool and derives keys_per_min and session_seconds. The differential secret-typing test is active and passing — the privacy ship-gate. The Terminal Pet renderer is still todo.
  • Phase 3 — macOS IOKit hardware collector. ✅ Done (v0.3). macos-collector/ (a sibling Swift package, swift build) reads aggregate hardware scalars via IOKit only — no powermetrics, no root: CPU load (Mach host_processor_info), battery %/charging (IOKit power sources), battery draw in watts (IORegistry AppleSmartBattery), and thermal state (ProcessInfo). It emits signal-schema wire frames — the schema gained the aggregate cpu_load metric and bumped to SCHEMA_VERSION = 3. GPU/fan are omitted (no clean root-free channel). The Swift ↔ Rust byte contract is documented and pinned by a shared canonical-frame test on both sides (crates/signal-schema/tests/hardware_wire.rs decodes the exact bytes the Swift encoder commits to in macos-collector/Tests/.../WireTests.swift). Still todo: the menu-bar permadeath pet, and the live socket handshake by which signald ingests the collector's frames (minimal/documented in v0.3).
  • Phase 4 — sonification (SSH-utility first, then continuous). Out of scope.
  • Phase 5 — live wallpaper (homelab, Path A) + e-ink/poster reuse. Out of scope.

Build & test

cargo build          # whole Rust workspace
cargo test           # includes the privacy invariant suite (must be green)

# The macOS IOKit hardware collector is a sibling Swift package (macOS only):
cd macos-collector
swift build          # builds the collector
swift test           # the Swift↔Rust wire-contract test
swift run macos-collector --once   # one real IOKit read (no root)

About

Rust 68.5%Swift 14.7%Markdown 14.7%Shell 2.1%

1 contributorcmc

Clone

SSH
git clone ssh://git@gitbay.org/krz/ambient-companions.git
HTTPS
git clone https://gitbay.org/krz/ambient-companions.git