Skip to main content

oino_core/extensions/
runner.rs

1//! Extension runner: registries and event dispatch with composition semantics
2//! (runtime.md §7.2).
3//!
4//! Handlers run sequentially in load order. Notification and transform handlers that fail
5//! are reported as `extension_error` and skipped (the chain keeps the previous value);
6//! `tool_call` and `user_bash` failures block. Every call has a deadline.
7
8use std::collections::HashSet;
9use std::panic::AssertUnwindSafe;
10use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
11use std::time::Duration;
12
13use futures::FutureExt;
14use indexmap::{IndexMap, IndexSet};
15use oino_ai::{AgentMessage, ImageContent, JsonObject, JsonValue};
16use serde_json::json;
17
18use super::api::{
19    ArbiterRoles, CallContext, CommandSpec, ExtensionCallError, ExtensionId, ExtensionInstance,
20    FlagSpec, Registration, ShortcutSpec, ToolSpec,
21};
22use crate::events::{ExtensionDisabledReason, ExtensionErrorEvent, OinoEvent};
23use crate::session::panic_message;
24
25/// Default handler deadline.
26pub const HOOK_DEADLINE: Duration = Duration::from_secs(5);
27/// `tool_call` deadline.
28pub const TOOL_CALL_DEADLINE: Duration = Duration::from_secs(30);
29/// `session_before_*` deadline.
30pub const SESSION_BEFORE_DEADLINE: Duration = Duration::from_secs(60);
31/// Upper bound of a manifest-raised handler deadline (runtime §7.2).
32pub const MAX_MANIFEST_DEADLINE: Duration = Duration::from_secs(300);
33/// Deadline of one `wait-reason` / `propose-nudge` call (runtime §7.6).
34pub const ARBITER_DEADLINE: Duration = Duration::from_secs(2);
35
36/// Deadline for one handler of `kind`.
37#[must_use]
38pub fn deadline_for(kind: &str) -> Duration {
39    if kind == "tool_call" {
40        TOOL_CALL_DEADLINE
41    } else if kind.starts_with("session_before_") {
42        SESSION_BEFORE_DEADLINE
43    } else {
44        HOOK_DEADLINE
45    }
46}
47
48fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
49    mutex.lock().unwrap_or_else(PoisonError::into_inner)
50}
51
52/// Receives extension failures (published as `extension_error`).
53pub trait ErrorSink: Send + Sync {
54    /// Reports one failure.
55    fn report(&self, error: ExtensionErrorEvent);
56
57    /// Reports that an extension was disabled for the session (X-TRAP), published as
58    /// `extension_disabled`.
59    fn disabled(&self, event: OinoEvent);
60}
61
62/// Mutable registrations of one extension (static + dynamic).
63#[derive(Clone, Debug, Default)]
64pub struct ExtensionState {
65    /// Tools by name.
66    pub tools: IndexMap<String, ToolSpec>,
67    /// Commands in registration order.
68    pub commands: Vec<CommandSpec>,
69    /// Shortcuts by id.
70    pub shortcuts: IndexMap<String, ShortcutSpec>,
71    /// Flags by name.
72    pub flags: IndexMap<String, FlagSpec>,
73    /// Subscribed event kinds.
74    pub events: IndexSet<String>,
75    /// Render specs.
76    pub renderers: Vec<JsonValue>,
77    /// Implemented provider APIs.
78    pub apis: Vec<String>,
79    /// Turn arbiter roles.
80    pub arbiter: ArbiterRoles,
81}
82
83impl From<Registration> for ExtensionState {
84    fn from(registration: Registration) -> Self {
85        Self {
86            tools: registration
87                .tools
88                .into_iter()
89                .map(|tool| (tool.name.clone(), tool))
90                .collect(),
91            commands: registration.commands,
92            shortcuts: registration
93                .shortcuts
94                .into_iter()
95                .map(|s| (s.id.clone(), s))
96                .collect(),
97            flags: registration
98                .flags
99                .into_iter()
100                .map(|f| (f.name.clone(), f))
101                .collect(),
102            events: registration.events.into_iter().collect(),
103            renderers: registration.renderers,
104            apis: registration.apis,
105            arbiter: registration.arbiter,
106        }
107    }
108}
109
110/// A loaded extension and its registrations.
111pub struct LoadedExtension {
112    /// The instance.
113    pub instance: Arc<dyn ExtensionInstance>,
114    state: Mutex<ExtensionState>,
115}
116
117impl std::fmt::Debug for LoadedExtension {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("LoadedExtension")
120            .field("id", &self.instance.id())
121            .finish_non_exhaustive()
122    }
123}
124
125impl LoadedExtension {
126    /// Wraps an initialised instance.
127    #[must_use]
128    pub fn new(instance: Arc<dyn ExtensionInstance>, registration: Registration) -> Self {
129        Self {
130            instance,
131            state: Mutex::new(registration.into()),
132        }
133    }
134
135    /// Copy of the registrations.
136    #[must_use]
137    pub fn state(&self) -> ExtensionState {
138        lock(&self.state).clone()
139    }
140
141    /// Edits the registrations.
142    pub fn update<R>(&self, f: impl FnOnce(&mut ExtensionState) -> R) -> R {
143        f(&mut lock(&self.state))
144    }
145
146    fn subscribed(&self, kind: &str) -> bool {
147        lock(&self.state).events.contains(kind)
148    }
149}
150
151/// Every loaded extension in load order (shared by the sessions of one cwd).
152#[derive(Debug, Default)]
153pub struct ExtensionSet {
154    extensions: Vec<Arc<LoadedExtension>>,
155}
156
157impl ExtensionSet {
158    /// A set in load order.
159    #[must_use]
160    pub fn new(extensions: Vec<Arc<LoadedExtension>>) -> Self {
161        Self { extensions }
162    }
163
164    /// Extensions in load order.
165    #[must_use]
166    pub fn extensions(&self) -> &[Arc<LoadedExtension>] {
167        &self.extensions
168    }
169
170    /// Extension by id.
171    #[must_use]
172    pub fn get(&self, id: &str) -> Option<&Arc<LoadedExtension>> {
173        self.extensions.iter().find(|e| e.instance.id() == id)
174    }
175
176    /// Number of extensions.
177    #[must_use]
178    pub fn len(&self) -> usize {
179        self.extensions.len()
180    }
181
182    /// Whether no extension is loaded.
183    #[must_use]
184    pub fn is_empty(&self) -> bool {
185        self.extensions.is_empty()
186    }
187}
188
189/// A command with its resolved invocation name.
190#[derive(Clone, Debug)]
191pub struct ResolvedCommand {
192    /// Owning extension.
193    pub extension: Arc<LoadedExtension>,
194    /// Spec.
195    pub spec: CommandSpec,
196    /// `name`, or `name:N` on collisions.
197    pub invocation_name: String,
198}
199
200/// A registered tool and its owner.
201#[derive(Clone, Debug)]
202pub struct RegisteredTool {
203    /// Owning extension.
204    pub extension: Arc<LoadedExtension>,
205    /// Spec.
206    pub spec: ToolSpec,
207}
208
209/// Result of [`ExtensionRunner::emit_input`].
210#[derive(Clone, Debug, PartialEq)]
211pub enum InputOutcome {
212    /// Continue with the original input.
213    Continue,
214    /// Continue with replaced input.
215    Transform {
216        /// New text.
217        text: String,
218        /// New images.
219        images: Option<Vec<ImageContent>>,
220    },
221    /// An extension handled the input.
222    Handled,
223}
224
225/// Combined `before_agent_start` result.
226#[derive(Clone, Debug, PartialEq)]
227pub struct BeforeAgentStartOutcome {
228    /// Messages to inject (`{customType, content, display, details?}`), in order.
229    pub messages: Vec<JsonValue>,
230    /// Final `systemPromptOptions`.
231    pub system_prompt_options: JsonValue,
232    /// Forced system prompt (last wins).
233    pub system_prompt: Option<String>,
234}
235
236/// Result of a boundary dispatch (`turn_end`, `agent_before_settle`).
237#[derive(Clone, Debug, PartialEq)]
238pub struct BoundaryOutcome {
239    /// Drafts to commit.
240    pub entries: Vec<JsonValue>,
241    /// Ensure one more request.
242    pub continue_run: bool,
243}
244
245/// Collected `resources_discover` paths with their extension.
246#[derive(Clone, Debug, Default, PartialEq, Eq)]
247pub struct DiscoveredResources {
248    /// `(path, extension id)` skill paths.
249    pub skill_paths: Vec<(String, String)>,
250    /// Prompt template paths.
251    pub prompt_paths: Vec<(String, String)>,
252    /// Theme paths.
253    pub theme_paths: Vec<(String, String)>,
254}
255
256/// Per-session dispatcher over the shared [`ExtensionSet`].
257#[derive(Clone)]
258pub struct ExtensionRunner {
259    set: Arc<ExtensionSet>,
260    cx: CallContext,
261    errors: Arc<dyn ErrorSink>,
262    flag_values: Arc<Mutex<IndexMap<String, JsonValue>>>,
263    stale: Arc<Mutex<Option<String>>>,
264    dialogs: Option<crate::ui::UiBridge>,
265    /// Extensions disabled for this session (X-TRAP); shared by every clone of this
266    /// session's runner.
267    disabled: Arc<Mutex<IndexSet<ExtensionId>>>,
268}
269
270impl std::fmt::Debug for ExtensionRunner {
271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272        f.debug_struct("ExtensionRunner")
273            .field("extensions", &self.set.len())
274            .field("session", &self.cx.session_id)
275            .finish_non_exhaustive()
276    }
277}
278
279/// The `invalidate` default message (verbatim, product name substituted).
280pub const STALE_CONTEXT_MESSAGE: &str = "This extension ctx is stale after session replacement or reload. Do not use a captured Oino or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().";
281
282impl ExtensionRunner {
283    /// A runner for one session.
284    #[must_use]
285    pub fn new(
286        set: Arc<ExtensionSet>,
287        cx: CallContext,
288        errors: Arc<dyn ErrorSink>,
289        flag_values: IndexMap<String, JsonValue>,
290    ) -> Self {
291        Self {
292            set,
293            cx,
294            errors,
295            flag_values: Arc::new(Mutex::new(flag_values)),
296            stale: Arc::new(Mutex::new(None)),
297            dialogs: None,
298            disabled: Arc::new(Mutex::new(IndexSet::new())),
299        }
300    }
301
302    /// Pauses handler deadlines while the handler's extension has a dialog open on `ui`,
303    /// and withdraws its dialogs when a handler times out (GA-42).
304    #[must_use]
305    pub fn with_dialogs(mut self, ui: crate::ui::UiBridge) -> Self {
306        self.dialogs = Some(ui);
307        self
308    }
309
310    /// The extension set.
311    #[must_use]
312    pub fn set(&self) -> &Arc<ExtensionSet> {
313        &self.set
314    }
315
316    /// Base call context of this session.
317    #[must_use]
318    pub fn call_context(&self) -> &CallContext {
319        &self.cx
320    }
321
322    /// Replaces the base call context (UI binding, mode).
323    pub fn set_call_context(&mut self, cx: CallContext) {
324        self.cx = cx;
325    }
326
327    /// Marks contexts of this runner stale.
328    pub fn invalidate(&self, message: Option<&str>) {
329        let mut stale = lock(&self.stale);
330        if stale.is_none() {
331            *stale = Some(message.unwrap_or(STALE_CONTEXT_MESSAGE).to_owned());
332        }
333    }
334
335    /// The stale message once invalidated.
336    #[must_use]
337    pub fn stale_message(&self) -> Option<String> {
338        lock(&self.stale).clone()
339    }
340
341    /// Reports an error as `extension_error`.
342    pub fn emit_error(&self, extension_path: &str, event: &str, error: impl Into<String>) {
343        self.errors.report(ExtensionErrorEvent {
344            extension_path: extension_path.to_owned(),
345            event: event.to_owned(),
346            error: error.into(),
347            stack: None,
348        });
349    }
350
351    fn active(&self) -> impl Iterator<Item = &Arc<LoadedExtension>> {
352        self.set
353            .extensions()
354            .iter()
355            .filter(|extension| !self.is_disabled(extension.instance.id()))
356    }
357
358    fn handlers(&self, kind: &str) -> Vec<Arc<LoadedExtension>> {
359        self.active()
360            .filter(|extension| extension.subscribed(kind))
361            .cloned()
362            .collect()
363    }
364
365    /// Returns whether any handler is registered.
366    #[must_use]
367    pub fn has_handlers(&self, kind: &str) -> bool {
368        self.active().any(|extension| extension.subscribed(kind))
369    }
370
371    /// Whether `id` is disabled for this session (X-TRAP).
372    #[must_use]
373    pub fn is_disabled(&self, id: &str) -> bool {
374        lock(&self.disabled).contains(id)
375    }
376
377    /// Extensions disabled for this session (X-TRAP), in disable order.
378    #[must_use]
379    pub fn disabled_extensions(&self) -> Vec<ExtensionId> {
380        lock(&self.disabled).iter().cloned().collect()
381    }
382
383    /// Disables `extension` for this session (X-TRAP): one trap or panic is enough — cheap
384    /// and reversible, since the runner is per session and a session replacement starts
385    /// with a fresh one. The instance-level circuit breaker (3 restarts / 10 min → disabled
386    /// for the process, instance.rs) stays as the last resort.
387    fn disable(&self, extension: &LoadedExtension, reason: ExtensionDisabledReason) {
388        let id = extension.instance.id().to_owned();
389        if !lock(&self.disabled).insert(id.clone()) {
390            return; // already disabled for this session; the event was emitted
391        }
392        if let Some(ui) = &self.dialogs {
393            ui.cancel_for(&id);
394        }
395        self.errors.disabled(OinoEvent::ExtensionDisabled {
396            session_id: self.cx.session_id.clone(),
397            extension_id: id,
398            reason,
399        });
400    }
401
402    async fn call(
403        &self,
404        extension: &LoadedExtension,
405        kind: &str,
406        payload: JsonValue,
407    ) -> Result<Option<JsonValue>, ExtensionCallError> {
408        let deadline = extension.instance.hook_deadline(kind).map_or_else(
409            || deadline_for(kind),
410            |d| d.min(MAX_MANIFEST_DEADLINE).max(deadline_for(kind)),
411        );
412        let id = extension.instance.id();
413        let waiting = || {
414            self.dialogs
415                .as_ref()
416                .is_some_and(|ui| ui.pending_for(id) > 0)
417        };
418        // X-TRAP: a Rust-side panic is contained to this call instead of taking the task
419        // down; it becomes an `ExtensionCallError` and disables the extension for the
420        // session. The async block covers the synchronous prefix too — some instances
421        // run handler bodies while building the call future.
422        let outcome = AssertUnwindSafe(async {
423            let call = extension.instance.on_event(kind, payload, self.cx.clone());
424            crate::extensions::deadline::with_deadline(call, deadline, waiting).await
425        })
426        .catch_unwind()
427        .await;
428        match outcome {
429            Ok(Some(result)) => {
430                if let Err(error) = &result
431                    && error.trap
432                {
433                    self.disable(extension, ExtensionDisabledReason::Trap);
434                }
435                result
436            }
437            Ok(None) => {
438                if let Some(ui) = &self.dialogs {
439                    ui.cancel_for(id);
440                }
441                Err(ExtensionCallError::new(format!(
442                    "Extension handler for {kind} timed out after {}ms",
443                    deadline.as_millis()
444                )))
445            }
446            Err(panic) => {
447                let error = ExtensionCallError {
448                    message: format!("extension panicked: {}", panic_message(&*panic)),
449                    trap: true,
450                };
451                self.disable(extension, ExtensionDisabledReason::Panic);
452                Err(error)
453            }
454        }
455    }
456
457    fn report(&self, extension: &LoadedExtension, kind: &str, error: &ExtensionCallError) {
458        self.emit_error(&extension.instance.path(), kind, error.message.clone());
459    }
460
461    /// Generic dispatch: notification events, plus the `session_before_*` gates
462    /// where the first result with `cancel: true` stops dispatch and the last result wins.
463    pub async fn emit(&self, payload: JsonValue) -> Option<JsonValue> {
464        let kind = payload["type"].as_str().unwrap_or_default().to_owned();
465        let gate = kind.starts_with("session_before_");
466        let mut result = None;
467        for extension in self.handlers(&kind) {
468            match self.call(&extension, &kind, payload.clone()).await {
469                Ok(Some(value)) if gate && !value.is_null() => {
470                    let cancel = value.get("cancel") == Some(&JsonValue::Bool(true));
471                    result = Some(value);
472                    if cancel {
473                        return result;
474                    }
475                }
476                Ok(_) => {}
477                Err(error) => self.report(&extension, &kind, &error),
478            }
479        }
480        result
481    }
482
483    /// Consumable dispatch (`oino_editor` intents, ui-protocol §7.2): `true` once a handler
484    /// returns `{consume: true}`; later handlers are not called.
485    pub async fn emit_consumable(&self, payload: JsonValue) -> bool {
486        let kind = payload["type"].as_str().unwrap_or_default().to_owned();
487        for extension in self.handlers(&kind) {
488            match self.call(&extension, &kind, payload.clone()).await {
489                Ok(Some(value)) if value.get("consume") == Some(&JsonValue::Bool(true)) => {
490                    return true;
491                }
492                Ok(_) => {}
493                Err(error) => self.report(&extension, &kind, &error),
494            }
495        }
496        false
497    }
498
499    /// Chain over one payload field: each handler's returned `{field: value}` replaces the
500    /// field for the next handler (`before_provider_headers`, `before_provider_request`).
501    /// Returns the final field value.
502    pub async fn emit_chain(&self, mut payload: JsonValue, field: &str) -> JsonValue {
503        let kind = payload["type"].as_str().unwrap_or_default().to_owned();
504        for extension in self.handlers(&kind) {
505            match self.call(&extension, &kind, payload.clone()).await {
506                Ok(Some(value)) => {
507                    if let Some(next) = value.get(field) {
508                        payload[field] = next.clone();
509                    } else if !value.is_object() {
510                        payload[field] = value;
511                    }
512                }
513                Ok(None) => {}
514                Err(error) => self.report(&extension, &kind, &error),
515            }
516        }
517        payload[field].take()
518    }
519
520    /// `tool_call` gate: the first `block` result wins; a failing handler blocks with
521    /// `Extension error: {message}`. Returned `input` replaces the arguments (Oino).
522    pub async fn emit_tool_call(&self, mut payload: JsonValue) -> Option<JsonValue> {
523        let mut result: Option<JsonValue> = None;
524        for extension in self.handlers("tool_call") {
525            match self.call(&extension, "tool_call", payload.clone()).await {
526                Ok(Some(value)) if value.is_object() => {
527                    if let Some(input) = value.get("input").filter(|v| v.is_object()) {
528                        payload["input"] = input.clone();
529                    }
530                    let block = value.get("block") == Some(&JsonValue::Bool(true));
531                    result = Some(value);
532                    if block {
533                        return result;
534                    }
535                }
536                Ok(_) => {}
537                Err(error) => {
538                    self.report(&extension, "tool_call", &error);
539                    return Some(json!({
540                        "block": true,
541                        "reason": format!("Extension error: {}", error.message),
542                    }));
543                }
544            }
545        }
546        if let (Some(result), Some(input)) = (result.as_mut(), payload.get("input"))
547            && result.get("input").is_some()
548        {
549            result["input"] = input.clone();
550        }
551        result
552    }
553
554    /// `tool_result` chain: returns the combined override, if any.
555    pub async fn emit_tool_result(&self, mut event: JsonObject) -> Option<JsonObject> {
556        let mut modified = false;
557        for extension in self.handlers("tool_result") {
558            let result = self
559                .call(&extension, "tool_result", JsonValue::Object(event.clone()))
560                .await;
561            let update = match result {
562                Ok(Some(JsonValue::Object(update))) => update,
563                Ok(_) => continue,
564                Err(error) => {
565                    self.report(&extension, "tool_result", &error);
566                    continue;
567                }
568            };
569            if let Some(content) = update.get("content").filter(|v| !v.is_null()) {
570                event.insert("content".into(), content.clone());
571                if update
572                    .get("structuredContent")
573                    .is_none_or(JsonValue::is_null)
574                {
575                    event.shift_remove("structuredContent");
576                }
577                modified = true;
578            }
579            for key in ["details", "structuredContent", "isError", "usage"] {
580                if let Some(value) = update.get(key).filter(|v| !v.is_null()) {
581                    event.insert(key.into(), value.clone());
582                    modified = true;
583                }
584            }
585        }
586        modified.then(|| {
587            [
588                "content",
589                "details",
590                "structuredContent",
591                "isError",
592                "usage",
593            ]
594            .into_iter()
595            .filter_map(|key| event.get(key).map(|v| (key.to_owned(), v.clone())))
596            .collect()
597        })
598    }
599
600    /// `message_end` chain: handlers may return a replacement with the same role.
601    pub async fn emit_message_end(&self, message: &AgentMessage) -> Option<AgentMessage> {
602        let mut current = message.clone();
603        let mut modified = false;
604        for extension in self.handlers("message_end") {
605            let payload = json!({"type": "message_end", "message": current});
606            match self.call(&extension, "message_end", payload).await {
607                Ok(Some(value)) => {
608                    let Some(replacement) = value.get("message").filter(|m| !m.is_null()) else {
609                        continue;
610                    };
611                    let mut replacement = replacement.clone();
612                    let role = replacement.get("role").and_then(JsonValue::as_str);
613                    if role != Some(role_of(&current)) {
614                        self.emit_error(
615                            &extension.instance.path(),
616                            "message_end",
617                            "message_end handlers must return a message with the same role",
618                        );
619                        continue;
620                    }
621                    if replacement.get("content").is_none_or(JsonValue::is_null)
622                        && matches!(role, Some("user" | "assistant" | "toolResult" | "custom"))
623                    {
624                        replacement["content"] = json!([]);
625                    }
626                    match serde_json::from_value::<AgentMessage>(replacement) {
627                        Ok(parsed) => {
628                            current = parsed;
629                            modified = true;
630                        }
631                        Err(error) => {
632                            self.emit_error(
633                                &extension.instance.path(),
634                                "message_end",
635                                error.to_string(),
636                            );
637                        }
638                    }
639                }
640                Ok(None) => {}
641                Err(error) => self.report(&extension, "message_end", &error),
642            }
643        }
644        modified.then_some(current)
645    }
646
647    /// `input` chain: `transform` replaces text/images for the next handler, `handled` stops.
648    pub async fn emit_input(
649        &self,
650        text: &str,
651        images: Option<&[ImageContent]>,
652        source: &str,
653        streaming_behavior: Option<&str>,
654    ) -> InputOutcome {
655        let mut current_text = text.to_owned();
656        let mut current_images: Option<Vec<ImageContent>> = images.map(<[ImageContent]>::to_vec);
657        let original_images = current_images.clone();
658        for extension in self.handlers("input") {
659            let mut payload = json!({"type": "input", "text": current_text, "source": source});
660            if let Some(images) = &current_images {
661                payload["images"] = images_json(images);
662            }
663            if let Some(behavior) = streaming_behavior {
664                payload["streamingBehavior"] = json!(behavior);
665            }
666            match self.call(&extension, "input", payload).await {
667                Ok(Some(value)) => match value.get("action").and_then(JsonValue::as_str) {
668                    Some("handled") => return InputOutcome::Handled,
669                    Some("transform") => {
670                        if let Some(text) = value.get("text").and_then(JsonValue::as_str) {
671                            text.clone_into(&mut current_text);
672                        }
673                        if let Some(images) = value
674                            .get("images")
675                            .filter(|v| !v.is_null())
676                            .and_then(|v| serde_json::from_value(v.clone()).ok())
677                        {
678                            current_images = Some(images);
679                        }
680                    }
681                    _ => {}
682                },
683                Ok(None) => {}
684                Err(error) => self.report(&extension, "input", &error),
685            }
686        }
687        if current_text != text || current_images != original_images {
688            InputOutcome::Transform {
689                text: current_text,
690                images: current_images,
691            }
692        } else {
693            InputOutcome::Continue
694        }
695    }
696
697    /// `context` then `context_with_system`.
698    pub async fn emit_context(&self, messages: Vec<AgentMessage>) -> Vec<AgentMessage> {
699        let mut current = messages;
700        for extension in self.handlers("context") {
701            let visible: Vec<AgentMessage> = current
702                .iter()
703                .filter(|m| !matches!(m, AgentMessage::System(_)))
704                .cloned()
705                .collect();
706            let payload = json!({"type": "context", "messages": visible});
707            match self.call(&extension, "context", payload).await {
708                Ok(Some(value)) => {
709                    let Some(returned) = value
710                        .get("messages")
711                        .filter(|v| !v.is_null())
712                        .and_then(|v| serde_json::from_value::<Vec<AgentMessage>>(v.clone()).ok())
713                    else {
714                        continue;
715                    };
716                    if returned == visible {
717                        continue;
718                    }
719                    let head = crate::session::current_system_message(&current);
720                    current = head
721                        .map(AgentMessage::System)
722                        .into_iter()
723                        .chain(returned)
724                        .collect();
725                }
726                Ok(None) => {}
727                Err(error) => self.report(&extension, "context", &error),
728            }
729        }
730        for extension in self.handlers("context_with_system") {
731            let had_head = matches!(current.first(), Some(AgentMessage::System(_)));
732            let payload = json!({"type": "context_with_system", "messages": current});
733            match self.call(&extension, "context_with_system", payload).await {
734                Ok(Some(value)) => {
735                    if let Some(returned) = value
736                        .get("messages")
737                        .filter(|v| !v.is_null())
738                        .and_then(|v| serde_json::from_value::<Vec<AgentMessage>>(v.clone()).ok())
739                    {
740                        current = returned;
741                    }
742                    if had_head && !matches!(current.first(), Some(AgentMessage::System(_))) {
743                        self.emit_error(&extension.instance.path(), "context_with_system", "Handler removed the leading system message; the request has no prompt or initial tool declarations. Keep it at index 0 or replace a dropped prefix with getCurrentSystemMessage().");
744                    }
745                }
746                Ok(None) => {}
747                Err(error) => self.report(&extension, "context_with_system", &error),
748            }
749        }
750        current
751    }
752
753    /// `before_agent_start`: `systemPromptOptions` chain (returned options replace the
754    /// current ones), `message`s accumulate, `systemPrompt` last wins. `render` renders the
755    /// current options for the event's `systemPrompt` field.
756    pub async fn emit_before_agent_start(
757        &self,
758        trigger: JsonObject,
759        options: JsonValue,
760        render: &(dyn Fn(&JsonValue) -> String + Send + Sync),
761    ) -> BeforeAgentStartOutcome {
762        let mut current = options;
763        let mut messages = Vec::new();
764        let mut forced = None;
765        for extension in self.handlers("before_agent_start") {
766            let mut payload = trigger.clone();
767            payload.insert("type".into(), json!("before_agent_start"));
768            payload.insert(
769                "systemPrompt".into(),
770                json!(forced.clone().unwrap_or_else(|| render(&current))),
771            );
772            payload.insert("systemPromptOptions".into(), current.clone());
773            match self
774                .call(&extension, "before_agent_start", JsonValue::Object(payload))
775                .await
776            {
777                Ok(Some(value)) => {
778                    if let Some(message) = value.get("message").filter(|m| m.is_object()) {
779                        messages.push(message.clone());
780                    }
781                    if let Some(options) =
782                        value.get("systemPromptOptions").filter(|o| o.is_object())
783                    {
784                        current = options.clone();
785                    }
786                    if let Some(prompt) = value.get("systemPrompt").and_then(JsonValue::as_str) {
787                        forced = Some(prompt.to_owned());
788                    }
789                }
790                Ok(None) => {}
791                Err(error) => self.report(&extension, "before_agent_start", &error),
792            }
793        }
794        BeforeAgentStartOutcome {
795            messages,
796            system_prompt_options: current,
797            system_prompt: forced,
798        }
799    }
800
801    /// `resources_discover`: every result merged in order.
802    pub async fn emit_resources_discover(&self, cwd: &str, reason: &str) -> DiscoveredResources {
803        let mut found = DiscoveredResources::default();
804        for extension in self.handlers("resources_discover") {
805            let payload = json!({"type": "resources_discover", "cwd": cwd, "reason": reason});
806            match self.call(&extension, "resources_discover", payload).await {
807                Ok(Some(value)) => {
808                    let id = extension.instance.id().to_owned();
809                    let collect = |key: &str, into: &mut Vec<(String, String)>| {
810                        for path in value
811                            .get(key)
812                            .and_then(JsonValue::as_array)
813                            .into_iter()
814                            .flatten()
815                        {
816                            if let Some(path) = path.as_str() {
817                                into.push((path.to_owned(), id.clone()));
818                            }
819                        }
820                    };
821                    collect("skillPaths", &mut found.skill_paths);
822                    collect("promptPaths", &mut found.prompt_paths);
823                    collect("themePaths", &mut found.theme_paths);
824                }
825                Ok(None) => {}
826                Err(error) => self.report(&extension, "resources_discover", &error),
827            }
828        }
829        found
830    }
831
832    /// Boundary dispatch. `context` rebuilds the preview after each
833    /// handler; an invalid draft list discards everything.
834    pub async fn emit_boundary(
835        &self,
836        base: JsonObject,
837        context: &(dyn Fn(&[JsonValue]) -> Result<JsonValue, String> + Send + Sync),
838    ) -> BoundaryOutcome {
839        let kind = base
840            .get("type")
841            .and_then(JsonValue::as_str)
842            .unwrap_or_default()
843            .to_owned();
844        let mut entries: Vec<JsonValue> = Vec::new();
845        let mut continue_run = false;
846        let mut preview = context(&entries).unwrap_or(JsonValue::Null);
847        let mut valid = true;
848        for extension in self.handlers(&kind) {
849            let mut payload = base.clone();
850            payload.insert("entries".into(), JsonValue::Array(entries.clone()));
851            payload.insert("continue".into(), JsonValue::Bool(continue_run));
852            payload.insert("context".into(), preview.clone());
853            match self
854                .call(&extension, &kind, JsonValue::Object(payload))
855                .await
856            {
857                Ok(Some(value)) => {
858                    if let Some(list) = value.get("entries").and_then(JsonValue::as_array) {
859                        entries.clone_from(list);
860                    }
861                    if let Some(flag) = value.get("continue").and_then(JsonValue::as_bool) {
862                        continue_run = flag;
863                    }
864                }
865                Ok(None) => {}
866                Err(error) => self.report(&extension, &kind, &error),
867            }
868            match context(&entries) {
869                Ok(next) => {
870                    preview = next;
871                    valid = true;
872                }
873                Err(error) => {
874                    valid = false;
875                    self.emit_error(
876                        &extension.instance.path(),
877                        &kind,
878                        format!("Invalid boundary entries: {error}"),
879                    );
880                }
881            }
882        }
883        if valid {
884            BoundaryOutcome {
885                entries,
886                continue_run,
887            }
888        } else {
889            BoundaryOutcome {
890                entries: Vec::new(),
891                continue_run: false,
892            }
893        }
894    }
895
896    /// `user_bash` gate: the first `{result}` wins; failures block (`Err`). `{operations}`
897    /// cannot be supplied by WASM extensions and is treated as no result (Oino delta).
898    ///
899    /// # Errors
900    ///
901    /// The failing handler's error text.
902    pub async fn emit_user_bash(&self, payload: JsonValue) -> Result<Option<JsonValue>, String> {
903        for extension in self.handlers("user_bash") {
904            match self.call(&extension, "user_bash", payload.clone()).await {
905                Ok(Some(value)) => {
906                    if let Some(result) = value.get("result").filter(|r| r.is_object()) {
907                        return Ok(Some(result.clone()));
908                    }
909                }
910                Ok(None) => {}
911                Err(error) => {
912                    self.report(&extension, "user_bash", &error);
913                    return Err(error.message);
914                }
915            }
916        }
917        Ok(None)
918    }
919
920    /// `cache_warming_decision`: the last returned `action` wins.
921    pub async fn emit_cache_warming_decision(&self, payload: JsonValue, action: String) -> String {
922        let mut action = action;
923        for extension in self.handlers("cache_warming_decision") {
924            match self
925                .call(&extension, "cache_warming_decision", payload.clone())
926                .await
927            {
928                Ok(Some(value)) => {
929                    if let Some(next) = value.get("action").and_then(JsonValue::as_str) {
930                        next.clone_into(&mut action);
931                    }
932                }
933                Ok(None) => {}
934                Err(error) => self.report(&extension, "cache_warming_decision", &error),
935            }
936        }
937        action
938    }
939
940    /// Arbiter wait sources (runtime §7.6): asks every `wait-source` concurrently (2 s each)
941    /// and returns the reasons in load order.
942    pub async fn arbiter_wait_reasons(&self) -> Vec<String> {
943        let sources: Vec<Arc<LoadedExtension>> = self
944            .active()
945            .filter(|e| e.state().arbiter.wait_source)
946            .cloned()
947            .collect();
948        let calls = sources.iter().map(|extension| {
949            let cx = self.cx.clone();
950            async move {
951                tokio::time::timeout(ARBITER_DEADLINE, extension.instance.wait_reason(cx))
952                    .await
953                    .ok()
954                    .flatten()
955            }
956        });
957        futures::future::join_all(calls)
958            .await
959            .into_iter()
960            .flatten()
961            .filter(|reason| !reason.trim().is_empty())
962            .collect()
963    }
964
965    /// Arbiter nudge (runtime §7.6): asks every `nudge-provider` concurrently (2 s each),
966    /// picks the highest `priority` (ties: earliest loaded), tells the winner
967    /// `nudge-delivered(id)` and returns its proposal.
968    pub async fn arbiter_nudge(&self) -> Option<JsonValue> {
969        let providers: Vec<Arc<LoadedExtension>> = self
970            .active()
971            .filter(|e| e.state().arbiter.nudge_provider)
972            .cloned()
973            .collect();
974        let calls = providers.iter().map(|extension| {
975            let cx = self.cx.clone();
976            async move {
977                tokio::time::timeout(ARBITER_DEADLINE, extension.instance.propose_nudge(cx))
978                    .await
979                    .ok()
980                    .flatten()
981                    .filter(|proposal| proposal.get("customType").is_some_and(JsonValue::is_string))
982            }
983        });
984        let proposals = futures::future::join_all(calls).await;
985        let priority = |p: &JsonValue| p.get("priority").and_then(JsonValue::as_f64).unwrap_or(0.0);
986        let mut winner: Option<(usize, JsonValue)> = None;
987        for (index, proposal) in proposals.into_iter().enumerate() {
988            let Some(proposal) = proposal else { continue };
989            if winner
990                .as_ref()
991                .is_none_or(|(_, best)| priority(&proposal) > priority(best))
992            {
993                winner = Some((index, proposal));
994            }
995        }
996        let (index, proposal) = winner?;
997        let id = proposal
998            .get("id")
999            .and_then(JsonValue::as_str)
1000            .unwrap_or_default()
1001            .to_owned();
1002        providers[index]
1003            .instance
1004            .nudge_delivered(&id, self.cx.clone());
1005        Some(proposal)
1006    }
1007
1008    /// Registered tools across extensions; the first registration per name wins.
1009    #[must_use]
1010    pub fn tools(&self) -> Vec<RegisteredTool> {
1011        let mut seen = HashSet::new();
1012        let mut tools = Vec::new();
1013        for extension in self.active() {
1014            for spec in extension.state().tools.into_values() {
1015                if seen.insert(spec.name.clone()) {
1016                    tools.push(RegisteredTool {
1017                        extension: Arc::clone(extension),
1018                        spec,
1019                    });
1020                }
1021            }
1022        }
1023        tools
1024    }
1025
1026    /// Commands with invocation names.
1027    #[must_use]
1028    pub fn commands(&self) -> Vec<ResolvedCommand> {
1029        let mut all = Vec::new();
1030        for extension in self.active() {
1031            for spec in extension.state().commands {
1032                all.push((Arc::clone(extension), spec));
1033            }
1034        }
1035        let mut counts: IndexMap<String, usize> = IndexMap::new();
1036        for (_, spec) in &all {
1037            *counts.entry(spec.name.clone()).or_default() += 1;
1038        }
1039        let mut seen: IndexMap<String, usize> = IndexMap::new();
1040        let mut taken = HashSet::new();
1041        all.into_iter()
1042            .map(|(extension, spec)| {
1043                let occurrence = {
1044                    let slot = seen.entry(spec.name.clone()).or_default();
1045                    *slot += 1;
1046                    *slot
1047                };
1048                let mut invocation_name = if counts.get(&spec.name).copied().unwrap_or(0) > 1 {
1049                    format!("{}:{occurrence}", spec.name)
1050                } else {
1051                    spec.name.clone()
1052                };
1053                let mut suffix = occurrence;
1054                while taken.contains(&invocation_name) {
1055                    suffix += 1;
1056                    invocation_name = format!("{}:{suffix}", spec.name);
1057                }
1058                taken.insert(invocation_name.clone());
1059                ResolvedCommand {
1060                    extension,
1061                    spec,
1062                    invocation_name,
1063                }
1064            })
1065            .collect()
1066    }
1067
1068    /// Returns the command matching an invocation name.
1069    #[must_use]
1070    pub fn command(&self, invocation_name: &str) -> Option<ResolvedCommand> {
1071        self.commands()
1072            .into_iter()
1073            .find(|command| command.invocation_name == invocation_name)
1074    }
1075
1076    /// Flags (first registration per name wins).
1077    #[must_use]
1078    pub fn flags(&self) -> IndexMap<String, FlagSpec> {
1079        let mut flags = IndexMap::new();
1080        for extension in self.active() {
1081            for (name, flag) in extension.state().flags {
1082                flags.entry(name).or_insert(flag);
1083            }
1084        }
1085        flags
1086    }
1087
1088    /// Flag value: CLI value, else the flag default.
1089    #[must_use]
1090    pub fn flag_value(&self, name: &str) -> Option<JsonValue> {
1091        lock(&self.flag_values)
1092            .get(name)
1093            .cloned()
1094            .or_else(|| self.flags().get(name).and_then(|f| f.default.clone()))
1095    }
1096
1097    /// Sets a flag value.
1098    pub fn set_flag_value(&self, name: &str, value: JsonValue) {
1099        lock(&self.flag_values).insert(name.to_owned(), value);
1100    }
1101
1102    /// Every flag value set on the CLI.
1103    #[must_use]
1104    pub fn flag_values(&self) -> IndexMap<String, JsonValue> {
1105        lock(&self.flag_values).clone()
1106    }
1107
1108    /// Shortcuts with their owners (later registrations of the same id win, like map keys).
1109    #[must_use]
1110    pub fn shortcuts(&self) -> Vec<(String, ShortcutSpec)> {
1111        let mut shortcuts: IndexMap<String, (String, ShortcutSpec)> = IndexMap::new();
1112        for extension in self.active() {
1113            for (id, spec) in extension.state().shortcuts {
1114                shortcuts.insert(id, (extension.instance.id().to_owned(), spec));
1115            }
1116        }
1117        shortcuts.into_values().collect()
1118    }
1119
1120    /// Every render spec in load order.
1121    #[must_use]
1122    pub fn renderers(&self) -> Vec<(String, JsonValue)> {
1123        self.active()
1124            .flat_map(|extension| {
1125                let id = extension.instance.id().to_owned();
1126                extension
1127                    .state()
1128                    .renderers
1129                    .into_iter()
1130                    .map(move |spec| (id.clone(), spec))
1131            })
1132            .collect()
1133    }
1134
1135    /// The provider component implementing `api` (first in load order).
1136    #[must_use]
1137    pub fn provider_for_api(&self, api: &str) -> Option<Arc<LoadedExtension>> {
1138        self.set
1139            .extensions()
1140            .iter()
1141            .find(|extension| {
1142                extension.instance.provider().is_some()
1143                    && extension.state().apis.iter().any(|a| a == api)
1144            })
1145            .cloned()
1146    }
1147}
1148
1149/// Role string of a message.
1150#[must_use]
1151pub fn role_of(message: &AgentMessage) -> &'static str {
1152    match message {
1153        AgentMessage::System(_) => "system",
1154        AgentMessage::User(_) => "user",
1155        AgentMessage::Assistant(_) => "assistant",
1156        AgentMessage::ToolResult(_) => "toolResult",
1157        AgentMessage::BashExecution(_) => "bashExecution",
1158        AgentMessage::Custom(_) => "custom",
1159        AgentMessage::BranchSummary(_) => "branchSummary",
1160        AgentMessage::CompactionSummary(_) => "compactionSummary",
1161    }
1162}
1163
1164fn images_json(images: &[ImageContent]) -> JsonValue {
1165    JsonValue::Array(
1166        images
1167            .iter()
1168            .map(|image| json!({"type": "image", "data": image.data, "mimeType": image.mime_type}))
1169            .collect(),
1170    )
1171}