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 ff9638c614

ff9638c61435d69d8b61a537fd342bbf7e5f753f

parent: 29abcb92ad

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-04 14:34 UTC

Add an install path: LaunchAgent, repos config, and documented defaults

packaging/net.krz.signald.plist is a launchd template that runs signald at
login with the hardware collector as its child, logging to
~/Library/Logs/signald/. Its header carries the sed and launchctl lines for a
manual install; brew services generates its own from the formula.

signald reads $XDG_CONFIG_HOME/signald/repos (else ~/.config/signald/repos)
when no repository is given on the command line. launchd starts an agent in /,
so without this brew services start signald would collect no git signals at
all. A missing file is not an error and the working directory still applies.

The README gains an Install section and a Paths table: the socket, history db
and terminal spool defaults in one place, with the XDG and non-XDG resolution
both spelled out. Unit tests in signald and terminal-garden pin their
resolution to that table, including that the spool matches SIGNALD_SPOOL's
default in the zsh hook. Path resolution is split from the environment so it
is testable without mutating it.

Also drops two "spec §" references in Cargo.toml descriptions that the earlier
sweep missed by only grepping .rs, .md, .swift and .zsh.

Ref #5
README.md +61
@@ -224,6 +224,67 @@ and — for the differential test — `zpty` modules ship with zsh).
224224- **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse.
225225 *Out of scope.*
226226
227## Install
228
229```sh
230brew install krz/tap/ambient-companions
231brew services start ambient-companions
232```
233
234Name the repositories to watch — signald takes none by default, and a login
235agent has no useful working directory:
236
237```sh
238mkdir -p ~/.config/signald
239cat > ~/.config/signald/repos <<'EOF'
240# one repository path per line; # comments and blank lines are ignored
241~/git/some-repo
242EOF
243brew services restart ambient-companions
244```
245
246Then add one line to `.zshrc` for the terminal collector:
247
248```sh
249source "$(brew --prefix)/share/ambient-companions/signald-hooks.zsh"
250```
251
252Open a new shell and watch the garden:
253
254```sh
255terminal-garden
256```
257
258`brew services` logs to `$(brew --prefix)/var/log/`. To run the agent by hand
259instead, `packaging/net.krz.signald.plist` is a launchd template; its header
260comment carries the `sed` line that fills in the paths and the `launchctl load`
261that starts it.
262
263### Paths
264
265Defaults, resolved the same way by `signald`, `terminal-garden`, and
266`shell-hooks/signald-hooks.zsh`. This table is the single place they are
267written down; unit tests in both binaries pin the code to it.
268
269| | `$XDG_RUNTIME_DIR` set | otherwise |
270|---|---|---|
271| socket | `$XDG_RUNTIME_DIR/signald.sock` | `~/.local/state/signald/sock` |
272| history db | `$XDG_RUNTIME_DIR/signald.sqlite` | `~/.local/state/signald/signald.sqlite` |
273| terminal spool | `$XDG_RUNTIME_DIR/terminal.spool` | `~/.local/state/signald/terminal.spool` |
274
275The db and the spool are derived from the socket's directory, so `--socket`
276moves all three together. Override individually with `--db` and `--spool`, and
277the spool from the shell side with `$SIGNALD_SPOOL` — it must match whatever
278the daemon uses, or the terminal metrics stay silently empty. launchd sets no
279`XDG_RUNTIME_DIR`, so an installed agent lands in `~/.local/state/signald`.
280
281The repository list is `$XDG_CONFIG_HOME/signald/repos`, else
282`~/.config/signald/repos`. Positional arguments to `signald` override it; with
283neither, the working directory is watched.
284
285Unix socket paths are limited to about 104 bytes. A deep scratch directory
286will hit `SUN_LEN`; use `mktemp -d` when testing by hand.
287
227288## Build & test
228289
229290```sh
crates/signald/Cargo.toml +1 −1
@@ -11,7 +11,7 @@ path = "src/main.rs"
1111
1212[dependencies]
1313signal-schema = { path = "../signal-schema" }
14# History store (spec §1.3): SQLite in WAL mode. `bundled` compiles SQLite in-
14# History store: SQLite in WAL mode. `bundled` compiles SQLite in-
1515# tree so there is no system-library dependency. This is the one new dependency
1616# added in v0.2; signal-schema stays dependency-free by design.
1717rusqlite = { version = "0.40", features = ["bundled"] }
crates/signald/src/main.rs +125 −6
@@ -26,7 +26,9 @@
2626//! [--collector <path>] [--interval-ms <n>] [--retention-days <n>]
2727//! [<repo-path> ...]
2828//! ```
29//! With no repo paths, the current directory is watched. The socket defaults to
29//! With no repo paths, `$XDG_CONFIG_HOME/signald/repos` (else
30//! `~/.config/signald/repos`) is read — one path per line, `#` comments
31//! ignored — and failing that the current directory is watched. The socket defaults to
3032//! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the
3133//! history db and terminal spool default alongside it. `--collector` names the
3234//! `macos-collector` binary; by default it is looked up on `PATH` and skipped,
@@ -140,6 +142,9 @@ fn parse_args() -> Config {
140142 }
141143 }
142144
145 if repos.is_empty() {
146 repos = repos_from_config_file();
147 }
143148 if repos.is_empty() {
144149 repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
145150 }
@@ -156,13 +161,65 @@ fn parse_args() -> Config {
156161 }
157162}
158163
159/// `$XDG_RUNTIME_DIR/signald.sock`, else `~/.local/state/signald/sock`.
160fn default_socket_path() -> PathBuf {
161 if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
164/// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`.
165///
166/// Split from the environment so it can be tested without mutating it. The
167/// same resolution is duplicated in the other binary and in
168/// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one
169/// place they are written down, and the tests below pin them to it.
170fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf {
171 if let Some(dir) = xdg_runtime_dir {
162172 return PathBuf::from(dir).join("signald.sock");
163173 }
164 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
165 PathBuf::from(home).join(".local/state/signald/sock")
174 PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock")
175}
176
177fn default_socket_path() -> PathBuf {
178 socket_path_from(
179 std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
180 std::env::var("HOME").ok().as_deref(),
181 )
182}
183
184/// `$XDG_CONFIG_HOME/signald/repos`, else `$HOME/.config/signald/repos`.
185fn repos_config_path(xdg_config_home: Option<&str>, home: Option<&str>) -> PathBuf {
186 match xdg_config_home {
187 Some(dir) => PathBuf::from(dir).join("signald/repos"),
188 None => PathBuf::from(home.unwrap_or(".")).join(".config/signald/repos"),
189 }
190}
191
192/// One repository path per line. Blank lines and `#` comments are ignored.
193fn parse_repos_file(contents: &str) -> Vec<PathBuf> {
194 contents
195 .lines()
196 .map(|l| l.trim())
197 .filter(|l| !l.is_empty() && !l.starts_with('#'))
198 .map(PathBuf::from)
199 .collect()
200}
201
202/// Repositories to watch when none were given on the command line.
203///
204/// A login agent has no useful working directory — launchd starts it in `/` —
205/// so falling straight through to the cwd would leave `brew services start
206/// signald` collecting no git signals at all. A missing file is not an error;
207/// the cwd fallback still applies.
208fn repos_from_config_file() -> Vec<PathBuf> {
209 let path = repos_config_path(
210 std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
211 std::env::var("HOME").ok().as_deref(),
212 );
213 match std::fs::read_to_string(&path) {
214 Ok(contents) => {
215 let repos = parse_repos_file(&contents);
216 if !repos.is_empty() {
217 eprintln!("signald: watching {} repo(s) from {}", repos.len(), path.display());
218 }
219 repos
220 }
221 Err(_) => Vec::new(),
222 }
166223}
167224
168225/// Log enabled collectors and assert none holds an input-tap capability. A real
@@ -188,3 +245,65 @@ fn print_self_attestation(cfg: &Config) {
188245 eprintln!(" collector {source:?}: input-tap capability = NONE — {state}");
189246 }
190247}
248
249#[cfg(test)]
250mod path_tests {
251 use super::*;
252
253 /// Pinned to the README "Paths" table. If this changes, the table and
254 /// `shell-hooks/signald-hooks.zsh` change with it.
255 #[test]
256 fn socket_default_follows_xdg_then_home() {
257 assert_eq!(
258 socket_path_from(Some("/run/user/501"), Some("/Users/x")),
259 PathBuf::from("/run/user/501/signald.sock")
260 );
261 assert_eq!(
262 socket_path_from(None, Some("/Users/x")),
263 PathBuf::from("/Users/x/.local/state/signald/sock")
264 );
265 }
266
267 #[test]
268 fn repos_file_skips_blanks_and_comments() {
269 let repos = parse_repos_file(
270 "# what to watch\n\n/Users/x/git/one\n /Users/x/git/two \n\n# trailing\n",
271 );
272 assert_eq!(
273 repos,
274 vec![
275 PathBuf::from("/Users/x/git/one"),
276 PathBuf::from("/Users/x/git/two")
277 ]
278 );
279 }
280
281 #[test]
282 fn repos_config_follows_xdg_then_home() {
283 assert_eq!(
284 repos_config_path(Some("/Users/x/.config"), Some("/Users/x")),
285 PathBuf::from("/Users/x/.config/signald/repos")
286 );
287 assert_eq!(
288 repos_config_path(None, Some("/Users/x")),
289 PathBuf::from("/Users/x/.config/signald/repos")
290 );
291 }
292
293 /// The db and the spool are derived from the socket's directory, so all
294 /// three move together when --socket is given.
295 #[test]
296 fn db_and_spool_sit_beside_the_socket() {
297 let socket = socket_path_from(None, Some("/Users/x"));
298 let base = socket.parent().unwrap();
299 assert_eq!(
300 base.join("signald.sqlite"),
301 PathBuf::from("/Users/x/.local/state/signald/signald.sqlite")
302 );
303 assert_eq!(
304 base.join("terminal.spool"),
305 PathBuf::from("/Users/x/.local/state/signald/terminal.spool"),
306 "must equal SIGNALD_SPOOL's default in shell-hooks/signald-hooks.zsh"
307 );
308 }
309}
crates/terminal-garden/Cargo.toml +1 −1
@@ -3,7 +3,7 @@ name = "terminal-garden"
33version.workspace = true
44edition.workspace = true
55license.workspace = true
6description = "First renderer (spec build order §3): a TUI garden that grows with commits and wilts on stale branches. A thin subscriber to signald — it reads git aggregates off the socket and never touches a sensor."
6description = "First renderer: a TUI garden that grows with commits and wilts on stale branches. A thin subscriber to signald — it reads git aggregates off the socket and never touches a sensor."
77
88[[bin]]
99name = "terminal-garden"
crates/terminal-garden/src/main.rs +35 −4
@@ -88,10 +88,41 @@ fn parse_socket() -> PathBuf {
8888 default_socket_path()
8989}
9090
91fn default_socket_path() -> PathBuf {
92 if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
91/// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`.
92///
93/// Split from the environment so it can be tested without mutating it. The
94/// same resolution is duplicated in the other binary and in
95/// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one
96/// place they are written down, and the tests below pin them to it.
97fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf {
98 if let Some(dir) = xdg_runtime_dir {
9399 return PathBuf::from(dir).join("signald.sock");
94100 }
95 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
96 PathBuf::from(home).join(".local/state/signald/sock")
101 PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock")
102}
103
104fn default_socket_path() -> PathBuf {
105 socket_path_from(
106 std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
107 std::env::var("HOME").ok().as_deref(),
108 )
109}
110
111#[cfg(test)]
112mod path_tests {
113 use super::*;
114
115 /// Pinned to the README "Paths" table. If this changes, the table and
116 /// `shell-hooks/signald-hooks.zsh` change with it.
117 #[test]
118 fn socket_default_follows_xdg_then_home() {
119 assert_eq!(
120 socket_path_from(Some("/run/user/501"), Some("/Users/x")),
121 PathBuf::from("/run/user/501/signald.sock")
122 );
123 assert_eq!(
124 socket_path_from(None, Some("/Users/x")),
125 PathBuf::from("/Users/x/.local/state/signald/sock")
126 );
127 }
97128}
packaging/net.krz.signald.plist added +64
@@ -0,0 +1,64 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!--
3 User LaunchAgent for signald.
4
5 This is the template for a manual install. `brew services start signald`
6 does not use it — the formula's own service block generates an equivalent
7 plist under Homebrew's control.
8
9 To install by hand:
10
11 mkdir -p ~/Library/Logs/signald ~/Library/LaunchAgents
12 sed -e "s|__HOME__|$HOME|g" -e "s|__PREFIX__|$(brew --prefix)|g" \
13 packaging/net.krz.signald.plist > ~/Library/LaunchAgents/net.krz.signald.plist
14 launchctl load ~/Library/LaunchAgents/net.krz.signald.plist
15
16 Edit ProgramArguments to name the repositories to watch: signald takes them
17 as positional paths, and with none it watches the working directory, which
18 is not useful for a login agent. Create the log directory first — launchd
19 creates the log files but not the directory holding them.
20-->
21<plist version="1.0">
22<dict>
23 <key>Label</key>
24 <string>net.krz.signald</string>
25
26 <key>ProgramArguments</key>
27 <array>
28 <string>__PREFIX__/bin/signald</string>
29 <!-- The hardware collector runs as a child of signald. Naming it here
30 avoids depending on launchd's PATH, which is not the shell's. -->
31 <string>--collector</string>
32 <string>__PREFIX__/bin/macos-collector</string>
33 <!-- Repositories to watch. Replace with your own; add one string per repo. -->
34 <string>__HOME__/git</string>
35 </array>
36
37 <key>RunAtLoad</key>
38 <true/>
39 <key>KeepAlive</key>
40 <dict>
41 <key>SuccessfulExit</key>
42 <false/>
43 </dict>
44
45 <key>StandardOutPath</key>
46 <string>__HOME__/Library/Logs/signald/signald.log</string>
47 <key>StandardErrorPath</key>
48 <string>__HOME__/Library/Logs/signald/signald.err.log</string>
49
50 <!-- signald resolves its socket, db and spool under $XDG_RUNTIME_DIR when
51 set and ~/.local/state/signald otherwise. launchd sets neither, so the
52 agent lands on the ~/.local/state/signald defaults the zsh hook also
53 uses. Keep it that way: a spool the daemon and the hook disagree about
54 is a silently empty terminal metric. -->
55 <key>EnvironmentVariables</key>
56 <dict>
57 <key>HOME</key>
58 <string>__HOME__</string>
59 </dict>
60
61 <key>ProcessType</key>
62 <string>Background</string>
63</dict>
64</plist>