Skip to main content

rpi_cli/
js_extensions.rs

1//! Minimal Pi JS/TS extension host.
2//!
3//! A single Node child process owns loaded extensions for the session. Rust
4//! exchanges JSON-lines requests with it, keeping extension code isolated from
5//! the agent process while still exposing Pi's tool and resource contracts.
6
7use std::fs::OpenOptions;
8use std::io::Write;
9use std::path::PathBuf;
10use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
11use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
12use std::sync::{mpsc as std_mpsc, Arc, Mutex};
13use std::time::Duration;
14
15use async_trait::async_trait;
16use rpi_agent::{
17    AgentError, AgentTool, AgentToolResult, TextContentOrImage, ToolExecutionMode,
18    ToolResultPartial,
19};
20use rpi_ai::types::{Context, Schema, Tool};
21use rpi_ai::{CacheRetention, Model, Provider, SimpleStreamOptions};
22use rpi_tui::TUI;
23use tokio_util::sync::CancellationToken;
24
25use crate::node_transport::{NodeTransport, NodeTransportStartup, RuntimeHandler};
26
27const NODE_HOST: &str = include_str!("node_host.mjs");
28static NEXT_NODE_HOST_SCRIPT: AtomicU64 = AtomicU64::new(1);
29const NODE_HOST_STOPPED: &str = "Node extension host stopped";
30const NODE_PREPARATION_CANCELLED: &str = "Node extension preparation cancelled";
31
32#[derive(Debug, Clone, Default)]
33pub struct JsResources {
34    pub skill_paths: Vec<PathBuf>,
35    pub prompt_paths: Vec<PathBuf>,
36    pub theme_paths: Vec<PathBuf>,
37}
38
39#[derive(Debug, Clone)]
40struct JsToolDefinition {
41    name: String,
42    label: String,
43    execution_mode: ToolExecutionMode,
44    tool: Tool,
45}
46
47#[derive(Clone)]
48pub struct JsExtensionSession {
49    transport: LazyNodeTransport,
50    tools: Arc<Vec<JsToolDefinition>>,
51    pub resources: JsResources,
52    pub commands: Vec<String>,
53    capabilities: Arc<Mutex<Vec<String>>>,
54    custom_active: Arc<Mutex<Option<String>>>,
55    custom_visible: Arc<Mutex<bool>>,
56    custom_input_waiters: Arc<Mutex<std::collections::HashMap<u64, std_mpsc::Sender<bool>>>>,
57    next_custom_input_id: Arc<AtomicU64>,
58    api_version: u32,
59}
60
61impl JsExtensionSession {
62    pub fn load(paths: &[PathBuf], _verbose: bool) -> Result<Option<Self>, String> {
63        Self::load_with_context(paths, _verbose, serde_json::json!({}))
64    }
65
66    pub fn load_with_context(
67        paths: &[PathBuf],
68        _verbose: bool,
69        context: serde_json::Value,
70    ) -> Result<Option<Self>, String> {
71        let paths: Vec<PathBuf> = paths
72            .iter()
73            .filter(|path| path.exists())
74            .map(normalize_host_path)
75            .collect();
76        if paths.is_empty() {
77            return Ok(None);
78        }
79        let (_, init) = start_node_transport(&paths, &context, true, &[])?.wait()?;
80        if !init
81            .get("ok")
82            .and_then(serde_json::Value::as_bool)
83            .unwrap_or(false)
84        {
85            return Err(init
86                .get("error")
87                .and_then(serde_json::Value::as_str)
88                .unwrap_or("Node extension load failed")
89                .to_string());
90        }
91        let result = init.get("result").cloned().unwrap_or_default();
92        let mut definitions = Vec::new();
93        for item in result
94            .get("tools")
95            .and_then(serde_json::Value::as_array)
96            .into_iter()
97            .flatten()
98        {
99            let name = item
100                .get("name")
101                .and_then(serde_json::Value::as_str)
102                .unwrap_or_default()
103                .to_string();
104            if name.is_empty() {
105                continue;
106            }
107            let label = item
108                .get("label")
109                .and_then(serde_json::Value::as_str)
110                .unwrap_or(&name)
111                .to_string();
112            let description = item
113                .get("description")
114                .and_then(serde_json::Value::as_str)
115                .unwrap_or_default()
116                .to_string();
117            let parameters = item
118                .get("parameters")
119                .cloned()
120                .unwrap_or_else(|| serde_json::json!({"type":"object","properties":{}}));
121            let execution_mode = match item
122                .get("executionMode")
123                .and_then(serde_json::Value::as_str)
124            {
125                Some("sequential") => ToolExecutionMode::Sequential,
126                _ => ToolExecutionMode::Parallel,
127            };
128            definitions.push(JsToolDefinition {
129                name: name.clone(),
130                label,
131                execution_mode,
132                tool: Tool {
133                    name,
134                    description,
135                    parameters: Schema::new(parameters),
136                    constrained_sampling: None,
137                },
138            });
139        }
140        let resources = parse_resources(result.get("resources"));
141        let api_version = result
142            .get("apiVersion")
143            .and_then(serde_json::Value::as_u64)
144            .unwrap_or(crate::extension_api::EXTENSION_API_VERSION as u64)
145            as u32;
146        let commands = result
147            .get("commands")
148            .and_then(serde_json::Value::as_array)
149            .map(|values| {
150                values
151                    .iter()
152                    .filter_map(|value| value.as_str().map(String::from))
153                    .collect()
154            })
155            .unwrap_or_default();
156        let capabilities = result
157            .get("capabilities")
158            .and_then(serde_json::Value::as_array)
159            .map(|values| {
160                values
161                    .iter()
162                    .filter_map(|value| value.as_str().map(String::from))
163                    .collect()
164            })
165            .unwrap_or_default();
166        // The discovery host already ran the startup lifecycle hook to derive
167        // the initial JS active-tool projection. Carry that projection into
168        // the later persistent host so it can skip replaying the same hook
169        // during module initialization. A second lifecycle invocation would
170        // duplicate user-visible side effects for extensions that subscribe to
171        // `before_agent_start`.
172        let mut persistent_context = context;
173        if let Some(active_tools) = result.get("activeTools") {
174            persistent_context["activeTools"] = active_tools.clone();
175        }
176        let transport = LazyNodeTransport::new(paths, persistent_context);
177        transport.record_active_tools(result.get("activeTools"));
178        Ok(Some(Self {
179            transport,
180            tools: Arc::new(definitions),
181            resources,
182            commands,
183            capabilities: Arc::new(Mutex::new(capabilities)),
184            custom_active: Arc::new(Mutex::new(None)),
185            custom_visible: Arc::new(Mutex::new(false)),
186            custom_input_waiters: Arc::new(Mutex::new(std::collections::HashMap::new())),
187            next_custom_input_id: Arc::new(AtomicU64::new(1)),
188            api_version,
189        }))
190    }
191
192    pub fn capabilities(&self) -> Vec<String> {
193        self.capabilities
194            .lock()
195            .map(|values| values.clone())
196            .unwrap_or_default()
197    }
198
199    pub fn tools(&self) -> impl Iterator<Item = JsToolAdapter> + '_ {
200        self.tools.iter().cloned().map(|definition| JsToolAdapter {
201            session: self.clone(),
202            definition,
203        })
204    }
205
206    fn invoke(
207        &self,
208        method: &str,
209        payload: serde_json::Value,
210    ) -> Result<serde_json::Value, String> {
211        self.transport.request(method, payload)
212    }
213
214    pub fn invoke_command(&self, command: &str, args: &str) -> Result<serde_json::Value, String> {
215        self.invoke_command_with_context(command, args, serde_json::json!({}))
216    }
217
218    pub fn invoke_command_with_context(
219        &self,
220        command: &str,
221        args: &str,
222        context: serde_json::Value,
223    ) -> Result<serde_json::Value, String> {
224        let result = self.invoke(
225            "invoke_command",
226            serde_json::json!({"command": command, "args": args, "context": context}),
227        )?;
228        self.transport
229            .record_active_tools(result.get("activeTools"));
230        Ok(result)
231    }
232
233    pub fn set_runtime_context(&self, context: serde_json::Value) -> Result<(), String> {
234        self.transport.set_runtime_context(context)
235    }
236
237    pub fn set_runtime_handler(&self, handler: RuntimeHandler) -> Result<(), String> {
238        self.transport.replace_runtime_handlers(handler)
239    }
240
241    pub fn add_runtime_handler(&self, handler: RuntimeHandler) -> Result<(), String> {
242        self.transport.add_runtime_handler(handler)
243    }
244
245    /// Stop the persistent Node host even when detached command/tool work still
246    /// holds a clone of this session. The transport wakes those callers, and
247    /// repeated calls are harmless.
248    pub fn shutdown(&self) {
249        self.clear_custom_state();
250        self.transport.shutdown();
251    }
252
253    fn clear_custom_state(&self) {
254        if let Ok(mut active) = self.custom_active.lock() {
255            *active = None;
256        }
257        if let Ok(mut visible) = self.custom_visible.lock() {
258            *visible = false;
259        }
260        let waiters = self
261            .custom_input_waiters
262            .lock()
263            .map(|mut waiters| {
264                waiters
265                    .drain()
266                    .map(|(_, sender)| sender)
267                    .collect::<Vec<_>>()
268            })
269            .unwrap_or_default();
270        for sender in waiters {
271            let _ = sender.send(false);
272        }
273    }
274
275    pub fn custom_active(&self) -> bool {
276        self.custom_active
277            .lock()
278            .map(|value| value.is_some())
279            .unwrap_or(false)
280    }
281
282    /// Names of JS tools currently selected by the extension runtime. The
283    /// returned list intentionally contains only the Node-side view; callers
284    /// merge it with Rust built-ins before updating the harness lane.
285    pub fn active_tools(&self) -> Option<Vec<String>> {
286        self.transport.active_tools()
287    }
288
289    pub fn tool_names(&self) -> Vec<String> {
290        self.tools.iter().map(|tool| tool.name.clone()).collect()
291    }
292
293    /// Whether the active custom component should receive terminal input.
294    /// Hidden overlay handles keep their promise alive but release keyboard
295    /// focus back to the outer TUI.
296    pub fn custom_accepts_input(&self) -> bool {
297        self.custom_active()
298            && self
299                .custom_visible
300                .lock()
301                .map(|value| *value)
302                .unwrap_or(false)
303    }
304
305    pub fn send_custom_input(&self, data: &str) -> Result<(), String> {
306        let id = self
307            .custom_active
308            .lock()
309            .map_err(|_| "Node custom state lock poisoned")?
310            .clone()
311            .ok_or("no active Node custom UI")?;
312        // Visible custom components own the key stream. Their component
313        // handler can be asynchronous, so do not wait for the hidden-overlay
314        // consumption acknowledgement on every keystroke.
315        self.transport.send_event(serde_json::json!({
316            "type": "host_event",
317            "event": "custom_input",
318            "customId": id,
319            "data": data,
320        }))
321    }
322
323    /// Send raw input to the Node custom component and return whether a
324    /// `ctx.ui.onTerminalInput` listener consumed it. Hidden overlays still
325    /// need to see the event for their reopen shortcut, while unconsumed input
326    /// must fall through to the outer editor.
327    pub fn send_custom_input_with_consumed(&self, data: &str) -> Result<bool, String> {
328        let id = self
329            .custom_active
330            .lock()
331            .map_err(|_| "Node custom state lock poisoned")?
332            .clone()
333            .ok_or("no active Node custom UI")?;
334        let hidden = !self.custom_accepts_input();
335        let input_id = self.next_custom_input_id.fetch_add(1, Ordering::Relaxed);
336        let (sender, receiver) = std_mpsc::channel();
337        self.custom_input_waiters
338            .lock()
339            .map_err(|_| "Node custom input lock poisoned")?
340            .insert(input_id, sender);
341        let event = serde_json::json!({
342            "type": "host_event",
343            "event": "custom_input",
344            "customId": id,
345            "data": data,
346            "inputId": input_id,
347            "hidden": hidden,
348        });
349        if let Err(error) = self.transport.send_event(event) {
350            if let Ok(mut waiters) = self.custom_input_waiters.lock() {
351                waiters.remove(&input_id);
352            }
353            return Err(error);
354        }
355        match receiver.recv_timeout(Duration::from_millis(250)) {
356            Ok(consumed) => Ok(consumed),
357            Err(std_mpsc::RecvTimeoutError::Timeout) => {
358                if let Ok(mut waiters) = self.custom_input_waiters.lock() {
359                    waiters.remove(&input_id);
360                }
361                // A crashed or older host must not permanently lock the outer
362                // editor. Treat a missing acknowledgement as unconsumed.
363                Ok(false)
364            }
365            Err(std_mpsc::RecvTimeoutError::Disconnected) => {
366                if let Ok(mut waiters) = self.custom_input_waiters.lock() {
367                    waiters.remove(&input_id);
368                }
369                Ok(false)
370            }
371        }
372    }
373
374    pub fn send_custom_resize(&self, width: usize, height: usize) -> Result<(), String> {
375        let id = self
376            .custom_active
377            .lock()
378            .map_err(|_| "Node custom state lock poisoned")?
379            .clone()
380            .ok_or("no active Node custom UI")?;
381        let event = serde_json::json!({
382            "type": "host_event",
383            "event": "custom_resize",
384            "customId": id,
385            "width": width,
386            "height": height,
387        });
388        self.transport.send_event(event)
389    }
390
391    pub fn install_ui_runtime(&self, tui: Arc<rpi_tui::TuiAltScreen>) -> Result<(), String> {
392        let active = self.custom_active.clone();
393        let visible = self.custom_visible.clone();
394        let input_waiters = self.custom_input_waiters.clone();
395        self.add_runtime_handler(Arc::new(move |action, args| match action {
396            "ui.custom.open" => {
397                let id = args
398                    .get("customId")
399                    .and_then(serde_json::Value::as_str)
400                    .ok_or("ui.custom.open missing customId")?
401                    .to_string();
402                *visible
403                    .lock()
404                    .map_err(|_| "Node custom visibility lock poisoned")? = true;
405                *active
406                    .lock()
407                    .map_err(|_| "Node custom state lock poisoned")? = Some(id.clone());
408                tui.set_render_suspended(true);
409                tui.terminal().clear_screen();
410                let info = tui.terminal().info();
411                Ok(serde_json::json!({
412                    "columns": info.columns,
413                    "rows": info.rows,
414                }))
415            }
416            "ui.custom.write" => {
417                let data = args
418                    .get("data")
419                    .and_then(serde_json::Value::as_str)
420                    .ok_or("ui.custom.write missing data")?;
421                tui.terminal().write(data);
422                Ok(serde_json::json!(true))
423            }
424            "ui.custom.invalidate" => {
425                tui.request_render(false);
426                Ok(serde_json::json!(true))
427            }
428            "ui.custom.input" => {
429                let input_id = args
430                    .get("inputId")
431                    .and_then(serde_json::Value::as_u64)
432                    .ok_or("ui.custom.input missing inputId")?;
433                let consumed = args
434                    .get("consumed")
435                    .and_then(serde_json::Value::as_bool)
436                    .unwrap_or(false);
437                if let Ok(mut waiters) = input_waiters.lock() {
438                    if let Some(sender) = waiters.remove(&input_id) {
439                        let _ = sender.send(consumed);
440                    }
441                }
442                Ok(serde_json::json!(consumed))
443            }
444            "ui.custom.handle" => {
445                let operation = args
446                    .get("operation")
447                    .and_then(serde_json::Value::as_str)
448                    .ok_or("ui.custom.handle missing operation")?;
449                let id = args
450                    .get("customId")
451                    .and_then(serde_json::Value::as_str)
452                    .ok_or("ui.custom.handle missing customId")?;
453                let is_active = active
454                    .lock()
455                    .map_err(|_| "Node custom state lock poisoned")?
456                    .as_deref()
457                    == Some(id);
458                if !is_active {
459                    return Ok(serde_json::json!(false));
460                }
461                match operation {
462                    "setHidden" | "hide" => {
463                        let hidden = args
464                            .get("hidden")
465                            .and_then(serde_json::Value::as_bool)
466                            .unwrap_or(operation == "hide");
467                        tui.terminal().clear_screen();
468                        // A hidden custom overlay gives the outer TUI its
469                        // terminal back; showing it suspends the outer repaint
470                        // again so Node owns the screen.
471                        tui.set_render_suspended(!hidden);
472                        *visible
473                            .lock()
474                            .map_err(|_| "Node custom visibility lock poisoned")? = !hidden;
475                        if !hidden {
476                            tui.terminal().clear_screen();
477                        }
478                    }
479                    // Focus state is maintained by the Node-side handle. The
480                    // Rust host only needs to repaint when visibility changes.
481                    "focus" | "unfocus" => {}
482                    _ => return Err(format!("unsupported custom handle operation: {operation}")),
483                }
484                Ok(serde_json::json!(true))
485            }
486            "ui.custom.close" => {
487                let restore_custom_id = args
488                    .get("restoreCustomId")
489                    .and_then(serde_json::Value::as_str)
490                    .map(String::from);
491                let restore_hidden = args
492                    .get("restoreHidden")
493                    .and_then(serde_json::Value::as_bool)
494                    .unwrap_or(false);
495                *visible
496                    .lock()
497                    .map_err(|_| "Node custom visibility lock poisoned")? =
498                    restore_custom_id.is_some() && !restore_hidden;
499                *active
500                    .lock()
501                    .map_err(|_| "Node custom state lock poisoned")? = restore_custom_id.clone();
502                // Each custom open clears the terminal. Clear again before a
503                // parent is restored so the child's frame cannot remain
504                // behind the parent's first render.
505                tui.terminal().clear_screen();
506                // A nested custom UI closes back into its parent custom UI.
507                // Keep the outer TUI suspended until the whole custom stack is
508                // gone; otherwise the editor renderer races the restored
509                // component and overwrites its terminal output.
510                if restore_custom_id.is_none() || restore_hidden {
511                    tui.set_render_suspended(false);
512                }
513                Ok(serde_json::json!(true))
514            }
515            "ui.custom.resize" => Ok(serde_json::json!(true)),
516            _ => Err(format!("unsupported capability: {action}")),
517        }))?;
518        self.enable_capability("ui.custom")?;
519        self.set_runtime_context(serde_json::json!({
520            "mode": "tui",
521            "hasUI": true,
522            "capabilities":["ui.custom"]
523        }))
524    }
525
526    /// Start the lazy Node host after all interactive runtime handlers are
527    /// installed and the TUI input worker is running. Startup executes the
528    /// `before_agent_start` lifecycle hook, which may wait on a native dialog;
529    /// callers must therefore invoke this only once the dialog bridge can
530    /// service pending requests.
531    pub fn ensure_runtime(&self) -> Result<(), String> {
532        self.transport.ensure().map(|_| ())
533    }
534
535    /// Prepare the Node runtime for one agent prompt. Starting a fresh lazy
536    /// host already runs `before_agent_start` during its context handoff;
537    /// prompts that reuse a live host send one context snapshot so the same
538    /// lifecycle runs exactly once again for that prompt.
539    pub fn prepare_for_prompt(&self) -> Result<(), String> {
540        self.prepare_for_prompt_with_cancellation(&CancellationToken::new())
541    }
542
543    pub(crate) fn prepare_for_prompt_with_cancellation(
544        &self,
545        cancellation: &CancellationToken,
546    ) -> Result<(), String> {
547        if cancellation.is_cancelled() {
548            return Err(NODE_PREPARATION_CANCELLED.into());
549        }
550        let already_live = self.transport.live_transport()?.is_some();
551        self.transport
552            .ensure_with_cancellation(Some(cancellation))?;
553        if cancellation.is_cancelled() {
554            return Err(NODE_PREPARATION_CANCELLED.into());
555        }
556        if already_live {
557            self.transport.set_runtime_context(serde_json::json!({}))?;
558        }
559        if cancellation.is_cancelled() {
560            return Err(NODE_PREPARATION_CANCELLED.into());
561        }
562        Ok(())
563    }
564
565    /// Interrupt only the host involved in prompt preparation. Unlike
566    /// [`Self::shutdown`], this leaves the lazy session reusable so a later
567    /// prompt can start a fresh host after the user aborts one stuck hook.
568    pub(crate) fn cancel_prompt_preparation(&self) {
569        self.clear_custom_state();
570        self.transport.stop_current();
571    }
572
573    /// Install the host side of the small dialog protocol used by
574    /// `ctx.ui.select/confirm/input/editor` in the Node runtime.
575    ///
576    /// The handler is deliberately supplied by the interactive TUI: opening a
577    /// dialog needs to swap/focus a native component and then wait for a key
578    /// press, while this module only owns the Node transport. It receives both
579    /// `ui.dialog` and `ui.dialog.cancel` actions and must return one of the
580    /// JSON result shapes documented in `node_host.mjs` (`{value}`,
581    /// `{confirmed}`, or `{cancelled:true}`).
582    pub fn install_ui_dialog_runtime(&self, handler: RuntimeHandler) -> Result<(), String> {
583        self.add_runtime_handler(handler)?;
584        for capability in ["ui.select", "ui.confirm", "ui.input", "ui.editor_dialog"] {
585            self.enable_capability(capability)?;
586        }
587        self.set_runtime_context(serde_json::json!({
588            "mode": "tui",
589            "hasUI": true,
590            "capabilities": ["ui.select", "ui.confirm", "ui.input", "ui.editor_dialog"],
591        }))
592    }
593
594    pub fn enable_capability(&self, capability: &str) -> Result<(), String> {
595        let mut capabilities = self
596            .capabilities
597            .lock()
598            .map_err(|_| "Node capability lock poisoned")?;
599        if !capabilities.iter().any(|value| value == capability) {
600            capabilities.push(capability.to_string());
601        }
602        Ok(())
603    }
604
605    /// Install the Rust provider as the Node Pi model registry's runtime
606    /// implementation. Node receives a complete AssistantMessage; Rust remains
607    /// the only owner of HTTP, authentication, retries, and provider details.
608    pub fn enable_provider_runtime(
609        &self,
610        provider: Arc<dyn Provider>,
611        runtime: tokio::runtime::Handle,
612    ) -> Result<(), String> {
613        let provider_id = provider.id().to_string();
614        self.set_runtime_handler(Arc::new(move |action, args| {
615            if !matches!(action, "provider.complete" | "provider.stream") {
616                return Err(format!("unsupported capability: {action}"));
617            }
618            let model: Model = serde_json::from_value(
619                args.get("model")
620                    .cloned()
621                    .ok_or("provider.complete missing model")?,
622            )
623            .map_err(|error| format!("invalid provider model: {error}"))?;
624            if model.provider != provider_id {
625                return Err(format!(
626                    "no provider registered for model provider '{}'",
627                    model.provider
628                ));
629            }
630            let context = normalize_provider_context(
631                args.get("context")
632                    .cloned()
633                    .ok_or("provider.complete missing context")?,
634            )
635            .map_err(|error| format!("invalid provider context: {error}"))?;
636            let options = parse_simple_stream_options(args.get("options"))?;
637            let stream = runtime.block_on(provider.stream_simple(&model, &context, &options));
638            if action == "provider.complete" {
639                let message = runtime
640                    .block_on(stream.result())
641                    .map_err(|error| format!("provider stream failed: {error}"))?;
642                return serde_json::to_value(message)
643                    .map_err(|error| format!("provider result encode failed: {error}"));
644            }
645
646            // The JSON-lines transport is request/response based, so the Node
647            // side receives a complete event snapshot. It still exposes the
648            // same async-iterator + result() shape as Pi's EventStream, while
649            // Rust remains the owner of provider execution and wire details.
650            let (mut events_rx, result_rx) = stream.split();
651            let (events, message) = runtime.block_on(async move {
652                let mut events = Vec::new();
653                while let Some(event) = events_rx.recv().await {
654                    events.push(
655                        serde_json::to_value(event)
656                            .map_err(|error| format!("provider event encode failed: {error}"))?,
657                    );
658                }
659                let message = result_rx
660                    .await
661                    .map_err(|error| format!("provider stream failed: {error}"))?;
662                Ok::<_, String>((events, message))
663            })?;
664            Ok(serde_json::json!({
665                "events": events,
666                "result": serde_json::to_value(message)
667                    .map_err(|error| format!("provider result encode failed: {error}"))?,
668            }))
669        }))?;
670        self.enable_capability("provider_calls")?;
671        self.set_runtime_context(serde_json::json!({
672            "capabilities": ["provider_calls"],
673        }))
674    }
675}
676
677/// Holds registrations discovered at startup while deferring the persistent
678/// Node runtime until the first request needs it.
679struct LazyNodeTransport {
680    /// A separate shared owner count lets the final clone perform cleanup
681    /// without shutting down the host when a short-lived tool/command clone is
682    /// dropped. This is explicit rather than derived from `Arc::strong_count`:
683    /// two clones can be dropped concurrently, and both would otherwise see
684    /// a count greater than one before either `Drop` releases its Arc.
685    owner: Arc<AtomicUsize>,
686    paths: Arc<Vec<PathBuf>>,
687    context: Arc<serde_json::Value>,
688    transport: Arc<Mutex<Option<NodeTransport>>>,
689    handlers: Arc<Mutex<Vec<RuntimeHandler>>>,
690    pending_context: Arc<Mutex<Option<serde_json::Value>>>,
691    context_revision: Arc<AtomicU64>,
692    active_tools: Arc<Mutex<Option<Vec<String>>>>,
693    shutdown: Arc<AtomicBool>,
694    // `ensure` serializes startup with this lock while keeping the transport
695    // slot free for shutdown to take a live instance out concurrently.
696    start_lock: Arc<Mutex<()>>,
697    // A startup transport is published here before any potentially blocking
698    // initialization request (`set_runtime_context`) is sent.
699    starting: Arc<Mutex<Option<NodeTransport>>>,
700    // Serializes context merging with the final startup handoff. It is never
701    // held while waiting for a Node response.
702    context_lock: Arc<Mutex<()>>,
703}
704
705impl Clone for LazyNodeTransport {
706    fn clone(&self) -> Self {
707        self.owner.fetch_add(1, Ordering::Relaxed);
708        Self {
709            owner: self.owner.clone(),
710            paths: self.paths.clone(),
711            context: self.context.clone(),
712            transport: self.transport.clone(),
713            handlers: self.handlers.clone(),
714            pending_context: self.pending_context.clone(),
715            context_revision: self.context_revision.clone(),
716            active_tools: self.active_tools.clone(),
717            shutdown: self.shutdown.clone(),
718            start_lock: self.start_lock.clone(),
719            starting: self.starting.clone(),
720            context_lock: self.context_lock.clone(),
721        }
722    }
723}
724
725impl Drop for LazyNodeTransport {
726    fn drop(&mut self) {
727        // `LazyNodeTransport` is cloned into tool adapters and detached
728        // command workers. Keep the host alive while any of those owners
729        // remain, then stop it when the final lazy owner disappears.
730        if self.owner.fetch_sub(1, Ordering::AcqRel) == 1 {
731            self.shutdown();
732        }
733    }
734}
735
736impl LazyNodeTransport {
737    fn new(paths: Vec<PathBuf>, context: serde_json::Value) -> Self {
738        Self {
739            owner: Arc::new(AtomicUsize::new(1)),
740            paths: Arc::new(paths),
741            context: Arc::new(context),
742            transport: Arc::new(Mutex::new(None)),
743            handlers: Arc::new(Mutex::new(Vec::new())),
744            pending_context: Arc::new(Mutex::new(None)),
745            context_revision: Arc::new(AtomicU64::new(0)),
746            active_tools: Arc::new(Mutex::new(None)),
747            shutdown: Arc::new(AtomicBool::new(false)),
748            start_lock: Arc::new(Mutex::new(())),
749            starting: Arc::new(Mutex::new(None)),
750            context_lock: Arc::new(Mutex::new(())),
751        }
752    }
753
754    fn live_transport(&self) -> Result<Option<NodeTransport>, String> {
755        let mut slot = self
756            .transport
757            .lock()
758            .map_err(|_| "Node transport lock poisoned")?;
759        match slot.as_ref() {
760            Some(transport) if !transport.is_shutdown() => Ok(Some(transport.clone())),
761            Some(_) => {
762                // The reader has terminated (or a write failed). Drop the
763                // stale slot so the next request can create a fresh host.
764                slot.take();
765                Ok(None)
766            }
767            None => Ok(None),
768        }
769    }
770
771    fn shutdown(&self) {
772        self.shutdown.store(true, Ordering::Release);
773        self.stop_current();
774    }
775
776    /// Stop the currently active or starting child without closing the lazy
777    /// session. Prompt preparation uses this for user cancellation so the
778    /// next prompt can retry with a fresh process.
779    fn stop_current(&self) {
780        // Take the transport out before calling into it so no lazy-state lock
781        // is held while the child is being terminated.
782        let active = self.transport.lock().ok().and_then(|mut slot| slot.take());
783        let starting = self.starting.lock().ok().and_then(|mut slot| slot.take());
784        if let Some(active) = active.as_ref() {
785            active.shutdown();
786        }
787        if let Some(starting) = starting {
788            if active
789                .as_ref()
790                .map_or(true, |active| !active.same_instance(&starting))
791            {
792                starting.shutdown();
793            }
794        }
795    }
796
797    fn active_tools(&self) -> Option<Vec<String>> {
798        self.active_tools
799            .lock()
800            .ok()
801            .and_then(|value| value.clone())
802    }
803
804    fn record_active_tools(&self, value: Option<&serde_json::Value>) {
805        let Some(values) = value.and_then(serde_json::Value::as_array) else {
806            return;
807        };
808        let names = values
809            .iter()
810            .filter_map(serde_json::Value::as_str)
811            .map(ToOwned::to_owned)
812            .collect();
813        if let Ok(mut active) = self.active_tools.lock() {
814            *active = Some(names);
815        }
816    }
817
818    /// Publish a `set_runtime_context` tool projection only while it still
819    /// belongs to the newest revision.
820    ///
821    /// Node serializes runtime-context requests, so responses are produced in
822    /// revision order. The two Rust callers are separate threads, though, and
823    /// nothing stops the one holding the *older* response from reaching this
824    /// point last; writing unconditionally then resurrects a tool list that a
825    /// newer context had already replaced. Node refuses a superseded request on
826    /// the request side for the same reason, so this mirrors that rule for the
827    /// result.
828    fn record_active_tools_for_revision(&self, revision: u64, value: Option<&serde_json::Value>) {
829        if self.context_revision.load(Ordering::Acquire) != revision {
830            return;
831        }
832        self.record_active_tools(value);
833    }
834
835    /// Put a context snapshot back into the pre-start queue after a failed
836    /// startup. `ensure` temporarily removes the snapshot so the Node host can
837    /// receive it in its environment; if that host exits before becoming the
838    /// active transport, the next attempt still needs the same state. Updates
839    /// that arrived concurrently are merged afterwards, preserving their
840    /// newer scalar values and additive capabilities.
841    fn restore_pending_context(&self, restored: Option<serde_json::Value>) {
842        let Some(mut restored) = restored else {
843            return;
844        };
845        let Ok(_context_guard) = self.context_lock.lock() else {
846            return;
847        };
848        let Ok(mut pending) = self.pending_context.lock() else {
849            return;
850        };
851        if let Some(current) = pending.take() {
852            merge_runtime_context(&mut restored, &current);
853        }
854        *pending = Some(restored);
855    }
856
857    fn ensure(&self) -> Result<NodeTransport, String> {
858        self.ensure_with_cancellation(None)
859    }
860
861    fn ensure_with_cancellation(
862        &self,
863        cancellation: Option<&CancellationToken>,
864    ) -> Result<NodeTransport, String> {
865        let cancelled = || cancellation.is_some_and(CancellationToken::is_cancelled);
866        if cancelled() {
867            return Err(NODE_PREPARATION_CANCELLED.into());
868        }
869        if self.shutdown.load(Ordering::Acquire) {
870            return Err(NODE_HOST_STOPPED.into());
871        }
872        if let Some(transport) = self.live_transport()? {
873            return Ok(transport);
874        }
875
876        // Keep only startup serialization under this lock. The transport slot
877        // itself is intentionally not held while Node starts or handles an
878        // initialization callback, so shutdown can terminate `starting`.
879        let _start_guard = self
880            .start_lock
881            .lock()
882            .map_err(|_| "Node startup lock poisoned")?;
883        if cancelled() {
884            return Err(NODE_PREPARATION_CANCELLED.into());
885        }
886        if self.shutdown.load(Ordering::Acquire) {
887            return Err(NODE_HOST_STOPPED.into());
888        }
889        if let Some(transport) = self.live_transport()? {
890            return Ok(transport);
891        }
892        let initial_handlers = self
893            .handlers
894            .lock()
895            .map_err(|_| "Node runtime handler lock poisoned")?
896            .clone();
897        // Include all context updates queued before the first request in the
898        // host's initial environment. This makes the first lifecycle hook see
899        // the real TUI/session capabilities instead of a stale headless base
900        // context; updates that arrive while startup is in flight are drained
901        // below after initialization.
902        let (initial_context, initial_pending, initial_revision) = {
903            let _context_guard = self
904                .context_lock
905                .lock()
906                .map_err(|_| "Node runtime context lock poisoned")?;
907            let mut context = (*self.context).clone();
908            let mut pending = self
909                .pending_context
910                .lock()
911                .map_err(|_| "Node runtime context lock poisoned")?;
912            let initial_pending = pending.take();
913            let initial_revision = self.context_revision.load(Ordering::Acquire);
914            if let Some(next) = initial_pending.as_ref() {
915                merge_runtime_context(&mut context, next);
916            }
917            (context, initial_pending, initial_revision)
918        };
919
920        // Spawn first and publish the transport before waiting for `id:0`.
921        // Extension factories and `before_agent_start` are user code and may
922        // block indefinitely; exposing this handle makes concurrent shutdown
923        // able to terminate the child during that phase.
924        let startup =
925            match start_node_transport(&self.paths, &initial_context, false, &initial_handlers) {
926                Ok(startup) => startup,
927                Err(error) => {
928                    self.restore_pending_context(initial_pending.clone());
929                    return Err(error);
930                }
931            };
932        let transport = startup.transport();
933
934        // Cancellation can arrive after the worker entered `ensure` but
935        // before the child was available through either shared slot.
936        if cancelled() {
937            transport.shutdown();
938            self.restore_pending_context(initial_pending.clone());
939            return Err(NODE_PREPARATION_CANCELLED.into());
940        }
941
942        {
943            let mut starting = self
944                .starting
945                .lock()
946                .map_err(|_| "Node startup state lock poisoned")?;
947            if self.shutdown.load(Ordering::Acquire) || cancelled() {
948                drop(starting);
949                transport.shutdown();
950                self.restore_pending_context(initial_pending.clone());
951                return Err(if cancelled() {
952                    NODE_PREPARATION_CANCELLED.into()
953                } else {
954                    NODE_HOST_STOPPED.into()
955                });
956            }
957            *starting = Some(transport.clone());
958            // Close the publication race with `stop_current`: a cancellation
959            // that happened while this lock was held must still tear down the
960            // child before we wait for its initialization envelope.
961            if cancelled() {
962                let stopped = starting.take();
963                drop(starting);
964                if let Some(stopped) = stopped {
965                    stopped.shutdown();
966                }
967                self.restore_pending_context(initial_pending.clone());
968                return Err(NODE_PREPARATION_CANCELLED.into());
969            }
970        }
971
972        let init = match startup.wait() {
973            Ok((_, init)) => init,
974            Err(error) => {
975                if let Ok(mut starting) = self.starting.lock() {
976                    starting.take();
977                }
978                self.restore_pending_context(initial_pending.clone());
979                return Err(error);
980            }
981        };
982
983        let startup_result = (|| -> Result<NodeTransport, String> {
984            if cancelled() {
985                return Err(NODE_PREPARATION_CANCELLED.into());
986            }
987            if !init
988                .get("ok")
989                .and_then(serde_json::Value::as_bool)
990                .unwrap_or(false)
991            {
992                return Err(init
993                    .get("error")
994                    .and_then(serde_json::Value::as_str)
995                    .unwrap_or("Node extension load failed")
996                    .to_string());
997            }
998            self.record_active_tools(
999                init.get("result")
1000                    .and_then(|value| value.get("activeTools")),
1001            );
1002            let handlers = self
1003                .handlers
1004                .lock()
1005                .map_err(|_| "Node runtime handler lock poisoned")?
1006                .clone();
1007            if let Some(first) = handlers.first() {
1008                transport.replace_runtime_handlers(first.clone())?;
1009                for handler in handlers.iter().skip(1).cloned() {
1010                    transport.add_runtime_handler(handler)?;
1011                }
1012            }
1013            // Keep a replayable snapshot of every context delta sent during
1014            // startup. If a request fails, the host is discarded and the next
1015            // attempt must receive all of these updates again.
1016            let mut startup_context = initial_pending
1017                .clone()
1018                .unwrap_or_else(|| serde_json::json!({}));
1019            // The initial context is also placed in the host environment so
1020            // factories can inspect it before the init envelope. Keep one
1021            // replay of the queued delta for the post-init handoff: the
1022            // persistent host skips its duplicate startup lifecycle hook, so
1023            // `set_runtime_context` must still be delivered once to run the
1024            // hook against the real runtime/UI context.
1025            // The persistent host deliberately skips its module-init lifecycle
1026            // pass (the discovery host already ran it). Always replay one
1027            // context envelope, even when no caller queued a delta, so the
1028            // persistent process still executes `before_agent_start` once for
1029            // its own extension state. An empty object is sufficient here:
1030            // the full initial context was supplied through the environment.
1031            let mut startup_pending = Some((
1032                initial_pending
1033                    .clone()
1034                    .unwrap_or_else(|| serde_json::json!({})),
1035                initial_revision,
1036            ));
1037            // Drain context updates that arrived while Node was starting. The
1038            // final empty check and active-slot handoff share `context_lock`,
1039            // so a concurrent setter cannot enqueue a value behind the check.
1040            loop {
1041                if cancelled() {
1042                    return Err(NODE_PREPARATION_CANCELLED.into());
1043                }
1044                let next = if let Some(next) = startup_pending.take() {
1045                    Some(next)
1046                } else {
1047                    let _context_guard = self
1048                        .context_lock
1049                        .lock()
1050                        .map_err(|_| "Node runtime context lock poisoned")?;
1051                    let mut pending = self
1052                        .pending_context
1053                        .lock()
1054                        .map_err(|_| "Node runtime context lock poisoned")?;
1055                    if let Some(context) = pending.take() {
1056                        let revision = self.context_revision.load(Ordering::Acquire);
1057                        Some((context, revision))
1058                    } else {
1059                        // Retain the latest merged delta after a successful
1060                        // handoff. A live host can exit later without another
1061                        // context setter, and its replacement must still see
1062                        // the session/UI state applied during this startup.
1063                        *pending = Some(startup_context.clone());
1064                        let mut slot = self
1065                            .transport
1066                            .lock()
1067                            .map_err(|_| "Node transport lock poisoned")?;
1068                        if self.shutdown.load(Ordering::Acquire) || cancelled() {
1069                            return Err(if cancelled() {
1070                                NODE_PREPARATION_CANCELLED.into()
1071                            } else {
1072                                NODE_HOST_STOPPED.into()
1073                            });
1074                        }
1075                        if let Some(existing) = slot.as_ref() {
1076                            return Ok(existing.clone());
1077                        }
1078                        *slot = Some(transport.clone());
1079                        // `shutdown()` can set the flag and drain the slots
1080                        // concurrently. Re-check after publishing so a
1081                        // shutdown that won the race cannot leave a stopped
1082                        // transport installed for the next caller.
1083                        if self.shutdown.load(Ordering::Acquire) || cancelled() {
1084                            let stopped = slot.take();
1085                            drop(slot);
1086                            if let Some(stopped) = stopped {
1087                                stopped.shutdown();
1088                            }
1089                            return Err(if cancelled() {
1090                                NODE_PREPARATION_CANCELLED.into()
1091                            } else {
1092                                NODE_HOST_STOPPED.into()
1093                            });
1094                        }
1095                        return Ok(transport.clone());
1096                    }
1097                };
1098                let Some((context, revision)) = next else {
1099                    continue;
1100                };
1101                if cancelled() {
1102                    return Err(NODE_PREPARATION_CANCELLED.into());
1103                }
1104                merge_runtime_context(&mut startup_context, &context);
1105                let result = match transport.request(
1106                    "set_runtime_context",
1107                    serde_json::json!({"context": context, "revision": revision}),
1108                ) {
1109                    Ok(result) => result,
1110                    Err(error) => {
1111                        // The `?` form would discard the context deltas that
1112                        // were already sent successfully in this startup.
1113                        // Requeue the complete replay snapshot before letting
1114                        // the outer error path tear down the host.
1115                        self.restore_pending_context(Some(startup_context));
1116                        return Err(error);
1117                    }
1118                };
1119                self.record_active_tools_for_revision(revision, result.get("activeTools"));
1120            }
1121        })();
1122
1123        // Only one ensure holds start_lock, so this slot can contain at most
1124        // the transport created above. Shutdown may already have taken it.
1125        if let Ok(mut starting) = self.starting.lock() {
1126            starting.take();
1127        }
1128        match startup_result {
1129            Ok(transport) => Ok(transport),
1130            Err(error) => {
1131                transport.shutdown();
1132                self.restore_pending_context(initial_pending);
1133                Err(error)
1134            }
1135        }
1136    }
1137
1138    fn request(
1139        &self,
1140        method: &str,
1141        payload: serde_json::Value,
1142    ) -> Result<serde_json::Value, String> {
1143        self.ensure()?.request(method, payload)
1144    }
1145
1146    fn begin_request(
1147        &self,
1148        method: &str,
1149        payload: serde_json::Value,
1150    ) -> Result<crate::node_transport::PendingRequest, String> {
1151        self.ensure()?.begin_request(method, payload)
1152    }
1153
1154    fn send_event(&self, event: serde_json::Value) -> Result<(), String> {
1155        self.ensure()?.send_event(event)
1156    }
1157
1158    fn cancel(&self, id: u64) -> Result<(), String> {
1159        self.ensure()?.cancel(id)
1160    }
1161
1162    fn register_tool_update_handler(
1163        &self,
1164        tool_call_id: impl Into<String>,
1165        handler: crate::node_transport::ToolUpdateHandler,
1166    ) -> Result<(), String> {
1167        self.ensure()?
1168            .register_tool_update_handler(tool_call_id, handler)
1169    }
1170
1171    fn unregister_tool_update_handler(&self, tool_call_id: &str) {
1172        if let Ok(slot) = self.transport.lock() {
1173            if let Some(transport) = slot.as_ref() {
1174                transport.unregister_tool_update_handler(tool_call_id);
1175            }
1176        }
1177    }
1178
1179    /// Return every currently spawned host that can receive runtime requests.
1180    /// During lazy startup the same child is exposed through `starting` until
1181    /// the initialization/context handoff completes, so deduplicate handles by
1182    /// child identity before mutating its handler list.
1183    fn handler_update_transports(&self) -> Result<Vec<NodeTransport>, String> {
1184        let active = self.live_transport()?;
1185        let starting = self
1186            .starting
1187            .lock()
1188            .map_err(|_| "Node startup state lock poisoned")?
1189            .as_ref()
1190            .filter(|transport| !transport.is_shutdown())
1191            .cloned();
1192        let mut transports = Vec::with_capacity(2);
1193        if let Some(active) = active {
1194            transports.push(active);
1195        }
1196        if let Some(starting) = starting {
1197            if !transports
1198                .iter()
1199                .any(|transport| transport.same_instance(&starting))
1200            {
1201                transports.push(starting);
1202            }
1203        }
1204        Ok(transports)
1205    }
1206
1207    fn replace_runtime_handlers(&self, handler: RuntimeHandler) -> Result<(), String> {
1208        *self
1209            .handlers
1210            .lock()
1211            .map_err(|_| "Node runtime handler lock poisoned")? = vec![handler.clone()];
1212        for transport in self.handler_update_transports()? {
1213            transport.replace_runtime_handlers(handler.clone())?;
1214        }
1215        Ok(())
1216    }
1217
1218    fn add_runtime_handler(&self, handler: RuntimeHandler) -> Result<(), String> {
1219        self.handlers
1220            .lock()
1221            .map_err(|_| "Node runtime handler lock poisoned")?
1222            .push(handler.clone());
1223        for transport in self.handler_update_transports()? {
1224            transport.add_runtime_handler(handler.clone())?;
1225        }
1226        Ok(())
1227    }
1228
1229    fn set_runtime_context(&self, context: serde_json::Value) -> Result<(), String> {
1230        if self.shutdown.load(Ordering::Acquire) {
1231            return Err(NODE_HOST_STOPPED.into());
1232        }
1233        // Serialize only the merge/snapshot step.  The Node request can invoke
1234        // a Rust runtime handler (and therefore re-enter this method), so
1235        // keeping `context_lock` across the blocking request would deadlock.
1236        let (merged, revision, transport) = {
1237            let _context_guard = self
1238                .context_lock
1239                .lock()
1240                .map_err(|_| "Node runtime context lock poisoned")?;
1241            let mut pending = self
1242                .pending_context
1243                .lock()
1244                .map_err(|_| "Node runtime context lock poisoned")?;
1245            let mut merged = pending.take().unwrap_or_else(|| serde_json::json!({}));
1246            merge_runtime_context(&mut merged, &context);
1247            *pending = Some(merged.clone());
1248            let revision = self.context_revision.fetch_add(1, Ordering::AcqRel) + 1;
1249            // Do not reuse a transport whose reader already reached EOF or
1250            // whose stdin write failed. Keep the merged context queued so the
1251            // next real request can start a fresh host and apply it during the
1252            // startup handoff.
1253            let transport = self.live_transport()?;
1254            (merged, revision, transport)
1255        };
1256        if let Some(transport) = transport {
1257            let result = transport.request(
1258                "set_runtime_context",
1259                serde_json::json!({"context": merged, "revision": revision}),
1260            )?;
1261            self.record_active_tools_for_revision(revision, result.get("activeTools"));
1262        }
1263        Ok(())
1264    }
1265}
1266
1267/// Merge runtime context updates that are queued before the lazy Node process
1268/// starts. Capability updates are additive; ordinary context fields use the
1269/// newest value. Without this, installing two independent bridges before the
1270/// first command (for example `ui.custom` and `ui.dialog`) drops the first
1271/// bridge's capability from the initial Node context.
1272fn merge_runtime_context(base: &mut serde_json::Value, next: &serde_json::Value) {
1273    let (Some(base_object), Some(next_object)) = (base.as_object_mut(), next.as_object()) else {
1274        *base = next.clone();
1275        return;
1276    };
1277    for (key, value) in next_object {
1278        if key == "capabilities" {
1279            let Some(next_values) = value.as_array() else {
1280                base_object.insert(key.clone(), value.clone());
1281                continue;
1282            };
1283            let entry = base_object
1284                .entry(key.clone())
1285                .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1286            let Some(base_values) = entry.as_array_mut() else {
1287                *entry = serde_json::Value::Array(Vec::new());
1288                let Some(base_values) = entry.as_array_mut() else {
1289                    continue;
1290                };
1291                for item in next_values {
1292                    if !base_values.contains(item) {
1293                        base_values.push(item.clone());
1294                    }
1295                }
1296                continue;
1297            };
1298            for item in next_values {
1299                if !base_values.contains(item) {
1300                    base_values.push(item.clone());
1301                }
1302            }
1303        } else {
1304            base_object.insert(key.clone(), value.clone());
1305        }
1306    }
1307}
1308
1309/// Accept Pi-compatible response objects that omit the discriminating `role`
1310/// field when a side thread feeds a previous response back into `Context`.
1311/// Only infer roles from unambiguous shape markers; malformed/ambiguous
1312/// messages still fail typed deserialization with the original error.
1313fn normalize_provider_context(mut value: serde_json::Value) -> Result<Context, String> {
1314    let Some(messages) = value
1315        .get_mut("messages")
1316        .and_then(serde_json::Value::as_array_mut)
1317    else {
1318        return serde_json::from_value(value).map_err(|error| error.to_string());
1319    };
1320    for message in messages {
1321        let Some(object) = message.as_object_mut() else {
1322            continue;
1323        };
1324        if object.contains_key("role") {
1325            continue;
1326        }
1327        let inferred = if object.contains_key("toolCallId")
1328            || object.contains_key("toolName")
1329            || object.contains_key("isError")
1330        {
1331            Some("toolResult")
1332        } else if object.contains_key("stopReason")
1333            && object.contains_key("content")
1334            && (object.contains_key("provider") || object.contains_key("model"))
1335        {
1336            Some("assistant")
1337        } else {
1338            None
1339        };
1340        if let Some(role) = inferred {
1341            object.insert(
1342                "role".to_string(),
1343                serde_json::Value::String(role.to_string()),
1344            );
1345        }
1346    }
1347    serde_json::from_value(value).map_err(|error| error.to_string())
1348}
1349
1350fn parse_simple_stream_options(
1351    value: Option<&serde_json::Value>,
1352) -> Result<SimpleStreamOptions, String> {
1353    let value = value.cloned().unwrap_or_default();
1354    let mut options = SimpleStreamOptions::default();
1355    options.api_key = value
1356        .get("apiKey")
1357        .and_then(|v| v.as_str())
1358        .map(String::from);
1359    options.headers = value
1360        .get("headers")
1361        .cloned()
1362        .map(serde_json::from_value)
1363        .transpose()
1364        .map_err(|e| format!("invalid headers: {e}"))?;
1365    options.metadata = value
1366        .get("metadata")
1367        .cloned()
1368        .map(serde_json::from_value)
1369        .transpose()
1370        .map_err(|e| format!("invalid metadata: {e}"))?;
1371    options.timeout = value
1372        .get("timeout")
1373        .and_then(|v| v.as_u64())
1374        .map(Duration::from_millis);
1375    options.max_retries = value
1376        .get("maxRetries")
1377        .and_then(|v| v.as_u64())
1378        .map(|value| value as u32);
1379    options.max_retry_delay = value
1380        .get("maxRetryDelay")
1381        .and_then(|v| v.as_u64())
1382        .map(Duration::from_millis);
1383    options.session_id = value
1384        .get("sessionId")
1385        .and_then(|v| v.as_str())
1386        .map(String::from);
1387    options.max_tokens = value.get("maxTokens").and_then(|v| v.as_u64());
1388    options.temperature = value.get("temperature").and_then(|v| v.as_f64());
1389    options.reasoning = value
1390        .get("reasoning")
1391        .cloned()
1392        .map(serde_json::from_value)
1393        .transpose()
1394        .map_err(|e| format!("invalid reasoning: {e}"))?;
1395    options.thinking_budgets = value
1396        .get("thinkingBudgets")
1397        .cloned()
1398        .map(serde_json::from_value)
1399        .transpose()
1400        .map_err(|e| format!("invalid thinkingBudgets: {e}"))?;
1401    options.cache_retention = match value.get("cacheRetention").and_then(|v| v.as_str()) {
1402        Some("long") => CacheRetention::Long,
1403        Some("none") => CacheRetention::None,
1404        _ => CacheRetention::Short,
1405    };
1406    Ok(options)
1407}
1408
1409impl crate::extension_api::ExtensionBackend for JsExtensionSession {
1410    fn backend_info(&self) -> crate::extension_api::ExtensionBackendInfo {
1411        use crate::extension_api::{ExtensionBackendInfo, ExtensionCapability};
1412
1413        let mut info = ExtensionBackendInfo::new("node");
1414        info.api_version = self.api_version;
1415        for capability in self
1416            .capabilities()
1417            .iter()
1418            .filter_map(|name| match name.as_str() {
1419                "tools" => Some(ExtensionCapability::Tools),
1420                "commands" => Some(ExtensionCapability::Commands),
1421                "resources" => Some(ExtensionCapability::Resources),
1422                "ui.notify" => Some(ExtensionCapability::UiNotify),
1423                "ui.editor" => Some(ExtensionCapability::UiEditor),
1424                "ui.custom" => Some(ExtensionCapability::UiCustom),
1425                "ui.select" => Some(ExtensionCapability::UiSelect),
1426                "ui.confirm" => Some(ExtensionCapability::UiConfirm),
1427                "ui.input" => Some(ExtensionCapability::UiInput),
1428                "ui.editor_dialog" => Some(ExtensionCapability::UiEditorDialog),
1429                "session" => Some(ExtensionCapability::Session),
1430                "models" => Some(ExtensionCapability::Models),
1431                "events" => Some(ExtensionCapability::Events),
1432                "providers" => Some(ExtensionCapability::Providers),
1433                "provider_calls" => Some(ExtensionCapability::ProviderCalls),
1434                "renderers" => Some(ExtensionCapability::Renderers),
1435                "runtime_actions" => Some(ExtensionCapability::RuntimeActions),
1436                _ => None,
1437            })
1438        {
1439            info.capabilities.insert(capability);
1440        }
1441        info
1442    }
1443}
1444
1445#[derive(Clone)]
1446pub struct JsToolAdapter {
1447    session: JsExtensionSession,
1448    definition: JsToolDefinition,
1449}
1450
1451#[async_trait]
1452impl AgentTool for JsToolAdapter {
1453    fn schema(&self) -> &Tool {
1454        &self.definition.tool
1455    }
1456    fn label(&self) -> &str {
1457        &self.definition.label
1458    }
1459    fn execution_mode(&self) -> ToolExecutionMode {
1460        self.definition.execution_mode
1461    }
1462    async fn execute(
1463        &self,
1464        tool_call_id: &str,
1465        params: serde_json::Value,
1466        signal: CancellationToken,
1467        on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
1468    ) -> Result<AgentToolResult, AgentError> {
1469        let name = self.definition.name.clone();
1470        let tool_call_id = tool_call_id.to_string();
1471        let transport = self.session.transport.clone();
1472        let update_handler: Arc<dyn Fn(serde_json::Value) + Send + Sync> = {
1473            let on_update = on_update.clone();
1474            Arc::new(move |value| {
1475                // Partial updates are best effort, just like the native Pi
1476                // callback. A malformed update must not fail the main tool
1477                // invocation or poison the transport reader thread.
1478                if let Ok(partial) = parse_tool_result(value) {
1479                    on_update(partial);
1480                }
1481            })
1482        };
1483        transport
1484            .register_tool_update_handler(tool_call_id.clone(), update_handler)
1485            .map_err(AgentError::tool)?;
1486        let pending = match transport.begin_request(
1487            "invoke_tool",
1488            serde_json::json!({"tool": name, "toolCallId": tool_call_id.clone(), "args": params}),
1489        ) {
1490            Ok(pending) => pending,
1491            Err(error) => {
1492                transport.unregister_tool_update_handler(&tool_call_id);
1493                return Err(AgentError::tool(error));
1494            }
1495        };
1496        let request_id = pending.id();
1497        let cancel_transport = transport.clone();
1498        let wait = tokio::task::spawn_blocking(move || pending.wait());
1499        let result = tokio::select! {
1500            result = wait => result
1501                .map_err(|error| AgentError::tool(error.to_string()))
1502                .and_then(|response| response.map_err(AgentError::tool)),
1503            _ = signal.cancelled() => {
1504                let _ = cancel_transport.cancel(request_id);
1505                Err(AgentError::Abort)
1506            }
1507        };
1508        transport.unregister_tool_update_handler(&tool_call_id);
1509        let result = result?;
1510        parse_tool_result(result).map_err(AgentError::tool)
1511    }
1512}
1513
1514fn start_node_transport(
1515    paths: &[PathBuf],
1516    context: &serde_json::Value,
1517    discovery_only: bool,
1518    initial_handlers: &[RuntimeHandler],
1519) -> Result<NodeTransportStartup, String> {
1520    let node = which_node()?;
1521    // `node -e <embedded host>` exceeds the Windows CreateProcess command
1522    // line limit once this host grows past roughly 8 KiB. Keep the eval path
1523    // on Unix (where it preserves the existing import.meta resolution), and
1524    // launch a short-lived temp module on Windows.
1525    let host_script = if cfg!(windows) {
1526        Some(write_node_host_script()?)
1527    } else {
1528        None
1529    };
1530    let mut command = Command::new(node);
1531    if let Some(path) = host_script.as_ref() {
1532        command.arg(path);
1533    } else {
1534        command.args(["--input-type=module", "-e", NODE_HOST]);
1535    }
1536    command
1537        .env(
1538            "RPI_JS_EXTENSION_PATHS",
1539            serde_json::to_string(paths).unwrap_or_else(|_| "[]".into()),
1540        )
1541        .env(
1542            "RPI_JS_EXTENSION_CONTEXT",
1543            serde_json::to_string(context).unwrap_or_else(|_| "{}".into()),
1544        )
1545        .stdin(Stdio::piped())
1546        .stdout(Stdio::piped())
1547        // Extension diagnostics belong on stderr; keeping it inherited
1548        // prevents an undrained pipe from blocking a noisy extension.
1549        .stderr(Stdio::inherit());
1550    if discovery_only {
1551        command.env("RPI_JS_EXTENSION_ONESHOT", "1");
1552    } else {
1553        command
1554            .env("RPI_JS_EXTENSION_SKIP_INITIAL_HOOK", "1")
1555            .env("RPI_JS_EXTENSION_SKIP_INITIAL_DISCOVERY", "1");
1556    }
1557    let mut child: Child = match command.spawn() {
1558        Ok(child) => child,
1559        Err(error) => {
1560            remove_node_host_script(host_script.as_ref());
1561            return Err(format!("could not start Node JS extension host: {error}"));
1562        }
1563    };
1564    let stdin: ChildStdin = match child.stdin.take() {
1565        Some(stdin) => stdin,
1566        None => {
1567            let _ = child.kill();
1568            let _ = child.wait();
1569            remove_node_host_script(host_script.as_ref());
1570            return Err("Node extension host stdin unavailable".into());
1571        }
1572    };
1573    let stdout: ChildStdout = match child.stdout.take() {
1574        Some(stdout) => stdout,
1575        None => {
1576            let _ = child.kill();
1577            let _ = child.wait();
1578            remove_node_host_script(host_script.as_ref());
1579            return Err("Node extension host stdout unavailable".into());
1580        }
1581    };
1582    let result = NodeTransport::start_pending_with_cleanup_and_handlers(
1583        child,
1584        stdin,
1585        stdout,
1586        host_script.clone(),
1587        initial_handlers.to_vec(),
1588    );
1589    if result.is_err() {
1590        remove_node_host_script(host_script.as_ref());
1591    }
1592    result
1593}
1594
1595fn write_node_host_script() -> Result<PathBuf, String> {
1596    let id = NEXT_NODE_HOST_SCRIPT.fetch_add(1, Ordering::Relaxed);
1597    let path = std::env::temp_dir().join(format!("rpi-node-host-{}-{id}.mjs", std::process::id()));
1598    let mut file = OpenOptions::new()
1599        .write(true)
1600        .create_new(true)
1601        .open(&path)
1602        .map_err(|error| format!("could not create Node host script: {error}"))?;
1603    if let Err(error) = file.write_all(NODE_HOST.as_bytes()) {
1604        let _ = std::fs::remove_file(&path);
1605        return Err(format!("could not write Node host script: {error}"));
1606    }
1607    Ok(path)
1608}
1609
1610fn remove_node_host_script(path: Option<&PathBuf>) {
1611    if let Some(path) = path {
1612        let _ = std::fs::remove_file(path);
1613    }
1614}
1615
1616fn which_node() -> Result<String, String> {
1617    for candidate in ["node", "nodejs"] {
1618        if Command::new(candidate)
1619            .arg("--version")
1620            .stdout(Stdio::null())
1621            .stderr(Stdio::null())
1622            .status()
1623            .is_ok_and(|status| status.success())
1624        {
1625            return Ok(candidate.to_string());
1626        }
1627    }
1628    Err("Pi JS/TS extensions require Node.js (node --version was not found)".into())
1629}
1630
1631fn parse_resources(value: Option<&serde_json::Value>) -> JsResources {
1632    let list = |key: &str| {
1633        value
1634            .and_then(|v| v.get(key))
1635            .and_then(serde_json::Value::as_array)
1636            .map(|items| {
1637                items
1638                    .iter()
1639                    .filter_map(|item| item.as_str().map(PathBuf::from))
1640                    .collect()
1641            })
1642            .unwrap_or_default()
1643    };
1644    JsResources {
1645        skill_paths: list("skillPaths"),
1646        prompt_paths: list("promptPaths"),
1647        theme_paths: list("themePaths"),
1648    }
1649}
1650
1651fn parse_tool_result(value: serde_json::Value) -> Result<AgentToolResult, String> {
1652    let mut result = AgentToolResult::default();
1653    if let Some(content) = value.get("content").and_then(serde_json::Value::as_array) {
1654        for block in content {
1655            if block.get("type").and_then(serde_json::Value::as_str) == Some("image") {
1656                result
1657                    .content
1658                    .push(TextContentOrImage::Image(rpi_ai::types::ImageContent {
1659                        kind: rpi_ai::types::ImageContentType,
1660                        data: block
1661                            .get("data")
1662                            .and_then(serde_json::Value::as_str)
1663                            .unwrap_or_default()
1664                            .into(),
1665                        mime_type: block
1666                            .get("mimeType")
1667                            .and_then(serde_json::Value::as_str)
1668                            .unwrap_or("image/png")
1669                            .into(),
1670                    }));
1671            } else {
1672                result.content.push(TextContentOrImage::text(
1673                    block
1674                        .get("text")
1675                        .and_then(serde_json::Value::as_str)
1676                        .unwrap_or_default(),
1677                ));
1678            }
1679        }
1680    } else if let Some(text) = value.as_str() {
1681        result.content.push(TextContentOrImage::text(text));
1682    }
1683    result.details = value
1684        .get("details")
1685        .cloned()
1686        .unwrap_or(serde_json::Value::Null);
1687    result.usage = value
1688        .get("usage")
1689        .cloned()
1690        .filter(|value| !value.is_null())
1691        // Usage is optional metadata. Keep a well-formed native Usage value,
1692        // but preserve the tool result when an extension sends a newer or
1693        // incomplete shape that this Rust version does not understand.
1694        .and_then(|value| serde_json::from_value(value).ok());
1695    result.added_tool_names = value
1696        .get("addedToolNames")
1697        .and_then(serde_json::Value::as_array)
1698        .map(|values| {
1699            values
1700                .iter()
1701                .filter_map(serde_json::Value::as_str)
1702                .map(ToOwned::to_owned)
1703                .collect()
1704        })
1705        .unwrap_or_default();
1706    result.terminate = value
1707        .get("terminate")
1708        .and_then(serde_json::Value::as_bool)
1709        .unwrap_or(false);
1710    Ok(result)
1711}
1712
1713fn normalize_host_path(path: &PathBuf) -> PathBuf {
1714    if cfg!(windows) {
1715        let text = path.to_string_lossy();
1716        if let Some(stripped) = text.strip_prefix("\\\\?\\") {
1717            return PathBuf::from(stripped);
1718        }
1719    }
1720    path.clone()
1721}
1722
1723#[cfg(test)]
1724mod tests {
1725    use super::*;
1726    use crate::extension_api::ExtensionBackend;
1727
1728    #[test]
1729    fn lazy_runtime_context_merges_capabilities_before_start() {
1730        let transport = LazyNodeTransport::new(Vec::new(), serde_json::json!({}));
1731        transport
1732            .set_runtime_context(serde_json::json!({
1733                "capabilities": ["ui.custom"],
1734                "session": {"id": "first"}
1735            }))
1736            .unwrap();
1737        transport
1738            .set_runtime_context(serde_json::json!({
1739                "capabilities": ["ui.dialog"],
1740                "session": {"id": "second"}
1741            }))
1742            .unwrap();
1743        let pending = transport.pending_context.lock().unwrap().clone().unwrap();
1744        assert_eq!(
1745            pending["capabilities"],
1746            serde_json::json!(["ui.custom", "ui.dialog"])
1747        );
1748        assert_eq!(pending["session"]["id"], "second");
1749    }
1750
1751    #[test]
1752    fn lazy_transport_stops_host_when_final_owner_drops() {
1753        let temp = tempfile::tempdir().unwrap();
1754        let path = temp.path().join("drop.js");
1755        std::fs::write(
1756            &path,
1757            "export default (pi) => pi.registerCommand('ping', async () => ({ text: 'pong' }));",
1758        )
1759        .unwrap();
1760        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
1761        let live = session.transport.ensure().unwrap();
1762        assert!(!live.is_shutdown());
1763
1764        // A detached adapter/session clone keeps the lazy owner alive. The
1765        // transport itself is retained so the test can observe the shared
1766        // shutdown flag after the final lazy owner is released.
1767        let retained_owner = session.transport.clone();
1768        drop(session);
1769        assert!(!live.is_shutdown());
1770        drop(retained_owner);
1771
1772        assert!(
1773            live.is_shutdown(),
1774            "the final LazyNodeTransport owner must stop the Node host"
1775        );
1776        assert!(live
1777            .request(
1778                "invoke_command",
1779                serde_json::json!({"command": "ping", "args": ""})
1780            )
1781            .is_err());
1782    }
1783
1784    #[test]
1785    fn parse_tool_result_preserves_supported_metadata_and_ignores_unknown_usage() {
1786        let result = parse_tool_result(serde_json::json!({
1787            "content": [{"type": "text", "text": "ok"}],
1788            "details": {"source": "js"},
1789            "usage": {"totalTokens": "later-version"},
1790            "addedToolNames": ["next", 7],
1791        }))
1792        .unwrap();
1793        assert!(result.usage.is_none());
1794        assert_eq!(result.added_tool_names, ["next"]);
1795        assert_eq!(result.details["source"], "js");
1796    }
1797
1798    #[test]
1799    fn loads_javascript_tool_and_invokes_it() {
1800        let temp = tempfile::tempdir().unwrap();
1801        let path = temp.path().join("extension.js");
1802        std::fs::write(
1803            &path,
1804            "export default (pi) => pi.registerTool({name: 'hello', description: 'test', executionMode: 'sequential', parameters: {type: 'object'}, async execute() { return {content: [{type: 'text', text: 'ok'}], details: {}}; }});",
1805        )
1806        .unwrap();
1807        let session = JsExtensionSession::load(&[path], true).unwrap().unwrap();
1808        let tool = session.tools().next().unwrap();
1809        assert_eq!(tool.schema().name, "hello");
1810        assert_eq!(tool.execution_mode(), ToolExecutionMode::Sequential);
1811        let value = session
1812            .invoke(
1813                "invoke_tool",
1814                serde_json::json!({"tool":"hello", "args":{}}),
1815            )
1816            .unwrap();
1817        assert_eq!(value["content"][0]["text"], "ok");
1818    }
1819
1820    #[test]
1821    fn failed_extension_rolls_back_registrations_and_keeps_loading() {
1822        let temp = tempfile::tempdir().unwrap();
1823        let broken = temp.path().join("a-broken.js");
1824        let healthy = temp.path().join("b-healthy.js");
1825        std::fs::write(
1826            &broken,
1827            r#"export default (pi) => {
1828                pi.registerCommand('leaked-command', async () => ({ text: 'leaked' }));
1829                pi.registerTool({
1830                    name: 'leaked-tool',
1831                    description: 'must be rolled back',
1832                    parameters: { type: 'object', properties: {} },
1833                    async execute() { return { text: 'leaked' }; }
1834                });
1835                throw new Error('broken factory');
1836            };"#,
1837        )
1838        .unwrap();
1839        std::fs::write(
1840            &healthy,
1841            "export default (pi) => pi.registerCommand('healthy', async () => ({ text: 'pong' }));",
1842        )
1843        .unwrap();
1844
1845        let session = JsExtensionSession::load(&[broken, healthy], false)
1846            .unwrap()
1847            .unwrap();
1848        assert_eq!(session.commands, ["healthy"]);
1849        assert!(!session
1850            .tool_names()
1851            .iter()
1852            .any(|name| name == "leaked-tool"));
1853        let value = session.invoke_command("healthy", "").unwrap();
1854        assert_eq!(value["result"]["text"], "pong");
1855        assert!(session.invoke_command("leaked-command", "").is_err());
1856    }
1857
1858    #[test]
1859    fn failed_extension_rollback_preserves_existing_event_unsubscribe() {
1860        let temp = tempfile::tempdir().unwrap();
1861        let healthy = temp.path().join("a-healthy.js");
1862        let broken = temp.path().join("b-broken.js");
1863        std::fs::write(
1864            &healthy,
1865            r#"export default (pi) => {
1866                let calls = 0;
1867                const off = pi.events.on('probe', () => { calls += 1; });
1868                pi.registerCommand('unsubscribe', async () => {
1869                    off();
1870                    await pi.events.emit('probe');
1871                    return { text: String(calls) };
1872                });
1873            };"#,
1874        )
1875        .unwrap();
1876        std::fs::write(
1877            &broken,
1878            r#"export default (pi) => {
1879                pi.events.on('probe', () => {});
1880                throw new Error('broken factory');
1881            };"#,
1882        )
1883        .unwrap();
1884
1885        let session = JsExtensionSession::load(&[healthy, broken], false)
1886            .unwrap()
1887            .unwrap();
1888        let value = session.invoke_command("unsubscribe", "").unwrap();
1889        assert_eq!(value["result"]["text"], "0");
1890    }
1891
1892    #[test]
1893    fn directory_discovery_does_not_recurse_beyond_one_child_level() {
1894        let temp = tempfile::tempdir().unwrap();
1895        let root = temp.path().join("extensions");
1896        let deep = root.join("child/deep");
1897        std::fs::create_dir_all(&deep).unwrap();
1898        std::fs::write(
1899            root.join("direct.js"),
1900            "export default (pi) => pi.registerCommand('direct', async () => ({ text: 'ok' }));",
1901        )
1902        .unwrap();
1903        std::fs::write(
1904            deep.join("index.js"),
1905            "export default (pi) => pi.registerCommand('too-deep', async () => ({ text: 'bad' }));",
1906        )
1907        .unwrap();
1908
1909        let session = JsExtensionSession::load(&[root], false).unwrap().unwrap();
1910        assert_eq!(session.commands, ["direct"]);
1911    }
1912
1913    #[test]
1914    fn directory_manifest_prefers_rpi_extension_entries() {
1915        let temp = tempfile::tempdir().unwrap();
1916        let root = temp.path().join("package");
1917        std::fs::create_dir_all(&root).unwrap();
1918        std::fs::write(
1919            root.join("package.json"),
1920            r#"{"pi":{"extensions":["pi.js"]},"rpi":{"extensions":["rpi.js"]}}"#,
1921        )
1922        .unwrap();
1923        std::fs::write(
1924            root.join("pi.js"),
1925            "export default (pi) => pi.registerCommand('pi-entry', async () => ({ text: 'pi' }));",
1926        )
1927        .unwrap();
1928        std::fs::write(
1929            root.join("rpi.js"),
1930            "export default (pi) => pi.registerCommand('rpi-entry', async () => ({ text: 'rpi' }));",
1931        )
1932        .unwrap();
1933
1934        let session = JsExtensionSession::load(&[root], false).unwrap().unwrap();
1935        assert_eq!(session.commands, ["rpi-entry"]);
1936    }
1937
1938    #[tokio::test]
1939    async fn javascript_tool_forwards_on_update_partials() {
1940        let temp = tempfile::tempdir().unwrap();
1941        let path = temp.path().join("progress.js");
1942        std::fs::write(
1943            &path,
1944            r#"export default (pi) => pi.registerTool({
1945                name: 'progress',
1946                description: 'emit progress',
1947                parameters: { type: 'object', properties: {} },
1948                async execute(_id, _args, _signal, onUpdate) {
1949                    onUpdate({ content: [{ type: 'text', text: 'first' }], details: { step: 1 } });
1950                    await new Promise(resolve => setTimeout(resolve, 20));
1951                    onUpdate({ content: [{ type: 'text', text: 'second' }], details: { step: 2 } });
1952                    return {
1953                        content: [{ type: 'text', text: 'done' }],
1954                        details: {},
1955                        usage: {
1956                            input: 1, output: 2, cacheRead: 3, cacheWrite: 4,
1957                            totalTokens: 10,
1958                            cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0, total: 0.3 }
1959                        },
1960                        addedToolNames: ['follow_up']
1961                    };
1962                }
1963            });"#,
1964        )
1965        .unwrap();
1966        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
1967        let updates = Arc::new(Mutex::new(Vec::<String>::new()));
1968        let observed = updates.clone();
1969        let tool = session.tools().next().expect("registered JS tool");
1970        let result = tool
1971            .execute(
1972                "progress-call",
1973                serde_json::json!({}),
1974                CancellationToken::new(),
1975                Arc::new(move |partial| {
1976                    let text = partial
1977                        .content
1978                        .first()
1979                        .and_then(|content| match content {
1980                            TextContentOrImage::Text(text) => Some(text.text.clone()),
1981                            TextContentOrImage::Image(_) => None,
1982                        })
1983                        .unwrap_or_default();
1984                    observed.lock().unwrap().push(text);
1985                }),
1986            )
1987            .await
1988            .unwrap();
1989        assert_eq!(
1990            result.content.first().and_then(|content| match content {
1991                TextContentOrImage::Text(text) => Some(text.text.as_str()),
1992                TextContentOrImage::Image(_) => None,
1993            }),
1994            Some("done")
1995        );
1996        assert_eq!(
1997            result.usage.as_ref().map(|usage| usage.total_tokens),
1998            Some(10)
1999        );
2000        assert_eq!(result.added_tool_names, ["follow_up"]);
2001        assert_eq!(updates.lock().unwrap().as_slice(), ["first", "second"]);
2002    }
2003
2004    #[tokio::test]
2005    async fn javascript_tool_receives_ui_context() {
2006        let temp = tempfile::tempdir().unwrap();
2007        let path = temp.path().join("ui-tool.js");
2008        std::fs::write(
2009            &path,
2010            r#"export default (pi) => pi.registerTool({
2011                name: 'ui_probe',
2012                description: 'verify the Pi tool context',
2013                parameters: { type: 'object', properties: {} },
2014                async execute(_toolCallId, _args, _signal, _onUpdate, ctx) {
2015                    if (ctx?.hasUI !== true) throw new Error('tool context missing hasUI');
2016                    if (typeof ctx?.ui?.custom !== 'function') throw new Error('tool context missing ui.custom');
2017                    if (!ctx.capabilities?.has('ui.custom')) throw new Error('tool context missing ui.custom capability');
2018                    return { content: [{ type: 'text', text: 'ui-context-ok' }], details: {} };
2019                }
2020            });"#,
2021        )
2022        .unwrap();
2023        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2024        // Simulate the interactive host installing the custom UI bridge before
2025        // the lazy Node process starts. The tool must observe the same runtime
2026        // context as a command handler.
2027        session
2028            .set_runtime_context(serde_json::json!({
2029                "mode": "tui",
2030                "hasUI": true,
2031                "capabilities": ["ui.custom"],
2032            }))
2033            .unwrap();
2034
2035        let tool = session.tools().next().expect("registered JS tool");
2036        let result = tool
2037            .execute(
2038                "tool-call-1",
2039                serde_json::json!({}),
2040                CancellationToken::new(),
2041                Arc::new(|_| {}),
2042            )
2043            .await
2044            .expect("tool should receive a UI context");
2045        match result.content.first() {
2046            Some(TextContentOrImage::Text(text)) => assert_eq!(text.text, "ui-context-ok"),
2047            other => panic!("unexpected tool content: {other:?}"),
2048        }
2049    }
2050
2051    #[test]
2052    fn loads_typescript_when_node_supports_type_stripping() {
2053        let temp = tempfile::tempdir().unwrap();
2054        let path = temp.path().join("extension.ts");
2055        std::fs::write(
2056            &path,
2057            "export default (pi: any) => pi.registerTool({name: 'typed', description: 'test', parameters: {type: 'object'}, async execute() { return {content: [{type: 'text', text: 'typed'}]}; }});",
2058        )
2059        .unwrap();
2060        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2061        assert_eq!(session.tools().next().unwrap().schema().name, "typed");
2062    }
2063
2064    #[test]
2065    fn command_context_roundtrips_editor_and_capabilities() {
2066        let temp = tempfile::tempdir().unwrap();
2067        let path = temp.path().join("command.js");
2068        std::fs::write(
2069            &path,
2070            "export default (pi) => pi.registerCommand('edit', async (_args, ctx) => { if (!ctx.capabilities.has('ui.editor')) throw new Error('missing capability'); if (ctx.model?.id !== 'model') throw new Error('missing model'); if (ctx.modelRegistry.getAll().length !== 1) throw new Error('missing catalog'); if (ctx.sessionManager.getSessionId() !== 'session-1' || ctx.sessionManager.getLeafId() !== 'leaf-1' || !ctx.sessionManager.getEntry('entry-1')) throw new Error('missing session'); ctx.ui.setEditorText(ctx.ui.getEditorText() + '!'); ctx.ui.notify('updated', 'info'); return {text: 'done'}; });",
2071        )
2072        .unwrap();
2073        let session = JsExtensionSession::load_with_context(
2074            &[path],
2075            false,
2076            serde_json::json!({
2077                "currentModel":{"id":"model","provider":"anthropic"},
2078                "models":[{"id":"model","provider":"anthropic"}]
2079            }),
2080        )
2081        .unwrap()
2082        .unwrap();
2083        assert!(session.transport.transport.lock().unwrap().is_none());
2084        session
2085            .set_runtime_context(serde_json::json!({
2086                "session":{"id":"session-1","leafId":"leaf-1","entries":[{"id":"entry-1"}]}
2087            }))
2088            .unwrap();
2089        assert!(session.transport.transport.lock().unwrap().is_none());
2090        let value = session
2091            .invoke_command_with_context("edit", "", serde_json::json!({"editorText":"draft"}))
2092            .unwrap();
2093        assert_eq!(value["result"]["text"], "done");
2094        assert_eq!(value["editorText"], "draft!");
2095        assert_eq!(value["notifications"][0]["message"], "updated");
2096        assert!(session
2097            .backend_info()
2098            .supports(crate::extension_api::ExtensionCapability::UiEditor));
2099    }
2100
2101    #[test]
2102    fn command_context_is_non_ui_until_a_tui_bridge_is_installed() {
2103        let temp = tempfile::tempdir().unwrap();
2104        let path = temp.path().join("ui-mode.js");
2105        std::fs::write(
2106            &path,
2107            r#"export default (pi) => pi.registerCommand('mode', async (_args, ctx) => ({
2108                hasUI: ctx.hasUI,
2109                mode: ctx.mode,
2110                hasCustom: typeof ctx.ui.custom === 'function'
2111            }));"#,
2112        )
2113        .unwrap();
2114        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2115        let plain = session.invoke_command("mode", "").unwrap();
2116        assert_eq!(plain["result"]["hasUI"], false);
2117        assert_eq!(plain["result"]["mode"], "print");
2118        assert_eq!(plain["result"]["hasCustom"], true);
2119
2120        session
2121            .install_ui_dialog_runtime(Arc::new(|action, _args| {
2122                Err(format!("unsupported capability: {action}"))
2123            }))
2124            .unwrap();
2125        let tui = session.invoke_command("mode", "").unwrap();
2126        assert_eq!(tui["result"]["hasUI"], true);
2127        assert_eq!(tui["result"]["mode"], "tui");
2128    }
2129
2130    #[test]
2131    fn before_agent_start_reconciles_js_active_tools_when_ui_changes() {
2132        let temp = tempfile::tempdir().unwrap();
2133        let path = temp.path().join("active-tools.js");
2134        std::fs::write(
2135            &path,
2136            r#"export default (pi) => {
2137                pi.registerTool({
2138                    name: 'ask_user_question',
2139                    description: 'probe',
2140                    parameters: { type: 'object', properties: {} },
2141                    async execute() { return { content: [{ type: 'text', text: 'ok' }] }; }
2142                });
2143                pi.on('before_agent_start', (_event, ctx) => {
2144                    const active = pi.getActiveTools();
2145                    if (!ctx.hasUI) {
2146                        pi.setActiveTools(active.filter((name) => name !== 'ask_user_question'));
2147                    } else if (!active.includes('ask_user_question')) {
2148                        pi.setActiveTools([...active, 'ask_user_question']);
2149                    }
2150                });
2151            };"#,
2152        )
2153        .unwrap();
2154        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2155        assert!(!session
2156            .active_tools()
2157            .unwrap()
2158            .iter()
2159            .any(|name| name == "ask_user_question"));
2160
2161        let tui = Arc::new(rpi_tui::TuiAltScreen::new(
2162            Box::new(rpi_tui::ProcessTerminal::new()),
2163            true,
2164            None,
2165        ));
2166        session.install_ui_runtime(tui).unwrap();
2167        assert!(session.transport.transport.lock().unwrap().is_none());
2168        session.ensure_runtime().unwrap();
2169        assert!(session
2170            .active_tools()
2171            .unwrap()
2172            .iter()
2173            .any(|name| name == "ask_user_question"));
2174    }
2175
2176    #[test]
2177    fn persistent_host_runs_before_agent_start_without_pending_context() {
2178        let temp = tempfile::tempdir().unwrap();
2179        let marker = temp.path().join("before-agent-start-count");
2180        let path = temp.path().join("before-agent-start.js");
2181        let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap();
2182        std::fs::write(&marker, "0").unwrap();
2183        std::fs::write(
2184            &path,
2185            format!(
2186                r#"import fs from 'node:fs';
2187                export default (pi) => {{
2188                    pi.registerCommand('ping', async () => ({{ text: 'pong' }}));
2189                    pi.on('before_agent_start', () => {{
2190                        const file = {marker};
2191                        const count = Number(fs.readFileSync(file, 'utf8') || '0') + 1;
2192                        fs.writeFileSync(file, String(count));
2193                    }});
2194                }};"#,
2195                marker = marker_literal,
2196            ),
2197        )
2198        .unwrap();
2199
2200        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2201        assert_eq!(
2202            std::fs::read_to_string(&marker).unwrap(),
2203            "1",
2204            "discovery host should run the lifecycle hook once"
2205        );
2206
2207        // No runtime context is queued here. The first command still starts a
2208        // new persistent host and must run its own lifecycle hook.
2209        session.invoke_command("ping", "").unwrap();
2210        assert_eq!(
2211            std::fs::read_to_string(&marker).unwrap(),
2212            "2",
2213            "persistent host skipped before_agent_start when no context was queued"
2214        );
2215    }
2216
2217    #[test]
2218    fn prepare_for_prompt_runs_lifecycle_once_per_prompt() {
2219        let temp = tempfile::tempdir().unwrap();
2220        let marker = temp.path().join("prompt-lifecycle-count");
2221        let path = temp.path().join("prompt-lifecycle.js");
2222        let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap();
2223        std::fs::write(&marker, "0").unwrap();
2224        std::fs::write(
2225            &path,
2226            format!(
2227                r#"import fs from 'node:fs';
2228                export default (pi) => {{
2229                    pi.on('before_agent_start', () => {{
2230                        const file = {marker};
2231                        const count = Number(fs.readFileSync(file, 'utf8') || '0') + 1;
2232                        fs.writeFileSync(file, String(count));
2233                    }});
2234                }};"#,
2235                marker = marker_literal,
2236            ),
2237        )
2238        .unwrap();
2239
2240        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2241        assert_eq!(std::fs::read_to_string(&marker).unwrap(), "1");
2242
2243        session.prepare_for_prompt().unwrap();
2244        assert_eq!(std::fs::read_to_string(&marker).unwrap(), "2");
2245
2246        session.prepare_for_prompt().unwrap();
2247        assert_eq!(std::fs::read_to_string(&marker).unwrap(), "3");
2248    }
2249
2250    #[test]
2251    fn concurrent_runtime_context_hooks_apply_latest_active_tools() {
2252        let temp = tempfile::tempdir().unwrap();
2253        let marker = temp.path().join("context-order.log");
2254        let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap();
2255        let path = temp.path().join("context-order.js");
2256        let source = r#"import fs from 'node:fs';
2257            export default (pi) => {
2258                pi.registerTool({ name: 'v1', description: 'v1', parameters: { type: 'object', properties: {} }, async execute() { return { text: 'v1' }; } });
2259                pi.registerTool({ name: 'v2', description: 'v2', parameters: { type: 'object', properties: {} }, async execute() { return { text: 'v2' }; } });
2260                pi.on('before_agent_start', async (_event, ctx) => {
2261                    const version = ctx.sessionManager.getSessionId();
2262                    fs.writeFileSync(__MARKER__, version);
2263                    if (version === 'v1') await new Promise(resolve => setTimeout(resolve, 200));
2264                    pi.setActiveTools([version]);
2265                });
2266            };"#
2267            .replace("__MARKER__", &marker_literal);
2268        std::fs::write(&path, source).unwrap();
2269
2270        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2271        session.ensure_runtime().unwrap();
2272
2273        let first = session.clone();
2274        let first_worker = std::thread::spawn(move || {
2275            first.set_runtime_context(serde_json::json!({ "session": { "id": "v1" } }))
2276        });
2277        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2278        while std::fs::read_to_string(&marker).ok().as_deref() != Some("v1")
2279            && std::time::Instant::now() < deadline
2280        {
2281            std::thread::sleep(std::time::Duration::from_millis(5));
2282        }
2283        assert_eq!(std::fs::read_to_string(&marker).unwrap(), "v1");
2284
2285        let second = session.clone();
2286        let second_worker = std::thread::spawn(move || {
2287            second.set_runtime_context(serde_json::json!({ "session": { "id": "v2" } }))
2288        });
2289        first_worker.join().unwrap().unwrap();
2290        second_worker.join().unwrap().unwrap();
2291
2292        assert_eq!(session.active_tools().unwrap(), ["v2"]);
2293    }
2294
2295    /// Only the newest revision may publish the tool projection it was
2296    /// answered with. Two threads can enter `set_runtime_context` at once, and
2297    /// the one holding the older response can reach the recording step last.
2298    #[test]
2299    fn superseded_revision_cannot_publish_its_tool_projection() {
2300        let transport = LazyNodeTransport::new(Vec::new(), serde_json::json!({}));
2301        let newest = std::sync::atomic::Ordering::Release;
2302        transport.context_revision.store(2, newest);
2303
2304        transport.record_active_tools_for_revision(2, Some(&serde_json::json!(["v2"])));
2305        assert_eq!(transport.active_tools().unwrap(), ["v2"]);
2306
2307        // A response from the superseded revision arrives last: it must not
2308        // resurrect the tool list the newer context already replaced.
2309        transport.record_active_tools_for_revision(1, Some(&serde_json::json!(["v1"])));
2310        assert_eq!(transport.active_tools().unwrap(), ["v2"]);
2311    }
2312
2313    #[test]
2314    fn stale_runtime_context_revision_cannot_overwrite_newer_state() {
2315        let temp = tempfile::tempdir().unwrap();
2316        let path = temp.path().join("context-revision.js");
2317        std::fs::write(
2318            &path,
2319            r#"export default (pi) => {
2320                pi.on('before_agent_start', (_event, ctx) => {
2321                    pi.setActiveTools([ctx.sessionManager.getSessionId()]);
2322                });
2323            };"#,
2324        )
2325        .unwrap();
2326        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2327        let transport = session.transport.ensure().unwrap();
2328
2329        let newer = transport
2330            .request(
2331                "set_runtime_context",
2332                serde_json::json!({
2333                    "revision": 2,
2334                    "context": {"session": {"id": "newer"}}
2335                }),
2336            )
2337            .unwrap();
2338        assert_eq!(newer["activeTools"], serde_json::json!(["newer"]));
2339
2340        let stale = transport
2341            .request(
2342                "set_runtime_context",
2343                serde_json::json!({
2344                    "revision": 1,
2345                    "context": {"session": {"id": "stale"}}
2346                }),
2347            )
2348            .unwrap();
2349        assert_eq!(stale["activeTools"], serde_json::json!(["newer"]));
2350    }
2351
2352    #[test]
2353    fn restarted_host_replays_context_queued_before_first_start() {
2354        let temp = tempfile::tempdir().unwrap();
2355        let path = temp.path().join("context-restart.js");
2356        std::fs::write(
2357            &path,
2358            r#"export default (pi) => {
2359                pi.registerCommand('session-id', async (_args, ctx) => ({
2360                    text: ctx.sessionManager.getSessionId()
2361                }));
2362            };"#,
2363        )
2364        .unwrap();
2365        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2366        session
2367            .set_runtime_context(serde_json::json!({"session": {"id": "latest"}}))
2368            .unwrap();
2369
2370        let first = session.invoke_command("session-id", "").unwrap();
2371        assert_eq!(first["result"]["text"], "latest");
2372        let first_transport = session.transport.live_transport().unwrap().unwrap();
2373        first_transport.shutdown();
2374
2375        let restarted = session.invoke_command("session-id", "").unwrap();
2376        assert_eq!(restarted["result"]["text"], "latest");
2377    }
2378
2379    #[test]
2380    fn failed_persistent_startup_requeues_initial_runtime_context() {
2381        let temp = tempfile::tempdir().unwrap();
2382        let path = temp.path().join("startup-failure.js");
2383        std::fs::write(
2384            &path,
2385            r#"export default async (pi) => {
2386                // Discovery is deliberately healthy, while the persistent
2387                // process exits before publishing its initialization envelope.
2388                // A thrown factory error is isolated per extension by the host
2389                // and therefore is not a transport-startup failure.
2390                if (process.env.RPI_JS_EXTENSION_ONESHOT !== '1') {
2391                    process.exit(17);
2392                }
2393                pi.registerCommand('ping', async () => ({ text: 'pong' }));
2394            };"#,
2395        )
2396        .unwrap();
2397        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2398        let context = serde_json::json!({
2399            "mode": "tui",
2400            "hasUI": true,
2401            "capabilities": ["ui.custom"],
2402            "session": {"id": "startup-session"},
2403        });
2404        session.set_runtime_context(context.clone()).unwrap();
2405
2406        let error = session.invoke_command("ping", "").unwrap_err();
2407        assert!(
2408            error.contains("exited before initialization")
2409                || error.contains("exited without a response")
2410                || error.contains("persistent host startup failure"),
2411            "unexpected startup error: {error}"
2412        );
2413        let pending = session
2414            .transport
2415            .pending_context
2416            .lock()
2417            .unwrap()
2418            .clone()
2419            .expect("failed startup must preserve runtime context");
2420        assert_eq!(pending["mode"], "tui");
2421        assert_eq!(pending["session"]["id"], "startup-session");
2422        assert_eq!(pending["capabilities"], serde_json::json!(["ui.custom"]));
2423    }
2424
2425    #[test]
2426    fn dialog_ui_roundtrips_through_runtime_handler() {
2427        let temp = tempfile::tempdir().unwrap();
2428        let path = temp.path().join("dialog.js");
2429        std::fs::write(
2430            &path,
2431            r#"export default (pi) => {
2432                pi.registerCommand('select', async (_args, ctx) => ({ text: await ctx.ui.select('Pick one', ['red', 'blue']) }));
2433                pi.registerCommand('confirm', async (_args, ctx) => ({ text: String(await ctx.ui.confirm('Continue?', 'Do it?')) }));
2434                pi.registerCommand('input', async (_args, ctx) => ({ text: await ctx.ui.input('Name', 'placeholder') }));
2435                pi.registerCommand('editor', async (_args, ctx) => ({ text: await ctx.ui.editor('Notes', 'prefilled') }));
2436            };"#,
2437        )
2438        .unwrap();
2439        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2440        let actions = Arc::new(Mutex::new(Vec::<(String, serde_json::Value)>::new()));
2441        let observed = actions.clone();
2442        session
2443            .install_ui_dialog_runtime(Arc::new(move |action, args| {
2444                observed
2445                    .lock()
2446                    .unwrap()
2447                    .push((action.to_string(), args.clone()));
2448                match action {
2449                    "ui.dialog" => match args.get("method").and_then(serde_json::Value::as_str) {
2450                        Some("select") => Ok(serde_json::json!({"value":"blue"})),
2451                        Some("confirm") => Ok(serde_json::json!({"confirmed":true})),
2452                        Some("input") => Ok(serde_json::json!({"value":"Ada"})),
2453                        Some("editor") => Ok(serde_json::json!({"value":"edited"})),
2454                        _ => Err("unknown dialog method".into()),
2455                    },
2456                    _ => Err(format!("unsupported capability: {action}")),
2457                }
2458            }))
2459            .unwrap();
2460
2461        let backend = session.backend_info();
2462        assert!(backend.supports(crate::extension_api::ExtensionCapability::UiSelect));
2463        assert!(backend.supports(crate::extension_api::ExtensionCapability::UiConfirm));
2464        assert!(backend.supports(crate::extension_api::ExtensionCapability::UiInput));
2465        assert!(backend.supports(crate::extension_api::ExtensionCapability::UiEditorDialog));
2466
2467        assert_eq!(
2468            session.invoke_command("select", "").unwrap()["result"]["text"],
2469            "blue"
2470        );
2471        assert_eq!(
2472            session.invoke_command("confirm", "").unwrap()["result"]["text"],
2473            "true"
2474        );
2475        assert_eq!(
2476            session.invoke_command("input", "").unwrap()["result"]["text"],
2477            "Ada"
2478        );
2479        assert_eq!(
2480            session.invoke_command("editor", "").unwrap()["result"]["text"],
2481            "edited"
2482        );
2483
2484        let actions = actions.lock().unwrap();
2485        assert_eq!(actions.len(), 4);
2486        assert!(actions.iter().all(|(action, _)| action == "ui.dialog"));
2487        assert_eq!(actions[0].1["method"], "select");
2488        assert_eq!(actions[0].1["options"], serde_json::json!(["red", "blue"]));
2489        assert_eq!(actions[2].1["placeholder"], "placeholder");
2490        assert_eq!(actions[3].1["prefill"], "prefilled");
2491    }
2492
2493    #[test]
2494    fn dialog_timeout_sends_cancel_request() {
2495        let temp = tempfile::tempdir().unwrap();
2496        let path = temp.path().join("dialog-timeout.js");
2497        std::fs::write(
2498            &path,
2499            r#"export default (pi) => pi.registerCommand('confirm', async (_args, ctx) => ({ text: String(await ctx.ui.confirm('Continue?', 'wait', { timeout: 20 })) }));"#,
2500        )
2501        .unwrap();
2502        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2503        let actions = Arc::new(Mutex::new(Vec::<String>::new()));
2504        let observed = actions.clone();
2505        session
2506            .install_ui_dialog_runtime(Arc::new(move |action, _args| {
2507                observed.lock().unwrap().push(action.to_string());
2508                match action {
2509                    "ui.dialog" => {
2510                        std::thread::sleep(std::time::Duration::from_millis(100));
2511                        Ok(serde_json::json!({"confirmed":true}))
2512                    }
2513                    "ui.dialog.cancel" => Ok(serde_json::json!(true)),
2514                    _ => Err(format!("unsupported capability: {action}")),
2515                }
2516            }))
2517            .unwrap();
2518        let value = session.invoke_command("confirm", "").unwrap();
2519        assert_eq!(value["result"]["text"], "false");
2520        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2521        while std::time::Instant::now() < deadline {
2522            let complete = {
2523                let actions = actions.lock().unwrap();
2524                actions.iter().any(|action| action == "ui.dialog")
2525                    && actions.iter().any(|action| action == "ui.dialog.cancel")
2526            };
2527            if complete {
2528                break;
2529            }
2530            std::thread::sleep(std::time::Duration::from_millis(10));
2531        }
2532        let actions = actions.lock().unwrap();
2533        assert!(actions.iter().any(|action| action == "ui.dialog"));
2534        assert!(actions.iter().any(|action| action == "ui.dialog.cancel"));
2535    }
2536
2537    #[test]
2538    fn runtime_request_roundtrips_through_rust_handler() {
2539        let temp = tempfile::tempdir().unwrap();
2540        let path = temp.path().join("runtime.js");
2541        std::fs::write(
2542            &path,
2543            "export default (pi) => pi.registerCommand('runtime', async () => ({text: await pi.runtimeRequest('echo', {value: 7})}));",
2544        )
2545        .unwrap();
2546        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2547        session
2548            .set_runtime_handler(Arc::new(|action, args| {
2549                if action == "echo" {
2550                    Ok(args.get("value").cloned().unwrap_or_default())
2551                } else {
2552                    Err(format!("unsupported capability: {action}"))
2553                }
2554            }))
2555            .unwrap();
2556        let value = session.invoke_command("runtime", "").unwrap();
2557        assert_eq!(value["result"]["text"], 7);
2558    }
2559
2560    #[test]
2561    fn startup_runtime_request_is_not_mistaken_for_init_and_uses_initial_handler() {
2562        let temp = tempfile::tempdir().unwrap();
2563        let path = temp.path().join("startup-runtime.js");
2564        std::fs::write(
2565            &path,
2566            r#"export default async (pi) => {
2567                const value = await pi.runtimeRequest('startup.echo', { value: 'persistent' })
2568                    .catch(() => 'discovery');
2569                pi.registerCommand('startup', async () => ({ text: String(value) }));
2570            };"#,
2571        )
2572        .unwrap();
2573
2574        // Discovery runs without runtime handlers. The request must receive a
2575        // normal unsupported-capability response so the host can still publish
2576        // its id:0 initialization envelope.
2577        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2578        session
2579            .set_runtime_handler(Arc::new(|action, args| {
2580                if action == "startup.echo" {
2581                    Ok(args.get("value").cloned().unwrap_or_default())
2582                } else {
2583                    Err(format!("unsupported capability: {action}"))
2584                }
2585            }))
2586            .unwrap();
2587
2588        // Lazy startup passes the registered handler before the factory runs,
2589        // so the persistent host observes the handler during initialization.
2590        let value = session.invoke_command("startup", "").unwrap();
2591        assert_eq!(value["result"]["text"], "persistent");
2592    }
2593
2594    #[test]
2595    fn provider_runtime_adapts_node_model_registry_to_rust_provider() {
2596        use rpi_ai::providers::faux::{FauxProvider, FauxScript};
2597
2598        let temp = tempfile::tempdir().unwrap();
2599        let path = temp.path().join("provider.js");
2600        std::fs::write(
2601            &path,
2602            r#"export default (pi) => pi.registerCommand('complete', async (_args, ctx) => {
2603                const model = ctx.model;
2604                const provider = ctx.modelRegistry.getProvider(model.provider);
2605                if (!provider) throw new Error('provider missing');
2606                const stream = provider.streamSimple(model, { messages: [] }, {});
2607                let eventCount = 0;
2608                for await (const event of stream) eventCount += 1;
2609                const message = await stream.result();
2610                return { text: message.content?.[0]?.text || '', stopReason: message.stopReason, eventCount };
2611            });"#,
2612        )
2613        .unwrap();
2614
2615        let runtime = tokio::runtime::Runtime::new().unwrap();
2616        let provider = FauxProvider::new(FauxScript::new().with_text("from rust"));
2617        let model = provider.default_model().clone();
2618        let session = JsExtensionSession::load_with_context(
2619            &[path],
2620            false,
2621            serde_json::json!({
2622                "currentModel": model,
2623                "models": [model],
2624            }),
2625        )
2626        .unwrap()
2627        .unwrap();
2628        session
2629            .enable_provider_runtime(provider.clone(), runtime.handle().clone())
2630            .unwrap();
2631
2632        // `invoke_command` is synchronous by design. Keep it outside the
2633        // runtime's enter context so the handler can block on provider work.
2634        let value = session.invoke_command("complete", "").unwrap();
2635        assert_eq!(value["result"]["text"], "from rust");
2636        assert_eq!(value["result"]["stopReason"], "stop");
2637        assert!(value["result"]["eventCount"].as_u64().unwrap() >= 2);
2638        assert_eq!(
2639            provider
2640                .state()
2641                .call_count
2642                .load(std::sync::atomic::Ordering::Relaxed),
2643            1
2644        );
2645        drop(session);
2646        runtime.shutdown_timeout(std::time::Duration::from_secs(1));
2647    }
2648
2649    #[test]
2650    fn node_transport_multiplexes_out_of_order_command_responses() {
2651        let temp = tempfile::tempdir().unwrap();
2652        let path = temp.path().join("concurrent.js");
2653        std::fs::write(
2654            &path,
2655            r#"export default (pi) => {
2656                pi.registerCommand('slow', async () => {
2657                    await new Promise(resolve => setTimeout(resolve, 300));
2658                    return { text: 'slow' };
2659                });
2660                pi.registerCommand('fast', async () => ({ text: 'fast' }));
2661            };"#,
2662        )
2663        .unwrap();
2664        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2665        assert!(session.transport.transport.lock().unwrap().is_none());
2666        let (sender, receiver) = std::sync::mpsc::channel();
2667
2668        let slow = session.clone();
2669        let slow_sender = sender.clone();
2670        std::thread::spawn(move || {
2671            let value = slow.invoke_command("slow", "").unwrap();
2672            slow_sender.send(value["result"]["text"].clone()).unwrap();
2673        });
2674        std::thread::sleep(std::time::Duration::from_millis(50));
2675        let fast = session.clone();
2676        std::thread::spawn(move || {
2677            let value = fast.invoke_command("fast", "").unwrap();
2678            sender.send(value["result"]["text"].clone()).unwrap();
2679        });
2680
2681        let first = receiver
2682            .recv_timeout(std::time::Duration::from_secs(2))
2683            .expect("fast request should not wait for the slow request");
2684        assert_eq!(first, serde_json::json!("fast"));
2685        assert_eq!(receiver.recv().unwrap(), serde_json::json!("slow"));
2686    }
2687
2688    #[test]
2689    fn shutting_down_session_wakes_detached_command_and_blocks_restart() {
2690        let temp = tempfile::tempdir().unwrap();
2691        let path = temp.path().join("shutdown.js");
2692        std::fs::write(
2693            &path,
2694            r#"export default (pi) => pi.registerCommand('wait', async () => {
2695                await new Promise(resolve => setTimeout(resolve, 10000));
2696                return { text: 'late' };
2697            });"#,
2698        )
2699        .unwrap();
2700        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2701        let worker_session = session.clone();
2702        let (sender, receiver) = std::sync::mpsc::channel();
2703        let worker = std::thread::spawn(move || {
2704            sender
2705                .send(worker_session.invoke_command("wait", ""))
2706                .unwrap();
2707        });
2708
2709        // Wait until the lazy transport has started and the worker has had a
2710        // chance to enqueue its request before exercising shutdown.
2711        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2712        while session.transport.transport.lock().unwrap().is_none()
2713            && std::time::Instant::now() < deadline
2714        {
2715            std::thread::sleep(std::time::Duration::from_millis(10));
2716        }
2717        assert!(
2718            session.transport.transport.lock().unwrap().is_some(),
2719            "lazy Node transport did not start"
2720        );
2721
2722        session.shutdown();
2723        session.shutdown();
2724        let result = receiver
2725            .recv_timeout(std::time::Duration::from_secs(2))
2726            .expect("shutdown should wake the detached command");
2727        assert!(result.is_err(), "stopped command unexpectedly succeeded");
2728        worker.join().unwrap();
2729        assert!(
2730            session.transport.ensure().is_err(),
2731            "shutdown must prevent lazy Node restart"
2732        );
2733    }
2734
2735    #[test]
2736    fn shutting_down_during_lazy_initialization_does_not_wait_for_slot_lock() {
2737        let temp = tempfile::tempdir().unwrap();
2738        let path = temp.path().join("shutdown-init.js");
2739        std::fs::write(
2740            &path,
2741            r#"export default (pi) => {
2742                pi.on('before_agent_start', async (_event, ctx) => {
2743                    if (ctx.hasUI) {
2744                        await new Promise(resolve => setTimeout(resolve, 5000));
2745                    }
2746                });
2747                pi.registerCommand('wait', async () => ({ text: 'late' }));
2748            };"#,
2749        )
2750        .unwrap();
2751        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2752        // Queue a context update so `ensure` has to wait for the corresponding
2753        // lifecycle response after the Node transport is started.
2754        session
2755            .set_runtime_context(serde_json::json!({
2756                "mode": "tui",
2757                "hasUI": true,
2758                "capabilities": ["ui.custom"],
2759            }))
2760            .unwrap();
2761        let worker_session = session.clone();
2762        let (command_sender, command_receiver) = std::sync::mpsc::channel();
2763        let command_worker = std::thread::spawn(move || {
2764            command_sender
2765                .send(worker_session.invoke_command("wait", ""))
2766                .unwrap();
2767        });
2768
2769        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2770        while session.transport.starting.lock().unwrap().is_none()
2771            && std::time::Instant::now() < deadline
2772        {
2773            std::thread::sleep(std::time::Duration::from_millis(10));
2774        }
2775        assert!(
2776            session.transport.starting.lock().unwrap().is_some(),
2777            "lazy startup did not publish its in-flight transport"
2778        );
2779
2780        let shutdown_session = session.clone();
2781        let (shutdown_sender, shutdown_receiver) = std::sync::mpsc::channel();
2782        let shutdown_worker = std::thread::spawn(move || {
2783            shutdown_session.shutdown();
2784            shutdown_sender.send(()).unwrap();
2785        });
2786        shutdown_receiver
2787            .recv_timeout(std::time::Duration::from_secs(2))
2788            .expect("shutdown should not wait for a blocked initialization request");
2789        let result = command_receiver
2790            .recv_timeout(std::time::Duration::from_secs(2))
2791            .expect("shutdown should wake lazy initialization");
2792        assert!(
2793            result.is_err(),
2794            "stopped initialization unexpectedly succeeded"
2795        );
2796        command_worker.join().unwrap();
2797        shutdown_worker.join().unwrap();
2798    }
2799
2800    #[test]
2801    fn shutting_down_during_node_initialization_kills_host_before_init_response() {
2802        let temp = tempfile::tempdir().unwrap();
2803        let path = temp.path().join("shutdown-before-init.js");
2804        std::fs::write(
2805            &path,
2806            r#"export default (pi) => {
2807                pi.on('before_agent_start', async (_event, ctx) => {
2808                    // Discovery runs in the one-shot host. Only the persistent
2809                    // host deliberately blocks before publishing id:0.
2810                    if (ctx.hasUI && process.env.RPI_JS_EXTENSION_ONESHOT !== '1') {
2811                        await new Promise(resolve => setTimeout(resolve, 30000));
2812                    }
2813                });
2814                pi.registerCommand('wait', async () => ({ text: 'late' }));
2815            };"#,
2816        )
2817        .unwrap();
2818
2819        let session = JsExtensionSession::load_with_context(
2820            &[path],
2821            false,
2822            serde_json::json!({"mode":"tui", "hasUI":true}),
2823        )
2824        .unwrap()
2825        .unwrap();
2826        let worker_session = session.clone();
2827        let (command_sender, command_receiver) = std::sync::mpsc::channel();
2828        let command_worker = std::thread::spawn(move || {
2829            command_sender
2830                .send(worker_session.invoke_command("wait", ""))
2831                .unwrap();
2832        });
2833
2834        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2835        while session.transport.starting.lock().unwrap().is_none()
2836            && std::time::Instant::now() < deadline
2837        {
2838            std::thread::sleep(std::time::Duration::from_millis(10));
2839        }
2840        assert!(
2841            session.transport.starting.lock().unwrap().is_some(),
2842            "lazy startup did not publish its transport before init"
2843        );
2844
2845        let started = std::time::Instant::now();
2846        session.shutdown();
2847        let result = command_receiver
2848            .recv_timeout(std::time::Duration::from_secs(2))
2849            .expect("shutdown should wake a command blocked in Node init");
2850        assert!(
2851            result.is_err(),
2852            "stopped initialization unexpectedly succeeded"
2853        );
2854        assert!(
2855            started.elapsed() < std::time::Duration::from_secs(2),
2856            "shutdown waited for the blocked init hook"
2857        );
2858        command_worker.join().unwrap();
2859    }
2860
2861    #[test]
2862    fn cancelling_prompt_preparation_kills_stuck_hook_and_allows_restart() {
2863        let temp = tempfile::tempdir().unwrap();
2864        let marker = temp.path().join("blocked-once");
2865        let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap();
2866        let path = temp.path().join("cancel-prompt-preparation.js");
2867        std::fs::write(
2868            &path,
2869            format!(
2870                r#"import fs from 'node:fs';
2871                export default (pi) => {{
2872                    pi.registerCommand('ping', async () => ({{ text: 'pong' }}));
2873                    pi.on('before_agent_start', async () => {{
2874                        if (process.env.RPI_JS_EXTENSION_ONESHOT !== '1'
2875                            && !fs.existsSync({marker})) {{
2876                            fs.writeFileSync({marker}, 'blocked');
2877                            await new Promise(() => {{}});
2878                        }}
2879                    }});
2880                }};"#,
2881                marker = marker_literal,
2882            ),
2883        )
2884        .unwrap();
2885
2886        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2887        let cancellation = CancellationToken::new();
2888        let worker_session = session.clone();
2889        let worker_cancellation = cancellation.clone();
2890        let (sender, receiver) = std::sync::mpsc::channel();
2891        let worker = std::thread::spawn(move || {
2892            sender
2893                .send(worker_session.prepare_for_prompt_with_cancellation(&worker_cancellation))
2894                .unwrap();
2895        });
2896
2897        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2898        while !marker.is_file() && std::time::Instant::now() < deadline {
2899            std::thread::sleep(std::time::Duration::from_millis(5));
2900        }
2901        assert!(marker.is_file(), "persistent lifecycle hook did not block");
2902
2903        cancellation.cancel();
2904        session.cancel_prompt_preparation();
2905        let result = receiver
2906            .recv_timeout(std::time::Duration::from_secs(2))
2907            .expect("cancellation should wake a stuck prompt preparation");
2908        assert!(
2909            result.is_err(),
2910            "cancelled preparation unexpectedly succeeded"
2911        );
2912        worker.join().unwrap();
2913        assert!(session.transport.transport.lock().unwrap().is_none());
2914        assert!(session.transport.starting.lock().unwrap().is_none());
2915
2916        // Cancelling one preparation is not a session shutdown. The marker
2917        // makes the replacement host's lifecycle return immediately.
2918        session.prepare_for_prompt().unwrap();
2919        let value = session.invoke_command("ping", "").unwrap();
2920        assert_eq!(value["result"]["text"], "pong");
2921    }
2922
2923    #[test]
2924    fn node_transport_cancellation_reaches_tool_abort_signal() {
2925        let temp = tempfile::tempdir().unwrap();
2926        let path = temp.path().join("cancel.js");
2927        std::fs::write(
2928            &path,
2929            r#"export default (pi) => pi.registerTool({
2930                name: 'wait',
2931                description: 'wait for cancellation',
2932                parameters: { type: 'object', properties: {} },
2933                execute: (_id, _args, signal) => new Promise(resolve => {
2934                    signal.addEventListener('abort', () => resolve({
2935                        content: [{ type: 'text', text: 'cancelled' }],
2936                        details: {}
2937                    }), { once: true });
2938                })
2939            });"#,
2940        )
2941        .unwrap();
2942        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2943        let pending = session
2944            .transport
2945            .begin_request(
2946                "invoke_tool",
2947                serde_json::json!({"tool": "wait", "toolCallId": "test", "args": {}}),
2948            )
2949            .unwrap();
2950        let request_id = pending.id();
2951        std::thread::sleep(std::time::Duration::from_millis(30));
2952        session.transport.cancel(request_id).unwrap();
2953        let result = pending.wait().unwrap();
2954        assert_eq!(result["content"][0]["text"], "cancelled");
2955    }
2956
2957    #[test]
2958    fn unsupported_ui_capabilities_fail_with_explicit_error() {
2959        let temp = tempfile::tempdir().unwrap();
2960        let path = temp.path().join("ui.js");
2961        std::fs::write(
2962            &path,
2963            "export default (pi) => pi.registerCommand('ui', async (_args, ctx) => { await ctx.ui.custom(() => null); });",
2964        )
2965        .unwrap();
2966        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2967        let error = session.invoke_command("ui", "").unwrap_err();
2968        assert!(error.contains("unsupported capability: ui.custom"));
2969    }
2970
2971    #[test]
2972    fn custom_ui_roundtrips_open_render_and_done() {
2973        let temp = tempfile::tempdir().unwrap();
2974        let path = temp.path().join("custom.js");
2975        std::fs::write(
2976            &path,
2977            r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
2978                return await ctx.ui.custom((parent, theme, keybindings, done) => {
2979                    parent.terminal.write(theme.fg('muted', 'frame'));
2980                    done({ ok: true });
2981                    return { render() { return ['frame']; }, handleInput() { return true; } };
2982                }, { overlay: true });
2983            });"#,
2984        )
2985        .unwrap();
2986        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2987        let actions = Arc::new(Mutex::new(Vec::<String>::new()));
2988        let observed = actions.clone();
2989        session
2990            .set_runtime_handler(Arc::new(move |action, args| {
2991                observed.lock().unwrap().push(action.to_string());
2992                match action {
2993                    "ui.custom.open" | "ui.custom.close" => Ok(serde_json::json!(true)),
2994                    "ui.custom.write" => {
2995                        let data = args["data"].as_str().unwrap_or_default();
2996                        assert!(data.ends_with("frame\r\n") || data.starts_with("frame"));
2997                        Ok(serde_json::json!(true))
2998                    }
2999                    _ => Err(format!("unsupported capability: {action}")),
3000                }
3001            }))
3002            .unwrap();
3003        let value = session.invoke_command("custom", "").unwrap();
3004        assert_eq!(value["result"]["ok"], true);
3005        let actions = actions.lock().unwrap();
3006        assert!(actions.iter().any(|action| action == "ui.custom.open"));
3007        assert!(actions.iter().any(|action| action == "ui.custom.write"));
3008        assert!(actions.iter().any(|action| action == "ui.custom.close"));
3009    }
3010
3011    #[test]
3012    fn custom_ui_forwards_overlay_options_handle_and_terminal_size() {
3013        let temp = tempfile::tempdir().unwrap();
3014        let path = temp.path().join("custom-overlay.js");
3015        std::fs::write(
3016            &path,
3017            r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3018                let finish;
3019                const result = ctx.ui.custom((parent, _theme, _keys, done) => {
3020                    if (parent.terminal.columns !== 90 || parent.terminal.rows !== 20) throw new Error('wrong terminal size');
3021                    finish = done;
3022                    return { render() { return ['overlay-frame']; }, invalidate() {}, dispose() {} };
3023                }, {
3024                    overlay: true,
3025                    overlayOptions: { anchor: 'bottom-center', width: '100%', maxHeight: '100%', margin: { left: 0, right: 0, bottom: 0 } },
3026                    onHandle(handle) {
3027                        if (!handle.isFocused() || handle.isHidden()) throw new Error('invalid overlay handle');
3028                        handle.setHidden(true);
3029                        if (!handle.isHidden()) throw new Error('setHidden(true) was ignored');
3030                        handle.setHidden(false);
3031                        if (handle.isHidden()) throw new Error('setHidden(false) was ignored');
3032                        finish({ ok: true });
3033                    }
3034                });
3035                return await result;
3036            });"#,
3037        )
3038        .unwrap();
3039        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3040        let actions = Arc::new(Mutex::new(Vec::<(String, serde_json::Value)>::new()));
3041        let observed = actions.clone();
3042        session
3043            .set_runtime_handler(Arc::new(move |action, args| {
3044                observed
3045                    .lock()
3046                    .unwrap()
3047                    .push((action.to_string(), args.clone()));
3048                match action {
3049                    "ui.custom.open" => Ok(serde_json::json!({ "columns": 90, "rows": 20 })),
3050                    "ui.custom.handle" | "ui.custom.close" | "ui.custom.invalidate" => {
3051                        Ok(serde_json::json!(true))
3052                    }
3053                    "ui.custom.write" => Ok(serde_json::json!(true)),
3054                    _ => Err(format!("unsupported capability: {action}")),
3055                }
3056            }))
3057            .unwrap();
3058        let value = session.invoke_command("custom", "").unwrap();
3059        assert_eq!(value["result"]["ok"], true);
3060        let actions = actions.lock().unwrap();
3061        let open = actions
3062            .iter()
3063            .find(|(action, _)| action == "ui.custom.open")
3064            .expect("custom open action");
3065        assert_eq!(open.1["options"]["overlay"], true);
3066        assert_eq!(
3067            open.1["options"]["overlayOptions"]["anchor"],
3068            "bottom-center"
3069        );
3070        assert!(actions
3071            .iter()
3072            .any(|(action, _)| action == "ui.custom.handle"));
3073        assert!(actions
3074            .iter()
3075            .any(|(action, args)| { action == "ui.custom.handle" && args["hidden"] == true }));
3076        assert!(actions
3077            .iter()
3078            .any(|(action, args)| { action == "ui.custom.handle" && args["hidden"] == false }));
3079    }
3080
3081    #[test]
3082    fn custom_ui_terminal_input_listener_can_consume_before_component() {
3083        let temp = tempfile::tempdir().unwrap();
3084        let path = temp.path().join("custom-input.js");
3085        std::fs::write(
3086            &path,
3087            r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3088                let finish;
3089                ctx.ui.onTerminalInput((data) => {
3090                    if (data !== 'x') return;
3091                    finish({ consumed: true });
3092                    return { consume: true };
3093                });
3094                return await ctx.ui.custom((_parent, _theme, _keys, done) => {
3095                    finish = done;
3096                    return { handleInput() { throw new Error('component received consumed input'); } };
3097                });
3098            });"#,
3099        )
3100        .unwrap();
3101        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3102        let tui = Arc::new(rpi_tui::TuiAltScreen::new(
3103            Box::new(rpi_tui::ProcessTerminal::new()),
3104            true,
3105            None,
3106        ));
3107        session.install_ui_runtime(tui).unwrap();
3108
3109        let worker_session = session.clone();
3110        let (sender, receiver) = std::sync::mpsc::channel();
3111        std::thread::spawn(move || {
3112            sender
3113                .send(worker_session.invoke_command("custom", ""))
3114                .unwrap();
3115        });
3116        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3117        while !session.custom_active() && std::time::Instant::now() < deadline {
3118            std::thread::sleep(std::time::Duration::from_millis(10));
3119        }
3120        assert!(session.custom_active(), "custom UI did not open");
3121        session.send_custom_input("x").unwrap();
3122        let value = receiver
3123            .recv_timeout(std::time::Duration::from_secs(5))
3124            .unwrap()
3125            .unwrap();
3126        assert_eq!(value["result"]["consumed"], true);
3127    }
3128
3129    #[test]
3130    fn custom_ui_terminal_input_listener_can_swallow_with_empty_data() {
3131        let temp = tempfile::tempdir().unwrap();
3132        let path = temp.path().join("custom-empty-input.js");
3133        std::fs::write(
3134            &path,
3135            r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3136                let finish;
3137                let componentCalls = 0;
3138                ctx.ui.onTerminalInput((data) => {
3139                    if (data !== 'empty') return;
3140                    // Let the current input dispatch finish before resolving
3141                    // the command. Without the host's empty-data short-circuit
3142                    // the component is called before this timer and the count
3143                    // becomes 1.
3144                    setTimeout(() => finish({ componentCalls }), 0);
3145                    return { data: '' };
3146                });
3147                return await ctx.ui.custom((_parent, _theme, _keys, done) => {
3148                    finish = done;
3149                    return { handleInput() { componentCalls += 1; } };
3150                });
3151            });"#,
3152        )
3153        .unwrap();
3154        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3155        let tui = Arc::new(rpi_tui::TuiAltScreen::new(
3156            Box::new(rpi_tui::ProcessTerminal::new()),
3157            true,
3158            None,
3159        ));
3160        session.install_ui_runtime(tui).unwrap();
3161
3162        let worker_session = session.clone();
3163        let (sender, receiver) = std::sync::mpsc::channel();
3164        std::thread::spawn(move || {
3165            sender
3166                .send(worker_session.invoke_command("custom", ""))
3167                .unwrap();
3168        });
3169        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3170        while !session.custom_active() && std::time::Instant::now() < deadline {
3171            std::thread::sleep(std::time::Duration::from_millis(10));
3172        }
3173        assert!(session.custom_active(), "custom UI did not open");
3174
3175        session.send_custom_input("empty").unwrap();
3176        let value = receiver
3177            .recv_timeout(std::time::Duration::from_secs(2))
3178            .unwrap()
3179            .unwrap();
3180        assert_eq!(value["result"]["componentCalls"], 0);
3181    }
3182
3183    #[test]
3184    fn hidden_custom_ui_releases_unconsumed_input_but_keeps_listener() {
3185        let temp = tempfile::tempdir().unwrap();
3186        let path = temp.path().join("custom-hidden-input.js");
3187        std::fs::write(
3188            &path,
3189            r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3190                let finish;
3191                let reopen;
3192                let componentCalls = 0;
3193                ctx.ui.onTerminalInput((data) => {
3194                    if (data === 'reopen') {
3195                        reopen?.setHidden(false);
3196                        return { consume: true };
3197                    }
3198                    if (data !== 'finish') return;
3199                    finish({ componentCalls });
3200                    return { consume: true };
3201                });
3202                return await ctx.ui.custom((_parent, _theme, _keys, done) => {
3203                    finish = done;
3204                    return { handleInput() { componentCalls += 1; } };
3205                 }, { onHandle(handle) { reopen = handle; handle.setHidden(true); } });
3206            });"#,
3207        )
3208        .unwrap();
3209        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3210        let tui = Arc::new(rpi_tui::TuiAltScreen::new(
3211            Box::new(rpi_tui::ProcessTerminal::new()),
3212            true,
3213            None,
3214        ));
3215        session.install_ui_runtime(tui).unwrap();
3216
3217        let worker_session = session.clone();
3218        let (sender, receiver) = std::sync::mpsc::channel();
3219        std::thread::spawn(move || {
3220            sender
3221                .send(worker_session.invoke_command("custom", ""))
3222                .unwrap();
3223        });
3224        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3225        while (session.custom_accepts_input() || !session.custom_active())
3226            && std::time::Instant::now() < deadline
3227        {
3228            std::thread::sleep(std::time::Duration::from_millis(10));
3229        }
3230        assert!(session.custom_active(), "custom UI did not open");
3231        assert!(
3232            !session.custom_accepts_input(),
3233            "hidden UI kept keyboard focus"
3234        );
3235
3236        // An ordinary key is acknowledged as unconsumed and must not reach the
3237        // hidden component. The outer TUI can use the same key for editing.
3238        assert!(!session.send_custom_input_with_consumed("ordinary").unwrap());
3239        assert!(session.send_custom_input_with_consumed("reopen").unwrap());
3240        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3241        while !session.custom_accepts_input() && std::time::Instant::now() < deadline {
3242            std::thread::sleep(std::time::Duration::from_millis(10));
3243        }
3244        assert!(
3245            session.custom_accepts_input(),
3246            "reopen shortcut did not restore focus"
3247        );
3248        // The raw listener still receives input while hidden and can reopen or
3249        // finish the overlay by consuming its shortcut.
3250        assert!(session.send_custom_input_with_consumed("finish").unwrap());
3251        let value = receiver
3252            .recv_timeout(std::time::Duration::from_secs(2))
3253            .unwrap()
3254            .unwrap();
3255        assert_eq!(value["result"]["componentCalls"], 0);
3256    }
3257
3258    #[test]
3259    fn custom_ui_closes_when_command_is_cancelled() {
3260        let temp = tempfile::tempdir().unwrap();
3261        let path = temp.path().join("custom-cancel.js");
3262        std::fs::write(
3263            &path,
3264            r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3265                return await ctx.ui.custom((_parent, _theme, _keys, _done) => ({
3266                    handleInput() {},
3267                    dispose() {},
3268                }));
3269            });"#,
3270        )
3271        .unwrap();
3272        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3273        let tui = Arc::new(rpi_tui::TuiAltScreen::new(
3274            Box::new(rpi_tui::ProcessTerminal::new()),
3275            true,
3276            None,
3277        ));
3278        session.install_ui_runtime(tui).unwrap();
3279
3280        let pending = session
3281            .transport
3282            .begin_request(
3283                "invoke_command",
3284                serde_json::json!({"command":"custom", "args":"", "context":{}}),
3285            )
3286            .unwrap();
3287        let request_id = pending.id();
3288        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3289        while !session.custom_active() && std::time::Instant::now() < deadline {
3290            std::thread::sleep(std::time::Duration::from_millis(10));
3291        }
3292        assert!(session.custom_active(), "custom UI did not open");
3293
3294        session.transport.cancel(request_id).unwrap();
3295        let value = pending
3296            .wait()
3297            .expect("cancelled custom command should resolve");
3298        assert!(value.get("result").is_some());
3299        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3300        while session.custom_active() && std::time::Instant::now() < deadline {
3301            std::thread::sleep(std::time::Duration::from_millis(10));
3302        }
3303        assert!(
3304            !session.custom_active(),
3305            "cancelled custom UI remained active"
3306        );
3307    }
3308
3309    #[test]
3310    fn runtime_handler_added_during_startup_reaches_starting_host() {
3311        let temp = tempfile::tempdir().unwrap();
3312        let ready = temp.path().join("persistent-factory-ready");
3313        let release = temp.path().join("release-persistent-factory");
3314        let path = temp.path().join("startup-handler-race.js");
3315        let ready_literal = serde_json::to_string(&ready.to_string_lossy()).unwrap();
3316        let release_literal = serde_json::to_string(&release.to_string_lossy()).unwrap();
3317        std::fs::write(
3318            &path,
3319            format!(
3320                r#"import fs from 'node:fs';
3321                const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3322                export default async (pi) => {{
3323                    if (process.env.RPI_JS_EXTENSION_ONESHOT === '1') {{
3324                        pi.registerCommand('late', async () => ({{ text: 'discovery' }}));
3325                        return;
3326                    }}
3327                    fs.writeFileSync({ready}, 'ready');
3328                    while (!fs.existsSync({release})) await sleep(5);
3329                    const value = await pi.runtimeRequest('late.echo', {{ value: 'handled' }});
3330                    pi.registerCommand('late', async () => ({{ text: String(value) }}));
3331                }};"#,
3332                ready = ready_literal,
3333                release = release_literal,
3334            ),
3335        )
3336        .unwrap();
3337
3338        let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3339        let worker_session = session.clone();
3340        let (sender, receiver) = std::sync::mpsc::channel();
3341        std::thread::spawn(move || {
3342            sender
3343                .send(worker_session.invoke_command("late", ""))
3344                .unwrap();
3345        });
3346
3347        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3348        while !ready.is_file() && std::time::Instant::now() < deadline {
3349            std::thread::sleep(std::time::Duration::from_millis(5));
3350        }
3351        assert!(ready.is_file(), "persistent factory did not reach startup");
3352        while session.transport.starting.lock().unwrap().is_none()
3353            && std::time::Instant::now() < deadline
3354        {
3355            std::thread::sleep(std::time::Duration::from_millis(5));
3356        }
3357        assert!(
3358            session.transport.starting.lock().unwrap().is_some(),
3359            "startup transport was not published"
3360        );
3361
3362        session
3363            .set_runtime_handler(Arc::new(|action, args| {
3364                if action == "late.echo" {
3365                    Ok(args.get("value").cloned().unwrap_or_default())
3366                } else {
3367                    Err(format!("unsupported capability: {action}"))
3368                }
3369            }))
3370            .unwrap();
3371        std::fs::write(&release, "release").unwrap();
3372
3373        let result = receiver
3374            .recv_timeout(std::time::Duration::from_secs(2))
3375            .expect("startup command did not finish");
3376        assert_eq!(result.unwrap()["result"]["text"], "handled");
3377    }
3378}