Skip to main content

pi/core/
extension_host.rs

1//! Product-side [`ExtensionRunner`] over the pi-ext [`HostClient`].
2//!
3//! The bundled TypeScript extension host owns the real `ExtensionRunner`
4//! (the 15-hook merge table, mutable results, command dispatch, transform
5//! chains). Rust is the validation boundary: it sends **one** event request
6//! per hook and trusts only the validated typed response, converts the host's
7//! registration snapshot into pi-ext tool/provider adapters, pumps unsolicited
8//! tool/provider/uiSlot/error traffic into bounded typed subscribers, drops
9//! stale generations, isolates every host failure as a single non-retryable
10//! `extension_error`, and owns reload generation / slot invalidation /
11//! exactly-once shutdown.
12//!
13//! See the authoritative `agent://ExtensionPlan` for the locked boundary
14//! decisions. `AgentSession` never depends on `pi-ext` directly; it talks to
15//! this runner through the [`ExtensionRunner`] trait seam defined in
16//! [`super::agent_session::extension_runner`].
17
18use std::collections::{BTreeMap, HashMap, HashSet};
19use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
20use std::sync::{Arc, Mutex as StdMutex, RwLock};
21use std::time::Duration;
22
23use futures::future::BoxFuture;
24use pi_agent::{
25    AfterToolCallResult, AgentMessage, AgentTool, AgentToolResult, BeforeToolCallResult, ToolError,
26    ToolExecutionMode, ToolUpdates,
27};
28use pi_ai::{AssistantMessage, AssistantMessageEvent, ToolResultContent};
29use pi_ext::adapters::{
30    self, CommandRegistration, ExtensionAgentTool, ExtensionProvider, FlagRegistration,
31    ProviderRegistration, Registry, RendererRegistration, ShortcutRegistration, ToolRegistration,
32};
33use pi_ext::client::{HostClient, HostClientError, HostEvent, HostUiRequest, HostUiResponse};
34use pi_ext::host::{self, HostError, HostSpec};
35use pi_ext::protocol::{
36    self, DisposeSlot, ExtensionErrorEvent, FlagValueWire, FlagsSetRequest, FlagsSetResponse,
37    NotifyRequest, ProviderEvent, ShortcutExecuteRequest, ShortcutExecuteResponse, ToolUpdate,
38    UiEventRequest, UiEventResponse, UiSlot,
39};
40use pi_ext::sanitize::{SanitizedSlot, sanitize_slot};
41use serde::{Deserialize, Serialize};
42use serde_json::{Map, Value};
43use tokio::sync::{broadcast, mpsc, watch};
44use tokio_util::sync::CancellationToken;
45
46use super::agent_session::events::AgentSessionEvent;
47use super::agent_session::extension_runner::{
48    BeforeAgentStartResult, CancelResult, ExtensionRunner, InputTransformResult,
49};
50use super::model_runtime::{
51    ModelRuntime, ModelRuntimeError, ProviderConfigInput, ProviderModelDefinition,
52};
53use super::resources::{ExtensionResourcePath, ResourceExtensionPaths};
54
55/// Lifecycle hook deadline (control RPC).
56pub const HOOK_TIMEOUT: Duration = Duration::from_secs(30);
57
58/// Deadline for the `hello` handshake + extension load.
59pub const START_TIMEOUT: Duration = Duration::from_secs(30);
60
61/// Bounded capacity for the tool-update / provider-event / error broadcasts.
62pub const EVENT_CHANNEL_CAPACITY: usize = 256;
63
64/// Open method string: client requests the host's registration snapshot.
65pub const LOAD_METHOD: &str = "extensions.load";
66
67/// Open method string: dispatch a registered slash command.
68pub const COMMAND_EXECUTE_METHOD: &str = "command.execute";
69/// Private compact streaming update request. Extensions still observe `message_update`.
70pub const MESSAGE_UPDATE_DELTA_METHOD: &str = "message_update_delta";
71
72/// Open method string: render an extension tool call/result as HTML (export).
73pub const TOOL_RENDER_HTML_METHOD: &str = "tool.renderHtml";
74
75/// The 33 lifecycle event `type` discriminants mirrored from the reference
76/// `ExtensionAPI.on()` overloads. The host reports which of these have at
77/// least one handler; Rust gates IPC on that set.
78pub const ALL_EVENT_TYPES: &[&str] = &[
79    "project_trust",
80    "resources_discover",
81    "session_start",
82    "session_info_changed",
83    "session_before_switch",
84    "session_before_fork",
85    "session_before_compact",
86    "session_compact",
87    "session_shutdown",
88    "session_before_tree",
89    "session_tree",
90    "context",
91    "before_provider_request",
92    "before_provider_headers",
93    "after_provider_response",
94    "before_agent_start",
95    "agent_start",
96    "agent_end",
97    "agent_settled",
98    "turn_start",
99    "turn_end",
100    "message_start",
101    "message_update",
102    "message_end",
103    "tool_execution_start",
104    "tool_execution_update",
105    "tool_execution_end",
106    "model_select",
107    "thinking_level_select",
108    "tool_call",
109    "tool_result",
110    "user_bash",
111    "input",
112];
113
114/// Which phase of an extension tool to render as HTML.
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub enum ToolRenderPhase {
117    /// Render the tool-call invocation (`renderCall`).
118    Call,
119    /// Render the tool-result payload (`renderResult`).
120    Result,
121}
122
123impl ToolRenderPhase {
124    const fn as_str(self) -> &'static str {
125        match self {
126            Self::Call => "call",
127            Self::Result => "result",
128        }
129    }
130}
131
132/// Sanitized extension UI activity delivered to an active product mode.
133#[derive(Debug, Clone)]
134pub enum ExtensionUiEvent {
135    /// Fire-and-forget notification.
136    Notify(NotifyRequest),
137    /// Sanitized keyed slot update.
138    Slot(SanitizedSlot),
139    /// Keyed slot disposal.
140    Dispose {
141        /// Stable extension widget key to remove.
142        key: String,
143    },
144}
145
146/// Failure while starting the extension host.
147#[derive(Debug, thiserror::Error)]
148pub enum HostStartError {
149    /// No host executable could be resolved.
150    #[error("extension host not available: {0}")]
151    Resolve(#[from] HostError),
152    /// Product-policy acquisition of the pinned host asset failed.
153    #[error("extension host unavailable: {0}")]
154    Acquire(#[source] acquire::HostAcquireError),
155    /// The host process could not be spawned.
156    #[error("extension host spawn failed: {0}")]
157    Spawn(String),
158    /// The `hello` handshake failed (version mismatch or transport).
159    #[error("extension host handshake failed: {0}")]
160    Handshake(String),
161    /// The registration snapshot could not be loaded or decoded.
162    #[error("extension host load failed: {0}")]
163    Load(String),
164    /// Validated flags could not be synchronized to the host.
165    #[error("extension host flag synchronization failed: {0}")]
166    FlagSync(String),
167}
168
169impl From<HostClientError> for HostStartError {
170    fn from(value: HostClientError) -> Self {
171        match value {
172            HostClientError::Spawn { message } => Self::Spawn(message),
173            HostClientError::Handshake { message } => Self::Handshake(message),
174            other => Self::Load(other.to_string()),
175        }
176    }
177}
178
179impl From<acquire::HostAcquireError> for HostStartError {
180    fn from(value: acquire::HostAcquireError) -> Self {
181        match value {
182            // Preserve the long-standing resolve diagnostic verbatim.
183            acquire::HostAcquireError::Resolution(error) => Self::Resolve(error),
184            other => Self::Acquire(other),
185        }
186    }
187}
188
189// ---------------------------------------------------------------------------
190// Registration snapshot wire types (host → Rust load response)
191// ---------------------------------------------------------------------------
192
193#[derive(Serialize)]
194#[serde(rename_all = "camelCase")]
195struct ExtensionsLoadRequest<'a> {
196    extension_paths: &'a [String],
197    cwd: &'a str,
198    project_trusted: bool,
199}
200
201/// Wire form of [`ToolRegistration`] received from the host load response.
202#[derive(Debug, Clone, Deserialize)]
203#[serde(rename_all = "camelCase")]
204struct ToolWire {
205    name: String,
206    #[serde(default)]
207    label: String,
208    #[serde(default)]
209    description: String,
210    #[serde(default)]
211    parameters: Value,
212    #[serde(default)]
213    execution_mode: Option<pi_agent::ToolExecutionMode>,
214}
215
216/// Wire form of [`CommandRegistration`].
217#[derive(Debug, Clone, Deserialize)]
218#[serde(rename_all = "camelCase")]
219struct CommandWire {
220    name: String,
221    #[serde(default)]
222    description: Option<String>,
223    #[serde(default)]
224    source: Option<String>,
225}
226
227/// Wire form of [`ShortcutRegistration`].
228#[derive(Debug, Clone, Deserialize)]
229#[serde(rename_all = "camelCase")]
230struct ShortcutWire {
231    key: String,
232    #[serde(default)]
233    description: Option<String>,
234    #[serde(default)]
235    extension_path: Option<String>,
236}
237
238/// Wire form of [`FlagRegistration`] with its current resolved value.
239#[derive(Debug, Clone, Deserialize)]
240#[serde(rename_all = "camelCase")]
241struct FlagWire {
242    name: String,
243    #[serde(default)]
244    description: Option<String>,
245    #[serde(default, rename = "type")]
246    kind: Option<String>,
247    #[serde(default)]
248    default: Option<String>,
249    /// Currently resolved value (from CLI / settings), if any.
250    #[serde(default)]
251    value: Option<Value>,
252    #[serde(default)]
253    extension_path: Option<String>,
254}
255
256/// Wire form of [`RendererRegistration`].
257#[derive(Debug, Clone, Deserialize)]
258#[serde(rename_all = "camelCase")]
259struct RendererWire {
260    #[serde(default, rename = "type")]
261    kind: Option<String>,
262    name: String,
263}
264
265/// Wire form of a host-registered custom provider.
266///
267/// Matches the host's `buildRegistrySnapshot` camelCase payload: full
268/// `ProviderConfig` fields plus a boolean `streamSimple` flag (the function
269/// itself never crosses the wire; the host keeps it and Rust proxies via
270/// [`ExtensionProvider`] when the flag is true).
271#[derive(Debug, Clone, Deserialize)]
272#[serde(rename_all = "camelCase")]
273struct ProviderWire {
274    name: String,
275    /// Display name (`config.name` in TypeScript); wire key is `displayName`.
276    #[serde(default)]
277    display_name: Option<String>,
278    #[serde(default)]
279    base_url: Option<String>,
280    #[serde(default)]
281    api: Option<String>,
282    #[serde(default)]
283    api_key: Option<String>,
284    #[serde(default)]
285    headers: Option<BTreeMap<String, String>>,
286    #[serde(default)]
287    auth_header: Option<bool>,
288    #[serde(default)]
289    models: Option<Vec<ProviderModelDefinition>>,
290    /// `true` when the host holds a live `streamSimple` function for this provider.
291    #[serde(default)]
292    stream_simple: bool,
293    /// Optional extension path used in diagnostic messages when present.
294    #[serde(default)]
295    extension_path: Option<String>,
296}
297
298impl ProviderWire {
299    fn to_config_input(&self) -> ProviderConfigInput {
300        ProviderConfigInput {
301            name: self.display_name.clone(),
302            base_url: self.base_url.clone(),
303            api_key: self.api_key.clone(),
304            api: self.api.clone(),
305            headers: self.headers.clone(),
306            auth_header: self.auth_header,
307            models: self.models.clone(),
308            model_overrides: None,
309            oauth: None,
310        }
311    }
312}
313
314/// Full host registration snapshot returned by `extensions.load`.
315#[derive(Debug, Clone, Default, Deserialize)]
316#[serde(rename_all = "camelCase")]
317pub struct RegistrySnapshotWire {
318    /// Registered extension tools (host already applied first-wins).
319    #[serde(default)]
320    tools: Vec<ToolWire>,
321    /// Registered slash commands.
322    #[serde(default)]
323    commands: Vec<CommandWire>,
324    /// Registered keyboard shortcuts.
325    #[serde(default)]
326    shortcuts: Vec<ShortcutWire>,
327    /// Registered CLI flags with current values.
328    #[serde(default)]
329    flags: Vec<FlagWire>,
330    /// Registered renderers (message / tool / widget).
331    #[serde(default)]
332    renderers: Vec<RendererWire>,
333    /// Registered custom providers.
334    #[serde(default)]
335    providers: Vec<ProviderWire>,
336    /// Lifecycle event types with at least one handler installed.
337    #[serde(default)]
338    handlers: Vec<String>,
339    /// Whether `ui.onTerminalInput` has at least one active handler.
340    #[serde(default)]
341    terminal_input: bool,
342    /// Number of extensions successfully loaded (host diagnostic field).
343    #[serde(default)]
344    extensions: Option<u64>,
345    /// Per-path load errors (sibling isolation).
346    #[serde(default)]
347    errors: Vec<LoadErrorWire>,
348}
349
350/// Per-extension load error from the host snapshot.
351#[derive(Debug, Clone, Default, Deserialize)]
352#[serde(rename_all = "camelCase")]
353struct LoadErrorWire {
354    #[serde(default)]
355    path: Option<String>,
356    #[serde(default)]
357    error: Option<String>,
358    #[serde(default)]
359    message: Option<String>,
360}
361
362/// Built registry snapshot: pi-ext [`Registry`] plus ready tool/provider
363/// adapters and the handler-presence set.
364#[derive(Default)]
365struct RegistrySnapshot {
366    /// Aggregate registrations (first-wins dedup applied on build).
367    registry: Registry,
368    /// Ordered, undeduplicated shortcut registrations for product last-wins resolution.
369    raw_shortcuts: Vec<ShortcutRegistration>,
370    /// Extension tool adapters keyed by tool name.
371    tools: HashMap<String, Arc<dyn AgentTool>>,
372    /// Lifecycle event types with at least one handler.
373    handlers: HashSet<String>,
374    /// Whether terminal input must be offered to the host before native dispatch.
375    terminal_input: bool,
376    /// Resolved flag values (host value if present, else default).
377    flag_values: HashMap<String, Value>,
378    /// Provider config inputs keyed by provider id (for `ModelRuntime` registration).
379    provider_configs: HashMap<String, ProviderConfigInput>,
380    /// Provider ids that expose a host-side `streamSimple` handler.
381    stream_provider_ids: HashSet<String>,
382    /// Optional extension path per provider (diagnostics).
383    provider_extension_paths: HashMap<String, String>,
384    /// Host-reported per-path load errors.
385    load_errors: Vec<(String, String)>,
386}
387
388fn build_snapshot(wire: RegistrySnapshotWire, client: &Arc<HostClient>) -> RegistrySnapshot {
389    let mut snapshot = RegistrySnapshot {
390        terminal_input: wire.terminal_input,
391        ..RegistrySnapshot::default()
392    };
393
394    for tool in wire.tools {
395        let meta = ToolRegistration {
396            name: tool.name.clone(),
397            label: tool.label,
398            description: tool.description,
399            parameters: tool.parameters,
400            execution_mode: tool.execution_mode,
401        };
402        // First registration wins (host already dedups; this is the Rust-side
403        // trust boundary for a duplicated name).
404        if snapshot.registry.register_tool(meta.clone()) {
405            let adapter = ExtensionAgentTool::new(meta, Arc::clone(client));
406            snapshot
407                .tools
408                .insert(adapter.name().to_owned(), Arc::new(adapter));
409        }
410    }
411
412    for command in wire.commands {
413        let _ = snapshot.registry.register_command(CommandRegistration {
414            name: command.name,
415            description: command.description,
416            source: command.source,
417        });
418    }
419
420    for shortcut in wire.shortcuts {
421        let registration = ShortcutRegistration {
422            key: shortcut.key,
423            description: shortcut.description,
424            extension_path: shortcut.extension_path,
425        };
426        snapshot.raw_shortcuts.push(registration.clone());
427        let _ = snapshot.registry.register_shortcut(registration);
428    }
429
430    for flag in wire.flags {
431        if snapshot.registry.register_flag(FlagRegistration {
432            name: flag.name.clone(),
433            description: flag.description,
434            kind: match flag.kind.as_deref() {
435                Some("boolean") => adapters::FlagKind::Boolean,
436                _ => adapters::FlagKind::String,
437            },
438            default: flag.default.clone(),
439            extension_path: flag.extension_path,
440        }) {
441            // First-wins: prefer the host-resolved value, fall back to default.
442            let value = flag
443                .value
444                .clone()
445                .or_else(|| flag.default.map(Value::String))
446                .unwrap_or_else(|| Value::String(String::new()));
447            snapshot.flag_values.insert(flag.name, value);
448        }
449    }
450
451    for renderer in wire.renderers {
452        let _ = snapshot.registry.register_renderer(RendererRegistration {
453            kind: match renderer.kind.as_deref() {
454                Some("tool") => adapters::RendererKind::Tool,
455                Some("widget") => adapters::RendererKind::Widget,
456                _ => adapters::RendererKind::Message,
457            },
458            name: renderer.name,
459        });
460    }
461
462    for provider in wire.providers {
463        let name = provider.name.clone();
464        let stream_simple = provider.stream_simple;
465        let extension_path = provider.extension_path.clone();
466        let config = provider.to_config_input();
467        if snapshot.registry.register_provider(ProviderRegistration {
468            name: name.clone(),
469            base_url: config.base_url.clone(),
470            api: config.api.clone(),
471        }) {
472            snapshot.provider_configs.insert(name.clone(), config);
473            if stream_simple {
474                snapshot.stream_provider_ids.insert(name.clone());
475            }
476            if let Some(path) = extension_path {
477                snapshot.provider_extension_paths.insert(name, path);
478            }
479        }
480    }
481
482    for err in wire.errors {
483        let path = err.path.unwrap_or_else(|| "<unknown>".to_owned());
484        let message = err
485            .error
486            .or(err.message)
487            .unwrap_or_else(|| "extension load failed".to_owned());
488        snapshot.load_errors.push((path, message));
489    }
490
491    snapshot.handlers = wire.handlers.into_iter().collect();
492    let _ = wire.extensions;
493    snapshot
494}
495
496// ---------------------------------------------------------------------------
497// Hook response wire types (validated typed responses trusted by Rust)
498// ---------------------------------------------------------------------------
499
500#[derive(Debug, Clone, Deserialize)]
501#[serde(rename_all = "camelCase")]
502struct CancelWire {
503    #[serde(default)]
504    cancel: bool,
505    #[serde(default)]
506    reason: Option<String>,
507}
508
509#[derive(Debug, Clone, Deserialize)]
510#[serde(rename_all = "camelCase")]
511struct BeforeToolCallWire {
512    #[serde(default)]
513    block: bool,
514    #[serde(default)]
515    reason: Option<String>,
516}
517
518#[derive(Debug, Clone, Default, Deserialize)]
519#[serde(rename_all = "camelCase")]
520struct AfterToolCallWire {
521    #[serde(default)]
522    content: Option<Vec<ToolResultContent>>,
523    #[serde(default)]
524    details: Option<Value>,
525    #[serde(default)]
526    is_error: Option<bool>,
527    #[serde(default)]
528    terminate: Option<bool>,
529}
530
531#[derive(Debug, Clone, Deserialize)]
532#[serde(tag = "action", rename_all = "camelCase")]
533enum InputTransformWire {
534    Continue,
535    Transform {
536        text: String,
537        #[serde(default)]
538        images: Option<Value>,
539    },
540    Handled,
541}
542
543#[derive(Debug, Clone, Default, Deserialize)]
544#[serde(rename_all = "camelCase")]
545struct BeforeAgentStartWire {
546    #[serde(default)]
547    messages: Vec<AgentMessage>,
548    #[serde(default)]
549    system_prompt: Option<String>,
550}
551#[derive(Debug, Clone, Deserialize)]
552#[serde(rename_all = "camelCase")]
553struct ResourcePathWire {
554    path: String,
555    extension_path: String,
556}
557
558#[derive(Debug, Clone, Default, Deserialize)]
559#[serde(rename_all = "camelCase")]
560struct ResourcesDiscoverWire {
561    #[serde(default, rename = "skillPaths")]
562    skills: Option<Vec<ResourcePathWire>>,
563    #[serde(default, rename = "promptPaths")]
564    prompts: Option<Vec<ResourcePathWire>>,
565    #[serde(default, rename = "themePaths")]
566    themes: Option<Vec<ResourcePathWire>>,
567}
568
569#[derive(Debug, Clone, Default, Deserialize)]
570struct MessageEndWire {
571    message: Option<AgentMessage>,
572}
573
574#[derive(Debug, Clone, Deserialize)]
575#[serde(rename_all = "camelCase")]
576struct CommandExecuteWire {
577    #[serde(default)]
578    ok: bool,
579}
580
581#[derive(Debug, Clone, Deserialize)]
582#[serde(rename_all = "camelCase")]
583struct ToolRenderHtmlWire {
584    #[serde(default)]
585    html: Option<String>,
586}
587
588// ---------------------------------------------------------------------------
589// Runner
590// ---------------------------------------------------------------------------
591
592/// Slot subscription state: latest sanitized slot (or `None` when disposed).
593type SlotWatch = watch::Sender<Option<SanitizedSlot>>;
594
595/// Message shown when a hook/tool call targets a runner retired by a reload.
596const RETIRED_MESSAGE: &str = "extension host retired by reload";
597
598/// In-flight hook/tool traffic gate for the reload cutover.
599///
600/// Every hook RPC and gated tool adapter holds a [`TrafficPermit`] for the
601/// duration of its host round-trip. [`TrafficGate::close`] stops new
602/// admissions and [`TrafficGate::drain`] waits until every held permit is
603/// released, so a retired transport is reaped only after in-flight old-host
604/// traffic has finished against the still-live client.
605#[derive(Default)]
606struct TrafficGate {
607    /// Permits currently held. `SeqCst` pairs with `closed` for the
608    /// admission-vs-drain handoff (increment-then-check vs close-then-read).
609    inflight: AtomicU64,
610    /// Set once the runner is retired; no new permits are granted.
611    closed: AtomicBool,
612    /// Notified whenever `inflight` returns to zero.
613    idle: tokio::sync::Notify,
614}
615
616impl TrafficGate {
617    /// Admit one host RPC, or `None` once the gate is closed.
618    fn enter(self: &Arc<Self>) -> Option<TrafficPermit> {
619        // Increment BEFORE the closed check: a drainer that stores `closed`
620        // and then observes `inflight == 0` can never miss this admission.
621        self.inflight.fetch_add(1, Ordering::SeqCst);
622        if self.closed.load(Ordering::SeqCst) {
623            self.release();
624            return None;
625        }
626        Some(TrafficPermit {
627            gate: Arc::clone(self),
628        })
629    }
630
631    fn release(&self) {
632        if self.inflight.fetch_sub(1, Ordering::SeqCst) == 1 {
633            self.idle.notify_waiters();
634        }
635    }
636
637    /// Stop admitting new traffic.
638    fn close(&self) {
639        self.closed.store(true, Ordering::SeqCst);
640    }
641
642    /// Wait until every held permit is released. Call after [`Self::close`].
643    async fn drain(&self) {
644        loop {
645            let idle = self.idle.notified();
646            tokio::pin!(idle);
647            idle.as_mut().enable();
648            if self.inflight.load(Ordering::SeqCst) == 0 {
649                return;
650            }
651            idle.await;
652        }
653    }
654}
655
656/// RAII admission token for one in-flight host RPC.
657struct TrafficPermit {
658    gate: Arc<TrafficGate>,
659}
660
661impl Drop for TrafficPermit {
662    fn drop(&mut self) {
663        self.gate.release();
664    }
665}
666
667/// Host-backed tool adapter wrapped with the runner's [`TrafficGate`].
668///
669/// A reload cutover drains in-flight executions before reaping the old
670/// transport; calls arriving after retirement fail with a clean
671/// [`ToolError`] instead of racing the dead client.
672struct GatedHostTool {
673    delegate: Arc<dyn AgentTool>,
674    traffic: Arc<TrafficGate>,
675}
676
677impl AgentTool for GatedHostTool {
678    fn name(&self) -> &str {
679        self.delegate.name()
680    }
681
682    fn label(&self) -> &str {
683        self.delegate.label()
684    }
685
686    fn description(&self) -> &str {
687        self.delegate.description()
688    }
689
690    fn parameters(&self) -> &Value {
691        self.delegate.parameters()
692    }
693
694    fn execution_mode(&self) -> Option<ToolExecutionMode> {
695        self.delegate.execution_mode()
696    }
697
698    fn prepare_arguments(&self, raw: &Map<String, Value>) -> Result<Map<String, Value>, ToolError> {
699        self.delegate.prepare_arguments(raw)
700    }
701
702    fn validate_arguments(
703        &self,
704        args: &Map<String, Value>,
705    ) -> Result<Map<String, Value>, ToolError> {
706        self.delegate.validate_arguments(args)
707    }
708
709    fn prepare_and_validate_arguments(
710        &self,
711        raw: Map<String, Value>,
712    ) -> BoxFuture<'_, Result<Map<String, Value>, ToolError>> {
713        Box::pin(async move {
714            let Some(_permit) = self.traffic.enter() else {
715                return Err(ToolError::new(RETIRED_MESSAGE));
716            };
717            self.delegate.prepare_and_validate_arguments(raw).await
718        })
719    }
720
721    fn execute(
722        &self,
723        tool_call_id: &str,
724        args: Map<String, Value>,
725        cancel: CancellationToken,
726        updates: ToolUpdates,
727    ) -> BoxFuture<'static, Result<AgentToolResult, ToolError>> {
728        let delegate = Arc::clone(&self.delegate);
729        let traffic = Arc::clone(&self.traffic);
730        let tool_call_id = tool_call_id.to_owned();
731        Box::pin(async move {
732            let Some(_permit) = traffic.enter() else {
733                return Err(ToolError::new(RETIRED_MESSAGE));
734            };
735            delegate.execute(&tool_call_id, args, cancel, updates).await
736        })
737    }
738}
739
740struct Inner {
741    client: Arc<HostClient>,
742    snapshot: RwLock<RegistrySnapshot>,
743    flag_values: RwLock<HashMap<String, Value>>,
744    slots: RwLock<HashMap<String, SlotWatch>>,
745    tool_updates_tx: broadcast::Sender<ToolUpdate>,
746    provider_events_tx: broadcast::Sender<ProviderEvent>,
747    errors_tx: broadcast::Sender<ExtensionErrorEvent>,
748    ui_tx: broadcast::Sender<ExtensionUiEvent>,
749    ui_requests_tx: mpsc::Sender<HostUiRequest>,
750    ui_requests_rx: StdMutex<Option<mpsc::Receiver<HostUiRequest>>>,
751    ui_requests_claimed: AtomicBool,
752    /// Paths passed to `extensions.load` (restart reuses them).
753    extension_paths: Vec<String>,
754    /// Cwd passed to `extensions.load`.
755    load_cwd: String,
756    /// Project trust passed to `extensions.load` and preserved across restart.
757    project_trusted: bool,
758    /// Monotonic reload generation; bumps invalidate every active slot.
759    reload_generation: AtomicU64,
760    /// Host transport is gone (EOF / crash / protocol error). All hooks and
761    /// handler-presence queries short-circuit to no-ops once set.
762    disabled: AtomicBool,
763    /// Runner invalidated after session replacement (`/reload` / runtime swap).
764    stale: AtomicBool,
765    /// `shutdown` has completed at least once.
766    shutdown_done: AtomicBool,
767    /// Serializes shutdown so concurrent callers await the same completed reap.
768    shutdown_lock: tokio::sync::Mutex<()>,
769    /// Per-hook control-RPC deadline (`HOOK_TIMEOUT` in production; shorter in
770    /// tests to exercise the timeout path quickly).
771    hook_timeout: Duration,
772    /// Reload-cutover admission gate held across every hook/tool host RPC.
773    traffic: Arc<TrafficGate>,
774}
775
776impl Inner {
777    fn new(
778        client: Arc<HostClient>,
779        mut snapshot: RegistrySnapshot,
780        extension_paths: Vec<String>,
781        load_cwd: String,
782        project_trusted: bool,
783        hook_timeout: Duration,
784    ) -> Self {
785        let (tool_updates_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
786        let (provider_events_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
787        let (errors_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
788        let (ui_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
789        let (ui_requests_tx, ui_requests_rx) = mpsc::channel(EVENT_CHANNEL_CAPACITY);
790        let flag_values = snapshot.flag_values.clone();
791        let traffic = Arc::new(TrafficGate::default());
792        // Host-backed tool adapters participate in the reload-cutover drain:
793        // in-flight executions keep the old transport alive until they
794        // finish, and post-retirement calls fail cleanly.
795        snapshot.tools = snapshot
796            .tools
797            .into_iter()
798            .map(|(name, delegate)| {
799                let gated: Arc<dyn AgentTool> = Arc::new(GatedHostTool {
800                    delegate,
801                    traffic: Arc::clone(&traffic),
802                });
803                (name, gated)
804            })
805            .collect();
806        Self {
807            client,
808            snapshot: RwLock::new(snapshot),
809            flag_values: RwLock::new(flag_values),
810            slots: RwLock::new(HashMap::new()),
811            tool_updates_tx,
812            provider_events_tx,
813            errors_tx,
814            ui_tx,
815            ui_requests_tx,
816            ui_requests_rx: StdMutex::new(Some(ui_requests_rx)),
817            ui_requests_claimed: AtomicBool::new(false),
818            extension_paths,
819            load_cwd,
820            project_trusted,
821            reload_generation: AtomicU64::new(1),
822            disabled: AtomicBool::new(false),
823            stale: AtomicBool::new(false),
824            shutdown_done: AtomicBool::new(false),
825            shutdown_lock: tokio::sync::Mutex::new(()),
826            hook_timeout,
827            traffic,
828        }
829    }
830
831    fn has_handlers(&self, event: &str) -> bool {
832        if self.disabled.load(Ordering::Relaxed) || self.stale.load(Ordering::Relaxed) {
833            return false;
834        }
835        self.snapshot
836            .read()
837            .is_ok_and(|guard| guard.handlers.contains(event))
838    }
839
840    fn active(&self) -> bool {
841        !self.disabled.load(Ordering::Relaxed) && !self.stale.load(Ordering::Relaxed)
842    }
843
844    /// Whether a slash command named `name` is registered (handler-presence
845    /// and disabled/stale gates apply).
846    fn has_command(&self, name: &str) -> bool {
847        if !self.active() {
848            return false;
849        }
850        self.snapshot.read().is_ok_and(|guard| {
851            guard
852                .registry
853                .commands()
854                .iter()
855                .any(|command| command.name == name)
856        })
857    }
858
859    /// Send one hook request. Returns the validated response frame, or an
860    /// error when the host is gone / timed out / returned an error frame.
861    async fn hook_request(
862        &self,
863        method: &str,
864        payload: Value,
865    ) -> Result<protocol::Frame, HostClientError> {
866        let Some(_permit) = self.traffic.enter() else {
867            return Err(HostClientError::NotRunning);
868        };
869        if !self.active() {
870            return Err(HostClientError::NotRunning);
871        }
872        self.client
873            .request_raw(method, payload, self.hook_timeout)
874            .await
875    }
876
877    /// Map a transport failure to a single non-retryable `extension_error`,
878    /// publish it to subscribers, and flip the disabled flag on fatal
879    /// conditions. Never aborts the caller.
880    fn report_host_error(&self, err: &HostClientError) {
881        let fatal = matches!(
882            err,
883            HostClientError::Closed { .. }
884                | HostClientError::Protocol { .. }
885                | HostClientError::NotRunning
886        );
887        if fatal {
888            self.disabled.store(true, Ordering::Relaxed);
889        }
890        let event = ExtensionErrorEvent {
891            code: error_code(err).to_owned(),
892            message: err.to_string(),
893            retryable: false,
894            data: None,
895        };
896        let _ = self.errors_tx.send(event);
897    }
898
899    fn publish_error(&self, code: &str, message: &str, data: Option<Value>) {
900        let event = ExtensionErrorEvent {
901            code: code.to_owned(),
902            message: message.to_owned(),
903            retryable: false,
904            data,
905        };
906        let _ = self.errors_tx.send(event);
907    }
908
909    fn slot_send(&self, slot: SanitizedSlot) {
910        // Teardown must not be reanimated: after invalidate/shutdown a
911        // delayed in-flight slot would otherwise re-insert a watch entry.
912        // Both the active check AND the synchronous ui_tx publish run UNDER
913        // the slots lock, so they serialize against dispose_all_slots and a
914        // Slot event can never be published after the teardown Dispose.
915        let Ok(mut slots) = self.slots.write() else {
916            return;
917        };
918        if !self.active() {
919            return;
920        }
921        let sender = slots
922            .entry(slot.key.clone())
923            .or_insert_with(|| watch::channel(None).0);
924        let _ = sender.send(Some(slot.clone()));
925        let _ = self.ui_tx.send(ExtensionUiEvent::Slot(slot));
926    }
927
928    fn slot_dispose(&self, key: &str) {
929        // Same lock discipline as slot_send: watch update and public publish
930        // are one atomic step relative to teardown.
931        let Ok(mut slots) = self.slots.write() else {
932            return;
933        };
934        if !self.active() {
935            return;
936        }
937        if let Some(sender) = slots.get(key) {
938            let _ = sender.send(None);
939        }
940        slots.remove(key);
941        let _ = self.ui_tx.send(ExtensionUiEvent::Dispose {
942            key: key.to_owned(),
943        });
944    }
945
946    fn notify_send(&self, notification: NotifyRequest) {
947        // Same lock discipline as slot_send/slot_dispose: the active check
948        // and the synchronous publish serialize against teardown so a Notify
949        // can never land after the teardown Dispose.
950        let Ok(_slots) = self.slots.write() else {
951            return;
952        };
953        if !self.active() {
954            return;
955        }
956        let _ = self.ui_tx.send(ExtensionUiEvent::Notify(notification));
957    }
958
959    fn dispose_all_slots(&self) {
960        // Publishing the Dispose events while still holding the lock keeps
961        // Slot-before-teardown-Dispose ordering: any concurrent slot_send
962        // either published before we acquired the lock or is dropped by the
963        // inactive gate afterwards. `broadcast::Sender::send` never blocks.
964        let Ok(mut slots) = self.slots.write() else {
965            return;
966        };
967        for (key, sender) in slots.iter() {
968            let _ = sender.send(None);
969            let _ = self
970                .ui_tx
971                .send(ExtensionUiEvent::Dispose { key: key.clone() });
972        }
973        slots.clear();
974    }
975}
976
977fn error_code(err: &HostClientError) -> &'static str {
978    match err {
979        HostClientError::Handshake { .. } => "extension_handshake",
980        HostClientError::Timeout { .. } => "extension_timeout",
981        HostClientError::Cancelled { .. } => "extension_cancelled",
982        HostClientError::Closed { .. } => "extension_closed",
983        HostClientError::Protocol { .. } => "extension_protocol",
984        HostClientError::Remote { .. } => "extension_remote",
985        HostClientError::Spawn { .. } => "extension_spawn",
986        HostClientError::NotRunning => "extension_not_running",
987        HostClientError::Payload(_) => "extension_payload",
988    }
989}
990
991/// Product extension runner backed by a live pi-ext [`HostClient`].
992///
993/// Construct with [`HostExtensionRunner::start`] (resolve + spawn) or
994/// [`HostExtensionRunner::connect`] (pre-built client, used by tests and the
995/// reload restart closure). All [`ExtensionRunner`] hooks send a single event
996/// request and trust only the validated typed response; the host owns the
997/// 15-hook merge. Host failures are isolated as a single non-retryable
998/// `extension_error` and never abort the session.
999pub struct HostExtensionRunner {
1000    inner: Arc<Inner>,
1001}
1002
1003impl HostExtensionRunner {
1004    /// Resolve, spawn, handshake, and load the host, returning a ready runner.
1005    ///
1006    /// # Errors
1007    ///
1008    /// Returns [`HostStartError::Resolve`] when no host executable is
1009    /// available, [`HostStartError::Spawn`] when the process cannot start,
1010    /// [`HostStartError::Handshake`] on version mismatch, or
1011    /// [`HostStartError::Load`] when the registration snapshot is unreadable.
1012    pub async fn start(extension_paths: Vec<String>) -> Result<Arc<Self>, HostStartError> {
1013        let spec = host::resolve_host()?;
1014        Self::spawn_from(&spec, extension_paths).await
1015    }
1016
1017    /// Spawn from an explicit [`HostSpec`], then bind.
1018    ///
1019    /// # Errors
1020    ///
1021    /// See [`HostExtensionRunner::start`].
1022    pub async fn spawn_from(
1023        spec: &HostSpec,
1024        extension_paths: Vec<String>,
1025    ) -> Result<Arc<Self>, HostStartError> {
1026        let client =
1027            Arc::new(HostClient::spawn(spec).map_err(|e| HostStartError::Spawn(e.to_string()))?);
1028        let startup = Self::connect(Arc::clone(&client), extension_paths).await;
1029        Self::finish_startup(&client, startup).await
1030    }
1031
1032    /// Bind a runner to a pre-built client: handshake, load, spawn the event
1033    /// pump. Used by [`start`](Self::start), the reload restart path, and the
1034    /// fake-host test harness.
1035    ///
1036    /// # Errors
1037    ///
1038    /// Returns [`HostStartError::Handshake`] or [`HostStartError::Load`].
1039    pub async fn connect(
1040        client: Arc<HostClient>,
1041        extension_paths: Vec<String>,
1042    ) -> Result<Arc<Self>, HostStartError> {
1043        Self::connect_with_timeout(client, extension_paths, HOOK_TIMEOUT).await
1044    }
1045
1046    /// Bind a runner with a custom hook timeout (test harness; production uses
1047    /// [`connect`](Self::connect) which applies [`HOOK_TIMEOUT`]).
1048    ///
1049    /// # Errors
1050    ///
1051    /// Returns [`HostStartError::Handshake`] or [`HostStartError::Load`].
1052    pub async fn connect_with_timeout(
1053        client: Arc<HostClient>,
1054        extension_paths: Vec<String>,
1055        hook_timeout: Duration,
1056    ) -> Result<Arc<Self>, HostStartError> {
1057        let load_cwd = std::env::current_dir()
1058            .map(|p| p.to_string_lossy().into_owned())
1059            .unwrap_or_default();
1060        Self::connect_with_cwd_and_trust(client, extension_paths, load_cwd, false, hook_timeout)
1061            .await
1062    }
1063
1064    /// Bind a runner with an explicit load cwd (services factory / tests).
1065    ///
1066    /// # Errors
1067    ///
1068    /// Returns [`HostStartError::Handshake`] or [`HostStartError::Load`].
1069    pub async fn connect_with_cwd(
1070        client: Arc<HostClient>,
1071        extension_paths: Vec<String>,
1072        load_cwd: impl Into<String>,
1073        hook_timeout: Duration,
1074    ) -> Result<Arc<Self>, HostStartError> {
1075        Self::connect_with_cwd_and_trust(client, extension_paths, load_cwd, false, hook_timeout)
1076            .await
1077    }
1078
1079    /// Bind a runner with an explicit load cwd and project-trust value.
1080    ///
1081    /// # Errors
1082    ///
1083    /// Returns [`HostStartError::Handshake`] or [`HostStartError::Load`].
1084    pub async fn connect_with_cwd_and_trust(
1085        client: Arc<HostClient>,
1086        extension_paths: Vec<String>,
1087        load_cwd: impl Into<String>,
1088        project_trusted: bool,
1089        hook_timeout: Duration,
1090    ) -> Result<Arc<Self>, HostStartError> {
1091        client.handshake().await?;
1092        let load_cwd = load_cwd.into();
1093        let snapshot = Self::load(&client, &extension_paths, &load_cwd, project_trusted).await?;
1094        let inner = Arc::new(Inner::new(
1095            Arc::clone(&client),
1096            snapshot,
1097            extension_paths,
1098            load_cwd,
1099            project_trusted,
1100            hook_timeout,
1101        ));
1102        let runner = Arc::new(Self {
1103            inner: Arc::clone(&inner),
1104        });
1105        spawn_event_pump(inner);
1106        Ok(runner)
1107    }
1108
1109    async fn finish_startup(
1110        client: &HostClient,
1111        startup: Result<Arc<Self>, HostStartError>,
1112    ) -> Result<Arc<Self>, HostStartError> {
1113        if startup.is_err() {
1114            let _ = client.shutdown().await;
1115        }
1116        startup
1117    }
1118
1119    async fn load(
1120        client: &Arc<HostClient>,
1121        extension_paths: &[String],
1122        cwd: &str,
1123        project_trusted: bool,
1124    ) -> Result<RegistrySnapshot, HostStartError> {
1125        let payload = serde_json::to_value(ExtensionsLoadRequest {
1126            extension_paths,
1127            cwd,
1128            project_trusted,
1129        })
1130        .map_err(|error| HostStartError::Load(error.to_string()))?;
1131        let frame = client
1132            .request_raw(LOAD_METHOD, payload, START_TIMEOUT)
1133            .await?;
1134        let wire: RegistrySnapshotWire = serde_json::from_value(frame.payload)
1135            .map_err(|e| HostStartError::Load(e.to_string()))?;
1136        Ok(build_snapshot(wire, client))
1137    }
1138
1139    /// Borrowed host client (for provider registration by the model runtime).
1140    #[must_use]
1141    pub fn client(&self) -> &Arc<HostClient> {
1142        &self.inner.client
1143    }
1144
1145    /// Extension paths used for the current host load.
1146    #[must_use]
1147    pub fn extension_paths(&self) -> Vec<String> {
1148        self.inner.extension_paths.clone()
1149    }
1150
1151    /// Host-reported per-path load errors from the latest snapshot.
1152    #[must_use]
1153    pub fn load_errors(&self) -> Vec<(String, String)> {
1154        self.inner
1155            .snapshot
1156            .read()
1157            .map(|guard| guard.load_errors.clone())
1158            .unwrap_or_default()
1159    }
1160
1161    /// Registered provider config inputs keyed by provider id.
1162    #[must_use]
1163    pub fn provider_configs(&self) -> HashMap<String, ProviderConfigInput> {
1164        self.inner
1165            .snapshot
1166            .read()
1167            .map(|guard| guard.provider_configs.clone())
1168            .unwrap_or_default()
1169    }
1170
1171    /// Provider ids that expose a host-side `streamSimple` handler.
1172    #[must_use]
1173    pub fn stream_provider_ids(&self) -> HashSet<String> {
1174        self.inner
1175            .snapshot
1176            .read()
1177            .map(|guard| guard.stream_provider_ids.clone())
1178            .unwrap_or_default()
1179    }
1180
1181    /// Optional extension path per provider (diagnostics).
1182    #[must_use]
1183    pub fn provider_extension_paths(&self) -> HashMap<String, String> {
1184        self.inner
1185            .snapshot
1186            .read()
1187            .map(|guard| guard.provider_extension_paths.clone())
1188            .unwrap_or_default()
1189    }
1190
1191    /// Registered extension flags as name → type, for CLI validation.
1192    #[must_use]
1193    pub fn registered_flag_types(
1194        &self,
1195    ) -> BTreeMap<String, super::agent_session_services::ExtensionFlagType> {
1196        use super::agent_session_services::ExtensionFlagType;
1197        self.inner
1198            .snapshot
1199            .read()
1200            .map(|guard| {
1201                guard
1202                    .registry
1203                    .flags()
1204                    .iter()
1205                    .map(|flag| {
1206                        let kind = match flag.kind {
1207                            adapters::FlagKind::Boolean => ExtensionFlagType::Boolean,
1208                            adapters::FlagKind::String => ExtensionFlagType::String,
1209                        };
1210                        (flag.name.clone(), kind)
1211                    })
1212                    .collect()
1213            })
1214            .unwrap_or_default()
1215    }
1216
1217    /// Registered extension provider adapters keyed by provider id, freshly
1218    /// bound to the live host client (callers register them with the model
1219    /// runtime). Rebuilt per call since [`ExtensionProvider`] is not `Clone`.
1220    ///
1221    /// Includes every host-registered provider. Custom-stream selection still
1222    /// requires `streamSimple: true` at registration time
1223    /// ([`Self::register_providers_on`]); baseURL-only providers stay native.
1224    #[must_use]
1225    pub fn providers(&self) -> HashMap<String, ExtensionProvider> {
1226        let client = Arc::clone(&self.inner.client);
1227        self.inner
1228            .snapshot
1229            .read()
1230            .map(|guard| {
1231                guard
1232                    .registry
1233                    .providers()
1234                    .iter()
1235                    .map(|provider| {
1236                        (
1237                            provider.name.clone(),
1238                            ExtensionProvider::new(provider.name.clone(), Arc::clone(&client)),
1239                        )
1240                    })
1241                    .collect()
1242            })
1243            .unwrap_or_default()
1244    }
1245
1246    /// Register this host's provider configs + stream adapters on `runtime`.
1247    ///
1248    /// Each provider failure becomes a diagnostic string; siblings continue.
1249    /// Stream handlers are registered only when `streamSimple` was true.
1250    #[must_use]
1251    pub fn register_providers_on(
1252        &self,
1253        runtime: &ModelRuntime,
1254    ) -> Vec<(String, Result<(), ModelRuntimeError>)> {
1255        let configs = self.provider_configs();
1256        let stream_ids = self.stream_provider_ids();
1257        let paths = self.provider_extension_paths();
1258        let mut results = Vec::with_capacity(configs.len());
1259        for (name, config) in configs {
1260            let path = paths.get(&name).cloned().unwrap_or_else(|| name.clone());
1261            let outcome = runtime.register_provider(&name, config);
1262            if outcome.is_ok() && stream_ids.contains(&name) {
1263                let adapter = ExtensionProvider::new(name.clone(), Arc::clone(self.client()));
1264                runtime.register_extension_stream_provider(name.clone(), Arc::new(adapter));
1265            }
1266            results.push((path, outcome));
1267        }
1268        results
1269    }
1270
1271    /// Unregister every provider currently owned by this runner from `runtime`.
1272    pub fn unregister_providers_from(&self, runtime: &ModelRuntime) {
1273        for name in self.provider_configs().keys() {
1274            runtime.unregister_provider(name);
1275        }
1276    }
1277
1278    /// Snapshot of the pi-ext [`Registry`] (tools/commands/shortcuts/flags/
1279    /// renderers/providers with first-wins dedup applied).
1280    #[must_use]
1281    pub fn registry(&self) -> Registry {
1282        self.inner
1283            .snapshot
1284            .read()
1285            .map(|guard| clone_registry(&guard.registry))
1286            .unwrap_or_default()
1287    }
1288
1289    /// Ordered, undeduplicated host shortcut registrations.
1290    ///
1291    /// Product code applies last-wins filtering after combining extension and native shortcuts.
1292    #[must_use]
1293    pub fn raw_shortcuts(&self) -> Vec<ShortcutRegistration> {
1294        self.inner
1295            .snapshot
1296            .read()
1297            .map(|guard| guard.raw_shortcuts.clone())
1298            .unwrap_or_default()
1299    }
1300
1301    /// Current reload generation (starts at 1, bumps on each reload).
1302    #[must_use]
1303    pub fn reload_generation(&self) -> u64 {
1304        self.inner.reload_generation.load(Ordering::Relaxed)
1305    }
1306
1307    /// Whether the host transport is still believed alive.
1308    #[must_use]
1309    pub fn is_running(&self) -> bool {
1310        self.inner.client.is_running() && !self.inner.disabled.load(Ordering::Relaxed)
1311    }
1312
1313    /// Synchronize a complete validated flag overlay with the host.
1314    ///
1315    /// The local flag snapshot is updated only after the host acknowledges the request.
1316    ///
1317    /// # Errors
1318    ///
1319    /// Returns [`HostClientError::Payload`] when the request or response payload
1320    /// cannot be (de)serialized, or when the host rejects the overlay
1321    /// (`ok == false`). Propagates the transport-level error from
1322    /// [`hook_request`](HostClientInner::hook_request) otherwise:
1323    /// [`HostClientError::NotRunning`] when the host is down, and
1324    /// [`HostClientError::Timeout`], [`HostClientError::Closed`], or
1325    /// [`HostClientError::Remote`] on transport failure.
1326    pub async fn apply_flag_values(
1327        &self,
1328        values: &BTreeMap<String, FlagValueWire>,
1329    ) -> Result<(), HostClientError> {
1330        let payload = protocol::to_payload(&FlagsSetRequest {
1331            values: values.clone(),
1332        })
1333        .map_err(|error| HostClientError::Payload(format!("encode flags.set: {error}")))?;
1334        let frame = self
1335            .inner
1336            .hook_request(protocol::FLAGS_SET_METHOD, payload)
1337            .await?;
1338        let response: FlagsSetResponse = protocol::from_payload(&frame.payload)
1339            .map_err(|error| HostClientError::Payload(format!("decode flags.set: {error}")))?;
1340        if !response.ok {
1341            return Err(HostClientError::Payload(
1342                "flags.set rejected by extension host".to_owned(),
1343            ));
1344        }
1345        if let Ok(mut flags) = self.inner.flag_values.write() {
1346            for (name, value) in values {
1347                let value = match value {
1348                    FlagValueWire::Boolean(value) => Value::Bool(*value),
1349                    FlagValueWire::String(value) => Value::String(value.clone()),
1350                };
1351                flags.insert(name.clone(), value);
1352            }
1353        }
1354        Ok(())
1355    }
1356
1357    /// Dispatch one effective extension shortcut.
1358    ///
1359    /// # Errors
1360    ///
1361    /// Returns [`HostClientError::Payload`] when the request or response payload
1362    /// cannot be (de)serialized. Propagates the transport-level error from
1363    /// [`hook_request`](HostClientInner::hook_request) otherwise:
1364    /// [`HostClientError::NotRunning`] when the host is down, and
1365    /// [`HostClientError::Timeout`], [`HostClientError::Closed`], or
1366    /// [`HostClientError::Remote`] on transport failure.
1367    pub async fn execute_shortcut(
1368        &self,
1369        key: impl Into<String>,
1370    ) -> Result<ShortcutExecuteResponse, HostClientError> {
1371        let payload =
1372            protocol::to_payload(&ShortcutExecuteRequest { key: key.into() }).map_err(|error| {
1373                HostClientError::Payload(format!("encode shortcut.execute: {error}"))
1374            })?;
1375        let frame = self
1376            .inner
1377            .hook_request(protocol::SHORTCUT_EXECUTE_METHOD, payload)
1378            .await?;
1379        protocol::from_payload(&frame.payload)
1380            .map_err(|error| HostClientError::Payload(format!("decode shortcut.execute: {error}")))
1381    }
1382
1383    /// Deliver one event to a keyed UI slot generation.
1384    ///
1385    /// # Errors
1386    ///
1387    /// Returns [`HostClientError::Payload`] when the request or response payload
1388    /// cannot be (de)serialized. Propagates the transport-level error from
1389    /// [`hook_request`](HostClientInner::hook_request) otherwise:
1390    /// [`HostClientError::NotRunning`] when the host is down, and
1391    /// [`HostClientError::Timeout`], [`HostClientError::Closed`], or
1392    /// [`HostClientError::Remote`] on transport failure.
1393    pub async fn send_ui_event(
1394        &self,
1395        request: UiEventRequest,
1396    ) -> Result<UiEventResponse, HostClientError> {
1397        let payload = protocol::to_payload(&request)
1398            .map_err(|error| HostClientError::Payload(format!("encode uiEvent: {error}")))?;
1399        let frame = self
1400            .inner
1401            .hook_request(protocol::Method::UiEvent.as_str(), payload)
1402            .await?;
1403        protocol::from_payload(&frame.payload)
1404            .map_err(|error| HostClientError::Payload(format!("decode uiEvent: {error}")))
1405    }
1406
1407    // -- Slot / tool-update / provider / error subscriptions ---------------
1408
1409    /// Subscribe to a keyed UI slot lifecycle. The receiver yields the latest
1410    /// sanitized slot, or `None` when the slot is disposed or invalidated by a
1411    /// reload. New keys start disposed (`None`) until the host pushes content.
1412    #[must_use]
1413    pub fn subscribe_slot(&self, key: &str) -> watch::Receiver<Option<SanitizedSlot>> {
1414        if let Ok(mut slots) = self.inner.slots.write() {
1415            let sender = slots
1416                .entry(key.to_owned())
1417                .or_insert_with(|| watch::channel(None).0);
1418            return sender.subscribe();
1419        }
1420        // Lock poisoned: hand back a dead receiver.
1421        let (tx, rx) = watch::channel(None);
1422        let _ = tx.send(None);
1423        rx
1424    }
1425
1426    /// Currently live slot keys.
1427    #[must_use]
1428    pub fn slot_keys(&self) -> Vec<String> {
1429        self.inner
1430            .slots
1431            .read()
1432            .map(|guard| guard.keys().cloned().collect())
1433            .unwrap_or_default()
1434    }
1435
1436    /// Snapshot all currently live sanitized slots.
1437    ///
1438    /// This lets a mode attach after `session_start` without losing widgets
1439    /// already published before its broadcast subscription existed.
1440    #[must_use]
1441    pub fn current_slots(&self) -> Vec<SanitizedSlot> {
1442        let mut slots = self
1443            .inner
1444            .slots
1445            .read()
1446            .map(|guard| {
1447                guard
1448                    .values()
1449                    .filter_map(|sender| sender.borrow().clone())
1450                    .collect::<Vec<_>>()
1451            })
1452            .unwrap_or_default();
1453        slots.sort_by(|left, right| left.key.cmp(&right.key));
1454        slots
1455    }
1456
1457    /// Subscribe to unsolicited partial tool updates from extension tools.
1458    #[must_use]
1459    pub fn subscribe_tool_updates(&self) -> broadcast::Receiver<ToolUpdate> {
1460        self.inner.tool_updates_tx.subscribe()
1461    }
1462
1463    /// Subscribe to unsolicited custom-provider stream events.
1464    #[must_use]
1465    pub fn subscribe_provider_events(&self) -> broadcast::Receiver<ProviderEvent> {
1466        self.inner.provider_events_tx.subscribe()
1467    }
1468
1469    /// Subscribe to non-retryable extension errors (host crashes, timeouts,
1470    /// remote error frames, handler-reported failures).
1471    #[must_use]
1472    pub fn subscribe_errors(&self) -> broadcast::Receiver<ExtensionErrorEvent> {
1473        self.inner.errors_tx.subscribe()
1474    }
1475
1476    /// Whether the loaded host has an active `ui.onTerminalInput` handler.
1477    #[must_use]
1478    pub fn has_terminal_input_handlers(&self) -> bool {
1479        self.inner
1480            .snapshot
1481            .read()
1482            .is_ok_and(|snapshot| snapshot.terminal_input)
1483    }
1484
1485    /// Offer canonical terminal input to the host's sequential 4 ms actor.
1486    ///
1487    /// # Errors
1488    ///
1489    /// Returns [`HostClientError`] when the host transport is down, the 4 ms
1490    /// deadline elapses, or the response payload cannot be decoded.
1491    pub async fn terminal_input(
1492        &self,
1493        data: &str,
1494    ) -> Result<protocol::TerminalInputResult, HostClientError> {
1495        let frame = self
1496            .inner
1497            .client
1498            .request(
1499                protocol::Method::TerminalInput,
1500                serde_json::json!({ "data": data }),
1501                Duration::from_millis(4),
1502            )
1503            .await?;
1504        protocol::from_payload(&frame.payload)
1505            .map_err(|error| HostClientError::Payload(format!("decode terminalInput: {error}")))
1506    }
1507
1508    /// Subscribe to host notifications and sanitized slot lifecycle.
1509    #[must_use]
1510    pub fn subscribe_ui(&self) -> broadcast::Receiver<ExtensionUiEvent> {
1511        self.inner.ui_tx.subscribe()
1512    }
1513
1514    /// Claim the sole lossless receiver for correlated host dialog requests.
1515    ///
1516    /// A product mode calls this exactly once when it binds. Subsequent callers
1517    /// receive `None`, preventing two modes from racing responses.
1518    #[must_use]
1519    pub fn take_ui_requests(&self) -> Option<mpsc::Receiver<HostUiRequest>> {
1520        let receiver = self
1521            .inner
1522            .ui_requests_rx
1523            .lock()
1524            .unwrap_or_else(std::sync::PoisonError::into_inner)
1525            .take();
1526        if receiver.is_some() {
1527            self.inner
1528                .ui_requests_claimed
1529                .store(true, Ordering::Release);
1530        }
1531        receiver
1532    }
1533
1534    /// Answer a correlated host-initiated dialog request.
1535    ///
1536    /// # Errors
1537    ///
1538    /// Returns a transport error if the host has already exited.
1539    pub async fn respond_ui(&self, response: HostUiResponse) -> Result<(), HostClientError> {
1540        self.inner.client.respond_ui(response).await
1541    }
1542
1543    // -- Custom tool HTML rendering (session export) ----------------------
1544
1545    /// Render an extension tool call or result as sanitized HTML for session
1546    /// export. Returns `Ok(None)` when no renderer is registered for
1547    /// `tool_name`. The host runs the registered `renderCall` / `renderResult`
1548    /// and returns an HTML fragment; Rust strips `<script>` / `<style>` blocks
1549    /// and escapes the remaining markup so plugin bytes never inject active
1550    /// content into an exported document.
1551    ///
1552    /// # Errors
1553    ///
1554    /// Returns [`ExtensionRunner` error](super::agent_session::extension_runner::ExtensionRunnerError)
1555    /// semantics: transport failures are reported as a non-retryable
1556    /// `extension_error` and the call resolves to `Ok(None)` (isolation).
1557    pub async fn render_extension_tool_html(
1558        &self,
1559        phase: ToolRenderPhase,
1560        tool_name: &str,
1561        payload: &Value,
1562    ) -> Option<String> {
1563        let _permit = self.inner.traffic.enter()?;
1564        if !self.inner.active() {
1565            return None;
1566        }
1567        let request = serde_json::json!({
1568            "phase": phase.as_str(),
1569            "toolName": tool_name,
1570            "payload": payload,
1571        });
1572        match self
1573            .inner
1574            .client
1575            .request_raw(TOOL_RENDER_HTML_METHOD, request, self.inner.hook_timeout)
1576            .await
1577        {
1578            Ok(frame) => match serde_json::from_value::<ToolRenderHtmlWire>(frame.payload) {
1579                Ok(wire) => wire.html.as_deref().map(sanitize_html),
1580                Err(_) => None,
1581            },
1582            Err(err) => {
1583                self.inner.report_host_error(&err);
1584                None
1585            }
1586        }
1587    }
1588
1589    // -- Reload / invalidate / shutdown -----------------------------------
1590
1591    /// Bump the reload generation, dispose every active slot, and reap the
1592    /// current host exactly once (reap-only; the session layer owns the
1593    /// typed `session_shutdown{reload}` emission before calling this). The
1594    /// caller re-creates the runner (via [`HostExtensionRunner::start`] /
1595    /// [`connect`](Self::connect)) for the clean registration pass. Returns
1596    /// the new generation.
1597    pub async fn reload(&self) -> u64 {
1598        let generation = self
1599            .inner
1600            .reload_generation
1601            .fetch_add(1, Ordering::Relaxed)
1602            .saturating_add(1);
1603        Self::shutdown_once_with_inner(&self.inner).await;
1604        self.inner.stale.store(true, Ordering::Relaxed);
1605        generation
1606    }
1607
1608    /// Transactional restart: prepare a fresh host and restore its flags while
1609    /// the old host and its runtime registrations remain live. Once the
1610    /// replacement is ready, replace the runtime registrations and return the
1611    /// new runner **without reaping the old transport**: the caller finishes
1612    /// the session-side cutover (trait runner, host handle, tool registry)
1613    /// while the old host is still live, then calls
1614    /// [`Self::retire_after_cutover`] on the old runner.
1615    ///
1616    /// # Errors
1617    ///
1618    /// Returns [`HostStartError`] when the replacement host fails to start or
1619    /// synchronize flags. The old runner and its runtime registrations remain
1620    /// usable on either failure.
1621    pub async fn restart_and_rewire(
1622        &self,
1623        runtime: &ModelRuntime,
1624        preserved_flags: HashMap<String, Value>,
1625    ) -> Result<Arc<Self>, HostStartError> {
1626        self.restart_and_rewire_with(
1627            runtime,
1628            preserved_flags,
1629            |paths, cwd, project_trusted| async move {
1630                Self::start_with_cwd_and_trust(paths, cwd, project_trusted).await
1631            },
1632        )
1633        .await
1634    }
1635
1636    pub(crate) async fn restart_and_rewire_with<F, Fut>(
1637        &self,
1638        runtime: &ModelRuntime,
1639        preserved_flags: HashMap<String, Value>,
1640        start: F,
1641    ) -> Result<Arc<Self>, HostStartError>
1642    where
1643        F: FnOnce(Vec<String>, String, bool) -> Fut,
1644        Fut: std::future::Future<Output = Result<Arc<Self>, HostStartError>>,
1645    {
1646        // 1. Spawn the replacement while the old host and all of its
1647        // registrations remain usable.
1648        let replacement = start(
1649            self.inner.extension_paths.clone(),
1650            self.inner.load_cwd.clone(),
1651            self.inner.project_trusted,
1652        )
1653        .await?;
1654        // 2. Restore flags before cutting any old surface over.
1655        let preserved_flags = match preserved_flags
1656            .into_iter()
1657            .map(|(name, value)| {
1658                let value = match value {
1659                    Value::Bool(value) => FlagValueWire::Boolean(value),
1660                    Value::String(value) => FlagValueWire::String(value),
1661                    other => {
1662                        return Err(HostStartError::FlagSync(format!(
1663                            "flag {name:?} has unsupported value {other}"
1664                        )));
1665                    }
1666                };
1667                Ok((name, value))
1668            })
1669            .collect::<Result<BTreeMap<_, _>, _>>()
1670        {
1671            Ok(flags) => flags,
1672            Err(error) => {
1673                replacement.shutdown_once().await;
1674                return Err(error);
1675            }
1676        };
1677        if let Err(error) = replacement.apply_flag_values(&preserved_flags).await {
1678            replacement.shutdown_once().await;
1679            return Err(HostStartError::FlagSync(error.to_string()));
1680        }
1681        // 3. Cut runtime traffic over synchronously so it can never retain a
1682        // stream adapter backed by the old host.
1683        self.unregister_providers_from(runtime);
1684        let _ = replacement.register_providers_on(runtime);
1685        // The old transport stays live: concurrent hooks/tools admitted
1686        // before the session-side cutover must never observe a reaped
1687        // client. The caller reaps via `retire_after_cutover` once the
1688        // trait runner, host handle, and tool registry point at the
1689        // replacement.
1690        Ok(replacement)
1691    }
1692
1693    /// Retire this runner after a successful reload cutover.
1694    ///
1695    /// Closes the traffic gate (new hook/tool calls on this retired runner
1696    /// fail cleanly without touching the transport), lets in-flight old-host
1697    /// traffic finish against the still-live client (bounded by the hook
1698    /// deadline plus grace so a hung call cannot pin two hosts forever), then
1699    /// bumps the reload generation, disposes slots, and reaps the transport
1700    /// exactly once via [`Self::reload`].
1701    pub async fn retire_after_cutover(&self) {
1702        self.inner.traffic.close();
1703        self.inner.stale.store(true, Ordering::Relaxed);
1704        let drain_deadline = self
1705            .inner
1706            .hook_timeout
1707            .saturating_add(Duration::from_secs(1));
1708        let _ = tokio::time::timeout(drain_deadline, self.inner.traffic.drain()).await;
1709        let _ = self.reload().await;
1710    }
1711
1712    /// Resolve + spawn with an explicit load cwd.
1713    ///
1714    /// # Errors
1715    ///
1716    /// See [`HostExtensionRunner::start_with_cwd_and_trust`].
1717    pub async fn start_with_cwd(
1718        extension_paths: Vec<String>,
1719        load_cwd: impl Into<String>,
1720    ) -> Result<Arc<Self>, HostStartError> {
1721        Self::start_with_cwd_and_trust(extension_paths, load_cwd, false).await
1722    }
1723
1724    /// Resolve + spawn with an explicit load cwd and project-trust value.
1725    ///
1726    /// Resolution follows product policy: env/sibling precedence first, then
1727    /// [`acquire`](acquire::resolve_or_acquire) of the pinned host asset when
1728    /// nothing is configured and `extension_paths` is non-empty (no download
1729    /// ever happens without discovered extensions).
1730    ///
1731    /// # Errors
1732    ///
1733    /// See [`HostExtensionRunner::start`]; additionally
1734    /// [`HostStartError::Acquire`] when acquisition fails.
1735    pub async fn start_with_cwd_and_trust(
1736        extension_paths: Vec<String>,
1737        load_cwd: impl Into<String>,
1738        project_trusted: bool,
1739    ) -> Result<Arc<Self>, HostStartError> {
1740        let spec = acquire::resolve_or_acquire(!extension_paths.is_empty()).await?;
1741        Self::spawn_with_cwd_and_trust(&spec, extension_paths, load_cwd, project_trusted).await
1742    }
1743
1744    /// Spawn from an explicit [`HostSpec`] with a load cwd and project-trust
1745    /// value (the injection point for product-acquired hosts).
1746    ///
1747    /// # Errors
1748    ///
1749    /// See [`HostExtensionRunner::start`].
1750    pub async fn spawn_with_cwd_and_trust(
1751        spec: &HostSpec,
1752        extension_paths: Vec<String>,
1753        load_cwd: impl Into<String>,
1754        project_trusted: bool,
1755    ) -> Result<Arc<Self>, HostStartError> {
1756        let client =
1757            Arc::new(HostClient::spawn(spec).map_err(|e| HostStartError::Spawn(e.to_string()))?);
1758        let startup = Self::connect_with_cwd_and_trust(
1759            Arc::clone(&client),
1760            extension_paths,
1761            load_cwd,
1762            project_trusted,
1763            HOOK_TIMEOUT,
1764        )
1765        .await;
1766        Self::finish_startup(&client, startup).await
1767    }
1768
1769    /// Mark this runner stale (session replacement). Subsequent hooks and
1770    /// handler-presence queries short-circuit to no-ops; active slots are
1771    /// disposed so the host disposes the previous component generation.
1772    pub fn invalidate(&self) {
1773        self.inner.stale.store(true, Ordering::Relaxed);
1774        self.inner.dispose_all_slots();
1775    }
1776
1777    /// Graceful shutdown of the host client, exactly once. Repeated calls are
1778    /// no-ops. Slot subscriptions are disposed and the runner is marked
1779    /// disabled.
1780    pub async fn shutdown_once(&self) {
1781        Self::shutdown_once_with_inner(&self.inner).await;
1782    }
1783}
1784
1785fn clone_registry(source: &Registry) -> Registry {
1786    let mut copy = Registry::new();
1787    for tool in source.tools() {
1788        let _ = copy.register_tool(tool.clone());
1789    }
1790    for command in source.commands() {
1791        let _ = copy.register_command(command.clone());
1792    }
1793    for shortcut in source.shortcuts() {
1794        let _ = copy.register_shortcut(shortcut.clone());
1795    }
1796    for flag in source.flags() {
1797        let _ = copy.register_flag(flag.clone());
1798    }
1799    for renderer in source.renderers() {
1800        let _ = copy.register_renderer(renderer.clone());
1801    }
1802    for provider in source.providers() {
1803        let _ = copy.register_provider(provider.clone());
1804    }
1805    copy
1806}
1807
1808/// Spawn the unsolicited-event pump. Routes typed host events into the bounded
1809/// subscribers; on fatal host conditions marks the runner disabled and emits a
1810/// single non-retryable `extension_error`.
1811fn spawn_event_pump(inner: Arc<Inner>) {
1812    let mut rx = inner.client.subscribe();
1813    tokio::spawn(async move {
1814        loop {
1815            match rx.recv().await {
1816                Ok(HostEvent::UiRequest(request)) => {
1817                    if !inner.active() || !inner.ui_requests_claimed.load(Ordering::Acquire) {
1818                        let _ = inner.client.respond_ui(default_ui_response(&request)).await;
1819                    } else if let Err(error) = inner.ui_requests_tx.send(request).await {
1820                        let _ = inner.client.respond_ui(default_ui_response(&error.0)).await;
1821                    }
1822                }
1823                Ok(HostEvent::Notify(notification)) => {
1824                    inner.notify_send(notification);
1825                }
1826                Ok(HostEvent::UiSlot(slot)) => {
1827                    forward_slot(&inner, &slot);
1828                }
1829                Ok(HostEvent::DisposeSlot(d)) => {
1830                    forward_dispose(&inner, &d);
1831                }
1832                Ok(HostEvent::ToolUpdate(update)) => {
1833                    let _ = inner.tool_updates_tx.send(update);
1834                }
1835                Ok(HostEvent::ProviderEvent(event)) => {
1836                    let _ = inner.provider_events_tx.send(event);
1837                }
1838                Ok(HostEvent::ExtensionError(event)) => {
1839                    let _ = inner.errors_tx.send(event);
1840                }
1841                Ok(HostEvent::Raw(frame)) => {
1842                    inner.disabled.store(true, Ordering::Relaxed);
1843                    inner.publish_error(
1844                        "extension_protocol",
1845                        &format!("unhandled host frame: {} {}", frame.kind, frame.method),
1846                        None,
1847                    );
1848                    HostExtensionRunner::shutdown_once_with_inner(&inner).await;
1849                    break;
1850                }
1851                Err(broadcast::error::RecvError::Lagged(skipped)) => {
1852                    inner.publish_error(
1853                        "extension_event_lagged",
1854                        &format!("dropped {skipped} extension host events"),
1855                        None,
1856                    );
1857                }
1858                Ok(HostEvent::Eof) => {
1859                    // Host stdout closed: fatal. Disable once, report, then
1860                    // shut down / reap the transport exactly once before exit.
1861                    inner.disabled.store(true, Ordering::Relaxed);
1862                    inner.publish_error("extension_closed", "extension host stream closed", None);
1863                    HostExtensionRunner::shutdown_once_with_inner(&inner).await;
1864                    break;
1865                }
1866                Ok(HostEvent::ProtocolError(message)) => {
1867                    inner.disabled.store(true, Ordering::Relaxed);
1868                    inner.publish_error("extension_protocol", &message, None);
1869                    HostExtensionRunner::shutdown_once_with_inner(&inner).await;
1870                    break;
1871                }
1872                Err(broadcast::error::RecvError::Closed) => break,
1873            }
1874        }
1875    });
1876}
1877
1878fn default_ui_response(request: &HostUiRequest) -> HostUiResponse {
1879    match request {
1880        HostUiRequest::Select { id, .. } => HostUiResponse::Select {
1881            id: *id,
1882            value: None,
1883        },
1884        HostUiRequest::Confirm { id, .. } => HostUiResponse::Confirm {
1885            id: *id,
1886            confirmed: false,
1887        },
1888        HostUiRequest::Input { id, .. } => HostUiResponse::Input {
1889            id: *id,
1890            value: None,
1891        },
1892        HostUiRequest::Editor { id, .. } => HostUiResponse::Editor {
1893            id: *id,
1894            value: None,
1895        },
1896    }
1897}
1898
1899fn forward_slot(inner: &Arc<Inner>, slot: &UiSlot) {
1900    // Rust is the trust boundary: re-scrub every run/style/link field even
1901    // though the host is supposed to send structured runs.
1902    let sanitized = sanitize_slot(slot);
1903    if sanitized.had_rejections {
1904        inner.publish_error(
1905            "extension_sanitized",
1906            "extension uiSlot contained rejected control sequences or oversized fields",
1907            None,
1908        );
1909    }
1910    inner.slot_send(sanitized);
1911}
1912
1913fn forward_dispose(inner: &Arc<Inner>, dispose: &DisposeSlot) {
1914    inner.slot_dispose(&dispose.key);
1915}
1916
1917/// Strip `<script>` / `<style>` blocks and escape ampersand / angle brackets
1918/// so an extension-supplied HTML fragment cannot inject active content into an
1919/// exported session document. Attribute-level injection is out of scope for
1920/// the export path (fragments are written into a known template).
1921fn sanitize_html(html: &str) -> String {
1922    let lower = html.to_ascii_lowercase();
1923    let mut kept = String::with_capacity(html.len());
1924    let mut cursor = 0usize;
1925    while cursor < html.len() {
1926        let script_rel = lower[cursor..].find("<script");
1927        let style_rel = lower[cursor..].find("<style");
1928        // Earliest dangerous open tag and its matching close marker.
1929        let (start, open_len, close_marker): (usize, usize, &str) = match (script_rel, style_rel) {
1930            (None, None) => {
1931                kept.push_str(&html[cursor..]);
1932                break;
1933            }
1934            (Some(rel), None) => (cursor + rel, "<script".len(), "</script>"),
1935            (None, Some(rel)) => (cursor + rel, "<style".len(), "</style>"),
1936            (Some(script), Some(style)) => {
1937                if script <= style {
1938                    (cursor + script, "<script".len(), "</script>")
1939                } else {
1940                    (cursor + style, "<style".len(), "</style>")
1941                }
1942            }
1943        };
1944        // Preserve everything before the dangerous block.
1945        kept.push_str(&html[cursor..start]);
1946        let search_from = start + open_len;
1947        match lower[search_from..].find(close_marker) {
1948            Some(rel) => {
1949                cursor = search_from + rel + close_marker.len();
1950            }
1951            None => {
1952                // Unterminated dangerous block: drop the remainder entirely.
1953                cursor = html.len();
1954            }
1955        }
1956    }
1957    // Escape the surviving markup so no raw tags can become active in the
1958    // exported document.
1959    kept.replace('&', "&amp;")
1960        .replace('<', "&lt;")
1961        .replace('>', "&gt;")
1962}
1963
1964/// Assistant metadata without the growing `content` array.
1965fn compact_assistant_meta(message: &AssistantMessage) -> Value {
1966    let mut value = serde_json::to_value(message).unwrap_or_else(|_| Value::Object(Map::new()));
1967    if let Value::Object(object) = &mut value {
1968        object.remove("content");
1969    }
1970    value
1971}
1972
1973/// The single content block addressed by a streaming event, if any.
1974fn compact_assistant_block(message: &AssistantMessage, content_index: u64) -> Value {
1975    usize::try_from(content_index)
1976        .ok()
1977        .and_then(|index| message.content.get(index))
1978        .and_then(|content| serde_json::to_value(content).ok())
1979        .unwrap_or(Value::Null)
1980}
1981
1982fn compact_message_update_event(event: &AssistantMessageEvent) -> Value {
1983    use AssistantMessageEvent as Ev;
1984    // (type name, partial, contentIndex, delta text, include block)
1985    let (kind, partial, content_index, delta, with_block) = match event {
1986        Ev::Start { partial } => ("start", partial, None, None, false),
1987        Ev::TextStart {
1988            content_index,
1989            partial,
1990        } => ("text_start", partial, Some(*content_index), None, true),
1991        Ev::TextDelta {
1992            content_index,
1993            delta,
1994            partial,
1995        } => (
1996            "text_delta",
1997            partial,
1998            Some(*content_index),
1999            Some(delta),
2000            false,
2001        ),
2002        Ev::TextEnd {
2003            content_index,
2004            partial,
2005            ..
2006        } => ("text_end", partial, Some(*content_index), None, true),
2007        Ev::ThinkingStart {
2008            content_index,
2009            partial,
2010        } => ("thinking_start", partial, Some(*content_index), None, true),
2011        Ev::ThinkingDelta {
2012            content_index,
2013            delta,
2014            partial,
2015        } => (
2016            "thinking_delta",
2017            partial,
2018            Some(*content_index),
2019            Some(delta),
2020            false,
2021        ),
2022        Ev::ThinkingEnd {
2023            content_index,
2024            partial,
2025            ..
2026        } => ("thinking_end", partial, Some(*content_index), None, true),
2027        Ev::ToolCallStart {
2028            content_index,
2029            partial,
2030        } => ("toolcall_start", partial, Some(*content_index), None, true),
2031        Ev::ToolCallDelta {
2032            content_index,
2033            delta,
2034            partial,
2035        } => (
2036            "toolcall_delta",
2037            partial,
2038            Some(*content_index),
2039            Some(delta),
2040            false,
2041        ),
2042        Ev::ToolCallEnd {
2043            content_index,
2044            partial,
2045            ..
2046        } => ("toolcall_end", partial, Some(*content_index), None, true),
2047        Ev::Done { reason, message } => {
2048            return serde_json::json!({
2049                "type": "done",
2050                "reason": reason,
2051                "final": message,
2052            });
2053        }
2054        Ev::Error { reason, error } => {
2055            return serde_json::json!({
2056                "type": "error",
2057                "reason": reason,
2058                "final": error,
2059            });
2060        }
2061    };
2062
2063    let mut object = Map::new();
2064    object.insert("type".to_owned(), Value::String(kind.to_owned()));
2065    object.insert("meta".to_owned(), compact_assistant_meta(partial));
2066    if let Some(content_index) = content_index {
2067        object.insert("contentIndex".to_owned(), Value::from(content_index));
2068        if with_block {
2069            object.insert(
2070                "block".to_owned(),
2071                compact_assistant_block(partial, content_index),
2072            );
2073        }
2074    }
2075    if let Some(delta) = delta {
2076        object.insert("delta".to_owned(), Value::String(delta.clone()));
2077    }
2078    Value::Object(object)
2079}
2080
2081// ---------------------------------------------------------------------------
2082// ExtensionRunner trait impl
2083// ---------------------------------------------------------------------------
2084
2085impl ExtensionRunner for HostExtensionRunner {
2086    fn has_handlers(&self, event: &str) -> bool {
2087        self.inner.has_handlers(event)
2088    }
2089
2090    fn emit(
2091        &self,
2092        event: AgentSessionEvent,
2093    ) -> BoxFuture<
2094        '_,
2095        Result<Option<CancelResult>, super::agent_session::extension_runner::ExtensionRunnerError>,
2096    > {
2097        let inner = Arc::clone(&self.inner);
2098        Box::pin(async move {
2099            let method = match event.type_name() {
2100                "compaction_start" => "session_before_compact",
2101                "compaction_end" => "session_compact",
2102                "thinking_level_changed" => "thinking_level_select",
2103                name => name,
2104            };
2105            if !inner.has_handlers(method) {
2106                return Ok(None);
2107            }
2108            let payload =
2109                serde_json::to_value(&event).unwrap_or_else(|_| Value::Object(Map::new()));
2110            match inner.hook_request(method, payload).await {
2111                Ok(frame) => {
2112                    let result = serde_json::from_value::<Option<CancelWire>>(frame.payload)
2113                        .ok()
2114                        .flatten()
2115                        .map(|wire| CancelResult {
2116                            cancel: wire.cancel,
2117                            reason: wire.reason,
2118                        });
2119                    Ok(result)
2120                }
2121                Err(err) => {
2122                    inner.report_host_error(&err);
2123                    Ok(None)
2124                }
2125            }
2126        })
2127    }
2128
2129    fn emit_message_update_delta<'a>(
2130        &'a self,
2131        event: &'a AssistantMessageEvent,
2132    ) -> BoxFuture<
2133        'a,
2134        Result<Option<CancelResult>, super::agent_session::extension_runner::ExtensionRunnerError>,
2135    > {
2136        let inner = Arc::clone(&self.inner);
2137        let payload = serde_json::json!({
2138            "type": MESSAGE_UPDATE_DELTA_METHOD,
2139            "event": compact_message_update_event(event),
2140        });
2141        Box::pin(async move {
2142            if !inner.has_handlers("message_update") {
2143                return Ok(None);
2144            }
2145            match inner
2146                .hook_request(MESSAGE_UPDATE_DELTA_METHOD, payload)
2147                .await
2148            {
2149                Ok(frame) => {
2150                    let result = serde_json::from_value::<Option<CancelWire>>(frame.payload)
2151                        .ok()
2152                        .flatten()
2153                        .map(|wire| CancelResult {
2154                            cancel: wire.cancel,
2155                            reason: wire.reason,
2156                        });
2157                    Ok(result)
2158                }
2159                Err(error) => {
2160                    inner.report_host_error(&error);
2161                    Ok(None)
2162                }
2163            }
2164        })
2165    }
2166
2167    fn emit_message_end(
2168        &self,
2169        message: AgentMessage,
2170    ) -> BoxFuture<
2171        '_,
2172        Result<Option<AgentMessage>, super::agent_session::extension_runner::ExtensionRunnerError>,
2173    > {
2174        let inner = Arc::clone(&self.inner);
2175        Box::pin(async move {
2176            if !inner.has_handlers("message_end") {
2177                return Ok(None);
2178            }
2179            let payload = serde_json::to_value(&message).unwrap_or(Value::Null);
2180            match inner.hook_request("message_end", payload).await {
2181                Ok(frame) => {
2182                    let replacement = serde_json::from_value::<MessageEndWire>(frame.payload)
2183                        .ok()
2184                        .and_then(|wire| wire.message);
2185                    // Enforce the role-preservation invariant the host merge
2186                    // guarantees; a mismatched role is dropped + reported.
2187                    let role_matches = replacement
2188                        .as_ref()
2189                        .is_some_and(|replacement| replacement.role() == message.role());
2190                    match replacement {
2191                        Some(message) if role_matches => Ok(Some(message)),
2192                        Some(_) => {
2193                            inner.publish_error(
2194                                "extension_message_end",
2195                                "message_end handler returned a message with a different role",
2196                                None,
2197                            );
2198                            Ok(None)
2199                        }
2200                        None => Ok(None),
2201                    }
2202                }
2203                Err(err) => {
2204                    inner.report_host_error(&err);
2205                    Ok(None)
2206                }
2207            }
2208        })
2209    }
2210
2211    fn emit_tool_call(
2212        &self,
2213        tool_name: &str,
2214        tool_call_id: &str,
2215        input: Map<String, Value>,
2216    ) -> BoxFuture<
2217        '_,
2218        Result<
2219            Option<BeforeToolCallResult>,
2220            super::agent_session::extension_runner::ExtensionRunnerError,
2221        >,
2222    > {
2223        let inner = Arc::clone(&self.inner);
2224        let tool_name = tool_name.to_owned();
2225        let tool_call_id = tool_call_id.to_owned();
2226        Box::pin(async move {
2227            if !inner.has_handlers("tool_call") {
2228                return Ok(None);
2229            }
2230            let payload = serde_json::json!({
2231                "toolName": tool_name,
2232                "toolCallId": tool_call_id,
2233                "input": input,
2234            });
2235            match inner.hook_request("tool_call", payload).await {
2236                Ok(frame) => {
2237                    let result =
2238                        serde_json::from_value::<Option<BeforeToolCallWire>>(frame.payload)
2239                            .ok()
2240                            .flatten()
2241                            .map(|wire| BeforeToolCallResult {
2242                                block: wire.block,
2243                                reason: wire.reason,
2244                            });
2245                    Ok(result)
2246                }
2247                Err(err) => {
2248                    inner.report_host_error(&err);
2249                    Ok(None)
2250                }
2251            }
2252        })
2253    }
2254
2255    fn emit_tool_result(
2256        &self,
2257        tool_name: &str,
2258        tool_call_id: &str,
2259        input: Map<String, Value>,
2260        content: Vec<ToolResultContent>,
2261        details: Value,
2262        is_error: bool,
2263    ) -> BoxFuture<
2264        '_,
2265        Result<
2266            Option<AfterToolCallResult>,
2267            super::agent_session::extension_runner::ExtensionRunnerError,
2268        >,
2269    > {
2270        let inner = Arc::clone(&self.inner);
2271        let tool_name = tool_name.to_owned();
2272        let tool_call_id = tool_call_id.to_owned();
2273        Box::pin(async move {
2274            if !inner.has_handlers("tool_result") {
2275                return Ok(None);
2276            }
2277            let payload = serde_json::json!({
2278                "toolName": tool_name,
2279                "toolCallId": tool_call_id,
2280                "input": input,
2281                "content": content,
2282                "details": details,
2283                "isError": is_error,
2284            });
2285            match inner.hook_request("tool_result", payload).await {
2286                Ok(frame) => {
2287                    let result = serde_json::from_value::<Option<AfterToolCallWire>>(frame.payload)
2288                        .ok()
2289                        .flatten()
2290                        .map(|wire| AfterToolCallResult {
2291                            content: wire.content,
2292                            details: wire.details,
2293                            is_error: wire.is_error,
2294                            terminate: wire.terminate,
2295                        });
2296                    Ok(result)
2297                }
2298                Err(err) => {
2299                    inner.report_host_error(&err);
2300                    Ok(None)
2301                }
2302            }
2303        })
2304    }
2305
2306    fn emit_input(
2307        &self,
2308        text: &str,
2309        images: Option<Value>,
2310        source: &str,
2311        streaming_behavior: Option<&str>,
2312    ) -> BoxFuture<
2313        '_,
2314        Result<InputTransformResult, super::agent_session::extension_runner::ExtensionRunnerError>,
2315    > {
2316        let inner = Arc::clone(&self.inner);
2317        let text = text.to_owned();
2318        let source = source.to_owned();
2319        let streaming_behavior = streaming_behavior.map(str::to_owned);
2320        Box::pin(async move {
2321            if !inner.has_handlers("input") {
2322                return Ok(InputTransformResult::default());
2323            }
2324            let payload = serde_json::json!({
2325                "text": text,
2326                "images": images,
2327                "source": source,
2328                "streamingBehavior": streaming_behavior,
2329            });
2330            match inner.hook_request("input", payload).await {
2331                Ok(frame) => {
2332                    let (handled, mapped_text, mapped_images) =
2333                        match serde_json::from_value::<InputTransformWire>(frame.payload) {
2334                            Ok(InputTransformWire::Handled) => (true, None, None),
2335                            Ok(InputTransformWire::Transform { text, images }) => {
2336                                (false, Some(text), images)
2337                            }
2338                            _ => (false, None, None),
2339                        };
2340                    Ok(InputTransformResult {
2341                        handled,
2342                        text: mapped_text,
2343                        images: mapped_images,
2344                    })
2345                }
2346                Err(err) => {
2347                    inner.report_host_error(&err);
2348                    Ok(InputTransformResult::default())
2349                }
2350            }
2351        })
2352    }
2353
2354    fn emit_before_agent_start(
2355        &self,
2356        prompt: &str,
2357        images: Option<Value>,
2358    ) -> BoxFuture<
2359        '_,
2360        Result<
2361            Option<BeforeAgentStartResult>,
2362            super::agent_session::extension_runner::ExtensionRunnerError,
2363        >,
2364    > {
2365        let inner = Arc::clone(&self.inner);
2366        let prompt = prompt.to_owned();
2367        Box::pin(async move {
2368            if !inner.has_handlers("before_agent_start") {
2369                return Ok(None);
2370            }
2371            let payload = serde_json::json!({
2372                "prompt": prompt,
2373                "images": images,
2374            });
2375            match inner.hook_request("before_agent_start", payload).await {
2376                Ok(frame) => {
2377                    let wire =
2378                        serde_json::from_value::<Option<BeforeAgentStartWire>>(frame.payload)
2379                            .ok()
2380                            .flatten();
2381                    Ok(wire.map(|wire| BeforeAgentStartResult {
2382                        messages: wire.messages,
2383                        system_prompt: wire.system_prompt,
2384                    }))
2385                }
2386                Err(err) => {
2387                    inner.report_host_error(&err);
2388                    Ok(None)
2389                }
2390            }
2391        })
2392    }
2393
2394    fn emit_resources_discover(
2395        &self,
2396        cwd: &str,
2397        reason: &str,
2398    ) -> BoxFuture<
2399        '_,
2400        Result<
2401            ResourceExtensionPaths,
2402            super::agent_session::extension_runner::ExtensionRunnerError,
2403        >,
2404    > {
2405        let inner = Arc::clone(&self.inner);
2406        let cwd = cwd.to_owned();
2407        let reason = reason.to_owned();
2408        Box::pin(async move {
2409            if !inner.has_handlers("resources_discover") {
2410                return Ok(ResourceExtensionPaths::default());
2411            }
2412            let payload = serde_json::json!({ "cwd": cwd, "reason": reason });
2413            match inner.hook_request("resources_discover", payload).await {
2414                Ok(frame) => {
2415                    let wire = serde_json::from_value::<ResourcesDiscoverWire>(frame.payload)
2416                        .unwrap_or_default();
2417                    let discovered = |paths: Option<Vec<ResourcePathWire>>| {
2418                        paths
2419                            .unwrap_or_default()
2420                            .into_iter()
2421                            .map(|entry| {
2422                                ExtensionResourcePath::discovered(entry.path, &entry.extension_path)
2423                            })
2424                            .collect()
2425                    };
2426                    Ok(ResourceExtensionPaths {
2427                        skill_paths: discovered(wire.skills),
2428                        prompt_paths: discovered(wire.prompts),
2429                        theme_paths: discovered(wire.themes),
2430                    })
2431                }
2432                Err(err) => {
2433                    inner.report_host_error(&err);
2434                    Ok(ResourceExtensionPaths::default())
2435                }
2436            }
2437        })
2438    }
2439
2440    fn get_registered_commands(&self) -> Vec<String> {
2441        self.inner
2442            .snapshot
2443            .read()
2444            .map(|guard| {
2445                guard
2446                    .registry
2447                    .commands()
2448                    .iter()
2449                    .map(|command| command.name.clone())
2450                    .collect()
2451            })
2452            .unwrap_or_default()
2453    }
2454
2455    fn execute_command(
2456        &self,
2457        name: &str,
2458        args: &str,
2459    ) -> BoxFuture<'_, Result<bool, super::agent_session::extension_runner::ExtensionRunnerError>>
2460    {
2461        let inner = Arc::clone(&self.inner);
2462        let name = name.to_owned();
2463        let args = args.to_owned();
2464        Box::pin(async move {
2465            if !inner.has_command(&name) {
2466                return Ok(false);
2467            }
2468            let Some(_permit) = inner.traffic.enter() else {
2469                return Ok(false);
2470            };
2471            if !inner.active() {
2472                return Ok(false);
2473            }
2474            let payload = serde_json::json!({ "name": name, "args": args });
2475            match inner
2476                .client
2477                .request_raw(COMMAND_EXECUTE_METHOD, payload, inner.hook_timeout)
2478                .await
2479            {
2480                Ok(frame) => {
2481                    let ok = serde_json::from_value::<CommandExecuteWire>(frame.payload)
2482                        .is_ok_and(|wire| wire.ok);
2483                    Ok(ok)
2484                }
2485                Err(err) => {
2486                    inner.report_host_error(&err);
2487                    Ok(true)
2488                }
2489            }
2490        })
2491    }
2492
2493    fn get_all_registered_tools(&self) -> HashMap<String, Arc<dyn AgentTool>> {
2494        self.inner
2495            .snapshot
2496            .read()
2497            .map(|guard| guard.tools.clone())
2498            .unwrap_or_default()
2499    }
2500
2501    fn get_flag_values(&self) -> HashMap<String, Value> {
2502        self.inner
2503            .flag_values
2504            .read()
2505            .map(|guard| guard.clone())
2506            .unwrap_or_default()
2507    }
2508
2509    fn invalidate(&self) {
2510        HostExtensionRunner::invalidate(self);
2511    }
2512
2513    fn emit_error(&self, message: String) {
2514        self.inner.publish_error("extension_error", &message, None);
2515    }
2516}
2517
2518impl HostExtensionRunner {
2519    async fn shutdown_once_with_inner(inner: &Arc<Inner>) {
2520        let _guard = inner.shutdown_lock.lock().await;
2521        if inner.shutdown_done.load(Ordering::Relaxed) {
2522            return;
2523        }
2524        Self::reap_inner(inner).await;
2525        inner.shutdown_done.store(true, Ordering::Relaxed);
2526    }
2527
2528    async fn reap_inner(inner: &Arc<Inner>) {
2529        // Order matters for the slot_send/slot_dispose teardown gate: the
2530        // flag must be set before dispose_all_slots takes the slots lock.
2531        inner.disabled.store(true, Ordering::Relaxed);
2532        inner.dispose_all_slots();
2533        let _ = inner.client.shutdown().await;
2534    }
2535}
2536
2537/// Product-policy acquisition of the pinned extension-host release asset.
2538pub mod acquire;
2539
2540#[cfg(test)]
2541mod tests;