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

Commit f10b9ac90e

f10b9ac90ea09456cc57ab5b15cd6f9597eba3af

parent: 888f0e549d

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-04 17:12 UTC

Add the menubar-pet design

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

Unlike the two existing faces it is not a stateless function of the current
snapshot. Permadeath needs state that outlives the 7-day history retention, a
reinstall and a reboot, which is what earns it a design rather than another
renderer in the existing shape.

The app polls a Rust one-shot rather than decoding the wire in Swift. A second
decoder would be a second implementation of the privacy boundary, and a
streaming sidecar would put child-process supervision inside a GUI app, which
is the class of bug v1.0 was spent removing from the daemon.
docs/superpowers/specs/2026-09-04-menubar-pet-design.md added +213
@@ -0,0 +1,213 @@
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.json`, else
129`~/.local/share/ambient-companions/pet.json`.
130
131Deliberately **not** under the daemon's state directory. That path derives from
132the socket, which follows `$XDG_RUNTIME_DIR` when set — a tmpfs on systems that
133set it, wiped every reboot. It is unset on macOS today, so putting the cemetery
134there would work by luck. Data, not runtime.
135
136Writes are temp-file-plus-rename, so a crash mid-write cannot corrupt the
137graveyard. If two instances run, worst case is last-writer-wins losing one
138update, costing a few seconds of `last_seen`.
139
140## The app
141
142**Build.** SwiftPM cannot emit a `.app`, so the executable is a normal SwiftPM
143target and a script assembles the bundle around it: `Contents/MacOS` and an
144`Info.plist` with `LSUIElement` set, so there is no Dock icon and no window.
145No `.xcodeproj``swift build` stays the primary path, which keeps it
146buildable from the formula and the pre-push hook.
147
148**Runtime.** An `NSStatusItem` showing the face, and a timer polling `pet-life`
149every 5 seconds. The menu carries name, stage and age, the cemetery below a
150separator, and Quit. It resolves `pet-life` next to its own binary first, then
151`PATH` — the order `signald` uses for `macos-collector`.
152
153If `pet-life` is missing or fails, the status item shows a neutral glyph and
154the menu says the daemon is not reachable. A face that cannot read the bus says
155so rather than showing a stale pet, which is the rule the terminal pet follows.
156
157**Distribution: extend the existing formula, no cask.** Casks want a
158downloadable signed archive, meaning release artifacts, signing and
159notarization — a great deal of machinery for a personal tool. The formula
160already builds from source, so it builds the bundle and installs it into the
161prefix alongside the binaries.
162
163Building locally also sidesteps the unsigned-app problem: Gatekeeper
164quarantines downloaded apps, not ones compiled on the machine.
165
166**Launch at login** is documented via System Settings -> Login Items, not
167another LaunchAgent plist. signald needs a plist because it is a background
168daemon that must run without a session; a menu-bar app is session-scoped, and
169Login Items is the mechanism people already know.
170
171**Not building:** preferences, a settings window, death notifications, or a
172dock-icon mode. The pet is one glyph and a menu.
173
174## Testing
175
176`pet-life` is pure functions over timestamps with `now_ms` injected, so tests
177are deterministic and instant — no sleeping, no clock mocking.
178
179- Every stage boundary.
180- Death derived at `last_activity + 7d`, identical whenever observed.
181- A month's absence yields exactly one grave, not a chain.
182- Rebirth requires activity.
183- Name determinism.
184- Cemetery append across generations.
185- A crash mid-write leaves the previous file intact.
186- The JSON contract, pinned to exact bytes.
187- `last_activity_ms` against an in-memory history fixture, including the case
188 where retention has pruned everything.
189
190The Swift side stays untested: it is a status item, a timer and a menu, and the
191logic lives in Rust. What is checked is that the bundle assembles — the `.app`
192exists and its `Info.plist` carries `LSUIElement`. Claiming UI coverage that
193does not exist would be worse than saying this plainly.
194
195## Documentation
196
197- README: the faces diagram gains a third built face; a phase-plan entry;
198 workspace layout; launch instructions; the Paths table gains `pet.json` with
199 a note on why it is not under the runtime directory.
200- CHANGELOG: an Unreleased entry.
201
202## Delivery
203
204A stack, each MR targeting the one below:
205
2061. `History::open_read_only` and `last_activity_ms` on signald.
2072. `crates/pet-life/`: the mechanic, persistence, the cemetery, the JSON.
2083. The Swift app and its bundle script.
2094. Formula, README and CHANGELOG.
210
211Then a minor release, since this adds a face and a binary without changing the
212wire contract. `SCHEMA_VERSION` stays 5: the pet reads existing signals and
213adds none.