Skip to main content

oxicode/
lib.rs

1// oxicode: CLI coding harness
2// Migrating to oxicode-vtui: relaxed linting for vendored code compatibility.
3#![allow(
4    missing_docs,
5    dead_code,
6    clippy::field_reassign_with_default,
7    clippy::unwrap_used,
8    clippy::let_and_return,
9    clippy::borrow_interior_mutable_const,
10    clippy::derivable_impls,
11    clippy::new_without_default,
12    unknown_lints
13)]
14//! oxicode: CLI coding harness
15//!
16//! This crate provides the main application logic for the oxicode CLI.
17
18// ─── Root-level entry modules ───────────────────────────────────────────────
19// cli must be pub for main.rs binary
20pub mod bootstrap;
21pub mod cli;
22pub mod foundation;
23pub mod home_migrate;
24pub mod internal_urls;
25pub mod lsp;
26pub mod main_dispatch;
27pub mod mcp_credentials;
28pub mod oauth_listener;
29pub mod oauth_refresh;
30pub mod print_mode;
31pub mod provider_oauth;
32pub mod services;
33pub mod setup_wizard;
34pub mod store;
35// ─── Directory groups ───────────────────────────────────────────────────────
36pub(crate) mod app;
37pub(crate) mod context;
38pub mod discovery;
39pub mod extensions; // public for main.rs
40pub(crate) mod infra;
41pub(crate) mod media;
42pub(crate) mod prompt;
43pub mod rpc_mode;
44pub(crate) mod skills;
45pub mod storage; // public for main.rs (packages)
46// Re-exports from storage for main.rs
47pub use storage::packages::PackageManager;
48pub use storage::packages::ResourceKind;
49pub mod tools;
50pub(crate) mod ui;
51pub(crate) mod util;
52
53///
54/// This is the **new entry point** for oxicode-cli run modes. It uses
55/// `oxicode-fs` adapters and `OxicodeBuilder::with_port_*` to construct an
56/// `Oxicode` with persistence, auth, config, and skills wired. The legacy
57/// `App::new` path is still used by the interactive TUI during the
58/// migration period.
59///
60/// ```
61/// use oxicode::build_oxicode_engine;
62/// # async fn _example() -> anyhow::Result<()> {
63/// let oxicode = build_oxicode_engine(None, None).await?;
64/// println!("providers: {}", oxicode.providers().names().len());
65/// # Ok(()) }
66/// ```
67pub async fn build_oxicode_engine(
68    embedding_provider: Option<std::sync::Arc<dyn oxicode_sdk::ports::EmbeddingProvider>>,
69    hook_runner: Option<std::sync::Arc<dyn oxicode_sdk::ports::HookRunner>>,
70) -> anyhow::Result<oxicode_sdk::Oxicode> {
71    let paths = services::OxicodePaths::default_paths()?;
72    services::build_oxicode(&paths, embedding_provider, hook_runner).await
73}
74
75/// Self-check the wired port implementations. Prints a one-line summary
76/// per port and returns `Ok(())` if all are reachable.
77///
78/// Triggered by the `OXICODE_PORT_CHECK=1` environment variable from
79/// `oxicode-cli/src/main.rs`. Useful for verifying the new composition root
80/// without disturbing the legacy `App::new` path.
81pub async fn run_port_check() -> anyhow::Result<()> {
82    let oxicode = build_oxicode_engine(None, None).await?;
83    let ports = oxicode.ports();
84
85    let entries = ports.state.list("").await?;
86    println!("[state]    entries: {}", entries.len());
87
88    // Auth
89    let providers = ports.auth.list_providers().await?;
90    println!("[auth]     providers with credentials: {:?}", providers);
91
92    // Config
93    let keys = ports.config.list()?;
94    println!("[config]   keys: {}", keys.len());
95
96    // Skills
97    let skills = ports.skills.list().await?;
98    println!("[skills]   {} skill(s) discovered", skills.len());
99    for s in &skills {
100        println!("           - {}: {}", s.name, s.description);
101    }
102
103    // Event bus / memory / etc — all noop unless registered
104    let _ = ports
105        .event_bus
106        .publish(&"port-check".to_string(), serde_json::json!({"ok": true}))
107        .await;
108    println!("[event-bus] publish ok (noop bus if not registered)");
109
110    println!("\nport check: ok");
111    Ok(())
112}
113
114/// Context for compaction operations, passed to extension hooks
115#[derive(Debug, Clone)]
116pub struct CompactionContext {
117    /// Messages being compacted
118    pub messages_count: usize,
119    /// Estimated tokens before compaction
120    pub tokens_before: usize,
121    /// Target token count after compaction
122    pub target_tokens: usize,
123    /// Strategy being used
124    pub strategy: String,
125}
126
127impl CompactionContext {
128    /// Create a new compaction context
129    pub fn new(
130        messages_count: usize,
131        tokens_before: usize,
132        target_tokens: usize,
133        strategy: impl Into<String>,
134    ) -> Self {
135        Self {
136            messages_count,
137            tokens_before,
138            target_tokens,
139            strategy: strategy.into(),
140        }
141    }
142
143    /// Get expected compression ratio
144    pub fn compression_ratio(&self) -> f32 {
145        if self.tokens_before == 0 {
146            return 1.0;
147        }
148        self.target_tokens as f32 / self.tokens_before as f32
149    }
150}
151
152// ─── Module-level imports ────────────────────────────────────────────────────
153use crate::store::settings::Settings;
154use anyhow::{Error, Result};
155use oxicode_agent::{Agent, AgentConfig, AgentEvent};
156use parking_lot::RwLock;
157use skills::SkillManager;
158use std::collections::VecDeque;
159use std::sync::Arc;
160
161/// Pre-built session state threaded into the agent hook chain.
162///
163/// Constructed by the cli BEFORE `oxicode.agent(...).build()` so the
164/// middleware pipeline (`with_port_hooks`) and session closures
165/// (`with_session_hooks`) are composed into a single `AgentHooks`
166/// instance — see the single-`set_hooks` invariant (only
167/// [`AgentBuilder::build`](oxicode_sdk::AgentBuilder::build) calls
168/// `set_hooks`). The same `Arc`s are cloned into `AgentSession` so the
169/// runtime queues, stop flag, and agent hooks all observe the same
170/// state across teardown/recreate cycles.
171#[derive(Clone)]
172pub struct SessionState {
173    /// Set when the user (or `Ctrl+C` handler) requests the agent to
174    /// stop after the current turn. Consulted by
175    /// [`oxicode_sdk::agent_builder::SessionHookClosures::should_stop_after_turn`].
176    pub should_stop: Arc<std::sync::atomic::AtomicBool>,
177    /// Steering messages — drained at the start of each turn (until empty).
178    pub steering: Arc<RwLock<VecDeque<oxicode_sdk::Message>>>,
179    /// Follow-up messages — drained after the agent has stopped.
180    pub follow_up: Arc<RwLock<VecDeque<oxicode_sdk::Message>>>,
181}
182
183impl Default for SessionState {
184    fn default() -> Self {
185        Self {
186            should_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
187            steering: Arc::new(RwLock::new(VecDeque::new())),
188            follow_up: Arc::new(RwLock::new(VecDeque::new())),
189        }
190    }
191}
192
193// ─── Application state ───────────────────────────────────────────────────────
194
195/// Holds an `Oxicode` engine (composition root) and a single `Agent` built
196/// from it. The legacy `App::new(settings)` constructor is **gone**;
197/// use [`App::from_oxicode`] with a wired `Oxicode` from
198/// [`build_oxicode_engine`].
199pub struct App {
200    oxicode: oxicode_sdk::Oxicode,
201    agent: Arc<Agent>,
202    settings: Settings,
203    skills: RwLock<SkillManager>,
204    active_skills: RwLock<Vec<String>>,
205    wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
206    ask_bridge: Option<std::sync::Arc<oxicode_agent::tools::ask::AskBridge>>,
207    /// Shared local issue store (`.oxicode/issues/`). Cloned cheaply (inner `Arc`).
208    /// Used by the agent `issue` tool, the TUI indicator, and the `oxicode issue`
209    /// CLI subcommand.
210    issue_store: Option<oxicode_sdk::FileIssueStore>,
211    /// Process-wide liveness identity used by every issue-ownership surface
212    /// in this process (agent tool's `ToolContext.session_id`, TUI panel,
213    /// slash-command `/issue` handlers). Unique per process in every mode:
214    /// `tui-<pid>-<uuid>` in TUI, `proc-<pid>-<uuid>` in headless runs.
215    ownership_session_id: String,
216    /// Alive-lock held for the lifetime of `App`. Dropped with `App`, releasing
217    /// the OS-held flock so any other process sees this session as dead once
218    /// we exit (including `kill -9` / crash / normal exit). Only held when
219    /// `issue_store` is available.
220    #[allow(dead_code)]
221    liveness_guard: Option<oxicode_sdk::liveness::AliveGuard>,
222    /// Cached `default` persona body, resolved once in `from_oxicode` so
223    /// synchronous prompt rebuilds can reuse it without awaiting the port.
224    persona_body: RwLock<Option<String>>,
225    /// Pre-built session queues + stop flag. Cloned into `AgentSession` so
226    /// the runtime and the agent's session-level closures share the SAME
227    /// state (see [`SessionState`] doc).
228    session_state: SessionState,
229}
230// ─── System prompt builder ───────────────────────────────────────────────────
231fn build_system_prompt(
232    thinking_level: crate::store::settings::ThinkingLevel,
233    skill_contents: &[String],
234    persona_body: Option<&str>,
235) -> String {
236    let skills: Vec<prompt::system_prompt::Skill> = skill_contents
237        .iter()
238        .enumerate()
239        .map(|(i, content)| prompt::system_prompt::Skill {
240            name: format!("skill-{}", i),
241            content: content.clone(),
242        })
243        .collect();
244
245    let options = prompt::system_prompt::BuildSystemPromptOptions {
246        custom_prompt: prompt::system_prompt::thinking_level_prompt(thinking_level),
247        skills,
248        cwd: std::env::current_dir()
249            .map(|p| p.to_string_lossy().to_string())
250            .unwrap_or_default(),
251        persona_prompt: persona_body.map(|s| s.to_string()),
252        ..Default::default()
253    };
254
255    prompt::system_prompt::build_system_prompt(&options)
256}
257
258// ─── App implementation ─────────────────────────────────────────────────────
259
260impl App {
261    /// Build an `App` from a wired `Oxicode` engine and a settings object.
262    ///
263    /// The `Oxicode` should be created via [`build_oxicode_engine`] (or
264    /// `services::build_oxicode`) so that all 11 ports are wired. The
265    /// settings hold the user's runtime configuration (model, thinking
266    /// level, etc.).
267    ///
268    /// `ownership_session_id` is the per-process liveness identity used by
269    /// the agent's `issue` tool (`ToolContext.session_id`), the TUI panel,
270    /// and the `/issue` slash command. It must be unique per process in
271    /// every mode (`tui-<pid>-<uuid>` / `proc-<pid>-<uuid>`) so two parallel
272    /// sessions never share one flock name — a shared name silently broke
273    /// ownership exclusivity between them.
274    ///
275    /// `session_state` is the pre-built [`SessionState`] passed into the
276    /// agent's `with_session_hooks` call. When `None`, fresh state is
277    /// constructed (the default for tests and most call sites — bootstrap
278    /// constructs it explicitly so the runtime and AgentSession share the
279    /// SAME queues + stop flag).
280    pub async fn from_oxicode(
281        oxicode: oxicode_sdk::Oxicode,
282        settings: Settings,
283        ownership_session_id: String,
284        session_state: Option<SessionState>,
285    ) -> Result<Self> {
286        let session_state = session_state.unwrap_or_default();
287        // Resolve the default persona once from the wired
288        // PersonaProvider port. The body flows into the system prompt;
289        // `preferred_model` overrides the settings default when no
290        // other override exists.
291        let persona = match oxicode.ports().personas.get("default").await {
292            Ok(Some(p)) if !p.system_prompt.trim().is_empty() => Some(p),
293            Ok(_) => None,
294            Err(e) => {
295                tracing::warn!(error = %e, "default persona lookup failed");
296                None
297            }
298        };
299
300        let model_id = persona
301            .as_ref()
302            .and_then(|p| p.preferred_model.clone())
303            .or_else(|| settings.effective_model(None))
304            .unwrap_or_default();
305        // Provider-name and api_key lookups removed in 0.55.0 — the SDK
306        // resolver consults the wired AuthProvider port directly.
307
308        let skills_dir = SkillManager::skills_read_dir().unwrap_or_else(|_| {
309            oxicode_catalog::product_env::home_dir()
310                .unwrap_or_default()
311                .join("skills")
312        });
313        let skills = SkillManager::load_from_dir(&skills_dir).unwrap_or_else(|e| {
314            tracing::debug!("Skills not loaded: {}", e);
315            SkillManager::new()
316        });
317
318        let body_str = persona.as_ref().map(|p| p.system_prompt.clone());
319        let system_prompt = build_system_prompt(settings.thinking_level, &[], body_str.as_deref());
320        let compaction_strategy = if settings.auto_compaction {
321            oxicode_sdk::CompactionStrategy::Threshold(0.8)
322        } else {
323            oxicode_sdk::CompactionStrategy::Disabled
324        };
325
326        let config = AgentConfig {
327            name: "oxicode".to_string(),
328            description: Some("oxicode CLI agent".to_string()),
329            model_id: model_id.clone(),
330            system_prompt: Some(system_prompt),
331            timeout_seconds: settings.tool_timeout_seconds,
332            temperature: settings.effective_temperature(),
333            max_tokens: settings.effective_max_tokens(),
334            compaction_strategy,
335            compaction_instruction: None,
336            context_window: 128_000,
337            workspace_dir: Some(
338                std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
339            ),
340            output_mode: None,
341            provider_options: None,
342            session_id: Some(ownership_session_id.clone()),
343            ttsr_engine: None,
344            memory: None,
345            todo: None,
346            agent_pool: None,
347            url_resolver: Some(Arc::new(oxicode_sdk::SdkUrlResolver::new(
348                oxicode.ports().url_router.clone(),
349            ))),
350            // LSP: lazy-spawn rust-analyzer (or other configured
351            // servers) on first request. When no servers are
352            // configured for the workspace, the field stays `None`
353            // and AgentBuilder.build() drops the `lsp` tool from
354            // the registry (see agent_builder.rs::build).
355            lsp: if crate::lsp::manager::default_servers().is_empty() {
356                None
357            } else {
358                Some(Arc::new(crate::lsp::CliLspProvider::with_defaults(
359                    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
360                )))
361            },
362            ..Default::default()
363        };
364
365        // Build the agent via the SDK's AgentBuilder — no manual wiring.
366        //
367        // Single `set_hooks` invariant: the agent's hook chain is built
368        // EXACTLY ONCE here, composing the port-backed middleware pipeline
369        // (`with_port_hooks`) and the cli-owned session closures
370        // (`with_session_hooks`) into one `AgentHooks` value. NEVER call
371        // `agent.set_hooks(...)` elsewhere (it would wipe the
372        // before/after_tool_call slots the middleware populated).
373        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
374
375        // Clone the shared session state into the closures. Each closure
376        // owns a fresh `Arc` clone — cheap, but essential so the agent
377        // and the runtime (which owns the `SessionState`) see mutations
378        // from either side.
379        // Build the shared AskBridge early. Its mode atomic is shared with
380        // the per-turn steering closure below so a runtime toggle
381        // (Shift+Tab in the TUI) takes effect immediately across the agent
382        // loop, the ask tool, and the render state.
383        let ask_timeout = if settings.ask_timeout_secs > 0 {
384            Some(std::time::Duration::from_secs(settings.ask_timeout_secs))
385        } else {
386            None
387        };
388        let bridge = std::sync::Arc::new(oxicode_agent::tools::ask::AskBridge::with_timeout(
389            ask_timeout,
390        ));
391        let mode_handle = bridge.mode_handle();
392
393        let stop_flag = Arc::clone(&session_state.should_stop);
394        let steering = Arc::clone(&session_state.steering);
395        let follow_up = Arc::clone(&session_state.follow_up);
396        let session_hooks = oxicode_sdk::agent_builder::SessionHookClosures {
397            should_stop_after_turn: Arc::new(move |_| {
398                stop_flag.load(std::sync::atomic::Ordering::SeqCst)
399            }),
400            get_steering_messages: Arc::new(move || {
401                let mut msgs: Vec<oxicode_sdk::Message> = steering.write().drain(..).collect();
402                // Auto mode: reinforce autonomous operation every turn so a
403                // mid-session Shift+Tab toggle takes effect immediately.
404                if oxicode_agent::config::Mode::load(&mode_handle).is_auto() {
405                    msgs.push(oxicode_sdk::Message::User(oxicode_sdk::UserMessage::new(
406                        "Autonomy mode (auto) is active: proceed autonomously to \
407                         completion without asking the user questions. Make \
408                         reasonable decisions on your own and keep working.",
409                    )));
410                }
411                msgs
412            }),
413            get_follow_up_messages: Arc::new(move || follow_up.write().drain(..).collect()),
414            tool_execution: oxicode_agent::config::ToolExecutionMode::Sequential,
415        };
416
417        let agent = oxicode
418            .agent(config)
419            .workspace(cwd)
420            .with_port_hooks()
421            .with_session_hooks(session_hooks)
422            .build()
423            .map_err(|e| Error::msg(format!("agent build failed: {e}")))?;
424        let agent = Arc::new(agent);
425
426        let ask_tool = oxicode_agent::tools::ask::AskTool::new(bridge.clone());
427        agent.tools().register_arc(std::sync::Arc::new(ask_tool));
428        // Open the local issue store rooted at the project (`.oxicode/issues/`).
429        // Best-effort: if the directory cannot be resolved, issues are simply
430        // unavailable — the app still works without them. The `/issue` slash
431        // command surfaces a clear error in that case.
432        let issue_store = std::env::current_dir()
433            .ok()
434            .map(|cwd| oxicode_sdk::FileIssueStore::open_from_cwd(&cwd))
435            .and_then(|r| {
436                r.map_err(|e| tracing::warn!("issue store unavailable: {e}"))
437                    .ok()
438            });
439
440        // Register the `issue` agent tool when the store is available.
441        if let Some(store) = issue_store.clone() {
442            let tool = std::sync::Arc::new(oxicode_sdk::IssueTool::new(store));
443            agent.tools().register_arc(tool);
444        }
445
446        Ok(Self {
447            oxicode,
448            agent,
449            settings,
450            skills: RwLock::new(skills),
451            active_skills: RwLock::new(Vec::new()),
452            wasm_ext: None,
453            ask_bridge: Some(bridge),
454            issue_store,
455            ownership_session_id,
456            liveness_guard: None, // set below once issue_store is known
457            persona_body: RwLock::new(persona.as_ref().map(|p| p.system_prompt.clone())),
458            session_state,
459        })
460        .map(|mut app| {
461            // Acquire the process-wide liveness flock now that issue_store exists.
462            // Best-effort: another live process already holds the lock is non-fatal;
463            // we still expose ownership_session_id so callers can detect the conflict.
464            app.liveness_guard =
465                acquire_ownership_guard(app.issue_store.as_ref(), &app.ownership_session_id);
466            app
467        })
468    }
469
470    /// Per-process liveness identity. Used by the agent's `issue` tool and any
471    /// other surface that gates on `is_session_alive`.
472    pub fn ownership_session_id(&self) -> &str {
473        &self.ownership_session_id
474    }
475
476    /// True iff `App` holds a live liveness flock under `ownership_session_id`.
477    /// False when there is no `issue_store` (e.g. headless test) or when another
478    /// live process already holds the lock (the assignment feature will surface
479    /// `Assigned` errors in that case — by design).
480    pub fn has_liveness_lock(&self) -> bool {
481        self.liveness_guard.is_some()
482    }
483
484    /// Get the current settings
485    pub fn settings(&self) -> &Settings {
486        &self.settings
487    }
488
489    /// Set the WASM extension manager
490    pub fn set_wasm_ext(
491        &mut self,
492        ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
493    ) {
494        self.wasm_ext = ext;
495    }
496
497    /// Get the WASM extension manager
498    pub fn wasm_ext(&self) -> Option<&std::sync::Arc<crate::extensions::WasmExtensionManager>> {
499        self.wasm_ext.as_ref()
500    }
501
502    /// Get a clone of the local issue store, if one was opened successfully.
503    pub fn issue_store(&self) -> Option<oxicode_sdk::FileIssueStore> {
504        self.issue_store.clone()
505    }
506
507    /// Get a reference to the underlying `Oxicode` engine. The catalog port and
508    /// other ports are accessible through it.
509    pub fn oxicode(&self) -> &oxicode_sdk::Oxicode {
510        &self.oxicode
511    }
512
513    /// Get a clone of the model catalog port (the canonical provider/model
514    /// metadata source). Used by the TUI to browse the full catalog in-TUI.
515    pub fn catalog(&self) -> std::sync::Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog> {
516        std::sync::Arc::clone(self.oxicode.catalog())
517    }
518
519    /// Get a reference to the underlying agent.
520    pub fn agent(&self) -> Arc<Agent> {
521        Arc::clone(&self.agent)
522    }
523
524    /// Get the tool registry (for registering extension tools)
525    pub fn agent_tools(&self) -> Arc<oxicode_agent::ToolRegistry> {
526        self.agent.tools()
527    }
528
529    /// Get the ask bridge, if initialized.
530    pub fn ask_bridge(&self) -> Option<&std::sync::Arc<oxicode_agent::tools::ask::AskBridge>> {
531        self.ask_bridge.as_ref()
532    }
533
534    /// Get a reference to the skill manager
535    pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
536        self.skills.read()
537    }
538
539    /// Activate a skill by name. Returns an error string if not found.
540    pub fn activate_skill(&self, name: &str) -> Result<(), String> {
541        {
542            let skills = self.skills.read();
543            if skills.get(name).is_none() {
544                return Err(format!("Skill '{}' not found", name));
545            }
546        }
547        let name_lower = name.to_lowercase();
548        {
549            let mut active = self.active_skills.write();
550            if !active.contains(&name_lower) {
551                active.push(name_lower);
552            }
553        }
554        self.rebuild_system_prompt();
555        Ok(())
556    }
557
558    /// Deactivate a skill by name.
559    pub fn deactivate_skill(&self, name: &str) {
560        let name_lower = name.to_lowercase();
561        {
562            let mut active = self.active_skills.write();
563            active.retain(|n| n != &name_lower);
564        }
565        self.rebuild_system_prompt();
566    }
567
568    /// List currently active skill names
569    pub fn active_skills(&self) -> Vec<String> {
570        self.active_skills.read().clone()
571    }
572
573    /// Rebuild the system prompt with current active skills
574    fn rebuild_system_prompt(&self) {
575        let active = self.active_skills.read();
576        let skills = self.skills.read();
577        let contents: Vec<String> = active
578            .iter()
579            .filter_map(|name| skills.get(name).map(|s| s.content.clone()))
580            .collect();
581        // The persona body was resolved once in `from_oxicode` and cached
582        // on `self.persona_body` so this sync rebuild can include it
583        // without re-awaiting the async PersonaProvider port.
584        let persona = self.persona_body.read().clone();
585        let prompt =
586            build_system_prompt(self.settings.thinking_level, &contents, persona.as_deref());
587        self.agent.set_system_prompt(prompt);
588    }
589
590    /// Get a clone of the current state
591    pub fn agent_state(&self) -> oxicode_agent::AgentState {
592        self.agent.state()
593    }
594
595    /// Run a single prompt and return the response
596    pub async fn run_prompt(&self, prompt: String) -> Result<String> {
597        let (response, _events) = self.agent.run(prompt).await?;
598        Ok(response.content)
599    }
600
601    /// Run a prompt with event callback
602    pub async fn run_prompt_with_events<F>(&self, prompt: String, on_event: F) -> Result<String>
603    where
604        F: FnMut(AgentEvent) + Send + 'static,
605    {
606        self.agent.run_streaming(prompt, on_event).await?;
607        let state = self.agent_state();
608        for msg in state.messages.iter().rev() {
609            if let oxicode_sdk::Message::Assistant(a) = msg {
610                return Ok(a.text_content());
611            }
612        }
613        Ok(String::new())
614    }
615
616    /// Reset the conversation
617    pub fn reset(&self) {
618        self.agent.reset();
619    }
620
621    /// Switch the model used for future LLM calls.
622    ///
623    /// The new provider is re-credentialed by the SDK resolver via the
624    /// wired AuthProvider port; the `api_key` parameter was removed in
625    /// 0.55.0 (issues #39/#40).
626    pub async fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
627        let _ = self.agent.switch_model(model_id);
628        Ok(())
629    }
630
631    /// Get the current model ID
632    pub fn model_id(&self) -> String {
633        self.agent.model_id()
634    }
635
636    /// Borrow the pre-built [`SessionState`] (stop flag + steering + follow-up
637    /// queues). The runtime clones the `Arc`s it needs into `AgentSession`
638    /// so the runtime and the agent's session-level closures share the SAME
639    /// state — required for Ctrl+C and `/steer` to take effect mid-run.
640    pub fn session_state(&self) -> &SessionState {
641        &self.session_state
642    }
643
644    /// Clone the shared stop flag. Cheap (single Arc bump).
645    pub fn should_stop_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
646        Arc::clone(&self.session_state.should_stop)
647    }
648
649    /// Clone the shared steering queue.
650    pub fn steering_queue(&self) -> Arc<RwLock<VecDeque<oxicode_sdk::Message>>> {
651        Arc::clone(&self.session_state.steering)
652    }
653
654    /// Clone the shared follow-up queue.
655    pub fn follow_up_queue(&self) -> Arc<RwLock<VecDeque<oxicode_sdk::Message>>> {
656        Arc::clone(&self.session_state.follow_up)
657    }
658}
659
660/// Acquire the process-wide liveness flock for `ownership_id` under the issue
661/// store's `.alive/` directory.
662///
663/// Returns `None` (no lock) when there is no issue store or when another live
664/// process already holds the lock — both non-fatal; the caller can still read
665/// `ownership_session_id` and the assignment feature will surface `Assigned`
666/// errors if contention actually occurs.
667///
668/// Extracted from `App::from_oxicode` so the single-lock invariant (defect #13 fix)
669/// can be unit-tested without standing up a full `Oxicode` engine.
670pub(crate) fn acquire_ownership_guard(
671    issue_store: Option<&oxicode_sdk::FileIssueStore>,
672    ownership_id: &str,
673) -> Option<oxicode_sdk::liveness::AliveGuard> {
674    let store = issue_store?;
675    if ownership_id.is_empty() {
676        // Defensive: never hold a lock under the empty string — that was the
677        // #13 bug shape (empty owner is never alive, so ownership was bypassed).
678        return None;
679    }
680    match oxicode_sdk::liveness::acquire(&store.issues_dir(), ownership_id) {
681        Ok(guard) => Some(guard),
682        Err(e) => {
683            // Non-fatal (reads and ownership checks still work), but the
684            // process is not recognized as a live flock holder while the
685            // lock is missing — its assignments stay contestable. Surface
686            // it loudly instead of the historical silent `.ok()`.
687            tracing::warn!(
688                ownership_id,
689                error = %e,
690                "issue liveness flock acquisition failed; this session's \
691                 ownership claims are contestable while the lock is missing"
692            );
693            None
694        }
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    //! P0 regression: `App` must hold exactly one liveness flock under its
701    //! ownership identity. We test the extracted `acquire_ownership_guard`
702    //! helper (the single chokepoint `from_oxicode` delegates to) rather than
703    //! standing up a full `Oxicode` engine.
704    use super::*;
705    use oxicode_sdk::FileIssueStore;
706    use oxicode_sdk::liveness;
707
708    fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
709        let tmp = tempfile::tempdir().unwrap();
710        let dir = tmp.path().join(".oxicode").join("issues");
711        std::fs::create_dir_all(&dir).unwrap();
712        (tmp, FileIssueStore::open(dir).unwrap())
713    }
714
715    #[test]
716    fn app_holds_single_liveness_lock() {
717        // The #13 invariant: acquiring the ownership guard makes the session
718        // live under that identity, and a second acquire under the SAME id
719        // fails (one flock per identity — single lock).
720        let (_tmp, store) = tmp_store();
721        let dir = store.issues_dir();
722        let id = "proc-test-app";
723
724        let guard = acquire_ownership_guard(Some(&store), id);
725        assert!(
726            guard.is_some(),
727            "App must acquire the liveness lock for its ownership id"
728        );
729        assert!(
730            liveness::is_session_alive(&dir, id),
731            "after acquire, the session must be live"
732        );
733
734        // While held, the same identity cannot be acquired again — single lock.
735        let second = liveness::acquire(&dir, id);
736        assert!(second.is_err(), "second acquire under same id must fail");
737
738        drop(guard);
739        assert!(
740            !liveness::is_session_alive(&dir, id),
741            "dropping App's guard releases the lock"
742        );
743    }
744
745    #[test]
746    fn acquire_returns_none_without_store() {
747        // No issue store (headless/test) → no lock. Not an error.
748        let dir = tempfile::tempdir().unwrap();
749        let id = "proc-x";
750        assert!(acquire_ownership_guard(None, id).is_none());
751        let _ = dir; // no store created
752    }
753
754    #[test]
755    fn acquire_rejects_empty_ownership_id() {
756        // Defensive guard against the #13 bug shape: never hold a lock under
757        // the empty string (it's never alive, so ownership would be bypassed).
758        let (_tmp, store) = tmp_store();
759        assert!(
760            acquire_ownership_guard(Some(&store), "").is_none(),
761            "empty ownership id must never acquire a lock (#13 guard)"
762        );
763    }
764}
765pub mod symbols;
766pub mod tui_vt;