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