# 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 ```text 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: > 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 one audited exception is a non-content identifier in `tag` — an absolute repo path, allow-listed to the four git metric names, and confined by the daemon to the roots it was told to watch. - **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 gate). - `differential_secret_typing` — **active (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 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. `signald` spawns it as a child process (found on `PATH`, or named with `--collector `) and ingests the frames it writes to stdout, so hardware signals reach the hub, the history store, and every subscriber by the same path as git and terminal signals. 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 ```text 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, hardware ingest, 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/hardware_ingest.rs # collector frames reach a subscriber │ │ └── 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**, 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 ` ` count records (numbers only) to a spool; `collectors::terminal` consumes 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. - **Hardware ingest. ✅ Done (v0.4).** `signald` spawns `macos-collector` as a child (`--collector `, or found on `PATH`) and reads its stdout with the same `wire::read_frame` the socket uses (`collectors::hardware`). The five hardware signals now appear in the hub, the history store, and every subscriber's snapshot (`crates/signald/tests/hardware_ingest.rs`). - **Bounded spool and history. ✅ Done (v0.4).** The terminal spool is consumed each tick (renamed aside, read, deleted) instead of re-read in full forever. Records carry the shell's pid, so `keys_per_min` is each active shell's rate summed rather than a mix of interleaved sessions, and `session_seconds` is the longest active shell. History rows older than `--retention-days` (default 7) are pruned on open and every 1000 inserts. - **Contract freeze. ✅ Done (v0.5).** `SignalName` is cut to the eleven metrics that have a producer, renumbered from zero, and `SCHEMA_VERSION` is **4 — the 1.0 contract**. Discriminants are append-only from 1.0, so v4 was the last chance to renumber. The only audited tag identifier left is the repo path, confined by the daemon to the roots it was told to watch. A reader now **skips** a frame it cannot decode instead of dying on it (`wire::Frame::Skipped`), so an older renderer keeps working against a newer daemon. References to an uncommitted spec are gone. - **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.* ## Install ```sh brew install krz/tap/ambient-companions brew services start ambient-companions ``` Name the repositories to watch — signald takes none by default, and a login agent has no useful working directory: ```sh mkdir -p ~/.config/signald cat > ~/.config/signald/repos <<'EOF' # one repository path per line; # comments and blank lines are ignored ~/git/some-repo EOF brew services restart ambient-companions ``` Then add one line to `.zshrc` for the terminal collector: ```sh source "$(brew --prefix)/share/ambient-companions/signald-hooks.zsh" ``` Open a new shell and watch the garden: ```sh terminal-garden ``` `brew services` logs to `$(brew --prefix)/var/log/`. To run the agent by hand instead, `packaging/net.krz.signald.plist` is a launchd template; its header comment carries the `sed` line that fills in the paths and the `launchctl load` that starts it. ### Paths Defaults, resolved the same way by `signald`, `terminal-garden`, and `shell-hooks/signald-hooks.zsh`. This table is the single place they are written down; unit tests in both binaries pin the code to it. | | `$XDG_RUNTIME_DIR` set | otherwise | |---|---|---| | socket | `$XDG_RUNTIME_DIR/signald.sock` | `~/.local/state/signald/sock` | | history db | `$XDG_RUNTIME_DIR/signald.sqlite` | `~/.local/state/signald/signald.sqlite` | | terminal spool | `$XDG_RUNTIME_DIR/terminal.spool` | `~/.local/state/signald/terminal.spool` | The db and the spool are derived from the socket's directory, so `--socket` moves all three together. Override individually with `--db` and `--spool`, and the spool from the shell side with `$SIGNALD_SPOOL` — it must match whatever the daemon uses, or the terminal metrics stay silently empty. launchd sets no `XDG_RUNTIME_DIR`, so an installed agent lands in `~/.local/state/signald`. The repository list is `$XDG_CONFIG_HOME/signald/repos`, else `~/.config/signald/repos`. Positional arguments to `signald` override it; with neither, the working directory is watched. Unix socket paths are limited to about 104 bytes. A deep scratch directory will hit `SUN_LEN`; use `mktemp -d` when testing by hand. ## Build & test ```sh 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) ``` ### Pre-push checks There is no CI. `.githooks/pre-push` is the gate: it runs `cargo test --locked`, `cargo clippy --all-targets --locked -- -D warnings`, and the `macos-collector` Swift tests, and a failure aborts the push. The differential secret-typing tests need `zsh` with the `zsh/zpty` module; the hook checks for it first and fails rather than skipping the gate. Enable it once per clone: ```sh git config core.hooksPath .githooks ```