omni_dev/daemon.rs
1//! The extensible omni-dev daemon: a long-lived supervisor that hosts pluggable
2//! [`DaemonService`](service::DaemonService)s over a local Unix-domain control
3//! socket.
4//!
5//! The daemon owns **lifecycle, single-instance supervision, status
6//! aggregation, and (on macOS) the menu-bar shell**. Each service wraps its own
7//! work and exposes status/control; the browser bridge is the first such
8//! service (#987). The control socket is a private operator/tray channel — it
9//! does **not** carry any service's own data plane (e.g. the bridge keeps its
10//! loopback-TCP planes per ADR-0036). See ADR-0039.
11//!
12//! Process model:
13//! - `daemon run` *becomes* the daemon ([`server::run`]), blocking until a
14//! signal or a built-in `shutdown` op.
15//! - `daemon start` launches it in the background (a launchd LaunchAgent on
16//! macOS, a systemd user unit on Linux); `stop` / `restart` / `status` are thin
17//! [`client::DaemonClient`]s.
18
19// The control plane is a Unix-domain socket (`UnixListener`/`UnixStream`), so the
20// daemon runtime is Unix-only and gated `#[cfg(unix)]`; on Windows it runs only
21// under WSL2 (a real Linux kernel), and a native (non-WSL) Windows port is future
22// work (#1363). `paths` stays cross-platform because the request log and the
23// browser thin-client token discovery depend on it.
24pub mod paths;
25
26#[cfg(unix)]
27pub(crate) mod accept;
28#[cfg(unix)]
29pub mod client;
30#[cfg(unix)]
31pub mod lifecycle;
32#[cfg(unix)]
33pub mod protocol;
34#[cfg(unix)]
35pub mod registry;
36#[cfg(unix)]
37pub mod selection;
38#[cfg(unix)]
39pub mod server;
40#[cfg(unix)]
41pub mod service;
42#[cfg(unix)]
43pub mod services;
44#[cfg(unix)]
45pub mod single_instance;
46
47#[cfg(all(unix, test))]
48pub(crate) mod testutil;
49
50#[cfg(target_os = "macos")]
51pub mod launchd;
52
53#[cfg(target_os = "linux")]
54pub mod systemd;
55
56#[cfg(all(target_os = "macos", feature = "menu-bar"))]
57pub mod tray;
58
59#[cfg(unix)]
60use std::path::Path;
61#[cfg(unix)]
62use std::path::PathBuf;
63#[cfg(unix)]
64use std::sync::Arc;
65
66#[cfg(unix)]
67use anyhow::Result;
68
69#[cfg(unix)]
70use crate::browser::BridgeConfig;
71#[cfg(unix)]
72use crate::snowflake::SnowflakeEngineConfig;
73#[cfg(unix)]
74use registry::ServiceRegistry;
75#[cfg(unix)]
76pub use selection::{DaemonServiceKind, ServiceSelection};
77#[cfg(unix)]
78use server::DaemonOptions;
79#[cfg(unix)]
80use services::bridge::BridgeService;
81#[cfg(unix)]
82use services::github_counters::GithubCountersService;
83#[cfg(unix)]
84use services::sessions::SessionsService;
85#[cfg(unix)]
86use services::snowflake::SnowflakeService;
87#[cfg(unix)]
88use services::worktrees::WorktreesService;
89
90/// Everything `daemon run` needs to start the daemon, resolved from the CLI.
91///
92/// Shared by the headless path ([`run_headless`]) and the macOS menu-bar path
93/// (`tray::run`) so both start an identical daemon. The latter is a plain code
94/// span, not an intra-doc link, because the `tray` module is feature- and
95/// target-gated and absent from the docs build.
96#[cfg(unix)]
97#[derive(Debug, Clone)]
98pub struct DaemonRunConfig {
99 /// Control-socket path (also the single-instance lock).
100 pub socket_path: PathBuf,
101 /// Browser-bridge configuration (ports, allow-origin, limits).
102 pub bridge_config: BridgeConfig,
103 /// Optional file the bridge session token is read from instead of generated.
104 pub bridge_token_file: Option<PathBuf>,
105 /// Where the resolved bridge token is persisted (`0600`) for thin clients.
106 pub bridge_token_path: PathBuf,
107 /// Which default-registry services to host (all, or an explicit subset).
108 pub services: ServiceSelection,
109}
110
111/// Builds the daemon's default service registry.
112///
113/// Starts the browser bridge on its loopback-TCP planes and registers it
114/// alongside the Snowflake query service, the cross-window worktrees registry,
115/// and the Claude Code sessions tracker.
116///
117/// `bridge_token_file` overrides token generation; `bridge_token_path` is where
118/// the resolved token is persisted (`0600`) for thin-client discovery. The
119/// Snowflake service is registered cheaply (no eager auth or I/O); its sessions
120/// are authenticated lazily on first query. The worktrees and sessions services
121/// are likewise cheap (in-memory only); they fill as VS Code windows register and
122/// as Claude Code hooks/transcripts report.
123///
124/// `socket_path` is the control socket; the sessions service reads the hook
125/// journals beside it (#2108).
126///
127/// `services` selects which of the four to host. A service outside the selection
128/// is never constructed, so its startup work is skipped entirely — no bridge TCP
129/// planes, no worktrees pollers, no sessions watcher (#1318). The default
130/// ([`ServiceSelection::All`]) hosts everything.
131#[cfg(unix)]
132pub async fn build_default_registry(
133 bridge_config: BridgeConfig,
134 bridge_token_file: Option<&Path>,
135 bridge_token_path: PathBuf,
136 socket_path: &Path,
137 services: &ServiceSelection,
138) -> Result<ServiceRegistry> {
139 let mut registry = ServiceRegistry::new();
140 if services.includes(DaemonServiceKind::Bridge) {
141 let bridge = BridgeService::start(bridge_config, bridge_token_file, bridge_token_path)?;
142 registry.register(Arc::new(bridge));
143 }
144 if services.includes(DaemonServiceKind::Snowflake) {
145 let snowflake = SnowflakeService::new(SnowflakeEngineConfig::from_env_and_settings()?);
146 registry.register(Arc::new(snowflake));
147 }
148 if services.includes(DaemonServiceKind::Worktrees) {
149 // Start the off-thread menu-refresh loop so the tray serves a cached menu
150 // instead of running git enrichment on the macOS GUI thread (#1186 fix).
151 let worktrees = WorktreesService::new();
152 // Seed the per-repo PR-poll enable set from its persisted `0600` file so the
153 // user's choices survive a restart; the poller reads it below (#1376). A path
154 // that cannot be resolved (no data dir) just disables persistence.
155 match crate::daemon::paths::worktrees_polling_path() {
156 Ok(path) => worktrees.load_polling_prefs(path),
157 Err(err) => tracing::warn!("worktrees polling prefs disabled: {err:#}"),
158 }
159 // Seed the resolved PR-badge cache from its persisted `0600` file so a restart
160 // serves badges instantly and the poller can skip its immediate re-poll when
161 // they are still fresh (#1389, fix 4). Before `start_pr_poller` so the warm
162 // start is in place when the loop spawns; a path that cannot be resolved just
163 // disables persistence.
164 match crate::daemon::paths::worktrees_pr_cache_path() {
165 Ok(path) => worktrees.load_pr_cache(path),
166 Err(err) => tracing::warn!("worktrees PR cache disabled: {err:#}"),
167 }
168 worktrees.start_menu_refresh();
169 // Keep PR check badges fresh for every open window from one `gh` call, rather
170 // than each window resolving its own and none of them ever re-asking (#1337).
171 worktrees.start_pr_poller();
172 // Watch the GitHub API budget the PR poller (and every other `gh` on the box)
173 // spends, so `daemon status` / the tray surface an approaching exhaustion before
174 // it rate-limits everything — polling `/rate_limit` is exempt, so this is free
175 // (#1375). Share the cache with the registry for the built-in `status` op.
176 worktrees.start_rate_limit_poller();
177 let rate_limit_cache = worktrees.rate_limit_cache();
178 registry.register(Arc::new(worktrees));
179 registry.set_github_rate_limit(rate_limit_cache);
180 }
181 if services.includes(DaemonServiceKind::Sessions) {
182 // The cross-window Claude Code sessions tracker; start its transcript watcher
183 // (Feed 2) so sessions predating the daemon — and the hook-silent thinking
184 // window — are still tracked (#1210). It also replays and tails the hook
185 // journals the sink writes beside the control socket (#2108).
186 let sessions = SessionsService::new()
187 .with_journal_dir(paths::sessions_journal_dir_for_socket(socket_path));
188 sessions.start_watcher();
189 registry.register(Arc::new(sessions));
190 }
191 // Periodically log a summary of the GitHub API-call counters (#1387): once
192 // ~5s after boot, every 10 minutes, and once on shutdown. Best-effort, no
193 // network: each summary reads only the request-log records appended since the
194 // previous one, so it stays cheap however large the log has grown, and the
195 // final one is cut short rather than hold up shutdown (#2132). Not one of the
196 // selectable services (#1318) — a daemon-wide observability concern that
197 // stays on for any subset.
198 let github_counters = GithubCountersService::new();
199 github_counters.start_counter_logger();
200 registry.register(Arc::new(github_counters));
201 Ok(registry)
202}
203
204/// Runs the daemon headlessly (no tray).
205///
206/// Builds the registry and serves until a signal or `daemon stop`. The default
207/// `daemon run` path on every platform, and the only path when the `menu-bar`
208/// feature is off.
209#[cfg(unix)]
210pub async fn run_headless(cfg: DaemonRunConfig) -> Result<()> {
211 let registry = build_default_registry(
212 cfg.bridge_config,
213 cfg.bridge_token_file.as_deref(),
214 cfg.bridge_token_path,
215 &cfg.socket_path,
216 &cfg.services,
217 )
218 .await?;
219 server::run(
220 registry,
221 DaemonOptions {
222 socket_path: cfg.socket_path,
223 },
224 )
225 .await
226}
227
228#[cfg(all(unix, test))]
229#[allow(clippy::unwrap_used, clippy::expect_used)]
230mod tests {
231 use super::*;
232
233 /// A subset selection registers only the chosen services and, crucially,
234 /// never constructs the others — so their startup work does not run. Snowflake
235 /// is the cheap probe here: its `new()` does no eager auth or I/O, and gating
236 /// out the bridge means the test binds no TCP planes. See #1318.
237 #[tokio::test]
238 async fn build_default_registry_honours_a_subset() {
239 let selection = ServiceSelection::Only(vec![DaemonServiceKind::Snowflake]);
240 let registry = build_default_registry(
241 BridgeConfig::default(),
242 None,
243 PathBuf::from("/nonexistent/bridge.token"),
244 Path::new("/nonexistent/daemon.sock"),
245 &selection,
246 )
247 .await
248 .expect("a snowflake-only registry builds without touching the bridge");
249
250 // Only the selected service is constructed, plus the always-on GitHub
251 // API-call counter logger (#1387) — daemon-wide observability that is not
252 // one of the four selectable kinds, so it rides along in any subset.
253 let names: Vec<_> = registry.services().iter().map(|s| s.name()).collect();
254 assert_eq!(names, vec![services::snowflake::SERVICE_NAME, "github"]);
255 }
256}