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 63e3c77637

63e3c776376cf4b57e9d2cdc734f9e377ae59232

parent: 106c62e2f9

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-04 16:31 UTC

Add terminal-pet --oneline for a shell prompt

signal_client::Frames::snapshot reads the last-value cache the daemon sends on
connect and returns. There is no end-of-snapshot marker on the wire, so it
reads one frame blocking and drains the rest non-blocking, stopping at
WouldBlock rather than waiting out a timeout — 2.7ms per call including process
start, which is what makes it affordable on every prompt.

terminal-pet --oneline prints the face and the keystroke rate and exits. With
no daemon it prints nothing and exits 1, so a prompt segment hides itself
rather than emitting a diagnostic into the prompt.

Mood now ignores stale readings. The terminal collector stops publishing once a
session falls outside its active window, so the last rate sat in the cache and
left the pet looking busy indefinitely after the typing stopped. A value the
renderer would already dim as stale should not be driving the face either.

Closes #13
crates/signal-client/src/lib.rs +34
@@ -59,6 +59,40 @@ impl Frames {
5959 }
6060}
6161
62impl Frames {
63 /// Read the last-value cache the daemon sends on connect, and return.
64 ///
65 /// There is no end-of-snapshot marker on the wire, so this reads one frame
66 /// blocking and then drains whatever else is already buffered, stopping as
67 /// soon as the socket would block. The cache arrives in a single burst, so
68 /// that returns immediately rather than waiting out a timeout — which is
69 /// what makes a one-shot reader cheap enough to run on every shell prompt.
70 ///
71 /// Consumes the `Frames`: draining can abandon a partially-arrived frame,
72 /// which leaves the stream out of sync. That is harmless for a reader that
73 /// exits, and unusable for one that keeps going.
74 pub fn snapshot(mut self) -> std::io::Result<Vec<signal_schema::Signal>> {
75 let mut out = Vec::new();
76 // The first frame blocks: the daemon always has something cached, and
77 // an empty read here means the socket died rather than went quiet.
78 match self.next() {
79 Some(Ok(s)) => out.push(s),
80 Some(Err(e)) => return Err(e),
81 None => return Ok(out),
82 }
83 self.reader.get_ref().set_nonblocking(true)?;
84 for frame in &mut self {
85 match frame {
86 Ok(s) => out.push(s),
87 // Nothing more is buffered: the cache has been drained.
88 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
89 Err(e) => return Err(e),
90 }
91 }
92 Ok(out)
93 }
94}
95
6296impl Iterator for Frames {
6397 /// `Err` is an unframeable stream. A skipped frame is not surfaced: the
6498 /// iterator swallows it and reads on, which is the whole point of
crates/terminal-pet/src/lib.rs +62 −4
@@ -122,15 +122,22 @@ pub fn pet_from_signals(signals: &[Signal], now_ms: u64) -> PetState {
122122 let battery_pct = read(SignalName::BatteryPct);
123123 let charging = read(SignalName::Charging);
124124
125 let plugged_in = charging.map(|c| c.value >= 1.0).unwrap_or(false);
125 // Mood is driven only by readings that are still current. A cached value
126 // outlives what it described: the terminal collector stops publishing once
127 // a session falls outside its active window, so the last keystroke rate
128 // sits in the daemon's cache indefinitely and would otherwise leave the pet
129 // looking busy long after the typing stopped.
130 let fresh = |r: Option<Reading>| r.filter(|r| !r.stale).map(|r| r.value);
131
132 let plugged_in = fresh(charging).map(|c| c >= 1.0).unwrap_or(false);
126133 let mood = if !down.is_empty() {
127134 Mood::Sick
128 } else if thermal_state.map(|t| t.value >= HOT_THERMAL_STATE).unwrap_or(false) {
135 } else if fresh(thermal_state).map(|t| t >= HOT_THERMAL_STATE).unwrap_or(false) {
129136 Mood::Overheating
130 } else if battery_pct.map(|b| b.value < LOW_BATTERY_PCT).unwrap_or(false) && !plugged_in {
137 } else if fresh(battery_pct).map(|b| b < LOW_BATTERY_PCT).unwrap_or(false) && !plugged_in {
131138 Mood::LowBattery
132139 } else {
133 match keys_per_min.map(|k| k.value) {
140 match fresh(keys_per_min) {
134141 Some(k) if k >= BUSY_KEYS_PER_MIN => Mood::Busy,
135142 Some(k) if k > 0.0 => Mood::Calm,
136143 _ => Mood::Sleeping,
@@ -181,6 +188,21 @@ pub fn render(state: &PetState) -> String {
181188 out
182189}
183190
191/// Render the pet as a single line for a shell prompt: the face, and the
192/// keystroke rate when the terminal collector has one.
193///
194/// Deliberately short and fixed-ish in width — it shares a prompt with
195/// everything else, and a segment that changes width every keystroke moves the
196/// rest of the prompt around.
197pub fn oneline(state: &PetState) -> String {
198 match state.keys_per_min {
199 Some(k) if !k.stale => format!("{} {:.0}k/m", state.mood.face(), k.value),
200 // A stale or absent rate is not worth a number: it would read as
201 // current and be minutes old.
202 _ => state.mood.face().to_string(),
203 }
204}
205
184206#[cfg(test)]
185207mod tests {
186208 use super::*;
@@ -203,6 +225,28 @@ mod tests {
203225 sig(Source::Terminal, SignalName::KeysPerMin, keys, NOW)
204226 }
205227
228 #[test]
229 fn oneline_is_the_face_and_the_rate() {
230 let pet = pet_from_signals(&[typing(42.4)], NOW);
231 assert_eq!(oneline(&pet), "(^_^) 42k/m");
232 }
233
234 #[test]
235 fn oneline_drops_a_rate_it_cannot_vouch_for() {
236 let old = sig(Source::Terminal, SignalName::KeysPerMin, 99.0, NOW - STALE_AFTER_MS - 1);
237 assert_eq!(oneline(&pet_from_signals(&[old], NOW)), "(-.-)", "stale rate omitted");
238 assert_eq!(oneline(&pet_from_signals(&[], NOW)), "(-.-)", "no rate at all");
239 }
240
241 #[test]
242 fn oneline_shows_a_sick_face() {
243 let signals = [
244 typing(10.0),
245 sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW),
246 ];
247 assert_eq!(oneline(&pet_from_signals(&signals, NOW)), "(x_x) 10k/m");
248 }
249
206250 #[test]
207251 fn typing_drives_energy() {
208252 assert_eq!(pet_from_signals(&[typing(0.0)], NOW).mood, Mood::Sleeping);
@@ -278,6 +322,20 @@ mod tests {
278322 assert_eq!(pet.down, vec![Source::Terminal, Source::Hardware]);
279323 }
280324
325 /// The terminal collector stops publishing once a session falls outside
326 /// its active window, so the last rate sits in the cache. The pet must not
327 /// keep looking busy on the strength of it.
328 #[test]
329 fn a_stale_rate_does_not_keep_the_pet_busy() {
330 let old = sig(
331 Source::Terminal,
332 SignalName::KeysPerMin,
333 300.0,
334 NOW - STALE_AFTER_MS - 1,
335 );
336 assert_eq!(pet_from_signals(&[old], NOW).mood, Mood::Sleeping);
337 }
338
281339 /// Up but stalled is not the same as down: the collector never reported a
282340 /// failure, the number behind it simply stopped moving.
283341 #[test]
crates/terminal-pet/src/main.rs +19 −1
@@ -7,6 +7,7 @@
77//! Usage:
88//! ```text
99//! terminal-pet [--socket <path>]
10//! terminal-pet --oneline [--socket <path>]
1011//! terminal-pet --help | --version
1112//! ```
1213//! Socket defaults to `$XDG_RUNTIME_DIR/signald.sock`, falling back to
@@ -16,19 +17,30 @@ use std::collections::BTreeMap;
1617use std::path::{Path, PathBuf};
1718
1819use signal_schema::Signal;
19use terminal_pet::{pet_from_signals, render};
20use terminal_pet::{oneline, pet_from_signals, render};
2021
2122const USAGE: &str = "\
2223Usage: terminal-pet [--socket <path>]
2324
2425 --socket <path> signald's socket (default $XDG_RUNTIME_DIR/signald.sock,
2526 else ~/.local/state/signald/sock)
27 --oneline print one line and exit, for a shell prompt. Prints
28 nothing and exits 1 if signald is not running.
2629 -h, --help print this and exit
2730 -V, --version print the version and exit
2831";
2932
3033fn main() {
3134 let socket = parse_socket();
35 if std::env::args().any(|a| a == "--oneline") {
36 // A prompt segment must never print a diagnostic or hang a shell: no
37 // daemon means no output and a non-zero exit, so the segment hides.
38 match one_shot(&socket) {
39 Ok(line) => println!("{line}"),
40 Err(_) => std::process::exit(1),
41 }
42 return;
43 }
3244 if let Err(e) = run(&socket) {
3345 eprintln!("terminal-pet: could not read signald at {}: {e}", socket.display());
3446 eprintln!("terminal-pet: is signald running? (signald --socket {})", socket.display());
@@ -54,6 +66,12 @@ fn run(socket: &Path) -> std::io::Result<()> {
5466 Ok(())
5567}
5668
69/// Read the daemon's cached snapshot, render one line, and return.
70fn one_shot(socket: &Path) -> std::io::Result<String> {
71 let snapshot = signal_client::Frames::connect(socket)?.snapshot()?;
72 Ok(oneline(&pet_from_signals(&snapshot, now_millis())))
73}
74
5775fn now_millis() -> u64 {
5876 std::time::SystemTime::now()
5977 .duration_since(std::time::UNIX_EPOCH)