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