docs/superpowers/specs/2026-09-04-menubar-pet-design.md
225 lines · 9981 bytes
1# menubar-pet design
2
3Date: 2026-09-04
4Status: approved, not yet implemented
5Baseline: v1.1.0 + `888f0e5`
6
7The third face, and the first macOS-native one. A menu-bar pet with one life:
8it ages, neglect kills it, and death is permanent for that pet.
9
10## Why this shape
11
12The two existing faces are stateless functions of the current snapshot. This
13one is not: permadeath needs durable state that outlives the 7-day history
14retention, a reinstall, and a reboot. That is the whole reason it warrants a
15design rather than another renderer in the existing shape.
16
17## The mechanic
18
19Neglect kills it. "Showing up" is defined precisely, since the whole mechanic
20turns on it: a history row for `KeysPerMin` with a value above zero, or for
21`CommitsToday` with a value above zero, in any repository signald was watching
22at the time. Both are already persisted with timestamps.
23
24`SessionSeconds` deliberately does not count — a shell sitting open is not
25attention, and counting it would mean a forgotten terminal keeps the pet alive
26indefinitely. Hardware signals do not count either: a sleeping machine still
27reports a battery percentage.
28
29| stage | quiet for |
30|---|---|
31| content | < 1 day |
32| restless | 1-2 days |
33| hungry | 2-3.5 days |
34| sick | 3.5-5 days |
35| dying | 5-7 days |
36| dead | >= 7 days |
37
38Thresholds are constants, so the pace is one edit.
39
40**Everything derives from timestamps; nothing accumulates.** Death occurs at
41`last_activity + 7 days`, not when the app noticed. The answer is identical
42whether you look an hour or a month later, which makes sleep, reboots and
43daemon restarts irrelevant by construction.
44
45**A new pet is born on the next activity after a death, never automatically.**
46Automatic rebirth would turn a fortnight away into a chain of pets born, never
47fed, and dead — a cemetery full of lives that did not happen. Requiring
48activity means you return to one grave and a fresh pet.
49
50**Names** come from a small fixed word list, chosen deterministically from the
51birth timestamp. A cemetery of "Mochi, 9 days" means something; one of
52"generation 3" does not.
53
54**The cemetery is unbounded, deliberately.** A pet dies at most every ~8 days:
55about 45 a year, a few dozen bytes each, under 100 KB in twenty years. The
56spool was capped because nothing consumed it. Here the point is remembering,
57and bounded remembering is forgetting on a delay.
58
59## Architecture
60
61The app polls a Rust one-shot. Three options were considered:
62
63- **Swift decodes the wire directly.** Rejected: a second implementation of the
64 decode path, including v5 skip semantics. The wire is the privacy boundary,
65 and the stated reason for one bus was one boundary to defend rather than
66 five.
67- **A streaming Rust sidecar.** Rejected: long-lived child-process supervision
68 inside a GUI app, which is the exact class of bug v1.0 was spent fixing in
69 the daemon.
70- **Swift polls a Rust one-shot.** Chosen.
71
72The decline plays out over days, so a 5-second poll is already far faster than
73the mechanic needs. It keeps one wire decoder, in the language that has the
74test suite and the privacy gate, and puts the interesting logic — aging, death,
75the cemetery — where it can be unit-tested.
76
77### Components
78
79**`crates/pet-life/`** — library plus a thin binary. The library owns aging,
80stages, death, the cemetery and persistence. The binary prints state as JSON
81and exits.
82
83**`menubar-pet`** — a Swift app: an `NSStatusItem`, a menu, and a timer. No
84wire decoding, no socket, no subprocess supervision.
85
86### The JSON contract
87
88```json
89{ "generation": 3, "name": "Mochi", "stage": "hungry",
90 "age_days": 4.2, "quiet_days": 2.1, "alive": true,
91 "cemetery": [ { "name": "Bean", "generation": 2, "lived_days": 9.4 } ] }
92```
93
94Hand-rolled rather than `serde`. The shape is fixed and every value is a
95number, a boolean, a stage name or a name from a curated list — no user input,
96so no escaping hazard. The README makes a point of `rusqlite` being the single
97dependency. If hand-rolling turns awkward, `serde` is the fallback and the
98change should say so rather than contorting around it.
99
100Pinned by a test asserting a known state serialises to exact bytes, the same
101discipline as the canonical wire frame. That has already caught two
102cross-language mismatches in this project, and this is a second language
103boundary.
104
105### Where activity comes from
106
107Not from what the app observes while running. If the app were closed for a
108week the pet would starve while you worked the whole time.
109
110`pet-life` reads signald's SQLite history for the most recent keystroke or
111commit activity, and separately persists the latest activity it has ever seen.
112The effective value is the later of the two. That survives the app being
113closed, the machine sleeping, and the daemon restarting — all normal, none of
114which should kill a pet.
115
116History retention defaults to 7 days and the neglect window is also 7, so
117beyond that the table holds no activity rows. By then the pet is dead
118regardless, so the ambiguity never affects a live pet, and the persisted value
119covers the rest.
120
121Access is via new `History::open_read_only` and `History::last_activity_ms` on
122signald rather than a second copy of the schema. Read-only matters: the normal
123`open` prunes and migrates, and a menu-bar app polling every five seconds must
124not mutate the daemon's store.
125
126### Persistence
127
128`$XDG_DATA_HOME/ambient-companions/pet.state`, else
129`~/.local/share/ambient-companions/pet.state`.
130
131Implemented as a line-based format rather than the JSON this design first
132called for. Only `pet-life` reads it, so a hand-rolled JSON *parser* would have
133been all risk and no benefit; the terminal spool already stores plain
134whitespace-separated records for the same reason. JSON remains the contract
135handed to the app, and that direction only needs emitting.
136
137Deliberately **not** under the daemon's state directory. That path derives from
138the socket, which follows `$XDG_RUNTIME_DIR` when set — a tmpfs on systems that
139set it, wiped every reboot. It is unset on macOS today, so putting the cemetery
140there would work by luck. Data, not runtime.
141
142Writes are temp-file-plus-rename, so a crash mid-write cannot corrupt the
143graveyard. If two instances run, worst case is last-writer-wins losing one
144update, costing a few seconds of `last_seen`.
145
146## The app
147
148**Build.** SwiftPM cannot emit a `.app`, so the executable is a normal SwiftPM
149target and a script assembles the bundle around it: `Contents/MacOS` and an
150`Info.plist` with `LSUIElement` set, so there is no Dock icon and no window.
151No `.xcodeproj` — `swift build` stays the primary path, which keeps it
152buildable from the formula and the pre-push hook.
153
154**Runtime.** An `NSStatusItem` showing the face, and a timer polling `pet-life`
155every 5 seconds. The menu carries name, stage and age, the cemetery below a
156separator, and Quit. It resolves `pet-life` next to its own binary first, then
157`PATH` — the order `signald` uses for `macos-collector`.
158
159If `pet-life` is missing or fails, the status item shows a neutral glyph and
160the menu says the daemon is not reachable. A face that cannot read the bus says
161so rather than showing a stale pet, which is the rule the terminal pet follows.
162
163**Distribution: extend the existing formula, no cask.** Casks want a
164downloadable signed archive, meaning release artifacts, signing and
165notarization — a great deal of machinery for a personal tool. The formula
166already builds from source, so it builds the bundle and installs it into the
167prefix alongside the binaries.
168
169Building locally also sidesteps the unsigned-app problem: Gatekeeper
170quarantines downloaded apps, not ones compiled on the machine.
171
172**Launch at login** is documented via System Settings -> Login Items, not
173another LaunchAgent plist. signald needs a plist because it is a background
174daemon that must run without a session; a menu-bar app is session-scoped, and
175Login Items is the mechanism people already know.
176
177**Not building:** preferences, a settings window, death notifications, or a
178dock-icon mode. The pet is one glyph and a menu.
179
180## Testing
181
182`pet-life` is pure functions over timestamps with `now_ms` injected, so tests
183are deterministic and instant — no sleeping, no clock mocking.
184
185- Every stage boundary.
186- Death derived at `last_activity + 7d`, identical whenever observed.
187- A month's absence yields exactly one grave, not a chain.
188- Rebirth requires activity.
189- Name determinism.
190- Cemetery append across generations.
191- A crash mid-write leaves the previous file intact.
192- The JSON contract, pinned to exact bytes.
193- `last_activity_ms` against an in-memory history fixture, including the case
194 where retention has pruned everything.
195
196The Swift side is almost untested: it is a status item, a timer and a menu, and
197the logic lives in Rust. Two things are checked. The bundle assembles — the
198`.app` exists and its `Info.plist` carries `LSUIElement`. And `PetKit` decodes
199the exact bytes `pet-life` pins, including the dead-pet case where `name` is
200null, which a plain `String` would reject. That second one departs from this
201design deliberately: the Rust-to-Swift contract has drifted twice in this
202project and a pinned fixture caught it both times.
203
204The menu bar's actual appearance is not verified by anything, and cannot be
205without a human looking at it.
206
207## Documentation
208
209- README: the faces diagram gains a third built face; a phase-plan entry;
210 workspace layout; launch instructions; the Paths table gains `pet.state` with
211 a note on why it is not under the runtime directory.
212- CHANGELOG: an Unreleased entry.
213
214## Delivery
215
216A stack, each MR targeting the one below:
217
2181. `History::open_read_only` and `last_activity_ms` on signald.
2192. `crates/pet-life/`: the mechanic, persistence, the cemetery, the JSON.
2203. The Swift app and its bundle script.
2214. Formula, README and CHANGELOG.
222
223Then a minor release, since this adds a face and a binary without changing the
224wire contract. `SCHEMA_VERSION` stays 5: the pet reads existing signals and
225adds none.