Skip to main content

oxicode/
bootstrap.rs

1//! Application bootstrap and run-mode dispatch.
2//!
3//! Owns: log init, app building (settings → custom providers → router →
4//! tools → WASM), and run-mode dispatch (TUI / print / RPC).
5//!
6//! The helper functions below are moved verbatim from main.rs and
7//! retain their original signatures.
8
9use crate::cli::CliArgs;
10use crate::print_mode;
11use crate::store::settings::Settings;
12use anyhow::Result;
13use tracing;
14
15/// Build a wired `App` from CLI args. All the wiring that used to be
16/// inline in `main()` lives here.
17pub async fn build_app(args: &CliArgs) -> Result<crate::App> {
18    // Layer 2.5 / Catalog Port (v3): the `FileModelCatalog` wired in
19    // `services::build_oxicode` performs its own init at `OxicodeBuilder::build`
20    // time — it loads the embedded SNAP, applies overrides, and attempts
21    // one refresh if the cache is stale. So we no longer call the legacy
22    // `init_models_dev()` here. To skip network access during boot, set
23    // `OXICODE_MODELS_DEV_DISABLE_FETCH=1`.
24
25    // Load settings (global + project + env layers).
26    let mut settings = Settings::load().unwrap_or_default();
27
28    // Apply CLI overrides. Centralized in a closure so the post-wizard reload
29    // re-applies the exact same overrides — adding a new flag can't silently
30    // diverge between the two call sites.
31    let apply_cli_overrides = |s: &mut Settings| {
32        s.merge_cli(args.model.clone(), args.provider.clone());
33    };
34    apply_cli_overrides(&mut settings);
35
36    // Pre-build the per-process liveness identity BEFORE the engine build so
37    // we can include it in the SessionStart hook context (and pass it to
38    // App::from_oxicode on the same path).
39    let ownership_session_id = if is_tui_mode(args) {
40        tui_ownership_id()
41    } else {
42        proc_ownership_id()
43    };
44
45    // Load hooks: global hooks (`~/.oxicode/settings.toml` -> `[[hooks]]`)
46    // are always trusted. Project hooks (`.oxicode/settings.toml`) require
47    // a first-run interactive Y/n approval — unless we're in a non-TUI
48    // mode, in which case we skip with a warning to keep the boot path
49    // non-interactive. See `store/hook_approval.rs` for the registry.
50    let global_hooks = settings.hooks.clone();
51    let cwd_now = std::env::current_dir().unwrap_or_default();
52    let project_hooks_path = Settings::find_project_settings(&cwd_now);
53    let project_hooks: Vec<oxicode_sdk::ports::HookSpec> = match &project_hooks_path {
54        Some(path) => {
55            let content = std::fs::read_to_string(path).unwrap_or_default();
56            let hash = crate::store::hook_approval::hash_settings(&content);
57            let mut registry = crate::store::hook_approval::HookApprovalRegistry::load_or_default();
58            if registry.is_approved(&cwd_now, &hash) {
59                // Approved: re-parse the project file and extract its [[hooks]].
60                match Settings::parse_from_str(&content, Settings::detect_format(path)) {
61                    Ok(s) => s.hooks,
62                    Err(e) => {
63                        tracing::warn!(error = %e, "project hooks file failed to parse");
64                        Vec::new()
65                    }
66                }
67            } else {
68                // First run or hash mismatch.
69                let count = content.matches("[[hooks]]").count();
70                if count > 0 {
71                    if is_tui_mode(args) {
72                        let ok = crate::store::hook_approval::prompt_for_approval(&cwd_now, count);
73                        if ok {
74                            registry.approve(&cwd_now, &hash);
75                            let _ = registry.persist();
76                            Settings::parse_from_str(&content, Settings::detect_format(path))
77                                .map(|s| s.hooks)
78                                .unwrap_or_default()
79                        } else {
80                            tracing::warn!("project hooks denied by user; skipping");
81                            Vec::new()
82                        }
83                    } else {
84                        tracing::warn!(
85                            count,
86                            "project hooks not approved; skipping (non-interactive mode)"
87                        );
88                        Vec::new()
89                    }
90                } else {
91                    Vec::new()
92                }
93            }
94        }
95        None => Vec::new(),
96    };
97    let mut all_hooks = global_hooks;
98    all_hooks.extend(project_hooks);
99    let hook_runner: std::sync::Arc<dyn oxicode_sdk::ports::HookRunner> =
100        match oxicode_sdk::ports::fs::CommandHookRunner::new(all_hooks) {
101            Ok(r) => std::sync::Arc::new(r),
102            Err(e) => {
103                tracing::warn!(error = %e, "hook runner construction failed; using empty runner");
104                std::sync::Arc::new(
105                    oxicode_sdk::ports::fs::CommandHookRunner::new(Vec::new())
106                        .expect("empty spec list is always valid"),
107                )
108            }
109        };
110
111    if settings
112        .effective_model(None)
113        .unwrap_or_default()
114        .is_empty()
115    {
116        // No model configured. In interactive (TUI) mode, drop the user
117        // straight into the setup wizard instead of erroring out — this is
118        // the common first-run experience. In non-interactive modes
119        // (print / JSON / RPC / single-prompt) the caller explicitly wants a
120        // one-shot run, so a hard error with guidance is correct.
121        if is_tui_mode(args) {
122            eprintln!("No model configured. Launching setup wizard...");
123            crate::setup_wizard::run().await?;
124
125            // Reload settings the wizard just persisted and re-apply the
126            // CLI overrides, then re-check. If the user bailed out of the
127            // wizard without selecting a model, fall through to the error.
128            settings = Settings::load().unwrap_or_default();
129            apply_cli_overrides(&mut settings);
130        }
131
132        if settings
133            .effective_model(None)
134            .unwrap_or_default()
135            .is_empty()
136        {
137            eprintln!(
138                "{}",
139                print_mode::format_error("No model configured. Run `oxicode setup` to configure.")
140            );
141            std::process::exit(1);
142        }
143    }
144
145    // Register custom OpenAI-compatible providers from settings.
146    register_custom_providers(&settings);
147
148    // Register model router (reads router_config file, opt-in).
149    register_router_provider();
150
151    // Apply thinking level if specified.
152    if let Some(ref level_str) = args.thinking {
153        if let Some(level) = crate::store::settings::parse_thinking_level(level_str) {
154            settings.thinking_level = level;
155        } else {
156            anyhow::bail!(
157                "Invalid thinking level: {}. Valid options: off, minimal, low, medium, high, xhigh",
158                level_str
159            );
160        }
161    }
162
163    // Durable memory: oxibrain daemon is the sole authority (Foundation
164    // plan §5). The backend is constructed per AgentSession via
165    // services::create_memory_backend; no local pipeline is spawned.
166    // Build the wired Oxicode engine + Agent via the SDK composition root.
167    // (The SDK embeddings port stays at its Noop default — durable memory is
168    // the oxibrain daemon, not an embedding pipeline.)
169    let oxicode = crate::build_oxicode_engine(None, Some(hook_runner.clone())).await?;
170
171    // Fire SessionStart (fail-open: a hook that errors must not block boot).
172    {
173        let hook_ctx = oxicode_sdk::ports::HookContext {
174            event: oxicode_sdk::ports::HookEvent::SessionStart,
175            session_id: Some(ownership_session_id.clone()),
176            session_cwd: Some(cwd_now.clone()),
177            ..Default::default()
178        };
179        let _ = oxicode
180            .ports()
181            .hooks
182            .run(oxicode_sdk::ports::HookEvent::SessionStart, &hook_ctx)
183            .await;
184    }
185
186    // Spawn the catalog event logger so refresh / override / local-discovery
187    // events show up in the log file. UI hooks can subscribe to
188    // `oxicode.catalog().subscribe()` separately for picker invalidation.
189    let _catalog_logger =
190        crate::services::spawn_catalog_event_logger(std::sync::Arc::clone(oxicode.catalog()));
191
192    // Pre-build session state so the runtime (AgentSession) and the
193    // agent's session-level closures (`with_session_hooks`) share the
194    // SAME queues + stop flag. The single `set_hooks` invariant depends
195    // on this state living across both ends.
196    let session_state = crate::SessionState::default();
197
198    // Register built-in tools + the coding-omp-v1 behavior pack on the
199    // engine's SHARED tool registry BEFORE App/Agent construction so the
200    // pack's AgentConfig patch (snapshot store, prompt layers) applies
201    // when the AgentConfig is built.
202    let tools = oxicode.tools();
203    let cwd = cwd_now.clone();
204    let behavior = register_builtin_tools(
205        &tools,
206        &cwd,
207        args,
208        &settings.disabled_tools,
209        &settings.model_roles,
210    );
211    if let Some(b) = &behavior {
212        tracing::info!(
213            packs = %b
214                .manifest
215                .packs
216                .iter()
217                .map(|p| p.0.as_str())
218                .collect::<Vec<_>>()
219                .join("+"),
220            tools = b.manifest.tools.len(),
221            degraded = b.manifest.degraded.len(),
222            level = ?b.manifest.compatibility_level(),
223            "behavior pack installed"
224        );
225    }
226
227    let mut app = crate::App::from_oxicode(
228        oxicode,
229        settings,
230        ownership_session_id,
231        Some(session_state),
232        behavior,
233    )
234    .await?;
235
236    // Fire-and-forget OAuth refresh: if the active provider has a stored
237    // OAuth credential that is expired (or within 60 s of expiry), ask the
238    // refresh module to rotate it in the background. A failed refresh must
239    // never crash startup — the agent will surface a re-login prompt on
240    // the first 401 if the refresh actually failed.
241    if let Some(active_provider) = app.settings().effective_provider(args.provider.as_deref())
242        && !active_provider.is_empty()
243    {
244        let p = active_provider.clone();
245        tokio::spawn(async move {
246            if let Err(e) = crate::oauth_refresh::refresh_if_expired(&p).await {
247                tracing::debug!(provider = %p, error = %e, "oauth refresh skipped");
248            }
249        });
250    }
251
252    // v2.2: wire the MCP credential provider (OAuth2 client_credentials).
253    // Reads the same `mcp.json` files the agent uses, picks every server
254    // with an `oauth` block, and gives the manager a provider that can
255    // obtain + refresh access tokens on demand. No-op when no server
256    // declares `oauth`.
257    let mcp_cfg = oxicode_agent::mcp::config::load_mcp_config();
258    let mut oauth_map: std::collections::HashMap<String, oxicode_agent::mcp::types::OAuthConfig> =
259        std::collections::HashMap::new();
260    for (name, entry) in &mcp_cfg.mcp_servers {
261        if let Some(oc) = entry.oauth.clone() {
262            oauth_map.insert(name.clone(), oc);
263        }
264    }
265    if !oauth_map.is_empty()
266        && let Some(manager) = app.agent_tools().mcp_manager()
267    {
268        let config_dir = dirs::config_dir()
269            .map(|d| d.join("oxicode"))
270            .unwrap_or_else(|| std::path::PathBuf::from("."));
271        match crate::mcp_credentials::FileMcpCredentialProvider::new(oauth_map, config_dir) {
272            Ok(provider) => {
273                manager.set_credential_provider(provider);
274            }
275            Err(e) => {
276                tracing::warn!("Failed to construct MCP credential provider: {}", e);
277            }
278        }
279    }
280
281    // Native headless browser (opt-in via the `native-browser` cargo feature).
282    // Constructs the pure-Rust `oxibrowser-core` engine and registers the
283    // browse tools (incl. `browse_session` with `observe`/`wait` actions) so
284    // the agent can navigate/observe/extract — omp-parity browsing without a
285    // Chrome dependency.
286    #[cfg(feature = "native-browser")]
287    {
288        match oxicode_agent::tools::browse::OxicodeBrowserEngine::new().await {
289            Ok(engine) => {
290                let (provider, model) = match (app.agent().model_id().is_empty(), app.oxicode()) {
291                    (false, oxicode) => {
292                        let model_id = app.agent().model_id();
293                        // model_id is the bare model id (Agent::model_id);
294                        // resolve provider name from the agent's config or fall
295                        // back to the Oxicode default.
296                        let provider_name = "anthropic".to_string();
297                        match (
298                            oxicode.create_provider(&provider_name),
299                            oxicode_ai::Model::new(
300                                model_id.clone(),
301                                model_id.clone(),
302                                oxicode_ai::Api::AnthropicMessages,
303                                provider_name.clone(),
304                                String::new(),
305                            ),
306                        ) {
307                            (Ok(p), m) => (Some(p), Some(m)),
308                            _ => (None, None),
309                        }
310                    }
311                    _ => (None, None),
312                };
313                let browser_registry = oxicode_agent::tools::browse::browsing_tools_with_session(
314                    provider,
315                    model,
316                    std::sync::Arc::new(engine),
317                );
318                tools.extend_from(&browser_registry);
319            }
320            Err(e) => {
321                tracing::warn!("native browser engine unavailable; browse tools disabled: {e}");
322            }
323        }
324    }
325
326    // Discover and load WASM extensions.
327    let wasm_ext = load_wasm_extensions(&app, &cwd, &tools);
328    app.set_wasm_ext(wasm_ext);
329
330    // Handle --append-system-prompt.
331    if let Some(ref prompt_path) = args.append_system_prompt {
332        let content = std::fs::read_to_string(prompt_path)
333            .map_err(|e| anyhow::anyhow!("Failed to read system prompt file: {}", e))?;
334        app.agent().set_system_prompt(content);
335    }
336
337    Ok(app)
338}
339
340/// Dispatch the run mode: TUI / print / RPC, based on the CLI flags.
341pub async fn dispatch_run_mode(args: &CliArgs, app: crate::App) -> Result<i32> {
342    let prompt = args.prompt.join(" ");
343
344    if args.mode.as_deref() == Some("json") || args.print {
345        let mode = if args.mode.as_deref() == Some("json") {
346            crate::print_mode::PrintMode::Json
347        } else {
348            crate::print_mode::PrintMode::Text
349        };
350        let options = crate::print_mode::PrintModeOptions {
351            mode,
352            initial_message: if prompt.is_empty() {
353                None
354            } else {
355                Some(prompt)
356            },
357            messages: vec![],
358            no_stdin: args.print,
359            no_session: args.print || args.no_session,
360            quiet: args.print,
361            timeout: args.timeout,
362        };
363        return crate::print_mode::run_print_mode(&app, options).await;
364    }
365
366    if args.mode.as_deref() == Some("rpc") {
367        crate::rpc_mode::run_rpc_mode(app).await?;
368        return Ok(0);
369    }
370
371    if let Some(mode) = args.mode.as_deref() {
372        anyhow::bail!("Unknown run mode: {mode}");
373    }
374
375    if prompt.is_empty() || args.interactive {
376        crate::tui_vt::run_tui(app).await?;
377        return Ok(0);
378    }
379
380    crate::main_dispatch::run_single_prompt(app, &prompt).await?;
381    Ok(0)
382}
383
384/// Parse args, build the app, dispatch.
385pub async fn run_with_args(args: CliArgs) -> Result<i32> {
386    let app = build_app(&args).await?;
387    dispatch_run_mode(&args, app).await
388}
389
390// ─── Helpers (moved verbatim from main.rs) ─────────────────────────────
391
392/// Initialize file-based logging to `~/.cache/oxicode/oxicode.log`.
393///
394/// Reads `RUST_LOG` for filter (default: `debug`). Builds a
395/// `tracing_subscriber::EnvFilter` and writes to a `Mutex<File>` writer.
396pub fn init_logging() {
397    let log_dir = dirs::cache_dir()
398        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
399        .join("oxicode");
400    let _ = std::fs::create_dir_all(&log_dir);
401    let log_path = log_dir.join("oxicode.log");
402
403    let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_string());
404    let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
405        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&log_filter));
406
407    // Logging is non-critical infrastructure: if the log file can't be
408    // created (permissions, read-only fs, …), degrade to stderr instead of
409    // aborting the process. Previously this `.expect()`-panicked on init,
410    // which under `panic = "abort"` killed the app before it could start.
411    let subscriber = tracing_subscriber::fmt()
412        .with_env_filter(env_filter)
413        .with_target(true)
414        .with_thread_ids(true)
415        .with_ansi(false);
416    match std::fs::File::create(&log_path) {
417        Ok(file) => {
418            subscriber.with_writer(std::sync::Mutex::new(file)).init();
419        }
420        Err(e) => {
421            eprintln!(
422                "oxicode: could not open log file {log_path:?} ({e}); falling back to stderr"
423            );
424            subscriber.with_writer(std::io::stderr).init();
425        }
426    }
427
428    tracing::info!("Logging initialized, log file: {:?}", log_path);
429}
430
431/// Register custom OpenAI-compatible providers from settings and auto-fetch their models.
432fn register_custom_providers(settings: &Settings) {
433    let auth_storage = crate::store::auth_storage::shared_auth_storage();
434    for cp in &settings.custom_providers {
435        let api_key = auth_storage.get_api_key(&cp.name);
436        let api = cp.api.to_lowercase();
437
438        match api.as_str() {
439            "openai-completions" | "openai" => {
440                let provider = oxicode_ai::OpenAiProvider::with_base_url_and_key(
441                    &cp.base_url,
442                    api_key.clone(),
443                );
444                oxicode_sdk::register_provider(&cp.name, provider);
445                tracing::info!(
446                    "Registered custom provider '{}' (openai-completions) -> {}",
447                    cp.name,
448                    cp.base_url
449                );
450            }
451            "openai-responses" | "responses" => {
452                let provider = oxicode_sdk::OpenAiResponsesProvider::with_base_url_and_key(
453                    &cp.base_url,
454                    api_key.clone(),
455                );
456                oxicode_sdk::register_provider(&cp.name, provider);
457                tracing::info!(
458                    "Registered custom provider '{}' (openai-responses) -> {}",
459                    cp.name,
460                    cp.base_url
461                );
462            }
463            _ => {
464                tracing::warn!(
465                    "Unknown API type '{}' for custom provider '{}'. Supported: openai-completions, openai-responses",
466                    cp.api,
467                    cp.name
468                );
469            }
470        }
471
472        fetch_and_register_models(cp, &api, &api_key);
473    }
474}
475
476/// Fetch models from a custom provider's /v1/models endpoint and register them.
477fn fetch_and_register_models(
478    cp: &crate::store::settings::CustomProvider,
479    api: &str,
480    api_key: &Option<String>,
481) {
482    if let Some(key) = api_key {
483        match oxicode_sdk::fetch_models_blocking(&cp.base_url, key.as_str()) {
484            Ok(model_ids) => {
485                let count = model_ids.len();
486                for model_id in &model_ids {
487                    let api_type = match api {
488                        "openai-responses" | "responses" => oxicode_sdk::Api::OpenAiResponses,
489                        _ => oxicode_sdk::Api::OpenAiCompletions,
490                    };
491                    // `/v1/models` reports bare ids with no metadata.
492                    // Custom providers usually proxy upstream models
493                    // (claude-*, gemini-*, …), so cross-fill real limits
494                    // from the models.dev catalog before falling back to
495                    // the conservative placeholder.
496                    let known = oxicode_sdk::find_entry_by_model_id(model_id);
497                    let model = oxicode_sdk::Model {
498                        id: model_id.clone(),
499                        name: model_id.clone(),
500                        api: api_type,
501                        provider: cp.name.clone(),
502                        base_url: cp.base_url.clone(),
503                        reasoning: known.map(|e| e.reasoning).unwrap_or(false),
504                        input: vec![oxicode_sdk::InputModality::Text],
505                        cost: known
506                            .map(|e| oxicode_sdk::Cost {
507                                input: e.cost_input.max(0.0),
508                                output: e.cost_output.max(0.0),
509                                cache_read: e.cost_cache_read.max(0.0),
510                                cache_write: e.cost_cache_write.max(0.0),
511                            })
512                            .unwrap_or_default(),
513                        context_window: known.map(|e| e.context_window as usize).unwrap_or(128_000),
514                        max_tokens: known.map(|e| e.max_tokens as usize).unwrap_or(8_192),
515                        headers: Default::default(),
516                        compat: None,
517                    };
518                    oxicode_sdk::register_model(model);
519                }
520                tracing::info!(
521                    "[oxicode] auto-fetched {} models from '{}' ({})",
522                    count,
523                    cp.name,
524                    cp.base_url
525                );
526            }
527            Err(e) => {
528                tracing::warn!(
529                    "[oxicode] warning: failed to resolve models for {}: {}",
530                    cp.name,
531                    e
532                );
533            }
534        }
535    }
536}
537
538/// Register builtin tools with the agent, respecting --tools filter and disabled_tools.
539///
540/// Also transfers the [`McpManager`](oxicode_agent::mcp::McpManager) reference from
541/// the built-in registry to the live agent registry. This matters because
542/// `register_arc` only copies the `Arc<dyn AgentTool>` — the manager field is
543/// stored separately and would otherwise be `None`, making `/mcp` show a
544/// "MCP is not configured" warning even though the `McpTool` is registered.
545fn register_builtin_tools(
546    tools: &oxicode_agent::ToolRegistry,
547    cwd: &std::path::Path,
548    args: &CliArgs,
549    disabled_tools: &[String],
550    model_roles: &std::collections::HashMap<String, String>,
551) -> Option<crate::behavior::BehaviorComposition> {
552    let builtin_registry = if let Some(ref tools_str) = args.tools {
553        let names: Vec<&str> = tools_str.split(',').map(|s| s.trim()).collect();
554        oxicode_agent::ToolRegistry::with_selected_tools(cwd.to_path_buf(), &names)
555    } else {
556        oxicode_agent::ToolRegistry::with_builtins_cwd(cwd.to_path_buf(), disabled_tools)
557    };
558    for name in builtin_registry.names() {
559        if let Some(tool) = builtin_registry.get(&name) {
560            tools.register_arc(tool);
561        }
562    }
563    // Propagate the MCP manager so the TUI's `/mcp` overlay can hot-reload
564    // configs, render live connection status, and so on.
565    if let Some(mgr) = builtin_registry.mcp_manager() {
566        tools.set_mcp_manager(mgr);
567    }
568
569    // Role-based commit model: if a `commit` role is configured, upgrade the
570    // deterministic (no-LLM) CommitTool to one backed by that model. Tools
571    // register by name, so this overwrites the unconfigured instance safely.
572    let role_registry = oxicode_sdk::RoleRegistry::from_map(model_roles.clone());
573    if let Some(model) =
574        oxicode_sdk::resolve_role_to_model(oxicode_sdk::ModelRole::Commit, &role_registry)
575    {
576        let commit: std::sync::Arc<dyn oxicode_agent::AgentTool> =
577            std::sync::Arc::new(oxicode_agent::CommitTool::new(model));
578        tools.register_arc(commit);
579        tracing::debug!("CommitTool upgraded to commit-role model");
580    }
581
582    // coding-omp-v1 behavior pack: canonical coding tools installed through
583    // the SDK's host installer interception point, overwriting the legacy
584    // instances of the same names. The returned manifest + config patch feed
585    // App::from_oxicode (snapshot store, prompt layers) and /info surfacing.
586    let allow: Option<Vec<String>> = args
587        .tools
588        .as_deref()
589        .map(|s| s.split(',').map(|t| t.trim().to_string()).collect());
590    crate::behavior::install_coding_omp_v1(tools, cwd, allow.as_deref(), disabled_tools)
591}
592
593/// Discover and load WASM extensions, registering their tools.
594fn load_wasm_extensions(
595    app: &crate::App,
596    cwd: &std::path::Path,
597    tools: &oxicode_agent::ToolRegistry,
598) -> Option<std::sync::Arc<crate::extensions::WasmExtensionManager>> {
599    if !app.settings().extensions_enabled {
600        return None;
601    }
602
603    let wasm_paths = crate::extensions::WasmExtensionManager::discover(cwd);
604    if wasm_paths.is_empty() {
605        return None;
606    }
607
608    let mut wasm_mgr = crate::extensions::WasmExtensionManager::new();
609    let (loaded, errors) = wasm_mgr.load_all(&wasm_paths);
610    for info in &loaded {
611        tracing::info!("WASM extension loaded: {} v{}", info.name, info.version);
612    }
613    for err in &errors {
614        tracing::warn!("WASM extension error: {}", err);
615    }
616
617    if wasm_mgr.is_empty() {
618        return None;
619    }
620
621    let mgr = std::sync::Arc::new(wasm_mgr);
622    for tool_def in mgr.all_tool_defs() {
623        let wasm_tool = crate::extensions::WasmTool::new(
624            mgr.clone(),
625            tool_def.name.clone(),
626            tool_def.description.clone(),
627            tool_def.schema.clone(),
628        );
629        tools.register(wasm_tool);
630    }
631    Some(mgr)
632}
633
634/// Register the model auto-router if configured in router_config.
635fn register_router_provider() {
636    let global_dir = dirs::config_dir().unwrap_or_default().join("oxicode");
637    let project_dir = std::env::current_dir().unwrap_or_default();
638
639    let store_cfg = match crate::store::router_config::load_router_config(&global_dir, &project_dir)
640    {
641        Some(cfg) => cfg,
642        None => {
643            tracing::debug!("No router config found — router/auto will not appear in model list");
644            return;
645        }
646    };
647
648    // Register router models only when configured.
649    oxicode_sdk::register_model(oxicode_sdk::Model::new(
650        "auto",
651        "Router (auto)".to_string(),
652        oxicode_sdk::Api::AnthropicMessages,
653        "router",
654        "router://local",
655    ));
656
657    // Convert store config to AI config.
658    let mut ai_profiles = std::collections::HashMap::new();
659    for (name, sp) in store_cfg.profiles() {
660        fn parse_thinking(s: &Option<String>) -> Option<oxicode_sdk::ThinkingLevel> {
661            s.as_ref().and_then(|s| match s.as_str() {
662                "off" => Some(oxicode_sdk::ThinkingLevel::Off),
663                "minimal" => Some(oxicode_sdk::ThinkingLevel::Minimal),
664                "low" => Some(oxicode_sdk::ThinkingLevel::Low),
665                "medium" => Some(oxicode_sdk::ThinkingLevel::Medium),
666                "high" => Some(oxicode_sdk::ThinkingLevel::High),
667                "xhigh" => Some(oxicode_sdk::ThinkingLevel::XHigh),
668                _ => None,
669            })
670        }
671        ai_profiles.insert(
672            name.clone(),
673            oxicode_sdk::router::RouterProfile {
674                high: oxicode_sdk::router::RoutedTierConfig {
675                    model: sp.high.model.clone(),
676                    thinking: parse_thinking(&sp.high.thinking),
677                    fallbacks: sp.high.fallbacks.clone(),
678                },
679                medium: oxicode_sdk::router::RoutedTierConfig {
680                    model: sp.medium.model.clone(),
681                    thinking: parse_thinking(&sp.medium.thinking),
682                    fallbacks: sp.medium.fallbacks.clone(),
683                },
684                low: oxicode_sdk::router::RoutedTierConfig {
685                    model: sp.low.model.clone(),
686                    thinking: parse_thinking(&sp.low.thinking),
687                    fallbacks: sp.low.fallbacks.clone(),
688                },
689            },
690        );
691    }
692    let ai_cfg = oxicode_sdk::router::RouterConfig::with_pinning(
693        store_cfg.default_profile().to_string(),
694        store_cfg.classifier_model().map(String::from),
695        store_cfg.context_upgrade_threshold(),
696        store_cfg.max_session_budget(),
697        ai_profiles,
698        oxicode_sdk::router::ScoringWeights {
699            structural: store_cfg.weights().structural,
700            behavioral: store_cfg.weights().behavioral,
701            context_budget: store_cfg.weights().context_budget,
702            vision: store_cfg.weights().vision,
703            message: store_cfg.weights().message,
704        },
705        store_cfg.pin_tier().and_then(|s| match s {
706            "high" => Some(oxicode_sdk::router::RouterTier::High),
707            "medium" => Some(oxicode_sdk::router::RouterTier::Medium),
708            "low" => Some(oxicode_sdk::router::RouterTier::Low),
709            _ => None,
710        }),
711        store_cfg.phase_bias(),
712    );
713
714    oxicode_sdk::router::register_router(&ai_cfg);
715}
716
717/// Unique liveness identity for THIS TUI process: `tui-<pid>-<uuid>`.
718///
719/// Historically every TUI shared the constant id `"tui"`. With parallel
720/// interactive sessions that collapsed into a single identity — the second
721/// TUI's flock acquisition failed silently and both sessions passed the
722/// `require_owner` check (both *were* "tui"), so ownership exclusivity was
723/// unenforceable between them. A unique id per process restores it: each
724/// TUI holds its own flock, and a foreign assignment fails liveness the way
725/// `proc-*` assignments always did.
726pub(crate) fn tui_ownership_id() -> String {
727    format!(
728        "tui-{}-{}",
729        std::process::id(),
730        uuid::Uuid::new_v4().simple()
731    )
732}
733
734/// Unique liveness identity for a headless (print / RPC / single-prompt) run.
735pub(crate) fn proc_ownership_id() -> String {
736    format!(
737        "proc-{}-{}",
738        std::process::id(),
739        uuid::Uuid::new_v4().simple()
740    )
741}
742
743/// Decide whether this run is the TUI (interactive) mode. Mirrors the
744/// dispatch in [`dispatch_run_mode`]: print / RPC / single-prompt are
745/// non-TUI. Used by [`build_app`] to pick the per-process liveness identity.
746fn is_tui_mode(args: &CliArgs) -> bool {
747    if matches!(args.mode.as_deref(), Some("json" | "rpc")) || args.print {
748        return false;
749    }
750    // prompt-only (no `--interactive` and a non-empty prompt) is non-TUI too;
751    // dispatch_run_mode sends it through main_dispatch::run_single_prompt.
752    // NOTE: must join the prompt Vec — clap's `default_value = ""` on the
753    // positional makes bare `oxicode` yield `prompt == vec![""]` (non-empty Vec,
754    // empty join). Comparing the Vec directly would mis-classify the bare
755    // interactive launch as a single-prompt run.
756    let prompt = args.prompt.join(" ");
757    if !args.interactive && !prompt.is_empty() {
758        return false;
759    }
760    true
761}
762#[cfg(test)]
763mod tests {
764    use super::*;
765    use clap::Parser;
766
767    #[test]
768    fn rpc_mode_is_headless() {
769        let args = CliArgs::try_parse_from(["oxicode", "--mode", "rpc"]).unwrap();
770        assert!(!is_tui_mode(&args));
771    }
772
773    #[test]
774    fn empty_hooks_does_not_block() {
775        use crate::store::settings::Settings;
776        let s = Settings::default();
777        assert!(s.hooks.is_empty());
778    }
779
780    #[test]
781    fn tui_ownership_ids_are_unique_and_prefixed() {
782        // Per-process TUI identity: two TUIs on the same machine must never
783        // share one flock name — that collision silently broke issue
784        // ownership exclusivity between parallel interactive sessions.
785        let a = tui_ownership_id();
786        let b = tui_ownership_id();
787        assert!(a.starts_with("tui-"), "prefix missing: {a}");
788        assert!(b.starts_with("tui-"), "prefix missing: {b}");
789        assert_ne!(a, b, "ids must be unique per call (pid + uuid)");
790        assert!(!a.contains(' '), "no whitespace: {a}");
791    }
792}