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

README.md

main
ambient-companions/README.md rendered · source · history · blame · raw

380 lines · 20303 bytes

  1# ambient-companions
  2
  3*Five faces over one backend.*
  4
  5One signal-collection daemon; five thin renderers that subscribe to it. This is
  6closer to **one project with five faces** than five projects: the backend is
  7the project, and each face is thin, disposable, and deletable without touching
  8the others.
  9
 10## One backend, five faces
 11
 12```text
 13                signald  (user LaunchAgent)
 14  zsh hooks ───▶ terminal collector ─┐
 15  git/fsevents ▶ git collector ──────┼─▶ normalizer ─▶ ring buffer
 16  IOKit/AppKit ▶ system+hw collector ┘        │
 17 18                              publish: Unix socket + SQLite WAL (history)
 19        ┌───────────┬───────────┼───────────┬───────────────┐
 20        ▼           ▼           ▼           ▼               ▼
 21   terminal-pet  garden    menubar-pet  sonifier      wallpaperd
 22    (TUI)        (TUI)     (SwiftUI)    (AVAudio)     (image/window)
 23    ✅ built     ✅ built   ✅ built     planned       planned
 24```
 25
 26**Three faces are built.** The other two are the roadmap, not the product —
 27see "Phase plan". Every face draws from the **same signal bus**: renderers
 28never poll hardware, never read the shell, they subscribe over a local Unix
 29socket (`$XDG_RUNTIME_DIR/signald.sock`). That buys one privacy boundary to
 30defend instead of five, cheap renderers, and reuse of the collection layer into
 31non-toy outputs (e-ink dashboards, printed posters).
 32
 33## The privacy boundary, enforced by construction
 34
 35Reading typing during real work is a keylogger unless it is *structurally*
 36content-free — and unless that is a **test**, not a sentence in a README.
 37
 38The invariant:
 39
 40> No process persists, transmits, or exposes any representation from which the
 41> content or identity of an individual keystroke, command argument, or typed
 42> character can be recovered. Only order-free aggregates (counts, rates,
 43> durations, codes) leave the terminal collector.
 44
 45Made true by construction:
 46
 47- **No content channel exists in the wire format.** The only payload channel is
 48  `Value`, a newtype over `f64`. There is no `text`, `bytes`, or `payload`
 49  field — a collector *cannot* emit typed content because the record has
 50  nowhere to put it. The one audited exception is a non-content identifier in
 51  `tag` — an absolute repo path, allow-listed to the four git metric names,
 52  and confined by the daemon to the roots it was told to watch.
 53- **The key counter never stores the key**, there is **no input tap anywhere**
 54  (`CGEventTap`, `IOHIDManager` keyboard usage, accessibility observation are
 55  all forbidden), and **aggregation happens before transport** (the shell emits
 56  counts on `precmd`, never per-key events).
 57
 58And it is tested (`crates/signal-schema/tests/privacy_invariant.rs`):
 59
 60- `value_channel_is_exactly_f64` — the payload is an `f64`, nothing wider.
 61- `wire_format_has_no_content_field` — the `Signal` type declares no
 62  content-carrying field beyond the audited `tag`.
 63- `forbidden_symbol_scan` — the tree contains none of the banned keylogger APIs
 64  or shell line-buffer references (the static gate).
 65- `differential_secret_typing`**active (the ship gate).** It drives the real
 66  hook (`shell-hooks/signald-hooks.zsh`) through a real interactive zsh under a
 67  real pseudo-terminal (zsh's own `zsh/zpty` — no extra dependency), *typing a
 68  planted secret*, and asserts the secret never appears — plain, reversed, hex,
 69  or base64 — in the shell spool or the `f64`-only wire encoding. The **full
 70  pipeline** version (same real typing driven through the terminal collector,
 71  the SQLite history store, and the hub) is
 72  `crates/signald/tests/differential_secret_typing.rs`. Real `zle` keystroke
 73  counting increments a *number* per key and discards the key, so the only thing
 74  the pipeline ever receives about the typing is a count — the tests prove that
 75  empirically across every downstream artifact.
 76
 77## Build order
 78
 79Build the backend to the point each renderer needs, then build the renderer
 80that is cheapest *and* most valuable given what exists:
 81
 821. **garden** — git is the cleanest signal (discrete, no privacy questions), so
 83   it proves the bus first. *(recommended first face)*
 842. **gated collector + pet** — add the sensitive terminal collector only after
 85   the privacy tests are green and extended to cover it.
 863. **menu-bar permadeath pet** — first macOS-native face; ships the one-life
 87   variant (ages in wall-clock time, dies permanently) — the emotional hook.
 884. **SSH-sonification** — unexpected access becomes *audible*; the utility
 89   sonification before the ambient one.
 905. **homelab wallpaper** — desktop-as-status-board (Path A: render-to-image +
 91   `setDesktopImageURL`); the same frame pipeline later feeds e-ink and posters.
 92
 93## macOS collector constraint
 94
 95The system + hardware collector is **IOKit-only: no root, no `powermetrics`.**
 96`powermetrics` wants root and would make the suite un-shippable as a plain user
 97agent. Signals come from IOKit (`IOPMPowerSource` / power sources, IORegistry
 98`AppleSmartBattery`), `ProcessInfo`, and Mach `host_processor_info`. Where a
 99metric can't be reached without root, it is simply **absent from the schema**
100rather than gated behind sudo. Accessibility permission is never requested.
101
102It ships as **`macos-collector/`**, a sibling **Swift** package (built with
103`swift build`, kept out of the cargo workspace — SwiftPM and cargo do not share
104a build system). It reads aggregate CPU load, battery %, charging, battery draw
105(W), and thermal state, and emits them as `signal-schema` wire frames — the
106same byte format `signald` parses. `signald` spawns it as a child process
107(found on `PATH`, or named with `--collector <path>`) and ingests the frames it
108writes to stdout, so hardware signals reach the hub, the history store, and
109every subscriber by the same path as git and terminal signals. GPU/fan are
110deliberately omitted (no clean root-free IOKit channel). See `macos-collector/README.md` for the full **Swift ↔
111Rust wire contract** (the byte layout) and the shared canonical-frame test that
112pins both sides to the same bytes.
113
114## Workspace layout
115
116```text
117ambient-companions/
118├── Cargo.toml                      # Rust workspace
119├── crates/
120│   ├── signal-schema/              # shared wire format (the privacy boundary)
121│   │   ├── src/lib.rs
122│   │   └── tests/privacy_invariant.rs
123│   ├── signald/                    # the daemon (collectors + live fan-out)
124│   │   ├── src/lib.rs              # git + terminal collectors, hardware ingest, publish
125│   │   ├── src/supervisor.rs      # collector liveness, restart, CollectorUp
126│   │   ├── src/history.rs         # SQLite (WAL) history store + recent() query
127│   │   ├── src/hub.rs            # last-value cache + live fan-out
128│   │   ├── src/main.rs          # CLI, producer loop, self-attestation
129│   │   ├── tests/git_collector.rs           # aggregates vs a temp git repo
130│   │   ├── tests/streaming.rs               # last-value cache + live update
131│   │   ├── tests/hardware_ingest.rs         # collector frames reach a subscriber
132│   │   └── tests/differential_secret_typing.rs  # the full privacy ship-gate
133│   ├── signal-client/              # socket path + frame iteration (shared)
134│   ├── terminal-garden/            # first renderer: git aggregates as plots
135│   │   ├── src/lib.rs             # signals → plots → render (unit-tested)
136│   │   └── src/main.rs           # live subscribe + redraw loop
137│   ├── terminal-pet/               # second renderer: shell + machine + health
138│   │   ├── src/lib.rs             # signals → PetState → render (unit-tested)
139│   │   └── src/main.rs           # live subscribe + redraw loop
140│   └── pet-life/                   # the one-life mechanic: ageing, death, cemetery
141│       ├── src/lib.rs             # pure over timestamps (unit-tested)
142│       └── src/main.rs           # one-shot: prints state as JSON
143├── shell-hooks/                    # zsh hooks: aggregate-only terminal collector
144│   ├── signald-hooks.zsh
145│   └── README.md
146├── menubar-pet/                    # SwiftPM sibling: the third face
147│   ├── Sources/PetKit/            #   the pet-life JSON contract (tested)
148│   ├── Sources/menubar-pet/       #   status item + timer + menu
149│   └── scripts/bundle.sh         #   assembles the .app (LSUIElement)
150└── macos-collector/                # SwiftPM sibling (NOT in the cargo workspace):
151    ├── Package.swift              #   macOS IOKit hardware collector (Phase 3)
152    ├── README.md                 #   the Swift↔Rust wire contract (byte layout)
153    ├── Sources/CollectorCore/    #   wire encoder + IOKit reads (host_processor_
154    │                            #     info, power sources, AppleSmartBattery)
155    ├── Sources/macos-collector/  #   thin CLI: --once / stream / --hex / --out
156    └── Tests/                    #   shared canonical-frame wire-contract test
157```
158
159### Language note
160
161The daemon is written in **Rust**, chosen
162because it makes the "no content field exists" guarantee enforceable in the
163type system (the `f64`-only `Value` payload), which is the whole point of the
164privacy boundary.
165
166### Dependencies
167
168`signal-schema` is **dependency-free** by design — the wire format is the
169privacy boundary and carries no third-party code. `signald` has **one**
170dependency, `rusqlite` (with the `bundled` feature, so SQLite is compiled
171in-tree and there is no system-library requirement), for the WAL history store.
172The terminal collector's shell side needs only `zsh` (the `zle`, `datetime`,
173and — for the differential test — `zpty` modules ship with zsh).
174
175## Phase plan
176
177- **M0 — backend skeleton + schema + privacy invariant test. ✅ Done.**
178  Workspace builds; the structural privacy tests pass.
179- **Phase 1 — git collector + garden. ✅ Done (v0.1).**
180  The wire format (`encode`/`decode` + length-prefixed framing) is real and
181  `f64`-only. `signald`'s git collector shells out to `git` for aggregate
182  scalars — commits-in-window, commits-today, branch count, days-since-last-
183  commit — tagged by the audited repo path. `terminal-garden` subscribes and
184  renders each repo as a plot: growth (🌱→🌿→🌳) tracks recent commits, wilt
185  (🥀→🍂) tracks staleness.
186- **Persistence + live streaming. ✅ Done (v0.2).**
187  - **SQLite (WAL) history** (`signald/src/history.rs`): every published signal
188    is persisted (aggregate scalars only — same privacy constraints as the
189    wire), and `recent()` / `recent_named()` let a renderer read recent history,
190    not just the live snapshot.
191  - **Live streaming + last-value cache** (`signald/src/hub.rs`,
192    `publish::serve`): the daemon runs a producer loop and continuously
193    publishes. On connect a subscriber is handed the current value of every
194    metric immediately, then streams updates as they change (no longer
195    snapshot-then-close). `terminal-garden` now redraws live.
196- **Phase 2 — terminal collector. ✅ Done (v0.2).**
197  The aggregate-only terminal path is real: `shell-hooks/signald-hooks.zsh`
198  counts keystrokes with a `zle` widget that increments a number and discards
199  the key, and appends `<epoch_ms> <keys> <session_seconds> <session_id>` count
200  records (numbers only) to a spool; `collectors::terminal` consumes the spool
201  and derives `keys_per_min` and `session_seconds`. The **differential
202  secret-typing test is active and passing** — the privacy ship-gate. The
203  Terminal Pet renderer landed in v1.0.
204- **Phase 3 — macOS IOKit hardware collector. ✅ Done (v0.3).**
205  `macos-collector/` (a sibling Swift package, `swift build`) reads aggregate
206  hardware scalars via **IOKit only — no `powermetrics`, no root**: CPU load
207  (Mach `host_processor_info`), battery %/charging (IOKit power sources), battery
208  draw in watts (IORegistry `AppleSmartBattery`), and thermal state
209  (`ProcessInfo`). It emits `signal-schema` wire frames — the schema gained the
210  aggregate `cpu_load` metric and bumped to `SCHEMA_VERSION = 3`. GPU/fan are
211  omitted (no clean root-free channel). The **Swift ↔ Rust byte contract** is
212  documented and pinned by a shared canonical-frame test on both sides
213  (`crates/signal-schema/tests/hardware_wire.rs` decodes the exact bytes the
214  Swift encoder commits to in `macos-collector/Tests/.../WireTests.swift`).
215  *The menu-bar permadeath pet is post-1.0.*
216- **Hardware ingest. ✅ Done (v0.4).**
217  `signald` spawns `macos-collector` as a child (`--collector <path>`, or found
218  on `PATH`) and reads its stdout with the same `wire::read_frame` the socket
219  uses (`collectors::hardware`). The five hardware signals now appear in the
220  hub, the history store, and every subscriber's snapshot
221  (`crates/signald/tests/hardware_ingest.rs`).
222- **Bounded spool and history. ✅ Done (v0.4).**
223  The terminal spool is consumed each tick (renamed aside, read, deleted)
224  instead of re-read in full forever. Records carry the shell's pid, so
225  `keys_per_min` is each active shell's rate summed rather than a mix of
226  interleaved sessions, and `session_seconds` is the longest active shell.
227  History rows older than `--retention-days` (default 7) are pruned on open
228  and every 1000 inserts.
229- **Contract freeze. ✅ Done (v0.5).**
230  `SignalName` is cut to the eleven metrics that have a producer, renumbered
231  from zero, and `SCHEMA_VERSION` is **4 — the 1.0 contract**. Discriminants
232  are append-only from 1.0, so v4 was the last chance to renumber. The only
233  audited tag identifier left is the repo path, confined by the daemon to the
234  roots it was told to watch. A reader now **skips** a frame it cannot decode
235  instead of dying on it (`wire::Frame::Skipped`), so an older renderer keeps
236  working against a newer daemon. References to an uncommitted spec are gone.
237- **v1.0 — trust it unattended. ✅ Done (v1.0.0).**
238  1.0 is not feature completeness and not an API promise to anyone else. It is
239  one claim: **the daemon runs unattended for weeks, recovers from its own
240  failures, and says so when it cannot.**
241  `signald/src/supervisor.rs` owns collector liveness. A panic in the git or
242  terminal collector costs one tick instead of the producer thread; the
243  hardware child is respawned with backoff; `run_git` kills a `git` that hangs,
244  because a Rust thread cannot be. Health rides the bus as
245  `SignalName::CollectorUp`, one per `Source` — the schema's `v5` addition —
246  so a dead collector reaches a face rather than a log file. `terminal-pet`
247  renders it: a pet that cannot feel its own hardware looks sick.
248- **Phase 3 renderer — menu-bar permadeath pet. ✅ Done.**
249  `menubar-pet/` is a sibling SwiftPM package: a status item, a timer and a
250  menu. One life — it ages, neglect kills it over a week through visible
251  stages, and death is permanent for that pet. A new one arrives when you come
252  back, and the old one is remembered in a cemetery that is never pruned.
253  The mechanic lives in the `pet-life` Rust crate, which the app runs on a
254  timer; the app decodes no wire and opens no socket, so there is still one
255  decoder and one privacy boundary. Everything derives from timestamps, so
256  sleep, reboots and the app not running change nothing.
257- **Phase 4** — sonification (SSH-utility first, then continuous). *Post-1.0.*
258- **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse.
259  *Post-1.0.*
260
261## Install
262
263```sh
264brew install krz/tap/ambient-companions
265brew services start ambient-companions
266```
267
268Name the repositories to watch — signald takes none by default, and a login
269agent has no useful working directory:
270
271```sh
272mkdir -p ~/.config/signald
273cat > ~/.config/signald/repos <<'EOF'
274# one repository path per line; # comments and blank lines are ignored
275~/git/some-repo
276EOF
277brew services restart ambient-companions
278```
279
280Then add one line to `.zshrc` for the terminal collector:
281
282```sh
283source "$(brew --prefix)/share/ambient-companions/signald-hooks.zsh"
284```
285
286Open a new shell and watch a face:
287
288```sh
289terminal-garden    # your repos, as plants that grow and wilt
290terminal-pet       # the shell and the machine, as a mood
291```
292
293The third face lives in the menu bar:
294
295```sh
296open "$(brew --prefix)/opt/ambient-companions/menubar-pet.app"
297```
298
299To have it every day, install the LaunchAgent rather than adding a Login Item:
300
301```sh
302cp packaging/net.krz.ambient-companions.menubar-pet.plist ~/Library/LaunchAgents/
303launchctl load ~/Library/LaunchAgents/net.krz.ambient-companions.menubar-pet.plist
304```
305
306A Login Item stores a *bookmark*, which macOS resolves to the versioned Cellar
307path when you create it — so it breaks silently at the first `brew cleanup`
308after an upgrade. launchd stores the path as a string and follows Homebrew's
309`opt` symlink at each launch, so upgrades are invisible to it. There is no
310`KeepAlive`: the menu has a Quit item, and quitting should stick.
311
312It has one life. Neglect it for a week — no typing, no commits — and it dies
313for good, and the next one starts when you come back.
314
315`brew services` logs to `$(brew --prefix)/var/log/`. To run the agent by hand
316instead, `packaging/net.krz.signald.plist` is a launchd template; its header
317comment carries the `sed` line that fills in the paths and the `launchctl load`
318that starts it.
319
320### Paths
321
322Defaults. `crates/signal-client` resolves the socket path once for the daemon
323and every renderer, and its test pins the values below;
324`shell-hooks/signald-hooks.zsh` derives the spool the same way in shell.
325
326| | `$XDG_RUNTIME_DIR` set | otherwise |
327|---|---|---|
328| socket | `$XDG_RUNTIME_DIR/signald.sock` | `~/.local/state/signald/sock` |
329| history db | `$XDG_RUNTIME_DIR/signald.sqlite` | `~/.local/state/signald/signald.sqlite` |
330| terminal spool | `$XDG_RUNTIME_DIR/terminal.spool` | `~/.local/state/signald/terminal.spool` |
331
332The menu-bar pet keeps its own state at
333`$XDG_DATA_HOME/ambient-companions/pet.state`, else
334`~/.local/share/ambient-companions/pet.state`. Deliberately not beside the
335socket: those paths follow `$XDG_RUNTIME_DIR` where it is set, which is a tmpfs
336wiped every reboot, and a graveyard a reboot can erase is not a graveyard.
337
338The db and the spool are derived from the socket's directory, so `--socket`
339moves all three together. Override individually with `--db` and `--spool`, and
340the spool from the shell side with `$SIGNALD_SPOOL` — it must match whatever
341the daemon uses, or the terminal metrics stay silently empty. launchd sets no
342`XDG_RUNTIME_DIR`, so an installed agent lands in `~/.local/state/signald`.
343
344The repository list is `$XDG_CONFIG_HOME/signald/repos`, else
345`~/.config/signald/repos`. Positional arguments to `signald` override it; with
346neither, the working directory is watched.
347
348Unix socket paths are limited to about 104 bytes. A deep scratch directory
349will hit `SUN_LEN`; use `mktemp -d` when testing by hand.
350
351## Changelog
352
353Notable changes per release are in [`CHANGELOG.org`](CHANGELOG.org), including
354every `SCHEMA_VERSION` move — the wire contract between the daemon, the Swift
355collector and every renderer.
356
357## Build & test
358
359```sh
360cargo build          # whole Rust workspace
361cargo test           # includes the privacy invariant suite (must be green)
362
363# The macOS IOKit hardware collector is a sibling Swift package (macOS only):
364cd macos-collector
365swift build          # builds the collector
366swift test           # the Swift↔Rust wire-contract test
367swift run macos-collector --once   # one real IOKit read (no root)
368```
369
370### Pre-push checks
371
372There is no CI. `.githooks/pre-push` is the gate: it runs `cargo test
373--locked`, `cargo clippy --all-targets --locked -- -D warnings`, and the
374`macos-collector` Swift tests, and a failure aborts the push. The differential
375secret-typing tests need `zsh` with the `zsh/zpty` module; the hook checks for
376it first and fails rather than skipping the gate. Enable it once per clone:
377
378```sh
379git config core.hooksPath .githooks
380```