Skip to main content

oino_core/
services.rs

1//! Per-cwd services: settings, resources, models + auth,
2//! loaded extensions, the provider stream and the host router extensions call back into.
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak};
7
8use futures::future::BoxFuture;
9use indexmap::IndexMap;
10use oino_ai::JsonValue;
11use oino_models::{ModelRegistry, ModelRegistryOptions, ProviderRegistration};
12use oino_resources::trust::{TrustRequest, TrustResolution, TrustStore, resolve_project_trust};
13use oino_resources::{ResourceLoader, ResourceLoaderOptions};
14use oino_settings::SettingsManager;
15use serde_json::json;
16
17use crate::extensions::api::{
18    CallContext, CallKind, ExtensionInstance, ExtensionKind, ExtensionMode,
19};
20use crate::extensions::host::{HostApi, HostError, HostOp};
21use crate::extensions::runner::{ExtensionSet, LoadedExtension};
22use crate::extensions::settings::{ExtensionSettings, ExtensionSettingsError, SettingsChange};
23use crate::provider::ProviderStream;
24use crate::safe_mode::{SafeModePlan, banner as safe_mode_banner};
25use crate::session::{AgentSession, Inner, lock};
26
27/// Loads extension instances (WASM host, bundled components, test doubles).
28pub trait ExtensionLoader: Send + Sync {
29    /// Loads every enabled instance in discovery order. `host` is what the instances call back
30    /// into; `project_trusted` gates `.oino/extensions`.
31    fn load(
32        &self,
33        host: Arc<dyn HostApi>,
34        project_trusted: bool,
35    ) -> BoxFuture<'_, Vec<Arc<dyn ExtensionInstance>>>;
36
37    /// [`ExtensionLoader::load`] without the extensions listed in `disabledExtensions`.
38    /// Loaders that can skip packages before instantiating them override this; the default
39    /// loads everything and [`Services::create`] drops the disabled instances before `init`.
40    fn load_enabled<'a>(
41        &'a self,
42        host: Arc<dyn HostApi>,
43        project_trusted: bool,
44        disabled: &'a [String],
45    ) -> BoxFuture<'a, Vec<Arc<dyn ExtensionInstance>>> {
46        let _ = disabled;
47        self.load(host, project_trusted)
48    }
49}
50
51/// A loader with a fixed instance list.
52pub struct StaticLoader(pub Vec<Arc<dyn ExtensionInstance>>);
53
54impl std::fmt::Debug for StaticLoader {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct("StaticLoader")
57            .field("instances", &self.0.len())
58            .finish()
59    }
60}
61
62impl ExtensionLoader for StaticLoader {
63    fn load(
64        &self,
65        _host: Arc<dyn HostApi>,
66        _project_trusted: bool,
67    ) -> BoxFuture<'_, Vec<Arc<dyn ExtensionInstance>>> {
68        let instances = self.0.clone();
69        Box::pin(async move { instances })
70    }
71}
72
73/// CLI resource flags (`-ns`, `-np`, `-nc`, `--skill`, …).
74#[allow(clippy::struct_excessive_bools, reason = "one field per CLI switch")]
75#[derive(Clone, Debug, Default, PartialEq, Eq)]
76pub struct ResourceFlags {
77    /// `--no-skills`.
78    pub no_skills: bool,
79    /// `--no-prompt-templates`.
80    pub no_prompt_templates: bool,
81    /// `--no-themes`.
82    pub no_themes: bool,
83    /// `--no-context-files`.
84    pub no_context_files: bool,
85    /// `--skill` paths.
86    pub skill_paths: Vec<String>,
87    /// `--prompt-template` paths.
88    pub prompt_template_paths: Vec<String>,
89    /// `--theme` paths.
90    pub theme_paths: Vec<String>,
91    /// `--system-prompt`.
92    pub system_prompt: Option<String>,
93    /// `--append-system-prompt` values.
94    pub append_system_prompt: Option<Vec<String>>,
95    /// `-ne`: disable `kind: "extension"` discovery (providers stay).
96    pub no_extensions: bool,
97    /// Safe-mode plan (X-SAFE): set by the embedding from [`crate::safe_mode::SafeMode::load`].
98    /// `disable_extensions` ORs into `-ne` (providers stay) and adds the banner diagnostic.
99    pub safe_mode: Option<SafeModePlan>,
100    /// `--offline` / `OINO_OFFLINE`: no startup network operations (dynamic model lists).
101    pub offline: bool,
102}
103
104/// `OINO_OFFLINE` is `1`, `true` or `yes`.
105#[must_use]
106pub fn offline_env() -> bool {
107    std::env::var("OINO_OFFLINE")
108        .is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
109}
110
111/// Inputs of [`Services::create`].
112pub struct ServicesOptions {
113    /// Working directory.
114    pub cwd: PathBuf,
115    /// Agent dir.
116    pub agent_dir: PathBuf,
117    /// Docs root (system prompt docs section). Empty = absent: the section is omitted
118    /// instead of pointing at a missing path (P-DOCS).
119    pub docs_root: PathBuf,
120    /// Run mode.
121    pub mode: ExtensionMode,
122    /// Dialog UI available (trust prompts, `hasUI`).
123    pub has_ui: bool,
124    /// CLI resource flags.
125    pub flags: ResourceFlags,
126    /// CLI values of extension flags.
127    pub extension_flag_values: IndexMap<String, JsonValue>,
128    /// Extension loader.
129    pub loader: Arc<dyn ExtensionLoader>,
130    /// `-a` / `-na`.
131    pub approve: Option<bool>,
132    /// Model registry override (tests); default reads `<agent>/models.json` and `auth.json`.
133    pub models: Option<ModelRegistry>,
134    /// In-memory settings (tests); default reads the settings files.
135    pub settings: Option<SettingsManager>,
136}
137
138/// Failure creating services or sessions.
139#[derive(Debug, thiserror::Error)]
140#[non_exhaustive]
141pub enum ServicesError {
142    /// Trust store failure.
143    #[error("{0}")]
144    Trust(String),
145    /// Session file failure.
146    #[error("{0}")]
147    Session(String),
148    /// Anything else.
149    #[error("{0}")]
150    Failed(String),
151}
152
153/// Routes host calls to the session named by the call context.
154#[derive(Default)]
155pub struct HostRouter {
156    sessions: Mutex<HashMap<String, Weak<Inner>>>,
157    extensions: OnceLock<Arc<ExtensionSet>>,
158    models: OnceLock<ModelRegistry>,
159    settings: OnceLock<Arc<ExtensionSettings>>,
160    base_cx: OnceLock<CallContext>,
161    topics: crate::topics::TopicStore,
162}
163
164/// Kind of the event announcing a settings write to the owning extension.
165pub const SETTINGS_CHANGED_EVENT: &str = "oino_settings_changed";
166
167fn settings_error(error: ExtensionSettingsError) -> HostError {
168    match error {
169        ExtensionSettingsError::Settings(_) | ExtensionSettingsError::Secrets(_) => {
170            HostError::Failed(error.to_string())
171        }
172        other => HostError::Invalid(other.to_string()),
173    }
174}
175
176impl std::fmt::Debug for HostRouter {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.debug_struct("HostRouter").finish_non_exhaustive()
179    }
180}
181
182impl HostRouter {
183    /// Registers a live session.
184    pub fn attach(&self, session: &AgentSession) {
185        lock(&self.sessions).insert(session.session_id(), Arc::downgrade(&session.inner));
186    }
187
188    /// Removes a session.
189    pub fn detach(&self, session_id: &str) {
190        lock(&self.sessions).remove(session_id);
191    }
192
193    fn session(&self, id: &str) -> Option<AgentSession> {
194        let mut sessions = lock(&self.sessions);
195        if let Some(inner) = sessions.get(id).and_then(Weak::upgrade) {
196            Some(AgentSession::from_inner(inner))
197        } else {
198            sessions.remove(id);
199            None
200        }
201    }
202
203    /// The extension settings registry (set once by [`Services::create`]).
204    #[must_use]
205    pub fn extension_settings(&self) -> Option<&Arc<ExtensionSettings>> {
206        self.settings.get()
207    }
208
209    /// Serves the settings operations ([`HostOp::is_settings_op`]) for `extension`.
210    ///
211    /// # Errors
212    ///
213    /// Registry failures; [`HostError::Failed`] before the registry is attached.
214    pub fn settings_call(&self, extension: &str, op: HostOp) -> Result<JsonValue, HostError> {
215        let registry = self
216            .settings
217            .get()
218            .ok_or_else(|| HostError::Failed("extension settings are not available".into()))?;
219        match op {
220            HostOp::RegisterSettings(schema) => registry
221                .register(extension, schema)
222                .map(|()| JsonValue::Null)
223                .map_err(settings_error),
224            HostOp::GetExtensionSettings { namespace, layer } => {
225                Ok(registry.values_for(extension, &namespace, layer))
226            }
227            HostOp::SetExtensionSetting {
228                namespace,
229                key,
230                value,
231                scope,
232            } => registry
233                .set_as(extension, &namespace, &key, value, scope)
234                .map(|()| JsonValue::Null)
235                .map_err(settings_error),
236            HostOp::GetAllExtensionSettings => Ok(registry.all_for(extension)),
237            HostOp::GetSettings => Ok(JsonValue::Object(registry.merged_settings())),
238            other => Err(HostError::Invalid(format!(
239                "not a settings operation: {other:?}"
240            ))),
241        }
242    }
243
244    /// Delivers `oino_settings_changed` to the namespace owner when it subscribed and
245    /// publishes the change on the bus topic `oino:settings:<namespace>`.
246    async fn deliver_settings_change(&self, change: SettingsChange) {
247        // Frontends (settings hub, StatusPanel) follow every write, including core namespaces
248        // that no extension owns (`tui.*`).
249        self.publish_topic(
250            crate::topics::CORE_PUBLISHER,
251            &crate::topics::settings_topic(&change.namespace),
252            json!({
253                "namespace": change.namespace.clone(),
254                "keys": change.keys,
255                "scope": change.scope,
256                "extensionId": change.extension_id,
257            }),
258            true,
259        );
260        let Some(owner) = change.extension_id.as_deref() else {
261            return;
262        };
263        let Some(extension) = self
264            .extensions
265            .get()
266            .and_then(|set| set.get(owner).cloned())
267        else {
268            return;
269        };
270        if !extension.state().events.contains(SETTINGS_CHANGED_EVENT) {
271            return;
272        }
273        let mut cx = self.base_cx.get().cloned().unwrap_or_else(|| CallContext {
274            session_id: String::new(),
275            mode: ExtensionMode::Print,
276            has_ui: false,
277            cwd: PathBuf::new(),
278            is_child: false,
279            child_role: None,
280            call_kind: CallKind::Event,
281        });
282        if let Some(id) = self.any_root_session() {
283            cx.session_id = id;
284        }
285        let payload = json!({
286            "type": SETTINGS_CHANGED_EVENT,
287            "namespace": change.namespace,
288            "keys": change.keys,
289            "scope": change.scope,
290        });
291        let deadline = crate::extensions::runner::deadline_for(SETTINGS_CHANGED_EVENT);
292        match tokio::time::timeout(
293            deadline,
294            extension
295                .instance
296                .on_event(SETTINGS_CHANGED_EVENT, payload, cx),
297        )
298        .await
299        {
300            Ok(Ok(_)) => {}
301            Ok(Err(error)) => {
302                tracing::warn!(
303                    extension = owner,
304                    "{SETTINGS_CHANGED_EVENT}: {}",
305                    error.message
306                );
307            }
308            Err(_) => tracing::warn!(extension = owner, "{SETTINGS_CHANGED_EVENT} timed out"),
309        }
310    }
311
312    /// The sticky topic store (`_oino/topics`).
313    #[must_use]
314    pub fn topics(&self) -> &crate::topics::TopicStore {
315        &self.topics
316    }
317
318    /// Live root sessions.
319    fn root_sessions(&self) -> Vec<AgentSession> {
320        lock(&self.sessions)
321            .values()
322            .filter_map(Weak::upgrade)
323            .filter(|inner| inner.agents.link.is_none())
324            .map(AgentSession::from_inner)
325            .collect()
326    }
327
328    /// Records a topic and offers it to every root session's frontend feed.
329    pub fn publish_topic(&self, publisher: &str, topic: &str, data: JsonValue, sticky: bool) {
330        if let Some(value) = self.topics.publish(publisher, topic, data, sticky) {
331            for session in self.root_sessions() {
332                session.offer_topic(&value);
333            }
334        }
335    }
336
337    /// Serves [`HostOp::is_bus_op`] operations for `extension`.
338    ///
339    /// # Errors
340    ///
341    /// [`HostError::Invalid`] for other operations.
342    pub fn bus_call(&self, extension: &str, op: HostOp) -> Result<JsonValue, HostError> {
343        match op {
344            HostOp::BusPublish {
345                topic,
346                data,
347                sticky,
348            } => {
349                self.publish_topic(extension, &topic, data, sticky);
350                Ok(JsonValue::Null)
351            }
352            HostOp::BusGet(topic) => Ok(self
353                .topics
354                .get(&topic)
355                .map_or(JsonValue::Null, |value| value.data)),
356            other => Err(HostError::Invalid(format!(
357                "not a bus operation: {other:?}"
358            ))),
359        }
360    }
361
362    /// Id of a live root session (event context for host-produced events).
363    fn any_root_session(&self) -> Option<String> {
364        let sessions = lock(&self.sessions);
365        sessions
366            .iter()
367            .filter_map(|(id, weak)| weak.upgrade().map(|inner| (id, inner)))
368            .find(|(_, inner)| inner.agents.link.is_none())
369            .map(|(id, _)| id.clone())
370    }
371
372    /// Registry operations that do not need a session (calls during `init` or after the
373    /// session ended).
374    fn without_session(&self, extension: &str, op: HostOp) -> Result<JsonValue, HostError> {
375        let loaded = self
376            .extensions
377            .get()
378            .and_then(|set| set.get(extension).cloned());
379        let models = self.models.get();
380        let need_loaded =
381            || HostError::StaleContext(crate::extensions::runner::STALE_CONTEXT_MESSAGE.to_owned());
382        match op {
383            HostOp::Subscribe(kinds) => loaded
384                .ok_or_else(need_loaded)?
385                .update(|s| s.events.extend(kinds)),
386            HostOp::RegisterTool(spec) => {
387                loaded
388                    .ok_or_else(need_loaded)?
389                    .update(|s| s.tools.insert(spec.name.clone(), spec));
390            }
391            HostOp::RegisterCommand(spec) => loaded
392                .ok_or_else(need_loaded)?
393                .update(|s| s.commands.push(spec)),
394            HostOp::SetArbiterRoles(roles) => loaded
395                .ok_or_else(need_loaded)?
396                .update(|s| s.arbiter = roles),
397            HostOp::RegisterProvider(config) => {
398                let registration: ProviderRegistration = serde_json::from_value(config)
399                    .map_err(|e| HostError::Invalid(e.to_string()))?;
400                models
401                    .ok_or_else(need_loaded)?
402                    .register_provider(extension, registration)
403                    .map_err(|e| HostError::Failed(e.to_string()))?;
404            }
405            _ => return Err(need_loaded()),
406        }
407        Ok(JsonValue::Null)
408    }
409}
410
411impl HostApi for HostRouter {
412    fn call<'a>(
413        &'a self,
414        extension: &'a str,
415        cx: &'a CallContext,
416        op: HostOp,
417    ) -> BoxFuture<'a, Result<JsonValue, HostError>> {
418        Box::pin(async move {
419            if op.is_settings_op() {
420                return self.settings_call(extension, op);
421            }
422            if op.is_bus_op() {
423                return self.bus_call(extension, op);
424            }
425            match self.session(&cx.session_id) {
426                Some(session) => session.host_call(extension, cx, op).await,
427                None => self.without_session(extension, op),
428            }
429        })
430    }
431
432    fn model_stream<'a>(
433        &'a self,
434        _extension: &'a str,
435        cx: &'a CallContext,
436        request: crate::extensions::host::ModelRequest,
437    ) -> BoxFuture<'a, Result<futures::stream::BoxStream<'static, JsonValue>, HostError>> {
438        Box::pin(async move {
439            let session = self.session(&cx.session_id).ok_or_else(|| {
440                HostError::StaleContext(crate::extensions::runner::STALE_CONTEXT_MESSAGE.into())
441            })?;
442            session.model_stream(request).await
443        })
444    }
445
446    fn detach_bash<'a>(
447        &'a self,
448        _extension: &'a str,
449        cx: &'a CallContext,
450        tool_call_id: &'a str,
451        task_id: &'a str,
452        reason: &'a str,
453    ) -> BoxFuture<'a, Result<crate::session::DetachedBash, HostError>> {
454        Box::pin(async move {
455            let session = self.session(&cx.session_id).ok_or_else(|| {
456                HostError::StaleContext(crate::extensions::runner::STALE_CONTEXT_MESSAGE.into())
457            })?;
458            session.detach_bash(tool_call_id, task_id, reason).await
459        })
460    }
461
462    fn spawn_agent<'a>(
463        &'a self,
464        extension: &'a str,
465        cx: &'a CallContext,
466        spec: JsonValue,
467    ) -> BoxFuture<'a, Result<Arc<crate::agents::ChildAgent>, HostError>> {
468        Box::pin(async move {
469            let session = self.session(&cx.session_id).ok_or_else(|| {
470                HostError::StaleContext(crate::extensions::runner::STALE_CONTEXT_MESSAGE.into())
471            })?;
472            let spec: crate::agents::AgentSpec = serde_json::from_value(spec)
473                .map_err(|e| HostError::Invalid(format!("AgentSpec: {e}")))?;
474            session.spawn_agent(extension, spec).await
475        })
476    }
477}
478
479/// Services of one `(cwd, agent_dir)`.
480pub struct Services {
481    /// Working directory.
482    pub cwd: PathBuf,
483    /// Agent dir.
484    pub agent_dir: PathBuf,
485    /// Bundled docs root.
486    pub docs_root: PathBuf,
487    /// Run mode.
488    pub mode: ExtensionMode,
489    /// Dialog UI available.
490    pub has_ui: bool,
491    /// Project trust decision.
492    pub project_trusted: bool,
493    /// Settings.
494    pub settings: Arc<Mutex<SettingsManager>>,
495    /// Extension settings registry over [`Self::settings`] (runtime §10.3).
496    pub extension_settings: Arc<ExtensionSettings>,
497    /// Resources.
498    pub resources: Arc<RwLock<ResourceLoader>>,
499    /// Models and auth.
500    pub models: ModelRegistry,
501    /// Loaded extensions.
502    pub extensions: Arc<ExtensionSet>,
503    /// Provider dispatch.
504    pub stream: Arc<ProviderStream>,
505    /// Host router.
506    pub host: Arc<HostRouter>,
507    /// Safe-mode plan of this start (X-SAFE; `None` when the embedding does not participate).
508    /// [`SafeModePlan::disable_extensions`] is what the `_oino/hello` reply reports as
509    /// `safeMode`.
510    pub safe_mode: Option<SafeModePlan>,
511    /// CLI extension flag values.
512    pub flag_values: IndexMap<String, JsonValue>,
513    /// Startup diagnostics.
514    pub diagnostics: Vec<String>,
515}
516
517impl std::fmt::Debug for Services {
518    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519        f.debug_struct("Services")
520            .field("cwd", &self.cwd)
521            .field("extensions", &self.extensions.len())
522            .finish_non_exhaustive()
523    }
524}
525
526fn resolve_trust(
527    options: &ServicesOptions,
528    settings: &oino_settings::Settings,
529) -> Result<bool, ServicesError> {
530    let store = TrustStore::new(&options.agent_dir);
531    let home = oino_settings::paths::home_dir();
532    let request = TrustRequest {
533        cwd: &options.cwd,
534        home: home.as_deref(),
535        cli_override: options.approve,
536        extension_decision: None,
537        default_trust: settings.default_project_trust(),
538        // Interactive trust prompts are a frontend concern (raised before services start).
539        has_ui: false,
540    };
541    match resolve_project_trust(&request, &store)
542        .map_err(|e| ServicesError::Trust(e.to_string()))?
543    {
544        TrustResolution::Decided { trusted, .. } => Ok(trusted),
545        TrustResolution::Ask { .. } => Ok(false),
546    }
547}
548
549/// X-SAFE: the plan carried in [`ResourceFlags::safe_mode`] — its `-ne` effect (providers
550/// stay), whether extensions are off, and the banner diagnostic for a tripped plan.
551fn safe_mode_start(
552    flags: &ResourceFlags,
553    diagnostics: &mut Vec<String>,
554) -> (Option<SafeModePlan>, bool) {
555    let safe_mode = flags.safe_mode.clone();
556    let no_extensions = flags.no_extensions
557        || safe_mode
558            .as_ref()
559            .is_some_and(|plan| plan.disable_extensions);
560    if let Some(plan) = &safe_mode
561        && plan.disable_extensions
562    {
563        diagnostics.push(safe_mode_banner(plan));
564    }
565    (safe_mode, no_extensions)
566}
567
568impl Services {
569    /// Startup (runtime §4): trust → settings/resources → extensions (`init`) → providers.
570    ///
571    /// # Errors
572    ///
573    /// Trust store failures.
574    pub async fn create(mut options: ServicesOptions) -> Result<Arc<Self>, ServicesError> {
575        let mut diagnostics = Vec::new();
576        let (safe_mode, no_extensions) = safe_mode_start(&options.flags, &mut diagnostics);
577        let mut settings = options
578            .settings
579            .take()
580            .unwrap_or_else(|| SettingsManager::create(&options.cwd, &options.agent_dir, false));
581        let trusted = resolve_trust(&options, settings.settings())?;
582        settings.set_project_trusted(trusted);
583        diagnostics.extend(settings.drain_errors().into_iter().map(|e| e.to_string()));
584        let mut resources = ResourceLoader::new(ResourceLoaderOptions {
585            cwd: options.cwd.clone(),
586            agent_dir: options.agent_dir.clone(),
587            home_dir: oino_settings::paths::home_dir(),
588            no_skills: options.flags.no_skills,
589            no_prompt_templates: options.flags.no_prompt_templates,
590            no_themes: options.flags.no_themes,
591            no_context_files: options.flags.no_context_files,
592            skill_paths: options.flags.skill_paths.clone(),
593            prompt_template_paths: options.flags.prompt_template_paths.clone(),
594            theme_paths: options.flags.theme_paths.clone(),
595            system_prompt: options.flags.system_prompt.clone(),
596            append_system_prompt: options.flags.append_system_prompt.clone(),
597        });
598        resources.reload(settings.settings(), trusted);
599        diagnostics.extend(resources.diagnostics().iter().map(|d| format!("{d:?}")));
600        let models = options.models.clone().unwrap_or_else(|| {
601            let auth = Arc::new(oino_models::AuthStorage::open(
602                options.agent_dir.join("auth.json"),
603            ));
604            ModelRegistry::new(
605                auth,
606                ModelRegistryOptions::for_agent_dir(&options.agent_dir),
607            )
608        });
609        let settings = Arc::new(Mutex::new(settings));
610        let extension_settings = Arc::new(ExtensionSettings::new(
611            Arc::clone(&settings),
612            Some(oino_settings::SecretStore::new(&options.agent_dir)),
613        ));
614        let host = Arc::new(HostRouter::default());
615        let _ = host.models.set(models.clone());
616        let _ = host.settings.set(Arc::clone(&extension_settings));
617        let disabled = lock(&settings).settings().disabled_extensions();
618        let instances: Vec<Arc<dyn ExtensionInstance>> = options
619            .loader
620            .load_enabled(Arc::clone(&host) as Arc<dyn HostApi>, trusted, &disabled)
621            .await
622            .into_iter()
623            .filter(|instance| !disabled.iter().any(|id| id == instance.id()))
624            .collect();
625        let cx = CallContext {
626            session_id: String::new(),
627            mode: options.mode,
628            has_ui: options.has_ui,
629            cwd: options.cwd.clone(),
630            is_child: false,
631            child_role: None,
632            call_kind: CallKind::Event,
633        };
634        let _ = host.base_cx.set(cx.clone());
635        let loaded = init_extensions(
636            instances,
637            &cx,
638            &models,
639            &extension_settings,
640            no_extensions,
641            &mut diagnostics,
642        )
643        .await;
644        let extensions = Arc::new(ExtensionSet::new(loaded));
645        let _ = host.extensions.set(Arc::clone(&extensions));
646        models.set_oauth_refresher(Arc::new(ComponentOAuth {
647            extensions: Arc::downgrade(&extensions),
648            models: models.clone(),
649        }));
650        forward_settings_changes(&extension_settings, &host);
651        start_model_refresh(&options, &extensions, &models).await;
652        Ok(Arc::new(Self {
653            cwd: options.cwd,
654            agent_dir: options.agent_dir,
655            docs_root: options.docs_root,
656            mode: options.mode,
657            has_ui: options.has_ui,
658            project_trusted: trusted,
659            settings,
660            extension_settings,
661            resources: Arc::new(RwLock::new(resources)),
662            models,
663            stream: Arc::new(ProviderStream::new(Arc::clone(&extensions))),
664            extensions,
665            host,
666            safe_mode,
667            flag_values: options.extension_flag_values,
668            diagnostics,
669        }))
670    }
671
672    /// Sessions root: `--session-dir` > `OINO_SESSION_DIR` > setting `sessionDir` >
673    /// `<agent>/sessions`.
674    #[must_use]
675    pub fn sessions_root(&self, cli: Option<&Path>) -> PathBuf {
676        if let Some(dir) = cli {
677            return dir.to_path_buf();
678        }
679        if let Some(dir) = std::env::var_os("OINO_SESSION_DIR").filter(|d| !d.is_empty()) {
680            return PathBuf::from(dir);
681        }
682        let setting = lock(&self.settings).settings().session_dir();
683        setting.map_or_else(
684            || self.agent_dir.join("sessions"),
685            |dir| {
686                if dir.is_absolute() {
687                    dir
688                } else {
689                    self.cwd.join(dir)
690                }
691            },
692        )
693    }
694}
695
696/// Initialises instances in load order, registering their providers, APIs and virtual models.
697async fn init_extensions(
698    instances: Vec<Arc<dyn ExtensionInstance>>,
699    cx: &CallContext,
700    models: &ModelRegistry,
701    settings: &ExtensionSettings,
702    no_extensions: bool,
703    diagnostics: &mut Vec<String>,
704) -> Vec<Arc<LoadedExtension>> {
705    let mut loaded: Vec<Arc<LoadedExtension>> = Vec::new();
706    for instance in instances {
707        if no_extensions && instance.kind() == ExtensionKind::Extension {
708            continue;
709        }
710        if loaded.iter().any(|l| l.instance.id() == instance.id()) {
711            diagnostics.push(format!("Duplicate extension id {} ignored", instance.id()));
712            continue;
713        }
714        match instance.init(cx.clone()).await {
715            Ok(mut registration) => {
716                // Render-only overrides of built-in tools need `tools.override` (U1–U2);
717                // specs for extension tools and custom messages are always allowed.
718                let overrides = instance.tool_overrides();
719                registration.renderers.retain(|spec| {
720                    let allowed = crate::session::check_render_override(spec, &overrides).is_ok();
721                    if !allowed {
722                        diagnostics.push(format!(
723                            "{}: render spec {spec} ignored (tools.override)",
724                            instance.id()
725                        ));
726                    }
727                    allowed
728                });
729                for api in &registration.apis {
730                    models.register_api_implementor(api, instance.id());
731                }
732                for provider in &registration.providers {
733                    match serde_json::from_value::<ProviderRegistration>(provider.clone()) {
734                        Ok(registration) => {
735                            if let Err(error) =
736                                models.register_provider(instance.id(), registration)
737                            {
738                                diagnostics.push(format!("{}: {error}", instance.id()));
739                            }
740                        }
741                        Err(error) => diagnostics
742                            .push(format!("{}: invalid provider: {error}", instance.id())),
743                    }
744                }
745                for namespace in &registration.settings {
746                    if let Err(error) = settings.register(instance.id(), namespace.clone()) {
747                        diagnostics.push(format!("{}: settings: {error}", instance.id()));
748                    }
749                }
750                for definition in &registration.virtual_models {
751                    match serde_json::from_value(definition.clone()) {
752                        Ok(definition) => {
753                            if let Err(error) =
754                                models.register_virtual_model(instance.id(), definition)
755                            {
756                                diagnostics.push(format!("{}: {error}", instance.id()));
757                            }
758                        }
759                        Err(error) => diagnostics
760                            .push(format!("{}: invalid virtual model: {error}", instance.id())),
761                    }
762                }
763                loaded.push(Arc::new(LoadedExtension::new(instance, registration)));
764            }
765            Err(error) => {
766                diagnostics.push(format!("{}: init failed: {}", instance.id(), error.message));
767            }
768        }
769    }
770    loaded
771}
772
773/// Forwards registry writes to the owning extensions (`oino_settings_changed`) until the
774/// registry or the router is gone.
775fn forward_settings_changes(registry: &ExtensionSettings, router: &Arc<HostRouter>) {
776    let Ok(handle) = tokio::runtime::Handle::try_current() else {
777        return;
778    };
779    let mut changes = registry.subscribe();
780    let router = Arc::downgrade(router);
781    handle.spawn(async move {
782        loop {
783            match changes.recv().await {
784                Ok(change) => {
785                    let Some(router) = router.upgrade() else {
786                        break;
787                    };
788                    router.deliver_settings_change(change).await;
789                }
790                Err(tokio::sync::broadcast::error::RecvError::Lagged(missed)) => {
791                    tracing::warn!("{missed} settings change notifications dropped");
792                }
793                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
794            }
795        }
796    });
797}
798
799/// Bridges `auth.json` OAuth refreshes to the registering component's `oauth-refresh`
800/// export (the component implementing the provider registration's `api`).
801struct ComponentOAuth {
802    extensions: Weak<ExtensionSet>,
803    models: ModelRegistry,
804}
805
806/// Deadline of one `oauth-refresh` call (15 s).
807const OAUTH_REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
808
809impl oino_models::OAuthRefresher for ComponentOAuth {
810    fn refresh<'a>(
811        &'a self,
812        provider: &'a str,
813        credential: JsonValue,
814    ) -> BoxFuture<'a, Result<JsonValue, String>> {
815        Box::pin(async move {
816            let extensions = self
817                .extensions
818                .upgrade()
819                .ok_or_else(|| "extensions are shut down".to_owned())?;
820            let registration = self
821                .models
822                .registration(provider)
823                .ok_or_else(|| format!("Unknown provider: {provider}"))?;
824            let component = provider_component(&extensions, &registration.api)
825                .ok_or_else(|| format!("No provider component for {}", registration.api))?;
826            let provider_instance = component
827                .instance
828                .provider()
829                .ok_or_else(|| format!("{provider}: not a provider"))?;
830            match tokio::time::timeout(
831                OAUTH_REFRESH_TIMEOUT,
832                provider_instance.oauth_refresh(provider, credential),
833            )
834            .await
835            {
836                Ok(result) => result.map_err(|e| e.message),
837                Err(_) => Err(format!("OAuth refresh for {provider} timed out")),
838            }
839        })
840    }
841}
842
843/// The provider component implementing `api` (first in load order).
844fn provider_component(extensions: &ExtensionSet, api: &str) -> Option<Arc<LoadedExtension>> {
845    extensions
846        .extensions()
847        .iter()
848        .find(|e| e.instance.provider().is_some() && e.state().apis.iter().any(|a| a == api))
849        .cloned()
850}
851
852/// Re-fetches the model list of one `models: "dynamic"` provider with `credential` (after
853/// `/login` or a new API key); other providers are left alone. Public for the embedding
854/// host's hub-level `set_api_key` (app spec §3.14 `host_cmd`, WP-A24).
855pub async fn refresh_provider_models(
856    extensions: &ExtensionSet,
857    models: &ModelRegistry,
858    provider_id: &str,
859    credential: Option<JsonValue>,
860) {
861    let Some(registration) = models.registration(provider_id) else {
862        return;
863    };
864    if !matches!(registration.models, oino_models::ProviderModels::Dynamic) {
865        return;
866    }
867    let Some(component) = provider_component(extensions, &registration.api) else {
868        return;
869    };
870    let Some(provider) = component.instance.provider() else {
871        return;
872    };
873    match tokio::time::timeout(
874        LIST_MODELS_TIMEOUT,
875        provider.list_models(provider_id, credential),
876    )
877    .await
878    {
879        Ok(Ok(list)) => {
880            if let Err(error) = models.set_provider_models(provider_id, &list) {
881                tracing::warn!(provider = %provider_id, "storing models failed: {error}");
882            }
883        }
884        Ok(Err(error)) => {
885            tracing::warn!(provider = %provider_id, "list-models failed: {}", error.message);
886        }
887        Err(_) => tracing::warn!(provider = %provider_id, "list-models timed out"),
888    }
889}
890
891/// Fetches `list-models` for providers registered with dynamic models.
892/// Upper bound for one provider's `list-models` call at startup.
893const LIST_MODELS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
894
895/// The startup model refresh of [`Services::create`]: awaited for terminal modes. An
896/// in-process app host (desktop/mobile, H-spec host.md §4.2) spawns it instead (see
897/// [`spawn_model_refresh`]) — its first session open, or a later wake in a new project
898/// cwd, must never wait on provider `list-models` probes, which dead local servers
899/// (ollama, lmstudio) can hold for the full [`LIST_MODELS_TIMEOUT`].
900async fn start_model_refresh(
901    options: &ServicesOptions,
902    extensions: &Arc<ExtensionSet>,
903    models: &ModelRegistry,
904) {
905    if options.flags.offline || offline_env() {
906        return;
907    }
908    if matches!(options.mode, ExtensionMode::Desktop | ExtensionMode::Mobile) {
909        spawn_model_refresh(Arc::clone(extensions), models.clone());
910    } else {
911        refresh_dynamic_models(extensions, models).await;
912    }
913}
914
915/// The app-host variant of the startup model refresh, spawned so [`Services::create`]
916/// never blocks; the refresh lands in the shared registry as it completes.
917fn spawn_model_refresh(extensions: Arc<ExtensionSet>, models: ModelRegistry) {
918    tokio::spawn(async move {
919        refresh_dynamic_models(&extensions, &models).await;
920    });
921}
922
923/// Fetches the model lists of `models: "dynamic"` providers concurrently. Providers that need
924/// an API key and have none configured are skipped (their models would be unavailable anyway);
925/// keyless providers (local servers) are probed with a short timeout.
926async fn refresh_dynamic_models(extensions: &ExtensionSet, models: &ModelRegistry) {
927    let mut calls = Vec::new();
928    for provider_id in models.registered_providers() {
929        let Some(registration) = models.registration(&provider_id) else {
930            continue;
931        };
932        if !matches!(registration.models, oino_models::ProviderModels::Dynamic) {
933            continue;
934        }
935        let Some(component) = extensions
936            .extensions()
937            .iter()
938            .find(|e| e.instance.provider().is_some() && e.state().apis.contains(&registration.api))
939            .cloned()
940        else {
941            continue;
942        };
943        let key = models.get_api_key(&provider_id).await;
944        let needs_key = !registration.auth_env.is_empty() || registration.oauth;
945        if needs_key && key.is_none() && !models.has_configured_auth(&provider_id) {
946            continue;
947        }
948        let credential = key.map(|key| json!({"type": "api_key", "key": key}));
949        calls.push(async move {
950            let provider = component.instance.provider()?;
951            let result = tokio::time::timeout(
952                LIST_MODELS_TIMEOUT,
953                provider.list_models(&provider_id, credential),
954            )
955            .await;
956            Some((provider_id, result))
957        });
958    }
959    for (provider_id, result) in futures::future::join_all(calls).await.into_iter().flatten() {
960        match result {
961            Ok(Ok(list)) => {
962                if let Err(error) = models.set_provider_models(&provider_id, &list) {
963                    tracing::warn!(provider = %provider_id, "storing models failed: {error}");
964                }
965            }
966            Ok(Err(error)) => {
967                tracing::warn!(provider = %provider_id, "list-models failed: {}", error.message);
968            }
969            Err(_) => tracing::warn!(provider = %provider_id, "list-models timed out"),
970        }
971    }
972}