docs/superpowers/specs/2026-09-04-menubar-pet-design.md
225 lines · 9981 bytes
menubar-pet design
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 .xcodeproj — swift 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_msagainst 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.statewith a note on why it is not under the runtime directory. - CHANGELOG: an Unreleased entry.
Delivery
A stack, each MR targeting the one below:
History::open_read_onlyandlast_activity_mson signald.crates/pet-life/: the mechanic, persistence, the cemetery, the JSON.- The Swift app and its bundle script.
- 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.