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 da37447dbf

da37447dbffbe07f97c25cc5996fada1a70a5f17

parent: d1942a01f0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-04 15:41 UTC

Add terminal-pet, the second face

The garden renders the four git aggregates. The shell's keystroke rate and
session length and the five hardware scalars had no reader at all, so a
collector producing them could stop without anything visibly changing. The pet
reads them, which is also what makes the supervision work observable.

Mood is a pure function of the current snapshot, in precedence order: a down
collector, then heat, then a low battery on an unplugged machine, then
typing rate. A collector reporting CollectorUp at 0.0 outranks everything —
a pet that cannot feel its own hardware should look wrong rather than calm.

A value not refreshed within 30s renders stale rather than sick: the collector
never reported a failure, the number behind it simply stopped moving.

Stateless, so there is no persistence to corrupt and nothing to migrate.

Verified live: killing the collector child moves the pet to sick with the
source named, and it recovers when the supervisor respawns.

Closes #11
Cargo.lock +8
@@ -237,6 +237,14 @@ dependencies = [
237237 "signal-schema",
238238]
239239
240[[package]]
241name = "terminal-pet"
242version = "0.6.1"
243dependencies = [
244 "signal-client",
245 "signal-schema",
246]
247
240248[[package]]
241249name = "thiserror"
242250version = "2.0.20"
Cargo.toml +1
@@ -5,6 +5,7 @@ members = [
55 "crates/signal-client",
66 "crates/signald",
77 "crates/terminal-garden",
8 "crates/terminal-pet",
89]
910
1011[workspace.package]
crates/terminal-pet/Cargo.toml added +14
@@ -0,0 +1,14 @@
1[package]
2name = "terminal-pet"
3version.workspace = true
4edition.workspace = true
5license.workspace = true
6description = "Second renderer: a TUI pet whose mood follows the shell and the machine. A thin subscriber to signald — it reads aggregates off the socket and never touches a sensor."
7
8[[bin]]
9name = "terminal-pet"
10path = "src/main.rs"
11
12[dependencies]
13signal-schema = { path = "../signal-schema" }
14signal-client = { path = "../signal-client" }
crates/terminal-pet/src/lib.rs added +297
@@ -0,0 +1,297 @@
1//! # terminal-pet
2//!
3//! The second face: a pet whose mood follows the shell and the machine.
4//!
5//! The garden renders the four git aggregates. Everything else on the bus —
6//! the shell's keystroke rate and session length, the five hardware scalars —
7//! had no reader at all, which meant the collectors producing them could stop
8//! without anything visibly changing. This is their reader.
9//!
10//! It is also where the daemon's own health becomes visible.
11//! [`SignalName::CollectorUp`] at `0.0` makes the pet sick, and that outranks
12//! every other mood: a pet that cannot feel its own hardware should look wrong
13//! rather than look calm.
14//!
15//! Stateless by design — [`PetState`] is a pure function of the current
16//! snapshot, so there is no persistence to corrupt and nothing to migrate.
17
18use std::collections::BTreeMap;
19
20use signal_schema::{Signal, SignalName, Source};
21
22/// A signal older than this is treated as stale: the collector may still be up
23/// while the value behind it stopped moving. Comfortably past the daemon's 2s
24/// default tick, so a healthy system never flickers.
25pub const STALE_AFTER_MS: u64 = 30_000;
26
27/// Keystrokes per minute at or above which the pet reads as busy.
28pub const BUSY_KEYS_PER_MIN: f64 = 60.0;
29/// `ProcessInfo` thermal state at or above which the pet reads as overheating.
30pub const HOT_THERMAL_STATE: f64 = 2.0;
31/// Battery percentage below which an unplugged machine reads as flagging.
32pub const LOW_BATTERY_PCT: f64 = 20.0;
33
34/// One metric as the pet sees it.
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct Reading {
37 pub value: f64,
38 /// The value has not been refreshed within [`STALE_AFTER_MS`]. Rendered
39 /// dimmed: "up but stalled" is not the same as "down".
40 pub stale: bool,
41}
42
43/// What the pet is doing, in precedence order.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Mood {
46 /// A collector is down. Outranks everything else.
47 Sick,
48 Overheating,
49 LowBattery,
50 Busy,
51 Calm,
52 Sleeping,
53}
54
55impl Mood {
56 pub fn face(self) -> &'static str {
57 match self {
58 Mood::Sick => "(x_x)",
59 Mood::Overheating => "(>_<)",
60 Mood::LowBattery => "(-_-)",
61 Mood::Busy => "(o_o)",
62 Mood::Calm => "(^_^)",
63 Mood::Sleeping => "(-.-)",
64 }
65 }
66
67 pub fn label(self) -> &'static str {
68 match self {
69 Mood::Sick => "sick",
70 Mood::Overheating => "overheating",
71 Mood::LowBattery => "flagging",
72 Mood::Busy => "busy",
73 Mood::Calm => "calm",
74 Mood::Sleeping => "asleep",
75 }
76 }
77}
78
79/// The pet, derived from one snapshot.
80#[derive(Debug, Clone, PartialEq)]
81pub struct PetState {
82 pub mood: Mood,
83 /// Collectors reporting down, in wire order.
84 pub down: Vec<Source>,
85 pub keys_per_min: Option<Reading>,
86 pub session_seconds: Option<Reading>,
87 pub cpu_load: Option<Reading>,
88 pub thermal_state: Option<Reading>,
89 pub battery_pct: Option<Reading>,
90 pub charging: Option<Reading>,
91 pub battery_draw_w: Option<Reading>,
92}
93
94/// Fold a snapshot into the pet.
95///
96/// `now_ms` is passed rather than read so the staleness rule is testable.
97pub fn pet_from_signals(signals: &[Signal], now_ms: u64) -> PetState {
98 let mut latest: BTreeMap<(u8, u8), &Signal> = BTreeMap::new();
99 for s in signals {
100 latest.insert((s.name.to_u8(), s.source.to_u8()), s);
101 }
102
103 let read = |name: SignalName| -> Option<Reading> {
104 latest
105 .iter()
106 .find(|((n, _), _)| *n == name.to_u8())
107 .map(|(_, s)| Reading {
108 value: s.value.0,
109 stale: now_ms.saturating_sub(s.ts) > STALE_AFTER_MS,
110 })
111 };
112
113 let mut down: Vec<Source> = latest
114 .iter()
115 .filter(|((n, _), s)| *n == SignalName::CollectorUp.to_u8() && s.value.0 == 0.0)
116 .filter_map(|((_, src), _)| Source::from_u8(*src))
117 .collect();
118 down.sort_by_key(|s| s.to_u8());
119
120 let keys_per_min = read(SignalName::KeysPerMin);
121 let thermal_state = read(SignalName::ThermalState);
122 let battery_pct = read(SignalName::BatteryPct);
123 let charging = read(SignalName::Charging);
124
125 let plugged_in = charging.map(|c| c.value >= 1.0).unwrap_or(false);
126 let mood = if !down.is_empty() {
127 Mood::Sick
128 } else if thermal_state.map(|t| t.value >= HOT_THERMAL_STATE).unwrap_or(false) {
129 Mood::Overheating
130 } else if battery_pct.map(|b| b.value < LOW_BATTERY_PCT).unwrap_or(false) && !plugged_in {
131 Mood::LowBattery
132 } else {
133 match keys_per_min.map(|k| k.value) {
134 Some(k) if k >= BUSY_KEYS_PER_MIN => Mood::Busy,
135 Some(k) if k > 0.0 => Mood::Calm,
136 _ => Mood::Sleeping,
137 }
138 };
139
140 PetState {
141 mood,
142 down,
143 keys_per_min,
144 session_seconds: read(SignalName::SessionSeconds),
145 cpu_load: read(SignalName::CpuLoad),
146 thermal_state,
147 battery_pct,
148 charging,
149 battery_draw_w: read(SignalName::BatteryDrawW),
150 }
151}
152
153/// Render the pet as a text block.
154pub fn render(state: &PetState) -> String {
155 let mut out = String::from("terminal-pet\n============\n\n");
156 out.push_str(&format!(" {} {}\n\n", state.mood.face(), state.mood.label()));
157
158 let row = |label: &str, reading: Option<Reading>, unit: &str| -> String {
159 match reading {
160 None => format!(" {label:<16}\n"),
161 Some(r) => {
162 let mark = if r.stale { " (stale)" } else { "" };
163 format!(" {label:<16} {:.2}{unit}{mark}\n", r.value)
164 }
165 }
166 };
167 out.push_str(&row("keys/min", state.keys_per_min, ""));
168 out.push_str(&row("session", state.session_seconds, "s"));
169 out.push_str(&row("cpu load", state.cpu_load, ""));
170 out.push_str(&row("thermal", state.thermal_state, ""));
171 out.push_str(&row("battery", state.battery_pct, "%"));
172 out.push_str(&row("draw", state.battery_draw_w, "W"));
173
174 if !state.down.is_empty() {
175 out.push_str("\n collectors down:");
176 for source in &state.down {
177 out.push_str(&format!(" {source:?}"));
178 }
179 out.push('\n');
180 }
181 out
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use signal_schema::{Value, SCHEMA_VERSION};
188
189 fn sig(source: Source, name: SignalName, value: f64, ts: u64) -> Signal {
190 Signal {
191 schema_version: SCHEMA_VERSION,
192 ts,
193 source,
194 name,
195 value: Value(value),
196 tag: None,
197 }
198 }
199
200 const NOW: u64 = 1_000_000;
201
202 fn typing(keys: f64) -> Signal {
203 sig(Source::Terminal, SignalName::KeysPerMin, keys, NOW)
204 }
205
206 #[test]
207 fn typing_drives_energy() {
208 assert_eq!(pet_from_signals(&[typing(0.0)], NOW).mood, Mood::Sleeping);
209 assert_eq!(pet_from_signals(&[typing(10.0)], NOW).mood, Mood::Calm);
210 assert_eq!(pet_from_signals(&[typing(120.0)], NOW).mood, Mood::Busy);
211 }
212
213 #[test]
214 fn no_terminal_signal_at_all_is_asleep_not_sick() {
215 assert_eq!(pet_from_signals(&[], NOW).mood, Mood::Sleeping);
216 }
217
218 #[test]
219 fn heat_outranks_typing() {
220 let signals = [
221 typing(120.0),
222 sig(Source::Macos, SignalName::ThermalState, 3.0, NOW),
223 ];
224 assert_eq!(pet_from_signals(&signals, NOW).mood, Mood::Overheating);
225 }
226
227 #[test]
228 fn a_low_battery_only_counts_when_unplugged() {
229 let low = sig(Source::Macos, SignalName::BatteryPct, 5.0, NOW);
230 let plugged = sig(Source::Macos, SignalName::Charging, 1.0, NOW);
231 let unplugged = sig(Source::Macos, SignalName::Charging, 0.0, NOW);
232
233 assert_eq!(
234 pet_from_signals(&[typing(1.0), low.clone(), unplugged], NOW).mood,
235 Mood::LowBattery
236 );
237 assert_eq!(
238 pet_from_signals(&[typing(1.0), low, plugged], NOW).mood,
239 Mood::Calm,
240 "on the charger a low battery is not a worry"
241 );
242 }
243
244 /// The point of putting health on the bus: a dead collector is visible in
245 /// the face, not just in a log nobody reads.
246 #[test]
247 fn a_down_collector_outranks_every_other_mood() {
248 let signals = [
249 typing(120.0),
250 sig(Source::Macos, SignalName::ThermalState, 3.0, NOW),
251 sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW),
252 ];
253 let pet = pet_from_signals(&signals, NOW);
254 assert_eq!(pet.mood, Mood::Sick);
255 assert_eq!(pet.down, vec![Source::Hardware]);
256 assert!(render(&pet).contains("collectors down: Hardware"));
257 }
258
259 #[test]
260 fn a_healthy_collector_is_not_listed_as_down() {
261 let signals = [
262 typing(5.0),
263 sig(Source::Hardware, SignalName::CollectorUp, 1.0, NOW),
264 ];
265 let pet = pet_from_signals(&signals, NOW);
266 assert_eq!(pet.mood, Mood::Calm);
267 assert!(pet.down.is_empty());
268 }
269
270 #[test]
271 fn health_is_tracked_per_source() {
272 let signals = [
273 sig(Source::Git, SignalName::CollectorUp, 1.0, NOW),
274 sig(Source::Terminal, SignalName::CollectorUp, 0.0, NOW),
275 sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW),
276 ];
277 let pet = pet_from_signals(&signals, NOW);
278 assert_eq!(pet.down, vec![Source::Terminal, Source::Hardware]);
279 }
280
281 /// Up but stalled is not the same as down: the collector never reported a
282 /// failure, the number behind it simply stopped moving.
283 #[test]
284 fn an_old_value_is_stale_but_not_sick() {
285 let old = sig(
286 Source::Hardware,
287 SignalName::CpuLoad,
288 0.5,
289 NOW - STALE_AFTER_MS - 1,
290 );
291 let pet = pet_from_signals(&[typing(5.0), old], NOW);
292 assert!(pet.cpu_load.expect("cpu load").stale);
293 assert!(!pet.keys_per_min.expect("keys").stale);
294 assert_eq!(pet.mood, Mood::Calm, "stale is not sick");
295 assert!(render(&pet).contains("(stale)"));
296 }
297}
crates/terminal-pet/src/main.rs added +85
@@ -0,0 +1,85 @@
1//! # terminal-pet
2//!
3//! A pet whose mood follows the shell and the machine. Like every renderer it
4//! is thin: connect to the socket, read frames, redraw. It never touches a
5//! sensor and never reads the shell.
6//!
7//! Usage:
8//! ```text
9//! terminal-pet [--socket <path>]
10//! terminal-pet --help | --version
11//! ```
12//! Socket defaults to `$XDG_RUNTIME_DIR/signald.sock`, falling back to
13//! `~/.local/state/signald/sock`.
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18use signal_schema::Signal;
19use terminal_pet::{pet_from_signals, render};
20
21const USAGE: &str = "\
22Usage: terminal-pet [--socket <path>]
23
24 --socket <path> signald's socket (default $XDG_RUNTIME_DIR/signald.sock,
25 else ~/.local/state/signald/sock)
26 -h, --help print this and exit
27 -V, --version print the version and exit
28";
29
30fn main() {
31 let socket = parse_socket();
32 if let Err(e) = run(&socket) {
33 eprintln!("terminal-pet: could not read signald at {}: {e}", socket.display());
34 eprintln!("terminal-pet: is signald running? (signald --socket {})", socket.display());
35 std::process::exit(1);
36 }
37}
38
39/// Connect and render live. Frames this build cannot decode are skipped by the
40/// iterator, so a metric appended after this renderer was built does not end
41/// the stream.
42fn run(socket: &Path) -> std::io::Result<()> {
43 let frames = signal_client::Frames::connect(socket)?;
44 // Keyed by (name, source) so a metric and the health of the collector that
45 // produced it stay distinct, matching the daemon's own cache.
46 let mut latest: BTreeMap<(u8, u8), Signal> = BTreeMap::new();
47 for sig in frames {
48 let sig = sig?;
49 latest.insert((sig.name.to_u8(), sig.source.to_u8()), sig);
50 let snapshot: Vec<Signal> = latest.values().cloned().collect();
51 let pet = pet_from_signals(&snapshot, now_millis());
52 print!("\x1b[2J\x1b[H{}", render(&pet));
53 }
54 Ok(())
55}
56
57fn now_millis() -> u64 {
58 std::time::SystemTime::now()
59 .duration_since(std::time::UNIX_EPOCH)
60 .map(|d| d.as_millis() as u64)
61 .unwrap_or(0)
62}
63
64fn parse_socket() -> PathBuf {
65 let mut args = std::env::args().skip(1);
66 while let Some(arg) = args.next() {
67 match arg.as_str() {
68 "--help" | "-h" => {
69 print!("{USAGE}");
70 std::process::exit(0);
71 }
72 "--version" | "-V" => {
73 println!("terminal-pet {}", env!("CARGO_PKG_VERSION"));
74 std::process::exit(0);
75 }
76 "--socket" => {
77 if let Some(path) = args.next() {
78 return PathBuf::from(path);
79 }
80 }
81 _ => {}
82 }
83 }
84 signal_client::default_socket_path()
85}