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