Skip to main content

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 mod client;
28#[cfg(unix)]
29pub mod lifecycle;
30#[cfg(unix)]
31pub mod protocol;
32#[cfg(unix)]
33pub mod registry;
34#[cfg(unix)]
35pub mod server;
36#[cfg(unix)]
37pub mod service;
38#[cfg(unix)]
39pub mod services;
40#[cfg(unix)]
41pub mod single_instance;
42
43#[cfg(all(unix, test))]
44pub(crate) mod testutil;
45
46#[cfg(target_os = "macos")]
47pub mod launchd;
48
49#[cfg(target_os = "linux")]
50pub mod systemd;
51
52#[cfg(all(target_os = "macos", feature = "menu-bar"))]
53pub mod tray;
54
55#[cfg(unix)]
56use std::path::Path;
57#[cfg(unix)]
58use std::path::PathBuf;
59#[cfg(unix)]
60use std::sync::Arc;
61
62#[cfg(unix)]
63use anyhow::Result;
64
65#[cfg(unix)]
66use crate::browser::BridgeConfig;
67#[cfg(unix)]
68use crate::snowflake::SnowflakeEngineConfig;
69#[cfg(unix)]
70use registry::ServiceRegistry;
71#[cfg(unix)]
72use server::DaemonOptions;
73#[cfg(unix)]
74use services::bridge::BridgeService;
75#[cfg(unix)]
76use services::github_counters::GithubCountersService;
77#[cfg(unix)]
78use services::sessions::SessionsService;
79#[cfg(unix)]
80use services::snowflake::SnowflakeService;
81#[cfg(unix)]
82use services::worktrees::WorktreesService;
83
84/// Everything `daemon run` needs to start the daemon, resolved from the CLI.
85///
86/// Shared by the headless path ([`run_headless`]) and the macOS menu-bar path
87/// (`tray::run`) so both start an identical daemon. The latter is a plain code
88/// span, not an intra-doc link, because the `tray` module is feature- and
89/// target-gated and absent from the docs build.
90#[cfg(unix)]
91#[derive(Debug, Clone)]
92pub struct DaemonRunConfig {
93    /// Control-socket path (also the single-instance lock).
94    pub socket_path: PathBuf,
95    /// Browser-bridge configuration (ports, allow-origin, limits).
96    pub bridge_config: BridgeConfig,
97    /// Optional file the bridge session token is read from instead of generated.
98    pub bridge_token_file: Option<PathBuf>,
99    /// Where the resolved bridge token is persisted (`0600`) for thin clients.
100    pub bridge_token_path: PathBuf,
101}
102
103/// Builds the daemon's default service registry.
104///
105/// Starts the browser bridge on its loopback-TCP planes and registers it
106/// alongside the Snowflake query service, the cross-window worktrees registry,
107/// and the Claude Code sessions tracker.
108///
109/// `bridge_token_file` overrides token generation; `bridge_token_path` is where
110/// the resolved token is persisted (`0600`) for thin-client discovery. The
111/// Snowflake service is registered cheaply (no eager auth or I/O); its sessions
112/// are authenticated lazily on first query. The worktrees and sessions services
113/// are likewise cheap (in-memory only); they fill as VS Code windows register and
114/// as Claude Code hooks/transcripts report.
115#[cfg(unix)]
116pub async fn build_default_registry(
117    bridge_config: BridgeConfig,
118    bridge_token_file: Option<&Path>,
119    bridge_token_path: PathBuf,
120) -> Result<ServiceRegistry> {
121    let mut registry = ServiceRegistry::new();
122    let bridge = BridgeService::start(bridge_config, bridge_token_file, bridge_token_path).await?;
123    registry.register(Arc::new(bridge));
124    let snowflake = SnowflakeService::new(SnowflakeEngineConfig::from_env_and_settings()?);
125    registry.register(Arc::new(snowflake));
126    // Start the off-thread menu-refresh loop so the tray serves a cached menu
127    // instead of running git enrichment on the macOS GUI thread (#1186 fix).
128    let worktrees = WorktreesService::new();
129    // Seed the per-repo PR-poll enable set from its persisted `0600` file so the
130    // user's choices survive a restart; the poller reads it below (#1376). A path
131    // that cannot be resolved (no data dir) just disables persistence.
132    match crate::daemon::paths::worktrees_polling_path() {
133        Ok(path) => worktrees.load_polling_prefs(path),
134        Err(err) => tracing::warn!("worktrees polling prefs disabled: {err:#}"),
135    }
136    // Seed the resolved PR-badge cache from its persisted `0600` file so a restart
137    // serves badges instantly and the poller can skip its immediate re-poll when
138    // they are still fresh (#1389, fix 4). Before `start_pr_poller` so the warm
139    // start is in place when the loop spawns; a path that cannot be resolved just
140    // disables persistence.
141    match crate::daemon::paths::worktrees_pr_cache_path() {
142        Ok(path) => worktrees.load_pr_cache(path),
143        Err(err) => tracing::warn!("worktrees PR cache disabled: {err:#}"),
144    }
145    worktrees.start_menu_refresh();
146    // Keep PR check badges fresh for every open window from one `gh` call, rather
147    // than each window resolving its own and none of them ever re-asking (#1337).
148    worktrees.start_pr_poller();
149    // Watch the GitHub API budget the PR poller (and every other `gh` on the box)
150    // spends, so `daemon status` / the tray surface an approaching exhaustion before
151    // it rate-limits everything — polling `/rate_limit` is exempt, so this is free
152    // (#1375). Share the cache with the registry for the built-in `status` op.
153    worktrees.start_rate_limit_poller();
154    let rate_limit_cache = worktrees.rate_limit_cache();
155    registry.register(Arc::new(worktrees));
156    registry.set_github_rate_limit(rate_limit_cache);
157    // The cross-window Claude Code sessions tracker; start its transcript watcher
158    // (Feed 2) so sessions predating the daemon — and the hook-silent thinking
159    // window — are still tracked (#1210).
160    let sessions = SessionsService::new();
161    sessions.start_watcher();
162    registry.register(Arc::new(sessions));
163    // Periodically log a summary of the GitHub API-call counters (#1387): once
164    // ~5s after boot, every 10 minutes, and once on shutdown. Best-effort and
165    // bounded (a small local log read, no network); never blocks shutdown.
166    let github_counters = GithubCountersService::new();
167    github_counters.start_counter_logger();
168    registry.register(Arc::new(github_counters));
169    Ok(registry)
170}
171
172/// Runs the daemon headlessly (no tray).
173///
174/// Builds the registry and serves until a signal or `daemon stop`. The default
175/// `daemon run` path on every platform, and the only path when the `menu-bar`
176/// feature is off.
177#[cfg(unix)]
178pub async fn run_headless(cfg: DaemonRunConfig) -> Result<()> {
179    let registry = build_default_registry(
180        cfg.bridge_config,
181        cfg.bridge_token_file.as_deref(),
182        cfg.bridge_token_path,
183    )
184    .await?;
185    server::run(
186        registry,
187        DaemonOptions {
188            socket_path: cfg.socket_path,
189        },
190    )
191    .await
192}