# 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 ```json { "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_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.