README.md
380 lines · 20303 bytes
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)
✅ built ✅ built ✅ built planned planned
Three faces are built. The other two are the roadmap, not the product —
see "Phase plan". Every face draws 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 overf64. There is notext,bytes, orpayloadfield — a collector cannot emit typed content because the record has nowhere to put it. The one audited exception is a non-content identifier intag— 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,IOHIDManagerkeyboard usage, accessibility observation are all forbidden), and aggregation happens before transport (the shell emits counts onprecmd, never per-key events).
And it is tested (crates/signal-schema/tests/privacy_invariant.rs):
value_channel_is_exactly_f64— the payload is anf64, nothing wider.wire_format_has_no_content_field— theSignaltype declares no content-carrying field beyond the auditedtag.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 ownzsh/zpty— no extra dependency), typing a planted secret, and asserts the secret never appears — plain, reversed, hex, or base64 — in the shell spool or thef64-only wire encoding. The full pipeline version (same real typing driven through the terminal collector, the SQLite history store, and the hub) iscrates/signald/tests/differential_secret_typing.rs. Realzlekeystroke 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:
- garden — git is the cleanest signal (discrete, no privacy questions), so it proves the bus first. (recommended first face)
- gated collector + pet — add the sensitive terminal collector only after the privacy tests are green and extended to cover it.
- menu-bar permadeath pet — first macOS-native face; ships the one-life variant (ages in wall-clock time, dies permanently) — the emotional hook.
- SSH-sonification — unexpected access becomes audible; the utility sonification before the ambient one.
- 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 <path>) 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
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/supervisor.rs # collector liveness, restart, CollectorUp
│ │ ├── 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
│ ├── signal-client/ # socket path + frame iteration (shared)
│ ├── terminal-garden/ # first renderer: git aggregates as plots
│ │ ├── src/lib.rs # signals → plots → render (unit-tested)
│ │ └── src/main.rs # live subscribe + redraw loop
│ ├── terminal-pet/ # second renderer: shell + machine + health
│ │ ├── src/lib.rs # signals → PetState → render (unit-tested)
│ │ └── src/main.rs # live subscribe + redraw loop
│ └── pet-life/ # the one-life mechanic: ageing, death, cemetery
│ ├── src/lib.rs # pure over timestamps (unit-tested)
│ └── src/main.rs # one-shot: prints state as JSON
├── shell-hooks/ # zsh hooks: aggregate-only terminal collector
│ ├── signald-hooks.zsh
│ └── README.md
├── menubar-pet/ # SwiftPM sibling: the third face
│ ├── Sources/PetKit/ # the pet-life JSON contract (tested)
│ ├── Sources/menubar-pet/ # status item + timer + menu
│ └── scripts/bundle.sh # assembles the .app (LSUIElement)
└── 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 andf64-only.signald's git collector shells out togitfor aggregate scalars — commits-in-window, commits-today, branch count, days-since-last- commit — tagged by the audited repo path.terminal-gardensubscribes 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), andrecent()/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-gardennow redraws live.
- SQLite (WAL) history (
- Phase 2 — terminal collector. ✅ Done (v0.2).
The aggregate-only terminal path is real:
shell-hooks/signald-hooks.zshcounts keystrokes with azlewidget that increments a number and discards the key, and appends<epoch_ms> <keys> <session_seconds> <session_id>count records (numbers only) to a spool;collectors::terminalconsumes the spool and deriveskeys_per_minandsession_seconds. The differential secret-typing test is active and passing — the privacy ship-gate. The Terminal Pet renderer landed in v1.0. - Phase 3 — macOS IOKit hardware collector. ✅ Done (v0.3).
macos-collector/(a sibling Swift package,swift build) reads aggregate hardware scalars via IOKit only — nopowermetrics, no root: CPU load (Machhost_processor_info), battery %/charging (IOKit power sources), battery draw in watts (IORegistryAppleSmartBattery), and thermal state (ProcessInfo). It emitssignal-schemawire frames — the schema gained the aggregatecpu_loadmetric and bumped toSCHEMA_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.rsdecodes the exact bytes the Swift encoder commits to inmacos-collector/Tests/.../WireTests.swift). The menu-bar permadeath pet is post-1.0. - Hardware ingest. ✅ Done (v0.4).
signaldspawnsmacos-collectoras a child (--collector <path>, or found onPATH) and reads its stdout with the samewire::read_framethe 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_minis each active shell's rate summed rather than a mix of interleaved sessions, andsession_secondsis 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).
SignalNameis cut to the eleven metrics that have a producer, renumbered from zero, andSCHEMA_VERSIONis 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. - v1.0 — trust it unattended. ✅ Done (v1.0.0).
1.0 is not feature completeness and not an API promise to anyone else. It is
one claim: the daemon runs unattended for weeks, recovers from its own
failures, and says so when it cannot.
signald/src/supervisor.rsowns collector liveness. A panic in the git or terminal collector costs one tick instead of the producer thread; the hardware child is respawned with backoff;run_gitkills agitthat hangs, because a Rust thread cannot be. Health rides the bus asSignalName::CollectorUp, one perSource— the schema'sv5addition — so a dead collector reaches a face rather than a log file.terminal-petrenders it: a pet that cannot feel its own hardware looks sick. - Phase 3 renderer — menu-bar permadeath pet. ✅ Done.
menubar-pet/is a sibling SwiftPM package: a status item, a timer and a menu. One life — it ages, neglect kills it over a week through visible stages, and death is permanent for that pet. A new one arrives when you come back, and the old one is remembered in a cemetery that is never pruned. The mechanic lives in thepet-lifeRust crate, which the app runs on a timer; the app decodes no wire and opens no socket, so there is still one decoder and one privacy boundary. Everything derives from timestamps, so sleep, reboots and the app not running change nothing. - Phase 4 — sonification (SSH-utility first, then continuous). Post-1.0.
- Phase 5 — live wallpaper (homelab, Path A) + e-ink/poster reuse. Post-1.0.
Install
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:
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:
source "$(brew --prefix)/share/ambient-companions/signald-hooks.zsh"
Open a new shell and watch a face:
terminal-garden # your repos, as plants that grow and wilt
terminal-pet # the shell and the machine, as a mood
The third face lives in the menu bar:
open "$(brew --prefix)/opt/ambient-companions/menubar-pet.app"
To have it every day, install the LaunchAgent rather than adding a Login Item:
cp packaging/net.krz.ambient-companions.menubar-pet.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/net.krz.ambient-companions.menubar-pet.plist
A Login Item stores a bookmark, which macOS resolves to the versioned Cellar
path when you create it — so it breaks silently at the first brew cleanup
after an upgrade. launchd stores the path as a string and follows Homebrew's
opt symlink at each launch, so upgrades are invisible to it. There is no
KeepAlive: the menu has a Quit item, and quitting should stick.
It has one life. Neglect it for a week — no typing, no commits — and it dies for good, and the next one starts when you come back.
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. crates/signal-client resolves the socket path once for the daemon
and every renderer, and its test pins the values below;
shell-hooks/signald-hooks.zsh derives the spool the same way in shell.
$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 menu-bar pet keeps its own state at
$XDG_DATA_HOME/ambient-companions/pet.state, else
~/.local/share/ambient-companions/pet.state. Deliberately not beside the
socket: those paths follow $XDG_RUNTIME_DIR where it is set, which is a tmpfs
wiped every reboot, and a graveyard a reboot can erase is not a graveyard.
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.
Changelog
Notable changes per release are in CHANGELOG.org, including
every SCHEMA_VERSION move — the wire contract between the daemon, the Swift
collector and every renderer.
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)
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:
git config core.hooksPath .githooks