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

docs/superpowers/specs/2026-09-04-v1-scope-design.md

main
ambient-companions/docs/superpowers/specs/2026-09-04-v1-scope-design.md rendered · source · history · blame · raw

248 lines · 11781 bytes

v1.0 scope and design

Date: 2026-09-04 Status: approved, not yet implemented Baseline: v0.6.1 (e8edd54)

What 1.0 means

1.0 is a personal daily-driver guarantee: the daemon runs unattended for weeks, recovers from its own failures, and says so when it cannot. It is not a feature-completeness marker and not an API-stability promise to third parties.

The repository is public and has a Homebrew formula, but that came out of packaging, not a decision to court users. Nothing here commits to supporting anyone else's renderer.

In scope

  • Reliability: supervision, restart, and health for every collector.
  • CollectorUp health signals on the bus (SCHEMA_VERSION = 5).
  • A second TUI face, terminal-pet.
  • A README and roadmap that stop promising five faces at 1.0.

Out of scope, explicitly post-1.0

The menu-bar permadeath pet, the sonifier, and the wallpaper renderer. The README currently leads with "One backend, five faces" while shipping one; that line is rewritten rather than met.

Problem statement

Four failure modes were found in v0.6.1. Three share a shape: the daemon degrades silently and never recovers, which is exactly what "unattended for weeks" cannot tolerate.

Failure Location Effect
Hardware collector never respawned signald/src/lib.rs:409 Child dies once; hardware signals stop permanently. KeepAlive does not help because signald itself is still alive.
Producer thread unsupervised signald/src/main.rs:94 A panic in git::collect or terminal.collect kills the thread. The socket keeps serving a frozen last-value cache, so faces render plausible numbers that stopped being true.
run_git has no timeout signald/src/lib.rs .output() blocks forever on a stuck lock or a network mount. Health would read "up" while nothing updates.
Spool unbounded while daemon is down shell-hooks/signald-hooks.zsh The hook appends every prompt with nothing consuming it; recovery reads the whole file into memory.

Handled already, for the record: a stale socket does not lock out restart — publish::serve unlinks before bind (lib.rs:461).

Design

1. Schema v5 and the cache key

hub.rs:80 keys the last-value cache as (name.to_u8(), tag). source is not part of the key, so two signals differing only by source overwrite each other. This is latent today because no two names share a discriminant, but it blocks one health variant disambiguated by source.

  • Key becomes (name, source, tag) in hub.rs, and the same in the garden's latest map (terminal-garden/src/main.rs:68).
  • SignalName::CollectorUp at discriminant 11; SCHEMA_VERSION = 5.
  • Value is 1.0 up, 0.0 down, matching Charging's existing convention.
  • Untagged. allows_tag stays the four git names, so no new Tag constructors — Tag::bundle_id and Tag::ssh_host were deleted in v0.5 and do not come back.
  • The supervisor publishes one CollectorUp per source it owns: Git, Terminal, Hardware.

Rejected: GitCollectorUp / TerminalCollectorUp / HardwareCollectorUp. It avoids touching the hub but spends three discriminants at the version where they become permanent, and leaves the cache-key flaw to bite something else.

Appending a variant is what the v4 freeze permits — the rule is append-only, prohibiting renumbering and removal, not addition. One README line changes: v4 becomes v5 as the 1.0 contract.

2. The supervisor

New module crates/signald/src/supervisor.rs. It owns liveness for all three collectors and is the only thing that publishes CollectorUp.

Git and terminal stay in one producer thread. Each collector call is wrapped in catch_unwind; a panic is caught, publishes that source down, and the tick continues to the next collector. The next tick retries. terminal .collect takes &mut self so it needs AssertUnwindSafe, and on a panic the supervisor replaces the Collector with a fresh one — its state is the active-session map, so rebuilding costs one window of rate data.

The hardware collector is a process and gets a real supervision loop: spawn, ingest until EOF, publish Hardware down, back off, respawn.

  • Backoff starts at 1s and doubles to a 60s cap.
  • Backoff resets after a run that survived 30s, so a flapping child cannot ratchet the delay to its ceiling permanently.
  • No retry limit. A transient IOKit failure at hour 3 must not disable hardware until a human notices.

Never configured is not the same as failed. If --collector is absent and nothing is on PATH, publish Hardware down once and start no retry loop.

Emission policy: transitions only, plus once at startup. Publishing up every 2s tick would put three redundant frames per tick on the bus. The last-value cache carries current state to subscribers that connect later, which is what makes the cache-key fix load-bearing.

The hang. run_git gets a timeout: spawn rather than output(), read on a thread, recv_timeout, kill the child on expiry, treat it as a failed call. A stuck git is otherwise unrecoverable — a Rust thread cannot be safely killed, so a watchdog could report the hang but never clear it. Scoped to run_git only, not a general subprocess policy.

publish::serve still owns the socket and still blocks main. No change to the hub's fan-out, the history store, or retention.

3. Clock and spool

The clock claim is wrong; fix the claim. Signal.ts is documented as "Unix millis, monotonic-corrected" and is plain SystemTime. ts cannot be both monotonic and a true Unix timestamp: it is persisted, retention prunes on ts < cutoff, and CommitsToday means since local midnight. Drop the claim and note that consumers must not assume monotonicity.

The real bug is the rate floor. Session::keys_per_min guards the backwards case already — saturating_sub plus dt_ms > 0 degrades to reporting the raw key count. It has no floor on dt_ms, so two flushes 1 ms apart give keys * 60000 / 1, a five-figure rate from a few fast newlines. Floor dt_ms at 1000 ms; below a second, report the raw count.

The spool bound belongs in the hook, the only place that bounds growth at the source.

  • Before appending, check the spool's size with zsh/stat (zstat -A); wc -c would fork on every prompt.
  • Truncate if it exceeds 1 MiB, roughly 25k records.
  • Truncate rather than rotate: a megabyte of backlog describes sessions ACTIVE_WINDOW_MS would discard anyway.
  • The new code reads a file size only, so forbidden_symbol_scan is unaffected, and the differential secret-typing test already drives this hook.

4. signal-client

default_socket_path is duplicated in signald/src/main.rs and terminal-garden/src/main.rs, with a Paths table and a pinned test in each binary keeping the copies honest. A third renderer makes a third copy.

New crate crates/signal-client/: socket-path resolution and a Frames iterator over the socket yielding wire::Frame. Both renderers use both parts; signald depends on it for default_socket_path alone. Three copies and two duplicate tests collapse to one definition and one test.

The name describes the dominant use. signald depending on a crate called "client" for one function is mildly odd, and the alternative — leaving signald its own copy — was rejected because two definitions drift as readily as three.

This is a simplification, not a new abstraction, and the second renderer is what justifies it. It cannot live in signal-schema, which is deliberately dependency-free and is the privacy boundary.

5. terminal-pet

New crate crates/terminal-pet/, mirroring the garden: lib.rs holds the pure signals -> PetState -> render path and its unit tests, main.rs is the subscribe-and-redraw loop.

Inputs are everything no face renders today: KeysPerMin, SessionSeconds, CpuLoad, ThermalState, BatteryPct, Charging, BatteryDrawW, and CollectorUp for all three sources. After this every collector has a consumer.

State model, four axes resolved into one expression:

  • energy from KeysPerMin — asleep / calm / busy
  • stress from ThermalState and CpuLoad
  • condition from BatteryPct and Charging
  • sick — any CollectorUp at 0.0, overriding the rest

Sick is the payoff for section 1: a pet that visibly cannot feel its own hardware is the ambient degradation signal, and the reason health went on the bus rather than into a log. Freshness is the second half — a signal whose ts is more than 30 seconds old renders dimmed, so "up but stalled" reads differently from "down". Thirty seconds is comfortably past the 2s default producer tick and the collector's own interval, so it does not flicker on a healthy system.

Stateless: a pure function of the current snapshot. No persistence, no animation timeline, no config file.

Documentation

The v5 bump repeats the v4 drill: SCHEMA_VERSION and its contract paragraph, the Swift SCHEMA_VERSION constant and its test, the canonical frame's 04 00 -> 05 00 in both hardware_wire.rs and WireTests.swift, and the byte table in macos-collector/README.md. Swift gains no CollectorUp case — health is published by the supervisor, not the collector — so Swift changes are version-only.

The README needs more than a version edit:

  • The five-faces diagram distinguishes what exists from what is planned.
  • The phase plan gains a v1.0 entry: a daemon that self-heals and reports its own health, plus two TUI faces.
  • Menu-bar pet, sonifier and wallpaper move explicitly to post-1.0.
  • The Paths table collapses to one entry now that signal-client owns resolution.
  • Workspace layout gains signal-client/, terminal-pet/, supervisor.rs.

Testing

No CI; the pre-push hook is the gate.

  • Supervisor: backoff schedule as a pure unit test, including the 30s-survival reset. Panic containment tested against the catch_unwind wrapper with a closure that panics, not by breaking a real collector. An integration test that kills the hardware child and asserts the respawn and the Hardware down-then-up transitions.
  • run_git timeout: point it at a sleeping command; assert it fails within budget and leaves no orphan process.
  • Hub: rewrite cache_keeps_latest_per_name, and add a test that the same name with two different sources coexists rather than overwriting — the regression the whole design leans on.
  • Rate floor: dt_ms = 1 no longer produces a five-figure rate.
  • Spool cap: drive the hook past 1 MiB and assert truncation.
  • Schema: v4_names_are_exactly_zero_through_ten becomes 0..=11; only_git_repo_path_names_allow_a_tag gains CollectorUp to the untagged list.
  • Pet: pure signals -> PetState tests, including sick-overrides-everything and stale-ts dimming.
  • signal-client: one socket-path test replacing the two duplicates.

Privacy invariants need no change: CollectorUp is an untagged f64 and adds no content channel.

Delivery

A stack of five MRs, each targeting the one below, matching the v0.4 and v0.5 pattern:

  1. Schema v5 and the hub cache key, carrying the schema-level doc changes: SCHEMA_VERSION, the Swift constant, both canonical frames, and the macos-collector byte table.
  2. signal-client extraction, garden migrated.
  3. Supervisor, run_git timeout, rate floor, spool cap.
  4. terminal-pet.
  5. README and roadmap rewrite — the narrative changes only: the faces diagram, the phase plan, the Paths table, workspace layout.

Then bump to 1.0.0, tag, and close the v1.0.0 milestone.

Unchanged

No CI for this repository, and the gitbay runner scoping stays as it is.