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

docs/superpowers/specs/2026-09-04-menubar-pet-design.md

v1.2.0
ambient-companions/docs/superpowers/specs/2026-09-04-menubar-pet-design.md rendered · source · history · blame · raw

225 lines · 9981 bytes

Date: 2026-09-04 Status: approved, not yet implemented Baseline: v1.1.0 + 888f0e5

The third face, and the first macOS-native one. A menu-bar pet with one life: it ages, neglect kills it, and death is permanent for that pet.

Why this shape

The two existing faces are stateless functions of the current snapshot. This one is not: permadeath needs durable state that outlives the 7-day history retention, a reinstall, and a reboot. That is the whole reason it warrants a design rather than another renderer in the existing shape.

The mechanic

Neglect kills it. "Showing up" is defined precisely, since the whole mechanic turns on it: a history row for KeysPerMin with a value above zero, or for CommitsToday with a value above zero, in any repository signald was watching at the time. Both are already persisted with timestamps.

SessionSeconds deliberately does not count — a shell sitting open is not attention, and counting it would mean a forgotten terminal keeps the pet alive indefinitely. Hardware signals do not count either: a sleeping machine still reports a battery percentage.

stage quiet for
content < 1 day
restless 1-2 days
hungry 2-3.5 days
sick 3.5-5 days
dying 5-7 days
dead >= 7 days

Thresholds are constants, so the pace is one edit.

Everything derives from timestamps; nothing accumulates. Death occurs at last_activity + 7 days, not when the app noticed. The answer is identical whether you look an hour or a month later, which makes sleep, reboots and daemon restarts irrelevant by construction.

A new pet is born on the next activity after a death, never automatically. Automatic rebirth would turn a fortnight away into a chain of pets born, never fed, and dead — a cemetery full of lives that did not happen. Requiring activity means you return to one grave and a fresh pet.

Names come from a small fixed word list, chosen deterministically from the birth timestamp. A cemetery of "Mochi, 9 days" means something; one of "generation 3" does not.

The cemetery is unbounded, deliberately. A pet dies at most every ~8 days: about 45 a year, a few dozen bytes each, under 100 KB in twenty years. The spool was capped because nothing consumed it. Here the point is remembering, and bounded remembering is forgetting on a delay.

Architecture

The app polls a Rust one-shot. Three options were considered:

  • Swift decodes the wire directly. Rejected: a second implementation of the decode path, including v5 skip semantics. The wire is the privacy boundary, and the stated reason for one bus was one boundary to defend rather than five.
  • A streaming Rust sidecar. Rejected: long-lived child-process supervision inside a GUI app, which is the exact class of bug v1.0 was spent fixing in the daemon.
  • Swift polls a Rust one-shot. Chosen.

The decline plays out over days, so a 5-second poll is already far faster than the mechanic needs. It keeps one wire decoder, in the language that has the test suite and the privacy gate, and puts the interesting logic — aging, death, the cemetery — where it can be unit-tested.

Components

crates/pet-life/ — library plus a thin binary. The library owns aging, stages, death, the cemetery and persistence. The binary prints state as JSON and exits.

menubar-pet — a Swift app: an NSStatusItem, a menu, and a timer. No wire decoding, no socket, no subprocess supervision.

The JSON contract

{ "generation": 3, "name": "Mochi", "stage": "hungry",
  "age_days": 4.2, "quiet_days": 2.1, "alive": true,
  "cemetery": [ { "name": "Bean", "generation": 2, "lived_days": 9.4 } ] }

Hand-rolled rather than serde. The shape is fixed and every value is a number, a boolean, a stage name or a name from a curated list — no user input, so no escaping hazard. The README makes a point of rusqlite being the single dependency. If hand-rolling turns awkward, serde is the fallback and the change should say so rather than contorting around it.

Pinned by a test asserting a known state serialises to exact bytes, the same discipline as the canonical wire frame. That has already caught two cross-language mismatches in this project, and this is a second language boundary.

Where activity comes from

Not from what the app observes while running. If the app were closed for a week the pet would starve while you worked the whole time.

pet-life reads signald's SQLite history for the most recent keystroke or commit activity, and separately persists the latest activity it has ever seen. The effective value is the later of the two. That survives the app being closed, the machine sleeping, and the daemon restarting — all normal, none of which should kill a pet.

History retention defaults to 7 days and the neglect window is also 7, so beyond that the table holds no activity rows. By then the pet is dead regardless, so the ambiguity never affects a live pet, and the persisted value covers the rest.

Access is via new History::open_read_only and History::last_activity_ms on signald rather than a second copy of the schema. Read-only matters: the normal open prunes and migrates, and a menu-bar app polling every five seconds must not mutate the daemon's store.

Persistence

$XDG_DATA_HOME/ambient-companions/pet.state, else ~/.local/share/ambient-companions/pet.state.

Implemented as a line-based format rather than the JSON this design first called for. Only pet-life reads it, so a hand-rolled JSON parser would have been all risk and no benefit; the terminal spool already stores plain whitespace-separated records for the same reason. JSON remains the contract handed to the app, and that direction only needs emitting.

Deliberately not under the daemon's state directory. That path derives from the socket, which follows $XDG_RUNTIME_DIR when set — a tmpfs on systems that set it, wiped every reboot. It is unset on macOS today, so putting the cemetery there would work by luck. Data, not runtime.

Writes are temp-file-plus-rename, so a crash mid-write cannot corrupt the graveyard. If two instances run, worst case is last-writer-wins losing one update, costing a few seconds of last_seen.

The app

Build. SwiftPM cannot emit a .app, so the executable is a normal SwiftPM target and a script assembles the bundle around it: Contents/MacOS and an Info.plist with LSUIElement set, so there is no Dock icon and no window. No .xcodeprojswift build stays the primary path, which keeps it buildable from the formula and the pre-push hook.

Runtime. An NSStatusItem showing the face, and a timer polling pet-life every 5 seconds. The menu carries name, stage and age, the cemetery below a separator, and Quit. It resolves pet-life next to its own binary first, then PATH — the order signald uses for macos-collector.

If pet-life is missing or fails, the status item shows a neutral glyph and the menu says the daemon is not reachable. A face that cannot read the bus says so rather than showing a stale pet, which is the rule the terminal pet follows.

Distribution: extend the existing formula, no cask. Casks want a downloadable signed archive, meaning release artifacts, signing and notarization — a great deal of machinery for a personal tool. The formula already builds from source, so it builds the bundle and installs it into the prefix alongside the binaries.

Building locally also sidesteps the unsigned-app problem: Gatekeeper quarantines downloaded apps, not ones compiled on the machine.

Launch at login is documented via System Settings -> Login Items, not another LaunchAgent plist. signald needs a plist because it is a background daemon that must run without a session; a menu-bar app is session-scoped, and Login Items is the mechanism people already know.

Not building: preferences, a settings window, death notifications, or a dock-icon mode. The pet is one glyph and a menu.

Testing

pet-life is pure functions over timestamps with now_ms injected, so tests are deterministic and instant — no sleeping, no clock mocking.

  • Every stage boundary.
  • Death derived at last_activity + 7d, identical whenever observed.
  • A month's absence yields exactly one grave, not a chain.
  • Rebirth requires activity.
  • Name determinism.
  • Cemetery append across generations.
  • A crash mid-write leaves the previous file intact.
  • The JSON contract, pinned to exact bytes.
  • last_activity_ms against an in-memory history fixture, including the case where retention has pruned everything.

The Swift side is almost untested: it is a status item, a timer and a menu, and the logic lives in Rust. Two things are checked. The bundle assembles — the .app exists and its Info.plist carries LSUIElement. And PetKit decodes the exact bytes pet-life pins, including the dead-pet case where name is null, which a plain String would reject. That second one departs from this design deliberately: the Rust-to-Swift contract has drifted twice in this project and a pinned fixture caught it both times.

The menu bar's actual appearance is not verified by anything, and cannot be without a human looking at it.

Documentation

  • README: the faces diagram gains a third built face; a phase-plan entry; workspace layout; launch instructions; the Paths table gains pet.state with a note on why it is not under the runtime directory.
  • CHANGELOG: an Unreleased entry.

Delivery

A stack, each MR targeting the one below:

  1. History::open_read_only and last_activity_ms on signald.
  2. crates/pet-life/: the mechanic, persistence, the cemetery, the JSON.
  3. The Swift app and its bundle script.
  4. Formula, README and CHANGELOG.

Then a minor release, since this adds a face and a binary without changing the wire contract. SCHEMA_VERSION stays 5: the pet reads existing signals and adds none.