Skip to main content

agentmux_srv/bootstrap/
background.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Split out of the single-file bootstrap module unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.3).
5
6use super::*;
7
8/// Output of [`spawn_background_subsystems`] — event/watcher infrastructure
9/// that needs no `AppState` and can start before it is built.
10pub struct BackgroundSubsystems {
11    pub event_bus: Arc<EventBus>,
12    pub broker: Arc<Broker>,
13    pub editor_file_watcher: Arc<backend::editor_file_watcher::EditorFileWatcher>,
14    pub media_file_watcher: Arc<backend::media_file_watcher::MediaFileWatcher>,
15    pub files_watcher: Arc<backend::files_watcher::FilesWatcher>,
16    pub fs_watch_pool: Arc<backend::fs_watch::FsWatchPool>,
17    pub config_watcher: Arc<wconfig::ConfigState>,
18    pub reactive_handler: &'static reactive::ReactiveHandler,
19    pub poller: Arc<Poller>,
20    pub messagebus: Arc<backend::messagebus::MessageBus>,
21    pub subagent_watcher: Arc<backend::subagent_watcher::SubagentWatcher>,
22    pub history_service: Arc<backend::history::HistoryService>,
23}
24
25/// Event infrastructure + all background task spawns that don't need
26/// `AppState` to exist yet: event bus/broker, editor file watcher, config
27/// watcher, sysinfo/watchdog/activity loops, the reactive handler + poller,
28/// the cloud push subscriber, messaging bridges (Discord/Telegram/Slack/
29/// WhatsApp), docsite dir, messagebus, subagent watcher, history service,
30/// and the session archiver.
31pub fn spawn_background_subsystems(
32    mstore: &Arc<Store>,
33    filestore: &Arc<FileStore>,
34    id_store: &Arc<Store>,
35    identity_store: &Arc<Store>,
36) -> BackgroundSubsystems {
37    // Event infrastructure
38    let event_bus = Arc::new(EventBus::new());
39    let broker = Arc::new(Broker::new());
40
41    // Bridge MPS events to WebSocket clients via EventBus
42    let bridge = backend::eventbus::EventBusBridge::new(event_bus.clone());
43    broker.set_client(Box::new(bridge));
44    // Remote connections' status changes, from wherever a connection is used.
45    backend::remote::link::init(broker.clone());
46
47    // Shared filesystem-watcher framework — constructed once, before any
48    // consumer, all three of which now build on it (see
49    // SPEC_SHARED_FS_WATCHER_FRAMEWORK_2026_08_07.md §5's migration order:
50    // config_watcher_fs first, editor+media here, native memory as a future
51    // consumer).
52    let fs_watch_pool = backend::fs_watch::FsWatchPool::new();
53
54    // Watches files open in editor/preview panes, publishing a per-block
55    // wake signal on external changes. See SPEC_EDITOR_LIVE_FILE_RELOAD_2026_07_18.md.
56    let editor_file_watcher = backend::editor_file_watcher::EditorFileWatcher::new(fs_watch_pool.clone(), broker.clone());
57
58    // Watches directories a Media pane is pointed at, publishing a per-block
59    // wake signal when a matching-extension file changes. See
60    // SPEC_MEDIA_PANE_2026_07_26.md.
61    let media_file_watcher = backend::media_file_watcher::MediaFileWatcher::new(fs_watch_pool.clone(), broker.clone());
62
63    // Watches folders open in Files panes (`fs.watch`), publishing a
64    // per-block "re-list this folder" signal. See
65    // SPEC_FILE_BROWSER_PANE_2026_10_01.md §6.3.
66    let files_watcher = backend::files_watcher::FilesWatcher::new(fs_watch_pool.clone(), broker.clone());
67
68    // Deploy shell integration scripts (muxlog.mjs/muxspect.mjs + rcfiles)
69    // unconditionally at startup, not opportunistically from a specific
70    // controller's spawn path. Previously the only call site was
71    // `ShellController::start`'s interactive (empty-command) branch
72    // (`blockcontroller/shell/lifecycle.rs`) — a user whose first-ever pane
73    // in a fresh data dir is an Agent pane (persistent/subprocess/acp, which
74    // never hits that branch) would never get the scripts deployed at all,
75    // so even muxspect's own documented `node ~/.agentmux/shell/muxspect.mjs`
76    // direct-path fallback would ENOENT (codex P1 on PR #2380 — a latent gap
77    // in muxlog's identical deployment mechanism that PR newly depends on).
78    // `deploy_scripts` is idempotent (skips if its version marker already
79    // matches), so calling it here in addition to the existing call site is
80    // safe, not a double-write race.
81    // Two roots, deliberately:
82    //   * the per-instance root the shells actually source (isolation, I6), and
83    //   * the stable `~/.agentmux/shell` path docs/MUXSPECT.md and docs/MUXSH.md
84    //     tell users to invoke directly, which must stay predictable.
85    backend::shellintegration::deploy_scripts(&backend::shellintegration::integration_base());
86    backend::shellintegration::deploy_scripts(&backend::shellintegration::documented_shell_root());
87
88    // Config watcher (created before sysinfo loop so it can read telemetry:interval)
89    let config_watcher = Arc::new(wconfig::ConfigState::with_config(wconfig::build_default_config()));
90
91    // Load user's settings.json from disk (merges with defaults)
92    backend::config_watcher_fs::load_settings_from_disk(&config_watcher);
93
94    // Watch settings.json for changes and broadcast to WebSocket clients
95    backend::config_watcher_fs::spawn_settings_watcher(
96        fs_watch_pool.clone(),
97        config_watcher.clone(),
98        event_bus.clone(),
99    );
100
101    // The user's own widgets.json beside settings.json, merged over the
102    // built-in widgets (Pane Tab contract Phase 6: where an `ext:` widget is
103    // added). Same load-then-watch shape, same pool.
104    backend::user_widgets::load_user_widgets_from_disk(&config_watcher);
105    backend::user_widgets::spawn_user_widgets_watcher(
106        fs_watch_pool.clone(),
107        config_watcher.clone(),
108        event_bus.clone(),
109        broker.clone(),
110    );
111
112    // Browser pane start page — same load-then-watch shape as settings.json
113    // above, on the SAME fs_watch_pool instance, so it rides the existing
114    // GetFullConfig/live-broadcast pipeline instead of a parallel one. See
115    // docs/specs/SPEC_BROWSER_PANE_START_PAGE_2026_09_16.md §3.2.
116    backend::browser_start_page::load_start_page_from_disk(&config_watcher);
117    backend::browser_start_page::spawn_start_page_watcher(
118        fs_watch_pool.clone(),
119        config_watcher.clone(),
120        event_bus.clone(),
121    );
122
123    // Start sysinfo collection loop (interval configurable via telemetry:interval)
124    let sysinfo_broker = broker.clone();
125    let sysinfo_config = config_watcher.clone();
126    let label_store = Arc::clone(mstore);
127    let pane_sampler = backend::sampler::panes::tower_sampler(move |id| {
128        backend::tower_sampler::block_label(&label_store, id)
129    });
130    tokio::spawn(async move {
131        sysinfo::run_sysinfo_loop(sysinfo_broker, sysinfo_config, "local".to_string(), pane_sampler).await;
132    });
133
134    // Start agent process watchdog (kills panes that exceed max-runtime or idle-output limits)
135    let watchdog_config = config_watcher.clone();
136    tokio::spawn(async move {
137        backend::blockcontroller::watchdog::run_watchdog_loop(watchdog_config).await;
138    });
139
140    // Recover a missing session title for a running agent (the Swarm row and
141    // pane header read it); see activity_watcher.rs —
142    // reads reactive::get_global_handler() as its registry, so it needs no
143    // AppState and can start before AppState is built (matches sysinfo/watchdog above).
144    let activity_mstore = Arc::clone(mstore);
145    let activity_filestore = Arc::clone(filestore);
146    let activity_event_bus = event_bus.clone();
147    // Every ambient call's spend goes to the status bar's totals (ambient/spend.rs).
148    crate::ambient::spend::publish_on(event_bus.clone());
149    tokio::spawn(async move {
150        backend::reactive::activity_watcher::run_agent_summary_loop(
151            activity_mstore, activity_filestore, activity_event_bus,
152        ).await;
153    });
154
155    // Push each agent's todo checklist + in-flight tool (swarm rows under the
156    // agent name). Separate loop from the summary above deliberately: this one
157    // is a tail-read + JSON parse with no model call, so it sweeps every 3s
158    // instead of 20s — a checklist that lags is worse than none. Same
159    // registry, same no-AppState property.
160    let progress_filestore = Arc::clone(filestore);
161    let progress_broker = broker.clone();
162    tokio::spawn(async move {
163        backend::reactive::progress_watcher::run_agent_progress_loop(
164            progress_filestore, progress_broker,
165        ).await;
166    });
167
168    // Keep each agent's repository, branch and uncommitted files fresh for
169    // WhoIsWorkingOn and ListConversations (backend/work_facts/git.rs): git
170    // runs here, never on a request.
171    let work_facts_mstore = Arc::clone(mstore);
172    tokio::spawn(async move {
173        backend::work_facts::git::run_git_refresh_loop(work_facts_mstore).await;
174    });
175
176    // Reactive handler (global singleton) + poller
177    let reactive_handler = reactive::get_global_handler();
178    reactive_handler.set_input_sender(Arc::new(|block_id: &str, data: &[u8]| {
179        backend::blockcontroller::send_input(
180            block_id,
181            backend::blockcontroller::BlockInputUnion::data(data.to_vec()),
182            None,
183        )
184    }));
185    // Controller-aware delivery (SPEC_AGENT_CONTROL_PROTOCOL §6 / Phase 3): persistent
186    // stream-json and ACP agents have no PTY, so muxbus Tier-1 keystroke injection
187    // silently misses them. Route those through their structured channel (live stdin /
188    // session/prompt) — which also steers the agent mid-turn — and fall back to PTY
189    // keystrokes only for terminal-based agents.
190    reactive_handler.set_message_sender(Arc::new(|block_id: &str, message: &str| {
191        match backend::blockcontroller::deliver_agent_message(block_id, message) {
192            Ok(backend::blockcontroller::AgentDelivery::Structured) => Ok(reactive::SenderDelivery::Delivered),
193            Ok(backend::blockcontroller::AgentDelivery::StructuredDeferred) => Ok(reactive::SenderDelivery::Deferred),
194            Ok(backend::blockcontroller::AgentDelivery::Pty) => Ok(reactive::SenderDelivery::Pty),
195            Err(e) => Err(e),
196        }
197    }));
198    // Recipient-identity check (issue #2695): before delivering, compare the
199    // resolved target's own live, spawn-time-captured identity (queried from
200    // the actual controller, independent of reactive_handler's own
201    // name/UID-to-block maps) against who the jekt was addressed to. See
202    // Controller::agent_id's doc comment for why this can't be derived from
203    // reactive_handler's own state.
204    reactive_handler.set_agent_identity_confirmer(Arc::new(|block_id: &str| {
205        backend::blockcontroller::get_controller(block_id).and_then(|c| c.agent_id())
206    }));
207    // Stable counterpart to the above (`AGENTMUX_AGENT_ID`, frozen at spawn)
208    // — see `Controller::stable_agent_id`'s and `NameBindings::stable`'s
209    // doc comments for why a jekt tagged with the stable ID needs its own,
210    // independently-sourced confirmer instead of reusing the live one.
211    reactive_handler.set_stable_agent_identity_confirmer(Arc::new(|block_id: &str| {
212        backend::blockcontroller::get_controller(block_id).and_then(|c| c.stable_agent_id())
213    }));
214    // Identity M2: the UID confirmer, consulted only for targets that
215    // resolved by UID — see `Handler::uid_identity_confirmer` for why it is
216    // a separate check and why `None` must mean "unverifiable".
217    reactive_handler.set_uid_identity_confirmer(Arc::new(|block_id: &str| {
218        backend::blockcontroller::get_controller(block_id).and_then(|c| c.stable_agent_uid())
219    }));
220    // Identity M2: liveness — the garbage collector that replaces
221    // eviction-by-name (spec §4.4.4 Q9). A block with no controller is dead
222    // and is swept when a name it held is resolved.
223    reactive_handler.set_block_liveness(Arc::new(|block_id: &str| {
224        backend::blockcontroller::get_controller(block_id).is_some()
225    }));
226    let poller = Arc::new(Poller::new(
227        PollerConfig {
228            muxbus_url: None,
229            muxbus_token: None,
230            poll_interval_secs: reactive::DEFAULT_POLL_INTERVAL_SECS,
231        },
232        reactive_handler,
233    ));
234
235    // Cloud delivery's state for the UI, the sign-in notification and the
236    // agents' pause/resume notes. Reads nothing from the keychain itself.
237    crate::muxbus::delivery_status::install(event_bus.clone(), broker.clone());
238    // Cloud push subscriber — single WS connection per sidecar that the cloud
239    // uses to push reactive injections instead of polling. The WS connection
240    // itself is a no-op until the user connects via muxbus.login, but
241    // `init_global` registers the muxbus credential with the broker
242    // scheduler unconditionally, which calls `Store::muxbus_is_fresh()` —
243    // a real, synchronous OS-keychain read — almost immediately. That read
244    // is the automatic (non-user-triggered) keychain touch
245    // docs/retro/retro-macos-muxbus-keychain-prompt-storm-2026-08-19.md
246    // investigates; skipping `init_global` entirely is not a no-op the way
247    // the comment above might suggest.
248    //
249    // `AGENTMUX_DISABLE_CLOUD_SUBSCRIBER` exists so `crates/srv/tests/
250    // integration_test.rs` can spawn the real binary without that read
251    // firing. Those tests spawn an ad-hoc/dev-signed `target/debug`
252    // binary, which is never on the Keychain ACL's trusted-signature list
253    // (only specific notarized, Developer-ID-signed installs are) — so
254    // every spawned test process gets its own interactive consent prompt
255    // for the SAME real credential, on whatever macOS account happens to
256    // be running the test. Confirmed live: a `cargo test -p agentmux-srv`
257    // run produced 4 near-simultaneous prompts, one per spawned subprocess,
258    // on a shared dev machine. Off by default — the shipped app and `task
259    // dev` must keep muxbus reconnect-on-launch working exactly as before.
260    //
261    // `isolated_muxbus_reconnect_enabled()` closes a second, broader gap
262    // the env-var flag above doesn't (see
263    // docs/retro/retro-macos-0560-stale-cef-cache-launch-crash-2026-09-16.md):
264    // every local `task package`/`task package:macos`/`task package:linux`
265    // build bakes a brand-new, randomized per-build channel (and, on
266    // macOS, a bundle identifier derived from it) into the binary, so it
267    // is a never-before-seen code signature to the Keychain every single
268    // time — the SAME already-`Always Allow`'d muxbus credential prompts
269    // again on every fresh local build, indefinitely. Unlike
270    // `isolated_auth_enabled`/`isolated_settings_enabled` (which also
271    // isolate `task dev`'s `dev-<branch>` channel), this flag deliberately
272    // exempts `dev-*` — see its own doc comment for why a per-branch-stable
273    // dev channel doesn't have the per-build-random-identity problem this
274    // one exists to solve. Skipping this eager call is also NOT permanent
275    // for the process's lifetime on an isolated channel: `muxbus.login`
276    // lazily initializes the subscriber the moment the user explicitly
277    // logs in (see `muxbus_handlers.rs`), and `muxbus.status` gates on
278    // subscriber presence rather than re-deriving this same channel check,
279    // so it starts working immediately after that login with no restart
280    // required.
281    let muxbus_reason = agentmux_common::isolated_muxbus_reconnect_reason();
282    if cloud_subscriber_disabled_from_env() {
283        tracing::info!("cloud_subscriber: init_global skipped (AGENTMUX_DISABLE_CLOUD_SUBSCRIBER)");
284    } else if muxbus_reason.is_isolated() {
285        tracing::info!(
286            reason = muxbus_reason.as_str(),
287            "cloud_subscriber: init_global skipped (ISOLATED — channel-scoped, no automatic MuxBus reconnect)"
288        );
289    } else {
290        crate::muxbus::cloud_subscriber::CloudSubscriber::init_global(id_store.clone());
291    }
292    // W3-S D1b: publish each agent's instance-certified WAN key to the
293    // account's cloud directory. Not gated on the subscriber: a logged-out
294    // or isolated channel simply finds no token and publishes nothing.
295    crate::muxbus::wan_publish::spawn(mstore.clone(), id_store.clone());
296
297    // Discord messaging bridge — connects to Discord Gateway if configured.
298    // Set messaging:discord:enabled + messaging:discord:token in settings.json to activate.
299    {
300        let settings = config_watcher.get_settings();
301        if settings.messaging_discord_enabled {
302            match settings.messaging_discord_token.clone() {
303                Some(token) if !token.is_empty() => {
304                    messaging::discord::DiscordBridge::init_global(
305                        messaging::discord::DiscordConfig {
306                            token,
307                            channel_id: settings.messaging_discord_channel.clone(),
308                            target_agent: settings.messaging_discord_target.clone(),
309                            guild_id: settings.messaging_discord_guild.clone(),
310                        },
311                        reqwest::Client::new(),
312                    );
313                }
314                _ => {
315                    tracing::warn!(
316                        "discord bridge: enabled but messaging:discord:token is not set in settings.json"
317                    );
318                }
319            }
320        }
321    }
322
323    // Telegram messaging bridge — long-polls getUpdates if configured.
324    // Set messaging:telegram:enabled + messaging:telegram:token in settings.json to activate.
325    {
326        let settings = config_watcher.get_settings();
327        if settings.messaging_telegram_enabled {
328            match settings.messaging_telegram_token.clone() {
329                Some(token) if !token.is_empty() => {
330                    let allowed_chat_ids = settings
331                        .messaging_telegram_allowed_chats
332                        .split(',')
333                        .filter_map(|s| s.trim().parse::<i64>().ok())
334                        .collect::<Vec<_>>();
335                    let default_chat_id = settings
336                        .messaging_telegram_default_chat
337                        .as_deref()
338                        .and_then(|s| s.parse::<i64>().ok());
339                    messaging::telegram::TelegramBridge::init_global(
340                        messaging::telegram::TelegramConfig {
341                            token,
342                            allowed_chat_ids,
343                            default_chat_id,
344                            target_agent: settings.messaging_telegram_target.clone(),
345                        },
346                        reqwest::Client::new(),
347                    );
348                }
349                _ => {
350                    tracing::warn!(
351                        "telegram bridge: enabled but messaging:telegram:token is not set in settings.json"
352                    );
353                }
354            }
355        }
356    }
357
358    // Slack messaging bridge — opens a Socket Mode connection if configured.
359    // Set messaging:slack:enabled + messaging:slack:bot_token + messaging:slack:app_token
360    // in settings.json to activate.
361    {
362        let settings = config_watcher.get_settings();
363        if settings.messaging_slack_enabled {
364            match (
365                settings.messaging_slack_bot_token.clone(),
366                settings.messaging_slack_app_token.clone(),
367            ) {
368                (Some(bot_token), Some(app_token))
369                    if !bot_token.is_empty() && !app_token.is_empty() =>
370                {
371                    messaging::slack::SlackBridge::init_global(
372                        messaging::slack::SlackConfig {
373                            bot_token,
374                            app_token,
375                            channel_id: settings.messaging_slack_channel.clone(),
376                            target_agent: settings.messaging_slack_target.clone(),
377                        },
378                        reqwest::Client::new(),
379                    );
380                }
381                _ => {
382                    tracing::warn!(
383                        "slack bridge: enabled but messaging:slack:bot_token and/or \
384                         messaging:slack:app_token is not set in settings.json"
385                    );
386                }
387            }
388        }
389    }
390
391    // WhatsApp Cloud API messaging bridge — outbound send + inbound webhook
392    // receiver. Unlike Discord/Telegram/Slack there is no bridge-managed
393    // network connection to open at startup: inbound delivery is passive
394    // HTTP (the GET/POST /webhook/whatsapp routes, registered unauthenticated
395    // in server/mod.rs — Meta cannot supply X-AuthKey), reachable only once
396    // the operator's own tunnel is up and the callback URL is registered in
397    // Meta's App Dashboard, both manual one-time steps this process does not
398    // perform (v1 does not manage a tunnel subprocess — see
399    // messaging/whatsapp/mod.rs). Set messaging:whatsapp:enabled +
400    // access_token/app_secret/webhook_verify_token in settings.json to activate.
401    // See docs/specs/SPEC_MESSAGING_INTEGRATION_WHATSAPP_2026_07_07.md.
402    {
403        let settings = config_watcher.get_settings();
404        if settings.messaging_whatsapp_enabled {
405            match (
406                settings.messaging_whatsapp_access_token.clone(),
407                settings.messaging_whatsapp_app_secret.clone(),
408                settings.messaging_whatsapp_webhook_verify_token.clone(),
409            ) {
410                (Some(token), Some(secret), Some(verify_token))
411                    if !token.is_empty() && !secret.is_empty() && !verify_token.is_empty() =>
412                {
413                    messaging::whatsapp::WhatsAppBridge::init_global(
414                        messaging::whatsapp::WhatsAppConfig {
415                            phone_number_id: settings.messaging_whatsapp_phone_number_id.clone(),
416                            access_token: token,
417                            app_secret: secret,
418                            webhook_verify_token: verify_token,
419                            target_agent: settings.messaging_whatsapp_target.clone(),
420                            fallback_template: settings.messaging_whatsapp_fallback_template.clone(),
421                            fallback_template_lang: settings
422                                .messaging_whatsapp_fallback_template_lang
423                                .clone()
424                                .unwrap_or_else(|| "en_US".to_string()),
425                        },
426                        reqwest::Client::new(),
427                    );
428                    if settings.messaging_whatsapp_tunnel_domain.is_empty() {
429                        tracing::warn!(
430                            "whatsapp bridge: enabled but messaging:whatsapp:tunnel_domain is not set — \
431                             point your own tunnel at this instance's webhook port and register \
432                             https://<your-domain>/webhook/whatsapp in Meta App Dashboard > WhatsApp > Configuration"
433                        );
434                    } else {
435                        tracing::info!(
436                            "whatsapp bridge: initialized — webhook callback = https://{}/webhook/whatsapp \
437                             — verify this is registered in Meta App Dashboard > WhatsApp > Configuration \
438                             (v1 does not manage the tunnel subprocess; ensure it's already running)",
439                            settings.messaging_whatsapp_tunnel_domain
440                        );
441                    }
442                }
443                _ => {
444                    tracing::warn!(
445                        "whatsapp bridge: enabled but one of messaging:whatsapp:{{access_token,app_secret,webhook_verify_token}} is not set in settings.json"
446                    );
447                }
448            }
449        }
450    }
451
452    // Set up docsite directory
453    if let Some(app_path) = base::get_mux_app_path() {
454        let docsite_dir = app_path.join("docsite");
455        docsite::set_docsite_dir(docsite_dir);
456    }
457
458    // Local MessageBus for inter-agent communication
459    let messagebus = Arc::new(backend::messagebus::MessageBus::new());
460
461    // Subagent watcher — monitors Claude Code session dirs for spawned subagents
462    let subagent_watcher = backend::subagent_watcher::SubagentWatcher::spawn(
463        event_bus.clone(),
464        mstore.clone(),
465        id_store.clone(),
466        identity_store.clone(),
467    );
468    // Wires `subagent:backfill_status` (scoped, persisted MPS event) — see
469    // `SubagentWatcher`'s `broker` field doc comment for why this is a
470    // post-construction setter rather than a constructor parameter.
471    subagent_watcher.set_broker(broker.clone());
472    // Registered as a global so blockcontroller/persistent/stdout_reader.rs's turn-end
473    // reconciliation hook (SPEC_SUBAGENT_LIVE_RECONCILIATION_AND_RETIRE_
474    // 2026_07_20 Phase A) can reach it without threading an Arc through
475    // PersistentSubprocessController::new and every one of its callers.
476    backend::subagent_watcher::set_global(subagent_watcher.clone());
477
478    // History service — discovers and indexes past CLI agent conversations
479    let history_service = Arc::new(backend::history::HistoryService::new());
480    history_service.warm_in_background();
481
482    // Install the pinned CLI of every provider the user's own agents use, in
483    // the background, so the first open after a pin bump doesn't wait on npm
484    // (SPEC_AGENT_OPEN_LATENCY_2026_09_27.md §4.1). Templates don't count:
485    // they span every provider. AGENTMUX_NO_CLI_WARM=1 turns it off.
486    if std::env::var("AGENTMUX_NO_CLI_WARM").map_or(true, |v| v != "1") {
487        match (
488            agentmux_common::DataPaths::from_env(),
489            mstore.agent_def_list(),
490        ) {
491            (Some(paths), Ok(defs)) => {
492                let providers: Vec<String> = defs
493                    .into_iter()
494                    .filter(|d| d.is_seeded != 1)
495                    .map(|d| d.provider)
496                    .collect();
497                backend::cli_install::warm_used_providers(paths, providers);
498            }
499            (_, Err(e)) => {
500                tracing::warn!(error = %e, "cli warm-up: cannot list agent definitions; skipped")
501            }
502            (None, _) => tracing::warn!("cli warm-up: no data paths; skipped"),
503        }
504    }
505
506    // Session archiver — auto-archive sessions inactive for >7 days, cap at 2 GB.
507    // Skip if home directory can't be determined (would otherwise fall back to a
508    // relative path and create archives under the current working directory).
509    if let Some(archive_dir) = backend::session_archive::default_archive_dir() {
510        let archiver = Arc::new(backend::session_archive::SessionArchiver::new(
511            mstore.clone(),
512            filestore.clone(),
513            7,                              // inactive days
514            2 * 1024 * 1024 * 1024,         // 2 GB max
515            archive_dir,
516        ));
517        tokio::spawn(async move {
518            loop {
519                tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
520                match archiver.sweep().await {
521                    Ok(stats) => tracing::info!(?stats, "session archiver sweep complete"),
522                    Err(e) => tracing::warn!(error = %e, "session archiver sweep failed"),
523                }
524            }
525        });
526    } else {
527        tracing::warn!("session archiver: home dir unavailable, archiver disabled");
528    }
529
530    BackgroundSubsystems {
531        event_bus,
532        broker,
533        editor_file_watcher,
534        media_file_watcher,
535        files_watcher,
536        fs_watch_pool,
537        config_watcher,
538        reactive_handler,
539        poller,
540        messagebus,
541        subagent_watcher,
542        history_service,
543    }
544}
545
546
547/// Should `CloudSubscriber::init_global` be skipped this run?
548///
549/// Presence-based, matching the `AGENTMUX_DEV`/`AGENTMUX_TRAY` idiom
550/// elsewhere in this codebase. See the call site's doc comment for why
551/// skipping this matters: `init_global` triggers a real, synchronous
552/// OS-keychain read almost immediately, which macOS gates behind an
553/// interactive consent prompt for any process whose code signature isn't
554/// on the target credential's trusted-signer ACL — exactly the case for an
555/// ad-hoc/dev-signed `target/debug` binary spawned by an integration test.
556fn cloud_subscriber_disabled_from_env() -> bool {
557    std::env::var("AGENTMUX_DISABLE_CLOUD_SUBSCRIBER").is_ok()
558}
559
560#[cfg(test)]
561mod cloud_subscriber_gate_tests {
562    use super::*;
563    use std::sync::Mutex;
564
565    // Serialize: both tests mutate the same process-global env var, and
566    // `cargo test` runs tests in parallel by default within one binary.
567    static ENV_LOCK: Mutex<()> = Mutex::new(());
568
569    fn lock() -> std::sync::MutexGuard<'static, ()> {
570        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
571    }
572
573    #[test]
574    fn defaults_to_enabled_so_shipped_and_dev_behavior_is_unchanged() {
575        let _guard = lock();
576        std::env::remove_var("AGENTMUX_DISABLE_CLOUD_SUBSCRIBER");
577        assert!(
578            !cloud_subscriber_disabled_from_env(),
579            "must default to false — the shipped app and `task dev` need the \
580             real muxbus reconnect-on-launch behavior with no opt-out set"
581        );
582    }
583
584    #[test]
585    fn is_disabled_when_the_var_is_present_regardless_of_value() {
586        let _guard = lock();
587        // Presence-based, not value-based — matches AGENTMUX_DEV/AGENTMUX_TRAY.
588        // An empty value must still disable it; a caller setting the var to
589        // "" to mean "off" would otherwise silently get the opposite of
590        // what every other flag in this idiom does.
591        std::env::set_var("AGENTMUX_DISABLE_CLOUD_SUBSCRIBER", "");
592        assert!(cloud_subscriber_disabled_from_env());
593        std::env::set_var("AGENTMUX_DISABLE_CLOUD_SUBSCRIBER", "1");
594        assert!(cloud_subscriber_disabled_from_env());
595        std::env::remove_var("AGENTMUX_DISABLE_CLOUD_SUBSCRIBER");
596    }
597}