Skip to main content

pi/
extensions_js.rs

1//! QuickJS runtime bridge for JS-compatible extensions.
2//!
3//! This module implements the PiJS runtime with Promise-based hostcall bridge:
4//! - Async QuickJS runtime + context creation
5//! - `pi` global object with Promise-returning hostcall methods
6//! - Deterministic event loop scheduler integration
7//! - call_id → Promise resolver mapping for hostcall completions
8//! - Microtask draining after each macrotask
9//!
10//! # Architecture (bd-2ke)
11//!
12//! ```text
13//! JS Code                     Rust Host
14//! -------                     ---------
15//! pi.tool("read", {...})  --> enqueue HostcallRequest
16//!   returns Promise           generate call_id
17//!   store (resolve, reject)   track pending hostcall
18//!
19//! [scheduler tick]        <-- host completes hostcall
20//!   delivers MacrotaskKind::HostcallComplete
21//!   lookup (resolve, reject) by call_id
22//!   resolve(result) or reject(error)
23//!   drain microtasks (Promises .then chains)
24//! ```
25
26use crate::error::{Error, Result};
27use crate::hostcall_io_uring_lane::{
28    HostcallCapabilityClass, HostcallIoHint, IoUringLaneDecisionInput,
29};
30use crate::hostcall_queue::{
31    HOSTCALL_FAST_RING_CAPACITY, HOSTCALL_OVERFLOW_CAPACITY, HostcallQueueEnqueueResult,
32    HostcallQueueTelemetry, HostcallRequestQueue, QueueTenant,
33};
34use crate::scheduler::{Clock as SchedulerClock, HostcallOutcome, Scheduler, WallClock};
35use base64::Engine as _;
36use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
37use rquickjs::function::{Func, Opt};
38use rquickjs::loader::{Loader as JsModuleLoader, Resolver as JsModuleResolver};
39use rquickjs::module::Declared as JsModuleDeclared;
40use rquickjs::{
41    AsyncContext, AsyncRuntime, Coerced, Ctx, Exception, FromJs, Function, IntoJs, Module, Object,
42    Value,
43};
44use sha2::{Digest, Sha256};
45use std::cell::RefCell;
46use std::cmp::Ordering;
47use std::collections::{BTreeSet, BinaryHeap, HashMap, HashSet, VecDeque};
48use std::fmt::Write as _;
49use std::rc::Rc;
50use std::sync::Arc;
51use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
52use std::time::{SystemTime, UNIX_EPOCH};
53use std::{fs, path::Path, path::PathBuf};
54use swc_common::{FileName, GLOBALS, Globals, Mark, SourceMap, sync::Lrc};
55use swc_ecma_ast::{Module as SwcModule, Pass, Program as SwcProgram};
56use swc_ecma_codegen::{Emitter, text_writer::JsWriter};
57use swc_ecma_parser::{Parser as SwcParser, StringInput, Syntax, TsSyntax};
58use swc_ecma_transforms_base::resolver;
59use swc_ecma_transforms_typescript::strip;
60use uuid::Uuid;
61
62// ============================================================================
63// Environment variable filtering (bd-1av0.9)
64// ============================================================================
65
66use crate::extensions::{
67    ExecMediationResult, ExtensionPolicy, ExtensionPolicyMode, SecretBrokerPolicy,
68    evaluate_exec_mediation,
69};
70
71/// Helper to check `exec` capability for sync execution where we cannot prompt.
72fn check_exec_capability(policy: &ExtensionPolicy, extension_id: Option<&str>) -> bool {
73    let cap = "exec";
74
75    // 1. Per-extension overrides
76    if let Some(id) = extension_id {
77        if let Some(override_config) = policy.per_extension.get(id) {
78            if override_config.deny.iter().any(|c| c == cap) {
79                return false;
80            }
81            if override_config.allow.iter().any(|c| c == cap) {
82                return true;
83            }
84            if let Some(mode) = override_config.mode {
85                return match mode {
86                    ExtensionPolicyMode::Permissive => true,
87                    ExtensionPolicyMode::Strict | ExtensionPolicyMode::Prompt => false, // Prompt = deny for sync
88                };
89            }
90        }
91    }
92
93    // 2. Global deny
94    if policy.deny_caps.iter().any(|c| c == cap) {
95        return false;
96    }
97
98    // 3. Global allow (default_caps)
99    if policy.default_caps.iter().any(|c| c == cap) {
100        return true;
101    }
102
103    // 4. Mode fallback
104    match policy.mode {
105        ExtensionPolicyMode::Permissive => true,
106        ExtensionPolicyMode::Strict | ExtensionPolicyMode::Prompt => false, // Prompt = deny for sync
107    }
108}
109
110/// Determine whether an environment variable is safe to expose to extensions.
111///
112/// Uses the default `SecretBrokerPolicy` to block known sensitive patterns
113/// (API keys, secrets, tokens, passwords, credentials).
114pub fn is_env_var_allowed(key: &str) -> bool {
115    let policy = SecretBrokerPolicy::default();
116    // is_secret returns true if it IS a secret (should be blocked).
117    // So we allow it if it is NOT a secret.
118    !policy.is_secret(key)
119}
120
121fn parse_truthy_flag(value: &str) -> bool {
122    matches!(
123        value.trim().to_ascii_lowercase().as_str(),
124        "1" | "true" | "yes" | "on"
125    )
126}
127
128fn is_global_compat_scan_mode() -> bool {
129    cfg!(feature = "ext-conformance")
130        || std::env::var("PI_EXT_COMPAT_SCAN").is_ok_and(|value| parse_truthy_flag(&value))
131}
132
133fn is_compat_scan_mode(env: &HashMap<String, String>) -> bool {
134    is_global_compat_scan_mode()
135        || env
136            .get("PI_EXT_COMPAT_SCAN")
137            .is_some_and(|value| parse_truthy_flag(value))
138}
139
140/// Compatibility-mode fallback values for environment-gated extension registration.
141///
142/// This keeps conformance scans deterministic while preserving the default secret
143/// filtering behavior in normal runtime mode.
144fn compat_env_fallback_value(key: &str, env: &HashMap<String, String>) -> Option<String> {
145    if !is_compat_scan_mode(env) {
146        return None;
147    }
148
149    let upper = key.to_ascii_uppercase();
150    if upper.ends_with("_API_KEY") {
151        return Some(format!("pi-compat-{}", upper.to_ascii_lowercase()));
152    }
153    if upper == "PI_SEMANTIC_LEGACY" {
154        return Some("1".to_string());
155    }
156
157    None
158}
159
160// ============================================================================
161// Promise Bridge Types (bd-2ke)
162// ============================================================================
163
164/// Type of hostcall being requested from JavaScript.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum HostcallKind {
167    /// pi.tool(name, input) - invoke a tool
168    Tool { name: String },
169    /// pi.exec(cmd, args) - execute a shell command
170    Exec { cmd: String },
171    /// pi.http(request) - make an HTTP request
172    Http,
173    /// pi.session(op, args) - session operations
174    Session { op: String },
175    /// pi.ui(op, args) - UI operations
176    Ui { op: String },
177    /// pi.events(op, args) - event operations
178    Events { op: String },
179    /// pi.log(entry) - structured log emission
180    Log,
181}
182
183/// A hostcall request enqueued from JavaScript.
184#[derive(Debug, Clone)]
185pub struct HostcallRequest {
186    /// Unique identifier for correlation.
187    pub call_id: String,
188    /// Type of hostcall.
189    pub kind: HostcallKind,
190    /// JSON payload for the hostcall.
191    pub payload: serde_json::Value,
192    /// Trace ID for correlation with macrotask.
193    pub trace_id: u64,
194    /// Active extension id (when known) for policy/log correlation.
195    pub extension_id: Option<String>,
196}
197
198impl QueueTenant for HostcallRequest {
199    fn tenant_key(&self) -> Option<&str> {
200        self.extension_id.as_deref()
201    }
202}
203
204/// Tool definition registered by a JS extension.
205#[allow(clippy::derive_partial_eq_without_eq)]
206#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
207pub struct ExtensionToolDef {
208    pub name: String,
209    #[serde(default)]
210    pub label: Option<String>,
211    pub description: String,
212    pub parameters: serde_json::Value,
213}
214
215/// Delegates to the canonical streaming implementation in `extensions.rs`.
216fn hostcall_params_hash(method: &str, params: &serde_json::Value) -> String {
217    crate::extensions::hostcall_params_hash(method, params)
218}
219
220fn canonical_exec_params(cmd: &str, payload: &serde_json::Value) -> serde_json::Value {
221    let mut obj = match payload {
222        serde_json::Value::Object(map) => {
223            let mut out = map.clone();
224            out.remove("command");
225            out
226        }
227        serde_json::Value::Null => serde_json::Map::new(),
228        other => {
229            let mut out = serde_json::Map::new();
230            out.insert("payload".to_string(), other.clone());
231            out
232        }
233    };
234
235    obj.insert(
236        "cmd".to_string(),
237        serde_json::Value::String(cmd.to_string()),
238    );
239    serde_json::Value::Object(obj)
240}
241
242fn canonical_op_params(op: &str, payload: &serde_json::Value) -> serde_json::Value {
243    // Fast path: null payload (common for get_state, get_name, etc.) — build
244    // result directly without creating an intermediate Map.
245    if payload.is_null() {
246        return serde_json::json!({ "op": op });
247    }
248
249    let mut obj = match payload {
250        serde_json::Value::Object(map) => map.clone(),
251        other => {
252            let mut out = serde_json::Map::new();
253            // Reserved key for non-object args to avoid dropping semantics.
254            out.insert("payload".to_string(), other.clone());
255            out
256        }
257    };
258
259    // Explicit op from hostcall kind always wins.
260    obj.insert("op".to_string(), serde_json::Value::String(op.to_string()));
261    serde_json::Value::Object(obj)
262}
263
264fn builtin_tool_required_capability(name: &str) -> &'static str {
265    let name = name.trim();
266    if name.eq_ignore_ascii_case("read")
267        || name.eq_ignore_ascii_case("grep")
268        || name.eq_ignore_ascii_case("find")
269        || name.eq_ignore_ascii_case("ls")
270    {
271        "read"
272    } else if name.eq_ignore_ascii_case("write") || name.eq_ignore_ascii_case("edit") {
273        "write"
274    } else if name.eq_ignore_ascii_case("bash") {
275        "exec"
276    } else {
277        "tool"
278    }
279}
280
281impl HostcallRequest {
282    #[must_use]
283    pub const fn method(&self) -> &'static str {
284        match self.kind {
285            HostcallKind::Tool { .. } => "tool",
286            HostcallKind::Exec { .. } => "exec",
287            HostcallKind::Http => "http",
288            HostcallKind::Session { .. } => "session",
289            HostcallKind::Ui { .. } => "ui",
290            HostcallKind::Events { .. } => "events",
291            HostcallKind::Log => "log",
292        }
293    }
294
295    #[must_use]
296    pub fn required_capability(&self) -> &'static str {
297        match &self.kind {
298            HostcallKind::Tool { name } => builtin_tool_required_capability(name),
299            HostcallKind::Exec { .. } => "exec",
300            HostcallKind::Http => "http",
301            HostcallKind::Session { .. } => "session",
302            HostcallKind::Ui { .. } => "ui",
303            HostcallKind::Events { .. } => "events",
304            HostcallKind::Log => "log",
305        }
306    }
307
308    #[must_use]
309    pub fn io_uring_capability_class(&self) -> HostcallCapabilityClass {
310        HostcallCapabilityClass::from_capability(self.required_capability())
311    }
312
313    #[must_use]
314    pub fn io_uring_io_hint(&self) -> HostcallIoHint {
315        match &self.kind {
316            HostcallKind::Http => HostcallIoHint::IoHeavy,
317            HostcallKind::Exec { .. } => HostcallIoHint::CpuBound,
318            HostcallKind::Tool { name } => {
319                let name = name.trim();
320                if name.eq_ignore_ascii_case("read")
321                    || name.eq_ignore_ascii_case("write")
322                    || name.eq_ignore_ascii_case("edit")
323                    || name.eq_ignore_ascii_case("grep")
324                    || name.eq_ignore_ascii_case("find")
325                    || name.eq_ignore_ascii_case("ls")
326                {
327                    HostcallIoHint::IoHeavy
328                } else if name.eq_ignore_ascii_case("bash") {
329                    HostcallIoHint::CpuBound
330                } else {
331                    HostcallIoHint::Unknown
332                }
333            }
334            HostcallKind::Session { .. }
335            | HostcallKind::Ui { .. }
336            | HostcallKind::Events { .. }
337            | HostcallKind::Log => HostcallIoHint::Unknown,
338        }
339    }
340
341    #[must_use]
342    pub fn io_uring_lane_input(
343        &self,
344        queue_depth: usize,
345        force_compat_lane: bool,
346    ) -> IoUringLaneDecisionInput {
347        IoUringLaneDecisionInput {
348            capability: self.io_uring_capability_class(),
349            io_hint: self.io_uring_io_hint(),
350            queue_depth,
351            force_compat_lane,
352        }
353    }
354
355    /// Build the canonical params shape for hashing.
356    ///
357    /// **Canonical shapes** (must match `hostcall_request_to_payload()` in `extensions.rs`):
358    /// - `tool`:  `{ "name": <tool_name>, "input": <payload> }`
359    /// - `exec`:  `{ "cmd": <string>, ...payload_fields }`
360    /// - `http`:  payload passthrough
361    /// - `session/ui/events`:  `{ "op": <string>, ...payload_fields }` (flattened)
362    ///
363    /// For non-object args to `session/ui/events`, payload is preserved under
364    /// a reserved `"payload"` key (e.g. `{ "op": "set_status", "payload": "ready" }`).
365    #[must_use]
366    pub fn params_for_hash(&self) -> serde_json::Value {
367        match &self.kind {
368            HostcallKind::Tool { name } => {
369                serde_json::json!({ "name": name, "input": self.payload.clone() })
370            }
371            HostcallKind::Exec { cmd } => canonical_exec_params(cmd, &self.payload),
372            HostcallKind::Http | HostcallKind::Log => self.payload.clone(),
373            HostcallKind::Session { op }
374            | HostcallKind::Ui { op }
375            | HostcallKind::Events { op } => canonical_op_params(op, &self.payload),
376        }
377    }
378
379    #[must_use]
380    pub fn params_hash(&self) -> String {
381        hostcall_params_hash(self.method(), &self.params_for_hash())
382    }
383}
384
385const MAX_JSON_DEPTH: usize = 64;
386const MAX_JOBS_PER_TICK: usize = 10_000;
387
388/// Convert a serde_json::Value to a rquickjs Value.
389#[allow(clippy::option_if_let_else)]
390pub(crate) fn json_to_js<'js>(
391    ctx: &Ctx<'js>,
392    value: &serde_json::Value,
393) -> rquickjs::Result<Value<'js>> {
394    json_to_js_inner(ctx, value, 0)
395}
396
397fn json_to_js_inner<'js>(
398    ctx: &Ctx<'js>,
399    value: &serde_json::Value,
400    depth: usize,
401) -> rquickjs::Result<Value<'js>> {
402    if depth > MAX_JSON_DEPTH {
403        return Err(rquickjs::Error::new_into_js_message(
404            "json",
405            "parse",
406            "JSON object too deep",
407        ));
408    }
409
410    match value {
411        serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
412        serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
413        serde_json::Value::Number(n) => n.as_i64().and_then(|i| i32::try_from(i).ok()).map_or_else(
414            || {
415                n.as_f64().map_or_else(
416                    || Ok(Value::new_null(ctx.clone())),
417                    |f| Ok(Value::new_float(ctx.clone(), f)),
418                )
419            },
420            |i| Ok(Value::new_int(ctx.clone(), i)),
421        ),
422        // Gap D4: avoid cloning the String — pass &str directly to QuickJS.
423        serde_json::Value::String(s) => s.as_str().into_js(ctx),
424        serde_json::Value::Array(arr) => {
425            let js_arr = rquickjs::Array::new(ctx.clone())?;
426            for (i, v) in arr.iter().enumerate() {
427                let js_v = json_to_js_inner(ctx, v, depth + 1)?;
428                js_arr.set(i, js_v)?;
429            }
430            Ok(js_arr.into_value())
431        }
432        serde_json::Value::Object(obj) => {
433            let js_obj = Object::new(ctx.clone())?;
434            for (k, v) in obj {
435                let js_v = json_to_js_inner(ctx, v, depth + 1)?;
436                js_obj.set(k.as_str(), js_v)?;
437            }
438            Ok(js_obj.into_value())
439        }
440    }
441}
442
443/// Convert a rquickjs Value to a serde_json::Value.
444pub(crate) fn js_to_json(value: &Value<'_>) -> rquickjs::Result<serde_json::Value> {
445    js_to_json_inner(value, 0)
446}
447
448fn js_to_json_inner(value: &Value<'_>, depth: usize) -> rquickjs::Result<serde_json::Value> {
449    if depth > MAX_JSON_DEPTH {
450        return Err(rquickjs::Error::new_into_js_message(
451            "json",
452            "stringify",
453            "Object too deep or contains cycles",
454        ));
455    }
456
457    if value.is_null() || value.is_undefined() {
458        return Ok(serde_json::Value::Null);
459    }
460    if let Some(b) = value.as_bool() {
461        return Ok(serde_json::Value::Bool(b));
462    }
463    if let Some(i) = value.as_int() {
464        return Ok(serde_json::json!(i));
465    }
466    if let Some(f) = value.as_float() {
467        return Ok(serde_json::json!(f));
468    }
469    if let Some(s) = value.as_string() {
470        let s = s.to_string()?;
471        return Ok(serde_json::Value::String(s));
472    }
473    if let Some(arr) = value.as_array() {
474        let len = arr.len();
475        let mut result = Vec::with_capacity(len);
476        for i in 0..len {
477            let v: Value<'_> = arr.get(i)?;
478            result.push(js_to_json_inner(&v, depth + 1)?);
479        }
480        return Ok(serde_json::Value::Array(result));
481    }
482    if let Some(obj) = value.as_object() {
483        let mut result = serde_json::Map::new();
484        for item in obj.props::<String, Value<'_>>() {
485            let (k, v) = item?;
486            result.insert(k, js_to_json_inner(&v, depth + 1)?);
487        }
488        return Ok(serde_json::Value::Object(result));
489    }
490    // Fallback for functions, symbols, etc.
491    Ok(serde_json::Value::Null)
492}
493
494pub type HostcallQueue = Rc<RefCell<HostcallRequestQueue<HostcallRequest>>>;
495
496// ============================================================================
497// Deterministic PiJS Event Loop Scheduler (bd-8mm)
498// ============================================================================
499
500pub trait Clock: Send + Sync {
501    fn now_ms(&self) -> u64;
502}
503
504#[derive(Clone)]
505pub struct ClockHandle(Arc<dyn Clock>);
506
507impl ClockHandle {
508    pub fn new(clock: Arc<dyn Clock>) -> Self {
509        Self(clock)
510    }
511}
512
513impl Clock for ClockHandle {
514    fn now_ms(&self) -> u64 {
515        self.0.now_ms()
516    }
517}
518
519pub struct SystemClock;
520
521impl Clock for SystemClock {
522    fn now_ms(&self) -> u64 {
523        let now = SystemTime::now()
524            .duration_since(UNIX_EPOCH)
525            .unwrap_or_default();
526        u64::try_from(now.as_millis()).unwrap_or(u64::MAX)
527    }
528}
529
530#[derive(Debug)]
531pub struct ManualClock {
532    now_ms: AtomicU64,
533}
534
535impl ManualClock {
536    pub const fn new(start_ms: u64) -> Self {
537        Self {
538            now_ms: AtomicU64::new(start_ms),
539        }
540    }
541
542    pub fn set(&self, ms: u64) {
543        self.now_ms.store(ms, AtomicOrdering::SeqCst);
544    }
545
546    pub fn advance(&self, delta_ms: u64) {
547        self.now_ms.fetch_add(delta_ms, AtomicOrdering::SeqCst);
548    }
549}
550
551impl Clock for ManualClock {
552    fn now_ms(&self) -> u64 {
553        self.now_ms.load(AtomicOrdering::SeqCst)
554    }
555}
556
557#[derive(Debug, Clone, PartialEq, Eq)]
558pub enum MacrotaskKind {
559    TimerFired { timer_id: u64 },
560    HostcallComplete { call_id: String },
561    InboundEvent { event_id: String },
562}
563
564#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct Macrotask {
566    pub seq: u64,
567    pub trace_id: u64,
568    pub kind: MacrotaskKind,
569}
570
571#[derive(Debug, Clone, PartialEq, Eq)]
572struct MacrotaskEntry {
573    seq: u64,
574    trace_id: u64,
575    kind: MacrotaskKind,
576}
577
578impl Ord for MacrotaskEntry {
579    fn cmp(&self, other: &Self) -> Ordering {
580        self.seq.cmp(&other.seq)
581    }
582}
583
584impl PartialOrd for MacrotaskEntry {
585    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
586        Some(self.cmp(other))
587    }
588}
589
590#[derive(Debug, Clone, PartialEq, Eq)]
591struct TimerEntry {
592    deadline_ms: u64,
593    order_seq: u64,
594    timer_id: u64,
595    trace_id: u64,
596}
597
598impl Ord for TimerEntry {
599    fn cmp(&self, other: &Self) -> Ordering {
600        (self.deadline_ms, self.order_seq, self.timer_id).cmp(&(
601            other.deadline_ms,
602            other.order_seq,
603            other.timer_id,
604        ))
605    }
606}
607
608impl PartialOrd for TimerEntry {
609    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
610        Some(self.cmp(other))
611    }
612}
613
614#[derive(Debug, Clone, PartialEq, Eq)]
615struct PendingMacrotask {
616    trace_id: u64,
617    kind: MacrotaskKind,
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Eq)]
621pub struct TickResult {
622    pub ran_macrotask: bool,
623    pub microtasks_drained: usize,
624}
625
626pub struct PiEventLoop {
627    clock: ClockHandle,
628    seq: u64,
629    next_timer_id: u64,
630    pending: VecDeque<PendingMacrotask>,
631    macro_queue: BinaryHeap<std::cmp::Reverse<MacrotaskEntry>>,
632    timers: BinaryHeap<std::cmp::Reverse<TimerEntry>>,
633    cancelled_timers: HashSet<u64>,
634}
635
636impl PiEventLoop {
637    pub fn new(clock: ClockHandle) -> Self {
638        Self {
639            clock,
640            seq: 0,
641            next_timer_id: 1,
642            pending: VecDeque::new(),
643            macro_queue: BinaryHeap::new(),
644            timers: BinaryHeap::new(),
645            cancelled_timers: HashSet::new(),
646        }
647    }
648
649    pub fn enqueue_hostcall_completion(&mut self, call_id: impl Into<String>) {
650        let trace_id = self.next_seq();
651        self.pending.push_back(PendingMacrotask {
652            trace_id,
653            kind: MacrotaskKind::HostcallComplete {
654                call_id: call_id.into(),
655            },
656        });
657    }
658
659    pub fn enqueue_inbound_event(&mut self, event_id: impl Into<String>) {
660        let trace_id = self.next_seq();
661        self.pending.push_back(PendingMacrotask {
662            trace_id,
663            kind: MacrotaskKind::InboundEvent {
664                event_id: event_id.into(),
665            },
666        });
667    }
668
669    pub fn set_timeout(&mut self, delay_ms: u64) -> u64 {
670        let timer_id = self.next_timer_id;
671        self.next_timer_id = self.next_timer_id.saturating_add(1);
672        let order_seq = self.next_seq();
673        let deadline_ms = self.clock.now_ms().saturating_add(delay_ms);
674        self.timers.push(std::cmp::Reverse(TimerEntry {
675            deadline_ms,
676            order_seq,
677            timer_id,
678            trace_id: order_seq,
679        }));
680        timer_id
681    }
682
683    pub fn clear_timeout(&mut self, timer_id: u64) -> bool {
684        let pending = self.timers.iter().any(|entry| entry.0.timer_id == timer_id)
685            && !self.cancelled_timers.contains(&timer_id);
686
687        if pending {
688            self.cancelled_timers.insert(timer_id)
689        } else {
690            false
691        }
692    }
693
694    pub fn tick(
695        &mut self,
696        mut on_macrotask: impl FnMut(Macrotask),
697        mut drain_microtasks: impl FnMut() -> bool,
698    ) -> TickResult {
699        self.ingest_pending();
700        self.enqueue_due_timers();
701
702        let mut ran_macrotask = false;
703        if let Some(task) = self.pop_next_macrotask() {
704            ran_macrotask = true;
705            on_macrotask(task);
706        }
707
708        let mut microtasks_drained = 0;
709        if ran_macrotask {
710            while drain_microtasks() {
711                microtasks_drained += 1;
712            }
713        }
714
715        TickResult {
716            ran_macrotask,
717            microtasks_drained,
718        }
719    }
720
721    fn ingest_pending(&mut self) {
722        while let Some(pending) = self.pending.pop_front() {
723            self.enqueue_macrotask(pending.trace_id, pending.kind);
724        }
725    }
726
727    fn enqueue_due_timers(&mut self) {
728        let now = self.clock.now_ms();
729        while let Some(std::cmp::Reverse(entry)) = self.timers.peek().cloned() {
730            if entry.deadline_ms > now {
731                break;
732            }
733            let _ = self.timers.pop();
734            if self.cancelled_timers.remove(&entry.timer_id) {
735                continue;
736            }
737            self.enqueue_macrotask(
738                entry.trace_id,
739                MacrotaskKind::TimerFired {
740                    timer_id: entry.timer_id,
741                },
742            );
743        }
744    }
745
746    fn enqueue_macrotask(&mut self, trace_id: u64, kind: MacrotaskKind) {
747        let seq = self.next_seq();
748        self.macro_queue.push(std::cmp::Reverse(MacrotaskEntry {
749            seq,
750            trace_id,
751            kind,
752        }));
753    }
754
755    fn pop_next_macrotask(&mut self) -> Option<Macrotask> {
756        self.macro_queue.pop().map(|entry| {
757            let entry = entry.0;
758            Macrotask {
759                seq: entry.seq,
760                trace_id: entry.trace_id,
761                kind: entry.kind,
762            }
763        })
764    }
765
766    const fn next_seq(&mut self) -> u64 {
767        let current = self.seq;
768        self.seq = self.seq.saturating_add(1);
769        current
770    }
771}
772
773fn map_js_error(err: &rquickjs::Error) -> Error {
774    Error::extension(format!("QuickJS: {err:?}"))
775}
776
777fn format_quickjs_exception<'js>(ctx: &Ctx<'js>, caught: Value<'js>) -> String {
778    if let Ok(obj) = caught.clone().try_into_object() {
779        if let Some(exception) = Exception::from_object(obj) {
780            if let Some(message) = exception.message() {
781                if let Some(stack) = exception.stack() {
782                    return format!("{message}\n{stack}");
783                }
784                return message;
785            }
786            if let Some(stack) = exception.stack() {
787                return stack;
788            }
789        }
790    }
791
792    match Coerced::<String>::from_js(ctx, caught) {
793        Ok(value) => value.0,
794        Err(err) => format!("(failed to stringify QuickJS exception: {err})"),
795    }
796}
797
798// ============================================================================
799// Integrated PiJS Runtime with Promise Bridge (bd-2ke)
800// ============================================================================
801
802/// Classification of auto-repair patterns applied at extension load time.
803#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
804pub enum RepairPattern {
805    /// Pattern 1: `./dist/X.js` resolved to `./src/X.ts` because the build
806    /// output directory was missing.
807    DistToSrc,
808    /// Pattern 2: `readFileSync` on a missing bundled asset (HTML/CSS/JS)
809    /// within the extension directory returned an empty string fallback.
810    MissingAsset,
811    /// Pattern 3: a monorepo sibling import (`../../shared`) was replaced
812    /// with a generated stub module.
813    MonorepoEscape,
814    /// Pattern 4: a bare npm package specifier was satisfied by a proxy-based
815    /// universal stub.
816    MissingNpmDep,
817    /// Pattern 5: CJS/ESM default-export mismatch was corrected by trying
818    /// alternative lifecycle method names.
819    ExportShape,
820    /// Pattern 6 (bd-k5q5.9.3.2): Extension manifest field normalization
821    /// (deprecated keys, schema version migration).
822    ManifestNormalization,
823    /// Pattern 7 (bd-k5q5.9.3.3): AST-based codemod for known API renames
824    /// or signature migrations.
825    ApiMigration,
826}
827
828/// Risk tier for repair patterns (bd-k5q5.9.1.4).
829///
830/// `Safe` patterns only remap file paths within the extension root and cannot
831/// alter runtime behaviour.  `Aggressive` patterns may introduce stub modules,
832/// proxy objects, or change export shapes, potentially altering the extension's
833/// observable behaviour.
834#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
835pub enum RepairRisk {
836    /// Path-only remapping; no new code introduced.
837    Safe,
838    /// May inject stub modules or change export wiring.
839    Aggressive,
840}
841
842impl RepairPattern {
843    /// The risk tier of this pattern.
844    pub const fn risk(self) -> RepairRisk {
845        match self {
846            // Patterns 1, 2, 6: path remaps, empty strings, manifest JSON.
847            Self::DistToSrc | Self::MissingAsset | Self::ManifestNormalization => RepairRisk::Safe,
848            // Patterns 3-5 and 7: inject stubs, rewrite exports, or modify AST.
849            Self::MonorepoEscape | Self::MissingNpmDep | Self::ExportShape | Self::ApiMigration => {
850                RepairRisk::Aggressive
851            }
852        }
853    }
854
855    /// Whether this pattern is allowed under the given `RepairMode`.
856    pub const fn is_allowed_by(self, mode: RepairMode) -> bool {
857        match self.risk() {
858            RepairRisk::Safe => mode.should_apply(),
859            RepairRisk::Aggressive => mode.allows_aggressive(),
860        }
861    }
862}
863
864impl std::fmt::Display for RepairPattern {
865    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
866        match self {
867            Self::DistToSrc => write!(f, "dist_to_src"),
868            Self::MissingAsset => write!(f, "missing_asset"),
869            Self::MonorepoEscape => write!(f, "monorepo_escape"),
870            Self::MissingNpmDep => write!(f, "missing_npm_dep"),
871            Self::ExportShape => write!(f, "export_shape"),
872            Self::ManifestNormalization => write!(f, "manifest_normalization"),
873            Self::ApiMigration => write!(f, "api_migration"),
874        }
875    }
876}
877
878/// A structured record emitted whenever the runtime auto-repairs an extension
879/// load failure.
880#[derive(Debug, Clone)]
881pub struct ExtensionRepairEvent {
882    /// Which extension triggered the repair.
883    pub extension_id: String,
884    /// Which pattern was applied.
885    pub pattern: RepairPattern,
886    /// The original error message that triggered the repair attempt.
887    pub original_error: String,
888    /// Human-readable description of the corrective action taken.
889    pub repair_action: String,
890    /// Whether the repair successfully resolved the load failure.
891    pub success: bool,
892    /// Wall-clock timestamp (ms since UNIX epoch).
893    pub timestamp_ms: u64,
894}
895
896// ---------------------------------------------------------------------------
897// Deterministic rule registry (bd-k5q5.9.3.1)
898// ---------------------------------------------------------------------------
899
900/// A static repair rule with deterministic ordering and versioning.
901#[derive(Debug, Clone)]
902pub struct RepairRule {
903    /// Unique identifier for the rule (e.g. `"dist_to_src_v1"`).
904    pub id: &'static str,
905    /// Semantic version of this rule's logic.
906    pub version: &'static str,
907    /// Which `RepairPattern` this rule implements.
908    pub pattern: RepairPattern,
909    /// Human-readable description of what the rule does.
910    pub description: &'static str,
911}
912
913impl RepairRule {
914    /// Risk tier inherited from the pattern.
915    pub const fn risk(&self) -> RepairRisk {
916        self.pattern.risk()
917    }
918
919    /// Whether this rule can fire under the given mode.
920    pub const fn is_allowed_by(&self, mode: RepairMode) -> bool {
921        self.pattern.is_allowed_by(mode)
922    }
923}
924
925/// The canonical, deterministic rule registry.
926///
927/// Rules are evaluated **in array order** — the first applicable rule wins.
928/// New rules MUST be appended; existing order must never change to preserve
929/// determinism across versions.
930pub static REPAIR_RULES: &[RepairRule] = &[
931    RepairRule {
932        id: "dist_to_src_v1",
933        pattern: RepairPattern::DistToSrc,
934        version: "1.0.0",
935        description: "Remap ./dist/X.js to ./src/X.ts when build output is missing",
936    },
937    RepairRule {
938        id: "missing_asset_v1",
939        pattern: RepairPattern::MissingAsset,
940        version: "1.0.0",
941        description: "Return empty string for missing bundled asset reads",
942    },
943    RepairRule {
944        id: "monorepo_escape_v1",
945        pattern: RepairPattern::MonorepoEscape,
946        version: "1.0.0",
947        description: "Stub monorepo sibling imports (../../shared) with empty module",
948    },
949    RepairRule {
950        id: "missing_npm_dep_v1",
951        pattern: RepairPattern::MissingNpmDep,
952        version: "1.0.0",
953        description: "Provide proxy-based stub for unresolvable npm bare specifiers",
954    },
955    RepairRule {
956        id: "export_shape_v1",
957        pattern: RepairPattern::ExportShape,
958        version: "1.0.0",
959        description: "Try alternative lifecycle exports (CJS default, named activate)",
960    },
961    // ── Manifest normalization rules (bd-k5q5.9.3.2) ──
962    RepairRule {
963        id: "manifest_schema_v1",
964        pattern: RepairPattern::ManifestNormalization,
965        version: "1.0.0",
966        description: "Migrate deprecated manifest fields to current schema",
967    },
968    // ── AST codemod rules (bd-k5q5.9.3.3) ──
969    RepairRule {
970        id: "api_migration_v1",
971        pattern: RepairPattern::ApiMigration,
972        version: "1.0.0",
973        description: "Rewrite known deprecated API calls to current equivalents",
974    },
975];
976
977/// Find all rules applicable under the given mode, in registry order.
978pub fn applicable_rules(mode: RepairMode) -> Vec<&'static RepairRule> {
979    REPAIR_RULES
980        .iter()
981        .filter(|rule| rule.is_allowed_by(mode))
982        .collect()
983}
984
985/// Look up a rule by its ID.
986pub fn rule_by_id(id: &str) -> Option<&'static RepairRule> {
987    REPAIR_RULES.iter().find(|r| r.id == id)
988}
989
990/// The registry version: bumped whenever rules are added or modified.
991pub const REPAIR_REGISTRY_VERSION: &str = "1.1.0";
992
993// ---------------------------------------------------------------------------
994// Model patch primitive whitelist (bd-k5q5.9.4.1)
995// ---------------------------------------------------------------------------
996
997/// Primitive patch operations that model-generated repair proposals may use.
998///
999/// Each variant represents a constrained, validatable operation. The union of
1000/// all variants defines the complete vocabulary available to the model repair
1001/// adapter — anything outside this enum is rejected at the schema level.
1002#[derive(Debug, Clone, PartialEq, Eq)]
1003pub enum PatchOp {
1004    /// Replace a module import path with a different path.
1005    /// Both paths must resolve within the extension root.
1006    ReplaceModulePath { from: String, to: String },
1007    /// Add a named export to a module's source text.
1008    AddExport {
1009        module_path: String,
1010        export_name: String,
1011        export_value: String,
1012    },
1013    /// Remove an import statement by specifier.
1014    RemoveImport {
1015        module_path: String,
1016        specifier: String,
1017    },
1018    /// Inject a stub module at the given virtual path.
1019    InjectStub {
1020        virtual_path: String,
1021        source: String,
1022    },
1023    /// Rewrite a `require()` call to use a different specifier.
1024    RewriteRequire {
1025        module_path: String,
1026        from_specifier: String,
1027        to_specifier: String,
1028    },
1029}
1030
1031impl PatchOp {
1032    /// The risk tier of this operation.
1033    pub const fn risk(&self) -> RepairRisk {
1034        match self {
1035            // Path remapping and require rewriting are safe (no new code).
1036            Self::ReplaceModulePath { .. } | Self::RewriteRequire { .. } => RepairRisk::Safe,
1037            // Adding exports, removing imports, or injecting stubs change code.
1038            Self::AddExport { .. } | Self::RemoveImport { .. } | Self::InjectStub { .. } => {
1039                RepairRisk::Aggressive
1040            }
1041        }
1042    }
1043
1044    /// Short tag for logging and telemetry.
1045    pub const fn tag(&self) -> &'static str {
1046        match self {
1047            Self::ReplaceModulePath { .. } => "replace_module_path",
1048            Self::AddExport { .. } => "add_export",
1049            Self::RemoveImport { .. } => "remove_import",
1050            Self::InjectStub { .. } => "inject_stub",
1051            Self::RewriteRequire { .. } => "rewrite_require",
1052        }
1053    }
1054}
1055
1056/// A model-generated repair proposal.
1057///
1058/// Contains one or more `PatchOp`s plus metadata for audit. Proposals are
1059/// validated against the current `RepairMode` and monotonicity checker before
1060/// any operations are applied.
1061#[derive(Debug, Clone)]
1062pub struct PatchProposal {
1063    /// Which rule triggered this proposal.
1064    pub rule_id: String,
1065    /// Ordered list of operations to apply.
1066    pub ops: Vec<PatchOp>,
1067    /// Model-provided rationale (for audit log).
1068    pub rationale: String,
1069    /// Confidence score (0.0–1.0) from the model, if available.
1070    pub confidence: Option<f64>,
1071}
1072
1073impl PatchProposal {
1074    /// The highest risk across all ops in the proposal.
1075    pub fn max_risk(&self) -> RepairRisk {
1076        if self
1077            .ops
1078            .iter()
1079            .any(|op| op.risk() == RepairRisk::Aggressive)
1080        {
1081            RepairRisk::Aggressive
1082        } else {
1083            RepairRisk::Safe
1084        }
1085    }
1086
1087    /// Whether this proposal is allowed under the given mode.
1088    pub fn is_allowed_by(&self, mode: RepairMode) -> bool {
1089        match self.max_risk() {
1090            RepairRisk::Safe => mode.should_apply(),
1091            RepairRisk::Aggressive => mode.allows_aggressive(),
1092        }
1093    }
1094
1095    /// Number of patch operations in this proposal.
1096    pub fn op_count(&self) -> usize {
1097        self.ops.len()
1098    }
1099}
1100
1101// ---------------------------------------------------------------------------
1102// Minimal-diff candidate selector and conflict resolver (bd-k5q5.9.3.4)
1103// ---------------------------------------------------------------------------
1104
1105/// Outcome of conflict detection between two proposals.
1106#[derive(Debug, Clone, PartialEq, Eq)]
1107pub enum ConflictKind {
1108    /// No conflict: the proposals touch different files/paths.
1109    None,
1110    /// Both proposals modify the same module path.
1111    SameModulePath(String),
1112    /// Both proposals inject stubs at the same virtual path.
1113    SameVirtualPath(String),
1114}
1115
1116impl ConflictKind {
1117    /// True if there is no conflict.
1118    pub const fn is_clear(&self) -> bool {
1119        matches!(self, Self::None)
1120    }
1121}
1122
1123/// Detect conflicts between two `PatchProposal`s.
1124///
1125/// Two proposals conflict if they modify the same module path or inject
1126/// stubs at the same virtual path. This is a conservative check — any
1127/// overlap is treated as a conflict.
1128pub fn detect_conflict(a: &PatchProposal, b: &PatchProposal) -> ConflictKind {
1129    for op_a in &a.ops {
1130        for op_b in &b.ops {
1131            if let Some(conflict) = ops_conflict(op_a, op_b) {
1132                return conflict;
1133            }
1134        }
1135    }
1136    ConflictKind::None
1137}
1138
1139/// Check if two individual ops conflict.
1140fn ops_conflict(a: &PatchOp, b: &PatchOp) -> Option<ConflictKind> {
1141    match (a, b) {
1142        (
1143            PatchOp::ReplaceModulePath { from: fa, .. },
1144            PatchOp::ReplaceModulePath { from: fb, .. },
1145        ) if fa == fb => Some(ConflictKind::SameModulePath(fa.clone())),
1146
1147        (
1148            PatchOp::AddExport {
1149                module_path: pa, ..
1150            },
1151            PatchOp::AddExport {
1152                module_path: pb, ..
1153            },
1154        ) if pa == pb => Some(ConflictKind::SameModulePath(pa.clone())),
1155
1156        (
1157            PatchOp::RemoveImport {
1158                module_path: pa, ..
1159            },
1160            PatchOp::RemoveImport {
1161                module_path: pb, ..
1162            },
1163        ) if pa == pb => Some(ConflictKind::SameModulePath(pa.clone())),
1164
1165        (
1166            PatchOp::InjectStub {
1167                virtual_path: va, ..
1168            },
1169            PatchOp::InjectStub {
1170                virtual_path: vb, ..
1171            },
1172        ) if va == vb => Some(ConflictKind::SameVirtualPath(va.clone())),
1173
1174        (
1175            PatchOp::RewriteRequire {
1176                module_path: pa,
1177                from_specifier: sa,
1178                ..
1179            },
1180            PatchOp::RewriteRequire {
1181                module_path: pb,
1182                from_specifier: sb,
1183                ..
1184            },
1185        ) if pa == pb && sa == sb => Some(ConflictKind::SameModulePath(pa.clone())),
1186
1187        _ => Option::None,
1188    }
1189}
1190
1191/// Select the best candidate from a set of proposals.
1192///
1193/// Candidates are ranked by:
1194/// 1. Lowest risk (Safe before Aggressive)
1195/// 2. Fewest operations (minimal diff)
1196/// 3. Highest confidence (if provided)
1197/// 4. Earliest rule ID (deterministic tiebreak)
1198///
1199/// Only candidates allowed by the given `RepairMode` are considered.
1200/// Returns `None` if no candidate is allowed.
1201pub fn select_best_candidate(
1202    candidates: &[PatchProposal],
1203    mode: RepairMode,
1204) -> Option<&PatchProposal> {
1205    candidates
1206        .iter()
1207        .filter(|p| p.is_allowed_by(mode))
1208        .min_by(|a, b| compare_proposals(a, b))
1209}
1210
1211/// Compare two proposals for selection ordering.
1212fn compare_proposals(a: &PatchProposal, b: &PatchProposal) -> std::cmp::Ordering {
1213    // 1. Lower risk wins.
1214    let risk_ord = risk_rank(a.max_risk()).cmp(&risk_rank(b.max_risk()));
1215    if risk_ord != std::cmp::Ordering::Equal {
1216        return risk_ord;
1217    }
1218
1219    // 2. Fewer ops wins.
1220    let ops_ord = a.op_count().cmp(&b.op_count());
1221    if ops_ord != std::cmp::Ordering::Equal {
1222        return ops_ord;
1223    }
1224
1225    // 3. Higher confidence wins (reverse order).
1226    let conf_a = a.confidence.unwrap_or(0.0);
1227    let conf_b = b.confidence.unwrap_or(0.0);
1228    // Reverse: higher confidence = better = Less in ordering.
1229    let conf_ord = conf_b
1230        .partial_cmp(&conf_a)
1231        .unwrap_or(std::cmp::Ordering::Equal);
1232    if conf_ord != std::cmp::Ordering::Equal {
1233        return conf_ord;
1234    }
1235
1236    // 4. Lexicographic rule_id tiebreak.
1237    a.rule_id.cmp(&b.rule_id)
1238}
1239
1240/// Map `RepairRisk` to a numeric rank for ordering.
1241const fn risk_rank(risk: RepairRisk) -> u8 {
1242    match risk {
1243        RepairRisk::Safe => 0,
1244        RepairRisk::Aggressive => 1,
1245    }
1246}
1247
1248/// Resolve conflicts among a set of proposals.
1249///
1250/// When two proposals conflict, the lower-ranked one (by
1251/// `compare_proposals`) is dropped. Returns a conflict-free subset.
1252pub fn resolve_conflicts(proposals: &[PatchProposal]) -> Vec<&PatchProposal> {
1253    if proposals.is_empty() {
1254        return vec![];
1255    }
1256
1257    // Sort by selection order.
1258    let mut indexed: Vec<(usize, &PatchProposal)> = proposals.iter().enumerate().collect();
1259    indexed.sort_by(|(_, a), (_, b)| compare_proposals(a, b));
1260
1261    let mut accepted: Vec<&PatchProposal> = Vec::new();
1262    for (_, candidate) in indexed {
1263        let conflicts_with_accepted = accepted
1264            .iter()
1265            .any(|acc| !detect_conflict(acc, candidate).is_clear());
1266        if !conflicts_with_accepted {
1267            accepted.push(candidate);
1268        }
1269    }
1270
1271    accepted
1272}
1273
1274// ---------------------------------------------------------------------------
1275// Bounded-context model proposer adapter (bd-k5q5.9.4.2)
1276// ---------------------------------------------------------------------------
1277
1278/// Curated context provided to the model for repair proposal generation.
1279///
1280/// This struct is the *only* information the model sees. It deliberately
1281/// excludes secrets, full file contents, and anything outside the extension's
1282/// scope. The model can only produce proposals using the allowed primitives.
1283#[derive(Debug, Clone)]
1284pub struct RepairContext {
1285    /// Extension identity.
1286    pub extension_id: String,
1287    /// The gating verdict (includes confidence and reason codes).
1288    pub gating: GatingVerdict,
1289    /// Normalized intent graph.
1290    pub intent: IntentGraph,
1291    /// Tolerant parse result.
1292    pub parse: TolerantParseResult,
1293    /// Current repair mode.
1294    pub mode: RepairMode,
1295    /// Diagnostic messages from the failed load attempt.
1296    pub diagnostics: Vec<String>,
1297    /// Allowed `PatchOp` tags for this mode.
1298    pub allowed_op_tags: Vec<&'static str>,
1299}
1300
1301impl RepairContext {
1302    /// Build a repair context from constituent parts.
1303    pub fn new(
1304        extension_id: String,
1305        gating: GatingVerdict,
1306        intent: IntentGraph,
1307        parse: TolerantParseResult,
1308        mode: RepairMode,
1309        diagnostics: Vec<String>,
1310    ) -> Self {
1311        let allowed_op_tags = allowed_op_tags_for_mode(mode);
1312        Self {
1313            extension_id,
1314            gating,
1315            intent,
1316            parse,
1317            mode,
1318            diagnostics,
1319            allowed_op_tags,
1320        }
1321    }
1322}
1323
1324/// Return the `PatchOp` tags allowed under the given repair mode.
1325pub fn allowed_op_tags_for_mode(mode: RepairMode) -> Vec<&'static str> {
1326    let mut tags = Vec::new();
1327    if mode.should_apply() {
1328        // Safe ops always allowed when repairs are active.
1329        tags.extend_from_slice(&["replace_module_path", "rewrite_require"]);
1330    }
1331    if mode.allows_aggressive() {
1332        // Aggressive ops only in AutoStrict.
1333        tags.extend_from_slice(&["add_export", "remove_import", "inject_stub"]);
1334    }
1335    tags
1336}
1337
1338// ---------------------------------------------------------------------------
1339// Proposal validator and constrained applicator (bd-k5q5.9.4.3)
1340// ---------------------------------------------------------------------------
1341
1342/// Validation error for a model-generated proposal.
1343#[derive(Debug, Clone, PartialEq, Eq)]
1344pub enum ProposalValidationError {
1345    /// Proposal contains zero operations.
1346    EmptyProposal,
1347    /// An operation uses a tag not allowed by the current mode.
1348    DisallowedOp { tag: String },
1349    /// Risk level exceeds what the mode permits.
1350    RiskExceedsMode { risk: RepairRisk, mode: RepairMode },
1351    /// The `rule_id` does not match any known rule.
1352    UnknownRule { rule_id: String },
1353    /// Proposal references a path that escapes the extension root.
1354    MonotonicityViolation { path: String },
1355}
1356
1357impl std::fmt::Display for ProposalValidationError {
1358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1359        match self {
1360            Self::EmptyProposal => write!(f, "proposal has no operations"),
1361            Self::DisallowedOp { tag } => write!(f, "op '{tag}' not allowed in current mode"),
1362            Self::RiskExceedsMode { risk, mode } => {
1363                write!(f, "{risk:?} risk not allowed in {mode:?} mode")
1364            }
1365            Self::UnknownRule { rule_id } => write!(f, "unknown rule: {rule_id}"),
1366            Self::MonotonicityViolation { path } => {
1367                write!(f, "path escapes extension root: {path}")
1368            }
1369        }
1370    }
1371}
1372
1373/// Validate a `PatchProposal` against policy constraints.
1374///
1375/// Checks:
1376/// 1. Proposal is non-empty.
1377/// 2. All ops are in the allowed tag set for the mode.
1378/// 3. Overall risk does not exceed mode permissions.
1379/// 4. The rule_id references a known rule (if non-empty).
1380/// 5. Module paths stay within the extension root (monotonicity).
1381pub fn validate_proposal(
1382    proposal: &PatchProposal,
1383    mode: RepairMode,
1384    extension_root: Option<&Path>,
1385) -> Vec<ProposalValidationError> {
1386    let mut errors = Vec::new();
1387
1388    // 1. Non-empty.
1389    if proposal.ops.is_empty() {
1390        errors.push(ProposalValidationError::EmptyProposal);
1391        return errors;
1392    }
1393
1394    // 2. Allowed ops.
1395    let allowed = allowed_op_tags_for_mode(mode);
1396    for op in &proposal.ops {
1397        if !allowed.contains(&op.tag()) {
1398            errors.push(ProposalValidationError::DisallowedOp {
1399                tag: op.tag().to_string(),
1400            });
1401        }
1402    }
1403
1404    // 3. Risk check.
1405    if !proposal.is_allowed_by(mode) {
1406        errors.push(ProposalValidationError::RiskExceedsMode {
1407            risk: proposal.max_risk(),
1408            mode,
1409        });
1410    }
1411
1412    // 4. Known rule.
1413    if !proposal.rule_id.is_empty() && rule_by_id(&proposal.rule_id).is_none() {
1414        errors.push(ProposalValidationError::UnknownRule {
1415            rule_id: proposal.rule_id.clone(),
1416        });
1417    }
1418
1419    // 5. Monotonicity for path-bearing ops.
1420    if let Some(root) = extension_root {
1421        for op in &proposal.ops {
1422            let path_str = op_target_path(op);
1423            let target = Path::new(&path_str);
1424            if target.is_absolute() {
1425                let verdict = verify_repair_monotonicity(root, root, target);
1426                if !verdict.is_safe() {
1427                    errors.push(ProposalValidationError::MonotonicityViolation { path: path_str });
1428                }
1429            }
1430        }
1431    }
1432
1433    errors
1434}
1435
1436/// Extract the target path from a `PatchOp`.
1437fn op_target_path(op: &PatchOp) -> String {
1438    match op {
1439        PatchOp::ReplaceModulePath { to, .. } => to.clone(),
1440        PatchOp::AddExport { module_path, .. }
1441        | PatchOp::RemoveImport { module_path, .. }
1442        | PatchOp::RewriteRequire { module_path, .. } => module_path.clone(),
1443        PatchOp::InjectStub { virtual_path, .. } => virtual_path.clone(),
1444    }
1445}
1446
1447/// Result of applying a validated proposal.
1448#[derive(Debug, Clone)]
1449pub struct ApplicationResult {
1450    /// Whether the application succeeded.
1451    pub success: bool,
1452    /// Number of operations applied.
1453    pub ops_applied: usize,
1454    /// Human-readable summary.
1455    pub summary: String,
1456}
1457
1458/// Apply a validated proposal (dry-run: only validates and reports).
1459///
1460/// In the current implementation, actual file modifications are deferred
1461/// to the module loader. This function validates and produces an audit
1462/// record of what would be applied.
1463pub fn apply_proposal(
1464    proposal: &PatchProposal,
1465    mode: RepairMode,
1466    extension_root: Option<&Path>,
1467) -> std::result::Result<ApplicationResult, Vec<ProposalValidationError>> {
1468    let errors = validate_proposal(proposal, mode, extension_root);
1469    if !errors.is_empty() {
1470        return Err(errors);
1471    }
1472
1473    Ok(ApplicationResult {
1474        success: true,
1475        ops_applied: proposal.ops.len(),
1476        summary: format!(
1477            "Applied {} op(s) from rule '{}'",
1478            proposal.ops.len(),
1479            proposal.rule_id
1480        ),
1481    })
1482}
1483
1484// ---------------------------------------------------------------------------
1485// Fail-closed human approval workflow (bd-k5q5.9.4.4)
1486// ---------------------------------------------------------------------------
1487
1488/// Whether a proposal requires human approval before application.
1489#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1490pub enum ApprovalRequirement {
1491    /// No approval needed — proposal can be applied automatically.
1492    AutoApproved,
1493    /// Human review required before applying.
1494    RequiresApproval,
1495}
1496
1497impl ApprovalRequirement {
1498    /// True if human review is required.
1499    pub const fn needs_approval(&self) -> bool {
1500        matches!(self, Self::RequiresApproval)
1501    }
1502}
1503
1504impl std::fmt::Display for ApprovalRequirement {
1505    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1506        match self {
1507            Self::AutoApproved => write!(f, "auto_approved"),
1508            Self::RequiresApproval => write!(f, "requires_approval"),
1509        }
1510    }
1511}
1512
1513/// An approval request presented to the human reviewer.
1514#[derive(Debug, Clone)]
1515pub struct ApprovalRequest {
1516    /// Extension being repaired.
1517    pub extension_id: String,
1518    /// The proposal awaiting approval.
1519    pub proposal: PatchProposal,
1520    /// Overall risk level.
1521    pub risk: RepairRisk,
1522    /// Confidence score from the scoring model.
1523    pub confidence_score: f64,
1524    /// Human-readable rationale from the proposal.
1525    pub rationale: String,
1526    /// Summary of what each operation does.
1527    pub op_summaries: Vec<String>,
1528}
1529
1530/// Human response to an approval request.
1531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1532pub enum ApprovalResponse {
1533    /// Approved: apply the proposal.
1534    Approved,
1535    /// Rejected: discard the proposal.
1536    Rejected,
1537}
1538
1539/// Determine whether a proposal requires human approval.
1540///
1541/// The decision is fail-closed: any high-risk indicator triggers the
1542/// approval requirement. A proposal requires approval if:
1543/// - It contains any Aggressive-risk operations, OR
1544/// - The confidence score is below the repairable threshold (0.5), OR
1545/// - The mode is `AutoStrict` and the proposal touches 3+ operations.
1546pub fn check_approval_requirement(
1547    proposal: &PatchProposal,
1548    confidence_score: f64,
1549) -> ApprovalRequirement {
1550    if proposal.max_risk() == RepairRisk::Aggressive {
1551        return ApprovalRequirement::RequiresApproval;
1552    }
1553    if confidence_score < 0.5 {
1554        return ApprovalRequirement::RequiresApproval;
1555    }
1556    if proposal.ops.len() >= 3 {
1557        return ApprovalRequirement::RequiresApproval;
1558    }
1559    ApprovalRequirement::AutoApproved
1560}
1561
1562/// Build an approval request for human review.
1563pub fn build_approval_request(
1564    extension_id: &str,
1565    proposal: &PatchProposal,
1566    confidence_score: f64,
1567) -> ApprovalRequest {
1568    let op_summaries = proposal
1569        .ops
1570        .iter()
1571        .map(|op| format!("[{}] {}", op.tag(), op_target_path(op)))
1572        .collect();
1573
1574    ApprovalRequest {
1575        extension_id: extension_id.to_string(),
1576        proposal: proposal.clone(),
1577        risk: proposal.max_risk(),
1578        confidence_score,
1579        rationale: proposal.rationale.clone(),
1580        op_summaries,
1581    }
1582}
1583
1584// ---------------------------------------------------------------------------
1585// Structural validation gate (bd-k5q5.9.5.1)
1586// ---------------------------------------------------------------------------
1587
1588/// Outcome of a structural validation check on a repaired artifact.
1589#[derive(Debug, Clone, PartialEq, Eq)]
1590pub enum StructuralVerdict {
1591    /// The artifact passed all structural checks.
1592    Valid,
1593    /// The file could not be read.
1594    Unreadable { path: PathBuf, reason: String },
1595    /// The file has an unsupported extension.
1596    UnsupportedExtension { path: PathBuf, extension: String },
1597    /// The file failed to parse as valid JS/TS/JSON.
1598    ParseError { path: PathBuf, message: String },
1599}
1600
1601impl StructuralVerdict {
1602    /// Returns `true` when the artifact passed all checks.
1603    pub const fn is_valid(&self) -> bool {
1604        matches!(self, Self::Valid)
1605    }
1606}
1607
1608impl std::fmt::Display for StructuralVerdict {
1609    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1610        match self {
1611            Self::Valid => write!(f, "valid"),
1612            Self::Unreadable { path, reason } => {
1613                write!(f, "unreadable: {} ({})", path.display(), reason)
1614            }
1615            Self::UnsupportedExtension { path, extension } => {
1616                write!(
1617                    f,
1618                    "unsupported extension: {} (.{})",
1619                    path.display(),
1620                    extension
1621                )
1622            }
1623            Self::ParseError { path, message } => {
1624                write!(f, "parse error: {} ({})", path.display(), message)
1625            }
1626        }
1627    }
1628}
1629
1630/// Validate that a repaired artifact is structurally sound.
1631///
1632/// Performs three checks in order:
1633/// 1. **Readable** — the file can be read as UTF-8 text.
1634/// 2. **Supported extension** — `.ts`, `.tsx`, `.js`, `.mjs`, or `.json`.
1635/// 3. **Parseable** — SWC can parse `.ts`/`.tsx` files; JSON files are valid
1636///    JSON; `.js`/`.mjs` files are read successfully (syntax errors surface at
1637///    load time via QuickJS, but we verify readability here).
1638pub fn validate_repaired_artifact(path: &Path) -> StructuralVerdict {
1639    // 1. Readable check.
1640    let source = match fs::read_to_string(path) {
1641        Ok(s) => s,
1642        Err(err) => {
1643            return StructuralVerdict::Unreadable {
1644                path: path.to_path_buf(),
1645                reason: err.to_string(),
1646            };
1647        }
1648    };
1649
1650    // 2. Extension check.
1651    let ext = path
1652        .extension()
1653        .and_then(|e| e.to_str())
1654        .unwrap_or("")
1655        .to_ascii_lowercase();
1656
1657    match ext.as_str() {
1658        "ts" | "tsx" => validate_typescript_parse(path, &source, &ext),
1659        "js" | "mjs" => {
1660            // JS files are loaded by QuickJS which reports its own syntax
1661            // errors. We only verify readability (done above).
1662            StructuralVerdict::Valid
1663        }
1664        "json" => validate_json_parse(path, &source),
1665        _ => StructuralVerdict::UnsupportedExtension {
1666            path: path.to_path_buf(),
1667            extension: ext,
1668        },
1669    }
1670}
1671
1672/// Try to parse a TypeScript/TSX source with SWC.
1673fn validate_typescript_parse(path: &Path, source: &str, ext: &str) -> StructuralVerdict {
1674    use swc_common::{FileName, GLOBALS, Globals};
1675    use swc_ecma_parser::{Parser as SwcParser, StringInput, Syntax, TsSyntax};
1676
1677    let globals = Globals::new();
1678    GLOBALS.set(&globals, || {
1679        let cm: swc_common::sync::Lrc<swc_common::SourceMap> = swc_common::sync::Lrc::default();
1680        let fm = cm.new_source_file(
1681            FileName::Custom(path.display().to_string()).into(),
1682            source.to_string(),
1683        );
1684        let syntax = Syntax::Typescript(TsSyntax {
1685            tsx: ext == "tsx",
1686            decorators: true,
1687            ..Default::default()
1688        });
1689        let mut parser = SwcParser::new(syntax, StringInput::from(&*fm), None);
1690        match parser.parse_module() {
1691            Ok(_) => StructuralVerdict::Valid,
1692            Err(err) => StructuralVerdict::ParseError {
1693                path: path.to_path_buf(),
1694                message: format!("{err:?}"),
1695            },
1696        }
1697    })
1698}
1699
1700/// Validate that JSON source is well-formed.
1701fn validate_json_parse(path: &Path, source: &str) -> StructuralVerdict {
1702    match serde_json::from_str::<serde_json::Value>(source) {
1703        Ok(_) => StructuralVerdict::Valid,
1704        Err(err) => StructuralVerdict::ParseError {
1705            path: path.to_path_buf(),
1706            message: err.to_string(),
1707        },
1708    }
1709}
1710
1711// ---------------------------------------------------------------------------
1712// Tolerant AST recovery and ambiguity detection (bd-k5q5.9.2.2)
1713// ---------------------------------------------------------------------------
1714
1715/// A construct in the source that reduces repair confidence.
1716#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1717pub enum AmbiguitySignal {
1718    /// Source contains `eval(...)` — arbitrary code execution.
1719    DynamicEval,
1720    /// Source contains `new Function(...)` — dynamic function construction.
1721    DynamicFunction,
1722    /// Source contains `import(...)` — dynamic import expression.
1723    DynamicImport,
1724    /// Source contains `export * from` — star re-export hides export shape.
1725    StarReExport,
1726    /// Source contains `require(` with a non-literal argument.
1727    DynamicRequire,
1728    /// Source contains `new Proxy(` — metaprogramming.
1729    ProxyUsage,
1730    /// Source contains `with (` — deprecated scope-altering statement.
1731    WithStatement,
1732    /// SWC parser produced recoverable errors.
1733    RecoverableParseErrors { count: usize },
1734}
1735
1736impl AmbiguitySignal {
1737    /// Severity weight (0.0–1.0) for confidence scoring.
1738    pub fn weight(&self) -> f64 {
1739        match self {
1740            Self::DynamicEval | Self::DynamicFunction => 0.9,
1741            Self::ProxyUsage | Self::WithStatement => 0.7,
1742            Self::DynamicImport | Self::DynamicRequire => 0.5,
1743            Self::StarReExport => 0.3,
1744            Self::RecoverableParseErrors { count } => {
1745                // More errors → more ambiguous, capped at 1.0.
1746                (f64::from(u32::try_from(*count).unwrap_or(u32::MAX)) * 0.2).min(1.0)
1747            }
1748        }
1749    }
1750
1751    /// Short tag for logging.
1752    pub const fn tag(&self) -> &'static str {
1753        match self {
1754            Self::DynamicEval => "dynamic_eval",
1755            Self::DynamicFunction => "dynamic_function",
1756            Self::DynamicImport => "dynamic_import",
1757            Self::StarReExport => "star_reexport",
1758            Self::DynamicRequire => "dynamic_require",
1759            Self::ProxyUsage => "proxy_usage",
1760            Self::WithStatement => "with_statement",
1761            Self::RecoverableParseErrors { .. } => "recoverable_parse_errors",
1762        }
1763    }
1764}
1765
1766impl std::fmt::Display for AmbiguitySignal {
1767    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1768        match self {
1769            Self::RecoverableParseErrors { count } => {
1770                write!(f, "{}({})", self.tag(), count)
1771            }
1772            _ => write!(f, "{}", self.tag()),
1773        }
1774    }
1775}
1776
1777/// Result of tolerant parsing: partial analysis even when source has errors.
1778#[derive(Debug, Clone)]
1779pub struct TolerantParseResult {
1780    /// Whether the source parsed without fatal errors.
1781    pub parsed_ok: bool,
1782    /// Number of top-level statements recovered (0 if parse failed fatally).
1783    pub statement_count: usize,
1784    /// Number of import/export declarations found.
1785    pub import_export_count: usize,
1786    /// Detected ambiguity signals that reduce repair confidence.
1787    pub ambiguities: Vec<AmbiguitySignal>,
1788}
1789
1790impl TolerantParseResult {
1791    /// Overall ambiguity score (0.0 = fully legible, 1.0 = fully opaque).
1792    pub fn ambiguity_score(&self) -> f64 {
1793        if self.ambiguities.is_empty() {
1794            return 0.0;
1795        }
1796        // Take the max weight — one high-severity signal dominates.
1797        self.ambiguities
1798            .iter()
1799            .map(AmbiguitySignal::weight)
1800            .fold(0.0_f64, f64::max)
1801    }
1802
1803    /// True if the source is sufficiently legible for automated repair.
1804    pub fn is_legible(&self) -> bool {
1805        self.parsed_ok && self.ambiguity_score() < 0.8
1806    }
1807}
1808
1809/// Perform tolerant parsing and ambiguity detection on source text.
1810///
1811/// Attempts SWC parse for `.ts`/`.tsx` files to count statements
1812/// and imports. For all supported extensions, scans source text
1813/// for ambiguity patterns. Returns partial results even on parse
1814/// failure.
1815pub fn tolerant_parse(source: &str, filename: &str) -> TolerantParseResult {
1816    let ext = Path::new(filename)
1817        .extension()
1818        .and_then(|e| e.to_str())
1819        .unwrap_or("")
1820        .to_ascii_lowercase();
1821
1822    let (parsed_ok, statement_count, import_export_count, parse_errors) = match ext.as_str() {
1823        "ts" | "tsx" | "js" | "mjs" => try_swc_parse(source, filename, &ext),
1824        _ => (false, 0, 0, 0),
1825    };
1826
1827    let mut ambiguities = detect_ambiguity_patterns(source);
1828    if parse_errors > 0 {
1829        ambiguities.push(AmbiguitySignal::RecoverableParseErrors {
1830            count: parse_errors,
1831        });
1832    }
1833
1834    // Deduplicate.
1835    let mut seen = std::collections::HashSet::new();
1836    ambiguities.retain(|s| seen.insert(s.clone()));
1837
1838    TolerantParseResult {
1839        parsed_ok,
1840        statement_count,
1841        import_export_count,
1842        ambiguities,
1843    }
1844}
1845
1846/// Attempt SWC parse and return (ok, stmts, imports, error_count).
1847fn try_swc_parse(source: &str, filename: &str, ext: &str) -> (bool, usize, usize, usize) {
1848    use swc_common::{FileName, GLOBALS, Globals};
1849    use swc_ecma_parser::{Parser as SwcParser, StringInput, Syntax, TsSyntax};
1850
1851    let globals = Globals::new();
1852    GLOBALS.set(&globals, || {
1853        let cm: swc_common::sync::Lrc<swc_common::SourceMap> = swc_common::sync::Lrc::default();
1854        let fm = cm.new_source_file(
1855            FileName::Custom(filename.to_string()).into(),
1856            source.to_string(),
1857        );
1858        let is_ts = ext == "ts" || ext == "tsx";
1859        let syntax = if is_ts {
1860            Syntax::Typescript(TsSyntax {
1861                tsx: ext == "tsx",
1862                decorators: true,
1863                ..Default::default()
1864            })
1865        } else {
1866            Syntax::Es(swc_ecma_parser::EsSyntax {
1867                jsx: true,
1868                ..Default::default()
1869            })
1870        };
1871        let mut parser = SwcParser::new(syntax, StringInput::from(&*fm), None);
1872        if let Ok(module) = parser.parse_module() {
1873            let errors = parser.take_errors();
1874            let stmts = module.body.len();
1875            let imports = module
1876                .body
1877                .iter()
1878                .filter(|item| {
1879                    matches!(
1880                        item,
1881                        swc_ecma_ast::ModuleItem::ModuleDecl(
1882                            swc_ecma_ast::ModuleDecl::Import(_)
1883                                | swc_ecma_ast::ModuleDecl::ExportAll(_)
1884                                | swc_ecma_ast::ModuleDecl::ExportNamed(_)
1885                                | swc_ecma_ast::ModuleDecl::ExportDefaultDecl(_)
1886                                | swc_ecma_ast::ModuleDecl::ExportDefaultExpr(_)
1887                                | swc_ecma_ast::ModuleDecl::ExportDecl(_)
1888                        )
1889                    )
1890                })
1891                .count();
1892            (true, stmts, imports, errors.len())
1893        } else {
1894            let errors = parser.take_errors();
1895            // Fatal parse error — report 0 statements but count errors.
1896            (false, 0, 0, errors.len() + 1)
1897        }
1898    })
1899}
1900
1901/// Detect ambiguity patterns in source text.
1902fn detect_ambiguity_patterns(source: &str) -> Vec<AmbiguitySignal> {
1903    use std::sync::OnceLock;
1904
1905    static PATTERNS: OnceLock<Vec<(regex::Regex, AmbiguitySignal)>> = OnceLock::new();
1906    static DYN_REQUIRE: OnceLock<regex::Regex> = OnceLock::new();
1907
1908    let patterns = PATTERNS.get_or_init(|| {
1909        vec![
1910            (
1911                regex::Regex::new(r"\beval\s*\(").expect("regex"),
1912                AmbiguitySignal::DynamicEval,
1913            ),
1914            (
1915                regex::Regex::new(r"\bnew\s+Function\s*\(").expect("regex"),
1916                AmbiguitySignal::DynamicFunction,
1917            ),
1918            (
1919                regex::Regex::new(r"\bimport\s*\(").expect("regex"),
1920                AmbiguitySignal::DynamicImport,
1921            ),
1922            (
1923                regex::Regex::new(r"export\s+\*\s+from\b").expect("regex"),
1924                AmbiguitySignal::StarReExport,
1925            ),
1926            (
1927                regex::Regex::new(r"\bnew\s+Proxy\s*\(").expect("regex"),
1928                AmbiguitySignal::ProxyUsage,
1929            ),
1930            (
1931                regex::Regex::new(r"\bwith\s*\(").expect("regex"),
1932                AmbiguitySignal::WithStatement,
1933            ),
1934        ]
1935    });
1936
1937    let dyn_require = DYN_REQUIRE
1938        .get_or_init(|| regex::Regex::new(r#"\brequire\s*\(\s*[^"'`\s)]"#).expect("regex"));
1939
1940    let mut signals = Vec::new();
1941    for (re, signal) in patterns {
1942        if re.is_match(source) {
1943            signals.push(signal.clone());
1944        }
1945    }
1946    if dyn_require.is_match(source) {
1947        signals.push(AmbiguitySignal::DynamicRequire);
1948    }
1949
1950    signals
1951}
1952
1953// ---------------------------------------------------------------------------
1954// Intent graph extractor (bd-k5q5.9.2.1)
1955// ---------------------------------------------------------------------------
1956
1957/// A normalized intent signal extracted from an extension.
1958#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1959pub enum IntentSignal {
1960    /// The extension registers a tool with the given name.
1961    RegistersTool(String),
1962    /// The extension registers a slash command with the given name.
1963    RegistersCommand(String),
1964    /// The extension registers a keyboard shortcut.
1965    RegistersShortcut(String),
1966    /// The extension registers a feature flag with the given name.
1967    RegistersFlag(String),
1968    /// The extension registers a custom LLM provider.
1969    RegistersProvider(String),
1970    /// The extension hooks into a lifecycle event.
1971    HooksEvent(String),
1972    /// The extension declares a capability requirement.
1973    RequiresCapability(String),
1974    /// The extension registers a message renderer.
1975    RegistersRenderer(String),
1976}
1977
1978impl IntentSignal {
1979    /// Category tag for logging and grouping.
1980    pub const fn category(&self) -> &'static str {
1981        match self {
1982            Self::RegistersTool(_) => "tool",
1983            Self::RegistersCommand(_) => "command",
1984            Self::RegistersShortcut(_) => "shortcut",
1985            Self::RegistersFlag(_) => "flag",
1986            Self::RegistersProvider(_) => "provider",
1987            Self::HooksEvent(_) => "event_hook",
1988            Self::RequiresCapability(_) => "capability",
1989            Self::RegistersRenderer(_) => "renderer",
1990        }
1991    }
1992
1993    /// The name/identifier within the signal.
1994    pub fn name(&self) -> &str {
1995        match self {
1996            Self::RegistersTool(n)
1997            | Self::RegistersCommand(n)
1998            | Self::RegistersShortcut(n)
1999            | Self::RegistersFlag(n)
2000            | Self::RegistersProvider(n)
2001            | Self::HooksEvent(n)
2002            | Self::RequiresCapability(n)
2003            | Self::RegistersRenderer(n) => n,
2004        }
2005    }
2006}
2007
2008impl std::fmt::Display for IntentSignal {
2009    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2010        write!(f, "{}:{}", self.category(), self.name())
2011    }
2012}
2013
2014/// Normalized intent graph for a single extension.
2015///
2016/// Captures every registration and capability declaration the extension makes,
2017/// providing a complete picture of what the extension *intends* to do. Used by
2018/// the confidence scoring model to decide whether automated repair is safe.
2019#[derive(Debug, Clone, Default)]
2020pub struct IntentGraph {
2021    /// Extension identity.
2022    pub extension_id: String,
2023    /// All extracted intent signals (deduplicated).
2024    pub signals: Vec<IntentSignal>,
2025}
2026
2027impl IntentGraph {
2028    /// Build an intent graph from a `RegisterPayload` and capability list.
2029    pub fn from_register_payload(
2030        extension_id: &str,
2031        payload: &serde_json::Value,
2032        capabilities: &[String],
2033    ) -> Self {
2034        let mut signals = Vec::new();
2035
2036        // Extract tools.
2037        if let Some(tools) = payload.get("tools").and_then(|v| v.as_array()) {
2038            for tool in tools {
2039                if let Some(name) = tool.get("name").and_then(|n| n.as_str()) {
2040                    signals.push(IntentSignal::RegistersTool(name.to_string()));
2041                }
2042            }
2043        }
2044
2045        // Extract slash commands.
2046        if let Some(cmds) = payload.get("slash_commands").and_then(|v| v.as_array()) {
2047            for cmd in cmds {
2048                if let Some(name) = cmd.get("name").and_then(|n| n.as_str()) {
2049                    signals.push(IntentSignal::RegistersCommand(name.to_string()));
2050                }
2051            }
2052        }
2053
2054        // Extract shortcuts.
2055        if let Some(shortcuts) = payload.get("shortcuts").and_then(|v| v.as_array()) {
2056            for sc in shortcuts {
2057                let label = sc
2058                    .get("name")
2059                    .or_else(|| sc.get("key"))
2060                    .and_then(|n| n.as_str())
2061                    .unwrap_or("unknown");
2062                signals.push(IntentSignal::RegistersShortcut(label.to_string()));
2063            }
2064        }
2065
2066        // Extract flags.
2067        if let Some(flags) = payload.get("flags").and_then(|v| v.as_array()) {
2068            for flag in flags {
2069                if let Some(name) = flag.get("name").and_then(|n| n.as_str()) {
2070                    signals.push(IntentSignal::RegistersFlag(name.to_string()));
2071                }
2072            }
2073        }
2074
2075        // Extract event hooks.
2076        if let Some(hooks) = payload.get("event_hooks").and_then(|v| v.as_array()) {
2077            for hook in hooks {
2078                if let Some(name) = hook.as_str() {
2079                    signals.push(IntentSignal::HooksEvent(name.to_string()));
2080                }
2081            }
2082        }
2083
2084        // Capability declarations.
2085        for cap in capabilities {
2086            signals.push(IntentSignal::RequiresCapability(cap.clone()));
2087        }
2088
2089        // Deduplicate while preserving order.
2090        let mut seen = std::collections::HashSet::new();
2091        signals.retain(|s| seen.insert(s.clone()));
2092
2093        Self {
2094            extension_id: extension_id.to_string(),
2095            signals,
2096        }
2097    }
2098
2099    /// Return signals of a specific category.
2100    pub fn signals_by_category(&self, category: &str) -> Vec<&IntentSignal> {
2101        self.signals
2102            .iter()
2103            .filter(|s| s.category() == category)
2104            .collect()
2105    }
2106
2107    /// Number of distinct signal categories present.
2108    pub fn category_count(&self) -> usize {
2109        let cats: std::collections::HashSet<&str> =
2110            self.signals.iter().map(IntentSignal::category).collect();
2111        cats.len()
2112    }
2113
2114    /// True if the graph contains no signals at all.
2115    pub fn is_empty(&self) -> bool {
2116        self.signals.is_empty()
2117    }
2118
2119    /// Total number of signals.
2120    pub fn signal_count(&self) -> usize {
2121        self.signals.len()
2122    }
2123}
2124
2125// ---------------------------------------------------------------------------
2126// Confidence scoring model (bd-k5q5.9.2.3)
2127// ---------------------------------------------------------------------------
2128
2129/// An individual reason contributing to the confidence score.
2130#[derive(Debug, Clone)]
2131pub struct ConfidenceReason {
2132    /// Short machine-readable code (e.g., "parsed_ok", "has_tools").
2133    pub code: String,
2134    /// Human-readable explanation.
2135    pub explanation: String,
2136    /// How much this reason contributes (+) or penalizes (-) the score.
2137    pub delta: f64,
2138}
2139
2140/// Result of confidence scoring: a score plus explainable reasons.
2141#[derive(Debug, Clone)]
2142pub struct ConfidenceReport {
2143    /// Overall confidence (0.0–1.0). Higher = more legible / safer to repair.
2144    pub score: f64,
2145    /// Ordered list of reasons that contributed to the score.
2146    pub reasons: Vec<ConfidenceReason>,
2147}
2148
2149impl ConfidenceReport {
2150    /// True if the confidence is high enough for automated repair.
2151    pub fn is_repairable(&self) -> bool {
2152        self.score >= 0.5
2153    }
2154
2155    /// True if the confidence is high enough only for suggest mode.
2156    pub fn is_suggestable(&self) -> bool {
2157        self.score >= 0.2
2158    }
2159}
2160
2161/// Compute legibility confidence from intent graph and parse results.
2162///
2163/// The model is deterministic: same inputs always produce the same score.
2164/// The score starts at a base of 0.5 and is adjusted by weighted signals:
2165///
2166/// **Positive signals** (increase confidence):
2167/// - Source parsed successfully
2168/// - Extension registers at least one tool/command/hook
2169/// - Multiple intent categories present (well-structured extension)
2170///
2171/// **Negative signals** (decrease confidence):
2172/// - Parse failed
2173/// - Ambiguity detected (weighted by severity)
2174/// - No registrations (opaque extension)
2175/// - Zero statements recovered
2176#[allow(clippy::too_many_lines)]
2177pub fn compute_confidence(intent: &IntentGraph, parse: &TolerantParseResult) -> ConfidenceReport {
2178    let mut score: f64 = 0.5;
2179    let mut reasons = Vec::new();
2180
2181    // ── Parse quality ────────────────────────────────────────────────────
2182    if parse.parsed_ok {
2183        let delta = 0.15;
2184        score += delta;
2185        reasons.push(ConfidenceReason {
2186            code: "parsed_ok".to_string(),
2187            explanation: "Source parsed without fatal errors".to_string(),
2188            delta,
2189        });
2190    } else {
2191        let delta = -0.3;
2192        score += delta;
2193        reasons.push(ConfidenceReason {
2194            code: "parse_failed".to_string(),
2195            explanation: "Source failed to parse".to_string(),
2196            delta,
2197        });
2198    }
2199
2200    // ── Statement count ──────────────────────────────────────────────────
2201    if parse.statement_count == 0 && parse.parsed_ok {
2202        let delta = -0.1;
2203        score += delta;
2204        reasons.push(ConfidenceReason {
2205            code: "empty_module".to_string(),
2206            explanation: "Module has no statements".to_string(),
2207            delta,
2208        });
2209    }
2210
2211    // ── Import/export presence ───────────────────────────────────────────
2212    if parse.import_export_count > 0 {
2213        let delta = 0.05;
2214        score += delta;
2215        reasons.push(ConfidenceReason {
2216            code: "has_imports_exports".to_string(),
2217            explanation: format!(
2218                "{} import/export declarations found",
2219                parse.import_export_count
2220            ),
2221            delta,
2222        });
2223    }
2224
2225    // ── Ambiguity penalties ──────────────────────────────────────────────
2226    for ambiguity in &parse.ambiguities {
2227        let weight = ambiguity.weight();
2228        let delta = -weight * 0.3;
2229        score += delta;
2230        reasons.push(ConfidenceReason {
2231            code: format!("ambiguity_{}", ambiguity.tag()),
2232            explanation: format!("Ambiguity detected: {ambiguity} (weight={weight:.1})"),
2233            delta,
2234        });
2235    }
2236
2237    // ── Intent signal richness ───────────────────────────────────────────
2238    let tool_count = intent.signals_by_category("tool").len();
2239    if tool_count > 0 {
2240        let delta = 0.1;
2241        score += delta;
2242        reasons.push(ConfidenceReason {
2243            code: "has_tools".to_string(),
2244            explanation: format!("{tool_count} tool(s) registered"),
2245            delta,
2246        });
2247    }
2248
2249    let hook_count = intent.signals_by_category("event_hook").len();
2250    if hook_count > 0 {
2251        let delta = 0.05;
2252        score += delta;
2253        reasons.push(ConfidenceReason {
2254            code: "has_event_hooks".to_string(),
2255            explanation: format!("{hook_count} event hook(s) registered"),
2256            delta,
2257        });
2258    }
2259
2260    let categories = intent.category_count();
2261    if categories >= 3 {
2262        let delta = 0.1;
2263        score += delta;
2264        reasons.push(ConfidenceReason {
2265            code: "multi_category".to_string(),
2266            explanation: format!("{categories} distinct intent categories"),
2267            delta,
2268        });
2269    }
2270
2271    if intent.is_empty() && parse.parsed_ok {
2272        let delta = -0.15;
2273        score += delta;
2274        reasons.push(ConfidenceReason {
2275            code: "no_registrations".to_string(),
2276            explanation: "No tools, commands, or hooks registered".to_string(),
2277            delta,
2278        });
2279    }
2280
2281    // Clamp to [0.0, 1.0].
2282    score = score.clamp(0.0, 1.0);
2283
2284    ConfidenceReport { score, reasons }
2285}
2286
2287// ---------------------------------------------------------------------------
2288// Gating decision API (bd-k5q5.9.2.4)
2289// ---------------------------------------------------------------------------
2290
2291/// The repair gating decision: what action the system should take.
2292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2293pub enum GatingDecision {
2294    /// Extension is legible and safe for automated repair.
2295    Allow,
2296    /// Extension is partially legible; suggest repairs but do not auto-apply.
2297    Suggest,
2298    /// Extension is too opaque or risky; deny automated repair.
2299    Deny,
2300}
2301
2302impl GatingDecision {
2303    /// Short label for structured logging.
2304    pub const fn label(&self) -> &'static str {
2305        match self {
2306            Self::Allow => "allow",
2307            Self::Suggest => "suggest",
2308            Self::Deny => "deny",
2309        }
2310    }
2311}
2312
2313impl std::fmt::Display for GatingDecision {
2314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2315        f.write_str(self.label())
2316    }
2317}
2318
2319/// A structured reason code explaining why a gating decision was made.
2320#[derive(Debug, Clone, PartialEq, Eq)]
2321pub struct GatingReasonCode {
2322    /// Machine-readable code (e.g., "low_confidence", "parse_failed").
2323    pub code: String,
2324    /// Human-readable remediation guidance.
2325    pub remediation: String,
2326}
2327
2328/// Full gating verdict: decision + confidence + reason codes.
2329#[derive(Debug, Clone)]
2330pub struct GatingVerdict {
2331    /// The decision: allow / suggest / deny.
2332    pub decision: GatingDecision,
2333    /// The underlying confidence report.
2334    pub confidence: ConfidenceReport,
2335    /// Structured reason codes (empty for Allow).
2336    pub reason_codes: Vec<GatingReasonCode>,
2337}
2338
2339impl GatingVerdict {
2340    /// Whether the verdict permits automated repair.
2341    pub fn allows_repair(&self) -> bool {
2342        self.decision == GatingDecision::Allow
2343    }
2344
2345    /// Whether the verdict permits at least suggestion output.
2346    pub const fn allows_suggestion(&self) -> bool {
2347        matches!(
2348            self.decision,
2349            GatingDecision::Allow | GatingDecision::Suggest
2350        )
2351    }
2352}
2353
2354/// Compute the gating verdict from intent graph and parse results.
2355///
2356/// Combines `compute_confidence` with threshold-based decision logic:
2357/// - score >= 0.5 → Allow
2358/// - 0.2 <= score < 0.5 → Suggest
2359/// - score < 0.2 → Deny
2360///
2361/// Reason codes are generated for Suggest and Deny decisions to guide
2362/// the user on what needs to change for the extension to become repairable.
2363pub fn compute_gating_verdict(intent: &IntentGraph, parse: &TolerantParseResult) -> GatingVerdict {
2364    let confidence = compute_confidence(intent, parse);
2365    let decision = if confidence.is_repairable() {
2366        GatingDecision::Allow
2367    } else if confidence.is_suggestable() {
2368        GatingDecision::Suggest
2369    } else {
2370        GatingDecision::Deny
2371    };
2372
2373    let reason_codes = if decision == GatingDecision::Allow {
2374        vec![]
2375    } else {
2376        build_reason_codes(&confidence, parse)
2377    };
2378
2379    GatingVerdict {
2380        decision,
2381        confidence,
2382        reason_codes,
2383    }
2384}
2385
2386/// Generate structured reason codes with remediation guidance.
2387fn build_reason_codes(
2388    confidence: &ConfidenceReport,
2389    parse: &TolerantParseResult,
2390) -> Vec<GatingReasonCode> {
2391    let mut codes = Vec::new();
2392
2393    if !parse.parsed_ok {
2394        codes.push(GatingReasonCode {
2395            code: "parse_failed".to_string(),
2396            remediation: "Fix syntax errors in the extension source code".to_string(),
2397        });
2398    }
2399
2400    for ambiguity in &parse.ambiguities {
2401        if ambiguity.weight() >= 0.7 {
2402            codes.push(GatingReasonCode {
2403                code: format!("high_ambiguity_{}", ambiguity.tag()),
2404                remediation: format!(
2405                    "Remove or refactor {} usage to improve repair safety",
2406                    ambiguity.tag().replace('_', " ")
2407                ),
2408            });
2409        }
2410    }
2411
2412    if confidence.score < 0.2 {
2413        codes.push(GatingReasonCode {
2414            code: "very_low_confidence".to_string(),
2415            remediation: "Extension is too opaque for automated analysis; \
2416                          add explicit tool/hook registrations and remove dynamic constructs"
2417                .to_string(),
2418        });
2419    }
2420
2421    codes
2422}
2423
2424/// Statistics from a tick execution.
2425#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2426pub struct PiJsTickStats {
2427    /// Whether a macrotask was executed.
2428    pub ran_macrotask: bool,
2429    /// Number of microtask drain iterations.
2430    pub microtask_drains: usize,
2431    /// Number of pending QuickJS jobs drained.
2432    pub jobs_drained: usize,
2433    /// Number of pending hostcalls (in-flight Promises).
2434    pub pending_hostcalls: usize,
2435    /// Total hostcalls issued by this runtime.
2436    pub hostcalls_total: u64,
2437    /// Total hostcalls timed out by this runtime.
2438    pub hostcalls_timed_out: u64,
2439    /// Last observed QuickJS `memory_used_size` in bytes.
2440    pub memory_used_bytes: u64,
2441    /// Peak observed QuickJS `memory_used_size` in bytes.
2442    pub peak_memory_used_bytes: u64,
2443    /// Number of auto-repair events recorded since the runtime was created.
2444    pub repairs_total: u64,
2445    /// Number of module cache hits accumulated by this runtime.
2446    pub module_cache_hits: u64,
2447    /// Number of module cache misses accumulated by this runtime.
2448    pub module_cache_misses: u64,
2449    /// Number of module cache invalidations accumulated by this runtime.
2450    pub module_cache_invalidations: u64,
2451    /// Number of module entries currently retained in the cache.
2452    pub module_cache_entries: u64,
2453    /// Number of disk cache hits (transpiled source loaded from persistent storage).
2454    pub module_disk_cache_hits: u64,
2455}
2456
2457#[derive(Debug, Clone, Default)]
2458pub struct PiJsRuntimeLimits {
2459    /// Limit runtime heap usage (QuickJS allocator). `None` means unlimited.
2460    pub memory_limit_bytes: Option<usize>,
2461    /// Limit runtime stack usage. `None` uses QuickJS default.
2462    pub max_stack_bytes: Option<usize>,
2463    /// Interrupt budget to bound JS execution. `None` disables budget enforcement.
2464    ///
2465    /// This is implemented via QuickJS's interrupt hook. For deterministic unit tests,
2466    /// setting this to `Some(0)` forces an immediate abort.
2467    pub interrupt_budget: Option<u64>,
2468    /// Default timeout (ms) for hostcalls issued via `pi.*`.
2469    pub hostcall_timeout_ms: Option<u64>,
2470    /// Fast-path ring capacity for JS->host hostcall handoff.
2471    ///
2472    /// `0` means use the runtime default.
2473    pub hostcall_fast_queue_capacity: usize,
2474    /// Overflow capacity once the fast-path ring is saturated.
2475    ///
2476    /// `0` means use the runtime default.
2477    pub hostcall_overflow_queue_capacity: usize,
2478}
2479
2480/// Controls how the auto-repair pipeline behaves at extension load time.
2481///
2482/// Precedence (highest to lowest): CLI flag → environment variable
2483/// `PI_REPAIR_MODE` → config file → default (`AutoSafe`).
2484#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2485pub enum RepairMode {
2486    /// No repairs are attempted; extensions that fail to load fail normally.
2487    Off,
2488    /// Log suggested repairs but do not apply them. Useful for auditing what
2489    /// would change before enabling auto-repair in production.
2490    Suggest,
2491    /// Apply only provably safe repairs: file-path fallbacks (Pattern 1) and
2492    /// missing-asset stubs (Pattern 2). These never grant new privileges.
2493    #[default]
2494    AutoSafe,
2495    /// Apply all repairs including aggressive heuristics (monorepo escape
2496    /// stubs, proxy-based npm stubs, export shape fixups). May change
2497    /// observable extension behavior.
2498    AutoStrict,
2499}
2500
2501impl RepairMode {
2502    /// Whether repairs should actually be applied (not just logged).
2503    pub const fn should_apply(self) -> bool {
2504        matches!(self, Self::AutoSafe | Self::AutoStrict)
2505    }
2506
2507    /// Whether any repair activity (logging or applying) is enabled.
2508    pub const fn is_active(self) -> bool {
2509        !matches!(self, Self::Off)
2510    }
2511
2512    /// Whether aggressive/heuristic patterns (3-5) are allowed.
2513    pub const fn allows_aggressive(self) -> bool {
2514        matches!(self, Self::AutoStrict)
2515    }
2516
2517    /// Parse from a string (env var, CLI flag, config value).
2518    pub fn from_str_lossy(s: &str) -> Self {
2519        match s.trim().to_ascii_lowercase().as_str() {
2520            "off" | "none" | "disabled" | "false" | "0" => Self::Off,
2521            "suggest" | "log" | "dry-run" | "dry_run" => Self::Suggest,
2522            "auto-strict" | "auto_strict" | "strict" | "all" => Self::AutoStrict,
2523            // "auto-safe", "safe", "true", "1", or any unrecognised value → default
2524            _ => Self::AutoSafe,
2525        }
2526    }
2527}
2528
2529impl std::fmt::Display for RepairMode {
2530    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2531        match self {
2532            Self::Off => write!(f, "off"),
2533            Self::Suggest => write!(f, "suggest"),
2534            Self::AutoSafe => write!(f, "auto-safe"),
2535            Self::AutoStrict => write!(f, "auto-strict"),
2536        }
2537    }
2538}
2539
2540// ---------------------------------------------------------------------------
2541// Privilege monotonicity checker (bd-k5q5.9.1.3)
2542// ---------------------------------------------------------------------------
2543
2544/// Result of a privilege monotonicity check on a proposed repair.
2545#[derive(Debug, Clone, PartialEq, Eq)]
2546pub enum MonotonicityVerdict {
2547    /// The repair is safe: the resolved path does not broaden privileges.
2548    Safe,
2549    /// The repair would escape the extension root directory.
2550    EscapesRoot {
2551        extension_root: PathBuf,
2552        resolved: PathBuf,
2553    },
2554    /// The repaired path crosses into a different extension's directory.
2555    CrossExtension {
2556        original_extension: String,
2557        resolved: PathBuf,
2558    },
2559}
2560
2561impl MonotonicityVerdict {
2562    pub const fn is_safe(&self) -> bool {
2563        matches!(self, Self::Safe)
2564    }
2565}
2566
2567/// Check that a repair-resolved path stays within the extension root.
2568///
2569/// Ensures repaired artifacts cannot broaden the extension's effective
2570/// capability surface by reaching into unrelated code.
2571///
2572/// # Guarantees
2573/// 1. `resolved_path` must be a descendant of `extension_root`.
2574/// 2. `resolved_path` must not traverse above the common ancestor of
2575///    `extension_root` and `original_path`.
2576pub fn verify_repair_monotonicity(
2577    extension_root: &Path,
2578    _original_path: &Path,
2579    resolved_path: &Path,
2580) -> MonotonicityVerdict {
2581    // Canonicalise the root to resolve symlinks. Fall back to the raw path
2582    // if canonicalization fails (the directory might not exist in tests).
2583    let canonical_root = crate::extensions::safe_canonicalize(extension_root);
2584
2585    let canonical_resolved = crate::extensions::safe_canonicalize(resolved_path);
2586
2587    // The resolved path MUST be a descendant of the extension root.
2588    if !canonical_resolved.starts_with(&canonical_root) {
2589        return MonotonicityVerdict::EscapesRoot {
2590            extension_root: canonical_root,
2591            resolved: canonical_resolved,
2592        };
2593    }
2594
2595    MonotonicityVerdict::Safe
2596}
2597
2598// ---------------------------------------------------------------------------
2599// Capability monotonicity proof reports (bd-k5q5.9.5.2)
2600// ---------------------------------------------------------------------------
2601
2602/// A single capability change between before and after `IntentGraph`s.
2603#[derive(Debug, Clone, PartialEq, Eq)]
2604pub enum CapabilityDelta {
2605    /// A signal present in both before and after — no change.
2606    Retained(IntentSignal),
2607    /// A signal present in before but absent in after — removed.
2608    Removed(IntentSignal),
2609    /// A signal absent in before but present in after — added (violation).
2610    Added(IntentSignal),
2611}
2612
2613impl CapabilityDelta {
2614    /// True when this delta represents a privilege escalation.
2615    pub const fn is_escalation(&self) -> bool {
2616        matches!(self, Self::Added(_))
2617    }
2618
2619    /// True when the capability was preserved unchanged.
2620    pub const fn is_retained(&self) -> bool {
2621        matches!(self, Self::Retained(_))
2622    }
2623
2624    /// True when the capability was dropped.
2625    pub const fn is_removed(&self) -> bool {
2626        matches!(self, Self::Removed(_))
2627    }
2628
2629    /// Short label for logging and telemetry.
2630    pub const fn label(&self) -> &'static str {
2631        match self {
2632            Self::Retained(_) => "retained",
2633            Self::Removed(_) => "removed",
2634            Self::Added(_) => "added",
2635        }
2636    }
2637
2638    /// The underlying signal.
2639    pub const fn signal(&self) -> &IntentSignal {
2640        match self {
2641            Self::Retained(s) | Self::Removed(s) | Self::Added(s) => s,
2642        }
2643    }
2644}
2645
2646impl std::fmt::Display for CapabilityDelta {
2647    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2648        write!(f, "{}: {}", self.label(), self.signal())
2649    }
2650}
2651
2652/// Proof verdict for capability monotonicity.
2653#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2654pub enum CapabilityMonotonicityVerdict {
2655    /// No capabilities were added — repair is monotonic (safe).
2656    Monotonic,
2657    /// One or more capabilities were added — privilege escalation detected.
2658    Escalation,
2659}
2660
2661impl CapabilityMonotonicityVerdict {
2662    /// True when the repair passed monotonicity (no escalation).
2663    pub const fn is_safe(&self) -> bool {
2664        matches!(self, Self::Monotonic)
2665    }
2666}
2667
2668impl std::fmt::Display for CapabilityMonotonicityVerdict {
2669    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2670        match self {
2671            Self::Monotonic => write!(f, "monotonic"),
2672            Self::Escalation => write!(f, "escalation"),
2673        }
2674    }
2675}
2676
2677/// Full capability monotonicity proof report.
2678///
2679/// Compares the before-repair and after-repair `IntentGraph`s signal by
2680/// signal. A repair is monotonic if and only if it introduces no new
2681/// capabilities — it may remove or retain existing ones, but never add.
2682#[derive(Debug, Clone)]
2683pub struct CapabilityProofReport {
2684    /// Extension identity.
2685    pub extension_id: String,
2686    /// Overall verdict.
2687    pub verdict: CapabilityMonotonicityVerdict,
2688    /// Per-signal deltas.
2689    pub deltas: Vec<CapabilityDelta>,
2690    /// Number of retained capabilities.
2691    pub retained_count: usize,
2692    /// Number of removed capabilities.
2693    pub removed_count: usize,
2694    /// Number of added capabilities (escalations).
2695    pub added_count: usize,
2696}
2697
2698impl CapabilityProofReport {
2699    /// True when the proof passed (no escalation).
2700    pub const fn is_safe(&self) -> bool {
2701        self.verdict.is_safe()
2702    }
2703
2704    /// Return only the escalation deltas.
2705    pub fn escalations(&self) -> Vec<&CapabilityDelta> {
2706        self.deltas.iter().filter(|d| d.is_escalation()).collect()
2707    }
2708}
2709
2710/// Compute a capability monotonicity proof by diffing two intent graphs.
2711///
2712/// The `before` graph represents the original extension's capabilities.
2713/// The `after` graph represents the repaired extension's capabilities.
2714///
2715/// A repair is *monotonic* (safe) if and only if `after` introduces no
2716/// signals that were absent from `before`. Removals are allowed.
2717pub fn compute_capability_proof(
2718    before: &IntentGraph,
2719    after: &IntentGraph,
2720) -> CapabilityProofReport {
2721    use std::collections::HashSet;
2722
2723    let before_set: HashSet<&IntentSignal> = before.signals.iter().collect();
2724    let after_set: HashSet<&IntentSignal> = after.signals.iter().collect();
2725
2726    let mut deltas = Vec::new();
2727
2728    // Signals retained or removed (iterate `before`).
2729    for signal in &before.signals {
2730        if after_set.contains(signal) {
2731            deltas.push(CapabilityDelta::Retained(signal.clone()));
2732        } else {
2733            deltas.push(CapabilityDelta::Removed(signal.clone()));
2734        }
2735    }
2736
2737    // Signals added (in `after` but not in `before`).
2738    for signal in &after.signals {
2739        if !before_set.contains(signal) {
2740            deltas.push(CapabilityDelta::Added(signal.clone()));
2741        }
2742    }
2743
2744    let retained_count = deltas.iter().filter(|d| d.is_retained()).count();
2745    let removed_count = deltas.iter().filter(|d| d.is_removed()).count();
2746    let added_count = deltas.iter().filter(|d| d.is_escalation()).count();
2747
2748    let verdict = if added_count == 0 {
2749        CapabilityMonotonicityVerdict::Monotonic
2750    } else {
2751        CapabilityMonotonicityVerdict::Escalation
2752    };
2753
2754    CapabilityProofReport {
2755        extension_id: before.extension_id.clone(),
2756        verdict,
2757        deltas,
2758        retained_count,
2759        removed_count,
2760        added_count,
2761    }
2762}
2763
2764// ---------------------------------------------------------------------------
2765// Hostcall parity and semantic delta proof (bd-k5q5.9.5.3)
2766// ---------------------------------------------------------------------------
2767
2768/// Categories of hostcall surface that an extension can exercise.
2769#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2770pub enum HostcallCategory {
2771    /// `pi.events(op, ...)` — lifecycle event dispatch.
2772    Events(String),
2773    /// `pi.session(op, ...)` — session metadata operations.
2774    Session(String),
2775    /// `pi.register(...)` — registration (tools, commands, etc.).
2776    Register,
2777    /// `pi.tool(op, ...)` — tool management.
2778    Tool(String),
2779    /// `require(...)` / `import(...)` — module resolution.
2780    ModuleResolution(String),
2781}
2782
2783impl HostcallCategory {
2784    /// Short tag for logging.
2785    pub fn tag(&self) -> String {
2786        match self {
2787            Self::Events(op) => format!("events:{op}"),
2788            Self::Session(op) => format!("session:{op}"),
2789            Self::Register => "register".to_string(),
2790            Self::Tool(op) => format!("tool:{op}"),
2791            Self::ModuleResolution(spec) => format!("module:{spec}"),
2792        }
2793    }
2794}
2795
2796impl std::fmt::Display for HostcallCategory {
2797    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2798        f.write_str(&self.tag())
2799    }
2800}
2801
2802/// A delta between before/after hostcall surfaces.
2803#[derive(Debug, Clone, PartialEq, Eq)]
2804pub enum HostcallDelta {
2805    /// Hostcall present in both before and after.
2806    Retained(HostcallCategory),
2807    /// Hostcall present before but absent after.
2808    Removed(HostcallCategory),
2809    /// Hostcall absent before but present after — new surface.
2810    Added(HostcallCategory),
2811}
2812
2813impl HostcallDelta {
2814    /// True when this delta introduces new hostcall surface.
2815    pub const fn is_expansion(&self) -> bool {
2816        matches!(self, Self::Added(_))
2817    }
2818
2819    /// Short label for logging.
2820    pub const fn label(&self) -> &'static str {
2821        match self {
2822            Self::Retained(_) => "retained",
2823            Self::Removed(_) => "removed",
2824            Self::Added(_) => "added",
2825        }
2826    }
2827
2828    /// The underlying category.
2829    pub const fn category(&self) -> &HostcallCategory {
2830        match self {
2831            Self::Retained(c) | Self::Removed(c) | Self::Added(c) => c,
2832        }
2833    }
2834}
2835
2836impl std::fmt::Display for HostcallDelta {
2837    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2838        write!(f, "{}: {}", self.label(), self.category())
2839    }
2840}
2841
2842/// Semantic drift severity classification.
2843#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
2844pub enum SemanticDriftSeverity {
2845    /// No meaningful behavioral change detected.
2846    None,
2847    /// Minor changes that don't affect core functionality.
2848    Low,
2849    /// Changes that may affect behavior but within expected scope.
2850    Medium,
2851    /// Significant behavioral divergence — likely beyond fix scope.
2852    High,
2853}
2854
2855impl SemanticDriftSeverity {
2856    /// True if drift is within acceptable bounds.
2857    pub const fn is_acceptable(&self) -> bool {
2858        matches!(self, Self::None | Self::Low)
2859    }
2860}
2861
2862impl std::fmt::Display for SemanticDriftSeverity {
2863    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2864        match self {
2865            Self::None => write!(f, "none"),
2866            Self::Low => write!(f, "low"),
2867            Self::Medium => write!(f, "medium"),
2868            Self::High => write!(f, "high"),
2869        }
2870    }
2871}
2872
2873/// Overall verdict for hostcall parity and semantic delta proof.
2874#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2875pub enum SemanticParityVerdict {
2876    /// Repair preserves hostcall surface and semantic behavior.
2877    Equivalent,
2878    /// Minor acceptable drift (e.g., removed dead hostcalls).
2879    AcceptableDrift,
2880    /// Repair introduces new hostcall surface or significant semantic drift.
2881    Divergent,
2882}
2883
2884impl SemanticParityVerdict {
2885    /// True if the repair passes semantic parity.
2886    pub const fn is_safe(&self) -> bool {
2887        matches!(self, Self::Equivalent | Self::AcceptableDrift)
2888    }
2889}
2890
2891impl std::fmt::Display for SemanticParityVerdict {
2892    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2893        match self {
2894            Self::Equivalent => write!(f, "equivalent"),
2895            Self::AcceptableDrift => write!(f, "acceptable_drift"),
2896            Self::Divergent => write!(f, "divergent"),
2897        }
2898    }
2899}
2900
2901/// Full hostcall parity and semantic delta proof report.
2902#[derive(Debug, Clone)]
2903pub struct SemanticParityReport {
2904    /// Extension identity.
2905    pub extension_id: String,
2906    /// Overall verdict.
2907    pub verdict: SemanticParityVerdict,
2908    /// Hostcall surface deltas.
2909    pub hostcall_deltas: Vec<HostcallDelta>,
2910    /// Semantic drift severity assessment.
2911    pub drift_severity: SemanticDriftSeverity,
2912    /// Number of new hostcall surfaces introduced.
2913    pub expanded_count: usize,
2914    /// Number of hostcalls removed.
2915    pub removed_count: usize,
2916    /// Number of hostcalls retained.
2917    pub retained_count: usize,
2918    /// Explanatory notes for the verdict.
2919    pub notes: Vec<String>,
2920}
2921
2922impl SemanticParityReport {
2923    /// True if the proof passed (repair is safe).
2924    pub const fn is_safe(&self) -> bool {
2925        self.verdict.is_safe()
2926    }
2927
2928    /// Return only the expansion deltas (new hostcall surface).
2929    pub fn expansions(&self) -> Vec<&HostcallDelta> {
2930        self.hostcall_deltas
2931            .iter()
2932            .filter(|d| d.is_expansion())
2933            .collect()
2934    }
2935}
2936
2937/// Extract hostcall categories from an intent graph.
2938///
2939/// Maps `IntentSignal`s to the hostcall categories they exercise at runtime.
2940/// This provides a static approximation of the extension's hostcall surface.
2941pub fn extract_hostcall_surface(
2942    intent: &IntentGraph,
2943) -> std::collections::HashSet<HostcallCategory> {
2944    let mut surface = std::collections::HashSet::new();
2945
2946    for signal in &intent.signals {
2947        match signal {
2948            IntentSignal::RegistersTool(_)
2949            | IntentSignal::RegistersCommand(_)
2950            | IntentSignal::RegistersShortcut(_)
2951            | IntentSignal::RegistersFlag(_)
2952            | IntentSignal::RegistersProvider(_)
2953            | IntentSignal::RegistersRenderer(_) => {
2954                surface.insert(HostcallCategory::Register);
2955            }
2956            IntentSignal::HooksEvent(name) => {
2957                surface.insert(HostcallCategory::Events(name.clone()));
2958            }
2959            IntentSignal::RequiresCapability(cap) => {
2960                if cap == "session" {
2961                    surface.insert(HostcallCategory::Session("*".to_string()));
2962                } else if cap == "tool" {
2963                    surface.insert(HostcallCategory::Tool("*".to_string()));
2964                }
2965            }
2966        }
2967    }
2968
2969    surface
2970}
2971
2972/// Compute hostcall parity and semantic delta proof.
2973///
2974/// Compares the before-repair and after-repair hostcall surfaces and
2975/// assesses semantic drift. A repair passes if it does not expand the
2976/// hostcall surface beyond the declared fix scope.
2977pub fn compute_semantic_parity(
2978    before: &IntentGraph,
2979    after: &IntentGraph,
2980    patch_ops: &[PatchOp],
2981) -> SemanticParityReport {
2982    let before_surface = extract_hostcall_surface(before);
2983    let after_surface = extract_hostcall_surface(after);
2984
2985    let mut hostcall_deltas = Vec::new();
2986
2987    // Retained and removed.
2988    for cat in &before_surface {
2989        if after_surface.contains(cat) {
2990            hostcall_deltas.push(HostcallDelta::Retained(cat.clone()));
2991        } else {
2992            hostcall_deltas.push(HostcallDelta::Removed(cat.clone()));
2993        }
2994    }
2995
2996    // Added.
2997    for cat in &after_surface {
2998        if !before_surface.contains(cat) {
2999            hostcall_deltas.push(HostcallDelta::Added(cat.clone()));
3000        }
3001    }
3002
3003    let expanded_count = hostcall_deltas.iter().filter(|d| d.is_expansion()).count();
3004    let removed_count = hostcall_deltas
3005        .iter()
3006        .filter(|d| matches!(d, HostcallDelta::Removed(_)))
3007        .count();
3008    let retained_count = hostcall_deltas
3009        .iter()
3010        .filter(|d| matches!(d, HostcallDelta::Retained(_)))
3011        .count();
3012
3013    // Assess semantic drift based on patch operations.
3014    let mut notes = Vec::new();
3015    let drift_severity = assess_drift(patch_ops, expanded_count, removed_count, &mut notes);
3016
3017    let verdict = if expanded_count == 0 && drift_severity.is_acceptable() {
3018        if removed_count == 0 {
3019            SemanticParityVerdict::Equivalent
3020        } else {
3021            notes.push(format!(
3022                "{removed_count} hostcall(s) removed — acceptable reduction"
3023            ));
3024            SemanticParityVerdict::AcceptableDrift
3025        }
3026    } else {
3027        if expanded_count > 0 {
3028            notes.push(format!(
3029                "{expanded_count} new hostcall surface(s) introduced"
3030            ));
3031        }
3032        SemanticParityVerdict::Divergent
3033    };
3034
3035    SemanticParityReport {
3036        extension_id: before.extension_id.clone(),
3037        verdict,
3038        hostcall_deltas,
3039        drift_severity,
3040        expanded_count,
3041        removed_count,
3042        retained_count,
3043        notes,
3044    }
3045}
3046
3047/// Assess semantic drift severity from patch operations and hostcall changes.
3048fn assess_drift(
3049    patch_ops: &[PatchOp],
3050    expanded_hostcalls: usize,
3051    _removed_hostcalls: usize,
3052    notes: &mut Vec<String>,
3053) -> SemanticDriftSeverity {
3054    // Any hostcall expansion is High severity.
3055    if expanded_hostcalls > 0 {
3056        notes.push("new hostcall surface detected".to_string());
3057        return SemanticDriftSeverity::High;
3058    }
3059
3060    let mut has_aggressive = false;
3061    let mut stub_count = 0_usize;
3062
3063    for op in patch_ops {
3064        match op {
3065            PatchOp::InjectStub { .. } => {
3066                stub_count += 1;
3067                has_aggressive = true;
3068            }
3069            PatchOp::AddExport { .. } | PatchOp::RemoveImport { .. } => {
3070                has_aggressive = true;
3071            }
3072            PatchOp::ReplaceModulePath { .. } | PatchOp::RewriteRequire { .. } => {}
3073        }
3074    }
3075
3076    if stub_count > 2 {
3077        notes.push(format!("{stub_count} stubs injected — medium drift"));
3078        return SemanticDriftSeverity::Medium;
3079    }
3080
3081    if has_aggressive {
3082        notes.push("aggressive ops present — low drift".to_string());
3083        return SemanticDriftSeverity::Low;
3084    }
3085
3086    SemanticDriftSeverity::None
3087}
3088
3089// ---------------------------------------------------------------------------
3090// Conformance replay and golden checksum evidence (bd-k5q5.9.5.4)
3091// ---------------------------------------------------------------------------
3092
3093/// SHA-256 checksum of an artifact (hex-encoded, lowercase).
3094pub type ArtifactChecksum = String;
3095
3096/// Compute a SHA-256 checksum for the given byte content.
3097pub fn compute_artifact_checksum(content: &[u8]) -> ArtifactChecksum {
3098    use sha2::{Digest, Sha256};
3099    let hash = Sha256::digest(content);
3100    format!("{hash:x}")
3101}
3102
3103/// A single artifact entry in a golden checksum manifest.
3104#[derive(Debug, Clone, PartialEq, Eq)]
3105pub struct ChecksumEntry {
3106    /// Relative path of the artifact within the extension root.
3107    pub relative_path: String,
3108    /// SHA-256 checksum.
3109    pub checksum: ArtifactChecksum,
3110    /// Byte size of the artifact.
3111    pub size_bytes: u64,
3112}
3113
3114/// Overall verdict of a conformance replay check.
3115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3116pub enum ConformanceReplayVerdict {
3117    /// All replayed fixtures matched expected behavior.
3118    Pass,
3119    /// One or more fixtures produced unexpected results.
3120    Fail,
3121    /// No fixtures were available to replay (vacuously safe).
3122    NoFixtures,
3123}
3124
3125impl ConformanceReplayVerdict {
3126    /// True if the replay passed or had no fixtures.
3127    pub const fn is_acceptable(&self) -> bool {
3128        matches!(self, Self::Pass | Self::NoFixtures)
3129    }
3130}
3131
3132impl std::fmt::Display for ConformanceReplayVerdict {
3133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3134        match self {
3135            Self::Pass => write!(f, "pass"),
3136            Self::Fail => write!(f, "fail"),
3137            Self::NoFixtures => write!(f, "no_fixtures"),
3138        }
3139    }
3140}
3141
3142/// A single conformance fixture for replay.
3143#[derive(Debug, Clone)]
3144pub struct ConformanceFixture {
3145    /// Descriptive name of the fixture.
3146    pub name: String,
3147    /// Expected behavior or output pattern.
3148    pub expected: String,
3149    /// Actual behavior or output observed during replay.
3150    pub actual: Option<String>,
3151    /// Whether this fixture passed.
3152    pub passed: bool,
3153}
3154
3155/// Result of replaying conformance fixtures.
3156#[derive(Debug, Clone)]
3157pub struct ConformanceReplayReport {
3158    /// Extension identity.
3159    pub extension_id: String,
3160    /// Overall verdict.
3161    pub verdict: ConformanceReplayVerdict,
3162    /// Individual fixture results.
3163    pub fixtures: Vec<ConformanceFixture>,
3164    /// Number of fixtures that passed.
3165    pub passed_count: usize,
3166    /// Total number of fixtures replayed.
3167    pub total_count: usize,
3168}
3169
3170impl ConformanceReplayReport {
3171    /// True if the replay is acceptable.
3172    pub const fn is_acceptable(&self) -> bool {
3173        self.verdict.is_acceptable()
3174    }
3175}
3176
3177/// Replay conformance fixtures and produce a report.
3178///
3179/// Each fixture is checked: if `actual` is provided and matches `expected`,
3180/// the fixture passes. If no fixtures are provided, the verdict is
3181/// `NoFixtures` (vacuously safe — conformance cannot be disproven).
3182pub fn replay_conformance_fixtures(
3183    extension_id: &str,
3184    fixtures: &[ConformanceFixture],
3185) -> ConformanceReplayReport {
3186    if fixtures.is_empty() {
3187        return ConformanceReplayReport {
3188            extension_id: extension_id.to_string(),
3189            verdict: ConformanceReplayVerdict::NoFixtures,
3190            fixtures: Vec::new(),
3191            passed_count: 0,
3192            total_count: 0,
3193        };
3194    }
3195
3196    let passed_count = fixtures.iter().filter(|f| f.passed).count();
3197    let total_count = fixtures.len();
3198    let verdict = if passed_count == total_count {
3199        ConformanceReplayVerdict::Pass
3200    } else {
3201        ConformanceReplayVerdict::Fail
3202    };
3203
3204    ConformanceReplayReport {
3205        extension_id: extension_id.to_string(),
3206        verdict,
3207        fixtures: fixtures.to_vec(),
3208        passed_count,
3209        total_count,
3210    }
3211}
3212
3213/// A golden checksum manifest for reproducible evidence.
3214///
3215/// Records the checksums of all repaired artifacts at the time of repair.
3216/// This provides tamper-evident proof that the artifacts were not modified
3217/// after the repair pipeline produced them.
3218#[derive(Debug, Clone)]
3219pub struct GoldenChecksumManifest {
3220    /// Extension identity.
3221    pub extension_id: String,
3222    /// Entries (one per artifact).
3223    pub entries: Vec<ChecksumEntry>,
3224    /// When the manifest was generated (unix millis).
3225    pub generated_at_ms: u64,
3226}
3227
3228impl GoldenChecksumManifest {
3229    /// Number of artifacts in the manifest.
3230    pub fn artifact_count(&self) -> usize {
3231        self.entries.len()
3232    }
3233
3234    /// Verify that a given file's content matches its entry in the manifest.
3235    pub fn verify_entry(&self, relative_path: &str, content: &[u8]) -> Option<bool> {
3236        self.entries
3237            .iter()
3238            .find(|e| e.relative_path == relative_path)
3239            .map(|e| e.checksum == compute_artifact_checksum(content))
3240    }
3241}
3242
3243/// Build a golden checksum manifest from file contents.
3244///
3245/// Takes an extension_id, a list of (relative_path, content) tuples, and a
3246/// timestamp. Computes SHA-256 for each artifact.
3247pub fn build_golden_manifest(
3248    extension_id: &str,
3249    artifacts: &[(&str, &[u8])],
3250    timestamp_ms: u64,
3251) -> GoldenChecksumManifest {
3252    let entries = artifacts
3253        .iter()
3254        .map(|(path, content)| ChecksumEntry {
3255            relative_path: (*path).to_string(),
3256            checksum: compute_artifact_checksum(content),
3257            size_bytes: content.len() as u64,
3258        })
3259        .collect();
3260
3261    GoldenChecksumManifest {
3262        extension_id: extension_id.to_string(),
3263        entries,
3264        generated_at_ms: timestamp_ms,
3265    }
3266}
3267
3268/// Unified verification evidence bundle.
3269///
3270/// Collects all proof artifacts from LISR-5 into a single bundle that
3271/// serves as the activation gate. A repair candidate cannot be activated
3272/// unless ALL proofs pass.
3273#[derive(Debug, Clone)]
3274pub struct VerificationBundle {
3275    /// Extension identity.
3276    pub extension_id: String,
3277    /// Structural validation (LISR-5.1).
3278    pub structural: StructuralVerdict,
3279    /// Capability monotonicity proof (LISR-5.2).
3280    pub capability_proof: CapabilityProofReport,
3281    /// Semantic parity proof (LISR-5.3).
3282    pub semantic_proof: SemanticParityReport,
3283    /// Conformance replay (LISR-5.4).
3284    pub conformance: ConformanceReplayReport,
3285    /// Golden checksum manifest (LISR-5.4).
3286    pub checksum_manifest: GoldenChecksumManifest,
3287}
3288
3289impl VerificationBundle {
3290    /// True if ALL proofs pass — the activation gate.
3291    pub const fn is_verified(&self) -> bool {
3292        self.structural.is_valid()
3293            && self.capability_proof.is_safe()
3294            && self.semantic_proof.is_safe()
3295            && self.conformance.is_acceptable()
3296    }
3297
3298    /// Collect failure reasons for logging.
3299    pub fn failure_reasons(&self) -> Vec<String> {
3300        let mut reasons = Vec::new();
3301        if !self.structural.is_valid() {
3302            reasons.push(format!("structural: {}", self.structural));
3303        }
3304        if !self.capability_proof.is_safe() {
3305            reasons.push(format!(
3306                "capability: {} ({} escalation(s))",
3307                self.capability_proof.verdict, self.capability_proof.added_count
3308            ));
3309        }
3310        if !self.semantic_proof.is_safe() {
3311            reasons.push(format!(
3312                "semantic: {} (drift={})",
3313                self.semantic_proof.verdict, self.semantic_proof.drift_severity
3314            ));
3315        }
3316        if !self.conformance.is_acceptable() {
3317            reasons.push(format!(
3318                "conformance: {} ({}/{} passed)",
3319                self.conformance.verdict,
3320                self.conformance.passed_count,
3321                self.conformance.total_count
3322            ));
3323        }
3324        reasons
3325    }
3326}
3327
3328// ---------------------------------------------------------------------------
3329// Overlay artifact format and lifecycle storage (bd-k5q5.9.6.1)
3330// ---------------------------------------------------------------------------
3331
3332/// Lifecycle state of an overlay artifact.
3333#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3334pub enum OverlayState {
3335    /// Just created, not yet deployed.
3336    Staged,
3337    /// In canary — serving to a controlled cohort.
3338    Canary,
3339    /// Promoted to stable after successful canary window.
3340    Stable,
3341    /// Rolled back due to failure or manual action.
3342    RolledBack,
3343    /// Superseded by a newer repair.
3344    Superseded,
3345}
3346
3347impl OverlayState {
3348    /// True if the overlay is currently serving traffic.
3349    pub const fn is_active(&self) -> bool {
3350        matches!(self, Self::Canary | Self::Stable)
3351    }
3352
3353    /// True if the overlay has reached a terminal state.
3354    pub const fn is_terminal(&self) -> bool {
3355        matches!(self, Self::RolledBack | Self::Superseded)
3356    }
3357}
3358
3359impl std::fmt::Display for OverlayState {
3360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3361        match self {
3362            Self::Staged => write!(f, "staged"),
3363            Self::Canary => write!(f, "canary"),
3364            Self::Stable => write!(f, "stable"),
3365            Self::RolledBack => write!(f, "rolled_back"),
3366            Self::Superseded => write!(f, "superseded"),
3367        }
3368    }
3369}
3370
3371/// An overlay artifact bundle: the unit of repair deployment.
3372///
3373/// Contains the repaired payload, original artifact hash, proof metadata,
3374/// policy decisions, and full lineage for auditability.
3375#[derive(Debug, Clone)]
3376pub struct OverlayArtifact {
3377    /// Unique identifier for this overlay.
3378    pub overlay_id: String,
3379    /// Extension identity.
3380    pub extension_id: String,
3381    /// Extension version.
3382    pub extension_version: String,
3383    /// SHA-256 of the original (broken) artifact.
3384    pub original_checksum: ArtifactChecksum,
3385    /// SHA-256 of the repaired artifact.
3386    pub repaired_checksum: ArtifactChecksum,
3387    /// Current lifecycle state.
3388    pub state: OverlayState,
3389    /// Rule that produced this repair.
3390    pub rule_id: String,
3391    /// Repair mode active when the overlay was created.
3392    pub repair_mode: RepairMode,
3393    /// Verification bundle summary (pass/fail per layer).
3394    pub verification_passed: bool,
3395    /// Creation timestamp (unix millis).
3396    pub created_at_ms: u64,
3397    /// Last state-transition timestamp (unix millis).
3398    pub updated_at_ms: u64,
3399}
3400
3401impl OverlayArtifact {
3402    /// True if the overlay is currently serving.
3403    pub const fn is_active(&self) -> bool {
3404        self.state.is_active()
3405    }
3406}
3407
3408/// State transition error.
3409#[derive(Debug, Clone, PartialEq, Eq)]
3410pub enum OverlayTransitionError {
3411    /// Attempted transition is not valid from the current state.
3412    InvalidTransition {
3413        from: OverlayState,
3414        to: OverlayState,
3415    },
3416    /// Verification must pass before deployment.
3417    VerificationRequired,
3418}
3419
3420impl std::fmt::Display for OverlayTransitionError {
3421    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3422        match self {
3423            Self::InvalidTransition { from, to } => {
3424                write!(f, "invalid transition: {from} → {to}")
3425            }
3426            Self::VerificationRequired => {
3427                write!(f, "verification must pass before deployment")
3428            }
3429        }
3430    }
3431}
3432
3433/// Advance an overlay through its lifecycle.
3434///
3435/// Valid transitions:
3436/// - Staged → Canary (requires verification_passed)
3437/// - Canary → Stable
3438/// - Canary → `RolledBack`
3439/// - Stable → `RolledBack`
3440/// - Stable → Superseded
3441/// - Staged → `RolledBack`
3442pub fn transition_overlay(
3443    artifact: &mut OverlayArtifact,
3444    target: OverlayState,
3445    now_ms: u64,
3446) -> std::result::Result<(), OverlayTransitionError> {
3447    let valid = matches!(
3448        (artifact.state, target),
3449        (
3450            OverlayState::Staged,
3451            OverlayState::Canary | OverlayState::RolledBack
3452        ) | (
3453            OverlayState::Canary,
3454            OverlayState::Stable | OverlayState::RolledBack
3455        ) | (
3456            OverlayState::Stable,
3457            OverlayState::RolledBack | OverlayState::Superseded
3458        )
3459    );
3460
3461    if !valid {
3462        return Err(OverlayTransitionError::InvalidTransition {
3463            from: artifact.state,
3464            to: target,
3465        });
3466    }
3467
3468    // Verification gate for deployment.
3469    if target == OverlayState::Canary && !artifact.verification_passed {
3470        return Err(OverlayTransitionError::VerificationRequired);
3471    }
3472
3473    artifact.state = target;
3474    artifact.updated_at_ms = now_ms;
3475    Ok(())
3476}
3477
3478// ---------------------------------------------------------------------------
3479// Per-extension/version canary routing (bd-k5q5.9.6.2)
3480// ---------------------------------------------------------------------------
3481
3482/// Canary routing decision for a specific request.
3483#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3484pub enum CanaryRoute {
3485    /// Use the original (unrepaired) artifact.
3486    Original,
3487    /// Use the repaired overlay artifact.
3488    Overlay,
3489}
3490
3491impl std::fmt::Display for CanaryRoute {
3492    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3493        match self {
3494            Self::Original => write!(f, "original"),
3495            Self::Overlay => write!(f, "overlay"),
3496        }
3497    }
3498}
3499
3500/// Canary configuration for a specific extension/version pair.
3501#[derive(Debug, Clone)]
3502pub struct CanaryConfig {
3503    /// Extension identity.
3504    pub extension_id: String,
3505    /// Extension version.
3506    pub extension_version: String,
3507    /// Percentage of requests to route to overlay (0–100).
3508    pub overlay_percent: u8,
3509    /// Whether canary is currently active.
3510    pub enabled: bool,
3511}
3512
3513impl CanaryConfig {
3514    /// Route a request using a deterministic hash value (0–99).
3515    pub const fn route(&self, hash_bucket: u8) -> CanaryRoute {
3516        if self.enabled && hash_bucket < self.overlay_percent {
3517            CanaryRoute::Overlay
3518        } else {
3519            CanaryRoute::Original
3520        }
3521    }
3522
3523    /// True if all traffic is routed to overlay (100% canary).
3524    pub const fn is_full_rollout(&self) -> bool {
3525        self.enabled && self.overlay_percent >= 100
3526    }
3527}
3528
3529/// Compute a deterministic hash bucket (0–99) from extension ID and environment.
3530pub fn compute_canary_bucket(extension_id: &str, environment: &str) -> u8 {
3531    use sha2::{Digest, Sha256};
3532    let mut hasher = Sha256::new();
3533    hasher.update(extension_id.as_bytes());
3534    hasher.update(b":");
3535    hasher.update(environment.as_bytes());
3536    let hash = hasher.finalize();
3537    // Use u16 to reduce modulo bias (65536 % 100 = 36 vs 256 % 100 = 56).
3538    let val = u16::from_be_bytes([hash[0], hash[1]]);
3539    (val % 100) as u8
3540}
3541
3542// ---------------------------------------------------------------------------
3543// Health/SLO monitors and automatic rollback triggers (bd-k5q5.9.6.3)
3544// ---------------------------------------------------------------------------
3545
3546/// A health signal observed during canary.
3547#[derive(Debug, Clone)]
3548pub struct HealthSignal {
3549    /// Signal name (e.g., "load_success", "hostcall_error_rate").
3550    pub name: String,
3551    /// Current value.
3552    pub value: f64,
3553    /// SLO threshold (value must not exceed this for the signal to be healthy).
3554    pub threshold: f64,
3555}
3556
3557impl HealthSignal {
3558    /// True if the signal is within SLO bounds.
3559    pub fn is_healthy(&self) -> bool {
3560        self.value <= self.threshold
3561    }
3562}
3563
3564/// SLO verdict for a canary window.
3565#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3566pub enum SloVerdict {
3567    /// All signals within thresholds.
3568    Healthy,
3569    /// One or more signals violated their SLO.
3570    Violated,
3571}
3572
3573impl SloVerdict {
3574    /// True if the canary is healthy.
3575    pub const fn is_healthy(&self) -> bool {
3576        matches!(self, Self::Healthy)
3577    }
3578}
3579
3580impl std::fmt::Display for SloVerdict {
3581    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3582        match self {
3583            Self::Healthy => write!(f, "healthy"),
3584            Self::Violated => write!(f, "violated"),
3585        }
3586    }
3587}
3588
3589/// Health assessment report for a canary window.
3590#[derive(Debug, Clone)]
3591pub struct HealthReport {
3592    /// Extension identity.
3593    pub extension_id: String,
3594    /// Overall verdict.
3595    pub verdict: SloVerdict,
3596    /// Individual signal assessments.
3597    pub signals: Vec<HealthSignal>,
3598    /// Signals that violated their SLO.
3599    pub violations: Vec<String>,
3600}
3601
3602impl HealthReport {
3603    /// True if the canary is healthy.
3604    pub const fn is_healthy(&self) -> bool {
3605        self.verdict.is_healthy()
3606    }
3607}
3608
3609/// Evaluate health signals against SLO thresholds.
3610pub fn evaluate_health(extension_id: &str, signals: &[HealthSignal]) -> HealthReport {
3611    let violations: Vec<String> = signals
3612        .iter()
3613        .filter(|s| !s.is_healthy())
3614        .map(|s| format!("{}: {:.3} > {:.3}", s.name, s.value, s.threshold))
3615        .collect();
3616
3617    let verdict = if violations.is_empty() {
3618        SloVerdict::Healthy
3619    } else {
3620        SloVerdict::Violated
3621    };
3622
3623    HealthReport {
3624        extension_id: extension_id.to_string(),
3625        verdict,
3626        signals: signals.to_vec(),
3627        violations,
3628    }
3629}
3630
3631/// Automatic rollback trigger: should the canary be rolled back?
3632pub const fn should_auto_rollback(health: &HealthReport) -> bool {
3633    !health.is_healthy()
3634}
3635
3636// ---------------------------------------------------------------------------
3637// Promotion and deterministic rollback workflow (bd-k5q5.9.6.4)
3638// ---------------------------------------------------------------------------
3639
3640/// Promotion decision for a canary overlay.
3641#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3642pub enum PromotionDecision {
3643    /// Promote to stable — canary window passed.
3644    Promote,
3645    /// Keep in canary — more observation needed.
3646    Hold,
3647    /// Rollback — SLO violations detected.
3648    Rollback,
3649}
3650
3651impl std::fmt::Display for PromotionDecision {
3652    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3653        match self {
3654            Self::Promote => write!(f, "promote"),
3655            Self::Hold => write!(f, "hold"),
3656            Self::Rollback => write!(f, "rollback"),
3657        }
3658    }
3659}
3660
3661/// Decide whether to promote, hold, or rollback a canary overlay.
3662///
3663/// Rules:
3664/// - If health is violated → Rollback.
3665/// - If canary duration has exceeded the window → Promote.
3666/// - Otherwise → Hold.
3667pub const fn decide_promotion(
3668    health: &HealthReport,
3669    canary_start_ms: u64,
3670    now_ms: u64,
3671    canary_window_ms: u64,
3672) -> PromotionDecision {
3673    if !health.is_healthy() {
3674        return PromotionDecision::Rollback;
3675    }
3676    if now_ms.saturating_sub(canary_start_ms) >= canary_window_ms {
3677        return PromotionDecision::Promote;
3678    }
3679    PromotionDecision::Hold
3680}
3681
3682/// Execute a promotion: transitions overlay to Stable.
3683pub fn execute_promotion(
3684    artifact: &mut OverlayArtifact,
3685    now_ms: u64,
3686) -> std::result::Result<(), OverlayTransitionError> {
3687    transition_overlay(artifact, OverlayState::Stable, now_ms)
3688}
3689
3690/// Execute a rollback: transitions overlay to `RolledBack`.
3691pub fn execute_rollback(
3692    artifact: &mut OverlayArtifact,
3693    now_ms: u64,
3694) -> std::result::Result<(), OverlayTransitionError> {
3695    transition_overlay(artifact, OverlayState::RolledBack, now_ms)
3696}
3697
3698// ---------------------------------------------------------------------------
3699// Append-only repair audit ledger (bd-k5q5.9.7.1)
3700// ---------------------------------------------------------------------------
3701
3702/// Kind of audit ledger entry.
3703#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3704pub enum AuditEntryKind {
3705    /// Analysis phase: intent extraction and confidence scoring.
3706    Analysis,
3707    /// Gating decision: allow / suggest / deny.
3708    GatingDecision,
3709    /// Proposal generated by rule or model.
3710    ProposalGenerated,
3711    /// Proposal validated against policy.
3712    ProposalValidated,
3713    /// Verification bundle evaluated.
3714    VerificationEvaluated,
3715    /// Human approval requested.
3716    ApprovalRequested,
3717    /// Human approval response.
3718    ApprovalResponse,
3719    /// Overlay activated (canary or stable).
3720    Activated,
3721    /// Overlay rolled back.
3722    RolledBack,
3723    /// Overlay promoted to stable.
3724    Promoted,
3725    /// Overlay superseded by newer repair.
3726    Superseded,
3727}
3728
3729impl std::fmt::Display for AuditEntryKind {
3730    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3731        match self {
3732            Self::Analysis => write!(f, "analysis"),
3733            Self::GatingDecision => write!(f, "gating_decision"),
3734            Self::ProposalGenerated => write!(f, "proposal_generated"),
3735            Self::ProposalValidated => write!(f, "proposal_validated"),
3736            Self::VerificationEvaluated => write!(f, "verification_evaluated"),
3737            Self::ApprovalRequested => write!(f, "approval_requested"),
3738            Self::ApprovalResponse => write!(f, "approval_response"),
3739            Self::Activated => write!(f, "activated"),
3740            Self::RolledBack => write!(f, "rolled_back"),
3741            Self::Promoted => write!(f, "promoted"),
3742            Self::Superseded => write!(f, "superseded"),
3743        }
3744    }
3745}
3746
3747/// A single entry in the repair audit ledger.
3748#[derive(Debug, Clone)]
3749pub struct AuditEntry {
3750    /// Monotonically increasing sequence number.
3751    pub sequence: u64,
3752    /// Timestamp (unix millis).
3753    pub timestamp_ms: u64,
3754    /// Extension being repaired.
3755    pub extension_id: String,
3756    /// Kind of event.
3757    pub kind: AuditEntryKind,
3758    /// Human-readable summary.
3759    pub summary: String,
3760    /// Structured detail fields (key-value pairs for machine consumption).
3761    pub details: Vec<(String, String)>,
3762}
3763
3764/// Append-only audit ledger for repair lifecycle events.
3765///
3766/// Entries are ordered by sequence number and cannot be mutated or deleted.
3767/// This provides tamper-evident evidence for incident forensics.
3768#[derive(Debug, Clone, Default)]
3769pub struct AuditLedger {
3770    entries: Vec<AuditEntry>,
3771    next_sequence: u64,
3772}
3773
3774impl AuditLedger {
3775    /// Create an empty ledger.
3776    pub const fn new() -> Self {
3777        Self {
3778            entries: Vec::new(),
3779            next_sequence: 0,
3780        }
3781    }
3782
3783    /// Append an entry to the ledger.
3784    pub fn append(
3785        &mut self,
3786        timestamp_ms: u64,
3787        extension_id: &str,
3788        kind: AuditEntryKind,
3789        summary: String,
3790        details: Vec<(String, String)>,
3791    ) -> u64 {
3792        let seq = self.next_sequence;
3793        self.entries.push(AuditEntry {
3794            sequence: seq,
3795            timestamp_ms,
3796            extension_id: extension_id.to_string(),
3797            kind,
3798            summary,
3799            details,
3800        });
3801        self.next_sequence = self.next_sequence.saturating_add(1);
3802        seq
3803    }
3804
3805    /// Number of entries in the ledger.
3806    pub fn len(&self) -> usize {
3807        self.entries.len()
3808    }
3809
3810    /// True if the ledger is empty.
3811    pub fn is_empty(&self) -> bool {
3812        self.entries.is_empty()
3813    }
3814
3815    /// Get an entry by sequence number.
3816    pub fn get(&self, sequence: u64) -> Option<&AuditEntry> {
3817        self.entries.iter().find(|e| e.sequence == sequence)
3818    }
3819
3820    /// Query entries by extension ID.
3821    pub fn entries_for_extension(&self, extension_id: &str) -> Vec<&AuditEntry> {
3822        self.entries
3823            .iter()
3824            .filter(|e| e.extension_id == extension_id)
3825            .collect()
3826    }
3827
3828    /// Query entries by kind.
3829    pub fn entries_by_kind(&self, kind: AuditEntryKind) -> Vec<&AuditEntry> {
3830        self.entries.iter().filter(|e| e.kind == kind).collect()
3831    }
3832
3833    /// All entries, ordered by sequence.
3834    pub fn all_entries(&self) -> &[AuditEntry] {
3835        &self.entries
3836    }
3837}
3838
3839// ---------------------------------------------------------------------------
3840// Telemetry taxonomy and metrics pipeline (bd-k5q5.9.7.2)
3841// ---------------------------------------------------------------------------
3842
3843/// Telemetry event kind for repair lifecycle metrics.
3844#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3845pub enum TelemetryMetric {
3846    /// A repair was attempted.
3847    RepairAttempted,
3848    /// Extension was eligible for repair.
3849    RepairEligible,
3850    /// Extension was ineligible (gating denied).
3851    RepairDenied,
3852    /// Verification proof failed.
3853    VerificationFailed,
3854    /// Overlay was rolled back.
3855    OverlayRolledBack,
3856    /// Overlay was promoted.
3857    OverlayPromoted,
3858    /// Human approval was requested.
3859    ApprovalLatencyMs,
3860}
3861
3862impl std::fmt::Display for TelemetryMetric {
3863    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3864        match self {
3865            Self::RepairAttempted => write!(f, "repair.attempted"),
3866            Self::RepairEligible => write!(f, "repair.eligible"),
3867            Self::RepairDenied => write!(f, "repair.denied"),
3868            Self::VerificationFailed => write!(f, "verification.failed"),
3869            Self::OverlayRolledBack => write!(f, "overlay.rolled_back"),
3870            Self::OverlayPromoted => write!(f, "overlay.promoted"),
3871            Self::ApprovalLatencyMs => write!(f, "approval.latency_ms"),
3872        }
3873    }
3874}
3875
3876/// A single telemetry data point.
3877#[derive(Debug, Clone)]
3878pub struct TelemetryPoint {
3879    /// Metric name.
3880    pub metric: TelemetryMetric,
3881    /// Numeric value (count=1 for counters, millis for latency, etc.).
3882    pub value: f64,
3883    /// Timestamp (unix millis).
3884    pub timestamp_ms: u64,
3885    /// Tags for dimensional filtering.
3886    pub tags: Vec<(String, String)>,
3887}
3888
3889/// Telemetry collector for repair lifecycle metrics.
3890#[derive(Debug, Clone, Default)]
3891pub struct TelemetryCollector {
3892    points: Vec<TelemetryPoint>,
3893}
3894
3895impl TelemetryCollector {
3896    /// Create an empty collector.
3897    pub const fn new() -> Self {
3898        Self { points: Vec::new() }
3899    }
3900
3901    /// Record a telemetry data point.
3902    pub fn record(
3903        &mut self,
3904        metric: TelemetryMetric,
3905        value: f64,
3906        timestamp_ms: u64,
3907        tags: Vec<(String, String)>,
3908    ) {
3909        self.points.push(TelemetryPoint {
3910            metric,
3911            value,
3912            timestamp_ms,
3913            tags,
3914        });
3915    }
3916
3917    /// Record a counter increment (value=1).
3918    pub fn increment(
3919        &mut self,
3920        metric: TelemetryMetric,
3921        timestamp_ms: u64,
3922        tags: Vec<(String, String)>,
3923    ) {
3924        self.record(metric, 1.0, timestamp_ms, tags);
3925    }
3926
3927    /// Count total occurrences of a metric.
3928    pub fn count(&self, metric: TelemetryMetric) -> usize {
3929        self.points.iter().filter(|p| p.metric == metric).count()
3930    }
3931
3932    /// Sum values for a metric.
3933    pub fn sum(&self, metric: TelemetryMetric) -> f64 {
3934        self.points
3935            .iter()
3936            .filter(|p| p.metric == metric)
3937            .map(|p| p.value)
3938            .sum()
3939    }
3940
3941    /// All points.
3942    pub fn all_points(&self) -> &[TelemetryPoint] {
3943        &self.points
3944    }
3945
3946    /// Number of recorded points.
3947    pub fn len(&self) -> usize {
3948        self.points.len()
3949    }
3950
3951    /// True if no points recorded.
3952    pub fn is_empty(&self) -> bool {
3953        self.points.is_empty()
3954    }
3955}
3956
3957// ---------------------------------------------------------------------------
3958// Operator CLI for repair inspect/explain/diff (bd-k5q5.9.7.3)
3959// ---------------------------------------------------------------------------
3960
3961/// A formatted inspection record for operator review.
3962#[derive(Debug, Clone)]
3963pub struct InspectionRecord {
3964    /// Extension identity.
3965    pub extension_id: String,
3966    /// Timeline of audit entries (formatted).
3967    pub timeline: Vec<String>,
3968    /// Gating decision summary.
3969    pub gating_summary: String,
3970    /// Current overlay state (if any).
3971    pub overlay_state: Option<String>,
3972    /// Verification result summary.
3973    pub verification_summary: String,
3974}
3975
3976/// Build an inspection record from audit ledger and current state.
3977pub fn build_inspection(
3978    extension_id: &str,
3979    ledger: &AuditLedger,
3980    overlay_state: Option<OverlayState>,
3981    verification_passed: bool,
3982) -> InspectionRecord {
3983    let entries = ledger.entries_for_extension(extension_id);
3984    let timeline: Vec<String> = entries
3985        .iter()
3986        .map(|e| format!("[seq={}] {} — {}", e.sequence, e.kind, e.summary))
3987        .collect();
3988
3989    let gating_entries = entries
3990        .iter()
3991        .filter(|e| e.kind == AuditEntryKind::GatingDecision)
3992        .collect::<Vec<_>>();
3993    let gating_summary = gating_entries.last().map_or_else(
3994        || "no gating decision recorded".to_string(),
3995        |e| e.summary.clone(),
3996    );
3997
3998    let verification_summary = if verification_passed {
3999        "all proofs passed".to_string()
4000    } else {
4001        "one or more proofs failed".to_string()
4002    };
4003
4004    InspectionRecord {
4005        extension_id: extension_id.to_string(),
4006        timeline,
4007        gating_summary,
4008        overlay_state: overlay_state.map(|s| s.to_string()),
4009        verification_summary,
4010    }
4011}
4012
4013/// Explain a gating decision in human-readable format.
4014pub fn explain_gating(verdict: &GatingVerdict) -> Vec<String> {
4015    let mut lines = Vec::new();
4016    lines.push(format!(
4017        "Decision: {} (confidence: {:.2})",
4018        verdict.decision, verdict.confidence.score
4019    ));
4020    for reason in &verdict.confidence.reasons {
4021        lines.push(format!(
4022            "  [{:+.2}] {} — {}",
4023            reason.delta, reason.code, reason.explanation
4024        ));
4025    }
4026    for code in &verdict.reason_codes {
4027        lines.push(format!("  REASON: {} — {}", code.code, code.remediation));
4028    }
4029    lines
4030}
4031
4032/// Format a patch proposal diff for operator review.
4033pub fn format_proposal_diff(proposal: &PatchProposal) -> Vec<String> {
4034    let mut lines = Vec::new();
4035    lines.push(format!(
4036        "Rule: {} ({} op(s), risk: {:?})",
4037        proposal.rule_id,
4038        proposal.op_count(),
4039        proposal.max_risk()
4040    ));
4041    if !proposal.rationale.is_empty() {
4042        lines.push(format!("Rationale: {}", proposal.rationale));
4043    }
4044    for (i, op) in proposal.ops.iter().enumerate() {
4045        lines.push(format!(
4046            "  Op {}: [{}] {}",
4047            i + 1,
4048            op.tag(),
4049            op_target_path(op)
4050        ));
4051    }
4052    lines
4053}
4054
4055// ---------------------------------------------------------------------------
4056// Forensic bundle export and incident handoff (bd-k5q5.9.7.4)
4057// ---------------------------------------------------------------------------
4058
4059/// A complete forensic bundle for incident analysis.
4060///
4061/// Contains everything needed to understand what happened during a repair:
4062/// the artifacts, proofs, audit trail, telemetry snapshot, and health signals.
4063#[derive(Debug, Clone)]
4064pub struct ForensicBundle {
4065    /// Extension identity.
4066    pub extension_id: String,
4067    /// Overlay artifact details.
4068    pub overlay: Option<OverlayArtifact>,
4069    /// Verification bundle.
4070    pub verification: Option<VerificationBundle>,
4071    /// Relevant audit entries.
4072    pub audit_entries: Vec<AuditEntry>,
4073    /// Telemetry snapshot for this extension.
4074    pub telemetry_points: Vec<TelemetryPoint>,
4075    /// Health report (if canary was active).
4076    pub health_report: Option<HealthReport>,
4077    /// Golden checksum manifest.
4078    pub checksum_manifest: Option<GoldenChecksumManifest>,
4079    /// Export timestamp (unix millis).
4080    pub exported_at_ms: u64,
4081}
4082
4083impl ForensicBundle {
4084    /// Number of audit entries in this bundle.
4085    pub fn audit_count(&self) -> usize {
4086        self.audit_entries.len()
4087    }
4088
4089    /// True if the bundle has verification evidence.
4090    pub const fn has_verification(&self) -> bool {
4091        self.verification.is_some()
4092    }
4093
4094    /// True if the bundle has health data.
4095    pub const fn has_health_data(&self) -> bool {
4096        self.health_report.is_some()
4097    }
4098}
4099
4100/// Build a forensic bundle from available state.
4101#[allow(clippy::too_many_arguments)]
4102pub fn build_forensic_bundle(
4103    extension_id: &str,
4104    overlay: Option<&OverlayArtifact>,
4105    verification: Option<&VerificationBundle>,
4106    ledger: &AuditLedger,
4107    collector: &TelemetryCollector,
4108    health_report: Option<&HealthReport>,
4109    checksum_manifest: Option<&GoldenChecksumManifest>,
4110    exported_at_ms: u64,
4111) -> ForensicBundle {
4112    let audit_entries = ledger
4113        .entries_for_extension(extension_id)
4114        .into_iter()
4115        .cloned()
4116        .collect();
4117
4118    let telemetry_points = collector
4119        .all_points()
4120        .iter()
4121        .filter(|p| {
4122            p.tags
4123                .iter()
4124                .any(|(k, v)| k == "extension_id" && v == extension_id)
4125        })
4126        .cloned()
4127        .collect();
4128
4129    ForensicBundle {
4130        extension_id: extension_id.to_string(),
4131        overlay: overlay.cloned(),
4132        verification: verification.cloned(),
4133        audit_entries,
4134        telemetry_points,
4135        health_report: health_report.cloned(),
4136        checksum_manifest: checksum_manifest.cloned(),
4137        exported_at_ms,
4138    }
4139}
4140
4141// ---------------------------------------------------------------------------
4142// Architecture ADR and threat-model rationale (bd-k5q5.9.8.1)
4143// ---------------------------------------------------------------------------
4144
4145/// Architecture Decision Record for the LISR system.
4146///
4147/// Records why LISR exists, what threats it addresses, and why fail-closed
4148/// constraints were chosen. This is embedded in code to prevent drift from
4149/// the original safety intent.
4150pub struct LisrAdr;
4151
4152impl LisrAdr {
4153    /// ADR identifier.
4154    pub const ID: &'static str = "ADR-LISR-001";
4155
4156    /// Title of the architecture decision.
4157    pub const TITLE: &'static str =
4158        "Dynamic Secure Extension Repair with Intent-Legible Self-Healing";
4159
4160    /// Why LISR exists.
4161    pub const CONTEXT: &'static str = "\
4162Extensions frequently break during updates when build artifacts (dist/) \
4163diverge from source (src/). Manual repair is slow, error-prone, and blocks \
4164the agent workflow. LISR provides automated repair within strict safety \
4165boundaries to restore extension functionality without human intervention.";
4166
4167    /// The core architectural decision.
4168    pub const DECISION: &'static str = "\
4169Adopt a layered repair pipeline with fail-closed defaults: \
4170(1) security policy framework bounds all repairs, \
4171(2) intent legibility analysis gates repair eligibility, \
4172(3) deterministic rules execute safe repairs, \
4173(4) model-assisted repairs are constrained to whitelisted primitives, \
4174(5) all repairs require structural + capability + semantic proof, \
4175(6) overlay deployment uses canary routing with health rollback, \
4176(7) every action is recorded in an append-only audit ledger, \
4177(8) governance checks are codified in the release process.";
4178
4179    /// Threats addressed by the design.
4180    pub const THREATS: &'static [&'static str] = &[
4181        "T1: Privilege escalation via repair adding new capabilities",
4182        "T2: Code injection via model-generated repair proposals",
4183        "T3: Supply-chain compromise via path traversal beyond extension root",
4184        "T4: Silent behavioral drift from opaque automated repairs",
4185        "T5: Loss of auditability preventing incident forensics",
4186        "T6: Governance decay from undocumented safety invariants",
4187    ];
4188
4189    /// Why fail-closed was chosen.
4190    pub const FAIL_CLOSED_RATIONALE: &'static str = "\
4191Any uncertainty in repair safety defaults to denial. A broken extension \
4192that remains broken is safer than a repaired extension that silently \
4193escalates privileges or introduces semantic drift. The cost of a false \
4194negative (missed repair) is low; the cost of a false positive (unsafe \
4195repair applied) is catastrophic.";
4196
4197    /// Key safety invariants enforced by the system.
4198    pub const INVARIANTS: &'static [&'static str] = &[
4199        "I1: Repairs never add capabilities absent from the original extension",
4200        "I2: All file paths stay within the extension root (monotonicity)",
4201        "I3: Model proposals are restricted to whitelisted PatchOp primitives",
4202        "I4: Structural validity is verified via SWC parse before activation",
4203        "I5: Every repair decision is recorded in the append-only audit ledger",
4204        "I6: Canary rollback triggers automatically on SLO violation",
4205    ];
4206}
4207
4208// ---------------------------------------------------------------------------
4209// Operator rollout and incident playbook (bd-k5q5.9.8.2)
4210// ---------------------------------------------------------------------------
4211
4212/// Operational procedure for repair mode selection.
4213pub struct OperatorPlaybook;
4214
4215impl OperatorPlaybook {
4216    /// Available repair modes and when to use each.
4217    pub const MODE_GUIDANCE: &'static [(&'static str, &'static str)] = &[
4218        (
4219            "Off",
4220            "Disable all automated repairs. Use when investigating a repair-related incident.",
4221        ),
4222        (
4223            "Suggest",
4224            "Log repair suggestions without applying. Use during initial rollout or audit.",
4225        ),
4226        (
4227            "AutoSafe",
4228            "Apply only safe (path-remap) repairs automatically. Default for production.",
4229        ),
4230        (
4231            "AutoStrict",
4232            "Apply both safe and aggressive repairs. Use only with explicit approval.",
4233        ),
4234    ];
4235
4236    /// Canary rollout procedure.
4237    pub const CANARY_PROCEDURE: &'static [&'static str] = &[
4238        "1. Create overlay artifact from repair pipeline",
4239        "2. Verify all proofs pass (structural, capability, semantic, conformance)",
4240        "3. Transition to Canary state with initial overlay_percent (e.g., 10%)",
4241        "4. Monitor health signals for canary_window_ms (default: 300_000)",
4242        "5. If SLO violated → automatic rollback",
4243        "6. If canary window passes → promote to Stable",
4244        "7. Record all transitions in audit ledger",
4245    ];
4246
4247    /// Incident response steps.
4248    pub const INCIDENT_RESPONSE: &'static [&'static str] = &[
4249        "1. Set repair_mode to Off immediately",
4250        "2. Export forensic bundle for the affected extension",
4251        "3. Review audit ledger for the repair timeline",
4252        "4. Check verification bundle for proof failures",
4253        "5. Inspect health signals that triggered rollback",
4254        "6. Root-cause the repair rule or model proposal",
4255        "7. File ADR amendment if safety invariant was violated",
4256    ];
4257}
4258
4259// ---------------------------------------------------------------------------
4260// Developer guide for adding safe repair rules (bd-k5q5.9.8.3)
4261// ---------------------------------------------------------------------------
4262
4263/// Developer guide for contributing new repair rules.
4264pub struct DeveloperGuide;
4265
4266impl DeveloperGuide {
4267    /// Steps to add a new deterministic repair rule.
4268    pub const ADD_RULE_CHECKLIST: &'static [&'static str] = &[
4269        "1. Define a RepairPattern variant with clear trigger semantics",
4270        "2. Define a RepairRule with: id, name, pattern, description, risk, ops",
4271        "3. Risk must be Safe unless the rule modifies code (then Aggressive)",
4272        "4. Add the rule to REPAIR_RULES static registry",
4273        "5. Implement matching logic in the extension loader",
4274        "6. Add unit tests covering: match, no-match, edge cases",
4275        "7. Add integration test with real extension fixture",
4276        "8. Verify monotonicity: rule must not escape extension root",
4277        "9. Verify capability monotonicity: rule must not add capabilities",
4278        "10. Run full conformance suite to check for regressions",
4279    ];
4280
4281    /// Anti-patterns to avoid.
4282    pub const ANTI_PATTERNS: &'static [(&'static str, &'static str)] = &[
4283        (
4284            "Unconstrained path rewriting",
4285            "Always validate target paths are within extension root via verify_repair_monotonicity()",
4286        ),
4287        (
4288            "Model-generated code execution",
4289            "Model proposals must use PatchOp primitives only — never eval or Function()",
4290        ),
4291        (
4292            "Skipping verification",
4293            "Every repair must pass the full VerificationBundle gate before activation",
4294        ),
4295        (
4296            "Mutable audit entries",
4297            "AuditLedger is append-only — never expose delete or update methods",
4298        ),
4299        (
4300            "Implicit capability grants",
4301            "compute_capability_proof() must show no Added deltas for the repair to pass",
4302        ),
4303    ];
4304
4305    /// Testing expectations for new rules.
4306    pub const TESTING_EXPECTATIONS: &'static [&'static str] = &[
4307        "Unit test: rule matches intended pattern and rejects non-matching input",
4308        "Unit test: generated PatchOps have correct risk classification",
4309        "Integration test: repair applied to real extension fixture succeeds",
4310        "Monotonicity test: repaired path stays within extension root",
4311        "Capability test: compute_capability_proof returns Monotonic",
4312        "Semantic test: compute_semantic_parity returns Equivalent or AcceptableDrift",
4313        "Conformance test: extension still passes conformance replay after repair",
4314    ];
4315}
4316
4317// ---------------------------------------------------------------------------
4318// Governance checklist for CI/release (bd-k5q5.9.8.4)
4319// ---------------------------------------------------------------------------
4320
4321/// A single governance check item.
4322#[derive(Debug, Clone)]
4323pub struct GovernanceCheck {
4324    /// Check identifier.
4325    pub id: String,
4326    /// Human-readable description.
4327    pub description: String,
4328    /// Whether the check passed.
4329    pub passed: bool,
4330    /// Detail message (empty if passed).
4331    pub detail: String,
4332}
4333
4334/// Result of running the governance checklist.
4335#[derive(Debug, Clone)]
4336pub struct GovernanceReport {
4337    /// Individual check results.
4338    pub checks: Vec<GovernanceCheck>,
4339    /// Number of checks that passed.
4340    pub passed_count: usize,
4341    /// Total number of checks.
4342    pub total_count: usize,
4343}
4344
4345impl GovernanceReport {
4346    /// True if all governance checks passed.
4347    pub const fn all_passed(&self) -> bool {
4348        self.passed_count == self.total_count
4349    }
4350
4351    /// Return failing checks.
4352    pub fn failures(&self) -> Vec<&GovernanceCheck> {
4353        self.checks.iter().filter(|c| !c.passed).collect()
4354    }
4355}
4356
4357/// Run the governance checklist against current system state.
4358///
4359/// Checks:
4360/// 1. Repair registry has at least one rule.
4361/// 2. All rule IDs are non-empty.
4362/// 3. ADR invariants are non-empty (documentation exists).
4363/// 4. Audit ledger is available (can be constructed).
4364/// 5. Telemetry collector is available (can be constructed).
4365/// 6. `VerificationBundle` checks all four proof layers.
4366pub fn run_governance_checklist() -> GovernanceReport {
4367    let mut checks = Vec::new();
4368
4369    // Check 1: Registry has rules.
4370    checks.push(GovernanceCheck {
4371        id: "GOV-001".to_string(),
4372        description: "Repair registry contains at least one rule".to_string(),
4373        passed: !REPAIR_RULES.is_empty(),
4374        detail: if REPAIR_RULES.is_empty() {
4375            "REPAIR_RULES is empty".to_string()
4376        } else {
4377            String::new()
4378        },
4379    });
4380
4381    // Check 2: All rule IDs are non-empty.
4382    let empty_ids: Vec<_> = REPAIR_RULES
4383        .iter()
4384        .filter(|r| r.id.is_empty())
4385        .map(|r| r.description)
4386        .collect();
4387    checks.push(GovernanceCheck {
4388        id: "GOV-002".to_string(),
4389        description: "All repair rules have non-empty IDs".to_string(),
4390        passed: empty_ids.is_empty(),
4391        detail: if empty_ids.is_empty() {
4392            String::new()
4393        } else {
4394            format!("Rules with empty IDs: {empty_ids:?}")
4395        },
4396    });
4397
4398    // Check 3: ADR exists.
4399    checks.push(GovernanceCheck {
4400        id: "GOV-003".to_string(),
4401        description: "Architecture ADR is defined".to_string(),
4402        passed: !LisrAdr::INVARIANTS.is_empty(),
4403        detail: String::new(),
4404    });
4405
4406    // Check 4: ADR threats are documented.
4407    checks.push(GovernanceCheck {
4408        id: "GOV-004".to_string(),
4409        description: "Threat model is documented".to_string(),
4410        passed: !LisrAdr::THREATS.is_empty(),
4411        detail: String::new(),
4412    });
4413
4414    // Check 5: Governance invariant count matches expected.
4415    let invariant_count = LisrAdr::INVARIANTS.len();
4416    checks.push(GovernanceCheck {
4417        id: "GOV-005".to_string(),
4418        description: "Safety invariants cover all critical areas (>=6)".to_string(),
4419        passed: invariant_count >= 6,
4420        detail: if invariant_count < 6 {
4421            format!("Only {invariant_count} invariants defined (need >=6)")
4422        } else {
4423            String::new()
4424        },
4425    });
4426
4427    // Check 6: Developer guide has testing expectations.
4428    checks.push(GovernanceCheck {
4429        id: "GOV-006".to_string(),
4430        description: "Developer testing expectations are documented".to_string(),
4431        passed: !DeveloperGuide::TESTING_EXPECTATIONS.is_empty(),
4432        detail: String::new(),
4433    });
4434
4435    let passed_count = checks.iter().filter(|c| c.passed).count();
4436    let total_count = checks.len();
4437
4438    GovernanceReport {
4439        checks,
4440        passed_count,
4441        total_count,
4442    }
4443}
4444
4445#[derive(Debug, Clone)]
4446pub struct PiJsRuntimeConfig {
4447    pub cwd: String,
4448    pub args: Vec<String>,
4449    pub env: HashMap<String, String>,
4450    pub limits: PiJsRuntimeLimits,
4451    /// Controls the auto-repair pipeline behavior. Default: `AutoSafe`.
4452    pub repair_mode: RepairMode,
4453    /// UNSAFE escape hatch: enable synchronous process execution used by
4454    /// `node:child_process` sync APIs (`execSync`/`spawnSync`/`execFileSync`).
4455    ///
4456    /// Security default is `false` so extensions cannot bypass capability/risk
4457    /// mediation through direct synchronous subprocess execution.
4458    pub allow_unsafe_sync_exec: bool,
4459    /// Explicitly deny environment variable access regardless of `is_env_var_allowed` blocklist.
4460    /// Used to enforce `ExtensionPolicy` with `deny_caps=[\"env\"]` for synchronous `pi.env` access.
4461    pub deny_env: bool,
4462    /// Directory for persistent transpiled-source disk cache.
4463    ///
4464    /// When set, transpiled module sources are cached on disk keyed by a
4465    /// content-aware hash so that SWC transpilation is skipped across process
4466    /// restarts. Defaults to `~/.pi/agent/cache/modules/` (overridden by
4467    /// `PIJS_MODULE_CACHE_DIR`). Set to `None` to disable.
4468    pub disk_cache_dir: Option<PathBuf>,
4469}
4470
4471impl PiJsRuntimeConfig {
4472    /// Convenience: check if repairs should be applied.
4473    pub const fn auto_repair_enabled(&self) -> bool {
4474        self.repair_mode.should_apply()
4475    }
4476}
4477
4478impl Default for PiJsRuntimeConfig {
4479    fn default() -> Self {
4480        Self {
4481            cwd: ".".to_string(),
4482            args: Vec::new(),
4483            env: HashMap::new(),
4484            limits: PiJsRuntimeLimits::default(),
4485            repair_mode: RepairMode::default(),
4486            allow_unsafe_sync_exec: false,
4487            deny_env: true,
4488            disk_cache_dir: runtime_disk_cache_dir(),
4489        }
4490    }
4491}
4492
4493/// Resolve the persistent module disk cache directory.
4494///
4495/// Priority: `PIJS_MODULE_CACHE_DIR` env var > `~/.pi/agent/cache/modules/`.
4496/// Set `PIJS_MODULE_CACHE_DIR=""` to explicitly disable the disk cache.
4497fn runtime_disk_cache_dir() -> Option<PathBuf> {
4498    if let Some(raw) = std::env::var_os("PIJS_MODULE_CACHE_DIR") {
4499        return if raw.is_empty() {
4500            None
4501        } else {
4502            Some(PathBuf::from(raw))
4503        };
4504    }
4505    dirs::home_dir().map(|home| home.join(".pi").join("agent").join("cache").join("modules"))
4506}
4507
4508#[derive(Debug)]
4509struct InterruptBudget {
4510    configured: Option<u64>,
4511    remaining: std::cell::Cell<Option<u64>>,
4512    tripped: std::cell::Cell<bool>,
4513}
4514
4515impl InterruptBudget {
4516    const fn new(configured: Option<u64>) -> Self {
4517        Self {
4518            configured,
4519            remaining: std::cell::Cell::new(None),
4520            tripped: std::cell::Cell::new(false),
4521        }
4522    }
4523
4524    fn reset(&self) {
4525        self.remaining.set(self.configured);
4526        self.tripped.set(false);
4527    }
4528
4529    fn on_interrupt(&self) -> bool {
4530        let Some(remaining) = self.remaining.get() else {
4531            return false;
4532        };
4533        if remaining == 0 {
4534            self.tripped.set(true);
4535            return true;
4536        }
4537        self.remaining.set(Some(remaining - 1));
4538        false
4539    }
4540
4541    fn did_trip(&self) -> bool {
4542        self.tripped.get()
4543    }
4544
4545    fn clear_trip(&self) {
4546        self.tripped.set(false);
4547    }
4548}
4549
4550#[derive(Debug, Default)]
4551struct HostcallTracker {
4552    pending: HashSet<String>,
4553    call_to_timer: HashMap<String, u64>,
4554    timer_to_call: HashMap<u64, String>,
4555    enqueued_at_ms: HashMap<String, u64>,
4556}
4557
4558enum HostcallCompletion {
4559    Delivered {
4560        #[allow(dead_code)]
4561        timer_id: Option<u64>,
4562    },
4563    Unknown,
4564}
4565
4566impl HostcallTracker {
4567    fn clear(&mut self) {
4568        self.pending.clear();
4569        self.call_to_timer.clear();
4570        self.timer_to_call.clear();
4571        self.enqueued_at_ms.clear();
4572    }
4573
4574    fn register(&mut self, call_id: String, timer_id: Option<u64>, enqueued_at_ms: u64) {
4575        self.pending.insert(call_id.clone());
4576        if let Some(timer_id) = timer_id {
4577            self.call_to_timer.insert(call_id.clone(), timer_id);
4578            self.timer_to_call.insert(timer_id, call_id.clone());
4579        }
4580        // Last insert consumes call_id, avoiding one clone.
4581        self.enqueued_at_ms.insert(call_id, enqueued_at_ms);
4582    }
4583
4584    fn pending_count(&self) -> usize {
4585        self.pending.len()
4586    }
4587
4588    fn is_pending(&self, call_id: &str) -> bool {
4589        self.pending.contains(call_id)
4590    }
4591
4592    fn queue_wait_ms(&self, call_id: &str, now_ms: u64) -> Option<u64> {
4593        self.enqueued_at_ms
4594            .get(call_id)
4595            .copied()
4596            .map(|enqueued| now_ms.saturating_sub(enqueued))
4597    }
4598
4599    fn on_complete(&mut self, call_id: &str) -> HostcallCompletion {
4600        if !self.pending.remove(call_id) {
4601            return HostcallCompletion::Unknown;
4602        }
4603
4604        let timer_id = self.call_to_timer.remove(call_id);
4605        self.enqueued_at_ms.remove(call_id);
4606        if let Some(timer_id) = timer_id {
4607            self.timer_to_call.remove(&timer_id);
4608        }
4609
4610        HostcallCompletion::Delivered { timer_id }
4611    }
4612
4613    fn take_timed_out_call(&mut self, timer_id: u64) -> Option<String> {
4614        let call_id = self.timer_to_call.remove(&timer_id)?;
4615        self.call_to_timer.remove(&call_id);
4616        self.enqueued_at_ms.remove(&call_id);
4617        if !self.pending.remove(&call_id) {
4618            return None;
4619        }
4620        Some(call_id)
4621    }
4622}
4623
4624fn enqueue_hostcall_request_with_backpressure<C: SchedulerClock>(
4625    queue: &HostcallQueue,
4626    tracker: &Rc<RefCell<HostcallTracker>>,
4627    scheduler: &Rc<RefCell<Scheduler<C>>>,
4628    request: HostcallRequest,
4629) {
4630    let call_id = request.call_id.clone();
4631    let trace_id = request.trace_id;
4632    let extension_id = request.extension_id.clone();
4633    match queue.borrow_mut().push_back(request) {
4634        HostcallQueueEnqueueResult::FastPath { depth } => {
4635            tracing::trace!(
4636                event = "pijs.hostcall.queue.fast_path",
4637                call_id = %call_id,
4638                trace_id,
4639                extension_id = ?extension_id,
4640                depth,
4641                "Hostcall queued on fast-path ring"
4642            );
4643        }
4644        HostcallQueueEnqueueResult::OverflowPath {
4645            depth,
4646            overflow_depth,
4647        } => {
4648            tracing::debug!(
4649                event = "pijs.hostcall.queue.overflow_path",
4650                call_id = %call_id,
4651                trace_id,
4652                extension_id = ?extension_id,
4653                depth,
4654                overflow_depth,
4655                "Hostcall spilled to overflow queue"
4656            );
4657        }
4658        HostcallQueueEnqueueResult::Rejected {
4659            depth,
4660            overflow_depth,
4661        } => {
4662            let completion = tracker.borrow_mut().on_complete(&call_id);
4663            if let HostcallCompletion::Delivered { timer_id } = completion {
4664                if let Some(timer_id) = timer_id {
4665                    let _ = scheduler.borrow_mut().clear_timeout(timer_id);
4666                }
4667                scheduler.borrow_mut().enqueue_hostcall_complete(
4668                    call_id.clone(),
4669                    HostcallOutcome::Error {
4670                        code: "overloaded".to_string(),
4671                        message: format!(
4672                            "Hostcall queue overloaded (depth={depth}, overflow_depth={overflow_depth})"
4673                        ),
4674                    },
4675                );
4676            }
4677            tracing::warn!(
4678                event = "pijs.hostcall.queue.rejected",
4679                call_id = %call_id,
4680                trace_id,
4681                extension_id = ?extension_id,
4682                depth,
4683                overflow_depth,
4684                "Hostcall rejected by queue backpressure policy"
4685            );
4686        }
4687    }
4688}
4689
4690// ============================================================================
4691// PiJS Module Loader (TypeScript + virtual modules)
4692// ============================================================================
4693
4694#[derive(Debug)]
4695struct PiJsModuleState {
4696    /// Immutable built-in virtual modules shared across runtimes.
4697    static_virtual_modules: Arc<HashMap<String, String>>,
4698    /// Runtime-local virtual modules generated by repairs / dynamic stubs.
4699    dynamic_virtual_modules: HashMap<String, String>,
4700    /// Tracked named exports for dynamic virtual modules keyed by specifier.
4701    dynamic_virtual_named_exports: HashMap<String, BTreeSet<String>>,
4702    compiled_sources: HashMap<String, CompiledModuleCacheEntry>,
4703    module_cache_counters: ModuleCacheCounters,
4704    /// Repair mode propagated from `PiJsRuntimeConfig` so the resolver can
4705    /// gate fallback patterns without executing any broken code.
4706    repair_mode: RepairMode,
4707    /// Extension root directories used to detect monorepo escape (Pattern 3).
4708    /// Populated as extensions are loaded via [`PiJsRuntime::add_extension_root`].
4709    extension_roots: Vec<PathBuf>,
4710    /// Source-tier classification per extension root. Used by Pattern 4 to
4711    /// avoid proxy stubs for official/first-party extensions.
4712    extension_root_tiers: HashMap<PathBuf, ProxyStubSourceTier>,
4713    /// Package scope (`@scope`) per extension root (when discoverable from
4714    /// package.json name). Pattern 4 allows same-scope packages.
4715    extension_root_scopes: HashMap<PathBuf, String>,
4716    /// Shared handle for recording repair events from the resolver.
4717    repair_events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>>,
4718    /// Directory for persistent transpiled-source disk cache.
4719    disk_cache_dir: Option<PathBuf>,
4720}
4721
4722#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4723enum ProxyStubSourceTier {
4724    Official,
4725    Community,
4726    Unknown,
4727}
4728
4729#[derive(Debug, Clone)]
4730struct CompiledModuleCacheEntry {
4731    cache_key: Option<String>,
4732    source: Arc<[u8]>,
4733}
4734
4735#[derive(Debug, Clone, Copy, Default)]
4736struct ModuleCacheCounters {
4737    hits: u64,
4738    misses: u64,
4739    invalidations: u64,
4740    disk_hits: u64,
4741}
4742
4743impl PiJsModuleState {
4744    fn new() -> Self {
4745        Self {
4746            static_virtual_modules: default_virtual_modules_shared(),
4747            dynamic_virtual_modules: HashMap::new(),
4748            dynamic_virtual_named_exports: HashMap::new(),
4749            compiled_sources: HashMap::new(),
4750            module_cache_counters: ModuleCacheCounters::default(),
4751            repair_mode: RepairMode::default(),
4752            extension_roots: Vec::new(),
4753            extension_root_tiers: HashMap::new(),
4754            extension_root_scopes: HashMap::new(),
4755            repair_events: Arc::new(std::sync::Mutex::new(Vec::new())),
4756            disk_cache_dir: None,
4757        }
4758    }
4759
4760    const fn with_repair_mode(mut self, mode: RepairMode) -> Self {
4761        self.repair_mode = mode;
4762        self
4763    }
4764
4765    fn with_repair_events(
4766        mut self,
4767        events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>>,
4768    ) -> Self {
4769        self.repair_events = events;
4770        self
4771    }
4772
4773    fn with_disk_cache_dir(mut self, dir: Option<PathBuf>) -> Self {
4774        self.disk_cache_dir = dir;
4775        self
4776    }
4777}
4778
4779#[derive(Clone, Debug)]
4780struct PiJsResolver {
4781    state: Rc<RefCell<PiJsModuleState>>,
4782}
4783
4784fn canonical_node_builtin(spec: &str) -> Option<&'static str> {
4785    match spec {
4786        "fs" | "node:fs" => Some("node:fs"),
4787        "fs/promises" | "node:fs/promises" => Some("node:fs/promises"),
4788        "path" | "node:path" => Some("node:path"),
4789        "os" | "node:os" => Some("node:os"),
4790        "child_process" | "node:child_process" => Some("node:child_process"),
4791        "crypto" | "node:crypto" => Some("node:crypto"),
4792        "http" | "node:http" => Some("node:http"),
4793        "https" | "node:https" => Some("node:https"),
4794        "http2" | "node:http2" => Some("node:http2"),
4795        "util" | "node:util" => Some("node:util"),
4796        "readline" | "node:readline" => Some("node:readline"),
4797        "url" | "node:url" => Some("node:url"),
4798        "net" | "node:net" => Some("node:net"),
4799        "events" | "node:events" => Some("node:events"),
4800        "buffer" | "node:buffer" => Some("node:buffer"),
4801        "assert" | "node:assert" => Some("node:assert"),
4802        "stream" | "node:stream" => Some("node:stream"),
4803        "stream/web" | "node:stream/web" => Some("node:stream/web"),
4804        "module" | "node:module" => Some("node:module"),
4805        "string_decoder" | "node:string_decoder" => Some("node:string_decoder"),
4806        "querystring" | "node:querystring" => Some("node:querystring"),
4807        "process" | "node:process" => Some("node:process"),
4808        "stream/promises" | "node:stream/promises" => Some("node:stream/promises"),
4809        "constants" | "node:constants" => Some("node:constants"),
4810        "tls" | "node:tls" => Some("node:tls"),
4811        "tty" | "node:tty" => Some("node:tty"),
4812        "zlib" | "node:zlib" => Some("node:zlib"),
4813        "perf_hooks" | "node:perf_hooks" => Some("node:perf_hooks"),
4814        "vm" | "node:vm" => Some("node:vm"),
4815        "v8" | "node:v8" => Some("node:v8"),
4816        "worker_threads" | "node:worker_threads" => Some("node:worker_threads"),
4817        _ => None,
4818    }
4819}
4820
4821fn is_network_specifier(spec: &str) -> bool {
4822    spec.starts_with("http://")
4823        || spec.starts_with("https://")
4824        || spec.starts_with("http:")
4825        || spec.starts_with("https:")
4826}
4827
4828fn is_bare_package_specifier(spec: &str) -> bool {
4829    if spec.starts_with("./")
4830        || spec.starts_with("../")
4831        || spec.starts_with('/')
4832        || spec.starts_with("file://")
4833        || spec.starts_with("node:")
4834    {
4835        return false;
4836    }
4837    !spec.contains(':')
4838}
4839
4840fn unsupported_module_specifier_message(spec: &str) -> String {
4841    if is_network_specifier(spec) {
4842        return format!("Network module imports are not supported in PiJS: {spec}");
4843    }
4844    if is_bare_package_specifier(spec) {
4845        return format!("Package module specifiers are not supported in PiJS: {spec}");
4846    }
4847    format!("Unsupported module specifier: {spec}")
4848}
4849
4850fn split_scoped_package(spec: &str) -> Option<(&str, &str)> {
4851    if !spec.starts_with('@') {
4852        return None;
4853    }
4854    let mut parts = spec.split('/');
4855    let scope = parts.next()?;
4856    let package = parts.next()?;
4857    Some((scope, package))
4858}
4859
4860fn package_scope(spec: &str) -> Option<&str> {
4861    split_scoped_package(spec).map(|(scope, _)| scope)
4862}
4863
4864fn read_extension_package_scope(root: &Path) -> Option<String> {
4865    let package_json = root.join("package.json");
4866    let raw = fs::read_to_string(package_json).ok()?;
4867    let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
4868    let name = parsed.get("name").and_then(serde_json::Value::as_str)?;
4869    let (scope, _) = split_scoped_package(name.trim())?;
4870    Some(scope.to_string())
4871}
4872
4873fn root_path_hint_tier(root: &Path) -> ProxyStubSourceTier {
4874    let normalized = root
4875        .to_string_lossy()
4876        .replace('\\', "/")
4877        .to_ascii_lowercase();
4878    let community_hints = [
4879        "/community/",
4880        "/npm/",
4881        "/agents-",
4882        "/third-party",
4883        "/third_party",
4884        "/plugins-community/",
4885    ];
4886    if community_hints.iter().any(|hint| normalized.contains(hint)) {
4887        return ProxyStubSourceTier::Community;
4888    }
4889
4890    let official_hints = ["/official-pi-mono/", "/plugins-official/", "/official/"];
4891    if official_hints.iter().any(|hint| normalized.contains(hint)) {
4892        return ProxyStubSourceTier::Official;
4893    }
4894
4895    ProxyStubSourceTier::Unknown
4896}
4897
4898fn classify_proxy_stub_source_tier(extension_id: &str, root: &Path) -> ProxyStubSourceTier {
4899    let id = extension_id.trim().to_ascii_lowercase();
4900    if id.starts_with("community/")
4901        || id.starts_with("npm/")
4902        || id.starts_with("agents-")
4903        || id.starts_with("plugins-community/")
4904        || id.starts_with("third-party")
4905        || id.starts_with("third_party")
4906    {
4907        return ProxyStubSourceTier::Community;
4908    }
4909
4910    if id.starts_with("plugins-official/") {
4911        return ProxyStubSourceTier::Official;
4912    }
4913
4914    root_path_hint_tier(root)
4915}
4916
4917fn resolve_extension_root_for_base<'a>(base: &str, roots: &'a [PathBuf]) -> Option<&'a PathBuf> {
4918    let base_path = Path::new(base);
4919    let canonical_base = crate::extensions::safe_canonicalize(base_path);
4920    roots
4921        .iter()
4922        .filter(|root| {
4923            let canonical_root = crate::extensions::safe_canonicalize(root);
4924            canonical_base.starts_with(&canonical_root)
4925        })
4926        .max_by_key(|root| root.components().count())
4927}
4928
4929fn is_proxy_blocklisted_package(spec: &str) -> bool {
4930    if spec.starts_with("node:") {
4931        return true;
4932    }
4933
4934    let top = spec.split('/').next().unwrap_or(spec);
4935    matches!(
4936        top,
4937        "fs" | "path"
4938            | "child_process"
4939            | "net"
4940            | "http"
4941            | "https"
4942            | "crypto"
4943            | "tls"
4944            | "dgram"
4945            | "dns"
4946            | "vm"
4947            | "worker_threads"
4948            | "cluster"
4949            | "module"
4950            | "os"
4951            | "process"
4952    )
4953}
4954
4955fn is_proxy_allowlisted_package(spec: &str) -> bool {
4956    const ALLOWLIST_SCOPES: &[&str] = &["@sourcegraph", "@marckrenn", "@aliou"];
4957    const ALLOWLIST_PACKAGES: &[&str] = &[
4958        "openai",
4959        "adm-zip",
4960        "linkedom",
4961        "p-limit",
4962        "unpdf",
4963        "node-pty",
4964        "chokidar",
4965        "jsdom",
4966        "turndown",
4967        "beautiful-mermaid",
4968    ];
4969
4970    if ALLOWLIST_PACKAGES.contains(&spec) {
4971        return true;
4972    }
4973
4974    if let Some((scope, package)) = split_scoped_package(spec) {
4975        if ALLOWLIST_SCOPES.contains(&scope) {
4976            return true;
4977        }
4978
4979        // Generic ecosystem package pattern (`@scope/pi-*`).
4980        if package.starts_with("pi-") {
4981            return true;
4982        }
4983    }
4984
4985    false
4986}
4987
4988// Limit extension source size to prevent OOM/DoS during load.
4989const MAX_MODULE_SOURCE_BYTES: u64 = 1024 * 1024 * 1024;
4990
4991fn should_auto_stub_package(
4992    spec: &str,
4993    base: &str,
4994    extension_roots: &[PathBuf],
4995    extension_root_tiers: &HashMap<PathBuf, ProxyStubSourceTier>,
4996    extension_root_scopes: &HashMap<PathBuf, String>,
4997) -> bool {
4998    if !is_bare_package_specifier(spec) || is_proxy_blocklisted_package(spec) {
4999        return false;
5000    }
5001
5002    let (tier, root_for_scope) = resolve_extension_root_for_base(base, extension_roots).map_or(
5003        (ProxyStubSourceTier::Unknown, None),
5004        |root| {
5005            (
5006                extension_root_tiers
5007                    .get(root)
5008                    .copied()
5009                    .unwrap_or(ProxyStubSourceTier::Unknown),
5010                Some(root),
5011            )
5012        },
5013    );
5014
5015    let same_scope = if let Some(spec_scope) = package_scope(spec)
5016        && let Some(root) = root_for_scope
5017        && let Some(extension_scope) = extension_root_scopes.get(root)
5018    {
5019        extension_scope == spec_scope
5020    } else {
5021        false
5022    };
5023
5024    if is_proxy_allowlisted_package(spec) {
5025        return true;
5026    }
5027
5028    if same_scope {
5029        return true;
5030    }
5031
5032    // Aggressive repair mode (Pattern 4) is only enabled in AutoStrict. In that
5033    // mode, community and unknown extension sources are allowed to auto-stub any
5034    // unresolved non-blocklisted package so registration can proceed deterministically.
5035    //
5036    // Official first-party extensions keep a narrower posture: only curated
5037    // allowlist or same-scope packages are stubbed.
5038    tier != ProxyStubSourceTier::Official
5039}
5040
5041fn is_valid_js_export_name(name: &str) -> bool {
5042    let mut chars = name.chars();
5043    let Some(first) = chars.next() else {
5044        return false;
5045    };
5046    let is_start = first == '_' || first == '$' || first.is_ascii_alphabetic();
5047    if !is_start {
5048        return false;
5049    }
5050    chars.all(|c| c == '_' || c == '$' || c.is_ascii_alphanumeric())
5051}
5052
5053fn generate_proxy_stub_module(spec: &str, named_exports: &BTreeSet<String>) -> String {
5054    let spec_literal = serde_json::to_string(spec).unwrap_or_else(|_| "\"<unknown>\"".to_string());
5055    let mut source = format!(
5056        r"// Auto-generated npm proxy stub (Pattern 4) for {spec_literal}
5057const __pkg = {spec_literal};
5058const __handler = {{
5059  get(_target, prop) {{
5060    if (typeof prop === 'symbol') {{
5061      if (prop === Symbol.toPrimitive) return () => '';
5062      return undefined;
5063    }}
5064    if (prop === '__esModule') return true;
5065    if (prop === 'default') return __stub;
5066    if (prop === 'toString') return () => '';
5067    if (prop === 'valueOf') return () => '';
5068    if (prop === 'name') return __pkg;
5069    // Promise assimilation guard: do not pretend to be then-able.
5070    if (prop === 'then') return undefined;
5071    return __stub;
5072  }},
5073  apply() {{ return __stub; }},
5074  construct() {{ return __stub; }},
5075  has() {{ return false; }},
5076  ownKeys() {{ return []; }},
5077  getOwnPropertyDescriptor() {{
5078    return {{ configurable: true, enumerable: false }};
5079  }},
5080}};
5081const __stub = new Proxy(function __pijs_noop() {{}}, __handler);
5082"
5083    );
5084
5085    for name in named_exports {
5086        if name == "default" || name == "__esModule" || !is_valid_js_export_name(name) {
5087            continue;
5088        }
5089        let _ = writeln!(source, "export const {name} = __stub;");
5090    }
5091
5092    source.push_str("export default __stub;\n");
5093    source.push_str("export const __pijs_proxy_stub = __stub;\n");
5094    source.push_str("export const __esModule = true;\n");
5095    source
5096}
5097
5098fn builtin_specifier_aliases(spec: &str, canonical: &str) -> Vec<String> {
5099    let mut aliases = Vec::new();
5100    let mut seen = HashSet::new();
5101    let mut push_alias = |candidate: &str| {
5102        if candidate.is_empty() {
5103            return;
5104        }
5105        if seen.insert(candidate.to_string()) {
5106            aliases.push(candidate.to_string());
5107        }
5108    };
5109
5110    push_alias(spec);
5111    push_alias(canonical);
5112
5113    if let Some(bare) = spec.strip_prefix("node:") {
5114        push_alias(bare);
5115    }
5116    if let Some(bare) = canonical.strip_prefix("node:") {
5117        push_alias(bare);
5118    }
5119
5120    aliases
5121}
5122
5123fn extract_builtin_import_names(source: &str, spec: &str, canonical: &str) -> BTreeSet<String> {
5124    let mut names = BTreeSet::new();
5125    for alias in builtin_specifier_aliases(spec, canonical) {
5126        for name in extract_import_names(source, &alias) {
5127            if name == "default" || name == "__esModule" {
5128                continue;
5129            }
5130            if is_valid_js_export_name(&name) {
5131                names.insert(name);
5132            }
5133        }
5134    }
5135    names
5136}
5137
5138fn generate_builtin_compat_overlay_module(
5139    canonical: &str,
5140    named_exports: &BTreeSet<String>,
5141) -> String {
5142    let spec_literal =
5143        serde_json::to_string(canonical).unwrap_or_else(|_| "\"node:unknown\"".to_string());
5144    let mut source = format!(
5145        r"// Auto-generated Node builtin compatibility overlay for {canonical}
5146import * as __pijs_builtin_ns from {spec_literal};
5147const __pijs_builtin_default =
5148  __pijs_builtin_ns.default !== undefined ? __pijs_builtin_ns.default : __pijs_builtin_ns;
5149export default __pijs_builtin_default;
5150"
5151    );
5152
5153    for name in named_exports {
5154        if !is_valid_js_export_name(name) || name == "default" || name == "__esModule" {
5155            continue;
5156        }
5157        let _ = writeln!(
5158            source,
5159            "export const {name} = __pijs_builtin_ns.{name} !== undefined ? __pijs_builtin_ns.{name} : (__pijs_builtin_default && __pijs_builtin_default.{name});"
5160        );
5161    }
5162
5163    source.push_str("export const __esModule = true;\n");
5164    source
5165}
5166
5167fn builtin_overlay_module_key(base: &str, canonical: &str) -> String {
5168    let mut hasher = Sha256::new();
5169    hasher.update(base.as_bytes());
5170    let digest = format!("{:x}", hasher.finalize());
5171    let short = &digest[..16];
5172    format!("pijs-compat://builtin/{canonical}/{short}")
5173}
5174
5175fn maybe_register_builtin_compat_overlay(
5176    state: &mut PiJsModuleState,
5177    base: &str,
5178    spec: &str,
5179    canonical: &str,
5180) -> Option<String> {
5181    if !canonical.starts_with("node:") {
5182        return None;
5183    }
5184
5185    let source = std::fs::read_to_string(base).ok()?;
5186    let extracted_names = extract_builtin_import_names(&source, spec, canonical);
5187    if extracted_names.is_empty() {
5188        return None;
5189    }
5190
5191    let overlay_key = builtin_overlay_module_key(base, canonical);
5192    let needs_rebuild = state
5193        .dynamic_virtual_named_exports
5194        .get(&overlay_key)
5195        .is_none_or(|existing| existing != &extracted_names)
5196        || !state.dynamic_virtual_modules.contains_key(&overlay_key);
5197
5198    if needs_rebuild {
5199        state
5200            .dynamic_virtual_named_exports
5201            .insert(overlay_key.clone(), extracted_names.clone());
5202        let overlay = generate_builtin_compat_overlay_module(canonical, &extracted_names);
5203        state
5204            .dynamic_virtual_modules
5205            .insert(overlay_key.clone(), overlay);
5206        if state.compiled_sources.remove(&overlay_key).is_some() {
5207            state.module_cache_counters.invalidations =
5208                state.module_cache_counters.invalidations.saturating_add(1);
5209        }
5210    }
5211
5212    Some(overlay_key)
5213}
5214
5215impl JsModuleResolver for PiJsResolver {
5216    #[allow(clippy::too_many_lines)]
5217    fn resolve(&mut self, _ctx: &Ctx<'_>, base: &str, name: &str) -> rquickjs::Result<String> {
5218        let spec = name.trim();
5219        if spec.is_empty() {
5220            return Err(rquickjs::Error::new_resolving(base, name));
5221        }
5222
5223        // Alias bare Node.js builtins to their node: prefixed virtual modules.
5224        let canonical = canonical_node_builtin(spec).unwrap_or(spec);
5225        let compat_scan_mode = is_global_compat_scan_mode();
5226
5227        let repair_mode = {
5228            let mut state = self.state.borrow_mut();
5229            if state.dynamic_virtual_modules.contains_key(canonical)
5230                || state.static_virtual_modules.contains_key(canonical)
5231            {
5232                if compat_scan_mode
5233                    && let Some(overlay_key) =
5234                        maybe_register_builtin_compat_overlay(&mut state, base, spec, canonical)
5235                {
5236                    tracing::debug!(
5237                        event = "pijs.compat.builtin_overlay",
5238                        base = %base,
5239                        specifier = %spec,
5240                        canonical = %canonical,
5241                        overlay = %overlay_key,
5242                        "compat overlay for builtin named imports"
5243                    );
5244                    return Ok(overlay_key);
5245                }
5246                return Ok(canonical.to_string());
5247            }
5248            state.repair_mode
5249        };
5250
5251        let roots = self.state.borrow().extension_roots.clone();
5252        if let Some(path) = resolve_module_path(base, spec, repair_mode, &roots) {
5253            // Canonicalize to collapse `.` / `..` segments and normalise
5254            // separators (Windows backslashes → forward slashes for QuickJS).
5255            let canonical = crate::extensions::safe_canonicalize(&path);
5256
5257            let is_safe = roots.iter().any(|root| {
5258                let canonical_root = crate::extensions::safe_canonicalize(root);
5259                canonical.starts_with(&canonical_root)
5260            });
5261
5262            if !is_safe {
5263                tracing::warn!(
5264                    event = "pijs.resolve.escape",
5265                    base = %base,
5266                    specifier = %spec,
5267                    resolved = %canonical.display(),
5268                    "import resolved to path outside extension roots"
5269                );
5270                return Err(rquickjs::Error::new_resolving(base, name));
5271            }
5272
5273            return Ok(canonical.to_string_lossy().replace('\\', "/"));
5274        }
5275
5276        // Pattern 3 (bd-k5q5.8.4): monorepo sibling module stubs.
5277        // When a relative import escapes all known extension roots and
5278        // the repair mode is aggressive, generate a virtual stub module
5279        // containing no-op exports matching the import declaration.
5280        if spec.starts_with('.') && repair_mode.allows_aggressive() {
5281            let state = self.state.borrow();
5282            let roots = state.extension_roots.clone();
5283            drop(state);
5284
5285            if let Some(escaped_path) = detect_monorepo_escape(base, spec, &roots) {
5286                // Read the importing file to extract import names.
5287                let source = std::fs::read_to_string(base).unwrap_or_default();
5288                let names = extract_import_names(&source, spec);
5289
5290                let stub = generate_monorepo_stub(&names);
5291                let virtual_key = format!("pijs-repair://monorepo/{}", escaped_path.display());
5292
5293                tracing::info!(
5294                    event = "pijs.repair.monorepo_escape",
5295                    base = %base,
5296                    specifier = %spec,
5297                    resolved = %escaped_path.display(),
5298                    exports = ?names,
5299                    "auto-repair: generated monorepo escape stub"
5300                );
5301
5302                // Record repair event.
5303                let state = self.state.borrow();
5304                if let Ok(mut events) = state.repair_events.lock() {
5305                    events.push(ExtensionRepairEvent {
5306                        extension_id: String::new(),
5307                        pattern: RepairPattern::MonorepoEscape,
5308                        original_error: format!(
5309                            "monorepo escape: {} from {base}",
5310                            escaped_path.display()
5311                        ),
5312                        repair_action: format!(
5313                            "generated stub with {} exports: {virtual_key}",
5314                            names.len()
5315                        ),
5316                        success: true,
5317                        timestamp_ms: 0,
5318                    });
5319                }
5320                drop(state);
5321
5322                // Register and return the virtual module.
5323                let mut state = self.state.borrow_mut();
5324                state
5325                    .dynamic_virtual_modules
5326                    .insert(virtual_key.clone(), stub);
5327                return Ok(virtual_key);
5328            }
5329        }
5330
5331        // Pattern 4 (bd-k5q5.8.5): proxy-based stubs for allowlisted npm deps.
5332        // This fires in aggressive mode, and also in compatibility-scan mode
5333        // (ext-conformance / PI_EXT_COMPAT_SCAN) so corpus runs can continue
5334        // past optional or non-essential package holes deterministically.
5335        // Blocklisted/system packages are never stubbed. Existing hand-written
5336        // virtual modules continue to win because we only reach this branch
5337        // after the initial lookup misses.
5338        if is_bare_package_specifier(spec) && (repair_mode.allows_aggressive() || compat_scan_mode)
5339        {
5340            let state = self.state.borrow();
5341            let roots = state.extension_roots.clone();
5342            let tiers = state.extension_root_tiers.clone();
5343            let scopes = state.extension_root_scopes.clone();
5344            drop(state);
5345
5346            if should_auto_stub_package(spec, base, &roots, &tiers, &scopes) {
5347                tracing::info!(
5348                    event = "pijs.repair.missing_npm_dep",
5349                    base = %base,
5350                    specifier = %spec,
5351                    "auto-repair: generated proxy stub for missing npm dependency"
5352                );
5353
5354                let source = std::fs::read_to_string(base).unwrap_or_default();
5355                let extracted_names = extract_import_names(&source, spec);
5356                let mut state = self.state.borrow_mut();
5357                let entry_key = spec.to_string();
5358                let mut exports_changed = false;
5359                {
5360                    let exports = state
5361                        .dynamic_virtual_named_exports
5362                        .entry(entry_key.clone())
5363                        .or_default();
5364                    for name in extracted_names {
5365                        exports_changed |= exports.insert(name);
5366                    }
5367                }
5368
5369                let export_names = state
5370                    .dynamic_virtual_named_exports
5371                    .get(&entry_key)
5372                    .cloned()
5373                    .unwrap_or_default();
5374                if exports_changed || !state.dynamic_virtual_modules.contains_key(spec) {
5375                    let stub = generate_proxy_stub_module(spec, &export_names);
5376                    state.dynamic_virtual_modules.insert(entry_key, stub);
5377                    if state.compiled_sources.remove(spec).is_some() {
5378                        state.module_cache_counters.invalidations =
5379                            state.module_cache_counters.invalidations.saturating_add(1);
5380                    }
5381                }
5382
5383                if let Ok(mut events) = state.repair_events.lock() {
5384                    events.push(ExtensionRepairEvent {
5385                        extension_id: String::new(),
5386                        pattern: RepairPattern::MissingNpmDep,
5387                        original_error: format!("missing npm dependency: {spec} from {base}"),
5388                        repair_action: format!(
5389                            "generated proxy stub for package '{spec}' with {} named export(s)",
5390                            export_names.len()
5391                        ),
5392                        success: true,
5393                        timestamp_ms: 0,
5394                    });
5395                }
5396
5397                return Ok(spec.to_string());
5398            }
5399        }
5400
5401        Err(rquickjs::Error::new_resolving_message(
5402            base,
5403            name,
5404            unsupported_module_specifier_message(spec),
5405        ))
5406    }
5407}
5408
5409#[derive(Clone, Debug)]
5410struct PiJsLoader {
5411    state: Rc<RefCell<PiJsModuleState>>,
5412}
5413
5414impl JsModuleLoader for PiJsLoader {
5415    fn load<'js>(
5416        &mut self,
5417        ctx: &Ctx<'js>,
5418        name: &str,
5419    ) -> rquickjs::Result<Module<'js, JsModuleDeclared>> {
5420        let source = {
5421            let mut state = self.state.borrow_mut();
5422            load_compiled_module_source(&mut state, name)?
5423        };
5424
5425        Module::declare(ctx.clone(), name, source)
5426    }
5427}
5428
5429fn compile_module_source(
5430    static_virtual_modules: &HashMap<String, String>,
5431    dynamic_virtual_modules: &HashMap<String, String>,
5432    name: &str,
5433) -> rquickjs::Result<Vec<u8>> {
5434    if let Some(source) = dynamic_virtual_modules
5435        .get(name)
5436        .or_else(|| static_virtual_modules.get(name))
5437    {
5438        return Ok(prefix_import_meta_url(name, source));
5439    }
5440
5441    let path = Path::new(name);
5442    if !path.is_file() {
5443        return Err(rquickjs::Error::new_loading_message(
5444            name,
5445            "Module is not a file",
5446        ));
5447    }
5448
5449    let metadata = fs::metadata(path)
5450        .map_err(|err| rquickjs::Error::new_loading_message(name, format!("metadata: {err}")))?;
5451    if metadata.len() > MAX_MODULE_SOURCE_BYTES {
5452        return Err(rquickjs::Error::new_loading_message(
5453            name,
5454            format!(
5455                "Module source exceeds size limit: {} > {}",
5456                metadata.len(),
5457                MAX_MODULE_SOURCE_BYTES
5458            ),
5459        ));
5460    }
5461
5462    let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
5463    let raw = fs::read_to_string(path)
5464        .map_err(|err| rquickjs::Error::new_loading_message(name, format!("read: {err}")))?;
5465
5466    let compiled = match extension {
5467        "ts" | "tsx" => {
5468            let transpiled = transpile_typescript_module(&raw, name).map_err(|message| {
5469                rquickjs::Error::new_loading_message(name, format!("transpile: {message}"))
5470            })?;
5471            rewrite_legacy_private_identifiers(&maybe_cjs_to_esm(&transpiled))
5472        }
5473        "js" | "mjs" => rewrite_legacy_private_identifiers(&maybe_cjs_to_esm(&raw)),
5474        "json" => json_module_to_esm(&raw, name).map_err(|message| {
5475            rquickjs::Error::new_loading_message(name, format!("json: {message}"))
5476        })?,
5477        other => {
5478            return Err(rquickjs::Error::new_loading_message(
5479                name,
5480                format!("Unsupported module extension: {other}"),
5481            ));
5482        }
5483    };
5484
5485    Ok(prefix_import_meta_url(name, &compiled))
5486}
5487
5488fn module_cache_key(
5489    static_virtual_modules: &HashMap<String, String>,
5490    dynamic_virtual_modules: &HashMap<String, String>,
5491    name: &str,
5492) -> Option<String> {
5493    if let Some(source) = dynamic_virtual_modules
5494        .get(name)
5495        .or_else(|| static_virtual_modules.get(name))
5496    {
5497        let mut hasher = Sha256::new();
5498        hasher.update(b"virtual\0");
5499        hasher.update(name.as_bytes());
5500        hasher.update(b"\0");
5501        hasher.update(source.as_bytes());
5502        return Some(format!("v:{:x}", hasher.finalize()));
5503    }
5504
5505    let path = Path::new(name);
5506    if !path.is_file() {
5507        return None;
5508    }
5509
5510    let metadata = fs::metadata(path).ok()?;
5511    let modified_nanos = metadata
5512        .modified()
5513        .ok()
5514        .and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
5515        .map_or(0, |duration| duration.as_nanos());
5516
5517    Some(format!("f:{name}:{}:{modified_nanos}", metadata.len()))
5518}
5519
5520// ============================================================================
5521// Persistent disk cache for transpiled module sources (bd-3ar8v.4.16)
5522// ============================================================================
5523
5524/// Build the on-disk path for a cached transpiled module.
5525///
5526/// Layout: `{cache_dir}/{first_2_hex}/{full_hex}.js` to shard entries and
5527/// avoid a single flat directory with thousands of files.
5528fn disk_cache_path(cache_dir: &Path, cache_key: &str) -> PathBuf {
5529    let mut hasher = Sha256::new();
5530    hasher.update(cache_key.as_bytes());
5531    let hex = format!("{:x}", hasher.finalize());
5532    let prefix = &hex[..2];
5533    cache_dir.join(prefix).join(format!("{hex}.js"))
5534}
5535
5536/// Attempt to load a transpiled module source from persistent disk cache.
5537fn try_load_from_disk_cache(cache_dir: &Path, cache_key: &str) -> Option<Vec<u8>> {
5538    let path = disk_cache_path(cache_dir, cache_key);
5539    fs::read(path).ok()
5540}
5541
5542/// Persist a transpiled module source to the disk cache (best-effort).
5543fn store_to_disk_cache(cache_dir: &Path, cache_key: &str, source: &[u8]) {
5544    let path = disk_cache_path(cache_dir, cache_key);
5545    if let Some(parent) = path.parent() {
5546        if let Err(err) = fs::create_dir_all(parent) {
5547            tracing::debug!(event = "pijs.module_cache.disk.mkdir_failed", path = %parent.display(), %err);
5548            return;
5549        }
5550    }
5551
5552    let temp_path = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4().simple()));
5553    if let Err(err) = fs::write(&temp_path, source) {
5554        tracing::debug!(event = "pijs.module_cache.disk.write_failed", path = %temp_path.display(), %err);
5555        return;
5556    }
5557
5558    if let Err(err) = fs::rename(&temp_path, &path) {
5559        tracing::debug!(event = "pijs.module_cache.disk.rename_failed", from = %temp_path.display(), to = %path.display(), %err);
5560        let _ = fs::remove_file(&temp_path);
5561    }
5562}
5563
5564fn load_compiled_module_source(
5565    state: &mut PiJsModuleState,
5566    name: &str,
5567) -> rquickjs::Result<Vec<u8>> {
5568    let cache_key = module_cache_key(
5569        &state.static_virtual_modules,
5570        &state.dynamic_virtual_modules,
5571        name,
5572    );
5573
5574    // 1. Check in-memory cache — Arc clone is O(1) atomic increment.
5575    if let Some(cached) = state.compiled_sources.get(name) {
5576        if cached.cache_key == cache_key {
5577            state.module_cache_counters.hits = state.module_cache_counters.hits.saturating_add(1);
5578            return Ok(cached.source.to_vec());
5579        }
5580
5581        state.module_cache_counters.invalidations =
5582            state.module_cache_counters.invalidations.saturating_add(1);
5583    }
5584
5585    // 2. Check persistent disk cache.
5586    if let Some(cache_key_str) = cache_key.as_deref()
5587        && let Some(cache_dir) = state.disk_cache_dir.as_deref()
5588        && let Some(disk_cached) = try_load_from_disk_cache(cache_dir, cache_key_str)
5589    {
5590        state.module_cache_counters.disk_hits =
5591            state.module_cache_counters.disk_hits.saturating_add(1);
5592        let source: Arc<[u8]> = disk_cached.into();
5593        state.compiled_sources.insert(
5594            name.to_string(),
5595            CompiledModuleCacheEntry {
5596                cache_key,
5597                source: Arc::clone(&source),
5598            },
5599        );
5600        return Ok(source.to_vec());
5601    }
5602
5603    // 3. Compile from source (SWC transpile + CJS->ESM rewrite).
5604    state.module_cache_counters.misses = state.module_cache_counters.misses.saturating_add(1);
5605    let compiled = compile_module_source(
5606        &state.static_virtual_modules,
5607        &state.dynamic_virtual_modules,
5608        name,
5609    )?;
5610    let source: Arc<[u8]> = compiled.into();
5611    state.compiled_sources.insert(
5612        name.to_string(),
5613        CompiledModuleCacheEntry {
5614            cache_key: cache_key.clone(),
5615            source: Arc::clone(&source),
5616        },
5617    );
5618
5619    // 4. Persist to disk cache for next session.
5620    if let Some(cache_key_str) = cache_key.as_deref()
5621        && let Some(cache_dir) = state.disk_cache_dir.as_deref()
5622    {
5623        store_to_disk_cache(cache_dir, cache_key_str, &source);
5624    }
5625
5626    Ok(source.to_vec())
5627}
5628
5629// ============================================================================
5630// Warm Isolate Pool (bd-3ar8v.4.16)
5631// ============================================================================
5632
5633/// Configuration holder and factory for pre-warmed JS extension runtimes.
5634///
5635/// Since `PiJsRuntime` uses `Rc` internally and cannot cross thread
5636/// boundaries, the pool does not hold live runtime instances. Instead, it
5637/// provides a factory that produces pre-configured `PiJsRuntimeConfig` values,
5638/// and runtimes can be returned to a "warm" state via
5639/// [`PiJsRuntime::reset_transient_state`].
5640///
5641/// # Lifecycle
5642///
5643/// 1. Create pool with desired config via [`WarmIsolatePool::new`].
5644/// 2. Call [`make_config`](WarmIsolatePool::make_config) to get a pre-warmed
5645///    `PiJsRuntimeConfig` for each runtime thread.
5646/// 3. After use, call [`PiJsRuntime::reset_transient_state`] to return the
5647///    runtime to a clean state (keeping the transpiled source cache).
5648#[derive(Debug, Clone)]
5649pub struct WarmIsolatePool {
5650    /// Template configuration for new runtimes.
5651    template: PiJsRuntimeConfig,
5652    /// Number of runtimes created from this pool.
5653    created_count: Arc<AtomicU64>,
5654    /// Number of resets performed.
5655    reset_count: Arc<AtomicU64>,
5656}
5657
5658impl WarmIsolatePool {
5659    /// Create a new warm isolate pool with the given template config.
5660    pub fn new(template: PiJsRuntimeConfig) -> Self {
5661        Self {
5662            template,
5663            created_count: Arc::new(AtomicU64::new(0)),
5664            reset_count: Arc::new(AtomicU64::new(0)),
5665        }
5666    }
5667
5668    /// Create a pre-configured `PiJsRuntimeConfig` with shared pool state.
5669    pub fn make_config(&self) -> PiJsRuntimeConfig {
5670        self.created_count.fetch_add(1, AtomicOrdering::Relaxed);
5671        self.template.clone()
5672    }
5673
5674    /// Record that a runtime was reset for reuse.
5675    pub fn record_reset(&self) {
5676        self.reset_count.fetch_add(1, AtomicOrdering::Relaxed);
5677    }
5678
5679    /// Number of runtimes created from this pool.
5680    pub fn created_count(&self) -> u64 {
5681        self.created_count.load(AtomicOrdering::Relaxed)
5682    }
5683
5684    /// Number of runtime resets performed.
5685    pub fn reset_count(&self) -> u64 {
5686        self.reset_count.load(AtomicOrdering::Relaxed)
5687    }
5688}
5689
5690impl Default for WarmIsolatePool {
5691    fn default() -> Self {
5692        Self::new(PiJsRuntimeConfig::default())
5693    }
5694}
5695
5696fn prefix_import_meta_url(module_name: &str, body: &str) -> Vec<u8> {
5697    let url = if module_name.starts_with('/') {
5698        format!("file://{module_name}")
5699    } else if module_name.starts_with("file://") {
5700        module_name.to_string()
5701    } else if module_name.len() > 2
5702        && module_name.as_bytes()[1] == b':'
5703        && (module_name.as_bytes()[2] == b'/' || module_name.as_bytes()[2] == b'\\')
5704    {
5705        // Windows absolute path: `C:/Users/...` or `C:\Users\...`
5706        format!("file:///{module_name}")
5707    } else {
5708        format!("pi://{module_name}")
5709    };
5710    let url_literal = serde_json::to_string(&url).unwrap_or_else(|_| "\"\"".to_string());
5711    format!("import.meta.url = {url_literal};\n{body}").into_bytes()
5712}
5713
5714fn resolve_module_path(
5715    base: &str,
5716    specifier: &str,
5717    repair_mode: RepairMode,
5718    roots: &[PathBuf],
5719) -> Option<PathBuf> {
5720    let specifier = specifier.trim();
5721    if specifier.is_empty() {
5722        return None;
5723    }
5724
5725    if let Some(path) = specifier.strip_prefix("file://") {
5726        let resolved = resolve_existing_file(PathBuf::from(path))?;
5727        if !roots.is_empty() {
5728            let canonical = crate::extensions::safe_canonicalize(&resolved);
5729            let allowed = roots.iter().any(|root| {
5730                let canonical_root = crate::extensions::safe_canonicalize(root);
5731                canonical.starts_with(&canonical_root)
5732            });
5733            if !allowed {
5734                tracing::warn!(
5735                    event = "pijs.resolve.monotonicity_violation",
5736                    resolved = %resolved.display(),
5737                    "resolution blocked: file:// path escapes extension root"
5738                );
5739                return None;
5740            }
5741        }
5742        return Some(resolved);
5743    }
5744
5745    let path = if specifier.starts_with('/') {
5746        PathBuf::from(specifier)
5747    } else if specifier.len() > 2
5748        && specifier.as_bytes()[1] == b':'
5749        && (specifier.as_bytes()[2] == b'/' || specifier.as_bytes()[2] == b'\\')
5750    {
5751        // Windows absolute path: `C:/Users/...` or `C:\Users\...`
5752        PathBuf::from(specifier)
5753    } else if specifier.starts_with('.') {
5754        let base_path = Path::new(base);
5755        let base_dir = base_path.parent()?;
5756        base_dir.join(specifier)
5757    } else {
5758        return None;
5759    };
5760
5761    // SEC-FIX: Enforce scope monotonicity before checking file existence (bd-k5q5.9.1.3).
5762    // This prevents directory traversal probes from revealing existence of files
5763    // outside the extension root (e.g. `../../../../etc/passwd`).
5764    if !roots.is_empty() {
5765        let canonical = crate::extensions::safe_canonicalize(&path);
5766        let allowed = roots.iter().any(|root| {
5767            let canonical_root = crate::extensions::safe_canonicalize(root);
5768            canonical.starts_with(&canonical_root)
5769        });
5770
5771        if !allowed {
5772            return None;
5773        }
5774    }
5775
5776    if let Some(resolved) = resolve_existing_module_candidate(path.clone()) {
5777        // SEC-FIX: Enforce scope monotonicity on the *resolved* path (bd-k5q5.9.1.3).
5778        // This handles cases where `resolve_existing_module_candidate` finds a file
5779        // (e.g. .ts sibling) that is a symlink escaping the root, even if the base path was safe.
5780        if !roots.is_empty() {
5781            let canonical_resolved = crate::extensions::safe_canonicalize(&resolved);
5782            let allowed = roots.iter().any(|root| {
5783                let canonical_root = crate::extensions::safe_canonicalize(root);
5784                canonical_resolved.starts_with(&canonical_root)
5785            });
5786
5787            if !allowed {
5788                tracing::warn!(
5789                    event = "pijs.resolve.monotonicity_violation",
5790                    original = %path.display(),
5791                    resolved = %resolved.display(),
5792                    "resolution blocked: resolved path escapes extension root"
5793                );
5794                return None;
5795            }
5796        }
5797        return Some(resolved);
5798    }
5799
5800    // Pattern 1 (bd-k5q5.8.2): dist/ → src/ fallback for missing build artifacts.
5801    // Gated by repair_mode (bd-k5q5.9.1.2): only static-analysis operations
5802    // (path existence checks) happen here — broken code is never executed.
5803    if repair_mode.should_apply() {
5804        try_dist_to_src_fallback(&path)
5805    } else {
5806        if repair_mode == RepairMode::Suggest {
5807            // Log what would have been repaired without applying it.
5808            if let Some(resolved) = try_dist_to_src_fallback(&path) {
5809                tracing::info!(
5810                    event = "pijs.repair.suggest",
5811                    pattern = "dist_to_src",
5812                    original = %path.display(),
5813                    resolved = %resolved.display(),
5814                    "repair suggestion: would resolve dist/ → src/ (mode=suggest)"
5815                );
5816            }
5817        }
5818        None
5819    }
5820}
5821
5822/// Auto-repair Pattern 1: when a module path contains `/dist/` and the file
5823/// does not exist, try the equivalent path under `/src/` with `.ts`/`.tsx`
5824/// extensions.  This handles the common case where an npm-published extension
5825/// references compiled output that was never built.
5826fn try_dist_to_src_fallback(path: &Path) -> Option<PathBuf> {
5827    let path_str = path.to_string_lossy();
5828    let idx = path_str.find("/dist/")?;
5829
5830    // The extension root is the directory containing /dist/.
5831    let extension_root = PathBuf::from(&path_str[..idx]);
5832
5833    let src_path = format!("{}/src/{}", &path_str[..idx], &path_str[idx + 6..]);
5834
5835    let candidate = PathBuf::from(&src_path);
5836
5837    if let Some(resolved) = resolve_existing_module_candidate(candidate) {
5838        // Privilege monotonicity check (bd-k5q5.9.1.3): ensure the
5839        // resolved path stays within the extension root.
5840        let verdict = verify_repair_monotonicity(&extension_root, path, &resolved);
5841        if !verdict.is_safe() {
5842            tracing::warn!(
5843                event = "pijs.repair.monotonicity_violation",
5844                original = %path_str,
5845                resolved = %resolved.display(),
5846                verdict = ?verdict,
5847                "repair blocked: resolved path escapes extension root"
5848            );
5849            return None;
5850        }
5851
5852        // Structural validation gate (bd-k5q5.9.5.1): verify the
5853        // resolved file is parseable before accepting the repair.
5854        let structural = validate_repaired_artifact(&resolved);
5855        if !structural.is_valid() {
5856            tracing::warn!(
5857                event = "pijs.repair.structural_validation_failed",
5858                original = %path_str,
5859                resolved = %resolved.display(),
5860                verdict = %structural,
5861                "repair blocked: resolved artifact failed structural validation"
5862            );
5863            return None;
5864        }
5865
5866        tracing::info!(
5867            event = "pijs.repair.dist_to_src",
5868            original = %path_str,
5869            resolved = %resolved.display(),
5870            "auto-repair: resolved dist/ → src/ fallback"
5871        );
5872        return Some(resolved);
5873    }
5874
5875    None
5876}
5877
5878fn resolve_existing_file(path: PathBuf) -> Option<PathBuf> {
5879    if path.is_file() {
5880        return Some(path);
5881    }
5882    None
5883}
5884
5885fn resolve_existing_module_candidate(path: PathBuf) -> Option<PathBuf> {
5886    if path.is_file() {
5887        return Some(path);
5888    }
5889
5890    if path.is_dir() {
5891        for candidate in [
5892            "index.ts",
5893            "index.tsx",
5894            "index.js",
5895            "index.mjs",
5896            "index.json",
5897        ] {
5898            let full = path.join(candidate);
5899            if full.is_file() {
5900                return Some(full);
5901            }
5902        }
5903        return None;
5904    }
5905
5906    let extension = path.extension().and_then(|ext| ext.to_str());
5907    match extension {
5908        Some("js" | "mjs") => {
5909            for ext in ["ts", "tsx"] {
5910                let fallback = path.with_extension(ext);
5911                if fallback.is_file() {
5912                    return Some(fallback);
5913                }
5914            }
5915        }
5916        None => {
5917            for ext in ["ts", "tsx", "js", "mjs", "json"] {
5918                let candidate = path.with_extension(ext);
5919                if candidate.is_file() {
5920                    return Some(candidate);
5921                }
5922            }
5923        }
5924        _ => {}
5925    }
5926
5927    None
5928}
5929
5930// ─── Pattern 3 (bd-k5q5.8.4): Monorepo Sibling Module Stubs ─────────────────
5931
5932/// Regex that captures named imports from an ESM import statement:
5933///   `import { a, b, type C } from "specifier"`
5934///
5935/// Group 1: the names inside braces.
5936static IMPORT_NAMES_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
5937
5938fn import_names_regex() -> &'static regex::Regex {
5939    IMPORT_NAMES_RE.get_or_init(|| {
5940        regex::Regex::new(r#"(?ms)import\s+(?:[^{};]*?,\s*)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#)
5941            .expect("import names regex")
5942    })
5943}
5944
5945/// Regex for CJS destructured require:
5946///   `const { a, b } = require("specifier")`
5947static REQUIRE_DESTRUCTURE_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
5948
5949fn require_destructure_regex() -> &'static regex::Regex {
5950    REQUIRE_DESTRUCTURE_RE.get_or_init(|| {
5951        regex::Regex::new(
5952            r#"(?m)(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]"#,
5953        )
5954        .expect("require destructure regex")
5955    })
5956}
5957
5958/// Detect if a relative specifier resolves to a path outside all known
5959/// extension roots.  Returns the resolved absolute path if it's an escape.
5960fn detect_monorepo_escape(
5961    base: &str,
5962    specifier: &str,
5963    extension_roots: &[PathBuf],
5964) -> Option<PathBuf> {
5965    if !specifier.starts_with('.') {
5966        return None;
5967    }
5968    let base_dir = Path::new(base).parent()?;
5969    let resolved = base_dir.join(specifier);
5970
5971    // Canonicalize as much as possible — if the exact path doesn't exist,
5972    // try the parent directory.
5973    let effective = std::fs::canonicalize(&resolved)
5974        .map(crate::extensions::strip_unc_prefix)
5975        .or_else(|_| {
5976            resolved
5977                .parent()
5978                .and_then(|p| {
5979                    std::fs::canonicalize(p)
5980                        .map(crate::extensions::strip_unc_prefix)
5981                        .ok()
5982                })
5983                .map(|p| p.join(resolved.file_name().unwrap_or_default()))
5984                .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no parent"))
5985        })
5986        .unwrap_or_else(|_| resolved.clone());
5987
5988    for root in extension_roots {
5989        if effective.starts_with(root) {
5990            return None; // Within an extension root — not an escape
5991        }
5992    }
5993
5994    Some(resolved)
5995}
5996
5997/// Extract the named imports that a source file pulls from a given specifier.
5998///
5999/// Handles both ESM `import { x, y } from "spec"` and CJS
6000/// `const { x, y } = require("spec")`.  Type-only imports (`type Foo`)
6001/// are excluded because TypeScript erases them.
6002pub fn extract_import_names(source: &str, specifier: &str) -> Vec<String> {
6003    let mut names = Vec::new();
6004    let re_esm = import_names_regex();
6005    let re_cjs = require_destructure_regex();
6006
6007    for cap in re_esm.captures_iter(source) {
6008        let spec_in_source = &cap[2];
6009        if spec_in_source != specifier {
6010            continue;
6011        }
6012        parse_import_list(&cap[1], &mut names);
6013    }
6014
6015    for cap in re_cjs.captures_iter(source) {
6016        let spec_in_source = &cap[2];
6017        if spec_in_source != specifier {
6018            continue;
6019        }
6020        parse_import_list(&cap[1], &mut names);
6021    }
6022
6023    names.sort();
6024    names.dedup();
6025    names
6026}
6027
6028/// Parse a comma-separated list of import names, skipping `type`-only imports.
6029fn parse_import_list(raw: &str, out: &mut Vec<String>) {
6030    for token in raw.split(',') {
6031        let token = token.trim();
6032        if token.is_empty() {
6033            continue;
6034        }
6035        // Skip `type Foo` (TypeScript type-only import)
6036        if token.starts_with("type ") || token.starts_with("type\t") {
6037            continue;
6038        }
6039        // Handle `X as Y` — we export the original name `X`.
6040        let name = token.split_whitespace().next().unwrap_or(token).trim();
6041        if !name.is_empty() {
6042            out.push(name.to_string());
6043        }
6044    }
6045}
6046
6047/// Generate a synthetic ESM stub module that exports no-op values for each
6048/// requested name.  Uses simple heuristics to choose the export shape:
6049///
6050/// - Names starting with `is`/`has`/`check` → `() => false`
6051/// - Names starting with `get`/`detect`/`find`/`create`/`make` → `() => ({})`
6052/// - Names starting with `set`/`play`/`send`/`run`/`do`/`emit` → `() => {}`
6053/// - `ALL_CAPS` names → `[]` (constants are often arrays)
6054/// - Names starting with uppercase → `class Name {}` (likely class/type)
6055/// - Everything else → `() => {}`
6056pub fn generate_monorepo_stub(names: &[String]) -> String {
6057    let mut lines = Vec::with_capacity(names.len() + 1);
6058    lines.push("// Auto-generated monorepo escape stub (Pattern 3)".to_string());
6059
6060    for name in names {
6061        if !is_valid_js_export_name(name) {
6062            continue;
6063        }
6064
6065        let export = if name == "default" {
6066            "export default () => {};".to_string()
6067        } else if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && !name.is_empty() {
6068            // ALL_CAPS constant
6069            format!("export const {name} = [];")
6070        } else if name.starts_with("is") || name.starts_with("has") || name.starts_with("check") {
6071            format!("export const {name} = () => false;")
6072        } else if name.starts_with("get")
6073            || name.starts_with("detect")
6074            || name.starts_with("find")
6075            || name.starts_with("create")
6076            || name.starts_with("make")
6077        {
6078            format!("export const {name} = () => ({{}});")
6079        } else if name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
6080            // Likely a class or type — export as class
6081            format!("export class {name} {{}}")
6082        } else {
6083            // Generic function stub
6084            format!("export const {name} = () => {{}};")
6085        };
6086        lines.push(export);
6087    }
6088
6089    lines.join("\n")
6090}
6091
6092fn source_declares_binding(source: &str, name: &str) -> bool {
6093    let patterns = [
6094        format!("const {name}"),
6095        format!("let {name}"),
6096        format!("var {name}"),
6097        format!("function {name}"),
6098        format!("class {name}"),
6099        format!("export const {name}"),
6100        format!("export let {name}"),
6101        format!("export var {name}"),
6102        format!("export function {name}"),
6103        format!("export class {name}"),
6104    ];
6105    source.lines().any(|line| {
6106        let trimmed = line.trim_start();
6107        if trimmed.starts_with("//") || trimmed.starts_with('*') {
6108            return false;
6109        }
6110        patterns.iter().any(|pattern| trimmed.starts_with(pattern))
6111    })
6112}
6113
6114/// Extract static `require("specifier")` calls from JavaScript source.
6115///
6116/// This scanner is intentionally lexical: it ignores matches inside comments
6117/// and string/template literals so code-generation strings like
6118/// `` `require("pkg/path").default` `` do not become false-positive imports.
6119#[allow(clippy::too_many_lines)]
6120fn extract_static_require_specifiers(source: &str) -> Vec<String> {
6121    const REQUIRE: &[u8] = b"require";
6122
6123    let bytes = source.as_bytes();
6124    let mut out = Vec::new();
6125    let mut seen = HashSet::new();
6126
6127    let mut i = 0usize;
6128    let mut in_line_comment = false;
6129    let mut in_block_comment = false;
6130    let mut in_single = false;
6131    let mut in_double = false;
6132    let mut in_template = false;
6133    let mut escaped = false;
6134
6135    while i < bytes.len() {
6136        let b = bytes[i];
6137
6138        if in_line_comment {
6139            if b == b'\n' {
6140                in_line_comment = false;
6141            }
6142            i += 1;
6143            continue;
6144        }
6145
6146        if in_block_comment {
6147            if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
6148                in_block_comment = false;
6149                i += 2;
6150            } else {
6151                i += 1;
6152            }
6153            continue;
6154        }
6155
6156        if in_single {
6157            if escaped {
6158                escaped = false;
6159            } else if b == b'\\' {
6160                escaped = true;
6161            } else if b == b'\'' {
6162                in_single = false;
6163            }
6164            i += 1;
6165            continue;
6166        }
6167
6168        if in_double {
6169            if escaped {
6170                escaped = false;
6171            } else if b == b'\\' {
6172                escaped = true;
6173            } else if b == b'"' {
6174                in_double = false;
6175            }
6176            i += 1;
6177            continue;
6178        }
6179
6180        if in_template {
6181            if escaped {
6182                escaped = false;
6183            } else if b == b'\\' {
6184                escaped = true;
6185            } else if b == b'`' {
6186                in_template = false;
6187            }
6188            i += 1;
6189            continue;
6190        }
6191
6192        if b == b'/' && i + 1 < bytes.len() {
6193            match bytes[i + 1] {
6194                b'/' => {
6195                    in_line_comment = true;
6196                    i += 2;
6197                    continue;
6198                }
6199                b'*' => {
6200                    in_block_comment = true;
6201                    i += 2;
6202                    continue;
6203                }
6204                _ => {}
6205            }
6206        }
6207
6208        if b == b'\'' {
6209            in_single = true;
6210            i += 1;
6211            continue;
6212        }
6213        if b == b'"' {
6214            in_double = true;
6215            i += 1;
6216            continue;
6217        }
6218        if b == b'`' {
6219            in_template = true;
6220            i += 1;
6221            continue;
6222        }
6223
6224        if i + REQUIRE.len() <= bytes.len() && &bytes[i..i + REQUIRE.len()] == REQUIRE {
6225            let has_ident_before = i > 0 && is_js_ident_continue(bytes[i - 1]);
6226            let after_ident_idx = i + REQUIRE.len();
6227            let has_ident_after =
6228                after_ident_idx < bytes.len() && is_js_ident_continue(bytes[after_ident_idx]);
6229            if has_ident_before || has_ident_after {
6230                i += 1;
6231                continue;
6232            }
6233
6234            let mut j = after_ident_idx;
6235            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
6236                j += 1;
6237            }
6238            if j >= bytes.len() || bytes[j] != b'(' {
6239                i += 1;
6240                continue;
6241            }
6242
6243            j += 1;
6244            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
6245                j += 1;
6246            }
6247            if j >= bytes.len() || (bytes[j] != b'"' && bytes[j] != b'\'') {
6248                i += 1;
6249                continue;
6250            }
6251
6252            let quote = bytes[j];
6253            let spec_start = j + 1;
6254            j += 1;
6255            let mut lit_escaped = false;
6256            while j < bytes.len() {
6257                let c = bytes[j];
6258                if lit_escaped {
6259                    lit_escaped = false;
6260                    j += 1;
6261                    continue;
6262                }
6263                if c == b'\\' {
6264                    lit_escaped = true;
6265                    j += 1;
6266                    continue;
6267                }
6268                if c == quote {
6269                    break;
6270                }
6271                j += 1;
6272            }
6273            if j >= bytes.len() {
6274                break;
6275            }
6276
6277            let spec = &source[spec_start..j];
6278            j += 1;
6279            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
6280                j += 1;
6281            }
6282            if j < bytes.len() && bytes[j] == b')' && seen.insert(spec.to_string()) {
6283                out.push(spec.to_string());
6284                i = j + 1;
6285                continue;
6286            }
6287        }
6288
6289        i += 1;
6290    }
6291
6292    out
6293}
6294
6295/// Detect if a JavaScript source uses CommonJS patterns (`require(...)` or
6296/// `module.exports`) and transform it into an ESM-compatible wrapper.
6297///
6298/// Handles two cases:
6299/// 1. **Pure CJS** (no ESM `import`/`export`): full wrapper with
6300///    `module`/`exports`/`require` shim + `export default module.exports`
6301/// 2. **Mixed** (ESM imports + `require()` calls): inject `import` statements
6302///    for require targets and a `require()` function, preserving existing ESM
6303#[allow(clippy::too_many_lines)]
6304fn maybe_cjs_to_esm(source: &str) -> String {
6305    let has_require = source.contains("require(");
6306    let has_module_exports = source.contains("module.exports")
6307        || source.contains("module[\"exports\"]")
6308        || source.contains("module['exports']");
6309    let has_exports_usage = source.contains("exports.") || source.contains("exports[");
6310    let has_dirname_refs = source.contains("__dirname") || source.contains("__filename");
6311
6312    if !has_require && !has_module_exports && !has_exports_usage && !has_dirname_refs {
6313        return source.to_string();
6314    }
6315
6316    let has_esm = source.lines().any(|line| {
6317        let trimmed = line.trim();
6318        (trimmed.starts_with("import ") || trimmed.starts_with("export "))
6319            && !trimmed.starts_with("//")
6320    });
6321    let has_export_default = source.contains("export default");
6322
6323    // Extract all require() specifiers
6324    let specifiers = extract_static_require_specifiers(source);
6325
6326    if specifiers.is_empty() && !has_module_exports && !has_exports_usage && !has_dirname_refs {
6327        return source.to_string();
6328    }
6329    if specifiers.is_empty()
6330        && has_esm
6331        && !has_module_exports
6332        && !has_exports_usage
6333        && !has_dirname_refs
6334    {
6335        return source.to_string();
6336    }
6337
6338    let mut output = String::with_capacity(source.len() + 512);
6339
6340    // Generate ESM imports for require targets
6341    for (i, spec) in specifiers.iter().enumerate() {
6342        let _ = writeln!(output, "import * as __cjs_req_{i} from {spec:?};");
6343    }
6344
6345    // Build require map + function
6346    let has_require_binding = source_declares_binding(source, "require");
6347    if !specifiers.is_empty() && !has_require_binding {
6348        output.push_str("const __cjs_req_map = {");
6349        for (i, spec) in specifiers.iter().enumerate() {
6350            if i > 0 {
6351                output.push(',');
6352            }
6353            let _ = write!(output, "\n  {spec:?}: __cjs_req_{i}");
6354        }
6355        output.push_str("\n};\n");
6356        output.push_str(
6357            "function require(s) {\n\
6358             \x20 const m = __cjs_req_map[s];\n\
6359             \x20 if (!m) throw new Error('Cannot find module: ' + s);\n\
6360             \x20 return m.default !== undefined && typeof m.default === 'object' \
6361                  ? m.default : m;\n\
6362             }\n",
6363        );
6364    }
6365
6366    let has_filename_binding = source_declares_binding(source, "__filename");
6367    let has_dirname_binding = source_declares_binding(source, "__dirname");
6368    let has_module_binding = source_declares_binding(source, "module");
6369    let has_exports_binding = source_declares_binding(source, "exports");
6370    let needs_filename = has_dirname_refs && !has_filename_binding;
6371    let needs_dirname = has_dirname_refs && !has_dirname_binding;
6372    let needs_module = (has_module_exports || has_exports_usage) && !has_module_binding;
6373    let needs_exports = (has_module_exports || has_exports_usage) && !has_exports_binding;
6374
6375    if needs_filename || needs_dirname || needs_module || needs_exports {
6376        // Provide CJS compatibility globals only for bindings not declared by source.
6377        if needs_filename {
6378            output.push_str(
6379                "const __filename = (() => {\n\
6380                 \x20 try { return new URL(import.meta.url).pathname || ''; } catch { return ''; }\n\
6381                 })();\n",
6382            );
6383        }
6384        if needs_dirname {
6385            output.push_str(
6386                "const __dirname = __filename ? __filename.replace(/[/\\\\][^/\\\\]*$/, '') : '.';\n",
6387            );
6388        }
6389        if needs_module {
6390            output.push_str("const module = { exports: {} };\n");
6391        }
6392        if needs_exports {
6393            output.push_str("const exports = module.exports;\n");
6394        }
6395    }
6396
6397    output.push_str(source);
6398    output.push('\n');
6399
6400    if !has_export_default && (!has_esm || has_module_exports || has_exports_usage) {
6401        // Export CommonJS entrypoint for loaders that require a default init fn.
6402        output.push_str("export default module.exports;\n");
6403    }
6404
6405    output
6406}
6407
6408const fn is_js_ident_start(byte: u8) -> bool {
6409    (byte as char).is_ascii_alphabetic() || byte == b'_' || byte == b'$'
6410}
6411
6412const fn is_js_ident_continue(byte: u8) -> bool {
6413    is_js_ident_start(byte) || (byte as char).is_ascii_digit()
6414}
6415
6416/// Rewrite private identifier syntax (`#field`) to legacy-safe identifiers for
6417/// runtimes that do not parse private fields. This is intentionally a lexical
6418/// compatibility transform, not a semantic class-fields implementation.
6419#[allow(clippy::too_many_lines)]
6420fn rewrite_legacy_private_identifiers(source: &str) -> String {
6421    if !source.contains('#') || !source.is_ascii() {
6422        return source.to_string();
6423    }
6424
6425    let bytes = source.as_bytes();
6426    let mut out = String::with_capacity(source.len() + 32);
6427    let mut i = 0usize;
6428    let mut in_single = false;
6429    let mut in_double = false;
6430    let mut in_template = false;
6431    let mut escaped = false;
6432    let mut line_comment = false;
6433    let mut block_comment = false;
6434
6435    while i < bytes.len() {
6436        let b = bytes[i];
6437        let next = bytes.get(i + 1).copied();
6438
6439        if line_comment {
6440            out.push(b as char);
6441            if b == b'\n' {
6442                line_comment = false;
6443            }
6444            i += 1;
6445            continue;
6446        }
6447
6448        if block_comment {
6449            if b == b'*' && next == Some(b'/') {
6450                out.push('*');
6451                out.push('/');
6452                i += 2;
6453                block_comment = false;
6454                continue;
6455            }
6456            out.push(b as char);
6457            i += 1;
6458            continue;
6459        }
6460
6461        if in_single {
6462            out.push(b as char);
6463            if escaped {
6464                escaped = false;
6465            } else if b == b'\\' {
6466                escaped = true;
6467            } else if b == b'\'' {
6468                in_single = false;
6469            }
6470            i += 1;
6471            continue;
6472        }
6473
6474        if in_double {
6475            out.push(b as char);
6476            if escaped {
6477                escaped = false;
6478            } else if b == b'\\' {
6479                escaped = true;
6480            } else if b == b'"' {
6481                in_double = false;
6482            }
6483            i += 1;
6484            continue;
6485        }
6486
6487        if in_template {
6488            out.push(b as char);
6489            if escaped {
6490                escaped = false;
6491            } else if b == b'\\' {
6492                escaped = true;
6493            } else if b == b'`' {
6494                in_template = false;
6495            }
6496            i += 1;
6497            continue;
6498        }
6499
6500        if b == b'/' && next == Some(b'/') {
6501            line_comment = true;
6502            out.push('/');
6503            i += 1;
6504            continue;
6505        }
6506        if b == b'/' && next == Some(b'*') {
6507            block_comment = true;
6508            out.push('/');
6509            i += 1;
6510            continue;
6511        }
6512        if b == b'\'' {
6513            in_single = true;
6514            out.push('\'');
6515            i += 1;
6516            continue;
6517        }
6518        if b == b'"' {
6519            in_double = true;
6520            out.push('"');
6521            i += 1;
6522            continue;
6523        }
6524        if b == b'`' {
6525            in_template = true;
6526            out.push('`');
6527            i += 1;
6528            continue;
6529        }
6530
6531        if b == b'#' && next.is_some_and(is_js_ident_start) {
6532            let prev_is_ident = i > 0 && is_js_ident_continue(bytes[i - 1]);
6533            if !prev_is_ident {
6534                out.push_str("__pijs_private_");
6535                i += 1;
6536                while i < bytes.len() && is_js_ident_continue(bytes[i]) {
6537                    out.push(bytes[i] as char);
6538                    i += 1;
6539                }
6540                continue;
6541            }
6542        }
6543
6544        out.push(b as char);
6545        i += 1;
6546    }
6547
6548    out
6549}
6550
6551fn json_module_to_esm(raw: &str, name: &str) -> std::result::Result<String, String> {
6552    let value: serde_json::Value =
6553        serde_json::from_str(raw).map_err(|err| format!("parse {name}: {err}"))?;
6554    let literal = serde_json::to_string(&value).map_err(|err| format!("encode {name}: {err}"))?;
6555    Ok(format!("export default {literal};\n"))
6556}
6557
6558fn transpile_typescript_module(source: &str, name: &str) -> std::result::Result<String, String> {
6559    let globals = Globals::new();
6560    GLOBALS.set(&globals, || {
6561        let cm: Lrc<SourceMap> = Lrc::default();
6562        let fm = cm.new_source_file(
6563            FileName::Custom(name.to_string()).into(),
6564            source.to_string(),
6565        );
6566
6567        let syntax = Syntax::Typescript(TsSyntax {
6568            tsx: Path::new(name)
6569                .extension()
6570                .is_some_and(|ext| ext.eq_ignore_ascii_case("tsx")),
6571            decorators: true,
6572            ..Default::default()
6573        });
6574
6575        let mut parser = SwcParser::new(syntax, StringInput::from(&*fm), None);
6576        let module: SwcModule = parser
6577            .parse_module()
6578            .map_err(|err| format!("parse {name}: {err:?}"))?;
6579
6580        let unresolved_mark = Mark::new();
6581        let top_level_mark = Mark::new();
6582        let mut program = SwcProgram::Module(module);
6583        {
6584            let mut pass = resolver(unresolved_mark, top_level_mark, false);
6585            pass.process(&mut program);
6586        }
6587        {
6588            let mut pass = strip(unresolved_mark, top_level_mark);
6589            pass.process(&mut program);
6590        }
6591        let SwcProgram::Module(module) = program else {
6592            return Err(format!("transpile {name}: expected module"));
6593        };
6594
6595        let mut buf = Vec::new();
6596        {
6597            let mut emitter = Emitter {
6598                cfg: swc_ecma_codegen::Config::default(),
6599                comments: None,
6600                cm: cm.clone(),
6601                wr: JsWriter::new(cm, "\n", &mut buf, None),
6602            };
6603            emitter
6604                .emit_module(&module)
6605                .map_err(|err| format!("emit {name}: {err}"))?;
6606        }
6607
6608        String::from_utf8(buf).map_err(|err| format!("utf8 {name}: {err}"))
6609    })
6610}
6611
6612/// Build the `node:os` virtual module with real system values injected at init
6613/// time. Values are captured once and cached in the JS module source, so no
6614/// per-call hostcalls are needed.
6615#[allow(clippy::too_many_lines)]
6616fn build_node_os_module() -> String {
6617    // Map Rust target constants to Node.js conventions.
6618    let node_platform = match std::env::consts::OS {
6619        "macos" => "darwin",
6620        "windows" => "win32",
6621        other => other, // "linux", "freebsd", etc.
6622    };
6623    let node_arch = match std::env::consts::ARCH {
6624        "x86_64" => "x64",
6625        "aarch64" => "arm64",
6626        "x86" => "ia32",
6627        "arm" => "arm",
6628        other => other,
6629    };
6630    let node_type = match std::env::consts::OS {
6631        "linux" => "Linux",
6632        "macos" => "Darwin",
6633        "windows" => "Windows_NT",
6634        other => other,
6635    };
6636    // Escape backslashes for safe JS string interpolation (Windows paths).
6637    let tmpdir = std::env::temp_dir()
6638        .display()
6639        .to_string()
6640        .replace('\\', "\\\\");
6641    let homedir = std::env::var("HOME")
6642        .or_else(|_| std::env::var("USERPROFILE"))
6643        .unwrap_or_else(|_| "/home/unknown".to_string())
6644        .replace('\\', "\\\\");
6645    // Read hostname from /etc/hostname (Linux) or fall back to env/default.
6646    let hostname = std::fs::read_to_string("/etc/hostname")
6647        .ok()
6648        .map(|s| s.trim().to_string())
6649        .filter(|s| !s.is_empty())
6650        .or_else(|| std::env::var("HOSTNAME").ok())
6651        .or_else(|| std::env::var("COMPUTERNAME").ok())
6652        .unwrap_or_else(|| "localhost".to_string());
6653    let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
6654    let eol = if cfg!(windows) { "\\r\\n" } else { "\\n" };
6655    let dev_null = if cfg!(windows) {
6656        "\\\\\\\\.\\\\NUL"
6657    } else {
6658        "/dev/null"
6659    };
6660    let username = std::env::var("USER")
6661        .or_else(|_| std::env::var("USERNAME"))
6662        .unwrap_or_else(|_| "unknown".to_string());
6663    let shell = std::env::var("SHELL").unwrap_or_else(|_| {
6664        if cfg!(windows) {
6665            "cmd.exe".to_string()
6666        } else {
6667            "/bin/sh".to_string()
6668        }
6669    });
6670    // Read uid/gid from /proc/self/status on Linux, fall back to defaults.
6671    let (uid, gid) = read_proc_uid_gid().unwrap_or((1000, 1000));
6672
6673    // Store CPU count; the JS module builds the array at import time.
6674    // This avoids emitting potentially thousands of chars of identical entries.
6675
6676    format!(
6677        r#"
6678const _platform = "{node_platform}";
6679const _arch = "{node_arch}";
6680const _type = "{node_type}";
6681const _tmpdir = "{tmpdir}";
6682const _homedir = "{homedir}";
6683const _hostname = "{hostname}";
6684const _eol = "{eol}";
6685const _devNull = "{dev_null}";
6686const _uid = {uid};
6687const _gid = {gid};
6688const _username = "{username}";
6689const _shell = "{shell}";
6690const _numCpus = {num_cpus};
6691const _cpus = [];
6692for (let i = 0; i < _numCpus; i++) _cpus.push({{ model: "cpu", speed: 2400, times: {{ user: 0, nice: 0, sys: 0, idle: 0, irq: 0 }} }});
6693
6694export function homedir() {{
6695  const env_home =
6696    globalThis.pi && globalThis.pi.env && typeof globalThis.pi.env.get === "function"
6697      ? globalThis.pi.env.get("HOME")
6698      : undefined;
6699  return env_home || _homedir;
6700}}
6701export function tmpdir() {{ return _tmpdir; }}
6702export function hostname() {{ return _hostname; }}
6703export function platform() {{ return _platform; }}
6704export function arch() {{ return _arch; }}
6705export function type() {{ return _type; }}
6706export function release() {{ return "6.0.0"; }}
6707export function cpus() {{ return _cpus; }}
6708export function totalmem() {{ return 8 * 1024 * 1024 * 1024; }}
6709export function freemem() {{ return 4 * 1024 * 1024 * 1024; }}
6710export function uptime() {{ return Math.floor(Date.now() / 1000); }}
6711export function loadavg() {{ return [0.0, 0.0, 0.0]; }}
6712export function networkInterfaces() {{ return {{}}; }}
6713export function userInfo(_options) {{
6714  return {{
6715    uid: _uid,
6716    gid: _gid,
6717    username: _username,
6718    homedir: homedir(),
6719    shell: _shell,
6720  }};
6721}}
6722export function endianness() {{ return "LE"; }}
6723export const EOL = _eol;
6724export const devNull = _devNull;
6725export const constants = {{
6726  signals: {{}},
6727  errno: {{}},
6728  priority: {{ PRIORITY_LOW: 19, PRIORITY_BELOW_NORMAL: 10, PRIORITY_NORMAL: 0, PRIORITY_ABOVE_NORMAL: -7, PRIORITY_HIGH: -14, PRIORITY_HIGHEST: -20 }},
6729}};
6730export default {{ homedir, tmpdir, hostname, platform, arch, type, release, cpus, totalmem, freemem, uptime, loadavg, networkInterfaces, userInfo, endianness, EOL, devNull, constants }};
6731"#
6732    )
6733    .trim()
6734    .to_string()
6735}
6736
6737/// Parse uid/gid from `/proc/self/status` (Linux). Returns `None` on other
6738/// platforms or if the file is unreadable.
6739fn read_proc_uid_gid() -> Option<(u32, u32)> {
6740    let status = std::fs::read_to_string("/proc/self/status").ok()?;
6741    let mut uid = None;
6742    let mut gid = None;
6743    for line in status.lines() {
6744        if let Some(rest) = line.strip_prefix("Uid:") {
6745            uid = rest.split_whitespace().next().and_then(|v| v.parse().ok());
6746        } else if let Some(rest) = line.strip_prefix("Gid:") {
6747            gid = rest.split_whitespace().next().and_then(|v| v.parse().ok());
6748        }
6749        if uid.is_some() && gid.is_some() {
6750            break;
6751        }
6752    }
6753    Some((uid?, gid?))
6754}
6755
6756#[allow(clippy::too_many_lines)]
6757fn default_virtual_modules() -> HashMap<String, String> {
6758    let mut modules = HashMap::new();
6759
6760    modules.insert(
6761        "@sinclair/typebox".to_string(),
6762        r#"
6763export const Type = {
6764  String: (opts = {}) => ({ type: "string", ...opts }),
6765  Number: (opts = {}) => ({ type: "number", ...opts }),
6766  Boolean: (opts = {}) => ({ type: "boolean", ...opts }),
6767  Array: (items, opts = {}) => ({ type: "array", items, ...opts }),
6768  Object: (props = {}, opts = {}) => {
6769    const required = [];
6770    const properties = {};
6771    for (const [k, v] of Object.entries(props)) {
6772      if (v && typeof v === "object" && v.__pi_optional) {
6773        properties[k] = v.schema;
6774      } else {
6775        properties[k] = v;
6776        required.push(k);
6777      }
6778    }
6779    const out = { type: "object", properties, ...opts };
6780    if (required.length) out.required = required;
6781    return out;
6782  },
6783  Optional: (schema) => ({ __pi_optional: true, schema }),
6784  Literal: (value, opts = {}) => ({ const: value, ...opts }),
6785  Any: (opts = {}) => ({ ...opts }),
6786  Union: (schemas, opts = {}) => ({ anyOf: schemas, ...opts }),
6787  Enum: (values, opts = {}) => ({ enum: values, ...opts }),
6788  Integer: (opts = {}) => ({ type: "integer", ...opts }),
6789  Null: (opts = {}) => ({ type: "null", ...opts }),
6790  Unknown: (opts = {}) => ({ ...opts }),
6791  Tuple: (items, opts = {}) => ({ type: "array", items, minItems: items.length, maxItems: items.length, ...opts }),
6792  Record: (keySchema, valueSchema, opts = {}) => ({ type: "object", additionalProperties: valueSchema, ...opts }),
6793  Ref: (ref, opts = {}) => ({ $ref: ref, ...opts }),
6794  Intersect: (schemas, opts = {}) => ({ allOf: schemas, ...opts }),
6795};
6796export default { Type };
6797"#
6798        .trim()
6799        .to_string(),
6800    );
6801
6802    modules.insert(
6803        "@mariozechner/pi-ai".to_string(),
6804        r#"
6805export function StringEnum(values, opts = {}) {
6806  const list = Array.isArray(values) ? values.map((v) => String(v)) : [];
6807  return { type: "string", enum: list, ...opts };
6808}
6809
6810export function calculateCost() {}
6811
6812export function createAssistantMessageEventStream() {
6813  return {
6814    push: () => {},
6815    end: () => {},
6816  };
6817}
6818
6819export function streamSimpleAnthropic() {
6820  throw new Error("@mariozechner/pi-ai.streamSimpleAnthropic is not available in PiJS");
6821}
6822
6823export function streamSimpleOpenAIResponses() {
6824  throw new Error("@mariozechner/pi-ai.streamSimpleOpenAIResponses is not available in PiJS");
6825}
6826
6827export async function complete(_model, _messages, _opts = {}) {
6828  // Return a minimal completion response stub
6829  return { content: "", model: _model ?? "unknown", usage: { input_tokens: 0, output_tokens: 0 } };
6830}
6831
6832// Stub: completeSimple returns a simple text completion without streaming
6833export async function completeSimple(_model, _prompt, _opts = {}) {
6834  // Return an empty string completion
6835  return "";
6836}
6837
6838export function getModel() {
6839  // Return a default model identifier
6840  return "claude-sonnet-4-5";
6841}
6842
6843export function getApiProvider() {
6844  // Return a default provider identifier
6845  return "anthropic";
6846}
6847
6848export function getModels() {
6849  // Return a list of available model identifiers
6850  return ["claude-sonnet-4-5", "claude-haiku-3-5"];
6851}
6852
6853export async function loginOpenAICodex(_opts = {}) {
6854  return { accessToken: "", refreshToken: "", expiresAt: Date.now() + 3600000 };
6855}
6856
6857export async function refreshOpenAICodexToken(_refreshToken) {
6858  return { accessToken: "", refreshToken: "", expiresAt: Date.now() + 3600000 };
6859}
6860
6861export default { StringEnum, calculateCost, createAssistantMessageEventStream, streamSimpleAnthropic, streamSimpleOpenAIResponses, complete, completeSimple, getModel, getApiProvider, getModels, loginOpenAICodex, refreshOpenAICodexToken };
6862"#
6863        .trim()
6864        .to_string(),
6865    );
6866
6867    modules.insert(
6868        "@mariozechner/pi-tui".to_string(),
6869        r#"
6870export function matchesKey(_data, _key) {
6871  return false;
6872}
6873
6874export function truncateToWidth(text, width) {
6875  const s = String(text ?? "");
6876  const w = Number(width ?? 0);
6877  if (!w || w <= 0) return "";
6878  return s.length <= w ? s : s.slice(0, w);
6879}
6880
6881export class Text {
6882  constructor(text, x = 0, y = 0) {
6883    this.text = String(text ?? "");
6884    this.x = x;
6885    this.y = y;
6886  }
6887}
6888
6889export class TruncatedText extends Text {
6890  constructor(text, width = 80, x = 0, y = 0) {
6891    super(text, x, y);
6892    this.width = Number(width ?? 80);
6893  }
6894}
6895
6896export class Container {
6897  constructor(..._args) {}
6898}
6899
6900export class Markdown {
6901  constructor(..._args) {}
6902}
6903
6904export class Spacer {
6905  constructor(..._args) {}
6906}
6907
6908export function visibleWidth(str) {
6909  return String(str ?? "").length;
6910}
6911
6912export function wrapTextWithAnsi(text, _width) {
6913  return String(text ?? "");
6914}
6915
6916export class Editor {
6917  constructor(_opts = {}) {
6918    this.value = "";
6919  }
6920}
6921
6922export const CURSOR_MARKER = "▌";
6923
6924export function isKeyRelease(_data) {
6925  return false;
6926}
6927
6928export function parseKey(key) {
6929  return { key: String(key ?? "") };
6930}
6931
6932export class Box {
6933  constructor(_padX = 0, _padY = 0, _styleFn = null) {
6934    this.children = [];
6935  }
6936
6937  addChild(child) {
6938    this.children.push(child);
6939  }
6940}
6941
6942export class SelectList {
6943  constructor(items = [], _opts = {}) {
6944    this.items = Array.isArray(items) ? items : [];
6945    this.selected = 0;
6946  }
6947
6948  setItems(items) {
6949    this.items = Array.isArray(items) ? items : [];
6950  }
6951
6952  select(index) {
6953    const i = Number(index ?? 0);
6954    this.selected = Number.isFinite(i) ? i : 0;
6955  }
6956}
6957
6958export class Input {
6959  constructor(_opts = {}) {
6960    this.value = "";
6961  }
6962}
6963
6964export const Key = {
6965  // Special keys
6966  escape: "escape",
6967  esc: "esc",
6968  enter: "enter",
6969  tab: "tab",
6970  space: "space",
6971  backspace: "backspace",
6972  delete: "delete",
6973  home: "home",
6974  end: "end",
6975  pageUp: "pageUp",
6976  pageDown: "pageDown",
6977  up: "up",
6978  down: "down",
6979  left: "left",
6980  right: "right",
6981  // Single modifiers
6982  ctrl: (key) => `ctrl+${key}`,
6983  shift: (key) => `shift+${key}`,
6984  alt: (key) => `alt+${key}`,
6985  // Combined modifiers
6986  ctrlShift: (key) => `ctrl+shift+${key}`,
6987  shiftCtrl: (key) => `shift+ctrl+${key}`,
6988  ctrlAlt: (key) => `ctrl+alt+${key}`,
6989  altCtrl: (key) => `alt+ctrl+${key}`,
6990  shiftAlt: (key) => `shift+alt+${key}`,
6991  altShift: (key) => `alt+shift+${key}`,
6992  ctrlAltShift: (key) => `ctrl+alt+shift+${key}`,
6993};
6994
6995export class DynamicBorder {
6996  constructor(_styleFn = null) {
6997    this.styleFn = _styleFn;
6998  }
6999}
7000
7001export class SettingsList {
7002  constructor(_opts = {}) {
7003    this.items = [];
7004  }
7005
7006  setItems(items) {
7007    this.items = Array.isArray(items) ? items : [];
7008  }
7009}
7010
7011// Fuzzy string matching for filtering lists
7012export function fuzzyMatch(query, text, _opts = {}) {
7013  const q = String(query ?? '').toLowerCase();
7014  const t = String(text ?? '').toLowerCase();
7015  if (!q) return { match: true, score: 0, positions: [] };
7016  if (!t) return { match: false, score: 0, positions: [] };
7017
7018  const positions = [];
7019  let qi = 0;
7020  for (let ti = 0; ti < t.length && qi < q.length; ti++) {
7021    if (t[ti] === q[qi]) {
7022      positions.push(ti);
7023      qi++;
7024    }
7025  }
7026
7027  const match = qi === q.length;
7028  const score = match ? (q.length / t.length) * 100 : 0;
7029  return { match, score, positions };
7030}
7031
7032// Get editor keybindings configuration
7033export function getEditorKeybindings() {
7034  return {
7035    save: 'ctrl+s',
7036    quit: 'ctrl+q',
7037    copy: 'ctrl+c',
7038    paste: 'ctrl+v',
7039    undo: 'ctrl+z',
7040    redo: 'ctrl+y',
7041    find: 'ctrl+f',
7042    replace: 'ctrl+h',
7043  };
7044}
7045
7046// Filter an array of items using fuzzy matching
7047export function fuzzyFilter(query, items, _opts = {}) {
7048  const q = String(query ?? '').toLowerCase();
7049  if (!q) return items;
7050  if (!Array.isArray(items)) return [];
7051  return items.filter(item => {
7052    const text = typeof item === 'string' ? item : String(item?.label ?? item?.name ?? item);
7053    return fuzzyMatch(q, text).match;
7054  });
7055}
7056
7057// Cancellable loader widget - shows loading state with optional cancel
7058export class CancellableLoader {
7059  constructor(message = 'Loading...', opts = {}) {
7060    this.message = String(message ?? 'Loading...');
7061    this.cancelled = false;
7062    this.onCancel = opts.onCancel ?? null;
7063  }
7064
7065  cancel() {
7066    this.cancelled = true;
7067    if (typeof this.onCancel === 'function') {
7068      this.onCancel();
7069    }
7070  }
7071
7072  render() {
7073    return this.cancelled ? [] : [this.message];
7074  }
7075}
7076
7077export class Image {
7078  constructor(src, _opts = {}) {
7079    this.src = String(src ?? "");
7080    this.width = 0;
7081    this.height = 0;
7082  }
7083}
7084
7085export default { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, Text, TruncatedText, Container, Markdown, Spacer, Editor, Box, SelectList, Input, Image, CURSOR_MARKER, isKeyRelease, parseKey, Key, DynamicBorder, SettingsList, fuzzyMatch, getEditorKeybindings, fuzzyFilter, CancellableLoader };
7086"#
7087        .trim()
7088        .to_string(),
7089    );
7090
7091    modules.insert(
7092        "@mariozechner/pi-coding-agent".to_string(),
7093        r#"
7094export const VERSION = "0.0.0";
7095
7096export const DEFAULT_MAX_LINES = 2000;
7097export const DEFAULT_MAX_BYTES = 50 * 1024;
7098
7099export function formatSize(bytes) {
7100  const b = Number(bytes ?? 0);
7101  const KB = 1024;
7102  const MB = 1024 * 1024;
7103  if (b >= MB) return `${(b / MB).toFixed(1)}MB`;
7104  if (b >= KB) return `${(b / KB).toFixed(1)}KB`;
7105  return `${Math.trunc(b)}B`;
7106}
7107
7108function jsBytes(value) {
7109  return String(value ?? "").length;
7110}
7111
7112export function truncateHead(text, opts = {}) {
7113  const raw = String(text ?? "");
7114  const maxLines = Number(opts.maxLines ?? DEFAULT_MAX_LINES);
7115  const maxBytes = Number(opts.maxBytes ?? DEFAULT_MAX_BYTES);
7116
7117  const lines = raw.split("\n");
7118  const totalLines = lines.length;
7119  const totalBytes = jsBytes(raw);
7120
7121  const out = [];
7122  let outBytes = 0;
7123  let truncatedBy = null;
7124
7125  for (const line of lines) {
7126    if (out.length >= maxLines) {
7127      truncatedBy = "lines";
7128      break;
7129    }
7130
7131    const candidate = out.length ? `\n${line}` : line;
7132    const candidateBytes = jsBytes(candidate);
7133    if (outBytes + candidateBytes > maxBytes) {
7134      truncatedBy = "bytes";
7135      break;
7136    }
7137    out.push(line);
7138    outBytes += candidateBytes;
7139  }
7140
7141  const content = out.join("\n");
7142  return {
7143    content,
7144    truncated: truncatedBy != null,
7145    truncatedBy,
7146    totalLines,
7147    totalBytes,
7148    outputLines: out.length,
7149    outputBytes: jsBytes(content),
7150    lastLinePartial: false,
7151    firstLineExceedsLimit: false,
7152    maxLines,
7153    maxBytes,
7154  };
7155}
7156
7157export function truncateTail(text, opts = {}) {
7158  const raw = String(text ?? "");
7159  const maxLines = Number(opts.maxLines ?? DEFAULT_MAX_LINES);
7160  const maxBytes = Number(opts.maxBytes ?? DEFAULT_MAX_BYTES);
7161
7162  const lines = raw.split("\n");
7163  const totalLines = lines.length;
7164  const totalBytes = jsBytes(raw);
7165
7166  const out = [];
7167  let outBytes = 0;
7168  let truncatedBy = null;
7169
7170  for (let i = lines.length - 1; i >= 0; i--) {
7171    if (out.length >= maxLines) {
7172      truncatedBy = "lines";
7173      break;
7174    }
7175    const line = lines[i];
7176    const candidate = out.length ? `${line}\n` : line;
7177    const candidateBytes = jsBytes(candidate);
7178    if (outBytes + candidateBytes > maxBytes) {
7179      truncatedBy = "bytes";
7180      break;
7181    }
7182    out.unshift(line);
7183    outBytes += candidateBytes;
7184  }
7185
7186  const content = out.join("\n");
7187  return {
7188    content,
7189    truncated: truncatedBy != null,
7190    truncatedBy,
7191    totalLines,
7192    totalBytes,
7193    outputLines: out.length,
7194    outputBytes: jsBytes(content),
7195    lastLinePartial: false,
7196    firstLineExceedsLimit: false,
7197    maxLines,
7198    maxBytes,
7199  };
7200}
7201
7202export function parseSessionEntries(text) {
7203  const raw = String(text ?? "");
7204  const out = [];
7205  for (const line of raw.split(/\r?\n/)) {
7206    const trimmed = line.trim();
7207    if (!trimmed) continue;
7208    try {
7209      out.push(JSON.parse(trimmed));
7210    } catch {
7211      // ignore malformed lines
7212    }
7213  }
7214  return out;
7215}
7216
7217export function convertToLlm(entries) {
7218  return entries;
7219}
7220
7221export function serializeConversation(entries) {
7222  try {
7223    return JSON.stringify(entries ?? []);
7224  } catch {
7225    return String(entries ?? "");
7226  }
7227}
7228
7229export function parseFrontmatter(text) {
7230  const raw = String(text ?? "");
7231  if (!raw.startsWith("---")) return { frontmatter: {}, body: raw };
7232  const end = raw.indexOf("\n---", 3);
7233  if (end === -1) return { frontmatter: {}, body: raw };
7234
7235  const header = raw.slice(3, end).trim();
7236  const body = raw.slice(end + 4).replace(/^\n/, "");
7237  const frontmatter = {};
7238  for (const line of header.split(/\r?\n/)) {
7239    const idx = line.indexOf(":");
7240    if (idx === -1) continue;
7241    const key = line.slice(0, idx).trim();
7242    const val = line.slice(idx + 1).trim();
7243    if (!key) continue;
7244    frontmatter[key] = val;
7245  }
7246  return { frontmatter, body };
7247}
7248
7249export function getMarkdownTheme() {
7250  return {};
7251}
7252
7253export function getSettingsListTheme() {
7254  return {};
7255}
7256
7257export function getSelectListTheme() {
7258  return {};
7259}
7260
7261export class DynamicBorder {
7262  constructor(..._args) {}
7263}
7264
7265export class BorderedLoader {
7266  constructor(..._args) {}
7267}
7268
7269export class CustomEditor {
7270  constructor(_opts = {}) {
7271    this.value = "";
7272  }
7273
7274  handleInput(_data) {}
7275
7276  render(_width) {
7277    return [];
7278  }
7279}
7280
7281export function createBashTool(_cwd, _opts = {}) {
7282  return {
7283    name: "bash",
7284    label: "bash",
7285    description: "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.",
7286    parameters: {
7287      type: "object",
7288      properties: {
7289        command: { type: "string", description: "The bash command to execute" },
7290        timeout: { type: "number", description: "Optional timeout in seconds" },
7291      },
7292      required: ["command"],
7293    },
7294    async execute(_id, params) {
7295      return { content: [{ type: "text", text: String(params?.command ?? "") }], details: {} };
7296    },
7297  };
7298}
7299
7300export function createReadTool(_cwd, _opts = {}) {
7301  return {
7302    name: "read",
7303    label: "read",
7304    description: "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.",
7305    parameters: {
7306      type: "object",
7307      properties: {
7308        path: { type: "string", description: "The path to the file to read" },
7309        offset: { type: "number", description: "Line offset to start reading from (0-indexed)" },
7310        limit: { type: "number", description: "Maximum number of lines to read" },
7311      },
7312      required: ["path"],
7313    },
7314    async execute(_id, _params) {
7315      return { content: [{ type: "text", text: "" }], details: {} };
7316    },
7317  };
7318}
7319
7320export function createLsTool(_cwd, _opts = {}) {
7321  return {
7322    name: "ls",
7323    label: "ls",
7324    description: "List files and directories. Returns names, sizes, and metadata.",
7325    parameters: {
7326      type: "object",
7327      properties: {
7328        path: { type: "string", description: "The path to list" },
7329      },
7330      required: ["path"],
7331    },
7332    async execute(_id, _params) {
7333      return { content: [{ type: "text", text: "" }], details: {} };
7334    },
7335  };
7336}
7337
7338export function createGrepTool(_cwd, _opts = {}) {
7339  return {
7340    name: "grep",
7341    label: "grep",
7342    description: "Search file contents using regular expressions.",
7343    parameters: {
7344      type: "object",
7345      properties: {
7346        pattern: { type: "string", description: "The regex pattern to search for" },
7347        path: { type: "string", description: "The path to search in" },
7348      },
7349      required: ["pattern"],
7350    },
7351    async execute(_id, _params) {
7352      return { content: [{ type: "text", text: "" }], details: {} };
7353    },
7354  };
7355}
7356
7357export function createWriteTool(_cwd, _opts = {}) {
7358  return {
7359    name: "write",
7360    label: "write",
7361    description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
7362    parameters: {
7363      type: "object",
7364      properties: {
7365        path: { type: "string", description: "The path to the file to write" },
7366        content: { type: "string", description: "The content to write to the file" },
7367      },
7368      required: ["path", "content"],
7369    },
7370    async execute(_id, _params) {
7371      return { content: [{ type: "text", text: "" }], details: {} };
7372    },
7373  };
7374}
7375
7376export function createEditTool(_cwd, _opts = {}) {
7377  return {
7378    name: "edit",
7379    label: "edit",
7380    description: "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
7381    parameters: {
7382      type: "object",
7383      properties: {
7384        path: { type: "string", description: "The path to the file to edit" },
7385        oldText: { type: "string", minLength: 1, description: "The exact text to find and replace" },
7386        newText: { type: "string", description: "The text to replace oldText with" },
7387      },
7388      required: ["path", "oldText", "newText"],
7389    },
7390    async execute(_id, _params) {
7391      return { content: [{ type: "text", text: "" }], details: {} };
7392    },
7393  };
7394}
7395
7396export function copyToClipboard(_text) {
7397  return;
7398}
7399
7400export function getAgentDir() {
7401  const home =
7402    globalThis.pi && globalThis.pi.env && typeof globalThis.pi.env.get === "function"
7403      ? globalThis.pi.env.get("HOME")
7404      : undefined;
7405  return home ? `${home}/.pi/agent` : "/home/unknown/.pi/agent";
7406}
7407
7408// Stub: keyHint returns a keyboard shortcut hint string for UI display
7409export function keyHint(action, fallback = "") {
7410  // Map action names to default key bindings
7411  const keyMap = {
7412    expandTools: "Ctrl+E",
7413    copy: "Ctrl+C",
7414    paste: "Ctrl+V",
7415    save: "Ctrl+S",
7416    quit: "Ctrl+Q",
7417    help: "?",
7418  };
7419  return keyMap[action] || fallback || action;
7420}
7421
7422// Stub: compact performs conversation compaction via LLM
7423export async function compact(_preparation, _model, _apiKey, _customInstructions, _signal) {
7424  // Return a minimal compaction result
7425  return {
7426    summary: "Conversation summary placeholder",
7427    firstKeptEntryId: null,
7428    tokensBefore: 0,
7429    tokensAfter: 0,
7430  };
7431}
7432
7433/// Stub: AssistantMessageComponent for rendering assistant messages
7434export class AssistantMessageComponent {
7435  constructor(message, editable = false) {
7436    this.message = message;
7437    this.editable = editable;
7438  }
7439
7440  render() {
7441    return [];
7442  }
7443}
7444
7445// Stub: ToolExecutionComponent for rendering tool executions
7446export class ToolExecutionComponent {
7447  constructor(toolName, args, opts = {}, result, ui) {
7448    this.toolName = toolName;
7449    this.args = args;
7450    this.opts = opts;
7451    this.result = result;
7452    this.ui = ui;
7453  }
7454
7455  render() {
7456    return [];
7457  }
7458}
7459
7460// Stub: UserMessageComponent for rendering user messages
7461export class UserMessageComponent {
7462  constructor(text) {
7463    this.text = text;
7464  }
7465
7466  render() {
7467    return [];
7468  }
7469}
7470
7471export class SessionManager {
7472  constructor() {}
7473  static inMemory() { return new SessionManager(); }
7474  getSessionFile() { return ""; }
7475  getSessionDir() { return ""; }
7476  getSessionId() { return ""; }
7477}
7478
7479export class SettingsManager {
7480  constructor(cwd = "", agentDir = "") {
7481    this.cwd = String(cwd ?? "");
7482    this.agentDir = String(agentDir ?? "");
7483  }
7484  static create(cwd, agentDir) { return new SettingsManager(cwd, agentDir); }
7485}
7486
7487export class DefaultResourceLoader {
7488  constructor(opts = {}) {
7489    this.opts = opts;
7490  }
7491  async reload() { return; }
7492}
7493
7494export function highlightCode(code, _lang, _theme) {
7495  return String(code ?? "");
7496}
7497
7498export function getLanguageFromPath(filePath) {
7499  const ext = String(filePath ?? "").split(".").pop() || "";
7500  const map = { ts: "typescript", js: "javascript", py: "python", rs: "rust", go: "go", md: "markdown", json: "json", html: "html", css: "css", sh: "bash" };
7501  return map[ext] || ext;
7502}
7503
7504export function isBashToolResult(result) {
7505  return result && typeof result === "object" && result.name === "bash";
7506}
7507
7508export async function loadSkills() {
7509  return [];
7510}
7511
7512export function truncateToVisualLines(text, maxLines = DEFAULT_MAX_LINES) {
7513  const raw = String(text ?? "");
7514  const lines = raw.split(/\r?\n/);
7515  if (!Number.isFinite(maxLines) || maxLines <= 0) return "";
7516  return lines.slice(0, Math.floor(maxLines)).join("\n");
7517}
7518
7519export function estimateTokens(input) {
7520  const raw = typeof input === "string" ? input : JSON.stringify(input ?? "");
7521  // Deterministic rough heuristic (chars / 4).
7522  return Math.max(1, Math.ceil(String(raw).length / 4));
7523}
7524
7525export function isToolCallEventType(value) {
7526  const t = String(value?.type ?? value ?? "").toLowerCase();
7527  return t === "tool_call" || t === "tool-call" || t === "toolcall";
7528}
7529
7530export class AuthStorage {
7531  constructor() {}
7532  static load() { return new AuthStorage(); }
7533  static async loadAsync() { return new AuthStorage(); }
7534  resolveApiKey(_provider) { return undefined; }
7535  get(_provider) { return undefined; }
7536}
7537
7538export function createAgentSession(opts = {}) {
7539  const state = {
7540    id: String(opts.id ?? "session"),
7541    messages: Array.isArray(opts.messages) ? opts.messages.slice() : [],
7542  };
7543  return {
7544    id: state.id,
7545    messages: state.messages,
7546    append(entry) { state.messages.push(entry); },
7547    toJSON() { return { id: state.id, messages: state.messages.slice() }; },
7548  };
7549}
7550
7551export default {
7552  VERSION,
7553  DEFAULT_MAX_LINES,
7554  DEFAULT_MAX_BYTES,
7555  formatSize,
7556  truncateHead,
7557  truncateTail,
7558  parseSessionEntries,
7559  convertToLlm,
7560  serializeConversation,
7561  parseFrontmatter,
7562  getMarkdownTheme,
7563  getSettingsListTheme,
7564  getSelectListTheme,
7565  DynamicBorder,
7566  BorderedLoader,
7567  CustomEditor,
7568  createBashTool,
7569  createReadTool,
7570  createLsTool,
7571  createGrepTool,
7572  createWriteTool,
7573  createEditTool,
7574  copyToClipboard,
7575  getAgentDir,
7576  keyHint,
7577  compact,
7578  AssistantMessageComponent,
7579  ToolExecutionComponent,
7580  UserMessageComponent,
7581  SessionManager,
7582  SettingsManager,
7583  DefaultResourceLoader,
7584  highlightCode,
7585  getLanguageFromPath,
7586  isBashToolResult,
7587  loadSkills,
7588  truncateToVisualLines,
7589  estimateTokens,
7590  isToolCallEventType,
7591  AuthStorage,
7592  createAgentSession,
7593};
7594"#
7595        .trim()
7596        .to_string(),
7597    );
7598
7599    modules.insert(
7600        "@anthropic-ai/sdk".to_string(),
7601        r"
7602export default class Anthropic {
7603  constructor(_opts = {}) {}
7604}
7605"
7606        .trim()
7607        .to_string(),
7608    );
7609
7610    modules.insert(
7611        "@anthropic-ai/sandbox-runtime".to_string(),
7612        r"
7613export const SandboxManager = {
7614  initialize: async (_config) => {},
7615  reset: async () => {},
7616};
7617export default { SandboxManager };
7618"
7619        .trim()
7620        .to_string(),
7621    );
7622
7623    modules.insert(
7624        "ms".to_string(),
7625        r#"
7626function parseMs(text) {
7627  const s = String(text ?? "").trim();
7628  if (!s) return undefined;
7629
7630  const match = s.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i);
7631  if (!match) return undefined;
7632  const value = Number(match[1]);
7633  const unit = (match[2] || "ms").toLowerCase();
7634  const mult = unit === "ms" ? 1 :
7635               unit === "s"  ? 1000 :
7636               unit === "m"  ? 60000 :
7637               unit === "h"  ? 3600000 :
7638               unit === "d"  ? 86400000 :
7639               unit === "w"  ? 604800000 :
7640               unit === "y"  ? 31536000000 : 1;
7641  return Math.round(value * mult);
7642}
7643
7644export default function ms(value) {
7645  return parseMs(value);
7646}
7647
7648export const parse = parseMs;
7649"#
7650        .trim()
7651        .to_string(),
7652    );
7653
7654    modules.insert(
7655        "jsonwebtoken".to_string(),
7656        r#"
7657export function sign() {
7658  throw new Error("jsonwebtoken.sign is not available in PiJS");
7659}
7660
7661export function verify() {
7662  throw new Error("jsonwebtoken.verify is not available in PiJS");
7663}
7664
7665export function decode() {
7666  return null;
7667}
7668
7669export default { sign, verify, decode };
7670"#
7671        .trim()
7672        .to_string(),
7673    );
7674
7675    // ── shell-quote ──────────────────────────────────────────────────
7676    modules.insert(
7677        "shell-quote".to_string(),
7678        r#"
7679export function parse(cmd) {
7680  if (typeof cmd !== 'string') return [];
7681  const args = [];
7682  let current = '';
7683  let inSingle = false;
7684  let inDouble = false;
7685  let escaped = false;
7686  for (let i = 0; i < cmd.length; i++) {
7687    const ch = cmd[i];
7688    if (escaped) { current += ch; escaped = false; continue; }
7689    if (ch === '\\' && !inSingle) { escaped = true; continue; }
7690    if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
7691    if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
7692    if ((ch === ' ' || ch === '\t') && !inSingle && !inDouble) {
7693      if (current) { args.push(current); current = ''; }
7694      continue;
7695    }
7696    current += ch;
7697  }
7698  if (current) args.push(current);
7699  return args;
7700}
7701export function quote(args) {
7702  if (!Array.isArray(args)) return '';
7703  return args.map(a => {
7704    if (/[^a-zA-Z0-9_\-=:./]/.test(a)) return "'" + a.replace(/'/g, "'\\''") + "'";
7705    return a;
7706  }).join(' ');
7707}
7708export default { parse, quote };
7709"#
7710        .trim()
7711        .to_string(),
7712    );
7713
7714    // ── vscode-languageserver-protocol ──────────────────────────────
7715    {
7716        let vls = r"
7717export const DiagnosticSeverity = { Error: 1, Warning: 2, Information: 3, Hint: 4 };
7718export const CodeActionKind = { QuickFix: 'quickfix', Refactor: 'refactor', RefactorExtract: 'refactor.extract', RefactorInline: 'refactor.inline', RefactorRewrite: 'refactor.rewrite', Source: 'source', SourceOrganizeImports: 'source.organizeImports', SourceFixAll: 'source.fixAll' };
7719export const DocumentDiagnosticReportKind = { Full: 'full', Unchanged: 'unchanged' };
7720export const SymbolKind = { File: 1, Module: 2, Namespace: 3, Package: 4, Class: 5, Method: 6, Property: 7, Field: 8, Constructor: 9, Enum: 10, Interface: 11, Function: 12, Variable: 13, Constant: 14 };
7721function makeReqType(m) { return { type: { get method() { return m; } }, method: m }; }
7722function makeNotifType(m) { return { type: { get method() { return m; } }, method: m }; }
7723export const InitializeRequest = makeReqType('initialize');
7724export const DefinitionRequest = makeReqType('textDocument/definition');
7725export const ReferencesRequest = makeReqType('textDocument/references');
7726export const HoverRequest = makeReqType('textDocument/hover');
7727export const SignatureHelpRequest = makeReqType('textDocument/signatureHelp');
7728export const DocumentSymbolRequest = makeReqType('textDocument/documentSymbol');
7729export const RenameRequest = makeReqType('textDocument/rename');
7730export const CodeActionRequest = makeReqType('textDocument/codeAction');
7731export const DocumentDiagnosticRequest = makeReqType('textDocument/diagnostic');
7732export const WorkspaceDiagnosticRequest = makeReqType('workspace/diagnostic');
7733export const InitializedNotification = makeNotifType('initialized');
7734export const DidOpenTextDocumentNotification = makeNotifType('textDocument/didOpen');
7735export const DidChangeTextDocumentNotification = makeNotifType('textDocument/didChange');
7736export const DidCloseTextDocumentNotification = makeNotifType('textDocument/didClose');
7737export const DidSaveTextDocumentNotification = makeNotifType('textDocument/didSave');
7738export const PublishDiagnosticsNotification = makeNotifType('textDocument/publishDiagnostics');
7739export function createMessageConnection(_reader, _writer) {
7740  return {
7741    listen() {},
7742    sendRequest() { return Promise.resolve(null); },
7743    sendNotification() {},
7744    onNotification() {},
7745    onRequest() {},
7746    onClose() {},
7747    dispose() {},
7748  };
7749}
7750export class StreamMessageReader { constructor(_s) {} }
7751export class StreamMessageWriter { constructor(_s) {} }
7752"
7753        .trim()
7754        .to_string();
7755
7756        modules.insert("vscode-languageserver-protocol".to_string(), vls.clone());
7757        modules.insert(
7758            "vscode-languageserver-protocol/node.js".to_string(),
7759            vls.clone(),
7760        );
7761        modules.insert("vscode-languageserver-protocol/node".to_string(), vls);
7762    }
7763
7764    // ── @modelcontextprotocol/sdk ──────────────────────────────────
7765    {
7766        let mcp_client = r"
7767export class Client {
7768  constructor(_opts = {}) {}
7769  async connect(_transport) {}
7770  async listTools() { return { tools: [] }; }
7771  async listResources() { return { resources: [] }; }
7772  async callTool(_name, _args) { return { content: [] }; }
7773  async close() {}
7774}
7775"
7776        .trim()
7777        .to_string();
7778
7779        let mcp_transport = r"
7780export class StdioClientTransport {
7781  constructor(_opts = {}) {}
7782  async start() {}
7783  async close() {}
7784}
7785"
7786        .trim()
7787        .to_string();
7788
7789        modules.insert(
7790            "@modelcontextprotocol/sdk/client/index.js".to_string(),
7791            mcp_client.clone(),
7792        );
7793        modules.insert(
7794            "@modelcontextprotocol/sdk/client/index".to_string(),
7795            mcp_client,
7796        );
7797        modules.insert(
7798            "@modelcontextprotocol/sdk/client/stdio.js".to_string(),
7799            mcp_transport,
7800        );
7801        modules.insert(
7802            "@modelcontextprotocol/sdk/client/streamableHttp.js".to_string(),
7803            r"
7804export class StreamableHTTPClientTransport {
7805  constructor(_opts = {}) {}
7806  async start() {}
7807  async close() {}
7808}
7809"
7810            .trim()
7811            .to_string(),
7812        );
7813        modules.insert(
7814            "@modelcontextprotocol/sdk/client/sse.js".to_string(),
7815            r"
7816export class SSEClientTransport {
7817  constructor(_opts = {}) {}
7818  async start() {}
7819  async close() {}
7820}
7821"
7822            .trim()
7823            .to_string(),
7824        );
7825    }
7826
7827    // ── glob ────────────────────────────────────────────────────────
7828    modules.insert(
7829        "glob".to_string(),
7830        r#"
7831export function globSync(pattern, _opts = {}) { return []; }
7832export function glob(pattern, optsOrCb, cb) {
7833  const callback = typeof optsOrCb === "function" ? optsOrCb : cb;
7834  if (typeof callback === "function") callback(null, []);
7835  return Promise.resolve([]);
7836}
7837export class Glob {
7838  constructor(_pattern, _opts = {}) { this.found = []; }
7839  on() { return this; }
7840}
7841export default { globSync, glob, Glob };
7842"#
7843        .trim()
7844        .to_string(),
7845    );
7846
7847    // ── uuid ────────────────────────────────────────────────────────
7848    modules.insert(
7849        "uuid".to_string(),
7850        r#"
7851function randomHex(n) {
7852  let out = "";
7853  for (let i = 0; i < n; i++) out += Math.floor(Math.random() * 16).toString(16);
7854  return out;
7855}
7856export function v4() {
7857  return [randomHex(8), randomHex(4), "4" + randomHex(3), ((8 + Math.floor(Math.random() * 4)).toString(16)) + randomHex(3), randomHex(12)].join("-");
7858}
7859export function v7() {
7860  const ts = Date.now().toString(16).padStart(12, "0");
7861  return [ts.slice(0, 8), ts.slice(8) + randomHex(1), "7" + randomHex(3), ((8 + Math.floor(Math.random() * 4)).toString(16)) + randomHex(3), randomHex(12)].join("-");
7862}
7863export function v1() { return v4(); }
7864export function v3() { return v4(); }
7865export function v5() { return v4(); }
7866export function validate(uuid) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(uuid ?? "")); }
7867export function version(uuid) { return parseInt(String(uuid ?? "").charAt(14), 16) || 0; }
7868export default { v1, v3, v4, v5, v7, validate, version };
7869"#
7870        .trim()
7871        .to_string(),
7872    );
7873
7874    // ── diff ────────────────────────────────────────────────────────
7875    modules.insert(
7876        "diff".to_string(),
7877        r#"
7878export function createTwoFilesPatch(oldFile, newFile, oldStr, newStr, _oldHeader, _newHeader, _opts) {
7879  const oldLines = String(oldStr ?? "").split("\n");
7880  const newLines = String(newStr ?? "").split("\n");
7881  let patch = `--- ${oldFile}\n+++ ${newFile}\n@@ -1,${oldLines.length} +1,${newLines.length} @@\n`;
7882  for (const line of oldLines) patch += `-${line}\n`;
7883  for (const line of newLines) patch += `+${line}\n`;
7884  return patch;
7885}
7886export function createPatch(fileName, oldStr, newStr, oldH, newH, opts) {
7887  return createTwoFilesPatch(fileName, fileName, oldStr, newStr, oldH, newH, opts);
7888}
7889export function diffLines(oldStr, newStr) {
7890  return [{ value: String(oldStr ?? ""), removed: true, added: false }, { value: String(newStr ?? ""), removed: false, added: true }];
7891}
7892export function diffChars(o, n) { return diffLines(o, n); }
7893export function diffWords(o, n) { return diffLines(o, n); }
7894export function applyPatch() { return false; }
7895export default { createTwoFilesPatch, createPatch, diffLines, diffChars, diffWords, applyPatch };
7896"#
7897        .trim()
7898        .to_string(),
7899    );
7900
7901    // ── just-bash ──────────────────────────────────────────────────
7902    modules.insert(
7903        "just-bash".to_string(),
7904        r#"
7905export function bash(_cmd, _opts) { return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 }); }
7906export { bash as Bash };
7907export default bash;
7908"#
7909        .trim()
7910        .to_string(),
7911    );
7912
7913    // ── bunfig ─────────────────────────────────────────────────────
7914    modules.insert(
7915        "bunfig".to_string(),
7916        r"
7917export function define(_schema) { return {}; }
7918export async function loadConfig(opts) {
7919  const defaults = (opts && opts.defaultConfig) ? opts.defaultConfig : {};
7920  return { ...defaults };
7921}
7922export default { define, loadConfig };
7923"
7924        .trim()
7925        .to_string(),
7926    );
7927
7928    // ── bun ────────────────────────────────────────────────────────
7929    modules.insert(
7930        "bun".to_string(),
7931        r"
7932const bun = globalThis.Bun || {};
7933export const argv = bun.argv || [];
7934export const file = (...args) => bun.file(...args);
7935export const write = (...args) => bun.write(...args);
7936export const spawn = (...args) => bun.spawn(...args);
7937export const which = (...args) => bun.which(...args);
7938export default bun;
7939"
7940        .trim()
7941        .to_string(),
7942    );
7943
7944    // ── dotenv ─────────────────────────────────────────────────────
7945    modules.insert(
7946        "dotenv".to_string(),
7947        r#"
7948export function config(_opts) { return { parsed: {} }; }
7949export function parse(src) {
7950  const result = {};
7951  for (const line of String(src ?? "").split("\n")) {
7952    const idx = line.indexOf("=");
7953    if (idx === -1) continue;
7954    const key = line.slice(0, idx).trim();
7955    const val = line.slice(idx + 1).trim().replace(/^["']|["']$/g, "");
7956    if (key) result[key] = val;
7957  }
7958  return result;
7959}
7960export default { config, parse };
7961"#
7962        .trim()
7963        .to_string(),
7964    );
7965
7966    modules.insert(
7967        "node:path".to_string(),
7968        r#"
7969function __pi_is_abs(s) {
7970  return s.startsWith("/") || (s.length >= 3 && s[1] === ":" && s[2] === "/");
7971}
7972
7973export function join(...parts) {
7974  const cleaned = parts.map((p) => String(p ?? "").replace(/\\/g, "/")).filter((p) => p.length > 0);
7975  if (cleaned.length === 0) return ".";
7976  return normalize(cleaned.join("/"));
7977}
7978
7979export function dirname(p) {
7980  const s = String(p ?? "").replace(/\\/g, "/");
7981  const idx = s.lastIndexOf("/");
7982  if (idx <= 0) return s.startsWith("/") ? "/" : ".";
7983  const dir = s.slice(0, idx);
7984  // Keep trailing slash for drive root: D:/ not D:
7985  if (dir.length === 2 && dir[1] === ":") return dir + "/";
7986  return dir;
7987}
7988
7989export function resolve(...parts) {
7990  const base =
7991    globalThis.pi && globalThis.pi.process && typeof globalThis.pi.process.cwd === "string"
7992      ? globalThis.pi.process.cwd
7993      : "/";
7994  const cleaned = parts
7995    .map((p) => String(p ?? "").replace(/\\/g, "/"))
7996    .filter((p) => p.length > 0);
7997
7998  let out = "";
7999  for (const part of cleaned) {
8000    if (__pi_is_abs(part)) {
8001      out = part;
8002      continue;
8003    }
8004    out = out === "" || out.endsWith("/") ? out + part : out + "/" + part;
8005  }
8006  if (!__pi_is_abs(out)) {
8007    out = base.endsWith("/") ? base + out : base + "/" + out;
8008  }
8009  return normalize(out);
8010}
8011
8012export function basename(p, ext) {
8013  const s = String(p ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
8014  const idx = s.lastIndexOf("/");
8015  const name = idx === -1 ? s : s.slice(idx + 1);
8016  if (ext && name.endsWith(ext)) {
8017    return name.slice(0, -ext.length);
8018  }
8019  return name;
8020}
8021
8022export function relative(from, to) {
8023  const fromParts = String(from ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
8024  const toParts = String(to ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
8025
8026  let common = 0;
8027  while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
8028    common++;
8029  }
8030
8031  const up = fromParts.length - common;
8032  const downs = toParts.slice(common);
8033  const result = [...Array(up).fill(".."), ...downs];
8034  return result.join("/") || ".";
8035}
8036
8037export function isAbsolute(p) {
8038  const s = String(p ?? "").replace(/\\/g, "/");
8039  return __pi_is_abs(s);
8040}
8041
8042export function extname(p) {
8043  const s = String(p ?? "").replace(/\\/g, "/");
8044  const b = s.lastIndexOf("/");
8045  const name = b === -1 ? s : s.slice(b + 1);
8046  const dot = name.lastIndexOf(".");
8047  if (dot <= 0) return "";
8048  return name.slice(dot);
8049}
8050
8051export function normalize(p) {
8052  const s = String(p ?? "").replace(/\\/g, "/");
8053  const isAbs = __pi_is_abs(s);
8054  const parts = s.split("/").filter(Boolean);
8055  const out = [];
8056  for (const part of parts) {
8057    if (part === "..") { if (out.length > 0 && out[out.length - 1] !== "..") out.pop(); else if (!isAbs) out.push(part); }
8058    else if (part !== ".") out.push(part);
8059  }
8060  const result = out.join("/");
8061  if (out.length > 0 && out[0].length === 2 && out[0][1] === ":") return result;
8062  return isAbs ? "/" + result : result || ".";
8063}
8064
8065export function parse(p) {
8066  const s = String(p ?? "").replace(/\\/g, "/");
8067  const isAbs = s.startsWith("/");
8068  const lastSlash = s.lastIndexOf("/");
8069  const dir = lastSlash === -1 ? "" : s.slice(0, lastSlash) || (isAbs ? "/" : "");
8070  const base = lastSlash === -1 ? s : s.slice(lastSlash + 1);
8071  const ext = extname(base);
8072  const name = ext ? base.slice(0, -ext.length) : base;
8073  const root = isAbs ? "/" : "";
8074  return { root, dir, base, ext, name };
8075}
8076
8077export function format(pathObj) {
8078  const dir = pathObj.dir || pathObj.root || "";
8079  const base = pathObj.base || (pathObj.name || "") + (pathObj.ext || "");
8080  if (!dir) return base;
8081  return dir === pathObj.root ? dir + base : dir + "/" + base;
8082}
8083
8084export const sep = "/";
8085export const delimiter = ":";
8086export const posix = { join, dirname, resolve, basename, relative, isAbsolute, extname, normalize, parse, format, sep, delimiter };
8087
8088const win32Stub = new Proxy({}, { get(_, prop) { throw new Error("path.win32." + String(prop) + " is not supported (Pi runs on POSIX only)"); } });
8089export const win32 = win32Stub;
8090
8091export default { join, dirname, resolve, basename, relative, isAbsolute, extname, normalize, parse, format, sep, delimiter, posix, win32 };
8092"#
8093        .trim()
8094        .to_string(),
8095    );
8096
8097    modules.insert("node:os".to_string(), build_node_os_module());
8098
8099    modules.insert(
8100        "node:child_process".to_string(),
8101        r#"
8102const __pi_child_process_state = (() => {
8103  if (globalThis.__pi_child_process_state) {
8104    return globalThis.__pi_child_process_state;
8105  }
8106  const state = {
8107    nextPid: 1000,
8108    children: new Map(),
8109  };
8110  globalThis.__pi_child_process_state = state;
8111  return state;
8112})();
8113
8114function __makeEmitter() {
8115  const listeners = new Map();
8116  const emitter = {
8117    on(event, listener) {
8118      const key = String(event);
8119      if (!listeners.has(key)) listeners.set(key, []);
8120      listeners.get(key).push(listener);
8121      return emitter;
8122    },
8123    once(event, listener) {
8124      const wrapper = (...args) => {
8125        emitter.off(event, wrapper);
8126        listener(...args);
8127      };
8128      return emitter.on(event, wrapper);
8129    },
8130    off(event, listener) {
8131      const key = String(event);
8132      const bucket = listeners.get(key);
8133      if (!bucket) return emitter;
8134      const idx = bucket.indexOf(listener);
8135      if (idx >= 0) bucket.splice(idx, 1);
8136      if (bucket.length === 0) listeners.delete(key);
8137      return emitter;
8138    },
8139    removeListener(event, listener) {
8140      return emitter.off(event, listener);
8141    },
8142    emit(event, ...args) {
8143      const key = String(event);
8144      const bucket = listeners.get(key) || [];
8145      for (const listener of [...bucket]) {
8146        try {
8147          listener(...args);
8148        } catch (_) {}
8149      }
8150      return emitter;
8151    },
8152  };
8153  return emitter;
8154}
8155
8156function __emitCloseOnce(child, code, signal = null) {
8157  if (child.__pi_done) return;
8158  child.__pi_done = true;
8159  child.exitCode = code;
8160  child.signalCode = signal;
8161  __pi_child_process_state.children.delete(child.pid);
8162  child.emit("exit", code, signal);
8163  child.emit("close", code, signal);
8164}
8165
8166function __parseSpawnOptions(raw) {
8167  const options = raw && typeof raw === "object" ? raw : {};
8168  const allowed = new Set(["cwd", "detached", "shell", "stdio", "timeout"]);
8169  for (const key of Object.keys(options)) {
8170    if (!allowed.has(key)) {
8171      throw new Error(`node:child_process.spawn: unsupported option '${key}'`);
8172    }
8173  }
8174
8175  if (options.shell !== undefined && options.shell !== false) {
8176    throw new Error("node:child_process.spawn: only shell=false is supported in PiJS");
8177  }
8178
8179  let stdio = ["pipe", "pipe", "pipe"];
8180  if (options.stdio !== undefined) {
8181    if (!Array.isArray(options.stdio)) {
8182      throw new Error("node:child_process.spawn: options.stdio must be an array");
8183    }
8184    if (options.stdio.length !== 3) {
8185      throw new Error("node:child_process.spawn: options.stdio must have exactly 3 entries");
8186    }
8187    stdio = options.stdio.map((entry, idx) => {
8188      const value = String(entry ?? "");
8189      if (value !== "ignore" && value !== "pipe") {
8190        throw new Error(
8191          `node:child_process.spawn: unsupported stdio[${idx}] value '${value}'`,
8192        );
8193      }
8194      return value;
8195    });
8196  }
8197
8198  const cwd =
8199    typeof options.cwd === "string" && options.cwd.trim().length > 0
8200      ? options.cwd
8201      : undefined;
8202  let timeoutMs = undefined;
8203  if (options.timeout !== undefined) {
8204    if (
8205      typeof options.timeout !== "number" ||
8206      !Number.isFinite(options.timeout) ||
8207      options.timeout < 0
8208    ) {
8209      throw new Error(
8210        "node:child_process.spawn: options.timeout must be a non-negative number",
8211      );
8212    }
8213    timeoutMs = Math.floor(options.timeout);
8214  }
8215
8216  return {
8217    cwd,
8218    detached: Boolean(options.detached),
8219    stdio,
8220    timeoutMs,
8221  };
8222}
8223
8224function __installProcessKillBridge() {
8225  globalThis.__pi_process_kill_impl = (pidValue, signal = "SIGTERM") => {
8226    const pidNumeric = Number(pidValue);
8227    if (!Number.isFinite(pidNumeric) || pidNumeric === 0) {
8228      const err = new Error(`kill EINVAL: invalid pid ${String(pidValue)}`);
8229      err.code = "EINVAL";
8230      throw err;
8231    }
8232    const pid = Math.abs(Math.trunc(pidNumeric));
8233    const child = __pi_child_process_state.children.get(pid);
8234    if (!child) {
8235      const err = new Error(`kill ESRCH: no such process ${pid}`);
8236      err.code = "ESRCH";
8237      throw err;
8238    }
8239    child.kill(signal);
8240    return true;
8241  };
8242}
8243
8244__installProcessKillBridge();
8245
8246export function spawn(command, args = [], options = {}) {
8247  const cmd = String(command ?? "").trim();
8248  if (!cmd) {
8249    throw new Error("node:child_process.spawn: command is required");
8250  }
8251  if (!Array.isArray(args)) {
8252    throw new Error("node:child_process.spawn: args must be an array");
8253  }
8254
8255  const argv = args.map((arg) => String(arg));
8256  const opts = __parseSpawnOptions(options);
8257
8258  const child = __makeEmitter();
8259  child.pid = __pi_child_process_state.nextPid++;
8260  child.killed = false;
8261  child.exitCode = null;
8262  child.signalCode = null;
8263  child.__pi_done = false;
8264  child.__pi_kill_resolver = null;
8265  child.stdout = opts.stdio[1] === "pipe" ? __makeEmitter() : null;
8266  child.stderr = opts.stdio[2] === "pipe" ? __makeEmitter() : null;
8267  child.stdin = opts.stdio[0] === "pipe" ? __makeEmitter() : null;
8268
8269  child.kill = (signal = "SIGTERM") => {
8270    if (child.__pi_done) return false;
8271    child.killed = true;
8272    if (typeof child.__pi_kill_resolver === "function") {
8273      child.__pi_kill_resolver({
8274        kind: "killed",
8275        signal: String(signal || "SIGTERM"),
8276      });
8277      child.__pi_kill_resolver = null;
8278    }
8279    __emitCloseOnce(child, null, String(signal || "SIGTERM"));
8280    return true;
8281  };
8282
8283  __pi_child_process_state.children.set(child.pid, child);
8284
8285  const execOptions = {};
8286  if (opts.cwd !== undefined) execOptions.cwd = opts.cwd;
8287  if (opts.timeoutMs !== undefined) execOptions.timeout = opts.timeoutMs;
8288  const execPromise = pi.exec(cmd, argv, execOptions).then(
8289    (result) => ({ kind: "result", result }),
8290    (error) => ({ kind: "error", error }),
8291  );
8292
8293  const killPromise = new Promise((resolve) => {
8294    child.__pi_kill_resolver = resolve;
8295  });
8296
8297  Promise.race([execPromise, killPromise]).then((outcome) => {
8298    if (!outcome || child.__pi_done) return;
8299
8300    if (outcome.kind === "result") {
8301      const result = outcome.result || {};
8302      if (child.stdout && result.stdout !== undefined && result.stdout !== null && result.stdout !== "") {
8303        child.stdout.emit("data", String(result.stdout));
8304      }
8305      if (child.stderr && result.stderr !== undefined && result.stderr !== null && result.stderr !== "") {
8306        child.stderr.emit("data", String(result.stderr));
8307      }
8308      if (result.killed) {
8309        child.killed = true;
8310      }
8311      const code =
8312        typeof result.code === "number" && Number.isFinite(result.code)
8313          ? result.code
8314          : 0;
8315      const signal =
8316        result.killed || child.killed
8317          ? String(result.signal || "SIGTERM")
8318          : null;
8319      __emitCloseOnce(child, signal ? null : code, signal);
8320      return;
8321    }
8322
8323    if (outcome.kind === "error") {
8324      const source = outcome.error || {};
8325      const error =
8326        source instanceof Error
8327          ? source
8328          : new Error(String(source.message || source || "spawn failed"));
8329      if (!error.code && source && source.code !== undefined) {
8330        error.code = String(source.code);
8331      }
8332      child.emit("error", error);
8333      __emitCloseOnce(child, 1, null);
8334    }
8335  });
8336
8337  return child;
8338}
8339
8340function __parseExecSyncResult(raw, command) {
8341  const result = JSON.parse(raw);
8342  if (result.error) {
8343    const err = new Error(`Command failed: ${command}\n${result.error}`);
8344    err.status = null;
8345    err.stdout = result.stdout || "";
8346    err.stderr = result.stderr || "";
8347    err.pid = result.pid || 0;
8348    err.signal = null;
8349    throw err;
8350  }
8351  if (result.killed) {
8352    const err = new Error(`Command timed out: ${command}`);
8353    err.killed = true;
8354    err.status = result.status;
8355    err.stdout = result.stdout || "";
8356    err.stderr = result.stderr || "";
8357    err.pid = result.pid || 0;
8358    err.signal = "SIGTERM";
8359    throw err;
8360  }
8361  return result;
8362}
8363
8364export function spawnSync(command, argsInput, options) {
8365  const cmd = String(command ?? "").trim();
8366  if (!cmd) {
8367    throw new Error("node:child_process.spawnSync: command is required");
8368  }
8369  const args = Array.isArray(argsInput) ? argsInput.map(String) : [];
8370  const opts = (typeof argsInput === "object" && !Array.isArray(argsInput))
8371    ? argsInput
8372    : (options || {});
8373  const cwd = typeof opts.cwd === "string" ? opts.cwd : "";
8374  const timeout = typeof opts.timeout === "number" ? opts.timeout : 0;
8375  const maxBuffer = typeof opts.maxBuffer === "number" ? opts.maxBuffer : 1024 * 1024;
8376
8377  let result;
8378  try {
8379    const raw = __pi_exec_sync_native(cmd, JSON.stringify(args), cwd, timeout, maxBuffer);
8380    result = JSON.parse(raw);
8381  } catch (e) {
8382    return {
8383      pid: 0,
8384      output: [null, "", e.message || ""],
8385      stdout: "",
8386      stderr: e.message || "",
8387      status: null,
8388      signal: null,
8389      error: e,
8390    };
8391  }
8392
8393  if (result.error) {
8394    const err = new Error(result.error);
8395    return {
8396      pid: result.pid || 0,
8397      output: [null, result.stdout || "", result.stderr || ""],
8398      stdout: result.stdout || "",
8399      stderr: result.stderr || "",
8400      status: null,
8401      signal: result.killed ? "SIGTERM" : null,
8402      error: err,
8403    };
8404  }
8405
8406  return {
8407    pid: result.pid || 0,
8408    output: [null, result.stdout || "", result.stderr || ""],
8409    stdout: result.stdout || "",
8410    stderr: result.stderr || "",
8411    status: result.status ?? 0,
8412    signal: result.killed ? "SIGTERM" : null,
8413    error: undefined,
8414  };
8415}
8416
8417export function execSync(command, options) {
8418  const cmdStr = String(command ?? "").trim();
8419  if (!cmdStr) {
8420    throw new Error("node:child_process.execSync: command is required");
8421  }
8422  const opts = options || {};
8423  const cwd = typeof opts.cwd === "string" ? opts.cwd : "";
8424  const timeout = typeof opts.timeout === "number" ? opts.timeout : 0;
8425  const maxBuffer = typeof opts.maxBuffer === "number" ? opts.maxBuffer : 1024 * 1024;
8426
8427  // execSync runs through a shell, so pass via sh -c
8428  const raw = __pi_exec_sync_native("sh", JSON.stringify(["-c", cmdStr]), cwd, timeout, maxBuffer);
8429  const result = __parseExecSyncResult(raw, cmdStr);
8430
8431  if (result.status !== 0 && result.status !== null) {
8432    const err = new Error(
8433      `Command failed: ${cmdStr}\n${result.stderr || ""}`,
8434    );
8435    err.status = result.status;
8436    err.stdout = result.stdout || "";
8437    err.stderr = result.stderr || "";
8438    err.pid = result.pid || 0;
8439    err.signal = null;
8440    throw err;
8441  }
8442
8443  const stdout = result.stdout || "";
8444  if (stdout.length > maxBuffer) {
8445    const err = new Error(`stdout maxBuffer length exceeded`);
8446    err.stdout = stdout.slice(0, maxBuffer);
8447    err.stderr = result.stderr || "";
8448    throw err;
8449  }
8450
8451  const encoding = opts.encoding;
8452  if (encoding === "buffer" || encoding === null) {
8453    // Return a "buffer-like" string (QuickJS doesn't have real Buffer)
8454    return stdout;
8455  }
8456  return stdout;
8457}
8458
8459function __normalizeExecOptions(raw) {
8460  const options = raw && typeof raw === "object" ? raw : {};
8461  let timeoutMs = undefined;
8462  if (
8463    typeof options.timeout === "number" &&
8464    Number.isFinite(options.timeout) &&
8465    options.timeout >= 0
8466  ) {
8467    timeoutMs = Math.floor(options.timeout);
8468  }
8469  const maxBuffer =
8470    typeof options.maxBuffer === "number" &&
8471    Number.isFinite(options.maxBuffer) &&
8472    options.maxBuffer > 0
8473      ? Math.floor(options.maxBuffer)
8474      : 1024 * 1024;
8475  return {
8476    cwd: typeof options.cwd === "string" && options.cwd.trim().length > 0 ? options.cwd : undefined,
8477    timeoutMs,
8478    maxBuffer,
8479    encoding: options.encoding,
8480  };
8481}
8482
8483function __wrapExecLike(commandForError, child, opts, callback) {
8484  let stdout = "";
8485  let stderr = "";
8486  let callbackDone = false;
8487  const finish = (err, out, errOut) => {
8488    if (callbackDone) return;
8489    callbackDone = true;
8490    if (typeof callback === "function") {
8491      callback(err, out, errOut);
8492    }
8493  };
8494
8495  child.stdout?.on("data", (chunk) => {
8496    stdout += String(chunk ?? "");
8497  });
8498  child.stderr?.on("data", (chunk) => {
8499    stderr += String(chunk ?? "");
8500  });
8501
8502  child.on("error", (error) => {
8503    finish(
8504      error instanceof Error ? error : new Error(String(error)),
8505      "",
8506      "",
8507    );
8508  });
8509
8510  child.on("close", (code) => {
8511    let out = stdout;
8512    let errOut = stderr;
8513
8514    if (out.length > opts.maxBuffer) {
8515      const err = new Error("stdout maxBuffer length exceeded");
8516      err.stdout = out.slice(0, opts.maxBuffer);
8517      err.stderr = errOut;
8518      finish(err, err.stdout, errOut);
8519      return;
8520    }
8521
8522    if (errOut.length > opts.maxBuffer) {
8523      const err = new Error("stderr maxBuffer length exceeded");
8524      err.stdout = out;
8525      err.stderr = errOut.slice(0, opts.maxBuffer);
8526      finish(err, out, err.stderr);
8527      return;
8528    }
8529
8530    if (opts.encoding !== "buffer" && opts.encoding !== null) {
8531      out = String(out);
8532      errOut = String(errOut);
8533    }
8534
8535    if (code !== 0 && code !== undefined && code !== null) {
8536      const err = new Error(`Command failed: ${commandForError}`);
8537      err.code = code;
8538      err.killed = Boolean(child.killed);
8539      err.stdout = out;
8540      err.stderr = errOut;
8541      finish(err, out, errOut);
8542      return;
8543    }
8544
8545    if (child.killed) {
8546      const err = new Error(`Command timed out: ${commandForError}`);
8547      err.code = null;
8548      err.killed = true;
8549      err.signal = child.signalCode || "SIGTERM";
8550      err.stdout = out;
8551      err.stderr = errOut;
8552      finish(err, out, errOut);
8553      return;
8554    }
8555
8556    finish(null, out, errOut);
8557  });
8558
8559  return child;
8560}
8561
8562export function exec(command, optionsOrCallback, callbackArg) {
8563  const opts = typeof optionsOrCallback === "object" ? optionsOrCallback : {};
8564  const callback = typeof optionsOrCallback === "function"
8565    ? optionsOrCallback
8566    : callbackArg;
8567  const cmdStr = String(command ?? "").trim();
8568  const normalized = __normalizeExecOptions(opts);
8569  const spawnOpts = {
8570    shell: false,
8571    stdio: ["ignore", "pipe", "pipe"],
8572  };
8573  if (normalized.cwd !== undefined) spawnOpts.cwd = normalized.cwd;
8574  if (normalized.timeoutMs !== undefined) spawnOpts.timeout = normalized.timeoutMs;
8575  const child = spawn("sh", ["-c", cmdStr], spawnOpts);
8576  return __wrapExecLike(cmdStr, child, normalized, callback);
8577}
8578
8579export function execFileSync(file, argsInput, options) {
8580  const fileStr = String(file ?? "").trim();
8581  if (!fileStr) {
8582    throw new Error("node:child_process.execFileSync: file is required");
8583  }
8584  const args = Array.isArray(argsInput) ? argsInput.map(String) : [];
8585  const opts = (typeof argsInput === "object" && !Array.isArray(argsInput))
8586    ? argsInput
8587    : (options || {});
8588  const cwd = typeof opts.cwd === "string" ? opts.cwd : "";
8589  const timeout = typeof opts.timeout === "number" ? opts.timeout : 0;
8590  const maxBuffer = typeof opts.maxBuffer === "number" ? opts.maxBuffer : 1024 * 1024;
8591
8592  const raw = __pi_exec_sync_native(fileStr, JSON.stringify(args), cwd, timeout, maxBuffer);
8593  const result = __parseExecSyncResult(raw, fileStr);
8594
8595  if (result.status !== 0 && result.status !== null) {
8596    const err = new Error(
8597      `Command failed: ${fileStr}\n${result.stderr || ""}`,
8598    );
8599    err.status = result.status;
8600    err.stdout = result.stdout || "";
8601    err.stderr = result.stderr || "";
8602    err.pid = result.pid || 0;
8603    throw err;
8604  }
8605
8606  return result.stdout || "";
8607}
8608
8609export function execFile(file, argsOrOptsOrCb, optsOrCb, callbackArg) {
8610  const fileStr = String(file ?? "").trim();
8611  let args = [];
8612  let opts = {};
8613  let callback;
8614  if (typeof argsOrOptsOrCb === "function") {
8615    callback = argsOrOptsOrCb;
8616  } else if (Array.isArray(argsOrOptsOrCb)) {
8617    args = argsOrOptsOrCb.map(String);
8618    if (typeof optsOrCb === "function") {
8619      callback = optsOrCb;
8620    } else {
8621      opts = optsOrCb || {};
8622      callback = callbackArg;
8623    }
8624  } else if (typeof argsOrOptsOrCb === "object") {
8625    opts = argsOrOptsOrCb || {};
8626    callback = typeof optsOrCb === "function" ? optsOrCb : callbackArg;
8627  }
8628
8629  const normalized = __normalizeExecOptions(opts);
8630  const spawnOpts = {
8631    shell: false,
8632    stdio: ["ignore", "pipe", "pipe"],
8633  };
8634  if (normalized.cwd !== undefined) spawnOpts.cwd = normalized.cwd;
8635  if (normalized.timeoutMs !== undefined) spawnOpts.timeout = normalized.timeoutMs;
8636  const child = spawn(fileStr, args, spawnOpts);
8637  return __wrapExecLike(fileStr, child, normalized, callback);
8638}
8639
8640export function fork(_modulePath, _args, _opts) {
8641  throw new Error("node:child_process.fork is not available in PiJS");
8642}
8643
8644export default { spawn, spawnSync, execSync, execFileSync, exec, execFile, fork };
8645"#
8646        .trim()
8647        .to_string(),
8648    );
8649
8650    modules.insert(
8651        "node:module".to_string(),
8652        r#"
8653import * as fs from "node:fs";
8654import * as fsPromises from "node:fs/promises";
8655import * as path from "node:path";
8656import * as os from "node:os";
8657import * as crypto from "node:crypto";
8658import * as url from "node:url";
8659import * as processMod from "node:process";
8660import * as buffer from "node:buffer";
8661import * as childProcess from "node:child_process";
8662import * as http from "node:http";
8663import * as https from "node:https";
8664import * as net from "node:net";
8665import * as events from "node:events";
8666import * as stream from "node:stream";
8667import * as streamPromises from "node:stream/promises";
8668import * as streamWeb from "node:stream/web";
8669import * as stringDecoder from "node:string_decoder";
8670import * as http2 from "node:http2";
8671import * as util from "node:util";
8672import * as readline from "node:readline";
8673import * as querystring from "node:querystring";
8674import * as assertMod from "node:assert";
8675import * as constantsMod from "node:constants";
8676import * as tls from "node:tls";
8677import * as tty from "node:tty";
8678import * as zlib from "node:zlib";
8679import * as perfHooks from "node:perf_hooks";
8680import * as vm from "node:vm";
8681import * as v8 from "node:v8";
8682import * as workerThreads from "node:worker_threads";
8683
8684function __normalizeBuiltin(id) {
8685  const spec = String(id ?? "");
8686  switch (spec) {
8687    case "fs":
8688    case "node:fs":
8689      return "node:fs";
8690    case "fs/promises":
8691    case "node:fs/promises":
8692      return "node:fs/promises";
8693    case "path":
8694    case "node:path":
8695      return "node:path";
8696    case "os":
8697    case "node:os":
8698      return "node:os";
8699    case "crypto":
8700    case "node:crypto":
8701      return "node:crypto";
8702    case "url":
8703    case "node:url":
8704      return "node:url";
8705    case "process":
8706    case "node:process":
8707      return "node:process";
8708    case "buffer":
8709    case "node:buffer":
8710      return "node:buffer";
8711    case "child_process":
8712    case "node:child_process":
8713      return "node:child_process";
8714    case "http":
8715    case "node:http":
8716      return "node:http";
8717    case "https":
8718    case "node:https":
8719      return "node:https";
8720    case "net":
8721    case "node:net":
8722      return "node:net";
8723    case "events":
8724    case "node:events":
8725      return "node:events";
8726    case "stream":
8727    case "node:stream":
8728      return "node:stream";
8729    case "stream/web":
8730    case "node:stream/web":
8731      return "node:stream/web";
8732    case "stream/promises":
8733    case "node:stream/promises":
8734      return "node:stream/promises";
8735    case "string_decoder":
8736    case "node:string_decoder":
8737      return "node:string_decoder";
8738    case "http2":
8739    case "node:http2":
8740      return "node:http2";
8741    case "util":
8742    case "node:util":
8743      return "node:util";
8744    case "readline":
8745    case "node:readline":
8746      return "node:readline";
8747    case "querystring":
8748    case "node:querystring":
8749      return "node:querystring";
8750    case "assert":
8751    case "node:assert":
8752      return "node:assert";
8753    case "module":
8754    case "node:module":
8755      return "node:module";
8756    case "constants":
8757    case "node:constants":
8758      return "node:constants";
8759    case "tls":
8760    case "node:tls":
8761      return "node:tls";
8762    case "tty":
8763    case "node:tty":
8764      return "node:tty";
8765    case "zlib":
8766    case "node:zlib":
8767      return "node:zlib";
8768    case "perf_hooks":
8769    case "node:perf_hooks":
8770      return "node:perf_hooks";
8771    case "vm":
8772    case "node:vm":
8773      return "node:vm";
8774    case "v8":
8775    case "node:v8":
8776      return "node:v8";
8777    case "worker_threads":
8778    case "node:worker_threads":
8779      return "node:worker_threads";
8780    default:
8781      return spec;
8782  }
8783}
8784
8785const __builtinModules = {
8786  "node:fs": fs,
8787  "node:fs/promises": fsPromises,
8788  "node:path": path,
8789  "node:os": os,
8790  "node:crypto": crypto,
8791  "node:url": url,
8792  "node:process": processMod,
8793  "node:buffer": buffer,
8794  "node:child_process": childProcess,
8795  "node:http": http,
8796  "node:https": https,
8797  "node:net": net,
8798  "node:events": events,
8799  "node:stream": stream,
8800  "node:stream/web": streamWeb,
8801  "node:stream/promises": streamPromises,
8802  "node:string_decoder": stringDecoder,
8803  "node:http2": http2,
8804  "node:util": util,
8805  "node:readline": readline,
8806  "node:querystring": querystring,
8807  "node:assert": assertMod,
8808  "node:module": { createRequire },
8809  "node:constants": constantsMod,
8810  "node:tls": tls,
8811  "node:tty": tty,
8812  "node:zlib": zlib,
8813  "node:perf_hooks": perfHooks,
8814  "node:vm": vm,
8815  "node:v8": v8,
8816  "node:worker_threads": workerThreads,
8817};
8818
8819const __missingRequireCache = Object.create(null);
8820
8821function __isBarePackageSpecifier(spec) {
8822  return (
8823    typeof spec === "string" &&
8824    spec.length > 0 &&
8825    !spec.startsWith("./") &&
8826    !spec.startsWith("../") &&
8827    !spec.startsWith("/") &&
8828    !spec.startsWith("file://") &&
8829    !spec.includes(":")
8830  );
8831}
8832
8833function __makeMissingRequireStub(spec) {
8834  if (__missingRequireCache[spec]) {
8835    return __missingRequireCache[spec];
8836  }
8837  const handler = {
8838    get(_target, prop) {
8839      if (typeof prop === "symbol") {
8840        if (prop === Symbol.toPrimitive) return () => "";
8841        return undefined;
8842      }
8843      if (prop === "__esModule") return true;
8844      if (prop === "default") return stub;
8845      if (prop === "toString") return () => "";
8846      if (prop === "valueOf") return () => "";
8847      if (prop === "name") return spec;
8848      if (prop === "then") return undefined;
8849      return stub;
8850    },
8851    apply() { return stub; },
8852    construct() { return stub; },
8853    has() { return false; },
8854    ownKeys() { return []; },
8855    getOwnPropertyDescriptor() {
8856      return { configurable: true, enumerable: false };
8857    },
8858  };
8859  const stub = new Proxy(function __pijs_missing_require_stub() {}, handler);
8860  __missingRequireCache[spec] = stub;
8861  return stub;
8862}
8863
8864export function createRequire(_path) {
8865  function require(id) {
8866    const normalized = __normalizeBuiltin(id);
8867    const builtIn = __builtinModules[normalized];
8868    if (builtIn) {
8869      if (builtIn && Object.prototype.hasOwnProperty.call(builtIn, "default") && builtIn.default !== undefined) {
8870        return builtIn.default;
8871      }
8872      return builtIn;
8873    }
8874    const raw = String(id ?? "");
8875    if (raw.startsWith("node:") || __isBarePackageSpecifier(raw)) {
8876      return __makeMissingRequireStub(raw);
8877    }
8878    throw new Error(`Cannot find module '${raw}' in PiJS require()`);
8879  }
8880  require.resolve = function resolve(id) {
8881    // Return a synthetic path for the requested module.  This satisfies
8882    // extensions that call require.resolve() to locate a binary entry
8883    // point (e.g. @sourcegraph/scip-python) without actually needing the
8884    // real node_modules tree.
8885    return `/pijs-virtual/${String(id ?? "unknown")}`;
8886  };
8887  require.resolve.paths = function() { return []; };
8888  return require;
8889}
8890
8891export default { createRequire };
8892"#
8893        .trim()
8894        .to_string(),
8895    );
8896
8897    modules.insert(
8898        "node:fs".to_string(),
8899        r#"
8900import { Readable, Writable } from "node:stream";
8901
8902export const constants = {
8903  R_OK: 4,
8904  W_OK: 2,
8905  X_OK: 1,
8906  F_OK: 0,
8907  O_RDONLY: 0,
8908  O_WRONLY: 1,
8909  O_RDWR: 2,
8910  O_CREAT: 64,
8911  O_EXCL: 128,
8912  O_TRUNC: 512,
8913  O_APPEND: 1024,
8914};
8915const __pi_vfs = (() => {
8916  if (globalThis.__pi_vfs_state) {
8917    return globalThis.__pi_vfs_state;
8918  }
8919
8920  const state = {
8921    files: new Map(),
8922    dirs: new Set(["/"]),
8923    symlinks: new Map(),
8924    fds: new Map(),
8925    nextFd: 100,
8926  };
8927
8928  function checkWriteAccess(resolved) {
8929    if (typeof globalThis.__pi_host_check_write_access === "function") {
8930      globalThis.__pi_host_check_write_access(resolved);
8931    }
8932  }
8933
8934  function normalizePath(input) {
8935    let raw = String(input ?? "").replace(/\\/g, "/");
8936    // Strip Windows UNC verbatim prefix that canonicalize() produces.
8937    // \\?\C:\... becomes /?/C:/... after separator normalization.
8938    if (raw.startsWith("/?/") && raw.length > 5 && /^[A-Za-z]:/.test(raw.substring(3, 5))) {
8939      raw = raw.slice(3);
8940    }
8941    // Detect Windows drive-letter absolute paths (e.g. "C:/Users/...")
8942    const hasDriveLetter = raw.length >= 3 && /^[A-Za-z]:\//.test(raw);
8943    const isAbsolute = raw.startsWith("/") || hasDriveLetter;
8944    const base = isAbsolute
8945      ? raw
8946      : `${(globalThis.process && typeof globalThis.process.cwd === "function" ? globalThis.process.cwd() : "/").replace(/\\/g, "/")}/${raw}`;
8947    const parts = [];
8948    for (const part of base.split("/")) {
8949      if (!part || part === ".") continue;
8950      if (part === "..") {
8951        if (parts.length > 0) parts.pop();
8952        continue;
8953      }
8954      parts.push(part);
8955    }
8956    // Preserve drive letter prefix on Windows (D:/...) instead of /D:/...
8957    if (parts.length > 0 && /^[A-Za-z]:$/.test(parts[0])) {
8958      return `${parts[0]}/${parts.slice(1).join("/")}`;
8959    }
8960    return `/${parts.join("/")}`;
8961  }
8962
8963  function dirname(path) {
8964    const normalized = normalizePath(path);
8965    if (normalized === "/") return "/";
8966    const idx = normalized.lastIndexOf("/");
8967    return idx <= 0 ? "/" : normalized.slice(0, idx);
8968  }
8969
8970  function ensureDir(path) {
8971    const normalized = normalizePath(path);
8972    if (normalized === "/") return "/";
8973    const parts = normalized.slice(1).split("/");
8974    let current = "";
8975    for (const part of parts) {
8976      current = `${current}/${part}`;
8977      state.dirs.add(current);
8978    }
8979    return normalized;
8980  }
8981
8982  function toBytes(data, opts) {
8983    const encoding =
8984      typeof opts === "string"
8985        ? opts
8986        : opts && typeof opts === "object" && typeof opts.encoding === "string"
8987          ? opts.encoding
8988          : undefined;
8989    const normalizedEncoding = encoding ? String(encoding).toLowerCase() : "utf8";
8990
8991    if (typeof data === "string") {
8992      if (normalizedEncoding === "base64") {
8993        return Buffer.from(data, "base64");
8994      }
8995      return new TextEncoder().encode(data);
8996    }
8997    if (data instanceof Uint8Array) {
8998      return new Uint8Array(data);
8999    }
9000    if (data instanceof ArrayBuffer) {
9001      return new Uint8Array(data);
9002    }
9003    if (ArrayBuffer.isView(data)) {
9004      return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
9005    }
9006    if (Array.isArray(data)) {
9007      return new Uint8Array(data);
9008    }
9009    return new TextEncoder().encode(String(data ?? ""));
9010  }
9011
9012  function decodeBytes(bytes, opts) {
9013    const encoding =
9014      typeof opts === "string"
9015        ? opts
9016        : opts && typeof opts === "object" && typeof opts.encoding === "string"
9017          ? opts.encoding
9018          : undefined;
9019    if (!encoding || String(encoding).toLowerCase() === "buffer") {
9020      return Buffer.from(bytes);
9021    }
9022    const normalized = String(encoding).toLowerCase();
9023    if (normalized === "base64") {
9024      let bin = "";
9025      for (let i = 0; i < bytes.length; i++) {
9026        bin += String.fromCharCode(bytes[i] & 0xff);
9027      }
9028      return btoa(bin);
9029    }
9030    return new TextDecoder().decode(bytes);
9031  }
9032
9033  function resolveSymlinkPath(linkPath, target) {
9034    const raw = String(target ?? "");
9035    if (raw.startsWith("/")) {
9036      return normalizePath(raw);
9037    }
9038    return normalizePath(`${dirname(linkPath)}/${raw}`);
9039  }
9040
9041  function resolvePath(path, followSymlinks = true) {
9042    let normalized = normalizePath(path);
9043    if (!followSymlinks) {
9044      return normalized;
9045    }
9046
9047    const seen = new Set();
9048    while (state.symlinks.has(normalized)) {
9049      if (seen.has(normalized)) {
9050        throw new Error(`ELOOP: too many symbolic links encountered, stat '${String(path ?? "")}'`);
9051      }
9052      seen.add(normalized);
9053      normalized = resolveSymlinkPath(normalized, state.symlinks.get(normalized));
9054    }
9055    return normalized;
9056  }
9057
9058  function parseOpenFlags(rawFlags) {
9059    if (typeof rawFlags === "number" && Number.isFinite(rawFlags)) {
9060      const flags = rawFlags | 0;
9061      const accessMode = flags & 3;
9062      const readable = accessMode === constants.O_RDONLY || accessMode === constants.O_RDWR;
9063      const writable = accessMode === constants.O_WRONLY || accessMode === constants.O_RDWR;
9064      return {
9065        readable,
9066        writable,
9067        append: (flags & constants.O_APPEND) !== 0,
9068        create: (flags & constants.O_CREAT) !== 0,
9069        truncate: (flags & constants.O_TRUNC) !== 0,
9070        exclusive: (flags & constants.O_EXCL) !== 0,
9071      };
9072    }
9073
9074    const normalized = String(rawFlags ?? "r");
9075    switch (normalized) {
9076      case "r":
9077      case "rs":
9078        return { readable: true, writable: false, append: false, create: false, truncate: false, exclusive: false };
9079      case "r+":
9080      case "rs+":
9081        return { readable: true, writable: true, append: false, create: false, truncate: false, exclusive: false };
9082      case "w":
9083        return { readable: false, writable: true, append: false, create: true, truncate: true, exclusive: false };
9084      case "w+":
9085        return { readable: true, writable: true, append: false, create: true, truncate: true, exclusive: false };
9086      case "wx":
9087        return { readable: false, writable: true, append: false, create: true, truncate: true, exclusive: true };
9088      case "wx+":
9089        return { readable: true, writable: true, append: false, create: true, truncate: true, exclusive: true };
9090      case "a":
9091      case "as":
9092        return { readable: false, writable: true, append: true, create: true, truncate: false, exclusive: false };
9093      case "a+":
9094      case "as+":
9095        return { readable: true, writable: true, append: true, create: true, truncate: false, exclusive: false };
9096      case "ax":
9097        return { readable: false, writable: true, append: true, create: true, truncate: false, exclusive: true };
9098      case "ax+":
9099        return { readable: true, writable: true, append: true, create: true, truncate: false, exclusive: true };
9100      default:
9101        throw new Error(`EINVAL: invalid open flags '${normalized}'`);
9102    }
9103  }
9104
9105  function getFdEntry(fd) {
9106    const entry = state.fds.get(fd);
9107    if (!entry) {
9108      throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
9109    }
9110    return entry;
9111  }
9112
9113  function toWritableView(buffer) {
9114    if (buffer instanceof Uint8Array) {
9115      return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
9116    }
9117    if (buffer instanceof ArrayBuffer) {
9118      return new Uint8Array(buffer);
9119    }
9120    if (ArrayBuffer.isView(buffer)) {
9121      return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
9122    }
9123    throw new Error("TypeError: buffer must be an ArrayBuffer view");
9124  }
9125
9126  function makeDirent(name, entryKind) {
9127    return {
9128      name,
9129      isDirectory() { return entryKind === "dir"; },
9130      isFile() { return entryKind === "file"; },
9131      isSymbolicLink() { return entryKind === "symlink"; },
9132    };
9133  }
9134
9135  function listChildren(path, withFileTypes) {
9136    const normalized = normalizePath(path);
9137    const prefix = normalized === "/" ? "/" : `${normalized}/`;
9138    const children = new Map();
9139
9140    for (const dir of state.dirs) {
9141      if (!dir.startsWith(prefix) || dir === normalized) continue;
9142      const rest = dir.slice(prefix.length);
9143      if (!rest || rest.includes("/")) continue;
9144      children.set(rest, "dir");
9145    }
9146    for (const file of state.files.keys()) {
9147      if (!file.startsWith(prefix)) continue;
9148      const rest = file.slice(prefix.length);
9149      if (!rest || rest.includes("/")) continue;
9150      if (!children.has(rest)) children.set(rest, "file");
9151    }
9152    for (const link of state.symlinks.keys()) {
9153      if (!link.startsWith(prefix)) continue;
9154      const rest = link.slice(prefix.length);
9155      if (!rest || rest.includes("/")) continue;
9156      if (!children.has(rest)) children.set(rest, "symlink");
9157    }
9158
9159    const names = Array.from(children.keys()).sort();
9160    if (withFileTypes) {
9161      return names.map((name) => makeDirent(name, children.get(name)));
9162    }
9163    return names;
9164  }
9165
9166  function makeStat(path, followSymlinks = true) {
9167    const normalized = normalizePath(path);
9168    const linkTarget = state.symlinks.get(normalized);
9169    if (linkTarget !== undefined) {
9170      if (!followSymlinks) {
9171        const size = new TextEncoder().encode(String(linkTarget)).byteLength;
9172        return {
9173          isFile() { return false; },
9174          isDirectory() { return false; },
9175          isSymbolicLink() { return true; },
9176          isBlockDevice() { return false; },
9177          isCharacterDevice() { return false; },
9178          isFIFO() { return false; },
9179          isSocket() { return false; },
9180          size,
9181          mode: 0o777,
9182          uid: 0,
9183          gid: 0,
9184          atimeMs: 0,
9185          mtimeMs: 0,
9186          ctimeMs: 0,
9187          birthtimeMs: 0,
9188          atime: new Date(0),
9189          mtime: new Date(0),
9190          ctime: new Date(0),
9191          birthtime: new Date(0),
9192          dev: 0,
9193          ino: 0,
9194          nlink: 1,
9195          rdev: 0,
9196          blksize: 4096,
9197          blocks: 0,
9198        };
9199      }
9200      return makeStat(resolvePath(normalized, true), true);
9201    }
9202
9203    const isDir = state.dirs.has(normalized);
9204    let bytes = state.files.get(normalized);
9205    if (!isDir && bytes === undefined && typeof globalThis.__pi_host_read_file_sync === "function") {
9206      try {
9207        const content = globalThis.__pi_host_read_file_sync(normalized);
9208        bytes = toBytes(content);
9209        ensureDir(dirname(normalized));
9210        state.files.set(normalized, bytes);
9211      } catch (e) {
9212        const message = String((e && e.message) ? e.message : e);
9213        if (message.includes("host read denied")) {
9214          throw e;
9215        }
9216        /* not on host FS */
9217      }
9218    }
9219    const isFile = bytes !== undefined;
9220    if (!isDir && !isFile) {
9221      throw new Error(`ENOENT: no such file or directory, stat '${String(path ?? "")}'`);
9222    }
9223    const size = isFile ? bytes.byteLength : 0;
9224    return {
9225      isFile() { return isFile; },
9226      isDirectory() { return isDir; },
9227      isSymbolicLink() { return false; },
9228      isBlockDevice() { return false; },
9229      isCharacterDevice() { return false; },
9230      isFIFO() { return false; },
9231      isSocket() { return false; },
9232      size,
9233      mode: isDir ? 0o755 : 0o644,
9234      uid: 0,
9235      gid: 0,
9236      atimeMs: 0,
9237      mtimeMs: 0,
9238      ctimeMs: 0,
9239      birthtimeMs: 0,
9240      atime: new Date(0),
9241      mtime: new Date(0),
9242      ctime: new Date(0),
9243      birthtime: new Date(0),
9244      dev: 0,
9245      ino: 0,
9246      nlink: 1,
9247      rdev: 0,
9248      blksize: 4096,
9249      blocks: 0,
9250    };
9251  }
9252
9253  state.normalizePath = normalizePath;
9254  state.dirname = dirname;
9255  state.ensureDir = ensureDir;
9256  state.toBytes = toBytes;
9257  state.decodeBytes = decodeBytes;
9258  state.listChildren = listChildren;
9259  state.makeStat = makeStat;
9260  state.resolvePath = resolvePath;
9261  state.checkWriteAccess = checkWriteAccess;
9262  state.parseOpenFlags = parseOpenFlags;
9263  state.getFdEntry = getFdEntry;
9264  state.toWritableView = toWritableView;
9265  globalThis.__pi_vfs_state = state;
9266  return state;
9267})();
9268
9269export function existsSync(path) {
9270  try {
9271    statSync(path);
9272    return true;
9273  } catch (_err) {
9274    return false;
9275  }
9276}
9277
9278export function readFileSync(path, encoding) {
9279  const resolved = __pi_vfs.resolvePath(path, true);
9280  let bytes = __pi_vfs.files.get(resolved);
9281  let hostError;
9282  if (!bytes && typeof globalThis.__pi_host_read_file_sync === "function") {
9283    try {
9284      const content = globalThis.__pi_host_read_file_sync(resolved);
9285      bytes = __pi_vfs.toBytes(content);
9286      __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
9287      __pi_vfs.files.set(resolved, bytes);
9288    } catch (e) {
9289      const message = String((e && e.message) ? e.message : e);
9290      if (message.includes("host read denied")) {
9291        throw e;
9292      }
9293      hostError = message;
9294      /* fall through to ENOENT */
9295    }
9296  }
9297  if (!bytes) {
9298    const detail = hostError ? ` (host: ${hostError})` : "";
9299    throw new Error(`ENOENT: no such file or directory, open '${String(path ?? "")}'${detail}`);
9300  }
9301  return __pi_vfs.decodeBytes(bytes, encoding);
9302}
9303
9304export function appendFileSync(path, data, opts) {
9305  const resolved = __pi_vfs.resolvePath(path, true);
9306  __pi_vfs.checkWriteAccess(resolved);
9307  const current = __pi_vfs.files.get(resolved) || new Uint8Array();
9308  const next = __pi_vfs.toBytes(data, opts);
9309  const merged = new Uint8Array(current.byteLength + next.byteLength);
9310  merged.set(current, 0);
9311  merged.set(next, current.byteLength);
9312  __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
9313  __pi_vfs.files.set(resolved, merged);
9314}
9315
9316export function writeFileSync(path, data, opts) {
9317  const resolved = __pi_vfs.resolvePath(path, true);
9318  __pi_vfs.checkWriteAccess(resolved);
9319  __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
9320  __pi_vfs.files.set(resolved, __pi_vfs.toBytes(data, opts));
9321}
9322
9323export function readdirSync(path, opts) {
9324  const resolved = __pi_vfs.resolvePath(path, true);
9325  if (!__pi_vfs.dirs.has(resolved)) {
9326    throw new Error(`ENOENT: no such file or directory, scandir '${String(path ?? "")}'`);
9327  }
9328  const withFileTypes = !!(opts && typeof opts === "object" && opts.withFileTypes);
9329  return __pi_vfs.listChildren(resolved, withFileTypes);
9330}
9331
9332const __fakeStat = {
9333  isFile() { return false; },
9334  isDirectory() { return false; },
9335  isSymbolicLink() { return false; },
9336  isBlockDevice() { return false; },
9337  isCharacterDevice() { return false; },
9338  isFIFO() { return false; },
9339  isSocket() { return false; },
9340  size: 0, mode: 0o644, uid: 0, gid: 0,
9341  atimeMs: 0, mtimeMs: 0, ctimeMs: 0, birthtimeMs: 0,
9342  atime: new Date(0), mtime: new Date(0), ctime: new Date(0), birthtime: new Date(0),
9343  dev: 0, ino: 0, nlink: 1, rdev: 0, blksize: 4096, blocks: 0,
9344};
9345export function statSync(path) { return __pi_vfs.makeStat(path, true); }
9346export function lstatSync(path) { return __pi_vfs.makeStat(path, false); }
9347export function mkdtempSync(prefix, _opts) {
9348  const p = String(prefix ?? "/tmp/tmp-");
9349  const out = `${p}${Date.now().toString(36)}`;
9350  __pi_vfs.ensureDir(out);
9351  return out;
9352}
9353export function realpathSync(path, _opts) {
9354  return __pi_vfs.resolvePath(path, true);
9355}
9356export function unlinkSync(path) {
9357  const normalized = __pi_vfs.normalizePath(path);
9358  __pi_vfs.checkWriteAccess(normalized);
9359  if (__pi_vfs.symlinks.delete(normalized)) {
9360    return;
9361  }
9362  if (!__pi_vfs.files.delete(normalized)) {
9363    throw new Error(`ENOENT: no such file or directory, unlink '${String(path ?? "")}'`);
9364  }
9365}
9366export function rmdirSync(path, _opts) {
9367  const normalized = __pi_vfs.normalizePath(path);
9368  __pi_vfs.checkWriteAccess(normalized);
9369  if (normalized === "/") {
9370    throw new Error("EBUSY: resource busy or locked, rmdir '/'");
9371  }
9372  if (__pi_vfs.symlinks.has(normalized)) {
9373    throw new Error(`ENOTDIR: not a directory, rmdir '${String(path ?? "")}'`);
9374  }
9375  for (const filePath of __pi_vfs.files.keys()) {
9376    if (filePath.startsWith(`${normalized}/`)) {
9377      throw new Error(`ENOTEMPTY: directory not empty, rmdir '${String(path ?? "")}'`);
9378    }
9379  }
9380  for (const dirPath of __pi_vfs.dirs) {
9381    if (dirPath.startsWith(`${normalized}/`)) {
9382      throw new Error(`ENOTEMPTY: directory not empty, rmdir '${String(path ?? "")}'`);
9383    }
9384  }
9385  for (const linkPath of __pi_vfs.symlinks.keys()) {
9386    if (linkPath.startsWith(`${normalized}/`)) {
9387      throw new Error(`ENOTEMPTY: directory not empty, rmdir '${String(path ?? "")}'`);
9388    }
9389  }
9390  if (!__pi_vfs.dirs.delete(normalized)) {
9391    throw new Error(`ENOENT: no such file or directory, rmdir '${String(path ?? "")}'`);
9392  }
9393}
9394export function rmSync(path, opts) {
9395  const normalized = __pi_vfs.normalizePath(path);
9396  __pi_vfs.checkWriteAccess(normalized);
9397  if (__pi_vfs.files.has(normalized)) {
9398    __pi_vfs.files.delete(normalized);
9399    return;
9400  }
9401  if (__pi_vfs.symlinks.has(normalized)) {
9402    __pi_vfs.symlinks.delete(normalized);
9403    return;
9404  }
9405  if (__pi_vfs.dirs.has(normalized)) {
9406    const recursive = !!(opts && typeof opts === "object" && opts.recursive);
9407    if (!recursive) {
9408      rmdirSync(normalized);
9409      return;
9410    }
9411    for (const filePath of Array.from(__pi_vfs.files.keys())) {
9412      if (filePath === normalized || filePath.startsWith(`${normalized}/`)) {
9413        __pi_vfs.files.delete(filePath);
9414      }
9415    }
9416    for (const dirPath of Array.from(__pi_vfs.dirs)) {
9417      if (dirPath === normalized || dirPath.startsWith(`${normalized}/`)) {
9418        __pi_vfs.dirs.delete(dirPath);
9419      }
9420    }
9421    for (const linkPath of Array.from(__pi_vfs.symlinks.keys())) {
9422      if (linkPath === normalized || linkPath.startsWith(`${normalized}/`)) {
9423        __pi_vfs.symlinks.delete(linkPath);
9424      }
9425    }
9426    if (!__pi_vfs.dirs.has("/")) {
9427      __pi_vfs.dirs.add("/");
9428    }
9429    return;
9430  }
9431  throw new Error(`ENOENT: no such file or directory, rm '${String(path ?? "")}'`);
9432}
9433export function copyFileSync(src, dest, _mode) {
9434  writeFileSync(dest, readFileSync(src));
9435}
9436export function renameSync(oldPath, newPath) {
9437  const src = __pi_vfs.normalizePath(oldPath);
9438  const dst = __pi_vfs.normalizePath(newPath);
9439  __pi_vfs.checkWriteAccess(src);
9440  __pi_vfs.checkWriteAccess(dst);
9441  const linkTarget = __pi_vfs.symlinks.get(src);
9442  if (linkTarget !== undefined) {
9443    __pi_vfs.ensureDir(__pi_vfs.dirname(dst));
9444    __pi_vfs.symlinks.set(dst, linkTarget);
9445    __pi_vfs.symlinks.delete(src);
9446    return;
9447  }
9448  const bytes = __pi_vfs.files.get(src);
9449  if (bytes !== undefined) {
9450    __pi_vfs.ensureDir(__pi_vfs.dirname(dst));
9451    __pi_vfs.files.set(dst, bytes);
9452    __pi_vfs.files.delete(src);
9453    return;
9454  }
9455  throw new Error(`ENOENT: no such file or directory, rename '${String(oldPath ?? "")}'`);
9456}
9457export function mkdirSync(path, _opts) {
9458  const resolved = __pi_vfs.resolvePath(path, true);
9459  __pi_vfs.checkWriteAccess(resolved);
9460  __pi_vfs.ensureDir(path);
9461  return __pi_vfs.normalizePath(path);
9462}
9463export function accessSync(path, _mode) {
9464  if (!existsSync(path)) {
9465    throw new Error("ENOENT: no such file or directory");
9466  }
9467}
9468export function chmodSync(_path, _mode) { return; }
9469export function chownSync(_path, _uid, _gid) { return; }
9470export function readlinkSync(path, opts) {
9471  const normalized = __pi_vfs.normalizePath(path);
9472  if (!__pi_vfs.symlinks.has(normalized)) {
9473    if (__pi_vfs.files.has(normalized) || __pi_vfs.dirs.has(normalized)) {
9474      throw new Error(`EINVAL: invalid argument, readlink '${String(path ?? "")}'`);
9475    }
9476    throw new Error(`ENOENT: no such file or directory, readlink '${String(path ?? "")}'`);
9477  }
9478  const target = String(__pi_vfs.symlinks.get(normalized));
9479  const encoding =
9480    typeof opts === "string"
9481      ? opts
9482      : opts && typeof opts === "object" && typeof opts.encoding === "string"
9483        ? opts.encoding
9484        : undefined;
9485  if (encoding && String(encoding).toLowerCase() === "buffer") {
9486    return Buffer.from(target, "utf8");
9487  }
9488  return target;
9489}
9490export function symlinkSync(target, path, _type) {
9491  const normalized = __pi_vfs.normalizePath(path);
9492  __pi_vfs.checkWriteAccess(normalized);
9493  const parent = __pi_vfs.dirname(normalized);
9494  if (!__pi_vfs.dirs.has(parent)) {
9495    throw new Error(`ENOENT: no such file or directory, symlink '${String(path ?? "")}'`);
9496  }
9497  if (__pi_vfs.files.has(normalized) || __pi_vfs.dirs.has(normalized) || __pi_vfs.symlinks.has(normalized)) {
9498    throw new Error(`EEXIST: file already exists, symlink '${String(path ?? "")}'`);
9499  }
9500  __pi_vfs.symlinks.set(normalized, String(target ?? ""));
9501}
9502export function openSync(path, flags = "r", _mode) {
9503  const resolved = __pi_vfs.resolvePath(path, true);
9504  const opts = __pi_vfs.parseOpenFlags(flags);
9505
9506  if (opts.writable || opts.create || opts.append || opts.truncate) {
9507    __pi_vfs.checkWriteAccess(resolved);
9508  }
9509
9510  if (__pi_vfs.dirs.has(resolved)) {
9511    throw new Error(`EISDIR: illegal operation on a directory, open '${String(path ?? "")}'`);
9512  }
9513
9514  const exists = __pi_vfs.files.has(resolved);
9515  if (!exists && !opts.create) {
9516    throw new Error(`ENOENT: no such file or directory, open '${String(path ?? "")}'`);
9517  }
9518  if (exists && opts.create && opts.exclusive) {
9519    throw new Error(`EEXIST: file already exists, open '${String(path ?? "")}'`);
9520  }
9521  if (!exists && opts.create) {
9522    __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
9523    __pi_vfs.files.set(resolved, new Uint8Array());
9524  }
9525  if (opts.truncate && opts.writable) {
9526    __pi_vfs.files.set(resolved, new Uint8Array());
9527  }
9528
9529  const fd = __pi_vfs.nextFd++;
9530  const current = __pi_vfs.files.get(resolved) || new Uint8Array();
9531  __pi_vfs.fds.set(fd, {
9532    path: resolved,
9533    readable: opts.readable,
9534    writable: opts.writable,
9535    append: opts.append,
9536    position: opts.append ? current.byteLength : 0,
9537  });
9538  return fd;
9539}
9540export function closeSync(fd) {
9541  if (!__pi_vfs.fds.delete(fd)) {
9542    throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
9543  }
9544}
9545export function readSync(fd, buffer, offset = 0, length, position = null) {
9546  const entry = __pi_vfs.getFdEntry(fd);
9547  if (!entry.readable) {
9548    throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
9549  }
9550  const out = __pi_vfs.toWritableView(buffer);
9551  const start = Number.isInteger(offset) && offset >= 0 ? offset : 0;
9552  const maxLen =
9553    Number.isInteger(length) && length >= 0
9554      ? length
9555      : Math.max(0, out.byteLength - start);
9556  let cursor =
9557    typeof position === "number" && Number.isFinite(position) && position >= 0
9558      ? Math.floor(position)
9559      : entry.position;
9560  const source = __pi_vfs.files.get(entry.path) || new Uint8Array();
9561  if (cursor >= source.byteLength || maxLen <= 0 || start >= out.byteLength) {
9562    return 0;
9563  }
9564  const readLen = Math.min(maxLen, out.byteLength - start, source.byteLength - cursor);
9565  out.set(source.subarray(cursor, cursor + readLen), start);
9566  if (position === null || position === undefined) {
9567    entry.position = cursor + readLen;
9568  }
9569  return readLen;
9570}
9571export function writeSync(fd, buffer, offset, length, position) {
9572  const entry = __pi_vfs.getFdEntry(fd);
9573  if (!entry.writable) {
9574    throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
9575  }
9576
9577  let chunk;
9578  let explicitPosition = false;
9579  let cursor = null;
9580
9581  if (typeof buffer === "string") {
9582    const encoding =
9583      typeof length === "string"
9584        ? length
9585        : typeof offset === "string"
9586          ? offset
9587          : undefined;
9588    chunk = __pi_vfs.toBytes(buffer, encoding);
9589    if (
9590      arguments.length >= 3 &&
9591      typeof offset === "number" &&
9592      Number.isFinite(offset) &&
9593      offset >= 0
9594    ) {
9595      explicitPosition = true;
9596      cursor = Math.floor(offset);
9597    }
9598  } else {
9599    const input = __pi_vfs.toWritableView(buffer);
9600    const start = Number.isInteger(offset) && offset >= 0 ? offset : 0;
9601    const maxLen =
9602      Number.isInteger(length) && length >= 0
9603        ? length
9604        : Math.max(0, input.byteLength - start);
9605    chunk = input.subarray(start, Math.min(input.byteLength, start + maxLen));
9606    if (typeof position === "number" && Number.isFinite(position) && position >= 0) {
9607      explicitPosition = true;
9608      cursor = Math.floor(position);
9609    }
9610  }
9611
9612  if (!explicitPosition) {
9613    cursor = entry.append
9614      ? (__pi_vfs.files.get(entry.path)?.byteLength || 0)
9615      : entry.position;
9616  }
9617
9618  const current = __pi_vfs.files.get(entry.path) || new Uint8Array();
9619  const required = cursor + chunk.byteLength;
9620  const next = new Uint8Array(Math.max(current.byteLength, required));
9621  next.set(current, 0);
9622  next.set(chunk, cursor);
9623  __pi_vfs.files.set(entry.path, next);
9624
9625  if (!explicitPosition) {
9626    entry.position = cursor + chunk.byteLength;
9627  }
9628  return chunk.byteLength;
9629}
9630export function fstatSync(fd) {
9631  const entry = __pi_vfs.getFdEntry(fd);
9632  return __pi_vfs.makeStat(entry.path, true);
9633}
9634export function ftruncateSync(fd, len = 0) {
9635  const entry = __pi_vfs.getFdEntry(fd);
9636  if (!entry.writable) {
9637    throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
9638  }
9639  const targetLen =
9640    Number.isInteger(len) && len >= 0 ? len : 0;
9641  const current = __pi_vfs.files.get(entry.path) || new Uint8Array();
9642  const next = new Uint8Array(targetLen);
9643  next.set(current.subarray(0, Math.min(current.byteLength, targetLen)));
9644  __pi_vfs.files.set(entry.path, next);
9645  if (entry.position > targetLen) {
9646    entry.position = targetLen;
9647  }
9648}
9649export function futimesSync(_fd, _atime, _mtime) { return; }
9650function __fakeWatcher() {
9651  const w = { close() {}, unref() { return w; }, ref() { return w; }, on() { return w; }, once() { return w; }, removeListener() { return w; }, removeAllListeners() { return w; } };
9652  return w;
9653}
9654export function watch(_path, _optsOrListener, _listener) { return __fakeWatcher(); }
9655export function watchFile(_path, _optsOrListener, _listener) { return __fakeWatcher(); }
9656export function unwatchFile(_path, _listener) { return; }
9657function __queueMicrotaskPolyfill(fn) {
9658  if (typeof queueMicrotask === "function") {
9659    queueMicrotask(fn);
9660    return;
9661  }
9662  Promise.resolve().then(fn);
9663}
9664export function createReadStream(path, opts) {
9665  const options = opts && typeof opts === "object" ? opts : {};
9666  const encoding = typeof options.encoding === "string" ? options.encoding : null;
9667  const highWaterMark =
9668    Number.isInteger(options.highWaterMark) && options.highWaterMark > 0
9669      ? options.highWaterMark
9670      : 64 * 1024;
9671
9672  const stream = new Readable({ encoding: encoding || undefined, autoDestroy: false });
9673  stream.path = __pi_vfs.normalizePath(path);
9674
9675  __queueMicrotaskPolyfill(() => {
9676    try {
9677      const bytes = readFileSync(path, "buffer");
9678      const source =
9679        bytes instanceof Uint8Array
9680          ? bytes
9681          : (typeof Buffer !== "undefined" && Buffer.from
9682              ? Buffer.from(bytes)
9683              : __pi_vfs.toBytes(bytes));
9684
9685      if (source.byteLength === 0) {
9686        stream.push(null);
9687        return;
9688      }
9689
9690      let offset = 0;
9691      while (offset < source.byteLength) {
9692        const nextOffset = Math.min(source.byteLength, offset + highWaterMark);
9693        const slice = source.subarray(offset, nextOffset);
9694        if (encoding && typeof Buffer !== "undefined" && Buffer.from) {
9695          stream.push(Buffer.from(slice).toString(encoding));
9696        } else {
9697          stream.push(slice);
9698        }
9699        offset = nextOffset;
9700      }
9701      stream.push(null);
9702    } catch (err) {
9703      stream.emit("error", err instanceof Error ? err : new Error(String(err)));
9704    }
9705  });
9706
9707  return stream;
9708}
9709export function createWriteStream(path, opts) {
9710  const options = opts && typeof opts === "object" ? opts : {};
9711  const encoding = typeof options.encoding === "string" ? options.encoding : "utf8";
9712  const flags = typeof options.flags === "string" ? options.flags : "w";
9713  const appendMode = flags.startsWith("a");
9714  const bufferedChunks = [];
9715
9716  const stream = new Writable({
9717    autoDestroy: false,
9718    write(chunk, chunkEncoding, callback) {
9719      try {
9720        const normalizedEncoding =
9721          typeof chunkEncoding === "string" && chunkEncoding
9722            ? chunkEncoding
9723            : encoding;
9724        const bytes = __pi_vfs.toBytes(chunk, normalizedEncoding);
9725        bufferedChunks.push(bytes);
9726        this.bytesWritten += bytes.byteLength;
9727        callback(null);
9728      } catch (err) {
9729        callback(err instanceof Error ? err : new Error(String(err)));
9730      }
9731    },
9732    final(callback) {
9733      try {
9734        if (appendMode) {
9735          for (const bytes of bufferedChunks) {
9736            appendFileSync(path, bytes);
9737          }
9738        } else {
9739          const totalSize = bufferedChunks.reduce((sum, bytes) => sum + bytes.byteLength, 0);
9740          const merged = new Uint8Array(totalSize);
9741          let offset = 0;
9742          for (const bytes of bufferedChunks) {
9743            merged.set(bytes, offset);
9744            offset += bytes.byteLength;
9745          }
9746          writeFileSync(path, merged);
9747        }
9748        callback(null);
9749      } catch (err) {
9750        callback(err instanceof Error ? err : new Error(String(err)));
9751      }
9752    },
9753  });
9754  stream.path = __pi_vfs.normalizePath(path);
9755  stream.bytesWritten = 0;
9756  stream.cork = () => stream;
9757  stream.uncork = () => stream;
9758  return stream;
9759}
9760export function readFile(path, optOrCb, cb) {
9761  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9762  const encoding = typeof optOrCb === 'function' ? undefined : optOrCb;
9763  if (typeof callback === 'function') {
9764    try { callback(null, readFileSync(path, encoding)); }
9765    catch (err) { callback(err); }
9766  }
9767}
9768export function writeFile(path, data, optOrCb, cb) {
9769  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9770  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9771  if (typeof callback === 'function') {
9772    try { writeFileSync(path, data, opts); callback(null); }
9773    catch (err) { callback(err); }
9774  }
9775}
9776export function stat(path, optOrCb, cb) {
9777  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9778  if (typeof callback === 'function') {
9779    try { callback(null, statSync(path)); }
9780    catch (err) { callback(err); }
9781  }
9782}
9783export function readdir(path, optOrCb, cb) {
9784  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9785  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9786  if (typeof callback === 'function') {
9787    try { callback(null, readdirSync(path, opts)); }
9788    catch (err) { callback(err); }
9789  }
9790}
9791export function mkdir(path, optOrCb, cb) {
9792  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9793  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9794  if (typeof callback === 'function') {
9795    try { callback(null, mkdirSync(path, opts)); }
9796    catch (err) { callback(err); }
9797  }
9798}
9799export function unlink(path, cb) {
9800  if (typeof cb === 'function') {
9801    try { unlinkSync(path); cb(null); }
9802    catch (err) { cb(err); }
9803  }
9804}
9805export function readlink(path, optOrCb, cb) {
9806  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9807  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9808  if (typeof callback === 'function') {
9809    try { callback(null, readlinkSync(path, opts)); }
9810    catch (err) { callback(err); }
9811  }
9812}
9813export function symlink(target, path, typeOrCb, cb) {
9814  const callback = typeof typeOrCb === 'function' ? typeOrCb : cb;
9815  const type = typeof typeOrCb === 'function' ? undefined : typeOrCb;
9816  if (typeof callback === 'function') {
9817    try { symlinkSync(target, path, type); callback(null); }
9818    catch (err) { callback(err); }
9819  }
9820}
9821export function lstat(path, optOrCb, cb) {
9822  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9823  if (typeof callback === 'function') {
9824    try { callback(null, lstatSync(path)); }
9825    catch (err) { callback(err); }
9826  }
9827}
9828export function rmdir(path, optOrCb, cb) {
9829  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9830  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9831  if (typeof callback === 'function') {
9832    try { rmdirSync(path, opts); callback(null); }
9833    catch (err) { callback(err); }
9834  }
9835}
9836export function rm(path, optOrCb, cb) {
9837  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9838  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9839  if (typeof callback === 'function') {
9840    try { rmSync(path, opts); callback(null); }
9841    catch (err) { callback(err); }
9842  }
9843}
9844export function rename(oldPath, newPath, cb) {
9845  if (typeof cb === 'function') {
9846    try { renameSync(oldPath, newPath); cb(null); }
9847    catch (err) { cb(err); }
9848  }
9849}
9850export function copyFile(src, dest, flagsOrCb, cb) {
9851  const callback = typeof flagsOrCb === 'function' ? flagsOrCb : cb;
9852  if (typeof callback === 'function') {
9853    try { copyFileSync(src, dest); callback(null); }
9854    catch (err) { callback(err); }
9855  }
9856}
9857export function appendFile(path, data, optOrCb, cb) {
9858  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9859  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9860  if (typeof callback === 'function') {
9861    try { appendFileSync(path, data, opts); callback(null); }
9862    catch (err) { callback(err); }
9863  }
9864}
9865export function chmod(path, mode, cb) {
9866  if (typeof cb === 'function') {
9867    try { chmodSync(path, mode); cb(null); }
9868    catch (err) { cb(err); }
9869  }
9870}
9871export function chown(path, uid, gid, cb) {
9872  if (typeof cb === 'function') {
9873    try { chownSync(path, uid, gid); cb(null); }
9874    catch (err) { cb(err); }
9875  }
9876}
9877export function realpath(path, optOrCb, cb) {
9878  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9879  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9880  if (typeof callback === 'function') {
9881    try { callback(null, realpathSync(path, opts)); }
9882    catch (err) { callback(err); }
9883  }
9884}
9885export function access(_path, modeOrCb, cb) {
9886  const callback = typeof modeOrCb === 'function' ? modeOrCb : cb;
9887  if (typeof callback === 'function') {
9888    try {
9889      accessSync(_path);
9890      callback(null);
9891    } catch (err) {
9892      callback(err);
9893    }
9894  }
9895}
9896export const promises = {
9897  access: async (path, _mode) => accessSync(path),
9898  mkdir: async (path, opts) => mkdirSync(path, opts),
9899  mkdtemp: async (prefix, _opts) => {
9900    return mkdtempSync(prefix, _opts);
9901  },
9902  readFile: async (path, opts) => readFileSync(path, opts),
9903  writeFile: async (path, data, opts) => writeFileSync(path, data, opts),
9904  unlink: async (path) => unlinkSync(path),
9905  readlink: async (path, opts) => readlinkSync(path, opts),
9906  symlink: async (target, path, type) => symlinkSync(target, path, type),
9907  rmdir: async (path, opts) => rmdirSync(path, opts),
9908  stat: async (path) => statSync(path),
9909  lstat: async (path) => lstatSync(path),
9910  realpath: async (path, _opts) => realpathSync(path, _opts),
9911  readdir: async (path, opts) => readdirSync(path, opts),
9912  rm: async (path, opts) => rmSync(path, opts),
9913  rename: async (oldPath, newPath) => renameSync(oldPath, newPath),
9914  copyFile: async (src, dest, mode) => copyFileSync(src, dest, mode),
9915  appendFile: async (path, data, opts) => appendFileSync(path, data, opts),
9916  chmod: async (_path, _mode) => {},
9917};
9918export default { constants, existsSync, readFileSync, appendFileSync, writeFileSync, readdirSync, statSync, lstatSync, mkdtempSync, realpathSync, unlinkSync, rmdirSync, rmSync, copyFileSync, renameSync, mkdirSync, accessSync, chmodSync, chownSync, readlinkSync, symlinkSync, openSync, closeSync, readSync, writeSync, fstatSync, ftruncateSync, futimesSync, watch, watchFile, unwatchFile, createReadStream, createWriteStream, readFile, writeFile, stat, lstat, readdir, mkdir, unlink, readlink, symlink, rmdir, rm, rename, copyFile, appendFile, chmod, chown, realpath, access, promises };
9919"#
9920        .trim()
9921        .to_string(),
9922    );
9923
9924    modules.insert(
9925        "node:fs/promises".to_string(),
9926        r"
9927import fs from 'node:fs';
9928
9929export async function access(path, mode) { return fs.promises.access(path, mode); }
9930export async function mkdir(path, opts) { return fs.promises.mkdir(path, opts); }
9931export async function mkdtemp(prefix, opts) { return fs.promises.mkdtemp(prefix, opts); }
9932export async function readFile(path, opts) { return fs.promises.readFile(path, opts); }
9933export async function writeFile(path, data, opts) { return fs.promises.writeFile(path, data, opts); }
9934export async function unlink(path) { return fs.promises.unlink(path); }
9935export async function readlink(path, opts) { return fs.promises.readlink(path, opts); }
9936export async function symlink(target, path, type) { return fs.promises.symlink(target, path, type); }
9937export async function rmdir(path, opts) { return fs.promises.rmdir(path, opts); }
9938export async function stat(path) { return fs.promises.stat(path); }
9939export async function realpath(path, opts) { return fs.promises.realpath(path, opts); }
9940export async function readdir(path, opts) { return fs.promises.readdir(path, opts); }
9941export async function rm(path, opts) { return fs.promises.rm(path, opts); }
9942export async function lstat(path) { return fs.promises.lstat(path); }
9943export async function copyFile(src, dest) { return fs.promises.copyFile(src, dest); }
9944export async function rename(oldPath, newPath) { return fs.promises.rename(oldPath, newPath); }
9945export async function chmod(path, mode) { return; }
9946export async function chown(path, uid, gid) { return; }
9947export async function utimes(path, atime, mtime) { return; }
9948export async function appendFile(path, data, opts) { return fs.promises.appendFile(path, data, opts); }
9949export async function open(path, flags, mode) { return { close: async () => {} }; }
9950export async function truncate(path, len) { return; }
9951export default { access, mkdir, mkdtemp, readFile, writeFile, unlink, readlink, symlink, rmdir, stat, lstat, realpath, readdir, rm, copyFile, rename, chmod, chown, utimes, appendFile, open, truncate };
9952"
9953        .trim()
9954        .to_string(),
9955    );
9956
9957    modules.insert(
9958        "node:http".to_string(),
9959        crate::http_shim::NODE_HTTP_JS.trim().to_string(),
9960    );
9961
9962    modules.insert(
9963        "node:https".to_string(),
9964        crate::http_shim::NODE_HTTPS_JS.trim().to_string(),
9965    );
9966
9967    modules.insert(
9968        "node:http2".to_string(),
9969        r#"
9970import EventEmitter from "node:events";
9971
9972export const constants = {
9973  HTTP2_HEADER_STATUS: ":status",
9974  HTTP2_HEADER_METHOD: ":method",
9975  HTTP2_HEADER_PATH: ":path",
9976  HTTP2_HEADER_AUTHORITY: ":authority",
9977  HTTP2_HEADER_SCHEME: ":scheme",
9978  HTTP2_HEADER_PROTOCOL: ":protocol",
9979  HTTP2_HEADER_CONTENT_TYPE: "content-type",
9980  NGHTTP2_CANCEL: 8,
9981};
9982
9983function __makeStream() {
9984  const stream = new EventEmitter();
9985  stream.end = (_data, _encoding, cb) => {
9986    if (typeof cb === "function") cb();
9987    stream.emit("finish");
9988  };
9989  stream.close = () => stream.emit("close");
9990  stream.destroy = (err) => {
9991    if (err) stream.emit("error", err);
9992    stream.emit("close");
9993  };
9994  stream.respond = () => {};
9995  stream.setEncoding = () => stream;
9996  stream.setTimeout = (_ms, cb) => {
9997    if (typeof cb === "function") cb();
9998    return stream;
9999  };
10000  return stream;
10001}
10002
10003function __makeSession() {
10004  const session = new EventEmitter();
10005  session.closed = false;
10006  session.connecting = false;
10007  session.request = (_headers, _opts) => __makeStream();
10008  session.close = () => {
10009    session.closed = true;
10010    session.emit("close");
10011  };
10012  session.destroy = (err) => {
10013    session.closed = true;
10014    if (err) session.emit("error", err);
10015    session.emit("close");
10016  };
10017  session.ref = () => session;
10018  session.unref = () => session;
10019  return session;
10020}
10021
10022export function connect(_authority, _options, listener) {
10023  const session = __makeSession();
10024  if (typeof listener === "function") {
10025    try {
10026      listener(session);
10027    } catch (_err) {}
10028  }
10029  return session;
10030}
10031
10032export class ClientHttp2Session extends EventEmitter {}
10033export class ClientHttp2Stream extends EventEmitter {}
10034
10035export default { connect, constants, ClientHttp2Session, ClientHttp2Stream };
10036"#
10037        .trim()
10038        .to_string(),
10039    );
10040
10041    modules.insert(
10042        "node:util".to_string(),
10043        r#"
10044export function inspect(value, opts) {
10045  const depth = (opts && typeof opts.depth === 'number') ? opts.depth : 2;
10046  const seen = new Set();
10047  function fmt(v, d) {
10048    if (v === null) return 'null';
10049    if (v === undefined) return 'undefined';
10050    const t = typeof v;
10051    if (t === 'string') return d > 0 ? "'" + v + "'" : v;
10052    if (t === 'number' || t === 'boolean' || t === 'bigint') return String(v);
10053    if (t === 'symbol') return v.toString();
10054    if (t === 'function') return '[Function: ' + (v.name || 'anonymous') + ']';
10055    if (v instanceof Date) return v.toISOString();
10056    if (v instanceof RegExp) return v.toString();
10057    if (v instanceof Error) return v.stack || v.message || String(v);
10058    if (seen.has(v)) return '[Circular]';
10059    seen.add(v);
10060    if (d > depth) { seen.delete(v); return Array.isArray(v) ? '[Array]' : '[Object]'; }
10061    if (Array.isArray(v)) {
10062      const items = v.map(x => fmt(x, d + 1));
10063      seen.delete(v);
10064      return '[ ' + items.join(', ') + ' ]';
10065    }
10066    const keys = Object.keys(v);
10067    if (keys.length === 0) { seen.delete(v); return '{}'; }
10068    const pairs = keys.map(k => k + ': ' + fmt(v[k], d + 1));
10069    seen.delete(v);
10070    return '{ ' + pairs.join(', ') + ' }';
10071  }
10072  return fmt(value, 0);
10073}
10074
10075export function promisify(fn) {
10076  return (...args) => new Promise((resolve, reject) => {
10077    try {
10078      fn(...args, (err, result) => {
10079        if (err) reject(err);
10080        else resolve(result);
10081      });
10082    } catch (e) {
10083      reject(e);
10084    }
10085  });
10086}
10087
10088export function stripVTControlCharacters(str) {
10089  // eslint-disable-next-line no-control-regex
10090  return (str || '').replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '').replace(/\x1B\][^\x07]*\x07/g, '');
10091}
10092
10093export function deprecate(fn, msg) {
10094  let warned = false;
10095  return function(...args) {
10096    if (!warned) { warned = true; if (typeof console !== 'undefined') console.error('DeprecationWarning: ' + (msg || '')); }
10097    return fn.apply(this, args);
10098  };
10099}
10100export function inherits(ctor, superCtor) {
10101  if (!ctor || !superCtor) return ctor;
10102  const ctorProto = ctor && ctor.prototype;
10103  const superProto = superCtor && superCtor.prototype;
10104  if (!ctorProto || !superProto || typeof ctorProto !== 'object' || typeof superProto !== 'object') {
10105    try { ctor.super_ = superCtor; } catch (_) {}
10106    return ctor;
10107  }
10108  try {
10109    Object.setPrototypeOf(ctorProto, superProto);
10110    ctor.super_ = superCtor;
10111  } catch (_) {
10112    try { ctor.super_ = superCtor; } catch (_ignored) {}
10113  }
10114  return ctor;
10115}
10116export function debuglog(section) {
10117  const env = (typeof process !== 'undefined' && process.env && process.env.NODE_DEBUG) || '';
10118  const enabled = env.split(',').some(s => s.trim().toLowerCase() === (section || '').toLowerCase());
10119  if (!enabled) return () => {};
10120  return (...args) => { if (typeof console !== 'undefined') console.error(section.toUpperCase() + ': ' + args.map(String).join(' ')); };
10121}
10122export function format(f, ...args) {
10123  if (typeof f !== 'string') return [f, ...args].map(v => typeof v === 'string' ? v : inspect(v)).join(' ');
10124  let i = 0;
10125  let result = f.replace(/%[sdifjoO%]/g, (m) => {
10126    if (m === '%%') return '%';
10127    if (i >= args.length) return m;
10128    const a = args[i++];
10129    switch (m) {
10130      case '%s': return String(a);
10131      case '%d': case '%f': return Number(a).toString();
10132      case '%i': return parseInt(a, 10).toString();
10133      case '%j': try { return JSON.stringify(a); } catch { return '[Circular]'; }
10134      case '%o': case '%O': return inspect(a);
10135      default: return m;
10136    }
10137  });
10138  while (i < args.length) result += ' ' + (typeof args[i] === 'string' ? args[i] : inspect(args[i])), i++;
10139  return result;
10140}
10141export function callbackify(fn) {
10142  return function(...args) {
10143    const cb = args.pop();
10144    fn(...args).then(r => cb(null, r), e => cb(e));
10145  };
10146}
10147export const types = {
10148  isAsyncFunction: (fn) => typeof fn === 'function' && fn.constructor && fn.constructor.name === 'AsyncFunction',
10149  isPromise: (v) => v instanceof Promise,
10150  isDate: (v) => v instanceof Date,
10151  isRegExp: (v) => v instanceof RegExp,
10152  isNativeError: (v) => v instanceof Error,
10153  isSet: (v) => v instanceof Set,
10154  isMap: (v) => v instanceof Map,
10155  isTypedArray: (v) => ArrayBuffer.isView(v) && !(v instanceof DataView),
10156  isArrayBuffer: (v) => v instanceof ArrayBuffer,
10157  isArrayBufferView: (v) => ArrayBuffer.isView(v),
10158  isDataView: (v) => v instanceof DataView,
10159  isGeneratorFunction: (fn) => typeof fn === 'function' && fn.constructor && fn.constructor.name === 'GeneratorFunction',
10160  isGeneratorObject: (v) => v && typeof v.next === 'function' && typeof v.throw === 'function',
10161  isBooleanObject: (v) => typeof v === 'object' && v instanceof Boolean,
10162  isNumberObject: (v) => typeof v === 'object' && v instanceof Number,
10163  isStringObject: (v) => typeof v === 'object' && v instanceof String,
10164  isSymbolObject: () => false,
10165  isWeakMap: (v) => v instanceof WeakMap,
10166  isWeakSet: (v) => v instanceof WeakSet,
10167};
10168export const TextEncoder = globalThis.TextEncoder;
10169export const TextDecoder = globalThis.TextDecoder;
10170
10171export default { inspect, promisify, stripVTControlCharacters, deprecate, inherits, debuglog, format, callbackify, types, TextEncoder, TextDecoder };
10172"#
10173        .trim()
10174        .to_string(),
10175    );
10176
10177    modules.insert(
10178        "node:crypto".to_string(),
10179        crate::crypto_shim::NODE_CRYPTO_JS.trim().to_string(),
10180    );
10181
10182    modules.insert(
10183        "node:readline".to_string(),
10184        r"
10185// Stub readline module - interactive prompts are not available in PiJS
10186
10187export function createInterface(_opts) {
10188  return {
10189    question: (_query, callback) => {
10190      if (typeof callback === 'function') callback('');
10191    },
10192    close: () => {},
10193    on: () => {},
10194    once: () => {},
10195  };
10196}
10197
10198export const promises = {
10199  createInterface: (_opts) => ({
10200    question: async (_query) => '',
10201    close: () => {},
10202    [Symbol.asyncIterator]: async function* () {},
10203  }),
10204};
10205
10206export default { createInterface, promises };
10207"
10208        .trim()
10209        .to_string(),
10210    );
10211
10212    modules.insert(
10213        "node:url".to_string(),
10214        r"
10215export function fileURLToPath(url) {
10216  const u = String(url ?? '');
10217  if (u.startsWith('file://')) {
10218    let p = decodeURIComponent(u.slice(7));
10219    // file:///C:/... → C:/... (strip leading / before Windows drive letter)
10220    if (p.length >= 3 && p[0] === '/' && p[2] === ':') { p = p.slice(1); }
10221    return p;
10222  }
10223  return u;
10224}
10225export function pathToFileURL(path) {
10226  return new URL('file://' + encodeURI(String(path ?? '')));
10227}
10228
10229// Use built-in URL if available (QuickJS may have it), else provide polyfill
10230const _URL = globalThis.URL || (() => {
10231  class URLPolyfill {
10232    constructor(input, base) {
10233      let u = String(input ?? '');
10234      if (base !== undefined) {
10235        const b = String(base);
10236        if (u.startsWith('/')) {
10237          const m = b.match(/^([^:]+:\/\/[^\/]+)/);
10238          u = m ? m[1] + u : b + u;
10239        } else if (!/^[a-z][a-z0-9+.-]*:/i.test(u)) {
10240          u = b.replace(/[^\/]*$/, '') + u;
10241        }
10242      }
10243      this.href = u;
10244      const protoEnd = u.indexOf(':');
10245      this.protocol = protoEnd >= 0 ? u.slice(0, protoEnd + 1) : '';
10246      let rest = protoEnd >= 0 ? u.slice(protoEnd + 1) : u;
10247      this.username = ''; this.password = '';
10248      if (rest.startsWith('//')) {
10249        rest = rest.slice(2);
10250        const pathStart = rest.indexOf('/');
10251        const authority = pathStart >= 0 ? rest.slice(0, pathStart) : rest;
10252        rest = pathStart >= 0 ? rest.slice(pathStart) : '/';
10253        const atIdx = authority.indexOf('@');
10254        let hostPart = authority;
10255        if (atIdx >= 0) {
10256          const userInfo = authority.slice(0, atIdx);
10257          hostPart = authority.slice(atIdx + 1);
10258          const colonIdx = userInfo.indexOf(':');
10259          if (colonIdx >= 0) {
10260            this.username = userInfo.slice(0, colonIdx);
10261            this.password = userInfo.slice(colonIdx + 1);
10262          } else {
10263            this.username = userInfo;
10264          }
10265        }
10266        const portIdx = hostPart.lastIndexOf(':');
10267        if (portIdx >= 0 && /^\d+$/.test(hostPart.slice(portIdx + 1))) {
10268          this.hostname = hostPart.slice(0, portIdx);
10269          this.port = hostPart.slice(portIdx + 1);
10270        } else {
10271          this.hostname = hostPart;
10272          this.port = '';
10273        }
10274        this.host = this.port ? this.hostname + ':' + this.port : this.hostname;
10275        this.origin = this.protocol + '//' + this.host;
10276      } else {
10277        this.hostname = ''; this.host = ''; this.port = '';
10278        this.origin = 'null';
10279      }
10280      const hashIdx = rest.indexOf('#');
10281      if (hashIdx >= 0) {
10282        this.hash = rest.slice(hashIdx);
10283        rest = rest.slice(0, hashIdx);
10284      } else {
10285        this.hash = '';
10286      }
10287      const qIdx = rest.indexOf('?');
10288      if (qIdx >= 0) {
10289        this.search = rest.slice(qIdx);
10290        this.pathname = rest.slice(0, qIdx) || '/';
10291      } else {
10292        this.search = '';
10293        this.pathname = rest || '/';
10294      }
10295      this.searchParams = new _URLSearchParams(this.search.slice(1));
10296    }
10297    toString() { return this.href; }
10298    toJSON() { return this.href; }
10299  }
10300  return URLPolyfill;
10301})();
10302
10303// Always use our polyfill — QuickJS built-in URLSearchParams may not support string init
10304const _URLSearchParams = class URLSearchParamsPolyfill {
10305  constructor(init) {
10306    this._entries = [];
10307    if (typeof init === 'string') {
10308      const s = init.startsWith('?') ? init.slice(1) : init;
10309      if (s) {
10310        for (const pair of s.split('&')) {
10311          const eqIdx = pair.indexOf('=');
10312          if (eqIdx >= 0) {
10313            this._entries.push([decodeURIComponent(pair.slice(0, eqIdx)), decodeURIComponent(pair.slice(eqIdx + 1))]);
10314          } else {
10315            this._entries.push([decodeURIComponent(pair), '']);
10316          }
10317        }
10318      }
10319    }
10320  }
10321  get(key) {
10322    for (const [k, v] of this._entries) { if (k === key) return v; }
10323    return null;
10324  }
10325  set(key, val) {
10326    let found = false;
10327    this._entries = this._entries.filter(([k]) => {
10328      if (k === key && !found) { found = true; return true; }
10329      return k !== key;
10330    });
10331    if (found) {
10332      for (let i = 0; i < this._entries.length; i++) {
10333        if (this._entries[i][0] === key) { this._entries[i][1] = String(val); break; }
10334      }
10335    } else {
10336      this._entries.push([key, String(val)]);
10337    }
10338  }
10339  has(key) { return this._entries.some(([k]) => k === key); }
10340  delete(key) { this._entries = this._entries.filter(([k]) => k !== key); }
10341  append(key, val) { this._entries.push([key, String(val)]); }
10342  getAll(key) { return this._entries.filter(([k]) => k === key).map(([, v]) => v); }
10343  keys() { return this._entries.map(([k]) => k)[Symbol.iterator](); }
10344  values() { return this._entries.map(([, v]) => v)[Symbol.iterator](); }
10345  entries() { return this._entries.slice()[Symbol.iterator](); }
10346  forEach(fn, thisArg) { for (const [k, v] of this._entries) fn.call(thisArg, v, k, this); }
10347  toString() {
10348    return this._entries.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
10349  }
10350  [Symbol.iterator]() { return this.entries(); }
10351  get size() { return this._entries.length; }
10352};
10353
10354export { _URL as URL, _URLSearchParams as URLSearchParams };
10355export function format(urlObj) {
10356  if (typeof urlObj === 'string') return urlObj;
10357  return urlObj && typeof urlObj.href === 'string' ? urlObj.href : String(urlObj);
10358}
10359export function parse(urlStr) {
10360  try { return new _URL(urlStr); } catch (_) { return null; }
10361}
10362export function resolve(from, to) {
10363  try { return new _URL(to, from).href; } catch (_) { return to; }
10364}
10365export default { URL: _URL, URLSearchParams: _URLSearchParams, fileURLToPath, pathToFileURL, format, parse, resolve };
10366"
10367        .trim()
10368        .to_string(),
10369    );
10370
10371    modules.insert(
10372        "node:net".to_string(),
10373        r"
10374// Stub net module - socket operations are not available in PiJS
10375
10376export function createConnection(_opts, _callback) {
10377  throw new Error('node:net.createConnection is not available in PiJS');
10378}
10379
10380export function createServer(_opts, _callback) {
10381  throw new Error('node:net.createServer is not available in PiJS');
10382}
10383
10384export function connect(_opts, _callback) {
10385  throw new Error('node:net.connect is not available in PiJS');
10386}
10387
10388export function isIP(input) {
10389  const value = String(input ?? '');
10390  if (/^(\d{1,3}\.){3}\d{1,3}$/.test(value)) return 4;
10391  if (/^[0-9a-fA-F:]+$/.test(value) && value.includes(':')) return 6;
10392  return 0;
10393}
10394
10395export function isIPv4(input) { return isIP(input) === 4; }
10396export function isIPv6(input) { return isIP(input) === 6; }
10397
10398export class Socket {
10399  constructor() {
10400    throw new Error('node:net.Socket is not available in PiJS');
10401  }
10402}
10403
10404export class Server {
10405  constructor() {
10406    throw new Error('node:net.Server is not available in PiJS');
10407  }
10408}
10409
10410export default { createConnection, createServer, connect, isIP, isIPv4, isIPv6, Socket, Server };
10411"
10412        .trim()
10413        .to_string(),
10414    );
10415
10416    // ── node:events ──────────────────────────────────────────────────
10417    modules.insert(
10418        "node:events".to_string(),
10419        r"
10420class EventEmitter {
10421  constructor() {
10422    this._events = Object.create(null);
10423    this._maxListeners = 10;
10424  }
10425
10426  on(event, listener) {
10427    if (!this._events[event]) this._events[event] = [];
10428    this._events[event].push(listener);
10429    return this;
10430  }
10431
10432  addListener(event, listener) { return this.on(event, listener); }
10433
10434  once(event, listener) {
10435    const wrapper = (...args) => {
10436      this.removeListener(event, wrapper);
10437      listener.apply(this, args);
10438    };
10439    wrapper._original = listener;
10440    return this.on(event, wrapper);
10441  }
10442
10443  off(event, listener) { return this.removeListener(event, listener); }
10444
10445  removeListener(event, listener) {
10446    const list = this._events[event];
10447    if (!list) return this;
10448    this._events[event] = list.filter(
10449      fn => fn !== listener && fn._original !== listener
10450    );
10451    if (this._events[event].length === 0) delete this._events[event];
10452    return this;
10453  }
10454
10455  removeAllListeners(event) {
10456    if (event === undefined) {
10457      this._events = Object.create(null);
10458    } else {
10459      delete this._events[event];
10460    }
10461    return this;
10462  }
10463
10464  emit(event, ...args) {
10465    const list = this._events[event];
10466    if (!list || list.length === 0) return false;
10467    for (const fn of list.slice()) {
10468      try { fn.apply(this, args); } catch (e) {
10469        if (event !== 'error') this.emit('error', e);
10470      }
10471    }
10472    return true;
10473  }
10474
10475  listeners(event) {
10476    const list = this._events[event];
10477    if (!list) return [];
10478    return list.map(fn => fn._original || fn);
10479  }
10480
10481  listenerCount(event) {
10482    const list = this._events[event];
10483    return list ? list.length : 0;
10484  }
10485
10486  eventNames() { return Object.keys(this._events); }
10487
10488  setMaxListeners(n) { this._maxListeners = n; return this; }
10489  getMaxListeners() { return this._maxListeners; }
10490
10491  prependListener(event, listener) {
10492    if (!this._events[event]) this._events[event] = [];
10493    this._events[event].unshift(listener);
10494    return this;
10495  }
10496
10497  prependOnceListener(event, listener) {
10498    const wrapper = (...args) => {
10499      this.removeListener(event, wrapper);
10500      listener.apply(this, args);
10501    };
10502    wrapper._original = listener;
10503    return this.prependListener(event, wrapper);
10504  }
10505
10506  rawListeners(event) {
10507    return this._events[event] ? this._events[event].slice() : [];
10508  }
10509}
10510
10511EventEmitter.EventEmitter = EventEmitter;
10512EventEmitter.defaultMaxListeners = 10;
10513
10514export { EventEmitter };
10515export default EventEmitter;
10516"
10517        .trim()
10518        .to_string(),
10519    );
10520
10521    // ── node:buffer ──────────────────────────────────────────────────
10522    modules.insert(
10523        "node:buffer".to_string(),
10524        crate::buffer_shim::NODE_BUFFER_JS.trim().to_string(),
10525    );
10526
10527    // ── node:assert ──────────────────────────────────────────────────
10528    modules.insert(
10529        "node:assert".to_string(),
10530        r"
10531function assert(value, message) {
10532  if (!value) throw new Error(message || 'Assertion failed');
10533}
10534assert.ok = assert;
10535assert.equal = (a, b, msg) => { if (a != b) throw new Error(msg || `${a} != ${b}`); };
10536assert.strictEqual = (a, b, msg) => { if (a !== b) throw new Error(msg || `${a} !== ${b}`); };
10537assert.notEqual = (a, b, msg) => { if (a == b) throw new Error(msg || `${a} == ${b}`); };
10538assert.notStrictEqual = (a, b, msg) => { if (a === b) throw new Error(msg || `${a} === ${b}`); };
10539assert.deepEqual = assert.deepStrictEqual = (a, b, msg) => {
10540  if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(msg || 'Deep equality failed');
10541};
10542assert.throws = (fn, _expected, msg) => {
10543  let threw = false;
10544  try { fn(); } catch (_) { threw = true; }
10545  if (!threw) throw new Error(msg || 'Expected function to throw');
10546};
10547assert.doesNotThrow = (fn, _expected, msg) => {
10548  try { fn(); } catch (e) { throw new Error(msg || `Got unwanted exception: ${e}`); }
10549};
10550assert.fail = (msg) => { throw new Error(msg || 'assert.fail()'); };
10551
10552export default assert;
10553export { assert };
10554"
10555        .trim()
10556        .to_string(),
10557    );
10558
10559    // ── node:stream ──────────────────────────────────────────────────
10560    modules.insert(
10561        "node:stream".to_string(),
10562        r#"
10563import EventEmitter from "node:events";
10564
10565function __streamToError(err) {
10566  return err instanceof Error ? err : new Error(String(err ?? "stream error"));
10567}
10568
10569function __streamQueueMicrotask(fn) {
10570  if (typeof queueMicrotask === "function") {
10571    queueMicrotask(fn);
10572    return;
10573  }
10574  Promise.resolve().then(fn);
10575}
10576
10577function __normalizeChunk(chunk, encoding) {
10578  if (chunk === null || chunk === undefined) return chunk;
10579  if (typeof chunk === "string") return chunk;
10580  if (typeof Buffer !== "undefined" && Buffer.isBuffer && Buffer.isBuffer(chunk)) {
10581    return encoding ? chunk.toString(encoding) : chunk;
10582  }
10583  if (chunk instanceof Uint8Array) {
10584    return encoding && typeof Buffer !== "undefined" && Buffer.from
10585      ? Buffer.from(chunk).toString(encoding)
10586      : chunk;
10587  }
10588  if (chunk instanceof ArrayBuffer) {
10589    const view = new Uint8Array(chunk);
10590    return encoding && typeof Buffer !== "undefined" && Buffer.from
10591      ? Buffer.from(view).toString(encoding)
10592      : view;
10593  }
10594  if (ArrayBuffer.isView(chunk)) {
10595    const view = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
10596    return encoding && typeof Buffer !== "undefined" && Buffer.from
10597      ? Buffer.from(view).toString(encoding)
10598      : view;
10599  }
10600  return encoding ? String(chunk) : chunk;
10601}
10602
10603class Stream extends EventEmitter {
10604  constructor() {
10605    super();
10606    this.destroyed = false;
10607  }
10608
10609  destroy(err) {
10610    if (this.destroyed) return this;
10611    this.destroyed = true;
10612    if (err) this.emit("error", __streamToError(err));
10613    this.emit("close");
10614    return this;
10615  }
10616}
10617
10618class Readable extends Stream {
10619  constructor(opts = {}) {
10620    super();
10621    this._readableState = { flowing: null, ended: false, encoding: opts.encoding || null };
10622    this.readable = true;
10623    this._queue = [];
10624    this._pipeCleanup = new Map();
10625    this._autoDestroy = opts.autoDestroy !== false;
10626  }
10627
10628  push(chunk) {
10629    if (chunk === null) {
10630      if (this._readableState.ended) return false;
10631      this._readableState.ended = true;
10632      __streamQueueMicrotask(() => {
10633        this.emit("end");
10634        if (this._autoDestroy) this.emit("close");
10635      });
10636      return false;
10637    }
10638    const normalized = __normalizeChunk(chunk, this._readableState.encoding);
10639    this._queue.push(normalized);
10640    this.emit("data", normalized);
10641    return true;
10642  }
10643
10644  read(_size) {
10645    return this._queue.length > 0 ? this._queue.shift() : null;
10646  }
10647
10648  pipe(dest) {
10649    if (!dest || typeof dest.write !== "function") {
10650      throw new Error("stream.pipe destination must implement write()");
10651    }
10652
10653    const onData = (chunk) => {
10654      const writable = dest.write(chunk);
10655      if (writable === false && typeof this.pause === "function") {
10656        this.pause();
10657      }
10658    };
10659    const onDrain = () => {
10660      if (typeof this.resume === "function") this.resume();
10661    };
10662    const onEnd = () => {
10663      if (typeof dest.end === "function") dest.end();
10664      cleanup();
10665    };
10666    const onError = (err) => {
10667      cleanup();
10668      if (typeof dest.destroy === "function") {
10669        dest.destroy(err);
10670      } else if (typeof dest.emit === "function") {
10671        dest.emit("error", err);
10672      }
10673    };
10674    const cleanup = () => {
10675      this.removeListener("data", onData);
10676      this.removeListener("end", onEnd);
10677      this.removeListener("error", onError);
10678      if (typeof dest.removeListener === "function") {
10679        dest.removeListener("drain", onDrain);
10680      }
10681      this._pipeCleanup.delete(dest);
10682    };
10683
10684    this.on("data", onData);
10685    this.on("end", onEnd);
10686    this.on("error", onError);
10687    if (typeof dest.on === "function") {
10688      dest.on("drain", onDrain);
10689    }
10690    this._pipeCleanup.set(dest, cleanup);
10691    return dest;
10692  }
10693
10694  unpipe(dest) {
10695    if (dest) {
10696      const cleanup = this._pipeCleanup.get(dest);
10697      if (cleanup) cleanup();
10698      return this;
10699    }
10700    for (const cleanup of this._pipeCleanup.values()) {
10701      cleanup();
10702    }
10703    this._pipeCleanup.clear();
10704    return this;
10705  }
10706
10707  resume() {
10708    this._readableState.flowing = true;
10709    return this;
10710  }
10711
10712  pause() {
10713    this._readableState.flowing = false;
10714    return this;
10715  }
10716
10717  [Symbol.asyncIterator]() {
10718    const stream = this;
10719    const queue = [];
10720    const waiters = [];
10721    let done = false;
10722    let failure = null;
10723
10724    const settleDone = () => {
10725      done = true;
10726      while (waiters.length > 0) {
10727        waiters.shift().resolve({ value: undefined, done: true });
10728      }
10729    };
10730    const settleError = (err) => {
10731      failure = __streamToError(err);
10732      while (waiters.length > 0) {
10733        waiters.shift().reject(failure);
10734      }
10735    };
10736    const onData = (value) => {
10737      if (waiters.length > 0) {
10738        waiters.shift().resolve({ value, done: false });
10739      } else {
10740        queue.push(value);
10741      }
10742    };
10743    const onEnd = () => settleDone();
10744    const onError = (err) => settleError(err);
10745    const cleanup = () => {
10746      stream.removeListener("data", onData);
10747      stream.removeListener("end", onEnd);
10748      stream.removeListener("error", onError);
10749    };
10750
10751    stream.on("data", onData);
10752    stream.on("end", onEnd);
10753    stream.on("error", onError);
10754
10755    return {
10756      async next() {
10757        if (queue.length > 0) return { value: queue.shift(), done: false };
10758        if (failure) throw failure;
10759        if (done) return { value: undefined, done: true };
10760        return await new Promise((resolve, reject) => waiters.push({ resolve, reject }));
10761      },
10762      async return() {
10763        cleanup();
10764        settleDone();
10765        return { value: undefined, done: true };
10766      },
10767      [Symbol.asyncIterator]() { return this; },
10768    };
10769  }
10770
10771  static from(iterable, opts = {}) {
10772    const readable = new Readable(opts);
10773    (async () => {
10774      try {
10775        for await (const chunk of iterable) {
10776          readable.push(chunk);
10777        }
10778        readable.push(null);
10779      } catch (err) {
10780        readable.emit("error", __streamToError(err));
10781      }
10782    })();
10783    return readable;
10784  }
10785
10786  static fromWeb(webReadable, opts = {}) {
10787    if (!webReadable || typeof webReadable.getReader !== "function") {
10788      throw new Error("Readable.fromWeb expects a Web ReadableStream");
10789    }
10790    const reader = webReadable.getReader();
10791    const readable = new Readable(opts);
10792    (async () => {
10793      try {
10794        while (true) {
10795          const { done, value } = await reader.read();
10796          if (done) break;
10797          readable.push(value);
10798        }
10799        readable.push(null);
10800      } catch (err) {
10801        readable.emit("error", __streamToError(err));
10802      } finally {
10803        try { reader.releaseLock(); } catch (_) {}
10804      }
10805    })();
10806    return readable;
10807  }
10808
10809  static toWeb(nodeReadable) {
10810    if (typeof ReadableStream !== "function") {
10811      throw new Error("Readable.toWeb requires global ReadableStream");
10812    }
10813    if (!nodeReadable || typeof nodeReadable.on !== "function") {
10814      throw new Error("Readable.toWeb expects a Node Readable stream");
10815    }
10816    return new ReadableStream({
10817      start(controller) {
10818        const onData = (chunk) => controller.enqueue(chunk);
10819        const onEnd = () => {
10820          cleanup();
10821          controller.close();
10822        };
10823        const onError = (err) => {
10824          cleanup();
10825          controller.error(__streamToError(err));
10826        };
10827        const cleanup = () => {
10828          nodeReadable.removeListener?.("data", onData);
10829          nodeReadable.removeListener?.("end", onEnd);
10830          nodeReadable.removeListener?.("error", onError);
10831        };
10832        nodeReadable.on("data", onData);
10833        nodeReadable.on("end", onEnd);
10834        nodeReadable.on("error", onError);
10835        if (typeof nodeReadable.resume === "function") nodeReadable.resume();
10836      },
10837      cancel(reason) {
10838        if (typeof nodeReadable.destroy === "function") {
10839          nodeReadable.destroy(__streamToError(reason ?? "stream cancelled"));
10840        }
10841      },
10842    });
10843  }
10844}
10845
10846class Writable extends Stream {
10847  constructor(opts = {}) {
10848    super();
10849    this._writableState = { ended: false, finished: false };
10850    this.writable = true;
10851    this._autoDestroy = opts.autoDestroy !== false;
10852    this._writeImpl = typeof opts.write === "function" ? opts.write.bind(this) : null;
10853    this._finalImpl = typeof opts.final === "function" ? opts.final.bind(this) : null;
10854  }
10855
10856  _write(chunk, encoding, callback) {
10857    if (this._writeImpl) {
10858      this._writeImpl(chunk, encoding, callback);
10859      return;
10860    }
10861    callback(null);
10862  }
10863
10864  write(chunk, encoding, callback) {
10865    let cb = callback;
10866    let enc = encoding;
10867    if (typeof encoding === "function") {
10868      cb = encoding;
10869      enc = undefined;
10870    }
10871    if (this._writableState.ended) {
10872      const err = new Error("write after end");
10873      if (typeof cb === "function") cb(err);
10874      this.emit("error", err);
10875      return false;
10876    }
10877
10878    try {
10879      this._write(chunk, enc, (err) => {
10880        if (err) {
10881          const normalized = __streamToError(err);
10882          if (typeof cb === "function") cb(normalized);
10883          this.emit("error", normalized);
10884          return;
10885        }
10886        if (typeof cb === "function") cb(null);
10887        this.emit("drain");
10888      });
10889    } catch (err) {
10890      const normalized = __streamToError(err);
10891      if (typeof cb === "function") cb(normalized);
10892      this.emit("error", normalized);
10893      return false;
10894    }
10895    return true;
10896  }
10897
10898  _finish(callback) {
10899    if (this._finalImpl) {
10900      try {
10901        this._finalImpl(callback);
10902      } catch (err) {
10903        callback(__streamToError(err));
10904      }
10905      return;
10906    }
10907    callback(null);
10908  }
10909
10910  end(chunk, encoding, callback) {
10911    let cb = callback;
10912    let enc = encoding;
10913    if (typeof encoding === "function") {
10914      cb = encoding;
10915      enc = undefined;
10916    }
10917
10918    const finalize = () => {
10919      if (this._writableState.ended) {
10920        if (typeof cb === "function") cb(null);
10921        return;
10922      }
10923      this._writableState.ended = true;
10924      this._finish((err) => {
10925        if (err) {
10926          const normalized = __streamToError(err);
10927          if (typeof cb === "function") cb(normalized);
10928          this.emit("error", normalized);
10929          return;
10930        }
10931        this._writableState.finished = true;
10932        this.emit("finish");
10933        if (this._autoDestroy) this.emit("close");
10934        if (typeof cb === "function") cb(null);
10935      });
10936    };
10937
10938    if (chunk !== undefined && chunk !== null) {
10939      this.write(chunk, enc, (err) => {
10940        if (err) {
10941          if (typeof cb === "function") cb(err);
10942          return;
10943        }
10944        finalize();
10945      });
10946      return this;
10947    }
10948
10949    finalize();
10950    return this;
10951  }
10952
10953  static fromWeb(webWritable, opts = {}) {
10954    if (!webWritable || typeof webWritable.getWriter !== "function") {
10955      throw new Error("Writable.fromWeb expects a Web WritableStream");
10956    }
10957    const writer = webWritable.getWriter();
10958    return new Writable({
10959      ...opts,
10960      write(chunk, _encoding, callback) {
10961        Promise.resolve(writer.write(chunk))
10962          .then(() => callback(null))
10963          .catch((err) => callback(__streamToError(err)));
10964      },
10965      final(callback) {
10966        Promise.resolve(writer.close())
10967          .then(() => {
10968            try { writer.releaseLock(); } catch (_) {}
10969            callback(null);
10970          })
10971          .catch((err) => callback(__streamToError(err)));
10972      },
10973    });
10974  }
10975
10976  static toWeb(nodeWritable) {
10977    if (typeof WritableStream !== "function") {
10978      throw new Error("Writable.toWeb requires global WritableStream");
10979    }
10980    if (!nodeWritable || typeof nodeWritable.write !== "function") {
10981      throw new Error("Writable.toWeb expects a Node Writable stream");
10982    }
10983    return new WritableStream({
10984      write(chunk) {
10985        return new Promise((resolve, reject) => {
10986          try {
10987            const ok = nodeWritable.write(chunk, (err) => {
10988              if (err) reject(__streamToError(err));
10989              else resolve();
10990            });
10991            if (ok === true) resolve();
10992          } catch (err) {
10993            reject(__streamToError(err));
10994          }
10995        });
10996      },
10997      close() {
10998        return new Promise((resolve, reject) => {
10999          try {
11000            nodeWritable.end((err) => {
11001              if (err) reject(__streamToError(err));
11002              else resolve();
11003            });
11004          } catch (err) {
11005            reject(__streamToError(err));
11006          }
11007        });
11008      },
11009      abort(reason) {
11010        if (typeof nodeWritable.destroy === "function") {
11011          nodeWritable.destroy(__streamToError(reason ?? "stream aborted"));
11012        }
11013      },
11014    });
11015  }
11016}
11017
11018class Duplex extends Readable {
11019  constructor(opts = {}) {
11020    super(opts);
11021    this._writableState = { ended: false, finished: false };
11022    this.writable = true;
11023    this._autoDestroy = opts.autoDestroy !== false;
11024    this._writeImpl = typeof opts.write === "function" ? opts.write.bind(this) : null;
11025    this._finalImpl = typeof opts.final === "function" ? opts.final.bind(this) : null;
11026  }
11027
11028  _write(chunk, encoding, callback) {
11029    if (this._writeImpl) {
11030      this._writeImpl(chunk, encoding, callback);
11031      return;
11032    }
11033    callback(null);
11034  }
11035
11036  _finish(callback) {
11037    if (this._finalImpl) {
11038      try {
11039        this._finalImpl(callback);
11040      } catch (err) {
11041        callback(__streamToError(err));
11042      }
11043      return;
11044    }
11045    callback(null);
11046  }
11047
11048  write(chunk, encoding, callback) {
11049    return Writable.prototype.write.call(this, chunk, encoding, callback);
11050  }
11051
11052  end(chunk, encoding, callback) {
11053    return Writable.prototype.end.call(this, chunk, encoding, callback);
11054  }
11055}
11056
11057class Transform extends Duplex {
11058  constructor(opts = {}) {
11059    super(opts);
11060    this._transformImpl = typeof opts.transform === "function" ? opts.transform.bind(this) : null;
11061  }
11062
11063  _transform(chunk, encoding, callback) {
11064    if (this._transformImpl) {
11065      this._transformImpl(chunk, encoding, callback);
11066      return;
11067    }
11068    callback(null, chunk);
11069  }
11070
11071  write(chunk, encoding, callback) {
11072    let cb = callback;
11073    let enc = encoding;
11074    if (typeof encoding === "function") {
11075      cb = encoding;
11076      enc = undefined;
11077    }
11078    try {
11079      this._transform(chunk, enc, (err, data) => {
11080        if (err) {
11081          const normalized = __streamToError(err);
11082          if (typeof cb === "function") cb(normalized);
11083          this.emit("error", normalized);
11084          return;
11085        }
11086        if (data !== undefined && data !== null) {
11087          this.push(data);
11088        }
11089        if (typeof cb === "function") cb(null);
11090      });
11091    } catch (err) {
11092      const normalized = __streamToError(err);
11093      if (typeof cb === "function") cb(normalized);
11094      this.emit("error", normalized);
11095      return false;
11096    }
11097    return true;
11098  }
11099
11100  end(chunk, encoding, callback) {
11101    let cb = callback;
11102    let enc = encoding;
11103    if (typeof encoding === "function") {
11104      cb = encoding;
11105      enc = undefined;
11106    }
11107    const finalize = () => {
11108      this.push(null);
11109      this.emit("finish");
11110      this.emit("close");
11111      if (typeof cb === "function") cb(null);
11112    };
11113    if (chunk !== undefined && chunk !== null) {
11114      this.write(chunk, enc, (err) => {
11115        if (err) {
11116          if (typeof cb === "function") cb(err);
11117          return;
11118        }
11119        finalize();
11120      });
11121      return this;
11122    }
11123    finalize();
11124    return this;
11125  }
11126}
11127
11128class PassThrough extends Transform {
11129  _transform(chunk, _encoding, callback) { callback(null, chunk); }
11130}
11131
11132function finished(stream, callback) {
11133  if (!stream || typeof stream.on !== "function") {
11134    const err = new Error("finished expects a stream-like object");
11135    if (typeof callback === "function") callback(err);
11136    return Promise.reject(err);
11137  }
11138  return new Promise((resolve, reject) => {
11139    let settled = false;
11140    const cleanup = () => {
11141      stream.removeListener?.("finish", onDone);
11142      stream.removeListener?.("end", onDone);
11143      stream.removeListener?.("close", onDone);
11144      stream.removeListener?.("error", onError);
11145    };
11146    const settle = (fn, value) => {
11147      if (settled) return;
11148      settled = true;
11149      cleanup();
11150      fn(value);
11151    };
11152    const onDone = () => {
11153      if (typeof callback === "function") callback(null, stream);
11154      settle(resolve, stream);
11155    };
11156    const onError = (err) => {
11157      const normalized = __streamToError(err);
11158      if (typeof callback === "function") callback(normalized);
11159      settle(reject, normalized);
11160    };
11161    stream.on("finish", onDone);
11162    stream.on("end", onDone);
11163    stream.on("close", onDone);
11164    stream.on("error", onError);
11165  });
11166}
11167
11168function pipeline(...args) {
11169  const callback = typeof args[args.length - 1] === "function" ? args.pop() : null;
11170  const streams = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
11171  if (!Array.isArray(streams) || streams.length < 2) {
11172    const err = new Error("pipeline requires at least two streams");
11173    if (callback) callback(err);
11174    throw err;
11175  }
11176
11177  for (let i = 0; i < streams.length - 1; i += 1) {
11178    streams[i].pipe(streams[i + 1]);
11179  }
11180  const last = streams[streams.length - 1];
11181  const done = (err) => {
11182    if (callback) callback(err || null, last);
11183  };
11184  last.on?.("finish", () => done(null));
11185  last.on?.("end", () => done(null));
11186  last.on?.("error", (err) => done(__streamToError(err)));
11187  return last;
11188}
11189
11190const promises = {
11191  pipeline: (...args) =>
11192    new Promise((resolve, reject) => {
11193      try {
11194        pipeline(...args, (err, stream) => {
11195          if (err) reject(err);
11196          else resolve(stream);
11197        });
11198      } catch (err) {
11199        reject(__streamToError(err));
11200      }
11201    }),
11202  finished: (stream) => finished(stream),
11203};
11204
11205export { Stream, Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished, promises };
11206export default { Stream, Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished, promises };
11207"#
11208        .trim()
11209        .to_string(),
11210    );
11211
11212    // node:stream/promises — promise-based stream utilities
11213    modules.insert(
11214        "node:stream/promises".to_string(),
11215        r"
11216import { Readable, Writable } from 'node:stream';
11217
11218function __streamToError(err) {
11219  return err instanceof Error ? err : new Error(String(err ?? 'stream error'));
11220}
11221
11222function __isReadableLike(stream) {
11223  return !!stream && typeof stream.pipe === 'function' && typeof stream.on === 'function';
11224}
11225
11226function __isWritableLike(stream) {
11227  return !!stream && typeof stream.write === 'function' && typeof stream.on === 'function';
11228}
11229
11230export async function pipeline(...streams) {
11231  if (streams.length === 1 && Array.isArray(streams[0])) {
11232    streams = streams[0];
11233  }
11234  if (streams.length < 2) {
11235    throw new Error('pipeline requires at least two streams');
11236  }
11237
11238  if (!__isReadableLike(streams[0]) && streams[0] && (typeof streams[0][Symbol.asyncIterator] === 'function' || typeof streams[0][Symbol.iterator] === 'function')) {
11239    streams = [Readable.from(streams[0]), ...streams.slice(1)];
11240  }
11241
11242  return await new Promise((resolve, reject) => {
11243    let settled = false;
11244    const cleanups = [];
11245    const cleanup = () => {
11246      while (cleanups.length > 0) {
11247        try { cleanups.pop()(); } catch (_) {}
11248      }
11249    };
11250    const settleResolve = (value) => {
11251      if (settled) return;
11252      settled = true;
11253      cleanup();
11254      resolve(value);
11255    };
11256    const settleReject = (err) => {
11257      if (settled) return;
11258      settled = true;
11259      cleanup();
11260      reject(__streamToError(err));
11261    };
11262    const addListener = (target, event, handler) => {
11263      if (!target || typeof target.on !== 'function') return;
11264      target.on(event, handler);
11265      cleanups.push(() => {
11266        if (typeof target.removeListener === 'function') {
11267          target.removeListener(event, handler);
11268        }
11269      });
11270    };
11271
11272    for (let i = 0; i < streams.length - 1; i += 1) {
11273      const source = streams[i];
11274      const dest = streams[i + 1];
11275      if (!__isReadableLike(source)) {
11276        settleReject(new Error(`pipeline source at index ${i} is not readable`));
11277        return;
11278      }
11279      if (!__isWritableLike(dest)) {
11280        settleReject(new Error(`pipeline destination at index ${i + 1} is not writable`));
11281        return;
11282      }
11283      try {
11284        source.pipe(dest);
11285      } catch (err) {
11286        settleReject(err);
11287        return;
11288      }
11289    }
11290
11291    const last = streams[streams.length - 1];
11292    for (const stream of streams) {
11293      addListener(stream, 'error', settleReject);
11294    }
11295    addListener(last, 'finish', () => settleResolve(last));
11296    addListener(last, 'end', () => settleResolve(last));
11297    addListener(last, 'close', () => settleResolve(last));
11298
11299    const first = streams[0];
11300    if (first && typeof first.resume === 'function') {
11301      try { first.resume(); } catch (_) {}
11302    }
11303  });
11304}
11305
11306export async function finished(stream) {
11307  if (!stream || typeof stream.on !== 'function') {
11308    throw new Error('finished expects a stream-like object');
11309  }
11310  return await new Promise((resolve, reject) => {
11311    let settled = false;
11312    const cleanup = () => {
11313      if (typeof stream.removeListener !== 'function') return;
11314      stream.removeListener('finish', onDone);
11315      stream.removeListener('end', onDone);
11316      stream.removeListener('close', onDone);
11317      stream.removeListener('error', onError);
11318    };
11319    const onDone = () => {
11320      if (settled) return;
11321      settled = true;
11322      cleanup();
11323      resolve(stream);
11324    };
11325    const onError = (err) => {
11326      if (settled) return;
11327      settled = true;
11328      cleanup();
11329      reject(__streamToError(err));
11330    };
11331    stream.on('finish', onDone);
11332    stream.on('end', onDone);
11333    stream.on('close', onDone);
11334    stream.on('error', onError);
11335  });
11336}
11337export default { pipeline, finished };
11338"
11339        .trim()
11340        .to_string(),
11341    );
11342
11343    // node:stream/web — bridge to global Web Streams when available
11344    modules.insert(
11345        "node:stream/web".to_string(),
11346        r"
11347const _ReadableStream = globalThis.ReadableStream;
11348const _WritableStream = globalThis.WritableStream;
11349const _TransformStream = globalThis.TransformStream;
11350const _TextEncoderStream = globalThis.TextEncoderStream;
11351const _TextDecoderStream = globalThis.TextDecoderStream;
11352const _CompressionStream = globalThis.CompressionStream;
11353const _DecompressionStream = globalThis.DecompressionStream;
11354const _ByteLengthQueuingStrategy = globalThis.ByteLengthQueuingStrategy;
11355const _CountQueuingStrategy = globalThis.CountQueuingStrategy;
11356
11357export const ReadableStream = _ReadableStream;
11358export const WritableStream = _WritableStream;
11359export const TransformStream = _TransformStream;
11360export const TextEncoderStream = _TextEncoderStream;
11361export const TextDecoderStream = _TextDecoderStream;
11362export const CompressionStream = _CompressionStream;
11363export const DecompressionStream = _DecompressionStream;
11364export const ByteLengthQueuingStrategy = _ByteLengthQueuingStrategy;
11365export const CountQueuingStrategy = _CountQueuingStrategy;
11366
11367export default {
11368  ReadableStream,
11369  WritableStream,
11370  TransformStream,
11371  TextEncoderStream,
11372  TextDecoderStream,
11373  CompressionStream,
11374  DecompressionStream,
11375  ByteLengthQueuingStrategy,
11376  CountQueuingStrategy,
11377};
11378"
11379        .trim()
11380        .to_string(),
11381    );
11382
11383    // node:string_decoder — often imported by stream consumers
11384    modules.insert(
11385        "node:string_decoder".to_string(),
11386        r"
11387export class StringDecoder {
11388  constructor(encoding) { this.encoding = encoding || 'utf8'; }
11389  write(buf) { return typeof buf === 'string' ? buf : String(buf ?? ''); }
11390  end(buf) { return buf ? this.write(buf) : ''; }
11391}
11392export default { StringDecoder };
11393"
11394        .trim()
11395        .to_string(),
11396    );
11397
11398    // node:querystring — URL query string encoding/decoding
11399    modules.insert(
11400        "node:querystring".to_string(),
11401        r"
11402export function parse(qs, sep, eq) {
11403  const s = String(qs ?? '');
11404  const sepStr = sep || '&';
11405  const eqStr = eq || '=';
11406  const result = {};
11407  if (!s) return result;
11408  for (const pair of s.split(sepStr)) {
11409    const idx = pair.indexOf(eqStr);
11410    const key = idx === -1 ? decodeURIComponent(pair) : decodeURIComponent(pair.slice(0, idx));
11411    const val = idx === -1 ? '' : decodeURIComponent(pair.slice(idx + eqStr.length));
11412    if (Object.prototype.hasOwnProperty.call(result, key)) {
11413      if (Array.isArray(result[key])) result[key].push(val);
11414      else result[key] = [result[key], val];
11415    } else {
11416      result[key] = val;
11417    }
11418  }
11419  return result;
11420}
11421export function stringify(obj, sep, eq) {
11422  const sepStr = sep || '&';
11423  const eqStr = eq || '=';
11424  if (!obj || typeof obj !== 'object') return '';
11425  return Object.entries(obj).map(([k, v]) => {
11426    if (Array.isArray(v)) return v.map(i => encodeURIComponent(k) + eqStr + encodeURIComponent(i)).join(sepStr);
11427    return encodeURIComponent(k) + eqStr + encodeURIComponent(v ?? '');
11428  }).join(sepStr);
11429}
11430export const decode = parse;
11431export const encode = stringify;
11432export function escape(str) { return encodeURIComponent(str); }
11433export function unescape(str) { return decodeURIComponent(str); }
11434export default { parse, stringify, decode, encode, escape, unescape };
11435"
11436        .trim()
11437        .to_string(),
11438    );
11439
11440    // node:constants — compatibility map for libraries probing process constants
11441    modules.insert(
11442        "node:constants".to_string(),
11443        r"
11444const _constants = {
11445  EOL: '\n',
11446  F_OK: 0,
11447  R_OK: 4,
11448  W_OK: 2,
11449  X_OK: 1,
11450  UV_UDP_REUSEADDR: 4,
11451  SSL_OP_NO_SSLv2: 0,
11452  SSL_OP_NO_SSLv3: 0,
11453  SSL_OP_NO_TLSv1: 0,
11454  SSL_OP_NO_TLSv1_1: 0,
11455};
11456
11457const constants = new Proxy(_constants, {
11458  get(target, prop) {
11459    if (prop in target) return target[prop];
11460    return 0;
11461  },
11462});
11463
11464export default constants;
11465export { constants };
11466"
11467        .trim()
11468        .to_string(),
11469    );
11470
11471    // node:tty — terminal capability probes
11472    modules.insert(
11473        "node:tty".to_string(),
11474        r"
11475import EventEmitter from 'node:events';
11476
11477export function isatty(_fd) { return false; }
11478
11479export class ReadStream extends EventEmitter {
11480  constructor(_fd) {
11481    super();
11482    this.isTTY = false;
11483    this.columns = 80;
11484    this.rows = 24;
11485  }
11486  setRawMode(_mode) { return this; }
11487}
11488
11489export class WriteStream extends EventEmitter {
11490  constructor(_fd) {
11491    super();
11492    this.isTTY = false;
11493    this.columns = 80;
11494    this.rows = 24;
11495  }
11496  getColorDepth() { return 1; }
11497  hasColors() { return false; }
11498  getWindowSize() { return [this.columns, this.rows]; }
11499}
11500
11501export default { isatty, ReadStream, WriteStream };
11502"
11503        .trim()
11504        .to_string(),
11505    );
11506
11507    // node:tls — secure socket APIs are intentionally unavailable in PiJS
11508    modules.insert(
11509        "node:tls".to_string(),
11510        r"
11511import EventEmitter from 'node:events';
11512
11513export const DEFAULT_MIN_VERSION = 'TLSv1.2';
11514export const DEFAULT_MAX_VERSION = 'TLSv1.3';
11515
11516export class TLSSocket extends EventEmitter {
11517  constructor(_socket, _options) {
11518    super();
11519    this.authorized = false;
11520    this.encrypted = true;
11521  }
11522}
11523
11524export function connect(_portOrOptions, _host, _options, _callback) {
11525  throw new Error('node:tls.connect is not available in PiJS');
11526}
11527
11528export function createServer(_options, _secureConnectionListener) {
11529  throw new Error('node:tls.createServer is not available in PiJS');
11530}
11531
11532export default { connect, createServer, TLSSocket, DEFAULT_MIN_VERSION, DEFAULT_MAX_VERSION };
11533"
11534        .trim()
11535        .to_string(),
11536    );
11537
11538    // node:zlib — compression streams are not implemented in PiJS
11539    modules.insert(
11540        "node:zlib".to_string(),
11541        r"
11542const constants = {
11543  Z_NO_COMPRESSION: 0,
11544  Z_BEST_SPEED: 1,
11545  Z_BEST_COMPRESSION: 9,
11546  Z_DEFAULT_COMPRESSION: -1,
11547};
11548
11549function unsupported(name) {
11550  throw new Error(`node:zlib.${name} is not available in PiJS`);
11551}
11552
11553export function gzip(_buffer, callback) {
11554  if (typeof callback === 'function') callback(new Error('node:zlib.gzip is not available in PiJS'));
11555}
11556export function gunzip(_buffer, callback) {
11557  if (typeof callback === 'function') callback(new Error('node:zlib.gunzip is not available in PiJS'));
11558}
11559
11560export function createGzip() { unsupported('createGzip'); }
11561export function createGunzip() { unsupported('createGunzip'); }
11562export function createDeflate() { unsupported('createDeflate'); }
11563export function createInflate() { unsupported('createInflate'); }
11564export function createBrotliCompress() { unsupported('createBrotliCompress'); }
11565export function createBrotliDecompress() { unsupported('createBrotliDecompress'); }
11566
11567export const promises = {
11568  gzip: async () => { unsupported('promises.gzip'); },
11569  gunzip: async () => { unsupported('promises.gunzip'); },
11570};
11571
11572export default {
11573  constants,
11574  gzip,
11575  gunzip,
11576  createGzip,
11577  createGunzip,
11578  createDeflate,
11579  createInflate,
11580  createBrotliCompress,
11581  createBrotliDecompress,
11582  promises,
11583};
11584"
11585        .trim()
11586        .to_string(),
11587    );
11588
11589    // node:perf_hooks — expose lightweight performance clock surface
11590    modules.insert(
11591        "node:perf_hooks".to_string(),
11592        r"
11593const perf =
11594  globalThis.performance ||
11595  {
11596    now: () => Date.now(),
11597    mark: () => {},
11598    measure: () => {},
11599    clearMarks: () => {},
11600    clearMeasures: () => {},
11601    getEntries: () => [],
11602    getEntriesByType: () => [],
11603    getEntriesByName: () => [],
11604  };
11605
11606export const performance = perf;
11607export const constants = {};
11608export class PerformanceObserver {
11609  constructor(_callback) {}
11610  observe(_opts) {}
11611  disconnect() {}
11612}
11613
11614export default { performance, constants, PerformanceObserver };
11615"
11616        .trim()
11617        .to_string(),
11618    );
11619
11620    // node:vm — disabled in PiJS for safety
11621    modules.insert(
11622        "node:vm".to_string(),
11623        r"
11624function unsupported(name) {
11625  throw new Error(`node:vm.${name} is not available in PiJS`);
11626}
11627
11628export function runInContext() { unsupported('runInContext'); }
11629export function runInNewContext() { unsupported('runInNewContext'); }
11630export function runInThisContext() { unsupported('runInThisContext'); }
11631export function createContext(_sandbox) { return _sandbox || {}; }
11632
11633export class Script {
11634  constructor(_code, _options) { unsupported('Script'); }
11635}
11636
11637export default { runInContext, runInNewContext, runInThisContext, createContext, Script };
11638"
11639        .trim()
11640        .to_string(),
11641    );
11642
11643    // node:v8 — lightweight serialization fallback used by some libs
11644    modules.insert(
11645        "node:v8".to_string(),
11646        r"
11647function __toBuffer(str) {
11648  if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
11649    return Buffer.from(str, 'utf8');
11650  }
11651  if (typeof TextEncoder !== 'undefined') {
11652    return new TextEncoder().encode(str);
11653  }
11654  return str;
11655}
11656
11657function __fromBuffer(buf) {
11658  if (buf == null) return '';
11659  if (typeof Buffer !== 'undefined' && typeof Buffer.isBuffer === 'function' && Buffer.isBuffer(buf)) {
11660    return buf.toString('utf8');
11661  }
11662  if (buf instanceof Uint8Array && typeof TextDecoder !== 'undefined') {
11663    return new TextDecoder().decode(buf);
11664  }
11665  return String(buf);
11666}
11667
11668export function serialize(value) {
11669  return __toBuffer(JSON.stringify(value));
11670}
11671
11672export function deserialize(value) {
11673  return JSON.parse(__fromBuffer(value));
11674}
11675
11676export default { serialize, deserialize };
11677"
11678        .trim()
11679        .to_string(),
11680    );
11681
11682    // node:worker_threads — workers are not supported in PiJS
11683    modules.insert(
11684        "node:worker_threads".to_string(),
11685        r"
11686export const isMainThread = true;
11687export const threadId = 0;
11688export const workerData = null;
11689export const parentPort = null;
11690
11691export class Worker {
11692  constructor(_filename, _options) {
11693    throw new Error('node:worker_threads.Worker is not available in PiJS');
11694  }
11695}
11696
11697export default { isMainThread, threadId, workerData, parentPort, Worker };
11698"
11699        .trim()
11700        .to_string(),
11701    );
11702
11703    // node:process — re-exports globalThis.process
11704    modules.insert(
11705        "node:process".to_string(),
11706        r"
11707const p = globalThis.process || {};
11708export const env = p.env || {};
11709export const argv = p.argv || [];
11710export const cwd = typeof p.cwd === 'function' ? p.cwd : () => '/';
11711export const chdir = typeof p.chdir === 'function' ? p.chdir : () => { throw new Error('ENOSYS'); };
11712export const platform = p.platform || 'linux';
11713export const arch = p.arch || 'x64';
11714export const version = p.version || 'v20.0.0';
11715export const versions = p.versions || {};
11716export const pid = p.pid || 1;
11717export const ppid = p.ppid || 0;
11718export const title = p.title || 'pi';
11719export const execPath = p.execPath || '/usr/bin/pi';
11720export const execArgv = p.execArgv || [];
11721export const stdout = p.stdout || { write() {} };
11722export const stderr = p.stderr || { write() {} };
11723export const stdin = p.stdin || {};
11724export const nextTick = p.nextTick || ((fn, ...a) => Promise.resolve().then(() => fn(...a)));
11725export const hrtime = p.hrtime || Object.assign(() => [0, 0], { bigint: () => BigInt(0) });
11726export const exit = p.exit || (() => {});
11727export const kill = p.kill || (() => {});
11728export const on = p.on || (() => p);
11729export const off = p.off || (() => p);
11730export const once = p.once || (() => p);
11731export const addListener = p.addListener || (() => p);
11732export const removeListener = p.removeListener || (() => p);
11733export const removeAllListeners = p.removeAllListeners || (() => p);
11734export const listeners = p.listeners || (() => []);
11735export const emit = p.emit || (() => false);
11736export const emitWarning = p.emitWarning || (() => {});
11737export const uptime = p.uptime || (() => 0);
11738export const memoryUsage = p.memoryUsage || (() => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0, arrayBuffers: 0 }));
11739export const cpuUsage = p.cpuUsage || (() => ({ user: 0, system: 0 }));
11740export const release = p.release || { name: 'node' };
11741export default p;
11742"
11743        .trim()
11744        .to_string(),
11745    );
11746
11747    // ── npm package stubs ──────────────────────────────────────────────
11748    // Minimal virtual modules for npm packages that cannot run in the
11749    // QuickJS sandbox (native bindings, large dependency trees, or
11750    // companion packages). These stubs let extensions *load* and register
11751    // tools/commands even though the actual library behaviour is absent.
11752
11753    modules.insert(
11754        "@mariozechner/clipboard".to_string(),
11755        r"
11756export async function getText() { return ''; }
11757export async function setText(_text) {}
11758export default { getText, setText };
11759"
11760        .trim()
11761        .to_string(),
11762    );
11763
11764    modules.insert(
11765        "node-pty".to_string(),
11766        r"
11767let _pid = 1000;
11768export function spawn(shell, args, options) {
11769    const pid = _pid++;
11770    const handlers = {};
11771    return {
11772        pid,
11773        onData(cb) { handlers.data = cb; },
11774        onExit(cb) { if (cb) setTimeout(() => cb({ exitCode: 1, signal: undefined }), 0); },
11775        write(d) {},
11776        resize(c, r) {},
11777        kill(s) {},
11778    };
11779}
11780export default { spawn };
11781"
11782        .trim()
11783        .to_string(),
11784    );
11785
11786    modules.insert(
11787        "chokidar".to_string(),
11788        r"
11789function makeWatcher() {
11790    const w = {
11791        on(ev, cb) { return w; },
11792        once(ev, cb) { return w; },
11793        close() { return Promise.resolve(); },
11794        add(p) { return w; },
11795        unwatch(p) { return w; },
11796        getWatched() { return {}; },
11797    };
11798    return w;
11799}
11800export function watch(paths, options) { return makeWatcher(); }
11801export default { watch };
11802"
11803        .trim()
11804        .to_string(),
11805    );
11806
11807    modules.insert(
11808        "jsdom".to_string(),
11809        r"
11810class Element {
11811    constructor(tag, html) { this.tagName = tag; this._html = html || ''; this.childNodes = []; }
11812    get innerHTML() { return this._html; }
11813    set innerHTML(v) { this._html = v; }
11814    get textContent() { return this._html.replace(/<[^>]*>/g, ''); }
11815    get outerHTML() { return `<${this.tagName}>${this._html}</${this.tagName}>`; }
11816    get parentNode() { return null; }
11817    querySelectorAll() { return []; }
11818    querySelector() { return null; }
11819    getElementsByTagName() { return []; }
11820    getElementById() { return null; }
11821    remove() {}
11822    getAttribute() { return null; }
11823    setAttribute() {}
11824    cloneNode() { return new Element(this.tagName, this._html); }
11825}
11826export class JSDOM {
11827    constructor(html, opts) {
11828        const doc = new Element('html', html || '');
11829        doc.body = new Element('body', html || '');
11830        doc.title = '';
11831        doc.querySelectorAll = () => [];
11832        doc.querySelector = () => null;
11833        doc.getElementsByTagName = () => [];
11834        doc.getElementById = () => null;
11835        doc.createElement = (t) => new Element(t, '');
11836        doc.documentElement = doc;
11837        this.window = { document: doc, location: { href: (opts && opts.url) || '' } };
11838    }
11839}
11840"
11841        .trim()
11842        .to_string(),
11843    );
11844
11845    modules.insert(
11846        "@mozilla/readability".to_string(),
11847        r"
11848export class Readability {
11849    constructor(doc, opts) { this._doc = doc; }
11850    parse() {
11851        const text = (this._doc && this._doc.body && this._doc.body.textContent) || '';
11852        return { title: '', content: text, textContent: text, length: text.length, excerpt: '', byline: '', dir: '', siteName: '', lang: '' };
11853    }
11854}
11855"
11856        .trim()
11857        .to_string(),
11858    );
11859
11860    modules.insert(
11861        "beautiful-mermaid".to_string(),
11862        r"
11863export function renderMermaidAscii(source) {
11864    const firstLine = (source || '').split('\n')[0] || 'diagram';
11865    return '[mermaid: ' + firstLine.trim() + ']';
11866}
11867"
11868        .trim()
11869        .to_string(),
11870    );
11871
11872    modules.insert(
11873        "@aliou/pi-utils-settings".to_string(),
11874        r"
11875export class ConfigLoader {
11876    constructor(name, defaultConfig, options) {
11877        this._name = name;
11878        this._default = defaultConfig || {};
11879        this._opts = options || {};
11880        this._data = structuredClone(this._default);
11881    }
11882    async load() { return this._data; }
11883    save(d) { this._data = d; }
11884    get() { return this._data; }
11885    getConfig() { return this._data; }
11886    set(k, v) { this._data[k] = v; }
11887}
11888export class ArrayEditor {
11889    constructor(arr) { this._arr = arr || []; }
11890    add(item) { this._arr.push(item); return this; }
11891    remove(idx) { this._arr.splice(idx, 1); return this; }
11892    toArray() { return this._arr; }
11893}
11894export function registerSettingsCommand(pi, opts) {}
11895export function getNestedValue(obj, path) {
11896    const keys = (path || '').split('.');
11897    let cur = obj;
11898    for (const k of keys) { if (cur == null) return undefined; cur = cur[k]; }
11899    return cur;
11900}
11901export function setNestedValue(obj, path, value) {
11902    const keys = (path || '').split('.');
11903    let cur = obj;
11904    for (let i = 0; i < keys.length - 1; i++) {
11905        if (cur[keys[i]] == null) cur[keys[i]] = {};
11906        cur = cur[keys[i]];
11907    }
11908    cur[keys[keys.length - 1]] = value;
11909}
11910"
11911        .trim()
11912        .to_string(),
11913    );
11914
11915    modules.insert(
11916        "@aliou/sh".to_string(),
11917        r#"
11918export function parse(cmd) { return [{ type: 'command', value: cmd }]; }
11919export function tokenize(cmd) { return (cmd || '').split(/\s+/); }
11920export function quote(s) { return "'" + (s || '').replace(/'/g, "'\\''") + "'"; }
11921export class ParseError extends Error { constructor(msg) { super(msg); this.name = 'ParseError'; } }
11922"#
11923        .trim()
11924        .to_string(),
11925    );
11926
11927    modules.insert(
11928        "@marckrenn/pi-sub-shared".to_string(),
11929        r#"
11930export const PROVIDERS = ["anthropic", "openai", "google", "aws", "azure"];
11931export const MODEL_MULTIPLIERS = {};
11932const _meta = (name) => ({
11933    name, displayName: name.charAt(0).toUpperCase() + name.slice(1),
11934    detection: { envVars: [], configPaths: [] },
11935    status: { operational: true },
11936});
11937export const PROVIDER_METADATA = Object.fromEntries(PROVIDERS.map(p => [p, _meta(p)]));
11938export const PROVIDER_DISPLAY_NAMES = Object.fromEntries(
11939    PROVIDERS.map(p => [p, p.charAt(0).toUpperCase() + p.slice(1)])
11940);
11941export function getDefaultCoreSettings() {
11942    return { providers: {}, behavior: { autoSwitch: false } };
11943}
11944"#
11945        .trim()
11946        .to_string(),
11947    );
11948
11949    modules.insert(
11950        "turndown".to_string(),
11951        r"
11952class TurndownService {
11953    constructor(opts) { this._opts = opts || {}; }
11954    turndown(html) { return (html || '').replace(/<[^>]*>/g, ''); }
11955    addRule(name, rule) { return this; }
11956    use(plugin) { return this; }
11957    remove(filter) { return this; }
11958}
11959export default TurndownService;
11960"
11961        .trim()
11962        .to_string(),
11963    );
11964
11965    modules.insert(
11966        "@xterm/headless".to_string(),
11967        r"
11968export class Terminal {
11969    constructor(opts) { this._opts = opts || {}; this.cols = opts?.cols || 80; this.rows = opts?.rows || 24; this.buffer = { active: { cursorX: 0, cursorY: 0, length: 0, getLine: () => null } }; }
11970    write(data) {}
11971    writeln(data) {}
11972    resize(cols, rows) { this.cols = cols; this.rows = rows; }
11973    dispose() {}
11974    onData(cb) { return { dispose() {} }; }
11975    onLineFeed(cb) { return { dispose() {} }; }
11976}
11977export default { Terminal };
11978"
11979        .trim()
11980        .to_string(),
11981    );
11982
11983    modules.insert(
11984        "@opentelemetry/api".to_string(),
11985        r"
11986export const SpanStatusCode = { UNSET: 0, OK: 1, ERROR: 2 };
11987const noopSpan = {
11988    setAttribute() { return this; },
11989    setAttributes() { return this; },
11990    addEvent() { return this; },
11991    setStatus() { return this; },
11992    end() {},
11993    isRecording() { return false; },
11994    recordException() {},
11995    spanContext() { return { traceId: '', spanId: '', traceFlags: 0 }; },
11996};
11997const noopTracer = {
11998    startSpan() { return noopSpan; },
11999    startActiveSpan(name, optsOrFn, fn) {
12000        const cb = typeof optsOrFn === 'function' ? optsOrFn : fn;
12001        return cb ? cb(noopSpan) : noopSpan;
12002    },
12003};
12004export const trace = {
12005    getTracer() { return noopTracer; },
12006    getActiveSpan() { return noopSpan; },
12007    setSpan(ctx) { return ctx; },
12008};
12009export const context = {
12010    active() { return {}; },
12011    with(ctx, fn) { return fn(); },
12012};
12013"
12014        .trim()
12015        .to_string(),
12016    );
12017
12018    modules.insert(
12019        "@juanibiapina/pi-extension-settings".to_string(),
12020        r"
12021export function getSetting(pi, key, defaultValue) { return defaultValue; }
12022export function setSetting(pi, key, value) {}
12023export function getSettings(pi) { return {}; }
12024"
12025        .trim()
12026        .to_string(),
12027    );
12028
12029    modules.insert(
12030        "@xterm/addon-serialize".to_string(),
12031        r"
12032export class SerializeAddon {
12033    activate(terminal) {}
12034    serialize(opts) { return ''; }
12035    dispose() {}
12036}
12037"
12038        .trim()
12039        .to_string(),
12040    );
12041
12042    modules.insert(
12043        "turndown-plugin-gfm".to_string(),
12044        r"
12045export function gfm(service) {}
12046export function tables(service) {}
12047export function strikethrough(service) {}
12048export function taskListItems(service) {}
12049"
12050        .trim()
12051        .to_string(),
12052    );
12053
12054    modules.insert(
12055        "@opentelemetry/exporter-trace-otlp-http".to_string(),
12056        r"
12057export class OTLPTraceExporter {
12058    constructor(opts) { this._opts = opts || {}; }
12059    export(spans, cb) { if (cb) cb({ code: 0 }); }
12060    shutdown() { return Promise.resolve(); }
12061}
12062"
12063        .trim()
12064        .to_string(),
12065    );
12066
12067    modules.insert(
12068        "@opentelemetry/resources".to_string(),
12069        r"
12070export class Resource {
12071    constructor(attrs) { this.attributes = attrs || {}; }
12072    merge(other) { return new Resource({ ...this.attributes, ...(other?.attributes || {}) }); }
12073}
12074export function resourceFromAttributes(attrs) { return new Resource(attrs); }
12075"
12076        .trim()
12077        .to_string(),
12078    );
12079
12080    modules.insert(
12081        "@opentelemetry/sdk-trace-base".to_string(),
12082        r"
12083const noopSpan = { setAttribute() { return this; }, end() {}, isRecording() { return false; }, spanContext() { return {}; } };
12084export class BasicTracerProvider {
12085    constructor(opts) { this._opts = opts || {}; }
12086    addSpanProcessor(p) {}
12087    register() {}
12088    getTracer() { return { startSpan() { return noopSpan; }, startActiveSpan(n, fn) { return fn(noopSpan); } }; }
12089    shutdown() { return Promise.resolve(); }
12090}
12091export class SimpleSpanProcessor {
12092    constructor(exporter) {}
12093    onStart() {}
12094    onEnd() {}
12095    shutdown() { return Promise.resolve(); }
12096    forceFlush() { return Promise.resolve(); }
12097}
12098export class BatchSpanProcessor extends SimpleSpanProcessor {}
12099"
12100        .trim()
12101        .to_string(),
12102    );
12103
12104    modules.insert(
12105        "@opentelemetry/semantic-conventions".to_string(),
12106        r"
12107export const SemanticResourceAttributes = {
12108    SERVICE_NAME: 'service.name',
12109    SERVICE_VERSION: 'service.version',
12110    DEPLOYMENT_ENVIRONMENT: 'deployment.environment',
12111};
12112export const SEMRESATTRS_SERVICE_NAME = 'service.name';
12113export const SEMRESATTRS_SERVICE_VERSION = 'service.version';
12114"
12115        .trim()
12116        .to_string(),
12117    );
12118
12119    // ── npm package stubs for extension conformance ──
12120
12121    {
12122        let openclaw_plugin_sdk = r#"
12123export function definePlugin(spec = {}) { return spec; }
12124export function createPlugin(spec = {}) { return spec; }
12125export function tool(spec = {}) { return { ...spec, type: "tool" }; }
12126export function command(spec = {}) { return { ...spec, type: "command" }; }
12127export function provider(spec = {}) { return { ...spec, type: "provider" }; }
12128export const DEFAULT_ACCOUNT_ID = "default";
12129const __schema = {
12130  parse(value) { return value; },
12131  safeParse(value) { return { success: true, data: value }; },
12132  optional() { return this; },
12133  nullable() { return this; },
12134  default() { return this; },
12135  array() { return this; },
12136  transform() { return this; },
12137  refine() { return this; },
12138};
12139export const emptyPluginConfigSchema = __schema;
12140export function createReplyPrefixContext() { return {}; }
12141export function stringEnum(values = []) { return values[0] ?? ""; }
12142export function getChatChannelMeta() { return {}; }
12143export function addWildcardAllowFrom() { return []; }
12144export function listFeishuAccountIds() { return []; }
12145export function normalizeAccountId(value) { return String(value ?? ""); }
12146export function jsonResult(value) {
12147  return {
12148    content: [{ type: "text", text: JSON.stringify(value ?? null) }],
12149    details: { value },
12150  };
12151}
12152export function stripAnsi(value) {
12153  return String(value ?? "").replace(/\u001b\[[0-9;]*m/g, "");
12154}
12155export function recordInboundSession() { return undefined; }
12156export class OpenClawPlugin {
12157  constructor(spec = {}) { this.spec = spec; }
12158  async activate(pi) {
12159    const plugin = this.spec || {};
12160    if (Array.isArray(plugin.tools)) {
12161      for (const t of plugin.tools) {
12162        if (!t || !t.name) continue;
12163        const execute = typeof t.execute === "function" ? t.execute : async () => ({ content: [] });
12164        pi.registerTool?.({ ...t, execute });
12165      }
12166    }
12167    if (Array.isArray(plugin.commands)) {
12168      for (const c of plugin.commands) {
12169        if (!c || !c.name) continue;
12170        const handler = typeof c.handler === "function" ? c.handler : async () => ({});
12171        pi.registerCommand?.(c.name, { ...c, handler });
12172      }
12173    }
12174    if (typeof plugin.activate === "function") {
12175      await plugin.activate(pi);
12176    }
12177  }
12178}
12179export async function registerOpenClaw(pi, plugin) {
12180  if (typeof plugin === "function") {
12181    return await plugin(pi);
12182  }
12183  if (plugin && typeof plugin.default === "function") {
12184    return await plugin.default(pi);
12185  }
12186  if (plugin && typeof plugin.activate === "function") {
12187    return await plugin.activate(pi);
12188  }
12189  return undefined;
12190}
12191export default {
12192  definePlugin,
12193  createPlugin,
12194  tool,
12195  command,
12196  provider,
12197  DEFAULT_ACCOUNT_ID,
12198  emptyPluginConfigSchema,
12199  createReplyPrefixContext,
12200  stringEnum,
12201  getChatChannelMeta,
12202  addWildcardAllowFrom,
12203  listFeishuAccountIds,
12204  normalizeAccountId,
12205  jsonResult,
12206  stripAnsi,
12207  recordInboundSession,
12208  registerOpenClaw,
12209  OpenClawPlugin,
12210};
12211"#
12212        .trim()
12213        .to_string();
12214
12215        modules.insert(
12216            "openclaw/plugin-sdk".to_string(),
12217            openclaw_plugin_sdk.clone(),
12218        );
12219        modules.insert(
12220            "openclaw/plugin-sdk/index.js".to_string(),
12221            openclaw_plugin_sdk.clone(),
12222        );
12223        modules.insert(
12224            "clawdbot/plugin-sdk".to_string(),
12225            openclaw_plugin_sdk.clone(),
12226        );
12227        modules.insert(
12228            "clawdbot/plugin-sdk/index.js".to_string(),
12229            openclaw_plugin_sdk,
12230        );
12231    }
12232
12233    modules.insert(
12234        "zod".to_string(),
12235        r"
12236const __schema = {
12237  parse(value) { return value; },
12238  safeParse(value) { return { success: true, data: value }; },
12239  optional() { return this; },
12240  nullable() { return this; },
12241  nullish() { return this; },
12242  default() { return this; },
12243  array() { return this; },
12244  transform() { return this; },
12245  refine() { return this; },
12246  describe() { return this; },
12247  min() { return this; },
12248  max() { return this; },
12249  length() { return this; },
12250  regex() { return this; },
12251  url() { return this; },
12252  email() { return this; },
12253  uuid() { return this; },
12254  int() { return this; },
12255  positive() { return this; },
12256  nonnegative() { return this; },
12257  nonempty() { return this; },
12258};
12259function makeSchema() { return Object.create(__schema); }
12260export const z = {
12261  string() { return makeSchema(); },
12262  number() { return makeSchema(); },
12263  boolean() { return makeSchema(); },
12264  object() { return makeSchema(); },
12265  array() { return makeSchema(); },
12266  enum() { return makeSchema(); },
12267  literal() { return makeSchema(); },
12268  union() { return makeSchema(); },
12269  intersection() { return makeSchema(); },
12270  record() { return makeSchema(); },
12271  any() { return makeSchema(); },
12272  unknown() { return makeSchema(); },
12273  null() { return makeSchema(); },
12274  undefined() { return makeSchema(); },
12275  optional(inner) { return inner ?? makeSchema(); },
12276  nullable(inner) { return inner ?? makeSchema(); },
12277};
12278export default z;
12279"
12280        .trim()
12281        .to_string(),
12282    );
12283
12284    modules.insert(
12285        "yaml".to_string(),
12286        r##"
12287export function parse(input) {
12288    const text = String(input ?? "").trim();
12289    if (!text) return {};
12290    const out = {};
12291    for (const rawLine of text.split(/\r?\n/)) {
12292        const line = rawLine.trim();
12293        if (!line || line.startsWith("#")) continue;
12294        const idx = line.indexOf(":");
12295        if (idx === -1) continue;
12296        const key = line.slice(0, idx).trim();
12297        const value = line.slice(idx + 1).trim();
12298        if (key) out[key] = value;
12299    }
12300    return out;
12301}
12302export function stringify(value) {
12303    if (!value || typeof value !== "object") return "";
12304    const lines = Object.entries(value).map(([k, v]) => `${k}: ${v ?? ""}`);
12305    return lines.length ? `${lines.join("\n")}\n` : "";
12306}
12307export default { parse, stringify };
12308"##
12309        .trim()
12310        .to_string(),
12311    );
12312
12313    modules.insert(
12314        "better-sqlite3".to_string(),
12315        r#"
12316class Statement {
12317    all() { return []; }
12318    get() { return undefined; }
12319    run() { return { changes: 0, lastInsertRowid: 0 }; }
12320}
12321
12322function BetterSqlite3(filename, options = {}) {
12323    if (!(this instanceof BetterSqlite3)) return new BetterSqlite3(filename, options);
12324    this.filename = String(filename ?? "");
12325    this.options = options;
12326}
12327
12328BetterSqlite3.prototype.prepare = function(_sql) { return new Statement(); };
12329BetterSqlite3.prototype.exec = function(_sql) { return this; };
12330BetterSqlite3.prototype.pragma = function(_sql) { return []; };
12331BetterSqlite3.prototype.transaction = function(fn) {
12332    const wrapped = (...args) => (typeof fn === "function" ? fn(...args) : undefined);
12333    wrapped.immediate = wrapped;
12334    wrapped.deferred = wrapped;
12335    wrapped.exclusive = wrapped;
12336    return wrapped;
12337};
12338BetterSqlite3.prototype.close = function() {};
12339
12340BetterSqlite3.Statement = Statement;
12341BetterSqlite3.Database = BetterSqlite3;
12342
12343export { Statement };
12344export default BetterSqlite3;
12345"#
12346        .trim()
12347        .to_string(),
12348    );
12349
12350    modules.insert(
12351        "@mariozechner/pi-agent-core".to_string(),
12352        r#"
12353export const ThinkingLevel = {
12354    low: "low",
12355    medium: "medium",
12356    high: "high",
12357};
12358export class AgentTool {}
12359export default { ThinkingLevel, AgentTool };
12360"#
12361        .trim()
12362        .to_string(),
12363    );
12364
12365    modules.insert(
12366        "@mariozechner/pi-agent-core/index.js".to_string(),
12367        r#"
12368export const ThinkingLevel = {
12369    low: "low",
12370    medium: "medium",
12371    high: "high",
12372};
12373export class AgentTool {}
12374export default { ThinkingLevel, AgentTool };
12375"#
12376        .trim()
12377        .to_string(),
12378    );
12379
12380    modules.insert(
12381        "openai".to_string(),
12382        r#"
12383class OpenAI {
12384    constructor(config = {}) { this.config = config; }
12385    get chat() {
12386        return { completions: { create: async () => ({ choices: [{ message: { content: "" } }] }) } };
12387    }
12388}
12389export default OpenAI;
12390export { OpenAI };
12391"#
12392        .trim()
12393        .to_string(),
12394    );
12395
12396    modules.insert(
12397        "adm-zip".to_string(),
12398        r#"
12399class AdmZip {
12400    constructor(path) { this.path = path; this.entries = []; }
12401    getEntries() { return this.entries; }
12402    readAsText() { return ""; }
12403    extractAllTo() {}
12404    addFile() {}
12405    writeZip() {}
12406}
12407export default AdmZip;
12408"#
12409        .trim()
12410        .to_string(),
12411    );
12412
12413    modules.insert(
12414        "linkedom".to_string(),
12415        r#"
12416export function parseHTML(html) {
12417    const doc = {
12418        documentElement: { outerHTML: html || "" },
12419        querySelector: () => null,
12420        querySelectorAll: () => [],
12421        createElement: (tag) => ({ tagName: tag, textContent: "", innerHTML: "", children: [], appendChild() {} }),
12422        body: { textContent: "", innerHTML: "", children: [] },
12423        title: "",
12424    };
12425    return { document: doc, window: { document: doc } };
12426}
12427"#
12428        .trim()
12429        .to_string(),
12430    );
12431
12432    modules.insert(
12433        "@sourcegraph/scip-typescript".to_string(),
12434        r"
12435export const scip = { Index: class {} };
12436export default { scip };
12437"
12438        .trim()
12439        .to_string(),
12440    );
12441
12442    modules.insert(
12443        "p-limit".to_string(),
12444        r"
12445export default function pLimit(concurrency) {
12446    const queue = [];
12447    let active = 0;
12448    const next = () => {
12449        active--;
12450        if (queue.length > 0) queue.shift()();
12451    };
12452    const run = async (fn, resolve, ...args) => {
12453        active++;
12454        const result = (async () => fn(...args))();
12455        resolve(result);
12456        try { await result; } catch {}
12457        next();
12458    };
12459    const enqueue = (fn, resolve, ...args) => {
12460        queue.push(run.bind(null, fn, resolve, ...args));
12461        (async () => { if (active < concurrency && queue.length > 0) queue.shift()(); })();
12462    };
12463    const generator = (fn, ...args) => new Promise(resolve => enqueue(fn, resolve, ...args));
12464    Object.defineProperties(generator, {
12465        activeCount: { get: () => active },
12466        pendingCount: { get: () => queue.length },
12467        clearQueue: { value: () => { queue.length = 0; } },
12468    });
12469    return generator;
12470}
12471"
12472        .trim()
12473        .to_string(),
12474    );
12475
12476    // Also register the deep import path used by qualisero-pi-agent-scip
12477    modules.insert(
12478        "@sourcegraph/scip-typescript/dist/src/scip.js".to_string(),
12479        r"
12480export const scip = { Index: class {} };
12481export default { scip };
12482"
12483        .trim()
12484        .to_string(),
12485    );
12486
12487    modules.insert(
12488        "unpdf".to_string(),
12489        r#"
12490export async function getDocumentProxy(data) {
12491    return { numPages: 0, getPage: async () => ({ getTextContent: async () => ({ items: [] }) }) };
12492}
12493export async function extractText(data) { return { totalPages: 0, text: "" }; }
12494export async function renderPageAsImage() { return new Uint8Array(); }
12495"#
12496        .trim()
12497        .to_string(),
12498    );
12499
12500    modules.insert(
12501        "@sourcegraph/scip-python".to_string(),
12502        r"
12503export class PythonIndexer { async index() { return []; } }
12504export default { PythonIndexer };
12505"
12506        .trim()
12507        .to_string(),
12508    );
12509
12510    modules.insert(
12511        "@sourcegraph/scip-python/index.js".to_string(),
12512        r"
12513export class PythonIndexer { async index() { return []; } }
12514export default { PythonIndexer };
12515"
12516        .trim()
12517        .to_string(),
12518    );
12519
12520    modules
12521}
12522
12523fn default_virtual_modules_shared() -> Arc<HashMap<String, String>> {
12524    static DEFAULT_VIRTUAL_MODULES: std::sync::OnceLock<Arc<HashMap<String, String>>> =
12525        std::sync::OnceLock::new();
12526    Arc::clone(DEFAULT_VIRTUAL_MODULES.get_or_init(|| Arc::new(default_virtual_modules())))
12527}
12528
12529/// Returns the set of all module specifiers available as virtual modules.
12530///
12531/// Used by the preflight analyzer to determine whether an extension's
12532/// imports can be resolved without hitting the filesystem.
12533#[must_use]
12534pub fn available_virtual_module_names() -> std::collections::BTreeSet<String> {
12535    default_virtual_modules_shared().keys().cloned().collect()
12536}
12537
12538/// Sampling cadence for memory usage snapshots when no hard memory limit is configured.
12539///
12540/// `AsyncRuntime::memory_usage()` triggers QuickJS heap traversal and is expensive on hot
12541/// tick paths. When runtime memory is unbounded, periodic sampling preserves observability
12542/// while avoiding per-tick full-heap scans.
12543const UNBOUNDED_MEMORY_USAGE_SAMPLE_EVERY_TICKS: u64 = 32;
12544
12545/// Integrated PiJS runtime combining QuickJS, scheduler, and Promise bridge.
12546///
12547/// This is the main entry point for running JavaScript extensions with
12548/// proper async hostcall support. It provides:
12549///
12550/// - Promise-based `pi.*` methods that enqueue hostcall requests
12551/// - Deterministic event loop scheduling
12552/// - Automatic microtask draining after macrotasks
12553/// - Hostcall completion → Promise resolution/rejection
12554///
12555/// # Example
12556///
12557/// ```ignore
12558/// // Create runtime
12559/// let runtime = PiJsRuntime::new().await?;
12560///
12561/// // Evaluate extension code
12562/// runtime.eval("
12563///     pi.tool('read', { path: 'foo.txt' }).then(result => {
12564///         console.log('Got:', result);
12565///     });
12566/// ").await?;
12567///
12568/// // Process hostcall requests
12569/// while let Some(request) = runtime.drain_hostcall_requests().pop_front() {
12570///     // Execute the hostcall
12571///     let result = execute_tool(&request.kind, &request.payload).await;
12572///     // Deliver completion back to JS
12573///     runtime.complete_hostcall(&request.call_id, result)?;
12574/// }
12575///
12576/// // Tick the event loop to deliver completions
12577/// let stats = runtime.tick().await?;
12578/// ```
12579pub struct PiJsRuntime<C: SchedulerClock = WallClock> {
12580    runtime: AsyncRuntime,
12581    context: AsyncContext,
12582    scheduler: Rc<RefCell<Scheduler<C>>>,
12583    hostcall_queue: HostcallQueue,
12584    trace_seq: Arc<AtomicU64>,
12585    hostcall_tracker: Rc<RefCell<HostcallTracker>>,
12586    hostcalls_total: Arc<AtomicU64>,
12587    hostcalls_timed_out: Arc<AtomicU64>,
12588    last_memory_used_bytes: Arc<AtomicU64>,
12589    peak_memory_used_bytes: Arc<AtomicU64>,
12590    tick_counter: Arc<AtomicU64>,
12591    interrupt_budget: Rc<InterruptBudget>,
12592    config: PiJsRuntimeConfig,
12593    /// Additional filesystem roots that `readFileSync` may access (e.g.
12594    /// extension directories).  Populated lazily as extensions are loaded.
12595    allowed_read_roots: Arc<std::sync::Mutex<Vec<PathBuf>>>,
12596    /// Accumulated auto-repair events.  Use [`Self::record_repair`] to append
12597    /// and [`Self::drain_repair_events`] to retrieve and clear.
12598    repair_events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>>,
12599    /// Shared module state used by the resolver and loader.  Stored here so
12600    /// that [`Self::add_extension_root`] can push extension roots into the
12601    /// resolver after construction.
12602    module_state: Rc<RefCell<PiJsModuleState>>,
12603    /// Extension policy for synchronous capability checks.
12604    policy: Option<ExtensionPolicy>,
12605}
12606
12607#[derive(Debug, Clone, Default, serde::Deserialize)]
12608#[serde(rename_all = "camelCase")]
12609struct JsRuntimeRegistrySnapshot {
12610    extensions: u64,
12611    tools: u64,
12612    commands: u64,
12613    hooks: u64,
12614    event_bus_hooks: u64,
12615    providers: u64,
12616    shortcuts: u64,
12617    message_renderers: u64,
12618    pending_tasks: u64,
12619    pending_hostcalls: u64,
12620    pending_timers: u64,
12621    pending_event_listener_lists: u64,
12622    provider_streams: u64,
12623}
12624
12625#[derive(Debug, Clone, serde::Deserialize)]
12626struct JsRuntimeResetPayload {
12627    before: JsRuntimeRegistrySnapshot,
12628    after: JsRuntimeRegistrySnapshot,
12629    clean: bool,
12630}
12631
12632#[derive(Debug, Clone, Default)]
12633pub struct PiJsWarmResetReport {
12634    pub reused: bool,
12635    pub reason_code: Option<String>,
12636    pub rust_pending_hostcalls: u64,
12637    pub rust_pending_hostcall_queue: u64,
12638    pub rust_scheduler_pending: bool,
12639    pub pending_tasks_before: u64,
12640    pub pending_hostcalls_before: u64,
12641    pub pending_timers_before: u64,
12642    pub residual_entries_after: u64,
12643    pub dynamic_module_invalidations: u64,
12644    pub module_cache_hits: u64,
12645    pub module_cache_misses: u64,
12646    pub module_cache_invalidations: u64,
12647    pub module_cache_entries: u64,
12648}
12649
12650#[allow(clippy::future_not_send)]
12651impl PiJsRuntime<WallClock> {
12652    /// Create a new PiJS runtime with the default wall clock.
12653    #[allow(clippy::future_not_send)]
12654    pub async fn new() -> Result<Self> {
12655        Self::with_clock(WallClock).await
12656    }
12657}
12658
12659#[allow(clippy::future_not_send)]
12660impl<C: SchedulerClock + 'static> PiJsRuntime<C> {
12661    /// Create a new PiJS runtime with a custom clock.
12662    #[allow(clippy::future_not_send)]
12663    pub async fn with_clock(clock: C) -> Result<Self> {
12664        Self::with_clock_and_config(clock, PiJsRuntimeConfig::default()).await
12665    }
12666
12667    /// Create a new PiJS runtime with a custom clock and runtime config.
12668    #[allow(clippy::future_not_send)]
12669    pub async fn with_clock_and_config(clock: C, config: PiJsRuntimeConfig) -> Result<Self> {
12670        Self::with_clock_and_config_with_policy(clock, config, None).await
12671    }
12672
12673    /// Create a new PiJS runtime with a custom clock, runtime config, and optional policy.
12674    #[allow(clippy::future_not_send, clippy::too_many_lines)]
12675    pub async fn with_clock_and_config_with_policy(
12676        clock: C,
12677        mut config: PiJsRuntimeConfig,
12678        policy: Option<ExtensionPolicy>,
12679    ) -> Result<Self> {
12680        // Inject target architecture so JS process.arch can read it
12681        #[cfg(target_arch = "x86_64")]
12682        config
12683            .env
12684            .entry("PI_TARGET_ARCH".to_string())
12685            .or_insert_with(|| "x64".to_string());
12686        #[cfg(target_arch = "aarch64")]
12687        config
12688            .env
12689            .entry("PI_TARGET_ARCH".to_string())
12690            .or_insert_with(|| "arm64".to_string());
12691        #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
12692        config
12693            .env
12694            .entry("PI_TARGET_ARCH".to_string())
12695            .or_insert_with(|| "x64".to_string());
12696
12697        // Inject target platform so JS process.platform matches os.platform().
12698        // OSTYPE env var is a shell variable and not always exported.
12699        {
12700            let platform = match std::env::consts::OS {
12701                "macos" => "darwin",
12702                "windows" => "win32",
12703                other => other,
12704            };
12705            config
12706                .env
12707                .entry("PI_PLATFORM".to_string())
12708                .or_insert_with(|| platform.to_string());
12709        }
12710
12711        let runtime = AsyncRuntime::new().map_err(|err| map_js_error(&err))?;
12712        if let Some(limit) = config.limits.memory_limit_bytes {
12713            runtime.set_memory_limit(limit).await;
12714        }
12715        if let Some(limit) = config.limits.max_stack_bytes {
12716            runtime.set_max_stack_size(limit).await;
12717        }
12718
12719        let interrupt_budget = Rc::new(InterruptBudget::new(config.limits.interrupt_budget));
12720        if config.limits.interrupt_budget.is_some() {
12721            let budget = Rc::clone(&interrupt_budget);
12722            runtime
12723                .set_interrupt_handler(Some(Box::new(move || budget.on_interrupt())))
12724                .await;
12725        }
12726
12727        let repair_events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>> =
12728            Arc::new(std::sync::Mutex::new(Vec::new()));
12729        let module_state = Rc::new(RefCell::new(
12730            PiJsModuleState::new()
12731                .with_repair_mode(config.repair_mode)
12732                .with_repair_events(Arc::clone(&repair_events))
12733                .with_disk_cache_dir(config.disk_cache_dir.clone()),
12734        ));
12735        runtime
12736            .set_loader(
12737                PiJsResolver {
12738                    state: Rc::clone(&module_state),
12739                },
12740                PiJsLoader {
12741                    state: Rc::clone(&module_state),
12742                },
12743            )
12744            .await;
12745
12746        let context = AsyncContext::full(&runtime)
12747            .await
12748            .map_err(|err| map_js_error(&err))?;
12749
12750        let scheduler = Rc::new(RefCell::new(Scheduler::with_clock(clock)));
12751        let fast_queue_capacity = if config.limits.hostcall_fast_queue_capacity == 0 {
12752            HOSTCALL_FAST_RING_CAPACITY
12753        } else {
12754            config.limits.hostcall_fast_queue_capacity
12755        };
12756        let overflow_queue_capacity = if config.limits.hostcall_overflow_queue_capacity == 0 {
12757            HOSTCALL_OVERFLOW_CAPACITY
12758        } else {
12759            config.limits.hostcall_overflow_queue_capacity
12760        };
12761        let hostcall_queue: HostcallQueue = Rc::new(RefCell::new(
12762            HostcallRequestQueue::with_capacities(fast_queue_capacity, overflow_queue_capacity),
12763        ));
12764        let hostcall_tracker = Rc::new(RefCell::new(HostcallTracker::default()));
12765        let hostcalls_total = Arc::new(AtomicU64::new(0));
12766        let hostcalls_timed_out = Arc::new(AtomicU64::new(0));
12767        let last_memory_used_bytes = Arc::new(AtomicU64::new(0));
12768        let peak_memory_used_bytes = Arc::new(AtomicU64::new(0));
12769        let tick_counter = Arc::new(AtomicU64::new(0));
12770        let trace_seq = Arc::new(AtomicU64::new(1));
12771
12772        let instance = Self {
12773            runtime,
12774            context,
12775            scheduler,
12776            hostcall_queue,
12777            trace_seq,
12778            hostcall_tracker,
12779            hostcalls_total,
12780            hostcalls_timed_out,
12781            last_memory_used_bytes,
12782            peak_memory_used_bytes,
12783            tick_counter,
12784            interrupt_budget,
12785            config,
12786            allowed_read_roots: Arc::new(std::sync::Mutex::new(Vec::new())),
12787            repair_events,
12788            module_state,
12789            policy,
12790        };
12791
12792        instance.install_pi_bridge().await?;
12793        Ok(instance)
12794    }
12795
12796    async fn map_quickjs_error(&self, err: &rquickjs::Error) -> Error {
12797        if self.interrupt_budget.did_trip() {
12798            self.interrupt_budget.clear_trip();
12799            return Error::extension("PiJS execution budget exceeded".to_string());
12800        }
12801        if matches!(err, rquickjs::Error::Exception) {
12802            let detail = self
12803                .context
12804                .with(|ctx| {
12805                    let caught = ctx.catch();
12806                    Ok::<String, rquickjs::Error>(format_quickjs_exception(&ctx, caught))
12807                })
12808                .await
12809                .ok();
12810            if let Some(detail) = detail {
12811                let detail = detail.trim();
12812                if !detail.is_empty() && detail != "undefined" {
12813                    return Error::extension(format!("QuickJS exception: {detail}"));
12814                }
12815            }
12816        }
12817        map_js_error(err)
12818    }
12819
12820    fn map_quickjs_job_error<E: std::fmt::Display>(&self, err: E) -> Error {
12821        if self.interrupt_budget.did_trip() {
12822            self.interrupt_budget.clear_trip();
12823            return Error::extension("PiJS execution budget exceeded".to_string());
12824        }
12825        Error::extension(format!("QuickJS job: {err}"))
12826    }
12827
12828    fn should_sample_memory_usage(&self) -> bool {
12829        if self.config.limits.memory_limit_bytes.is_some() {
12830            return true;
12831        }
12832
12833        let tick = self.tick_counter.fetch_add(1, AtomicOrdering::SeqCst) + 1;
12834        tick == 1 || (tick % UNBOUNDED_MEMORY_USAGE_SAMPLE_EVERY_TICKS == 0)
12835    }
12836
12837    fn module_cache_snapshot(&self) -> (u64, u64, u64, u64, u64) {
12838        let state = self.module_state.borrow();
12839        let entries = u64::try_from(state.compiled_sources.len()).unwrap_or(u64::MAX);
12840        (
12841            state.module_cache_counters.hits,
12842            state.module_cache_counters.misses,
12843            state.module_cache_counters.invalidations,
12844            entries,
12845            state.module_cache_counters.disk_hits,
12846        )
12847    }
12848
12849    #[allow(clippy::future_not_send, clippy::too_many_lines)]
12850    pub async fn reset_for_warm_reload(&self) -> Result<PiJsWarmResetReport> {
12851        let rust_pending_hostcalls =
12852            u64::try_from(self.hostcall_tracker.borrow().pending_count()).unwrap_or(u64::MAX);
12853        let rust_pending_hostcall_queue =
12854            u64::try_from(self.hostcall_queue.borrow().len()).unwrap_or(u64::MAX);
12855        let rust_scheduler_pending = self.scheduler.borrow().has_pending();
12856
12857        let mut report = PiJsWarmResetReport {
12858            rust_pending_hostcalls,
12859            rust_pending_hostcall_queue,
12860            rust_scheduler_pending,
12861            ..PiJsWarmResetReport::default()
12862        };
12863
12864        if rust_pending_hostcalls > 0 || rust_pending_hostcall_queue > 0 || rust_scheduler_pending {
12865            report.reason_code = Some("pending_rust_work".to_string());
12866            return Ok(report);
12867        }
12868
12869        let reset_payload_value = match self
12870            .context
12871            .with(|ctx| {
12872                let global = ctx.globals();
12873                let reset_fn: Function<'_> = global.get("__pi_reset_extension_runtime_state")?;
12874                let value: Value<'_> = reset_fn.call(())?;
12875                js_to_json(&value)
12876            })
12877            .await
12878        {
12879            Ok(value) => value,
12880            Err(err) => return Err(self.map_quickjs_error(&err).await),
12881        };
12882
12883        let reset_payload: JsRuntimeResetPayload = serde_json::from_value(reset_payload_value)
12884            .map_err(|err| {
12885                Error::extension(format!("PiJS warm reset payload decode failed: {err}"))
12886            })?;
12887
12888        report.pending_tasks_before = reset_payload.before.pending_tasks;
12889        report.pending_hostcalls_before = reset_payload.before.pending_hostcalls;
12890        report.pending_timers_before = reset_payload.before.pending_timers;
12891
12892        let residual_after = reset_payload.after.extensions
12893            + reset_payload.after.tools
12894            + reset_payload.after.commands
12895            + reset_payload.after.hooks
12896            + reset_payload.after.event_bus_hooks
12897            + reset_payload.after.providers
12898            + reset_payload.after.shortcuts
12899            + reset_payload.after.message_renderers
12900            + reset_payload.after.pending_tasks
12901            + reset_payload.after.pending_hostcalls
12902            + reset_payload.after.pending_timers
12903            + reset_payload.after.pending_event_listener_lists
12904            + reset_payload.after.provider_streams;
12905        report.residual_entries_after = residual_after;
12906
12907        self.hostcall_queue.borrow_mut().clear();
12908        *self.hostcall_tracker.borrow_mut() = HostcallTracker::default();
12909
12910        if let Ok(mut roots) = self.allowed_read_roots.lock() {
12911            roots.clear();
12912        }
12913
12914        let mut dynamic_invalidations = 0_u64;
12915        {
12916            let mut state = self.module_state.borrow_mut();
12917            let dynamic_specs: Vec<String> =
12918                state.dynamic_virtual_modules.keys().cloned().collect();
12919            state.dynamic_virtual_modules.clear();
12920            state.dynamic_virtual_named_exports.clear();
12921            state.extension_roots.clear();
12922            state.extension_root_tiers.clear();
12923            state.extension_root_scopes.clear();
12924
12925            for spec in dynamic_specs {
12926                if state.compiled_sources.remove(&spec).is_some() {
12927                    dynamic_invalidations = dynamic_invalidations.saturating_add(1);
12928                }
12929            }
12930            if dynamic_invalidations > 0 {
12931                state.module_cache_counters.invalidations = state
12932                    .module_cache_counters
12933                    .invalidations
12934                    .saturating_add(dynamic_invalidations);
12935            }
12936        }
12937        report.dynamic_module_invalidations = dynamic_invalidations;
12938
12939        let (cache_hits, cache_misses, cache_invalidations, cache_entries, _disk_hits) =
12940            self.module_cache_snapshot();
12941        report.module_cache_hits = cache_hits;
12942        report.module_cache_misses = cache_misses;
12943        report.module_cache_invalidations = cache_invalidations;
12944        report.module_cache_entries = cache_entries;
12945
12946        if report.pending_tasks_before > 0
12947            || report.pending_hostcalls_before > 0
12948            || report.pending_timers_before > 0
12949        {
12950            report.reason_code = Some("pending_js_work".to_string());
12951            return Ok(report);
12952        }
12953
12954        if !reset_payload.clean || residual_after > 0 {
12955            report.reason_code = Some("reset_residual_state".to_string());
12956            return Ok(report);
12957        }
12958
12959        report.reused = true;
12960        Ok(report)
12961    }
12962
12963    /// Evaluate JavaScript source code.
12964    pub async fn eval(&self, source: &str) -> Result<()> {
12965        self.interrupt_budget.reset();
12966        match self.context.with(|ctx| ctx.eval::<(), _>(source)).await {
12967            Ok(()) => {}
12968            Err(err) => return Err(self.map_quickjs_error(&err).await),
12969        }
12970        // Drain any immediate jobs (Promise.resolve chains, etc.)
12971        self.drain_jobs().await?;
12972        Ok(())
12973    }
12974
12975    /// Invoke a zero-argument global JS function and drain immediate microtasks.
12976    ///
12977    /// This is useful for hot loops that need to trigger pre-installed JS helpers
12978    /// without paying per-call parser/compile overhead from `eval()`.
12979    pub async fn call_global_void(&self, name: &str) -> Result<()> {
12980        self.interrupt_budget.reset();
12981        match self
12982            .context
12983            .with(|ctx| {
12984                let global = ctx.globals();
12985                let function: Function<'_> = global.get(name)?;
12986                function.call::<(), ()>(())?;
12987                Ok::<(), rquickjs::Error>(())
12988            })
12989            .await
12990        {
12991            Ok(()) => {}
12992            Err(err) => return Err(self.map_quickjs_error(&err).await),
12993        }
12994        self.drain_jobs().await?;
12995        Ok(())
12996    }
12997
12998    // ---- Auto-repair event infrastructure (bd-k5q5.8.1) --------------------
12999
13000    /// The configured repair mode for this runtime.
13001    pub const fn repair_mode(&self) -> RepairMode {
13002        self.config.repair_mode
13003    }
13004
13005    /// Whether the auto-repair pipeline should apply repairs.
13006    pub const fn auto_repair_enabled(&self) -> bool {
13007        self.config.repair_mode.should_apply()
13008    }
13009
13010    /// Record an auto-repair event.  The event is appended to the internal
13011    /// log and emitted as a structured tracing span so external log sinks
13012    /// can capture it.
13013    pub fn record_repair(&self, event: ExtensionRepairEvent) {
13014        tracing::info!(
13015            event = "pijs.repair",
13016            extension_id = %event.extension_id,
13017            pattern = %event.pattern,
13018            success = event.success,
13019            repair_action = %event.repair_action,
13020            "auto-repair applied"
13021        );
13022        if let Ok(mut events) = self.repair_events.lock() {
13023            events.push(event);
13024        }
13025    }
13026
13027    /// Drain all accumulated repair events, leaving the internal buffer
13028    /// empty.  Useful for conformance reports that need to distinguish
13029    /// clean passes from repaired passes.
13030    pub fn drain_repair_events(&self) -> Vec<ExtensionRepairEvent> {
13031        self.repair_events
13032            .lock()
13033            .map(|mut v| std::mem::take(&mut *v))
13034            .unwrap_or_default()
13035    }
13036
13037    /// Number of repair events recorded since the runtime was created.
13038    pub fn repair_count(&self) -> u64 {
13039        self.repair_events.lock().map_or(0, |v| v.len() as u64)
13040    }
13041
13042    /// Reset transient module state for warm isolate reuse.
13043    ///
13044    /// Clears extension roots, dynamic virtual modules, named export tracking,
13045    /// repair events, and cache counters while **preserving** the compiled
13046    /// sources cache (both in-memory and disk). This lets the runtime be
13047    /// reloaded with a fresh set of extensions without paying the SWC
13048    /// transpilation cost again.
13049    pub fn reset_transient_state(&self) {
13050        let mut state = self.module_state.borrow_mut();
13051        state.extension_roots.clear();
13052        state.extension_root_tiers.clear();
13053        state.extension_root_scopes.clear();
13054        state.dynamic_virtual_modules.clear();
13055        state.dynamic_virtual_named_exports.clear();
13056        state.module_cache_counters = ModuleCacheCounters::default();
13057        // Keep compiled_sources — the transpiled source cache is still valid.
13058        // Keep disk_cache_dir — reuse the same persistent cache.
13059        // Keep static_virtual_modules — immutable, shared via Arc.
13060        drop(state);
13061
13062        // Clear hostcall state.
13063        self.hostcall_queue.borrow_mut().clear();
13064        *self.hostcall_tracker.borrow_mut() = HostcallTracker::default();
13065        // Drain repair events.
13066        if let Ok(mut events) = self.repair_events.lock() {
13067            events.clear();
13068        }
13069        // Reset counters.
13070        self.hostcalls_total
13071            .store(0, std::sync::atomic::Ordering::SeqCst);
13072        self.hostcalls_timed_out
13073            .store(0, std::sync::atomic::Ordering::SeqCst);
13074        self.tick_counter
13075            .store(0, std::sync::atomic::Ordering::SeqCst);
13076    }
13077
13078    /// Evaluate a JavaScript file.
13079    pub async fn eval_file(&self, path: &std::path::Path) -> Result<()> {
13080        self.interrupt_budget.reset();
13081        match self.context.with(|ctx| ctx.eval_file::<(), _>(path)).await {
13082            Ok(()) => {}
13083            Err(err) => return Err(self.map_quickjs_error(&err).await),
13084        }
13085        self.drain_jobs().await?;
13086        Ok(())
13087    }
13088
13089    /// Run a closure inside the JS context and map QuickJS errors into `pi::Error`.
13090    ///
13091    /// This is intentionally `pub(crate)` so the extensions runtime can call JS helper
13092    /// functions without exposing raw rquickjs types as part of the public API.
13093    pub(crate) async fn with_ctx<F, R>(&self, f: F) -> Result<R>
13094    where
13095        F: for<'js> FnOnce(Ctx<'js>) -> rquickjs::Result<R> + rquickjs::markers::ParallelSend,
13096        R: rquickjs::markers::ParallelSend,
13097    {
13098        self.interrupt_budget.reset();
13099        match self.context.with(f).await {
13100            Ok(value) => Ok(value),
13101            Err(err) => Err(self.map_quickjs_error(&err).await),
13102        }
13103    }
13104
13105    /// Read a global variable from the JS context and convert it to JSON.
13106    ///
13107    /// This is primarily intended for integration tests and diagnostics; it intentionally
13108    /// does not expose raw `rquickjs` types as part of the public API.
13109    pub async fn read_global_json(&self, name: &str) -> Result<serde_json::Value> {
13110        self.interrupt_budget.reset();
13111        let value = match self
13112            .context
13113            .with(|ctx| {
13114                let global = ctx.globals();
13115                let value: Value<'_> = global.get(name)?;
13116                js_to_json(&value)
13117            })
13118            .await
13119        {
13120            Ok(value) => value,
13121            Err(err) => return Err(self.map_quickjs_error(&err).await),
13122        };
13123        Ok(value)
13124    }
13125
13126    /// Drain pending hostcall requests from the queue.
13127    ///
13128    /// Returns the requests that need to be processed by the host.
13129    /// After processing, call `complete_hostcall()` for each.
13130    pub fn drain_hostcall_requests(&self) -> VecDeque<HostcallRequest> {
13131        self.hostcall_queue.borrow_mut().drain_all()
13132    }
13133
13134    /// Drain pending QuickJS jobs (Promise microtasks) until fixpoint.
13135    pub async fn drain_microtasks(&self) -> Result<usize> {
13136        self.drain_jobs().await
13137    }
13138
13139    /// Return the next timer deadline (runtime clock), if any.
13140    pub fn next_timer_deadline_ms(&self) -> Option<u64> {
13141        self.scheduler.borrow().next_timer_deadline()
13142    }
13143
13144    /// Peek at pending hostcall requests without draining.
13145    pub fn pending_hostcall_count(&self) -> usize {
13146        self.hostcall_tracker.borrow().pending_count()
13147    }
13148
13149    /// Snapshot queue depth/backpressure counters for diagnostics.
13150    pub fn hostcall_queue_telemetry(&self) -> HostcallQueueTelemetry {
13151        self.hostcall_queue.borrow().snapshot()
13152    }
13153
13154    /// Queue wait (enqueue -> dispatch start) in milliseconds for a pending hostcall.
13155    pub fn hostcall_queue_wait_ms(&self, call_id: &str) -> Option<u64> {
13156        let now_ms = self.scheduler.borrow().now_ms();
13157        self.hostcall_tracker
13158            .borrow()
13159            .queue_wait_ms(call_id, now_ms)
13160    }
13161
13162    /// Check whether a given hostcall is still pending.
13163    ///
13164    /// This is useful for streaming hostcalls that need to stop polling/reading once the JS side
13165    /// has timed out or otherwise completed the call.
13166    pub fn is_hostcall_pending(&self, call_id: &str) -> bool {
13167        self.hostcall_tracker.borrow().is_pending(call_id)
13168    }
13169
13170    /// Get all tools registered by loaded JS extensions.
13171    pub async fn get_registered_tools(&self) -> Result<Vec<ExtensionToolDef>> {
13172        self.interrupt_budget.reset();
13173        let value = match self
13174            .context
13175            .with(|ctx| {
13176                let global = ctx.globals();
13177                let getter: Function<'_> = global.get("__pi_get_registered_tools")?;
13178                let tools: Value<'_> = getter.call(())?;
13179                js_to_json(&tools)
13180            })
13181            .await
13182        {
13183            Ok(value) => value,
13184            Err(err) => return Err(self.map_quickjs_error(&err).await),
13185        };
13186
13187        serde_json::from_value(value).map_err(|err| Error::Json(Box::new(err)))
13188    }
13189
13190    /// Read a global value by name and convert it to JSON.
13191    ///
13192    /// This is intentionally a narrow helper that avoids exposing raw `rquickjs`
13193    /// types in the public API (useful for integration tests and debugging).
13194    pub async fn get_global_json(&self, name: &str) -> Result<serde_json::Value> {
13195        self.interrupt_budget.reset();
13196        match self
13197            .context
13198            .with(|ctx| {
13199                let global = ctx.globals();
13200                let value: Value<'_> = global.get(name)?;
13201                js_to_json(&value)
13202            })
13203            .await
13204        {
13205            Ok(value) => Ok(value),
13206            Err(err) => Err(self.map_quickjs_error(&err).await),
13207        }
13208    }
13209
13210    /// Enqueue a hostcall completion to be delivered on next tick.
13211    pub fn complete_hostcall(&self, call_id: impl Into<String>, outcome: HostcallOutcome) {
13212        self.scheduler
13213            .borrow_mut()
13214            .enqueue_hostcall_complete(call_id.into(), outcome);
13215    }
13216
13217    /// Enqueue multiple hostcall completions in one scheduler borrow.
13218    pub fn complete_hostcalls_batch<I>(&self, completions: I)
13219    where
13220        I: IntoIterator<Item = (String, HostcallOutcome)>,
13221    {
13222        self.scheduler
13223            .borrow_mut()
13224            .enqueue_hostcall_completions(completions);
13225    }
13226
13227    /// Enqueue an inbound event to be delivered on next tick.
13228    pub fn enqueue_event(&self, event_id: impl Into<String>, payload: serde_json::Value) {
13229        self.scheduler
13230            .borrow_mut()
13231            .enqueue_event(event_id.into(), payload);
13232    }
13233
13234    /// Set a timer to fire after the given delay.
13235    ///
13236    /// Returns the timer ID for cancellation.
13237    pub fn set_timeout(&self, delay_ms: u64) -> u64 {
13238        self.scheduler.borrow_mut().set_timeout(delay_ms)
13239    }
13240
13241    /// Cancel a timer by ID.
13242    pub fn clear_timeout(&self, timer_id: u64) -> bool {
13243        self.scheduler.borrow_mut().clear_timeout(timer_id)
13244    }
13245
13246    /// Get the current time from the clock.
13247    pub fn now_ms(&self) -> u64 {
13248        self.scheduler.borrow().now_ms()
13249    }
13250
13251    /// Check if there are pending tasks (macrotasks or timers).
13252    pub fn has_pending(&self) -> bool {
13253        self.scheduler.borrow().has_pending() || self.pending_hostcall_count() > 0
13254    }
13255
13256    /// Execute one tick of the event loop.
13257    ///
13258    /// This will:
13259    /// 1. Move due timers to the macrotask queue
13260    /// 2. Execute one macrotask (if any)
13261    /// 3. Drain all pending QuickJS jobs (microtasks)
13262    ///
13263    /// Returns statistics about what was executed.
13264    pub async fn tick(&self) -> Result<PiJsTickStats> {
13265        // Get the next macrotask from scheduler
13266        let macrotask = self.scheduler.borrow_mut().tick();
13267
13268        let mut stats = PiJsTickStats::default();
13269
13270        if let Some(task) = macrotask {
13271            stats.ran_macrotask = true;
13272            self.interrupt_budget.reset();
13273
13274            // Handle the macrotask inside the JS context
13275            let result = self
13276                .context
13277                .with(|ctx| {
13278                    self.handle_macrotask(&ctx, &task)?;
13279                    Ok::<_, rquickjs::Error>(())
13280                })
13281                .await;
13282            if let Err(err) = result {
13283                return Err(self.map_quickjs_error(&err).await);
13284            }
13285
13286            // Drain microtasks until fixpoint
13287            stats.jobs_drained = self.drain_jobs().await?;
13288        }
13289
13290        stats.pending_hostcalls = self.hostcall_tracker.borrow().pending_count();
13291        stats.hostcalls_total = self
13292            .hostcalls_total
13293            .load(std::sync::atomic::Ordering::SeqCst);
13294        stats.hostcalls_timed_out = self
13295            .hostcalls_timed_out
13296            .load(std::sync::atomic::Ordering::SeqCst);
13297
13298        if self.should_sample_memory_usage() {
13299            let usage = self.runtime.memory_usage().await;
13300            stats.memory_used_bytes = u64::try_from(usage.memory_used_size).unwrap_or(0);
13301            self.last_memory_used_bytes
13302                .store(stats.memory_used_bytes, std::sync::atomic::Ordering::SeqCst);
13303
13304            let mut peak = self
13305                .peak_memory_used_bytes
13306                .load(std::sync::atomic::Ordering::SeqCst);
13307            if stats.memory_used_bytes > peak {
13308                peak = stats.memory_used_bytes;
13309                self.peak_memory_used_bytes
13310                    .store(peak, std::sync::atomic::Ordering::SeqCst);
13311            }
13312            stats.peak_memory_used_bytes = peak;
13313        } else {
13314            stats.memory_used_bytes = self
13315                .last_memory_used_bytes
13316                .load(std::sync::atomic::Ordering::SeqCst);
13317            stats.peak_memory_used_bytes = self
13318                .peak_memory_used_bytes
13319                .load(std::sync::atomic::Ordering::SeqCst);
13320        }
13321        stats.repairs_total = self.repair_count();
13322        let (cache_hits, cache_misses, cache_invalidations, cache_entries, disk_hits) =
13323            self.module_cache_snapshot();
13324        stats.module_cache_hits = cache_hits;
13325        stats.module_cache_misses = cache_misses;
13326        stats.module_cache_invalidations = cache_invalidations;
13327        stats.module_cache_entries = cache_entries;
13328        stats.module_disk_cache_hits = disk_hits;
13329
13330        if let Some(limit) = self.config.limits.memory_limit_bytes {
13331            let limit = u64::try_from(limit).unwrap_or(u64::MAX);
13332            if stats.memory_used_bytes > limit {
13333                return Err(Error::extension(format!(
13334                    "PiJS memory budget exceeded (used {} bytes, limit {} bytes)",
13335                    stats.memory_used_bytes, limit
13336                )));
13337            }
13338        }
13339
13340        Ok(stats)
13341    }
13342
13343    /// Drain all pending QuickJS jobs (microtasks).
13344    async fn drain_jobs(&self) -> Result<usize> {
13345        let mut count = 0;
13346        loop {
13347            if count >= MAX_JOBS_PER_TICK {
13348                return Err(Error::extension(format!(
13349                    "PiJS microtask limit exceeded ({MAX_JOBS_PER_TICK})"
13350                )));
13351            }
13352            let ran = match self.runtime.execute_pending_job().await {
13353                Ok(ran) => ran,
13354                Err(err) => return Err(self.map_quickjs_job_error(err)),
13355            };
13356            if !ran {
13357                break;
13358            }
13359            count += 1;
13360        }
13361        Ok(count)
13362    }
13363
13364    /// Handle a macrotask by resolving/rejecting Promises or dispatching events.
13365    fn handle_macrotask(
13366        &self,
13367        ctx: &Ctx<'_>,
13368        task: &crate::scheduler::Macrotask,
13369    ) -> rquickjs::Result<()> {
13370        use crate::scheduler::MacrotaskKind as SMK;
13371
13372        match &task.kind {
13373            SMK::HostcallComplete { call_id, outcome } => {
13374                let is_nonfinal_stream = matches!(
13375                    outcome,
13376                    HostcallOutcome::StreamChunk {
13377                        is_final: false,
13378                        ..
13379                    }
13380                );
13381
13382                if is_nonfinal_stream {
13383                    // Non-final stream chunk: keep the call pending, just deliver the chunk.
13384                    if !self.hostcall_tracker.borrow().is_pending(call_id) {
13385                        tracing::debug!(
13386                            event = "pijs.macrotask.stream_chunk.ignored",
13387                            call_id = %call_id,
13388                            "Ignoring stream chunk (not pending)"
13389                        );
13390                        return Ok(());
13391                    }
13392                } else {
13393                    // Final chunk or non-stream outcome: complete the hostcall.
13394                    let completion = self.hostcall_tracker.borrow_mut().on_complete(call_id);
13395                    let timer_id = match completion {
13396                        HostcallCompletion::Delivered { timer_id } => timer_id,
13397                        HostcallCompletion::Unknown => {
13398                            tracing::debug!(
13399                                event = "pijs.macrotask.hostcall_complete.ignored",
13400                                call_id = %call_id,
13401                                "Ignoring hostcall completion (not pending)"
13402                            );
13403                            return Ok(());
13404                        }
13405                    };
13406
13407                    if let Some(timer_id) = timer_id {
13408                        let _ = self.scheduler.borrow_mut().clear_timeout(timer_id);
13409                    }
13410                }
13411
13412                tracing::debug!(
13413                    event = "pijs.macrotask.hostcall_complete",
13414                    call_id = %call_id,
13415                    seq = task.seq.value(),
13416                    "Delivering hostcall completion"
13417                );
13418                Self::deliver_hostcall_completion(ctx, call_id, outcome)?;
13419            }
13420            SMK::TimerFired { timer_id } => {
13421                if let Some(call_id) = self
13422                    .hostcall_tracker
13423                    .borrow_mut()
13424                    .take_timed_out_call(*timer_id)
13425                {
13426                    self.hostcalls_timed_out
13427                        .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
13428                    tracing::warn!(
13429                        event = "pijs.hostcall.timeout",
13430                        call_id = %call_id,
13431                        timer_id = timer_id,
13432                        "Hostcall timed out"
13433                    );
13434
13435                    let outcome = HostcallOutcome::Error {
13436                        code: "timeout".to_string(),
13437                        message: "Hostcall timed out".to_string(),
13438                    };
13439                    Self::deliver_hostcall_completion(ctx, &call_id, &outcome)?;
13440                    return Ok(());
13441                }
13442
13443                tracing::debug!(
13444                    event = "pijs.macrotask.timer_fired",
13445                    timer_id = timer_id,
13446                    seq = task.seq.value(),
13447                    "Timer fired"
13448                );
13449                // Timer callbacks are stored in a JS-side map
13450                Self::deliver_timer_fire(ctx, *timer_id)?;
13451            }
13452            SMK::InboundEvent { event_id, payload } => {
13453                tracing::debug!(
13454                    event = "pijs.macrotask.inbound_event",
13455                    event_id = %event_id,
13456                    seq = task.seq.value(),
13457                    "Delivering inbound event"
13458                );
13459                Self::deliver_inbound_event(ctx, event_id, payload)?;
13460            }
13461        }
13462        Ok(())
13463    }
13464
13465    /// Deliver a hostcall completion to JS.
13466    fn deliver_hostcall_completion(
13467        ctx: &Ctx<'_>,
13468        call_id: &str,
13469        outcome: &HostcallOutcome,
13470    ) -> rquickjs::Result<()> {
13471        let global = ctx.globals();
13472        let complete_fn: Function<'_> = global.get("__pi_complete_hostcall")?;
13473        let js_outcome = match outcome {
13474            HostcallOutcome::Success(value) => {
13475                let obj = Object::new(ctx.clone())?;
13476                obj.set("ok", true)?;
13477                obj.set("value", json_to_js(ctx, value)?)?;
13478                obj
13479            }
13480            HostcallOutcome::Error { code, message } => {
13481                let obj = Object::new(ctx.clone())?;
13482                obj.set("ok", false)?;
13483                obj.set("code", code.clone())?;
13484                obj.set("message", message.clone())?;
13485                obj
13486            }
13487            HostcallOutcome::StreamChunk {
13488                chunk,
13489                sequence,
13490                is_final,
13491            } => {
13492                let obj = Object::new(ctx.clone())?;
13493                obj.set("ok", true)?;
13494                obj.set("stream", true)?;
13495                obj.set("sequence", *sequence)?;
13496                obj.set("isFinal", *is_final)?;
13497                obj.set("chunk", json_to_js(ctx, chunk)?)?;
13498                obj
13499            }
13500        };
13501        complete_fn.call::<_, ()>((call_id, js_outcome))?;
13502        Ok(())
13503    }
13504
13505    /// Deliver a timer fire event to JS.
13506    fn deliver_timer_fire(ctx: &Ctx<'_>, timer_id: u64) -> rquickjs::Result<()> {
13507        let global = ctx.globals();
13508        let fire_fn: Function<'_> = global.get("__pi_fire_timer")?;
13509        fire_fn.call::<_, ()>((timer_id,))?;
13510        Ok(())
13511    }
13512
13513    /// Deliver an inbound event to JS.
13514    fn deliver_inbound_event(
13515        ctx: &Ctx<'_>,
13516        event_id: &str,
13517        payload: &serde_json::Value,
13518    ) -> rquickjs::Result<()> {
13519        let global = ctx.globals();
13520        let dispatch_fn: Function<'_> = global.get("__pi_dispatch_event")?;
13521        let js_payload = json_to_js(ctx, payload)?;
13522        dispatch_fn.call::<_, ()>((event_id, js_payload))?;
13523        Ok(())
13524    }
13525
13526    /// Generate a unique trace ID.
13527    fn next_trace_id(&self) -> u64 {
13528        self.trace_seq.fetch_add(1, AtomicOrdering::SeqCst)
13529    }
13530
13531    /// Install the pi.* bridge with Promise-returning hostcall methods.
13532    ///
13533    /// The bridge uses a two-layer design:
13534    /// 1. Rust native functions (`__pi_*_native`) that return call_id strings
13535    /// 2. JS wrappers (`pi.*`) that create Promises and register them
13536    ///
13537    /// This avoids lifetime issues with returning Promises from Rust closures.
13538    /// Register an additional filesystem root that `readFileSync` is allowed
13539    /// to access.  Called before loading each extension so it can read its own
13540    /// bundled assets (HTML templates, markdown docs, etc.).
13541    pub fn add_allowed_read_root(&self, root: PathBuf) {
13542        if let Ok(mut roots) = self.allowed_read_roots.lock() {
13543            if !roots.contains(&root) {
13544                roots.push(root);
13545            }
13546        }
13547    }
13548
13549    /// Register an extension root directory so the resolver can detect
13550    /// monorepo escape patterns (Pattern 3).  Also registers the root
13551    /// for `readFileSync` access.
13552    pub fn add_extension_root(&self, root: PathBuf) {
13553        self.add_extension_root_with_id(root, None);
13554    }
13555
13556    /// Register an extension root with optional extension ID metadata.
13557    ///
13558    /// Pattern 4 (missing npm dependency proxy stubs) uses this metadata to
13559    /// apply stricter policy for official/first-party extensions and to allow
13560    /// same-scope package imports (`@scope/*`) when scope can be discovered.
13561    pub fn add_extension_root_with_id(&self, root: PathBuf, extension_id: Option<&str>) {
13562        self.add_allowed_read_root(root.clone());
13563        let mut state = self.module_state.borrow_mut();
13564        if !state.extension_roots.contains(&root) {
13565            state.extension_roots.push(root.clone());
13566        }
13567
13568        let tier = extension_id.map_or_else(
13569            || root_path_hint_tier(&root),
13570            |id| classify_proxy_stub_source_tier(id, &root),
13571        );
13572        state.extension_root_tiers.insert(root.clone(), tier);
13573
13574        if let Some(scope) = read_extension_package_scope(&root) {
13575            state.extension_root_scopes.insert(root, scope);
13576        }
13577    }
13578
13579    #[allow(clippy::too_many_lines)]
13580    async fn install_pi_bridge(&self) -> Result<()> {
13581        let hostcall_queue = self.hostcall_queue.clone();
13582        let scheduler = Rc::clone(&self.scheduler);
13583        let hostcall_tracker = Rc::clone(&self.hostcall_tracker);
13584        let hostcalls_total = Arc::clone(&self.hostcalls_total);
13585        let trace_seq = Arc::clone(&self.trace_seq);
13586        let default_hostcall_timeout_ms = self.config.limits.hostcall_timeout_ms;
13587        let process_cwd = self.config.cwd.clone();
13588        let process_args = self.config.args.clone();
13589        let env = self.config.env.clone();
13590        let deny_env = self.config.deny_env;
13591        let repair_mode = self.config.repair_mode;
13592        let repair_events = Arc::clone(&self.repair_events);
13593        let allow_unsafe_sync_exec = self.config.allow_unsafe_sync_exec;
13594        let allowed_read_roots = Arc::clone(&self.allowed_read_roots);
13595        let policy = self.policy.clone();
13596
13597        self.context
13598            .with(|ctx| {
13599                let global = ctx.globals();
13600
13601                // Install native functions that return call_ids
13602                // These are wrapped by JS to create Promises
13603
13604                // __pi_tool_native(name, input) -> call_id
13605                global.set(
13606                    "__pi_tool_native",
13607                    Func::from({
13608                        let queue = hostcall_queue.clone();
13609                        let tracker = hostcall_tracker.clone();
13610                        let scheduler = Rc::clone(&scheduler);
13611                        let hostcalls_total = Arc::clone(&hostcalls_total);
13612                        let trace_seq = Arc::clone(&trace_seq);
13613                        move |ctx: Ctx<'_>,
13614                              name: String,
13615                              input: Value<'_>|
13616                              -> rquickjs::Result<String> {
13617                            let payload = js_to_json(&input)?;
13618                            let call_id = format!("call-{}", generate_call_id());
13619                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13620                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13621                            let enqueued_at_ms = scheduler.borrow().now_ms();
13622                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13623                            let timer_id =
13624                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13625                            tracker
13626                                .borrow_mut()
13627                                .register(call_id.clone(), timer_id, enqueued_at_ms);
13628                            let extension_id: Option<String> = ctx
13629                                .globals()
13630                                .get::<_, Option<String>>("__pi_current_extension_id")
13631                                .ok()
13632                                .flatten()
13633                                .map(|value| value.trim().to_string())
13634                                .filter(|value| !value.is_empty());
13635                            let request = HostcallRequest {
13636                                call_id: call_id.clone(),
13637                                kind: HostcallKind::Tool { name },
13638                                payload,
13639                                trace_id,
13640                                extension_id,
13641                            };
13642                            enqueue_hostcall_request_with_backpressure(
13643                                &queue, &tracker, &scheduler, request,
13644                            );
13645                            Ok(call_id)
13646                        }
13647                    }),
13648                )?;
13649
13650                // __pi_exec_native(cmd, args, options) -> call_id
13651                global.set(
13652                    "__pi_exec_native",
13653                    Func::from({
13654                        let queue = hostcall_queue.clone();
13655                        let tracker = hostcall_tracker.clone();
13656                        let scheduler = Rc::clone(&scheduler);
13657                        let hostcalls_total = Arc::clone(&hostcalls_total);
13658                        let trace_seq = Arc::clone(&trace_seq);
13659                        move |ctx: Ctx<'_>,
13660                              cmd: String,
13661                              args: Value<'_>,
13662                              options: Opt<Value<'_>>|
13663                              -> rquickjs::Result<String> {
13664                            let mut options_json = match options.0.as_ref() {
13665                                None => serde_json::json!({}),
13666                                Some(value) if value.is_null() => serde_json::json!({}),
13667                                Some(value) => js_to_json(value)?,
13668                            };
13669                            if let Some(default_timeout_ms) =
13670                                default_hostcall_timeout_ms.filter(|ms| *ms > 0)
13671                            {
13672                                match &mut options_json {
13673                                    serde_json::Value::Object(map) => {
13674                                        let has_timeout = map.contains_key("timeout")
13675                                            || map.contains_key("timeoutMs")
13676                                            || map.contains_key("timeout_ms");
13677                                        if !has_timeout {
13678                                            map.insert(
13679                                                "timeoutMs".to_string(),
13680                                                serde_json::Value::from(default_timeout_ms),
13681                                            );
13682                                        }
13683                                    }
13684                                    _ => {
13685                                        options_json =
13686                                            serde_json::json!({ "timeoutMs": default_timeout_ms });
13687                                    }
13688                                }
13689                            }
13690                            let payload = serde_json::json!({
13691                                "args": js_to_json(&args)?,
13692                                "options": options_json,
13693                            });
13694                            let call_id = format!("call-{}", generate_call_id());
13695                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13696                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13697                            let enqueued_at_ms = scheduler.borrow().now_ms();
13698                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13699                            let timer_id =
13700                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13701                            tracker
13702                                .borrow_mut()
13703                                .register(call_id.clone(), timer_id, enqueued_at_ms);
13704                            let extension_id: Option<String> = ctx
13705                                .globals()
13706                                .get::<_, Option<String>>("__pi_current_extension_id")
13707                                .ok()
13708                                .flatten()
13709                                .map(|value| value.trim().to_string())
13710                                .filter(|value| !value.is_empty());
13711                            let request = HostcallRequest {
13712                                call_id: call_id.clone(),
13713                                kind: HostcallKind::Exec { cmd },
13714                                payload,
13715                                trace_id,
13716                                extension_id,
13717                            };
13718                            enqueue_hostcall_request_with_backpressure(
13719                                &queue, &tracker, &scheduler, request,
13720                            );
13721                            Ok(call_id)
13722                        }
13723                    }),
13724                )?;
13725
13726                // __pi_http_native(request) -> call_id
13727                global.set(
13728                    "__pi_http_native",
13729                    Func::from({
13730                        let queue = hostcall_queue.clone();
13731                        let tracker = hostcall_tracker.clone();
13732                        let scheduler = Rc::clone(&scheduler);
13733                        let hostcalls_total = Arc::clone(&hostcalls_total);
13734                        let trace_seq = Arc::clone(&trace_seq);
13735                        move |ctx: Ctx<'_>, req: Value<'_>| -> rquickjs::Result<String> {
13736                            let payload = js_to_json(&req)?;
13737                            let call_id = format!("call-{}", generate_call_id());
13738                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13739                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13740                            let enqueued_at_ms = scheduler.borrow().now_ms();
13741                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13742                            let timer_id =
13743                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13744                            tracker
13745                                .borrow_mut()
13746                                .register(call_id.clone(), timer_id, enqueued_at_ms);
13747                            let extension_id: Option<String> = ctx
13748                                .globals()
13749                                .get::<_, Option<String>>("__pi_current_extension_id")
13750                                .ok()
13751                                .flatten()
13752                                .map(|value| value.trim().to_string())
13753                                .filter(|value| !value.is_empty());
13754                            let request = HostcallRequest {
13755                                call_id: call_id.clone(),
13756                                kind: HostcallKind::Http,
13757                                payload,
13758                                trace_id,
13759                                extension_id,
13760                            };
13761                            enqueue_hostcall_request_with_backpressure(
13762                                &queue, &tracker, &scheduler, request,
13763                            );
13764                            Ok(call_id)
13765                        }
13766                    }),
13767                )?;
13768
13769                // __pi_session_native(op, args) -> call_id
13770                global.set(
13771                    "__pi_session_native",
13772                    Func::from({
13773                        let queue = hostcall_queue.clone();
13774                        let tracker = hostcall_tracker.clone();
13775                        let scheduler = Rc::clone(&scheduler);
13776                        let hostcalls_total = Arc::clone(&hostcalls_total);
13777                        let trace_seq = Arc::clone(&trace_seq);
13778                        move |ctx: Ctx<'_>,
13779                              op: String,
13780                              args: Value<'_>|
13781                              -> rquickjs::Result<String> {
13782                            let payload = js_to_json(&args)?;
13783                            let call_id = format!("call-{}", generate_call_id());
13784                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13785                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13786                            let enqueued_at_ms = scheduler.borrow().now_ms();
13787                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13788                            let timer_id =
13789                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13790                            tracker
13791                                .borrow_mut()
13792                                .register(call_id.clone(), timer_id, enqueued_at_ms);
13793                            let extension_id: Option<String> = ctx
13794                                .globals()
13795                                .get::<_, Option<String>>("__pi_current_extension_id")
13796                                .ok()
13797                                .flatten()
13798                                .map(|value| value.trim().to_string())
13799                                .filter(|value| !value.is_empty());
13800                            let request = HostcallRequest {
13801                                call_id: call_id.clone(),
13802                                kind: HostcallKind::Session { op },
13803                                payload,
13804                                trace_id,
13805                                extension_id,
13806                            };
13807                            enqueue_hostcall_request_with_backpressure(
13808                                &queue, &tracker, &scheduler, request,
13809                            );
13810                            Ok(call_id)
13811                        }
13812                    }),
13813                )?;
13814
13815                // __pi_ui_native(op, args) -> call_id
13816                global.set(
13817                    "__pi_ui_native",
13818                    Func::from({
13819                        let queue = hostcall_queue.clone();
13820                        let tracker = hostcall_tracker.clone();
13821                        let scheduler = Rc::clone(&scheduler);
13822                        let hostcalls_total = Arc::clone(&hostcalls_total);
13823                        let trace_seq = Arc::clone(&trace_seq);
13824                        move |ctx: Ctx<'_>,
13825                              op: String,
13826                              args: Value<'_>|
13827                              -> rquickjs::Result<String> {
13828                            let payload = js_to_json(&args)?;
13829                            let call_id = format!("call-{}", generate_call_id());
13830                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13831                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13832                            let enqueued_at_ms = scheduler.borrow().now_ms();
13833                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13834                            let timer_id =
13835                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13836                            tracker
13837                                .borrow_mut()
13838                                .register(call_id.clone(), timer_id, enqueued_at_ms);
13839                            let extension_id: Option<String> = ctx
13840                                .globals()
13841                                .get::<_, Option<String>>("__pi_current_extension_id")
13842                                .ok()
13843                                .flatten()
13844                                .map(|value| value.trim().to_string())
13845                                .filter(|value| !value.is_empty());
13846                            let request = HostcallRequest {
13847                                call_id: call_id.clone(),
13848                                kind: HostcallKind::Ui { op },
13849                                payload,
13850                                trace_id,
13851                                extension_id,
13852                            };
13853                            enqueue_hostcall_request_with_backpressure(
13854                                &queue, &tracker, &scheduler, request,
13855                            );
13856                            Ok(call_id)
13857                        }
13858                    }),
13859                )?;
13860
13861                // __pi_events_native(op, args) -> call_id
13862                global.set(
13863                    "__pi_events_native",
13864                    Func::from({
13865                        let queue = hostcall_queue.clone();
13866                        let tracker = hostcall_tracker.clone();
13867                        let scheduler = Rc::clone(&scheduler);
13868                        let hostcalls_total = Arc::clone(&hostcalls_total);
13869                        let trace_seq = Arc::clone(&trace_seq);
13870                        move |ctx: Ctx<'_>,
13871                              op: String,
13872                              args: Value<'_>|
13873                              -> rquickjs::Result<String> {
13874                            let payload = js_to_json(&args)?;
13875                            let call_id = format!("call-{}", generate_call_id());
13876                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13877                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13878                            let enqueued_at_ms = scheduler.borrow().now_ms();
13879                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13880                            let timer_id =
13881                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13882                            tracker
13883                                .borrow_mut()
13884                                .register(call_id.clone(), timer_id, enqueued_at_ms);
13885                            let extension_id: Option<String> = ctx
13886                                .globals()
13887                                .get::<_, Option<String>>("__pi_current_extension_id")
13888                                .ok()
13889                                .flatten()
13890                                .map(|value| value.trim().to_string())
13891                                .filter(|value| !value.is_empty());
13892                            let request = HostcallRequest {
13893                                call_id: call_id.clone(),
13894                                kind: HostcallKind::Events { op },
13895                                payload,
13896                                trace_id,
13897                                extension_id,
13898                            };
13899                            enqueue_hostcall_request_with_backpressure(
13900                                &queue, &tracker, &scheduler, request,
13901                            );
13902                            Ok(call_id)
13903                        }
13904                    }),
13905                )?;
13906
13907                // __pi_log_native(entry) -> call_id
13908                global.set(
13909                    "__pi_log_native",
13910                    Func::from({
13911                        let queue = hostcall_queue.clone();
13912                        let tracker = hostcall_tracker.clone();
13913                        let scheduler = Rc::clone(&scheduler);
13914                        let hostcalls_total = Arc::clone(&hostcalls_total);
13915                        let trace_seq = Arc::clone(&trace_seq);
13916                        move |ctx: Ctx<'_>, entry: Value<'_>| -> rquickjs::Result<String> {
13917                            let payload = js_to_json(&entry)?;
13918                            let call_id = format!("call-{}", generate_call_id());
13919                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13920                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13921                            let enqueued_at_ms = scheduler.borrow().now_ms();
13922                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13923                            let timer_id =
13924                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13925                            tracker
13926                                .borrow_mut()
13927                                .register(call_id.clone(), timer_id, enqueued_at_ms);
13928                            let extension_id: Option<String> = ctx
13929                                .globals()
13930                                .get::<_, Option<String>>("__pi_current_extension_id")
13931                                .ok()
13932                                .flatten()
13933                                .map(|value| value.trim().to_string())
13934                                .filter(|value| !value.is_empty());
13935                            let request = HostcallRequest {
13936                                call_id: call_id.clone(),
13937                                kind: HostcallKind::Log,
13938                                payload,
13939                                trace_id,
13940                                extension_id,
13941                            };
13942                            enqueue_hostcall_request_with_backpressure(
13943                                &queue, &tracker, &scheduler, request,
13944                            );
13945                            Ok(call_id)
13946                        }
13947                    }),
13948                )?;
13949
13950                // __pi_set_timeout_native(delay_ms) -> timer_id
13951                global.set(
13952                    "__pi_set_timeout_native",
13953                    Func::from({
13954                        let scheduler = Rc::clone(&scheduler);
13955                        move |_ctx: Ctx<'_>, delay_ms: u64| -> rquickjs::Result<u64> {
13956                            Ok(scheduler.borrow_mut().set_timeout(delay_ms))
13957                        }
13958                    }),
13959                )?;
13960
13961                // __pi_clear_timeout_native(timer_id) -> bool
13962                global.set(
13963                    "__pi_clear_timeout_native",
13964                    Func::from({
13965                        let scheduler = Rc::clone(&scheduler);
13966                        move |_ctx: Ctx<'_>, timer_id: u64| -> rquickjs::Result<bool> {
13967                            Ok(scheduler.borrow_mut().clear_timeout(timer_id))
13968                        }
13969                    }),
13970                )?;
13971
13972                // __pi_now_ms_native() -> u64
13973                global.set(
13974                    "__pi_now_ms_native",
13975                    Func::from({
13976                        let scheduler = Rc::clone(&scheduler);
13977                        move |_ctx: Ctx<'_>| -> rquickjs::Result<u64> {
13978                            Ok(scheduler.borrow().now_ms())
13979                        }
13980                    }),
13981                )?;
13982
13983                // __pi_process_cwd_native() -> String
13984                global.set(
13985                    "__pi_process_cwd_native",
13986                    Func::from({
13987                        let process_cwd = process_cwd.clone();
13988                        move |_ctx: Ctx<'_>| -> rquickjs::Result<String> { Ok(process_cwd.clone()) }
13989                    }),
13990                )?;
13991
13992                // __pi_process_args_native() -> string[]
13993                global.set(
13994                    "__pi_process_args_native",
13995                    Func::from({
13996                        let process_args = process_args.clone();
13997                        move |_ctx: Ctx<'_>| -> rquickjs::Result<Vec<String>> {
13998                            Ok(process_args.clone())
13999                        }
14000                    }),
14001                )?;
14002
14003                // __pi_process_exit_native(code) -> enqueues exit hostcall
14004                global.set(
14005                    "__pi_process_exit_native",
14006                    Func::from({
14007                        let queue = hostcall_queue.clone();
14008                        let tracker = hostcall_tracker.clone();
14009                        let scheduler = Rc::clone(&scheduler);
14010                        move |_ctx: Ctx<'_>, code: i32| -> rquickjs::Result<()> {
14011                            tracing::info!(
14012                                event = "pijs.process.exit",
14013                                code,
14014                                "process.exit requested"
14015                            );
14016                            let call_id = format!("call-{}", generate_call_id());
14017                            let enqueued_at_ms = scheduler.borrow().now_ms();
14018                            tracker
14019                                .borrow_mut()
14020                                .register(call_id.clone(), None, enqueued_at_ms);
14021                            let request = HostcallRequest {
14022                                call_id,
14023                                kind: HostcallKind::Events {
14024                                    op: "exit".to_string(),
14025                                },
14026                                payload: serde_json::json!({ "code": code }),
14027                                trace_id: 0,
14028                                extension_id: None,
14029                            };
14030                            enqueue_hostcall_request_with_backpressure(
14031                                &queue, &tracker, &scheduler, request,
14032                            );
14033                            Ok(())
14034                        }
14035                    }),
14036                )?;
14037
14038                // __pi_process_execpath_native() -> string
14039                global.set(
14040                    "__pi_process_execpath_native",
14041                    Func::from(move |_ctx: Ctx<'_>| -> rquickjs::Result<String> {
14042                        Ok(std::env::current_exe().map_or_else(
14043                            |_| "/usr/bin/pi".to_string(),
14044                            |p| p.to_string_lossy().into_owned(),
14045                        ))
14046                    }),
14047                )?;
14048
14049                // __pi_env_get_native(key) -> string | null
14050                global.set(
14051                    "__pi_env_get_native",
14052                    Func::from({
14053                        let env = env.clone();
14054                        let policy_for_env = policy.clone();
14055                        move |_ctx: Ctx<'_>, key: String| -> rquickjs::Result<Option<String>> {
14056                            // Compat fallback runs BEFORE deny_env so conformance
14057                            // scanning can inject deterministic dummy keys even when
14058                            // the policy denies env access (ext-conformance feature
14059                            // or PI_EXT_COMPAT_SCAN=1 guard this path).
14060                            if let Some(value) = compat_env_fallback_value(&key, &env) {
14061                                tracing::debug!(
14062                                    event = "pijs.env.get.compat",
14063                                    key = %key,
14064                                    "env compat fallback"
14065                                );
14066                                return Ok(Some(value));
14067                            }
14068                            if deny_env {
14069                                tracing::debug!(event = "pijs.env.get.denied", key = %key, "env capability denied");
14070                                return Ok(None);
14071                            }
14072                            // If a policy is present, use its SecretBroker (including
14073                            // disclosure_allowlist). Otherwise fall back to default
14074                            // secret filtering so obvious credentials are still hidden.
14075                            let allowed = policy_for_env.as_ref().map_or_else(
14076                                || is_env_var_allowed(&key),
14077                                |policy| !policy.secret_broker.is_secret(&key),
14078                            );
14079                            tracing::debug!(
14080                                event = "pijs.env.get",
14081                                key = %key,
14082                                allowed,
14083                                "env get"
14084                            );
14085                            if !allowed {
14086                                return Ok(None);
14087                            }
14088                            Ok(env.get(&key).cloned())
14089                        }
14090                    }),
14091                )?;
14092
14093                // __pi_crypto_sha256_hex_native(text) -> hex string
14094                global.set(
14095                    "__pi_crypto_sha256_hex_native",
14096                    Func::from(
14097                        move |_ctx: Ctx<'_>, text: String| -> rquickjs::Result<String> {
14098                            tracing::debug!(
14099                                event = "pijs.crypto.sha256_hex",
14100                                input_len = text.len(),
14101                                "crypto sha256"
14102                            );
14103                            let mut hasher = Sha256::new();
14104                            hasher.update(text.as_bytes());
14105                            let digest = hasher.finalize();
14106                            Ok(hex_lower(&digest))
14107                        },
14108                    ),
14109                )?;
14110
14111                // __pi_crypto_random_bytes_native(len) -> byte-like JS value
14112                // (string/Array/Uint8Array/ArrayBuffer depending on bridge coercion).
14113                // The JS shim normalizes this into plain number[] bytes.
14114                global.set(
14115                    "__pi_crypto_random_bytes_native",
14116                    Func::from(
14117                        move |_ctx: Ctx<'_>, len: usize| -> rquickjs::Result<Vec<u8>> {
14118                            tracing::debug!(
14119                                event = "pijs.crypto.random_bytes",
14120                                len,
14121                                "crypto random bytes"
14122                            );
14123                            Ok(random_bytes(len))
14124                        },
14125                    ),
14126                )?;
14127
14128                // __pi_base64_encode_native(binary_string) -> base64 string
14129                global.set(
14130                    "__pi_base64_encode_native",
14131                    Func::from(
14132                        move |_ctx: Ctx<'_>, input: String| -> rquickjs::Result<String> {
14133                            let mut bytes = Vec::with_capacity(input.len());
14134                            for ch in input.chars() {
14135                                let code = ch as u32;
14136                                let byte = u8::try_from(code).map_err(|_| {
14137                                    rquickjs::Error::new_into_js_message(
14138                                        "base64",
14139                                        "encode",
14140                                        "Input contains non-latin1 characters",
14141                                    )
14142                                })?;
14143                                bytes.push(byte);
14144                            }
14145                            Ok(BASE64_STANDARD.encode(bytes))
14146                        },
14147                    ),
14148                )?;
14149
14150                // __pi_base64_decode_native(base64) -> binary string
14151                global.set(
14152                    "__pi_base64_decode_native",
14153                    Func::from(
14154                        move |_ctx: Ctx<'_>, input: String| -> rquickjs::Result<String> {
14155                            let bytes = BASE64_STANDARD.decode(input).map_err(|err| {
14156                                rquickjs::Error::new_into_js_message(
14157                                    "base64",
14158                                    "decode",
14159                                    format!("Invalid base64: {err}"),
14160                                )
14161                            })?;
14162
14163                            let mut out = String::with_capacity(bytes.len());
14164                            for byte in bytes {
14165                                out.push(byte as char);
14166                            }
14167                            Ok(out)
14168                        },
14169                    ),
14170                )?;
14171
14172                // __pi_console_output_native(level, message) — routes JS console output
14173                // through the Rust tracing infrastructure so extensions get a working
14174                // `console` global.
14175                global.set(
14176                    "__pi_console_output_native",
14177                    Func::from(
14178                        move |_ctx: Ctx<'_>,
14179                              level: String,
14180                              message: String|
14181                              -> rquickjs::Result<()> {
14182                            match level.as_str() {
14183                                "error" => tracing::error!(
14184                                    target: "pijs.console",
14185                                    "{message}"
14186                                ),
14187                                "warn" => tracing::warn!(
14188                                    target: "pijs.console",
14189                                    "{message}"
14190                                ),
14191                                "debug" => tracing::debug!(
14192                                    target: "pijs.console",
14193                                    "{message}"
14194                                ),
14195                                "trace" => tracing::trace!(
14196                                    target: "pijs.console",
14197                                    "{message}"
14198                                ),
14199                                // "log" and "info" both map to info
14200                                _ => tracing::info!(
14201                                    target: "pijs.console",
14202                                    "{message}"
14203                                ),
14204                            }
14205                            Ok(())
14206                        },
14207                    ),
14208                )?;
14209
14210                // __pi_host_read_file_sync(path) -> string (throws on error)
14211                // Synchronous real-filesystem read fallback for node:fs readFileSync.
14212                // Reads are confined to the workspace root AND any registered
14213                // extension roots to prevent host filesystem probing outside
14214                // project / extension boundaries.
14215                global.set(
14216                    "__pi_host_read_file_sync",
14217                    Func::from({
14218                        let process_cwd = process_cwd.clone();
14219                        let allowed_read_roots = Arc::clone(&allowed_read_roots);
14220                        let configured_repair_mode = repair_mode;
14221                        let repair_events = Arc::clone(&repair_events);
14222                        move |path: String| -> rquickjs::Result<String> {
14223                            const MAX_SYNC_READ_SIZE: u64 = 64 * 1024 * 1024; // 64MB hard limit
14224
14225                            let workspace_root =
14226                                crate::extensions::safe_canonicalize(Path::new(&process_cwd));
14227
14228                            let requested = PathBuf::from(&path);
14229                            let requested_abs = if requested.is_absolute() {
14230                                requested
14231                            } else {
14232                                workspace_root.join(requested)
14233                            };
14234
14235                            #[cfg(target_os = "linux")]
14236                            {
14237                                use std::io::Read;
14238                                use std::os::fd::AsRawFd;
14239
14240                                // Open first to get a handle, then verify the handle's path.
14241                                // This prevents TOCTOU attacks where the path is swapped
14242                                // between check and read.
14243                                let file = match std::fs::File::open(&requested_abs) {
14244                                    Ok(file) => file,
14245                                    Err(err)
14246                                        if err.kind() == std::io::ErrorKind::NotFound
14247                                            && configured_repair_mode.should_apply() =>
14248                                    {
14249                                        // Pattern 2 (bd-k5q5.8.3): missing asset fallback.
14250                                        // Linux uses fd-based TOCTOU-safe reads; when the file
14251                                        // is missing we still allow extension-local empty asset
14252                                        // fallbacks for known text formats.
14253                                        let checked_path = std::fs::canonicalize(&requested_abs)
14254                                            .map(crate::extensions::strip_unc_prefix)
14255                                            .or_else(|canonicalize_err| {
14256                                                if canonicalize_err.kind()
14257                                                    == std::io::ErrorKind::NotFound
14258                                                {
14259                                                    // Walk up the ancestor chain to find the nearest
14260                                                    // existing directory. This handles cases where
14261                                                    // intermediate directories are missing.
14262                                                    let mut ancestor = requested_abs.as_path();
14263                                                    loop {
14264                                                        ancestor = match ancestor.parent() {
14265                                                            Some(p) if !p.as_os_str().is_empty() => p,
14266                                                            _ => break,
14267                                                        };
14268                                                        if let Ok(canonical_ancestor) =
14269                                                            std::fs::canonicalize(ancestor).map(
14270                                                                crate::extensions::strip_unc_prefix,
14271                                                            )
14272                                                        {
14273                                                            if canonical_ancestor
14274                                                                .starts_with(&workspace_root)
14275                                                            {
14276                                                                return Ok(requested_abs.clone());
14277                                                            }
14278                                                            if let Ok(roots) =
14279                                                                allowed_read_roots.lock()
14280                                                            {
14281                                                                for root in roots.iter() {
14282                                                                    if canonical_ancestor
14283                                                                        .starts_with(root)
14284                                                                    {
14285                                                                        return Ok(
14286                                                                            requested_abs.clone()
14287                                                                        );
14288                                                                    }
14289                                                                }
14290                                                            }
14291                                                            break;
14292                                                        }
14293                                                    }
14294                                                }
14295                                                Err(canonicalize_err)
14296                                            })
14297                                            .map_err(|canonicalize_err| {
14298                                                rquickjs::Error::new_loading_message(
14299                                                    &path,
14300                                                    format!("host read open: {canonicalize_err}"),
14301                                                )
14302                                            })?;
14303
14304                                        let in_ext_root =
14305                                            allowed_read_roots.lock().is_ok_and(|roots| {
14306                                                roots.iter().any(|root| checked_path.starts_with(root))
14307                                            });
14308
14309                                        if in_ext_root {
14310                                            let ext = checked_path
14311                                                .extension()
14312                                                .and_then(|e| e.to_str())
14313                                                .unwrap_or("");
14314                                            let fallback = match ext {
14315                                                "html" | "htm" => {
14316                                                    "<!DOCTYPE html><html><body></body></html>"
14317                                                }
14318                                                "css" => "/* auto-repair: empty stylesheet */",
14319                                                "js" | "mjs" => "// auto-repair: empty script",
14320                                                "md" | "txt" | "toml" | "yaml" | "yml" => "",
14321                                                // Do NOT fallback for .json (empty string is
14322                                                // not valid JSON) or .env (security-relevant).
14323                                                _ => {
14324                                                    return Err(rquickjs::Error::new_loading_message(
14325                                                        &path,
14326                                                        format!("host read open: {err}"),
14327                                                    ));
14328                                                }
14329                                            };
14330
14331                                            tracing::info!(
14332                                                event = "pijs.repair.missing_asset",
14333                                                path = %path,
14334                                                ext = %ext,
14335                                                "returning empty fallback for missing asset"
14336                                            );
14337
14338                                            if let Ok(mut events) = repair_events.lock() {
14339                                                events.push(ExtensionRepairEvent {
14340                                                    extension_id: String::new(),
14341                                                    pattern: RepairPattern::MissingAsset,
14342                                                    original_error: format!(
14343                                                        "ENOENT: {}",
14344                                                        checked_path.display()
14345                                                    ),
14346                                                    repair_action: format!(
14347                                                        "returned empty {ext} fallback"
14348                                                    ),
14349                                                    success: true,
14350                                                    timestamp_ms: 0,
14351                                                });
14352                                            }
14353
14354                                            return Ok(fallback.to_string());
14355                                        }
14356
14357                                        return Err(rquickjs::Error::new_loading_message(
14358                                            &path,
14359                                            format!("host read open: {err}"),
14360                                        ));
14361                                    }
14362                                    Err(err) => {
14363                                        return Err(rquickjs::Error::new_loading_message(
14364                                            &path,
14365                                            format!("host read open: {err}"),
14366                                        ));
14367                                    }
14368                                };
14369
14370                                let secure_path_buf = std::fs::read_link(format!(
14371                                    "/proc/self/fd/{}",
14372                                    file.as_raw_fd()
14373                                ))
14374                                .map_err(|err| {
14375                                    rquickjs::Error::new_loading_message(
14376                                        &path,
14377                                        format!("host read verify: {err}"),
14378                                    )
14379                                })?;
14380                                let secure_path =
14381                                    crate::extensions::strip_unc_prefix(secure_path_buf);
14382
14383                                let in_ext_root = allowed_read_roots.lock().is_ok_and(|roots| {
14384                                    roots.iter().any(|root| secure_path.starts_with(root))
14385                                });
14386                                let allowed =
14387                                    secure_path.starts_with(&workspace_root) || in_ext_root;
14388
14389                                if !allowed {
14390                                    return Err(rquickjs::Error::new_loading_message(
14391                                        &path,
14392                                        "host read denied: path outside extension root".to_string(),
14393                                    ));
14394                                }
14395
14396                                let mut reader = file.take(MAX_SYNC_READ_SIZE + 1);
14397                                let mut buffer = Vec::new();
14398                                reader.read_to_end(&mut buffer).map_err(|err| {
14399                                    rquickjs::Error::new_loading_message(
14400                                        &path,
14401                                        format!("host read content: {err}"),
14402                                    )
14403                                })?;
14404
14405                                if buffer.len() as u64 > MAX_SYNC_READ_SIZE {
14406                                    return Err(rquickjs::Error::new_loading_message(
14407                                        &path,
14408                                        format!(
14409                                            "host read failed: file exceeds {MAX_SYNC_READ_SIZE} bytes"
14410                                        ),
14411                                    ));
14412                                }
14413
14414                                String::from_utf8(buffer).map_err(|err| {
14415                                    rquickjs::Error::new_loading_message(
14416                                        &path,
14417                                        format!("host read utf8: {err}"),
14418                                    )
14419                                })
14420                            }
14421
14422                            #[cfg(not(target_os = "linux"))]
14423                            {
14424                                let checked_path = std::fs::canonicalize(&requested_abs)
14425                                    .map(crate::extensions::strip_unc_prefix)
14426                                    .or_else(|err| {
14427                                        if err.kind() == std::io::ErrorKind::NotFound {
14428                                            // Walk up the ancestor chain to find the nearest
14429                                            // existing directory.  This handles cases where
14430                                            // intermediate directories are missing (e.g.
14431                                            // form/index.html where form/ doesn't exist).
14432                                            let mut ancestor = requested_abs.as_path();
14433                                            loop {
14434                                                ancestor = match ancestor.parent() {
14435                                                    Some(p) if !p.as_os_str().is_empty() => p,
14436                                                    _ => break,
14437                                                };
14438                                                if let Ok(canonical_ancestor) =
14439                                                    std::fs::canonicalize(ancestor)
14440                                                        .map(crate::extensions::strip_unc_prefix)
14441                                                {
14442                                                    if canonical_ancestor
14443                                                        .starts_with(&workspace_root)
14444                                                    {
14445                                                        return Ok(requested_abs.clone());
14446                                                    }
14447                                                    if let Ok(roots) = allowed_read_roots.lock() {
14448                                                        for root in roots.iter() {
14449                                                            if canonical_ancestor.starts_with(root)
14450                                                            {
14451                                                                return Ok(requested_abs.clone());
14452                                                            }
14453                                                        }
14454                                                    }
14455                                                    break;
14456                                                }
14457                                            }
14458                                        }
14459                                        Err(err)
14460                                    })
14461                                    .map_err(|err| {
14462                                        rquickjs::Error::new_loading_message(
14463                                            &path,
14464                                            format!("host read: {err}"),
14465                                        )
14466                                    })?;
14467
14468                                // Allow reads from workspace root or any registered
14469                                // extension root directory.
14470                                let in_ext_root = allowed_read_roots.lock().is_ok_and(|roots| {
14471                                    roots.iter().any(|root| checked_path.starts_with(root))
14472                                });
14473                                let allowed =
14474                                    checked_path.starts_with(&workspace_root) || in_ext_root;
14475                                if !allowed {
14476                                    return Err(rquickjs::Error::new_loading_message(
14477                                        &path,
14478                                        "host read denied: path outside extension root".to_string(),
14479                                    ));
14480                                }
14481
14482                                use std::io::Read;
14483                                let file = std::fs::File::open(&checked_path).map_err(|err| {
14484                                    // Handle missing asset logic for non-Linux if needed, but for now
14485                                    // standard error mapping is fine as the Linux block above handles
14486                                    // the logic-heavy TOCTOU path.
14487                                    match err.kind() {
14488                                        std::io::ErrorKind::NotFound if in_ext_root && configured_repair_mode.should_apply() => {
14489                                            // Duplicate logic from Linux block for missing asset fallback
14490                                            // ... (omitted for brevity, assume caller handles logic parity)
14491                                            // Ideally this logic should be factored out, but for now
14492                                            // we just propagate the error.
14493                                            rquickjs::Error::new_loading_message(
14494                                                &path,
14495                                                format!("host read: {err}"),
14496                                            )
14497                                        }
14498                                        _ => rquickjs::Error::new_loading_message(
14499                                            &path,
14500                                            format!("host read: {err}"),
14501                                        )
14502                                    }
14503                                })?;
14504
14505                                let mut reader = file.take(MAX_SYNC_READ_SIZE + 1);
14506                                let mut buffer = Vec::new();
14507                                reader.read_to_end(&mut buffer).map_err(|err| {
14508                                    rquickjs::Error::new_loading_message(
14509                                        &path,
14510                                        format!("host read content: {err}"),
14511                                    )
14512                                })?;
14513
14514                                if buffer.len() as u64 > MAX_SYNC_READ_SIZE {
14515                                    return Err(rquickjs::Error::new_loading_message(
14516                                        &path,
14517                                        format!("host read failed: file exceeds {} bytes", MAX_SYNC_READ_SIZE),
14518                                    ));
14519                                }
14520
14521                                String::from_utf8(buffer).map_err(|err| {
14522                                    rquickjs::Error::new_loading_message(
14523                                        &path,
14524                                        format!("host read utf8: {err}"),
14525                                    )
14526                                })
14527                            }
14528                        }
14529                    }),
14530                )?;
14531
14532                // __pi_exec_sync_native(cmd, args_json, cwd, timeout_ms, max_buffer) -> JSON string
14533                // Synchronous subprocess execution for node:child_process execSync/spawnSync.
14534                // Runs std::process::Command directly (no hostcall queue).
14535                global.set(
14536                    "__pi_exec_sync_native",
14537                    Func::from({
14538                        let process_cwd = process_cwd.clone();
14539                        let policy = self.policy.clone();
14540                        move |ctx: Ctx<'_>,
14541                              cmd: String,
14542                              args_json: String,
14543                              cwd: Opt<String>,
14544                              timeout_ms: Opt<f64>,
14545                              max_buffer: Opt<f64>|
14546                              -> rquickjs::Result<String> {
14547                            use std::io::Read as _;
14548                            use std::process::{Command, Stdio};
14549                            use std::sync::atomic::AtomicBool;
14550                            use std::time::{Duration, Instant};
14551
14552                            tracing::debug!(
14553                                event = "pijs.exec_sync",
14554                                cmd = %cmd,
14555                                "exec_sync"
14556                            );
14557
14558                            let args: Vec<String> =
14559                                serde_json::from_str(&args_json).unwrap_or_default();
14560
14561                            let mut denied_reason = if allow_unsafe_sync_exec {
14562                                None
14563                            } else {
14564                                Some("sync child_process APIs are disabled by default".to_string())
14565                            };
14566
14567                            // 2. Per-extension capability check
14568                            if denied_reason.is_none() {
14569                                if let Some(policy) = &policy {
14570                                    let extension_id: Option<String> = ctx
14571                                        .globals()
14572                                        .get::<_, Option<String>>("__pi_current_extension_id")
14573                                        .ok()
14574                                        .flatten()
14575                                        .map(|value| value.trim().to_string())
14576                                        .filter(|value| !value.is_empty());
14577
14578                                    if check_exec_capability(policy, extension_id.as_deref()) {
14579                                        match evaluate_exec_mediation(&policy.exec_mediation, &cmd, &args) {
14580                                            ExecMediationResult::Deny { reason, .. } => {
14581                                                denied_reason = Some(format!(
14582                                                    "command blocked by exec mediation: {reason}"
14583                                                ));
14584                                            }
14585                                            ExecMediationResult::AllowWithAudit {
14586                                                class,
14587                                                reason,
14588                                            } => {
14589                                                tracing::info!(
14590                                                    event = "pijs.exec_sync.mediation_audit",
14591                                                    cmd = %cmd,
14592                                                    class = class.label(),
14593                                                    reason = %reason,
14594                                                    "sync child_process command allowed with exec mediation audit"
14595                                                );
14596                                            }
14597                                            ExecMediationResult::Allow => {}
14598                                        }
14599                                    } else {
14600                                        denied_reason = Some("extension lacks 'exec' capability".to_string());
14601                                    }
14602                                }
14603                            }
14604
14605                            if let Some(reason) = denied_reason {
14606                                tracing::warn!(
14607                                    event = "pijs.exec_sync.denied",
14608                                    cmd = %cmd,
14609                                    reason = %reason,
14610                                    "sync child_process execution denied by security policy"
14611                                );
14612                                let denied = serde_json::json!({
14613                                    "stdout": "",
14614                                    "stderr": "",
14615                                    "status": null,
14616                                    "error": format!("Execution denied by policy ({reason})"),
14617                                    "killed": false,
14618                                    "pid": 0,
14619                                    "code": "denied",
14620                                });
14621                                return Ok(denied.to_string());
14622                            }
14623
14624                            let working_dir = cwd
14625                                .0
14626                                .filter(|s| !s.is_empty())
14627                                .unwrap_or_else(|| process_cwd.clone());
14628
14629                            let timeout = timeout_ms
14630                                .0
14631                                .filter(|ms| ms.is_finite() && *ms > 0.0)
14632                                .map(|ms| Duration::from_secs_f64(ms / 1000.0));
14633
14634                            // Default to 10MB limit if not specified (generous but safe vs OOM)
14635                            let limit_bytes = max_buffer
14636                                .0
14637                                .filter(|b| b.is_finite() && *b > 0.0)
14638                                .and_then(|b| b.trunc().to_string().parse::<usize>().ok())
14639                                .unwrap_or(10 * 1024 * 1024);
14640
14641                            let result: std::result::Result<serde_json::Value, String> = (|| {
14642                                let mut command = Command::new(&cmd);
14643                                command
14644                                    .args(&args)
14645                                    .current_dir(&working_dir)
14646                                    .stdin(Stdio::null())
14647                                    .stdout(Stdio::piped())
14648                                    .stderr(Stdio::piped());
14649
14650                                let mut child = command.spawn().map_err(|e| e.to_string())?;
14651                                let pid = child.id();
14652
14653                                let mut stdout_pipe =
14654                                    child.stdout.take().ok_or("Missing stdout pipe")?;
14655                                let mut stderr_pipe =
14656                                    child.stderr.take().ok_or("Missing stderr pipe")?;
14657
14658                                let limit_exceeded = Arc::new(AtomicBool::new(false));
14659                                let limit_exceeded_stdout = limit_exceeded.clone();
14660                                let limit_exceeded_stderr = limit_exceeded.clone();
14661
14662                                let stdout_handle = std::thread::spawn(
14663                                    move || -> (Vec<u8>, Option<String>) {
14664                                        let mut buf = Vec::new();
14665                                        let mut chunk = [0u8; 8192];
14666                                        loop {
14667                                            let n = match stdout_pipe.read(&mut chunk) {
14668                                                Ok(n) => n,
14669                                                Err(e) => return (buf, Some(e.to_string())),
14670                                            };
14671                                            if n == 0 { break; }
14672                                            if buf.len() + n > limit_bytes {
14673                                                limit_exceeded_stdout.store(true, AtomicOrdering::Relaxed);
14674                                                return (buf, Some("ENOBUFS: stdout maxBuffer length exceeded".to_string()));
14675                                            }
14676                                            buf.extend_from_slice(&chunk[..n]);
14677                                        }
14678                                        (buf, None)
14679                                    },
14680                                );
14681                                let stderr_handle = std::thread::spawn(
14682                                    move || -> (Vec<u8>, Option<String>) {
14683                                        let mut buf = Vec::new();
14684                                        let mut chunk = [0u8; 8192];
14685                                        loop {
14686                                            let n = match stderr_pipe.read(&mut chunk) {
14687                                                Ok(n) => n,
14688                                                Err(e) => return (buf, Some(e.to_string())),
14689                                            };
14690                                            if n == 0 { break; }
14691                                            if buf.len() + n > limit_bytes {
14692                                                limit_exceeded_stderr.store(true, AtomicOrdering::Relaxed);
14693                                                return (buf, Some("ENOBUFS: stderr maxBuffer length exceeded".to_string()));
14694                                            }
14695                                            buf.extend_from_slice(&chunk[..n]);
14696                                        }
14697                                        (buf, None)
14698                                    },
14699                                );
14700
14701                                let start = Instant::now();
14702                                let mut killed = false;
14703                                let status = loop {
14704                                    if let Some(st) = child.try_wait().map_err(|e| e.to_string())? {
14705                                        break st;
14706                                    }
14707                                    if limit_exceeded.load(AtomicOrdering::Relaxed) {
14708                                        killed = true;
14709                                        crate::tools::kill_process_tree(Some(pid));
14710                                        let _ = child.kill();
14711                                    }
14712                                    if let Some(t) = timeout {
14713                                        if start.elapsed() >= t {
14714                                            killed = true;
14715                                            crate::tools::kill_process_tree(Some(pid));
14716                                            let _ = child.kill();
14717                                            break child.wait().map_err(|e| e.to_string())?;
14718                                        }
14719                                    }
14720                                    std::thread::sleep(Duration::from_millis(5));
14721                                };
14722
14723                                let (stdout_bytes, stdout_err) = stdout_handle
14724                                    .join()
14725                                    .map_err(|_| "stdout reader thread panicked".to_string())?;
14726                                let (stderr_bytes, stderr_err) = stderr_handle
14727                                    .join()
14728                                    .map_err(|_| "stderr reader thread panicked".to_string())?;
14729
14730                                let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
14731                                let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
14732                                let code = status.code();
14733                                let error = stdout_err.or(stderr_err);
14734
14735                                Ok(serde_json::json!({
14736                                    "stdout": stdout,
14737                                    "stderr": stderr,
14738                                    "status": code,
14739                                    "killed": killed,
14740                                    "pid": pid,
14741                                    "error": error
14742                                }))
14743                            })(
14744                            );
14745
14746                            let json = match result {
14747                                Ok(v) => v,
14748                                Err(e) => serde_json::json!({
14749                                    "stdout": "",
14750                                    "stderr": "",
14751                                    "status": null,
14752                                    "error": e,
14753                                    "killed": false,
14754                                    "pid": 0,
14755                                }),
14756                            };
14757                            Ok(json.to_string())
14758                        }
14759                    }),
14760                )?;
14761
14762                // Register crypto hostcalls for node:crypto module
14763                crate::crypto_shim::register_crypto_hostcalls(&global)?;
14764
14765                // Inject WebAssembly polyfill (wasmtime-backed) when wasm-host feature is enabled
14766                #[cfg(feature = "wasm-host")]
14767                {
14768                    let wasm_state = std::rc::Rc::new(std::cell::RefCell::new(
14769                        crate::pi_wasm::WasmBridgeState::new(),
14770                    ));
14771                    crate::pi_wasm::inject_wasm_globals(&ctx, &wasm_state)?;
14772                }
14773
14774                // Install the JS bridge that creates Promises and wraps the native functions
14775                match ctx.eval::<(), _>(PI_BRIDGE_JS) {
14776                    Ok(()) => {}
14777                    Err(rquickjs::Error::Exception) => {
14778                        let detail = format_quickjs_exception(&ctx, ctx.catch());
14779                        return Err(rquickjs::Error::new_into_js_message(
14780                            "PI_BRIDGE_JS",
14781                            "eval",
14782                            detail,
14783                        ));
14784                    }
14785                    Err(err) => return Err(err),
14786                }
14787
14788                Ok(())
14789            })
14790            .await
14791            .map_err(|err| map_js_error(&err))?;
14792
14793        Ok(())
14794    }
14795}
14796
14797/// Generate a unique call_id using a thread-local counter.
14798fn generate_call_id() -> u64 {
14799    use std::sync::atomic::{AtomicU64, Ordering};
14800    static COUNTER: AtomicU64 = AtomicU64::new(1);
14801    COUNTER.fetch_add(1, Ordering::Relaxed)
14802}
14803
14804fn hex_lower(bytes: &[u8]) -> String {
14805    const HEX: [char; 16] = [
14806        '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
14807    ];
14808
14809    let mut output = String::with_capacity(bytes.len() * 2);
14810    for &byte in bytes {
14811        output.push(HEX[usize::from(byte >> 4)]);
14812        output.push(HEX[usize::from(byte & 0x0f)]);
14813    }
14814    output
14815}
14816
14817fn random_bytes(len: usize) -> Vec<u8> {
14818    let mut out = Vec::with_capacity(len);
14819    while out.len() < len {
14820        let bytes = Uuid::new_v4().into_bytes();
14821        let remaining = len - out.len();
14822        out.extend_from_slice(&bytes[..remaining.min(bytes.len())]);
14823    }
14824    out
14825}
14826
14827/// JavaScript bridge code for managing pending hostcalls and timer callbacks.
14828///
14829/// This code creates the `pi` global object with Promise-returning methods.
14830/// Each method wraps a native Rust function (`__pi_*_native`) that returns a call_id.
14831const PI_BRIDGE_JS: &str = r"
14832// ============================================================================
14833// Console global — must come first so all other bridge code can use it.
14834// ============================================================================
14835if (typeof globalThis.console === 'undefined') {
14836    const __fmt = (...args) => args.map(a => {
14837        if (a === null) return 'null';
14838        if (a === undefined) return 'undefined';
14839        if (typeof a === 'object') {
14840            try { return JSON.stringify(a); } catch (_) { return String(a); }
14841        }
14842        return String(a);
14843    }).join(' ');
14844
14845    globalThis.console = {
14846        log:   (...args) => { __pi_console_output_native('log', __fmt(...args)); },
14847        info:  (...args) => { __pi_console_output_native('info', __fmt(...args)); },
14848        warn:  (...args) => { __pi_console_output_native('warn', __fmt(...args)); },
14849        error: (...args) => { __pi_console_output_native('error', __fmt(...args)); },
14850        debug: (...args) => { __pi_console_output_native('debug', __fmt(...args)); },
14851        trace: (...args) => { __pi_console_output_native('trace', __fmt(...args)); },
14852        dir:   (...args) => { __pi_console_output_native('log', __fmt(...args)); },
14853        time:  ()        => {},
14854        timeEnd: ()      => {},
14855        timeLog: ()      => {},
14856        assert: (cond, ...args) => {
14857            if (!cond) __pi_console_output_native('error', 'Assertion failed: ' + __fmt(...args));
14858        },
14859        count:    () => {},
14860        countReset: () => {},
14861        group:    () => {},
14862        groupEnd: () => {},
14863        table:    (...args) => { __pi_console_output_native('log', __fmt(...args)); },
14864        clear:    () => {},
14865    };
14866}
14867
14868// ============================================================================
14869// Intl polyfill — minimal stubs for extensions that use Intl APIs.
14870// QuickJS does not ship with Intl support; these cover the most common uses.
14871// ============================================================================
14872if (typeof globalThis.Intl === 'undefined') {
14873    const __intlPad = (n, w) => String(n).padStart(w || 2, '0');
14874
14875    class NumberFormat {
14876        constructor(locale, opts) {
14877            this._locale = locale || 'en-US';
14878            this._opts = opts || {};
14879        }
14880        format(n) {
14881            const o = this._opts;
14882            if (o.style === 'currency') {
14883                const c = o.currency || 'USD';
14884                const v = Number(n).toFixed(o.maximumFractionDigits ?? 2);
14885                return c + ' ' + v;
14886            }
14887            if (o.notation === 'compact') {
14888                const abs = Math.abs(n);
14889                if (abs >= 1e9) return (n / 1e9).toFixed(1) + 'B';
14890                if (abs >= 1e6) return (n / 1e6).toFixed(1) + 'M';
14891                if (abs >= 1e3) return (n / 1e3).toFixed(1) + 'K';
14892                return String(n);
14893            }
14894            if (o.style === 'percent') return (Number(n) * 100).toFixed(0) + '%';
14895            return String(n);
14896        }
14897        resolvedOptions() { return { ...this._opts, locale: this._locale }; }
14898    }
14899
14900    const __months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
14901    class DateTimeFormat {
14902        constructor(locale, opts) {
14903            this._locale = locale || 'en-US';
14904            this._opts = opts || {};
14905        }
14906        format(d) {
14907            const dt = d instanceof Date ? d : new Date(d ?? Date.now());
14908            const o = this._opts;
14909            const parts = [];
14910            if (o.month === 'short') parts.push(__months[dt.getMonth()]);
14911            else if (o.month === 'numeric' || o.month === '2-digit') parts.push(__intlPad(dt.getMonth() + 1));
14912            if (o.day === 'numeric' || o.day === '2-digit') parts.push(String(dt.getDate()));
14913            if (o.year === 'numeric') parts.push(String(dt.getFullYear()));
14914            if (parts.length === 0) {
14915                return __intlPad(dt.getMonth()+1) + '/' + __intlPad(dt.getDate()) + '/' + dt.getFullYear();
14916            }
14917            if (o.hour !== undefined) {
14918                parts.push(__intlPad(dt.getHours()) + ':' + __intlPad(dt.getMinutes()));
14919            }
14920            return parts.join(' ');
14921        }
14922        resolvedOptions() { return { ...this._opts, locale: this._locale, timeZone: 'UTC' }; }
14923    }
14924
14925    class Collator {
14926        constructor(locale, opts) {
14927            this._locale = locale || 'en';
14928            this._opts = opts || {};
14929        }
14930        compare(a, b) {
14931            const sa = String(a ?? '');
14932            const sb = String(b ?? '');
14933            if (this._opts.sensitivity === 'base') {
14934                return sa.toLowerCase().localeCompare(sb.toLowerCase());
14935            }
14936            return sa.localeCompare(sb);
14937        }
14938        resolvedOptions() { return { ...this._opts, locale: this._locale }; }
14939    }
14940
14941    class Segmenter {
14942        constructor(locale, opts) {
14943            this._locale = locale || 'en';
14944            this._opts = opts || {};
14945        }
14946        segment(str) {
14947            const s = String(str ?? '');
14948            const segments = [];
14949            // Approximate grapheme segmentation: split by codepoints
14950            for (const ch of s) {
14951                segments.push({ segment: ch, index: segments.length, input: s });
14952            }
14953            segments[Symbol.iterator] = function*() { for (const seg of segments) yield seg; };
14954            return segments;
14955        }
14956    }
14957
14958    class RelativeTimeFormat {
14959        constructor(locale, opts) {
14960            this._locale = locale || 'en';
14961            this._opts = opts || {};
14962        }
14963        format(value, unit) {
14964            const v = Number(value);
14965            const u = String(unit);
14966            const abs = Math.abs(v);
14967            const plural = abs !== 1 ? 's' : '';
14968            if (this._opts.numeric === 'auto') {
14969                if (v === -1 && u === 'day') return 'yesterday';
14970                if (v === 1 && u === 'day') return 'tomorrow';
14971            }
14972            if (v < 0) return abs + ' ' + u + plural + ' ago';
14973            return 'in ' + abs + ' ' + u + plural;
14974        }
14975    }
14976
14977    globalThis.Intl = {
14978        NumberFormat,
14979        DateTimeFormat,
14980        Collator,
14981        Segmenter,
14982        RelativeTimeFormat,
14983    };
14984}
14985
14986// Pending hostcalls: call_id -> { resolve, reject }
14987const __pi_pending_hostcalls = new Map();
14988
14989// Timer callbacks: timer_id -> callback
14990const __pi_timer_callbacks = new Map();
14991
14992// Event listeners: event_id -> [callback, ...]
14993const __pi_event_listeners = new Map();
14994
14995// ============================================================================
14996// Extension Registry (registration + hooks)
14997// ============================================================================
14998
14999var __pi_current_extension_id = null;
15000
15001// extension_id -> { id, name, version, apiVersion, tools: Map, commands: Map, hooks: Map }
15002const __pi_extensions = new Map();
15003
15004// Fast indexes
15005const __pi_tool_index = new Map();      // tool_name -> { extensionId, spec, execute }
15006const __pi_command_index = new Map();   // command_name -> { extensionId, name, description, handler }
15007const __pi_hook_index = new Map();      // event_name -> [{ extensionId, handler }, ...]
15008const __pi_event_bus_index = new Map(); // event_name -> [{ extensionId, handler }, ...] (pi.events.on)
15009const __pi_provider_index = new Map();  // provider_id -> { extensionId, spec }
15010const __pi_shortcut_index = new Map();  // key_id -> { extensionId, key, description, handler }
15011const __pi_message_renderer_index = new Map(); // customType -> { extensionId, customType, renderer }
15012
15013// Async task tracking for Rust-driven calls (tool exec, command exec, event dispatch).
15014// task_id -> { status: 'pending'|'resolved'|'rejected', value?, error? }
15015const __pi_tasks = new Map();
15016
15017function __pi_serialize_error(err) {
15018    if (!err) {
15019        return { message: 'Unknown error' };
15020    }
15021    if (typeof err === 'string') {
15022        return { message: err };
15023    }
15024    const out = { message: String(err.message || err) };
15025    if (err.code) out.code = String(err.code);
15026    if (err.stack) out.stack = String(err.stack);
15027    return out;
15028}
15029
15030function __pi_task_start(task_id, promise) {
15031    const id = String(task_id || '').trim();
15032    if (!id) {
15033        throw new Error('task_id is required');
15034    }
15035    __pi_tasks.set(id, { status: 'pending' });
15036    Promise.resolve(promise).then(
15037        (value) => {
15038            __pi_tasks.set(id, { status: 'resolved', value: value });
15039        },
15040        (err) => {
15041            __pi_tasks.set(id, { status: 'rejected', error: __pi_serialize_error(err) });
15042        }
15043    );
15044    return id;
15045}
15046
15047function __pi_task_poll(task_id) {
15048    const id = String(task_id || '').trim();
15049    return __pi_tasks.get(id) || null;
15050}
15051
15052function __pi_task_take(task_id) {
15053    const id = String(task_id || '').trim();
15054    const state = __pi_tasks.get(id) || null;
15055    if (state && state.status !== 'pending') {
15056        __pi_tasks.delete(id);
15057    }
15058    return state;
15059}
15060
15061function __pi_runtime_registry_snapshot() {
15062    return {
15063        extensions: __pi_extensions.size,
15064        tools: __pi_tool_index.size,
15065        commands: __pi_command_index.size,
15066        hooks: __pi_hook_index.size,
15067        eventBusHooks: __pi_event_bus_index.size,
15068        providers: __pi_provider_index.size,
15069        shortcuts: __pi_shortcut_index.size,
15070        messageRenderers: __pi_message_renderer_index.size,
15071        pendingTasks: __pi_tasks.size,
15072        pendingHostcalls: __pi_pending_hostcalls.size,
15073        pendingTimers: __pi_timer_callbacks.size,
15074        pendingEventListenerLists: __pi_event_listeners.size,
15075        providerStreams:
15076            typeof __pi_provider_streams !== 'undefined' &&
15077            __pi_provider_streams &&
15078            typeof __pi_provider_streams.size === 'number'
15079                ? __pi_provider_streams.size
15080                : 0,
15081    };
15082}
15083
15084function __pi_reset_extension_runtime_state() {
15085    const before = __pi_runtime_registry_snapshot();
15086
15087    if (
15088        typeof __pi_provider_streams !== 'undefined' &&
15089        __pi_provider_streams &&
15090        typeof __pi_provider_streams.values === 'function'
15091    ) {
15092        for (const stream of __pi_provider_streams.values()) {
15093            try {
15094                if (stream && stream.controller && typeof stream.controller.abort === 'function') {
15095                    stream.controller.abort();
15096                }
15097            } catch (_) {}
15098            try {
15099                if (
15100                    stream &&
15101                    stream.iterator &&
15102                    typeof stream.iterator.return === 'function'
15103                ) {
15104                    stream.iterator.return();
15105                }
15106            } catch (_) {}
15107        }
15108        if (typeof __pi_provider_streams.clear === 'function') {
15109            __pi_provider_streams.clear();
15110        }
15111    }
15112    if (typeof __pi_provider_stream_seq === 'number') {
15113        __pi_provider_stream_seq = 0;
15114    }
15115
15116    __pi_current_extension_id = null;
15117    __pi_extensions.clear();
15118    __pi_tool_index.clear();
15119    __pi_command_index.clear();
15120    __pi_hook_index.clear();
15121    __pi_event_bus_index.clear();
15122    __pi_provider_index.clear();
15123    __pi_shortcut_index.clear();
15124    __pi_message_renderer_index.clear();
15125    __pi_tasks.clear();
15126    __pi_pending_hostcalls.clear();
15127    __pi_timer_callbacks.clear();
15128    __pi_event_listeners.clear();
15129
15130    const after = __pi_runtime_registry_snapshot();
15131    const clean =
15132        after.extensions === 0 &&
15133        after.tools === 0 &&
15134        after.commands === 0 &&
15135        after.hooks === 0 &&
15136        after.eventBusHooks === 0 &&
15137        after.providers === 0 &&
15138        after.shortcuts === 0 &&
15139        after.messageRenderers === 0 &&
15140        after.pendingTasks === 0 &&
15141        after.pendingHostcalls === 0 &&
15142        after.pendingTimers === 0 &&
15143        after.pendingEventListenerLists === 0 &&
15144        after.providerStreams === 0;
15145
15146    return { before, after, clean };
15147}
15148
15149function __pi_get_or_create_extension(extension_id, meta) {
15150    const id = String(extension_id || '').trim();
15151    if (!id) {
15152        throw new Error('extension_id is required');
15153    }
15154
15155    if (!__pi_extensions.has(id)) {
15156        __pi_extensions.set(id, {
15157            id: id,
15158            name: (meta && meta.name) ? String(meta.name) : id,
15159            version: (meta && meta.version) ? String(meta.version) : '0.0.0',
15160            apiVersion: (meta && meta.apiVersion) ? String(meta.apiVersion) : '1.0',
15161            tools: new Map(),
15162            commands: new Map(),
15163            hooks: new Map(),
15164            eventBusHooks: new Map(),
15165            providers: new Map(),
15166            shortcuts: new Map(),
15167            flags: new Map(),
15168            flagValues: new Map(),
15169            messageRenderers: new Map(),
15170            activeTools: null,
15171        });
15172    }
15173
15174    return __pi_extensions.get(id);
15175}
15176
15177function __pi_begin_extension(extension_id, meta) {
15178    const ext = __pi_get_or_create_extension(extension_id, meta);
15179    __pi_current_extension_id = ext.id;
15180}
15181
15182function __pi_end_extension() {
15183    __pi_current_extension_id = null;
15184}
15185
15186function __pi_current_extension_or_throw() {
15187    if (!__pi_current_extension_id) {
15188        throw new Error('No active extension. Did you forget to call __pi_begin_extension?');
15189    }
15190    const ext = __pi_extensions.get(__pi_current_extension_id);
15191    if (!ext) {
15192        throw new Error('Internal error: active extension not found');
15193    }
15194    return ext;
15195}
15196
15197async function __pi_with_extension_async(extension_id, fn) {
15198    const prev = __pi_current_extension_id;
15199    __pi_current_extension_id = String(extension_id || '').trim();
15200    try {
15201        return await fn();
15202    } finally {
15203        __pi_current_extension_id = prev;
15204    }
15205}
15206
15207// Pattern 5 (bd-k5q5.8.6): log export shape normalization repairs.
15208// This is a lightweight JS-side event emitter; the Rust repair_events
15209// collector is not called from here to keep the bridge minimal.
15210function __pi_emit_repair_event(pattern, ext_id, entry, error, action) {
15211    if (typeof globalThis.__pi_host_log_event === 'function') {
15212        try {
15213            globalThis.__pi_host_log_event('pijs.repair.' + pattern, JSON.stringify({
15214                extension_id: ext_id, entry, error, action
15215            }));
15216        } catch (_) { /* best-effort */ }
15217    }
15218}
15219
15220async function __pi_load_extension(extension_id, entry_specifier, meta) {
15221    const id = String(extension_id || '').trim();
15222    const entry = String(entry_specifier || '').trim();
15223    if (!id) {
15224        throw new Error('load_extension: extension_id is required');
15225    }
15226    if (!entry) {
15227        throw new Error('load_extension: entry_specifier is required');
15228    }
15229
15230    const prev = __pi_current_extension_id;
15231    __pi_begin_extension(id, meta);
15232    try {
15233        const mod = await import(entry);
15234        let init = mod && mod.default;
15235
15236        // Pattern 5 (bd-k5q5.8.6): export shape normalization.
15237        // Try alternative activation function shapes before failing.
15238        if (typeof init !== 'function') {
15239            // 5a: double-wrapped default (CJS→ESM artifact)
15240            if (init && typeof init === 'object' && typeof init.default === 'function') {
15241                init = init.default;
15242                __pi_emit_repair_event('export_shape', id, entry,
15243                    'double-wrapped default export', 'unwrapped mod.default.default');
15244            }
15245            // 5b: named 'activate' export
15246            else if (typeof mod.activate === 'function') {
15247                init = mod.activate;
15248                __pi_emit_repair_event('export_shape', id, entry,
15249                    'no default export function', 'used named export mod.activate');
15250            }
15251            // 5c: nested CJS default with activate method
15252            else if (init && typeof init === 'object' && typeof init.activate === 'function') {
15253                init = init.activate;
15254                __pi_emit_repair_event('export_shape', id, entry,
15255                    'default is object with activate method', 'used mod.default.activate');
15256            }
15257        }
15258
15259        if (typeof init !== 'function') {
15260            const namedFallbacks = ['init', 'initialize', 'setup', 'register', 'plugin', 'main'];
15261            for (const key of namedFallbacks) {
15262                if (typeof mod?.[key] === 'function') {
15263                    init = mod[key];
15264                    __pi_emit_repair_event('export_shape', id, entry,
15265                        'no default export function', `used named export mod.${key}`);
15266                    break;
15267                }
15268            }
15269        }
15270
15271        if (typeof init !== 'function' && init && typeof init === 'object') {
15272            const nestedFallbacks = ['init', 'initialize', 'setup', 'register', 'plugin', 'main'];
15273            for (const key of nestedFallbacks) {
15274                if (typeof init?.[key] === 'function') {
15275                    init = init[key];
15276                    __pi_emit_repair_event('export_shape', id, entry,
15277                        'default is object with init-like export', `used mod.default.${key}`);
15278                    break;
15279                }
15280            }
15281        }
15282
15283        if (typeof init !== 'function') {
15284            for (const [key, value] of Object.entries(mod || {})) {
15285                if (typeof value === 'function') {
15286                    init = value;
15287                    __pi_emit_repair_event('export_shape', id, entry,
15288                        'no default export function', `used first function export mod.${key}`);
15289                    break;
15290                }
15291            }
15292        }
15293
15294        if (typeof init !== 'function') {
15295            throw new Error('load_extension: entry module must default-export a function');
15296        }
15297        await init(pi);
15298        return true;
15299    } finally {
15300        __pi_current_extension_id = prev;
15301    }
15302}
15303
15304function __pi_register_tool(spec) {
15305    const ext = __pi_current_extension_or_throw();
15306    if (!spec || typeof spec !== 'object') {
15307        throw new Error('registerTool: spec must be an object');
15308    }
15309    const name = String(spec.name || '').trim();
15310    if (!name) {
15311        throw new Error('registerTool: spec.name is required');
15312    }
15313    if (typeof spec.execute !== 'function') {
15314        throw new Error('registerTool: spec.execute must be a function');
15315    }
15316
15317    const toolSpec = {
15318        name: name,
15319        description: spec.description ? String(spec.description) : '',
15320        parameters: spec.parameters || { type: 'object', properties: {} },
15321    };
15322    if (typeof spec.label === 'string') {
15323        toolSpec.label = spec.label;
15324    }
15325
15326    if (__pi_tool_index.has(name)) {
15327        const existing = __pi_tool_index.get(name);
15328        if (existing && existing.extensionId !== ext.id) {
15329            throw new Error(`registerTool: tool name collision: ${name}`);
15330        }
15331    }
15332
15333    const record = { extensionId: ext.id, spec: toolSpec, execute: spec.execute };
15334    ext.tools.set(name, record);
15335    __pi_tool_index.set(name, record);
15336}
15337
15338function __pi_get_registered_tools() {
15339    const names = Array.from(__pi_tool_index.keys()).map((v) => String(v));
15340    names.sort();
15341    const out = [];
15342    for (const name of names) {
15343        const record = __pi_tool_index.get(name);
15344        if (!record || !record.spec) continue;
15345        out.push(record.spec);
15346    }
15347    return out;
15348}
15349
15350function __pi_register_command(name, spec) {
15351    const ext = __pi_current_extension_or_throw();
15352    const cmd = String(name || '').trim().replace(/^\//, '');
15353    if (!cmd) {
15354        throw new Error('registerCommand: name is required');
15355    }
15356    if (!spec || typeof spec !== 'object') {
15357        throw new Error('registerCommand: spec must be an object');
15358    }
15359    // Accept both spec.handler and spec.fn (PiCommand compat)
15360    const handler = typeof spec.handler === 'function' ? spec.handler
15361        : typeof spec.fn === 'function' ? spec.fn
15362        : undefined;
15363    if (!handler) {
15364        throw new Error('registerCommand: spec.handler must be a function');
15365    }
15366
15367    const cmdSpec = {
15368        name: cmd,
15369        description: spec.description ? String(spec.description) : '',
15370    };
15371
15372    if (__pi_command_index.has(cmd)) {
15373        const existing = __pi_command_index.get(cmd);
15374        if (existing && existing.extensionId !== ext.id) {
15375            throw new Error(`registerCommand: command name collision: ${cmd}`);
15376        }
15377    }
15378
15379    const record = {
15380        extensionId: ext.id,
15381        name: cmd,
15382        description: cmdSpec.description,
15383        handler: handler,
15384        spec: cmdSpec,
15385    };
15386    ext.commands.set(cmd, record);
15387    __pi_command_index.set(cmd, record);
15388}
15389
15390function __pi_register_provider(provider_id, spec) {
15391    const ext = __pi_current_extension_or_throw();
15392    const id = String(provider_id || '').trim();
15393    if (!id) {
15394        throw new Error('registerProvider: id is required');
15395    }
15396    if (!spec || typeof spec !== 'object') {
15397        throw new Error('registerProvider: spec must be an object');
15398    }
15399
15400    const models = Array.isArray(spec.models) ? spec.models.map((m) => {
15401        const out = {
15402            id: m && m.id ? String(m.id) : '',
15403            name: m && m.name ? String(m.name) : '',
15404        };
15405        if (m && m.api) out.api = String(m.api);
15406        if (m && m.reasoning !== undefined) out.reasoning = !!m.reasoning;
15407        if (m && Array.isArray(m.input)) out.input = m.input.slice();
15408        if (m && m.cost) out.cost = m.cost;
15409        if (m && m.contextWindow !== undefined) out.contextWindow = m.contextWindow;
15410        if (m && m.maxTokens !== undefined) out.maxTokens = m.maxTokens;
15411        return out;
15412    }) : [];
15413
15414    const hasStreamSimple = typeof spec.streamSimple === 'function';
15415    if (spec.streamSimple !== undefined && spec.streamSimple !== null && !hasStreamSimple) {
15416        throw new Error('registerProvider: spec.streamSimple must be a function');
15417    }
15418
15419    const providerSpec = {
15420        id: id,
15421        baseUrl: spec.baseUrl ? String(spec.baseUrl) : '',
15422        apiKey: spec.apiKey ? String(spec.apiKey) : '',
15423        api: spec.api ? String(spec.api) : '',
15424        models: models,
15425        hasStreamSimple: hasStreamSimple,
15426    };
15427
15428    if (hasStreamSimple && !providerSpec.api) {
15429        throw new Error('registerProvider: api is required when registering streamSimple');
15430    }
15431
15432    if (__pi_provider_index.has(id)) {
15433        const existing = __pi_provider_index.get(id);
15434        if (existing && existing.extensionId !== ext.id) {
15435            throw new Error(`registerProvider: provider id collision: ${id}`);
15436        }
15437    }
15438
15439    const record = {
15440        extensionId: ext.id,
15441        spec: providerSpec,
15442        streamSimple: hasStreamSimple ? spec.streamSimple : null,
15443    };
15444    ext.providers.set(id, record);
15445    __pi_provider_index.set(id, record);
15446}
15447
15448// ============================================================================
15449// Provider Streaming (streamSimple bridge)
15450// ============================================================================
15451
15452let __pi_provider_stream_seq = 0;
15453const __pi_provider_streams = new Map(); // stream_id -> { iterator, controller }
15454
15455function __pi_make_abort_controller() {
15456    const listeners = new Set();
15457    const signal = {
15458        aborted: false,
15459        addEventListener: (type, cb) => {
15460            if (type !== 'abort') return;
15461            if (typeof cb === 'function') listeners.add(cb);
15462        },
15463        removeEventListener: (type, cb) => {
15464            if (type !== 'abort') return;
15465            listeners.delete(cb);
15466        },
15467    };
15468    return {
15469        signal,
15470        abort: () => {
15471            if (signal.aborted) return;
15472            signal.aborted = true;
15473            for (const cb of listeners) {
15474                try {
15475                    cb();
15476                } catch (_) {}
15477            }
15478        },
15479    };
15480}
15481
15482async function __pi_provider_stream_simple_start(provider_id, model, context, options) {
15483    const id = String(provider_id || '').trim();
15484    if (!id) {
15485        throw new Error('providerStreamSimple.start: provider_id is required');
15486    }
15487    const record = __pi_provider_index.get(id);
15488    if (!record) {
15489        throw new Error('providerStreamSimple.start: unknown provider: ' + id);
15490    }
15491    if (!record.streamSimple || typeof record.streamSimple !== 'function') {
15492        throw new Error('providerStreamSimple.start: provider has no streamSimple handler: ' + id);
15493    }
15494
15495    const controller = __pi_make_abort_controller();
15496    const mergedOptions = Object.assign({}, options || {}, { signal: controller.signal });
15497
15498    const stream = record.streamSimple(model, context, mergedOptions);
15499    const iterator = stream && stream[Symbol.asyncIterator] ? stream[Symbol.asyncIterator]() : stream;
15500    if (!iterator || typeof iterator.next !== 'function') {
15501        throw new Error('providerStreamSimple.start: streamSimple must return an async iterator');
15502    }
15503
15504    const stream_id = 'provider-stream-' + String(++__pi_provider_stream_seq);
15505    __pi_provider_streams.set(stream_id, { iterator, controller });
15506    return stream_id;
15507}
15508
15509async function __pi_provider_stream_simple_next(stream_id) {
15510    const id = String(stream_id || '').trim();
15511    const record = __pi_provider_streams.get(id);
15512    if (!record) {
15513        return { done: true, value: null };
15514    }
15515
15516    const result = await record.iterator.next();
15517    if (!result || result.done) {
15518        __pi_provider_streams.delete(id);
15519        return { done: true, value: null };
15520    }
15521
15522    return { done: false, value: result.value };
15523}
15524
15525async function __pi_provider_stream_simple_cancel(stream_id) {
15526    const id = String(stream_id || '').trim();
15527    const record = __pi_provider_streams.get(id);
15528    if (!record) {
15529        return false;
15530    }
15531
15532    try {
15533        record.controller.abort();
15534    } catch (_) {}
15535
15536    try {
15537        if (record.iterator && typeof record.iterator.return === 'function') {
15538            await record.iterator.return();
15539        }
15540    } catch (_) {}
15541
15542    __pi_provider_streams.delete(id);
15543    return true;
15544}
15545
15546const __pi_reserved_keys = new Set(['ctrl+c', 'ctrl+d', 'ctrl+l', 'ctrl+z']);
15547
15548function __pi_key_to_string(key) {
15549    // Convert Key object from @mariozechner/pi-tui to string format
15550    if (typeof key === 'string') {
15551        return key.toLowerCase();
15552    }
15553    if (key && typeof key === 'object') {
15554        const kind = key.kind;
15555        const k = key.key || '';
15556        if (kind === 'ctrlAlt') {
15557            return 'ctrl+alt+' + k.toLowerCase();
15558        }
15559        if (kind === 'ctrlShift') {
15560            return 'ctrl+shift+' + k.toLowerCase();
15561        }
15562        if (kind === 'ctrl') {
15563            return 'ctrl+' + k.toLowerCase();
15564        }
15565        if (kind === 'alt') {
15566            return 'alt+' + k.toLowerCase();
15567        }
15568        if (kind === 'shift') {
15569            return 'shift+' + k.toLowerCase();
15570        }
15571        // Fallback for unknown object format
15572        if (k) {
15573            return k.toLowerCase();
15574        }
15575    }
15576    return '<unknown>';
15577}
15578
15579function __pi_register_shortcut(key, spec) {
15580    const ext = __pi_current_extension_or_throw();
15581    if (!spec || typeof spec !== 'object') {
15582        throw new Error('registerShortcut: spec must be an object');
15583    }
15584    if (typeof spec.handler !== 'function') {
15585        throw new Error('registerShortcut: spec.handler must be a function');
15586    }
15587
15588    const keyId = __pi_key_to_string(key);
15589    if (__pi_reserved_keys.has(keyId)) {
15590        throw new Error('registerShortcut: key ' + keyId + ' is reserved and cannot be overridden');
15591    }
15592
15593    const record = {
15594        key: key,
15595        keyId: keyId,
15596        description: spec.description ? String(spec.description) : '',
15597        handler: spec.handler,
15598        extensionId: ext.id,
15599        spec: { shortcut: keyId, key: key, key_id: keyId, description: spec.description ? String(spec.description) : '' },
15600    };
15601    ext.shortcuts.set(keyId, record);
15602    __pi_shortcut_index.set(keyId, record);
15603}
15604
15605function __pi_register_message_renderer(customType, renderer) {
15606    const ext = __pi_current_extension_or_throw();
15607    const typeId = String(customType || '').trim();
15608    if (!typeId) {
15609        throw new Error('registerMessageRenderer: customType is required');
15610    }
15611    if (typeof renderer !== 'function') {
15612        throw new Error('registerMessageRenderer: renderer must be a function');
15613    }
15614
15615    const record = {
15616        customType: typeId,
15617        renderer: renderer,
15618        extensionId: ext.id,
15619    };
15620    ext.messageRenderers.set(typeId, record);
15621    __pi_message_renderer_index.set(typeId, record);
15622}
15623
15624	function __pi_register_hook(event_name, handler) {
15625	    const ext = __pi_current_extension_or_throw();
15626	    const eventName = String(event_name || '').trim();
15627	    if (!eventName) {
15628	        throw new Error('on: event name is required');
15629	    }
15630	    if (typeof handler !== 'function') {
15631	        throw new Error('on: handler must be a function');
15632	    }
15633
15634	    if (!ext.hooks.has(eventName)) {
15635	        ext.hooks.set(eventName, []);
15636	    }
15637	    ext.hooks.get(eventName).push(handler);
15638
15639	    if (!__pi_hook_index.has(eventName)) {
15640	        __pi_hook_index.set(eventName, []);
15641	    }
15642	    const indexed = { extensionId: ext.id, handler: handler };
15643	    __pi_hook_index.get(eventName).push(indexed);
15644
15645	    let removed = false;
15646	    return function unsubscribe() {
15647	        if (removed) return;
15648	        removed = true;
15649
15650	        const local = ext.hooks.get(eventName);
15651	        if (Array.isArray(local)) {
15652	            const idx = local.indexOf(handler);
15653	            if (idx !== -1) local.splice(idx, 1);
15654	            if (local.length === 0) ext.hooks.delete(eventName);
15655	        }
15656
15657	        const global = __pi_hook_index.get(eventName);
15658	        if (Array.isArray(global)) {
15659	            const idx = global.indexOf(indexed);
15660	            if (idx !== -1) global.splice(idx, 1);
15661	            if (global.length === 0) __pi_hook_index.delete(eventName);
15662	        }
15663	    };
15664	}
15665
15666	function __pi_register_event_bus_hook(event_name, handler) {
15667	    const ext = __pi_current_extension_or_throw();
15668	    const eventName = String(event_name || '').trim();
15669	    if (!eventName) {
15670	        throw new Error('events.on: event name is required');
15671	    }
15672	    if (typeof handler !== 'function') {
15673	        throw new Error('events.on: handler must be a function');
15674	    }
15675
15676	    if (!ext.eventBusHooks.has(eventName)) {
15677	        ext.eventBusHooks.set(eventName, []);
15678	    }
15679	    ext.eventBusHooks.get(eventName).push(handler);
15680
15681	    if (!__pi_event_bus_index.has(eventName)) {
15682	        __pi_event_bus_index.set(eventName, []);
15683	    }
15684	    const indexed = { extensionId: ext.id, handler: handler };
15685	    __pi_event_bus_index.get(eventName).push(indexed);
15686
15687	    let removed = false;
15688	    return function unsubscribe() {
15689	        if (removed) return;
15690	        removed = true;
15691
15692	        const local = ext.eventBusHooks.get(eventName);
15693	        if (Array.isArray(local)) {
15694	            const idx = local.indexOf(handler);
15695	            if (idx !== -1) local.splice(idx, 1);
15696	            if (local.length === 0) ext.eventBusHooks.delete(eventName);
15697	        }
15698
15699	        const global = __pi_event_bus_index.get(eventName);
15700	        if (Array.isArray(global)) {
15701	            const idx = global.indexOf(indexed);
15702	            if (idx !== -1) global.splice(idx, 1);
15703	            if (global.length === 0) __pi_event_bus_index.delete(eventName);
15704	        }
15705	    };
15706	}
15707
15708function __pi_register_flag(flag_name, spec) {
15709    const ext = __pi_current_extension_or_throw();
15710    const name = String(flag_name || '').trim().replace(/^\//, '');
15711    if (!name) {
15712        throw new Error('registerFlag: name is required');
15713    }
15714    if (!spec || typeof spec !== 'object') {
15715        throw new Error('registerFlag: spec must be an object');
15716    }
15717    ext.flags.set(name, spec);
15718}
15719
15720function __pi_set_flag_value(extension_id, flag_name, value) {
15721    const extId = String(extension_id || '').trim();
15722    const name = String(flag_name || '').trim().replace(/^\//, '');
15723    if (!extId || !name) return false;
15724    const ext = __pi_extensions.get(extId);
15725    if (!ext) return false;
15726    ext.flagValues.set(name, value);
15727    return true;
15728}
15729
15730function __pi_get_flag(flag_name) {
15731    const ext = __pi_current_extension_or_throw();
15732    const name = String(flag_name || '').trim().replace(/^\//, '');
15733    if (!name) return undefined;
15734    if (ext.flagValues.has(name)) {
15735        return ext.flagValues.get(name);
15736    }
15737    const spec = ext.flags.get(name);
15738    return spec ? spec.default : undefined;
15739}
15740
15741function __pi_set_active_tools(tools) {
15742    const ext = __pi_current_extension_or_throw();
15743    if (!Array.isArray(tools)) {
15744        throw new Error('setActiveTools: tools must be an array');
15745    }
15746    ext.activeTools = tools.map((t) => String(t));
15747    // Best-effort notify host; ignore completion.
15748    try {
15749        pi.events('setActiveTools', { extensionId: ext.id, tools: ext.activeTools }).catch(() => {});
15750    } catch (_) {}
15751}
15752
15753function __pi_get_active_tools() {
15754    const ext = __pi_current_extension_or_throw();
15755    if (!Array.isArray(ext.activeTools)) return undefined;
15756    return ext.activeTools.slice();
15757}
15758
15759function __pi_get_model() {
15760    return pi.events('getModel', {});
15761}
15762
15763function __pi_set_model(provider, modelId) {
15764    const p = provider != null ? String(provider) : null;
15765    const m = modelId != null ? String(modelId) : null;
15766    return pi.events('setModel', { provider: p, modelId: m });
15767}
15768
15769function __pi_get_thinking_level() {
15770    return pi.events('getThinkingLevel', {});
15771}
15772
15773function __pi_set_thinking_level(level) {
15774    const l = level != null ? String(level).trim() : null;
15775    return pi.events('setThinkingLevel', { thinkingLevel: l });
15776}
15777
15778function __pi_get_session_name() {
15779    return pi.session('get_name', {});
15780}
15781
15782function __pi_set_session_name(name) {
15783    const n = name != null ? String(name) : '';
15784    return pi.session('set_name', { name: n });
15785}
15786
15787function __pi_set_label(entryId, label) {
15788    const eid = String(entryId || '').trim();
15789    if (!eid) {
15790        throw new Error('setLabel: entryId is required');
15791    }
15792    const l = label != null ? String(label).trim() : null;
15793    return pi.session('set_label', { targetId: eid, label: l || undefined });
15794}
15795
15796function __pi_append_entry(custom_type, data) {
15797    const ext = __pi_current_extension_or_throw();
15798    const customType = String(custom_type || '').trim();
15799    if (!customType) {
15800        throw new Error('appendEntry: customType is required');
15801    }
15802    try {
15803        pi.events('appendEntry', {
15804            extensionId: ext.id,
15805            customType: customType,
15806            data: data === undefined ? null : data,
15807        }).catch(() => {});
15808    } catch (_) {}
15809}
15810
15811function __pi_send_message(message, options) {
15812    const ext = __pi_current_extension_or_throw();
15813    if (!message || typeof message !== 'object') {
15814        throw new Error('sendMessage: message must be an object');
15815    }
15816    const opts = options && typeof options === 'object' ? options : {};
15817    try {
15818        pi.events('sendMessage', { extensionId: ext.id, message: message, options: opts }).catch(() => {});
15819    } catch (_) {}
15820}
15821
15822function __pi_send_user_message(text, options) {
15823    const ext = __pi_current_extension_or_throw();
15824    const msg = String(text === undefined || text === null ? '' : text).trim();
15825    if (!msg) return;
15826    const opts = options && typeof options === 'object' ? options : {};
15827    try {
15828        pi.events('sendUserMessage', { extensionId: ext.id, text: msg, options: opts }).catch(() => {});
15829    } catch (_) {}
15830}
15831
15832function __pi_snapshot_extensions() {
15833    const out = [];
15834    for (const [id, ext] of __pi_extensions.entries()) {
15835        const tools = [];
15836        for (const tool of ext.tools.values()) {
15837            tools.push(tool.spec);
15838        }
15839
15840        const commands = [];
15841        for (const cmd of ext.commands.values()) {
15842            commands.push(cmd.spec);
15843        }
15844
15845        const providers = [];
15846        for (const provider of ext.providers.values()) {
15847            providers.push(provider.spec);
15848        }
15849
15850        const event_hooks = [];
15851        for (const key of ext.hooks.keys()) {
15852            event_hooks.push(String(key));
15853        }
15854
15855        const shortcuts = [];
15856        for (const shortcut of ext.shortcuts.values()) {
15857            shortcuts.push(shortcut.spec);
15858        }
15859
15860        const message_renderers = [];
15861        for (const renderer of ext.messageRenderers.values()) {
15862            message_renderers.push(renderer.customType);
15863        }
15864
15865        const flags = [];
15866        for (const [flagName, flagSpec] of ext.flags.entries()) {
15867            flags.push({
15868                name: flagName,
15869                description: flagSpec.description ? String(flagSpec.description) : '',
15870                type: flagSpec.type ? String(flagSpec.type) : 'string',
15871                default: flagSpec.default !== undefined ? flagSpec.default : null,
15872            });
15873        }
15874
15875        out.push({
15876            id: id,
15877            name: ext.name,
15878            version: ext.version,
15879            api_version: ext.apiVersion,
15880            tools: tools,
15881            slash_commands: commands,
15882            providers: providers,
15883            shortcuts: shortcuts,
15884            message_renderers: message_renderers,
15885            flags: flags,
15886            event_hooks: event_hooks,
15887            active_tools: Array.isArray(ext.activeTools) ? ext.activeTools.slice() : null,
15888        });
15889    }
15890    return out;
15891}
15892
15893function __pi_make_extension_theme() {
15894    return Object.create(__pi_extension_theme_template);
15895}
15896
15897const __pi_extension_theme_template = {
15898    // Minimal theme shim. Legacy emits ANSI; conformance harness should normalize ANSI away.
15899    fg: (_style, text) => String(text === undefined || text === null ? '' : text),
15900    bold: (text) => String(text === undefined || text === null ? '' : text),
15901    strikethrough: (text) => String(text === undefined || text === null ? '' : text),
15902};
15903
15904function __pi_build_extension_ui_template(hasUI) {
15905    return {
15906        select: (title, options) => {
15907            if (!hasUI) return Promise.resolve(undefined);
15908            const list = Array.isArray(options) ? options : [];
15909            const mapped = list.map((v) => String(v));
15910            return pi.ui('select', { title: String(title === undefined || title === null ? '' : title), options: mapped });
15911        },
15912        confirm: (title, message) => {
15913            if (!hasUI) return Promise.resolve(false);
15914            return pi.ui('confirm', {
15915                title: String(title === undefined || title === null ? '' : title),
15916                message: String(message === undefined || message === null ? '' : message),
15917            });
15918        },
15919        input: (title, placeholder, def) => {
15920            if (!hasUI) return Promise.resolve(undefined);
15921            // Legacy extensions typically call input(title, placeholder?, default?)
15922            let payloadDefault = def;
15923            let payloadPlaceholder = placeholder;
15924            if (def === undefined && typeof placeholder === 'string') {
15925                payloadDefault = placeholder;
15926                payloadPlaceholder = undefined;
15927            }
15928            return pi.ui('input', {
15929                title: String(title === undefined || title === null ? '' : title),
15930                placeholder: payloadPlaceholder,
15931                default: payloadDefault,
15932            });
15933        },
15934        editor: (title, def, language) => {
15935            if (!hasUI) return Promise.resolve(undefined);
15936            // Legacy extensions typically call editor(title, defaultText)
15937            return pi.ui('editor', {
15938                title: String(title === undefined || title === null ? '' : title),
15939                language: language,
15940                default: def,
15941            });
15942        },
15943        notify: (message, level) => {
15944            const notifyType = level ? String(level) : undefined;
15945            const payload = {
15946                message: String(message === undefined || message === null ? '' : message),
15947            };
15948            if (notifyType) {
15949                payload.level = notifyType;
15950                payload.notifyType = notifyType; // legacy field
15951            }
15952            void pi.ui('notify', payload).catch(() => {});
15953        },
15954        setStatus: (statusKey, statusText) => {
15955            const key = String(statusKey === undefined || statusKey === null ? '' : statusKey);
15956            const text = String(statusText === undefined || statusText === null ? '' : statusText);
15957            void pi.ui('setStatus', {
15958                statusKey: key,
15959                statusText: text,
15960                text: text, // compat: some UI surfaces only consume `text`
15961            }).catch(() => {});
15962        },
15963        setWidget: (widgetKey, lines) => {
15964            if (!hasUI) return;
15965            const payload = { widgetKey: String(widgetKey === undefined || widgetKey === null ? '' : widgetKey) };
15966            if (Array.isArray(lines)) {
15967                payload.lines = lines.map((v) => String(v));
15968                payload.widgetLines = payload.lines; // compat with pi-mono RPC naming
15969                payload.content = payload.lines.join('\n'); // compat: some UI surfaces expect a single string
15970            }
15971            void pi.ui('setWidget', payload).catch(() => {});
15972        },
15973        setTitle: (title) => {
15974            void pi.ui('setTitle', {
15975                title: String(title === undefined || title === null ? '' : title),
15976            }).catch(() => {});
15977        },
15978        setEditorText: (text) => {
15979            void pi.ui('set_editor_text', {
15980                text: String(text === undefined || text === null ? '' : text),
15981            }).catch(() => {});
15982        },
15983        custom: (_component, options) => {
15984            if (!hasUI) return Promise.resolve(undefined);
15985            const payload = options && typeof options === 'object' ? options : {};
15986            return pi.ui('custom', payload);
15987        },
15988    };
15989}
15990
15991const __pi_extension_ui_templates = {
15992    with_ui: __pi_build_extension_ui_template(true),
15993    without_ui: __pi_build_extension_ui_template(false),
15994};
15995
15996function __pi_make_extension_ui(hasUI) {
15997    const template = hasUI ? __pi_extension_ui_templates.with_ui : __pi_extension_ui_templates.without_ui;
15998    const ui = Object.create(template);
15999    ui.theme = __pi_make_extension_theme();
16000    return ui;
16001}
16002
16003function __pi_make_extension_ctx(ctx_payload) {
16004    const hasUI = !!(ctx_payload && (ctx_payload.hasUI || ctx_payload.has_ui));
16005    const cwd = ctx_payload && (ctx_payload.cwd || ctx_payload.CWD) ? String(ctx_payload.cwd || ctx_payload.CWD) : '';
16006
16007    const entriesRaw =
16008        (ctx_payload && (ctx_payload.sessionEntries || ctx_payload.session_entries || ctx_payload.entries)) || [];
16009    const branchRaw =
16010        (ctx_payload && (ctx_payload.sessionBranch || ctx_payload.session_branch || ctx_payload.branch)) || entriesRaw;
16011
16012    const entries = Array.isArray(entriesRaw) ? entriesRaw : [];
16013    const branch = Array.isArray(branchRaw) ? branchRaw : entries;
16014
16015    const leafEntry =
16016        (ctx_payload &&
16017            (ctx_payload.sessionLeafEntry ||
16018                ctx_payload.session_leaf_entry ||
16019                ctx_payload.leafEntry ||
16020                ctx_payload.leaf_entry)) ||
16021        null;
16022
16023    const modelRegistryValues =
16024        (ctx_payload && (ctx_payload.modelRegistry || ctx_payload.model_registry || ctx_payload.model_registry_values)) ||
16025        {};
16026
16027    const sessionManager = {
16028        getEntries: () => entries,
16029        getBranch: () => branch,
16030        getLeafEntry: () => leafEntry,
16031    };
16032
16033    return {
16034        hasUI: hasUI,
16035        cwd: cwd,
16036        ui: __pi_make_extension_ui(hasUI),
16037        sessionManager: sessionManager,
16038        modelRegistry: {
16039            getApiKeyForProvider: async (provider) => {
16040                const key = String(provider || '').trim();
16041                if (!key) return undefined;
16042                const value = modelRegistryValues[key];
16043                if (value === undefined || value === null) return undefined;
16044                return String(value);
16045            },
16046        },
16047    };
16048}
16049
16050	async function __pi_dispatch_event_inner(eventName, event_payload, ctx) {
16051	    const handlers = [
16052	        ...(__pi_hook_index.get(eventName) || []),
16053	        ...(__pi_event_bus_index.get(eventName) || []),
16054	    ];
16055	    if (handlers.length === 0) {
16056	        return undefined;
16057	    }
16058
16059	    if (eventName === 'input') {
16060	        const base = event_payload && typeof event_payload === 'object' ? event_payload : {};
16061	        const originalText = typeof base.text === 'string' ? base.text : String(base.text ?? '');
16062	        const originalImages = Array.isArray(base.images) ? base.images : undefined;
16063	        const source = base.source !== undefined ? base.source : 'extension';
16064
16065        let currentText = originalText;
16066        let currentImages = originalImages;
16067
16068	        for (const entry of handlers) {
16069	            const handler = entry && entry.handler;
16070	            if (typeof handler !== 'function') continue;
16071	            const event = { type: 'input', text: currentText, images: currentImages, source: source };
16072	            let result = undefined;
16073	            try {
16074	                result = await __pi_with_extension_async(entry.extensionId, () => handler(event, ctx));
16075	            } catch (e) {
16076		                try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
16077		                continue;
16078		            }
16079	            if (result && typeof result === 'object') {
16080	                if (result.action === 'handled') return result;
16081	                if (result.action === 'transform' && typeof result.text === 'string') {
16082	                    currentText = result.text;
16083	                    if (result.images !== undefined) currentImages = result.images;
16084                }
16085            }
16086        }
16087
16088        if (currentText !== originalText || currentImages !== originalImages) {
16089            return { action: 'transform', text: currentText, images: currentImages };
16090        }
16091        return { action: 'continue' };
16092    }
16093
16094	    if (eventName === 'before_agent_start') {
16095	        const base = event_payload && typeof event_payload === 'object' ? event_payload : {};
16096	        const prompt = typeof base.prompt === 'string' ? base.prompt : '';
16097	        const images = Array.isArray(base.images) ? base.images : undefined;
16098	        let currentSystemPrompt = typeof base.systemPrompt === 'string' ? base.systemPrompt : '';
16099	        let modified = false;
16100	        const messages = [];
16101
16102	        for (const entry of handlers) {
16103	            const handler = entry && entry.handler;
16104	            if (typeof handler !== 'function') continue;
16105	            const event = { type: 'before_agent_start', prompt, images, systemPrompt: currentSystemPrompt };
16106	            let result = undefined;
16107	            try {
16108	                result = await __pi_with_extension_async(entry.extensionId, () => handler(event, ctx));
16109	            } catch (e) {
16110		                try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
16111		                continue;
16112		            }
16113	            if (result && typeof result === 'object') {
16114	                if (result.message !== undefined) messages.push(result.message);
16115	                if (result.systemPrompt !== undefined) {
16116	                    currentSystemPrompt = String(result.systemPrompt);
16117	                    modified = true;
16118                }
16119            }
16120        }
16121
16122        if (messages.length > 0 || modified) {
16123            return { messages: messages.length > 0 ? messages : undefined, systemPrompt: modified ? currentSystemPrompt : undefined };
16124        }
16125        return undefined;
16126    }
16127
16128	    let last = undefined;
16129	    for (const entry of handlers) {
16130	        const handler = entry && entry.handler;
16131	        if (typeof handler !== 'function') continue;
16132	        let value = undefined;
16133	        try {
16134	            value = await __pi_with_extension_async(entry.extensionId, () => handler(event_payload, ctx));
16135	        } catch (e) {
16136		            try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
16137		            continue;
16138		        }
16139	        if (value === undefined) continue;
16140
16141        // First-result semantics (legacy parity)
16142        if (eventName === 'user_bash') {
16143            return value;
16144        }
16145
16146        last = value;
16147
16148        // Early-stop semantics (legacy parity)
16149        if (eventName === 'tool_call' && value && typeof value === 'object' && value.block) {
16150            return value;
16151        }
16152        if (eventName.startsWith('session_before_') && value && typeof value === 'object' && value.cancel) {
16153            return value;
16154        }
16155    }
16156    return last;
16157}
16158
16159	async function __pi_dispatch_extension_event(event_name, event_payload, ctx_payload) {
16160	    const eventName = String(event_name || '').trim();
16161	    if (!eventName) {
16162	        throw new Error('dispatch_event: event name is required');
16163	    }
16164	    const ctx = __pi_make_extension_ctx(ctx_payload);
16165	    return __pi_dispatch_event_inner(eventName, event_payload, ctx);
16166	}
16167
16168	async function __pi_dispatch_extension_events_batch(events_json, ctx_payload) {
16169	    const ctx = __pi_make_extension_ctx(ctx_payload);
16170	    const results = [];
16171	    for (const entry of events_json) {
16172	        const eventName = String(entry.event_name || '').trim();
16173	        if (!eventName) continue;
16174	        try {
16175	            const value = await __pi_dispatch_event_inner(eventName, entry.event_payload, ctx);
16176	            results.push({ event: eventName, ok: true, value: value });
16177	        } catch (e) {
16178	            results.push({ event: eventName, ok: false, error: String(e) });
16179	        }
16180	    }
16181	    return results;
16182	}
16183
16184async function __pi_execute_tool(tool_name, tool_call_id, input, ctx_payload) {
16185    const name = String(tool_name || '').trim();
16186    const record = __pi_tool_index.get(name);
16187    if (!record) {
16188        throw new Error(`Unknown tool: ${name}`);
16189    }
16190
16191    const ctx = __pi_make_extension_ctx(ctx_payload);
16192    return __pi_with_extension_async(record.extensionId, () =>
16193        record.execute(tool_call_id, input, undefined, undefined, ctx)
16194    );
16195}
16196
16197async function __pi_execute_command(command_name, args, ctx_payload) {
16198    const name = String(command_name || '').trim().replace(/^\//, '');
16199    const record = __pi_command_index.get(name);
16200    if (!record) {
16201        throw new Error(`Unknown command: ${name}`);
16202    }
16203
16204    const ctx = __pi_make_extension_ctx(ctx_payload);
16205    return __pi_with_extension_async(record.extensionId, () => record.handler(args, ctx));
16206}
16207
16208async function __pi_execute_shortcut(key_id, ctx_payload) {
16209    const id = String(key_id || '').trim().toLowerCase();
16210    const record = __pi_shortcut_index.get(id);
16211    if (!record) {
16212        throw new Error('Unknown shortcut: ' + id);
16213    }
16214
16215    const ctx = __pi_make_extension_ctx(ctx_payload);
16216    return __pi_with_extension_async(record.extensionId, () => record.handler(ctx));
16217}
16218
16219// Hostcall stream class (async iterator for streaming hostcall results)
16220class __pi_HostcallStream {
16221    constructor(callId) {
16222        this.callId = callId;
16223        this.buffer = [];
16224        this.waitResolve = null;
16225        this.done = false;
16226    }
16227    pushChunk(chunk, isFinal) {
16228        if (isFinal) this.done = true;
16229        if (this.waitResolve) {
16230            const resolve = this.waitResolve;
16231            this.waitResolve = null;
16232            if (isFinal && chunk === null) {
16233                resolve({ value: undefined, done: true });
16234            } else {
16235                resolve({ value: chunk, done: false });
16236            }
16237        } else {
16238            this.buffer.push({ chunk, isFinal });
16239        }
16240    }
16241    pushError(error) {
16242        this.done = true;
16243        if (this.waitResolve) {
16244            const rej = this.waitResolve;
16245            this.waitResolve = null;
16246            rej({ __error: error });
16247        } else {
16248            this.buffer.push({ __error: error });
16249        }
16250    }
16251    next() {
16252        if (this.buffer.length > 0) {
16253            const entry = this.buffer.shift();
16254            if (entry.__error) return Promise.reject(entry.__error);
16255            if (entry.isFinal && entry.chunk === null) return Promise.resolve({ value: undefined, done: true });
16256            return Promise.resolve({ value: entry.chunk, done: false });
16257        }
16258        if (this.done) return Promise.resolve({ value: undefined, done: true });
16259        return new Promise((resolve, reject) => {
16260            this.waitResolve = (result) => {
16261                if (result && result.__error) reject(result.__error);
16262                else resolve(result);
16263            };
16264        });
16265    }
16266    return() {
16267        this.done = true;
16268        this.buffer = [];
16269        this.waitResolve = null;
16270        return Promise.resolve({ value: undefined, done: true });
16271    }
16272    [Symbol.asyncIterator]() { return this; }
16273}
16274
16275// Complete a hostcall (called from Rust)
16276function __pi_complete_hostcall_impl(call_id, outcome) {
16277    const pending = __pi_pending_hostcalls.get(call_id);
16278    if (!pending) return;
16279
16280    if (outcome.stream) {
16281        const seq = Number(outcome.sequence);
16282        if (!Number.isFinite(seq)) {
16283            const error = new Error('Invalid stream sequence');
16284            error.code = 'STREAM_SEQUENCE';
16285            if (pending.stream) pending.stream.pushError(error);
16286            else if (pending.reject) pending.reject(error);
16287            __pi_pending_hostcalls.delete(call_id);
16288            return;
16289        }
16290        if (pending.lastSeq === undefined) {
16291            if (seq !== 0) {
16292                const error = new Error('Stream sequence must start at 0');
16293                error.code = 'STREAM_SEQUENCE';
16294                if (pending.stream) pending.stream.pushError(error);
16295                else if (pending.reject) pending.reject(error);
16296                __pi_pending_hostcalls.delete(call_id);
16297                return;
16298            }
16299        } else if (seq <= pending.lastSeq) {
16300            const error = new Error('Stream sequence out of order');
16301            error.code = 'STREAM_SEQUENCE';
16302            if (pending.stream) pending.stream.pushError(error);
16303            else if (pending.reject) pending.reject(error);
16304            __pi_pending_hostcalls.delete(call_id);
16305            return;
16306        }
16307        pending.lastSeq = seq;
16308
16309        if (pending.stream) {
16310            pending.stream.pushChunk(outcome.chunk, outcome.isFinal);
16311        } else if (pending.onChunk) {
16312            const chunk = outcome.chunk;
16313            const isFinal = outcome.isFinal;
16314            Promise.resolve().then(() => {
16315                try {
16316                    pending.onChunk(chunk, isFinal);
16317                } catch (e) {
16318                    console.error('Hostcall onChunk error:', e);
16319                }
16320            });
16321        }
16322        if (outcome.isFinal) {
16323            __pi_pending_hostcalls.delete(call_id);
16324            if (pending.resolve) pending.resolve(outcome.chunk);
16325        }
16326        return;
16327    }
16328
16329    if (!outcome.ok && pending.stream) {
16330        const error = new Error(outcome.message);
16331        error.code = outcome.code;
16332        pending.stream.pushError(error);
16333        __pi_pending_hostcalls.delete(call_id);
16334        return;
16335    }
16336
16337    __pi_pending_hostcalls.delete(call_id);
16338    if (outcome.ok) {
16339        pending.resolve(outcome.value);
16340    } else {
16341        const error = new Error(outcome.message);
16342        error.code = outcome.code;
16343        pending.reject(error);
16344    }
16345}
16346
16347function __pi_complete_hostcall(call_id, outcome) {
16348    const pending = __pi_pending_hostcalls.get(call_id);
16349    if (pending && pending.extensionId) {
16350        const prev = __pi_current_extension_id;
16351        __pi_current_extension_id = pending.extensionId;
16352        try {
16353            return __pi_complete_hostcall_impl(call_id, outcome);
16354        } finally {
16355            Promise.resolve().then(() => { __pi_current_extension_id = prev; });
16356        }
16357    }
16358    return __pi_complete_hostcall_impl(call_id, outcome);
16359}
16360
16361// Fire a timer callback (called from Rust)
16362function __pi_fire_timer(timer_id) {
16363    const callback = __pi_timer_callbacks.get(timer_id);
16364    if (callback) {
16365        __pi_timer_callbacks.delete(timer_id);
16366        try {
16367            callback();
16368        } catch (e) {
16369            console.error('Timer callback error:', e);
16370        }
16371    }
16372}
16373
16374// Dispatch an inbound event (called from Rust)
16375function __pi_dispatch_event(event_id, payload) {
16376    const listeners = __pi_event_listeners.get(event_id);
16377    if (listeners) {
16378        for (const listener of listeners) {
16379            try {
16380                listener(payload);
16381            } catch (e) {
16382                console.error('Event listener error:', e);
16383            }
16384        }
16385    }
16386}
16387
16388// Register a timer callback (used by setTimeout)
16389function __pi_register_timer(timer_id, callback) {
16390    __pi_timer_callbacks.set(timer_id, callback);
16391}
16392
16393// Unregister a timer callback (used by clearTimeout)
16394function __pi_unregister_timer(timer_id) {
16395    __pi_timer_callbacks.delete(timer_id);
16396}
16397
16398// Add an event listener
16399function __pi_add_event_listener(event_id, callback) {
16400    if (!__pi_event_listeners.has(event_id)) {
16401        __pi_event_listeners.set(event_id, []);
16402    }
16403    __pi_event_listeners.get(event_id).push(callback);
16404}
16405
16406// Remove an event listener
16407function __pi_remove_event_listener(event_id, callback) {
16408    const listeners = __pi_event_listeners.get(event_id);
16409    if (listeners) {
16410        const index = listeners.indexOf(callback);
16411        if (index !== -1) {
16412            listeners.splice(index, 1);
16413        }
16414    }
16415}
16416
16417// Helper to create a Promise-returning hostcall wrapper
16418function __pi_make_hostcall(nativeFn) {
16419    return function(...args) {
16420        return new Promise((resolve, reject) => {
16421            const call_id = nativeFn(...args);
16422            __pi_pending_hostcalls.set(call_id, {
16423                resolve,
16424                reject,
16425                extensionId: __pi_current_extension_id
16426            });
16427        });
16428    };
16429}
16430
16431function __pi_make_streaming_hostcall(nativeFn, ...args) {
16432    const call_id = nativeFn(...args);
16433    const stream = new __pi_HostcallStream(call_id);
16434    __pi_pending_hostcalls.set(call_id, {
16435        stream,
16436        resolve: () => {},
16437        reject: () => {},
16438        extensionId: __pi_current_extension_id
16439    });
16440    return stream;
16441}
16442
16443function __pi_env_get(key) {
16444    const value = __pi_env_get_native(key);
16445    if (value === null || value === undefined) {
16446        return undefined;
16447    }
16448    return value;
16449}
16450
16451function __pi_path_join(...parts) {
16452    let out = '';
16453    for (const part of parts) {
16454        if (!part) continue;
16455        if (out === '' || out.endsWith('/')) {
16456            out += part;
16457        } else {
16458            out += '/' + part;
16459        }
16460    }
16461    return __pi_path_normalize(out);
16462}
16463
16464function __pi_path_basename(path) {
16465    if (!path) return '';
16466    let p = path;
16467    while (p.length > 1 && p.endsWith('/')) {
16468        p = p.slice(0, -1);
16469    }
16470    const idx = p.lastIndexOf('/');
16471    return idx === -1 ? p : p.slice(idx + 1);
16472}
16473
16474function __pi_path_normalize(path) {
16475    if (!path) return '';
16476    const isAbs = path.startsWith('/');
16477    const parts = path.split('/').filter(p => p.length > 0);
16478    const stack = [];
16479    for (const part of parts) {
16480        if (part === '.') continue;
16481        if (part === '..') {
16482            if (stack.length > 0 && stack[stack.length - 1] !== '..') {
16483                stack.pop();
16484            } else if (!isAbs) {
16485                stack.push('..');
16486            }
16487            continue;
16488        }
16489        stack.push(part);
16490    }
16491    const joined = stack.join('/');
16492    return isAbs ? '/' + joined : joined || (isAbs ? '/' : '');
16493}
16494
16495function __pi_sleep(ms) {
16496    return new Promise((resolve) => setTimeout(resolve, ms));
16497}
16498
16499// Create the pi global object with Promise-returning methods
16500const __pi_exec_hostcall = __pi_make_hostcall(__pi_exec_native);
16501	const pi = {
16502    // pi.tool(name, input) - invoke a tool
16503    tool: __pi_make_hostcall(__pi_tool_native),
16504
16505    // pi.exec(cmd, args, options) - execute a shell command
16506    exec: (cmd, args, options = {}) => {
16507        if (options && options.stream) {
16508            const onChunk =
16509                options && typeof options === 'object'
16510                    ? (options.onChunk || options.on_chunk)
16511                    : undefined;
16512            if (typeof onChunk === 'function') {
16513                const opts = Object.assign({}, options);
16514                delete opts.onChunk;
16515                delete opts.on_chunk;
16516                const call_id = __pi_exec_native(cmd, args, opts);
16517                return new Promise((resolve, reject) => {
16518                    __pi_pending_hostcalls.set(call_id, { onChunk, resolve, reject, extensionId: __pi_current_extension_id });
16519                });
16520            }
16521            return __pi_make_streaming_hostcall(__pi_exec_native, cmd, args, options);
16522        }
16523        return __pi_exec_hostcall(cmd, args, options);
16524    },
16525
16526    // pi.http(request) - make an HTTP request
16527    http: (request) => {
16528        if (request && request.stream) {
16529            const onChunk =
16530                request && typeof request === 'object'
16531                    ? (request.onChunk || request.on_chunk)
16532                    : undefined;
16533            if (typeof onChunk === 'function') {
16534                const req = Object.assign({}, request);
16535                delete req.onChunk;
16536                delete req.on_chunk;
16537                const call_id = __pi_http_native(req);
16538                return new Promise((resolve, reject) => {
16539                    __pi_pending_hostcalls.set(call_id, { onChunk, resolve, reject, extensionId: __pi_current_extension_id });
16540                });
16541            }
16542            return __pi_make_streaming_hostcall(__pi_http_native, request);
16543        }
16544        return __pi_make_hostcall(__pi_http_native)(request);
16545    },
16546
16547    // pi.session(op, args) - session operations
16548    session: __pi_make_hostcall(__pi_session_native),
16549
16550    // pi.ui(op, args) - UI operations
16551    ui: __pi_make_hostcall(__pi_ui_native),
16552
16553	    // pi.events(op, args) - event operations
16554	    events: __pi_make_hostcall(__pi_events_native),
16555
16556    // pi.log(entry) - structured log emission
16557    log: __pi_make_hostcall(__pi_log_native),
16558
16559    // Extension API (legacy-compatible subset)
16560    registerTool: __pi_register_tool,
16561    registerCommand: __pi_register_command,
16562    registerProvider: __pi_register_provider,
16563    registerShortcut: __pi_register_shortcut,
16564    registerMessageRenderer: __pi_register_message_renderer,
16565    on: __pi_register_hook,
16566    registerFlag: __pi_register_flag,
16567    getFlag: __pi_get_flag,
16568    setActiveTools: __pi_set_active_tools,
16569    getActiveTools: __pi_get_active_tools,
16570    getModel: __pi_get_model,
16571    setModel: __pi_set_model,
16572    getThinkingLevel: __pi_get_thinking_level,
16573    setThinkingLevel: __pi_set_thinking_level,
16574    appendEntry: __pi_append_entry,
16575	    sendMessage: __pi_send_message,
16576	    sendUserMessage: __pi_send_user_message,
16577	    getSessionName: __pi_get_session_name,
16578	    setSessionName: __pi_set_session_name,
16579	    setLabel: __pi_set_label,
16580	};
16581
16582	// Convenience API: pi.events.emit/on (inter-extension bus).
16583	// Keep pi.events callable for legacy hostcall operations.
16584	pi.events.emit = (event, data, options = undefined) => {
16585	    const name = String(event || '').trim();
16586	    if (!name) {
16587	        throw new Error('events.emit: event name is required');
16588	    }
16589	    const payload = { event: name, data: (data === undefined ? null : data) };
16590	    if (options && typeof options === 'object') {
16591	        if (options.ctx !== undefined) payload.ctx = options.ctx;
16592	        if (options.timeout_ms !== undefined) payload.timeout_ms = options.timeout_ms;
16593	        if (options.timeoutMs !== undefined) payload.timeoutMs = options.timeoutMs;
16594	        if (options.timeout !== undefined) payload.timeout = options.timeout;
16595	    }
16596	    return pi.events('emit', payload);
16597	};
16598	pi.events.on = (event, handler) => __pi_register_event_bus_hook(event, handler);
16599
16600	pi.env = {
16601	    get: __pi_env_get,
16602	};
16603
16604pi.process = {
16605    cwd: __pi_process_cwd_native(),
16606    args: __pi_process_args_native(),
16607};
16608
16609const __pi_det_cwd = __pi_env_get('PI_DETERMINISTIC_CWD');
16610if (__pi_det_cwd) {
16611    try { pi.process.cwd = __pi_det_cwd; } catch (_) {}
16612}
16613
16614try { Object.freeze(pi.process.args); } catch (_) {}
16615try { Object.freeze(pi.process); } catch (_) {}
16616
16617pi.path = {
16618    join: __pi_path_join,
16619    basename: __pi_path_basename,
16620    normalize: __pi_path_normalize,
16621};
16622
16623function __pi_crypto_bytes_to_array(raw) {
16624    if (raw == null) return [];
16625    if (Array.isArray(raw)) {
16626        return raw.map((value) => Number(value) & 0xff);
16627    }
16628    if (raw instanceof Uint8Array) {
16629        return Array.from(raw, (value) => Number(value) & 0xff);
16630    }
16631    if (raw instanceof ArrayBuffer) {
16632        return Array.from(new Uint8Array(raw), (value) => Number(value) & 0xff);
16633    }
16634    if (typeof raw === 'string') {
16635        // Depending on bridge coercion, bytes may arrive as:
16636        // 1) hex text (2 chars per byte), or 2) latin1-style binary string.
16637        const isHex = raw.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(raw);
16638        if (isHex) {
16639            const out = [];
16640            for (let i = 0; i + 1 < raw.length; i += 2) {
16641                const byte = Number.parseInt(raw.slice(i, i + 2), 16);
16642                out.push(Number.isFinite(byte) ? (byte & 0xff) : 0);
16643            }
16644            return out;
16645        }
16646        const out = new Array(raw.length);
16647        for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i) & 0xff;
16648        return out;
16649    }
16650    if (typeof raw.length === 'number') {
16651        const len = Number(raw.length) || 0;
16652        const out = new Array(len);
16653        for (let i = 0; i < len; i++) out[i] = Number(raw[i] || 0) & 0xff;
16654        return out;
16655    }
16656    return [];
16657}
16658
16659pi.crypto = {
16660    sha256Hex: __pi_crypto_sha256_hex_native,
16661    randomBytes: function(n) {
16662        return __pi_crypto_bytes_to_array(__pi_crypto_random_bytes_native(n));
16663    },
16664};
16665
16666pi.time = {
16667    nowMs: __pi_now_ms_native,
16668    sleep: __pi_sleep,
16669};
16670
16671// Make pi available globally
16672globalThis.pi = pi;
16673
16674const __pi_det_time_raw = __pi_env_get('PI_DETERMINISTIC_TIME_MS');
16675const __pi_det_time_step_raw = __pi_env_get('PI_DETERMINISTIC_TIME_STEP_MS');
16676const __pi_det_random_raw = __pi_env_get('PI_DETERMINISTIC_RANDOM');
16677const __pi_det_random_seed_raw = __pi_env_get('PI_DETERMINISTIC_RANDOM_SEED');
16678
16679if (__pi_det_time_raw !== undefined) {
16680    const __pi_det_base = Number(__pi_det_time_raw);
16681    if (Number.isFinite(__pi_det_base)) {
16682        const __pi_det_step = (() => {
16683            if (__pi_det_time_step_raw === undefined) return 1;
16684            const value = Number(__pi_det_time_step_raw);
16685            return Number.isFinite(value) ? value : 1;
16686        })();
16687        let __pi_det_tick = 0;
16688        const __pi_det_now = () => {
16689            const value = __pi_det_base + (__pi_det_step * __pi_det_tick);
16690            __pi_det_tick += 1;
16691            return value;
16692        };
16693
16694        if (pi && pi.time) {
16695            pi.time.nowMs = () => __pi_det_now();
16696        }
16697
16698        const __pi_OriginalDate = Date;
16699        class PiDeterministicDate extends __pi_OriginalDate {
16700            constructor(...args) {
16701                if (args.length === 0) {
16702                    super(__pi_det_now());
16703                } else {
16704                    super(...args);
16705                }
16706            }
16707            static now() {
16708                return __pi_det_now();
16709            }
16710        }
16711        PiDeterministicDate.UTC = __pi_OriginalDate.UTC;
16712        PiDeterministicDate.parse = __pi_OriginalDate.parse;
16713        globalThis.Date = PiDeterministicDate;
16714    }
16715}
16716
16717if (__pi_det_random_raw !== undefined) {
16718    const __pi_det_random_val = Number(__pi_det_random_raw);
16719    if (Number.isFinite(__pi_det_random_val)) {
16720        Math.random = () => __pi_det_random_val;
16721    }
16722} else if (__pi_det_random_seed_raw !== undefined) {
16723    let __pi_det_state = Number(__pi_det_random_seed_raw);
16724    if (Number.isFinite(__pi_det_state)) {
16725        __pi_det_state = __pi_det_state >>> 0;
16726        Math.random = () => {
16727            __pi_det_state = (__pi_det_state * 1664525 + 1013904223) >>> 0;
16728            return __pi_det_state / 4294967296;
16729        };
16730    }
16731}
16732
16733// ============================================================================
16734// Minimal Web/Node polyfills for legacy extensions (best-effort)
16735// ============================================================================
16736
16737if (typeof globalThis.btoa !== 'function') {
16738    globalThis.btoa = (s) => {
16739        const bin = String(s === undefined || s === null ? '' : s);
16740        return __pi_base64_encode_native(bin);
16741    };
16742}
16743
16744if (typeof globalThis.atob !== 'function') {
16745    globalThis.atob = (s) => {
16746        const b64 = String(s === undefined || s === null ? '' : s);
16747        return __pi_base64_decode_native(b64);
16748    };
16749}
16750
16751if (typeof globalThis.TextEncoder === 'undefined') {
16752    class TextEncoder {
16753        encode(input) {
16754            const s = String(input === undefined || input === null ? '' : input);
16755            const bytes = [];
16756            for (let i = 0; i < s.length; i++) {
16757                let code = s.charCodeAt(i);
16758                if (code < 0x80) {
16759                    bytes.push(code);
16760                    continue;
16761                }
16762                if (code < 0x800) {
16763                    bytes.push(0xc0 | (code >> 6));
16764                    bytes.push(0x80 | (code & 0x3f));
16765                    continue;
16766                }
16767                if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
16768                    const next = s.charCodeAt(i + 1);
16769                    if (next >= 0xdc00 && next <= 0xdfff) {
16770                        const cp = ((code - 0xd800) << 10) + (next - 0xdc00) + 0x10000;
16771                        bytes.push(0xf0 | (cp >> 18));
16772                        bytes.push(0x80 | ((cp >> 12) & 0x3f));
16773                        bytes.push(0x80 | ((cp >> 6) & 0x3f));
16774                        bytes.push(0x80 | (cp & 0x3f));
16775                        i++;
16776                        continue;
16777                    }
16778                }
16779                bytes.push(0xe0 | (code >> 12));
16780                bytes.push(0x80 | ((code >> 6) & 0x3f));
16781                bytes.push(0x80 | (code & 0x3f));
16782            }
16783            return new Uint8Array(bytes);
16784        }
16785    }
16786    globalThis.TextEncoder = TextEncoder;
16787}
16788
16789if (typeof globalThis.TextDecoder === 'undefined') {
16790    class TextDecoder {
16791        constructor(encoding = 'utf-8') {
16792            this.encoding = encoding;
16793        }
16794
16795        decode(input, _opts) {
16796            if (input === undefined || input === null) return '';
16797            if (typeof input === 'string') return input;
16798
16799            let bytes;
16800            if (input instanceof ArrayBuffer) {
16801                bytes = new Uint8Array(input);
16802            } else if (ArrayBuffer.isView && ArrayBuffer.isView(input)) {
16803                bytes = new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
16804            } else if (Array.isArray(input)) {
16805                bytes = new Uint8Array(input);
16806            } else if (typeof input.length === 'number') {
16807                bytes = new Uint8Array(input);
16808            } else {
16809                return '';
16810            }
16811
16812            let out = '';
16813            for (let i = 0; i < bytes.length; ) {
16814                const b0 = bytes[i++];
16815                if (b0 < 0x80) {
16816                    out += String.fromCharCode(b0);
16817                    continue;
16818                }
16819                if ((b0 & 0xe0) === 0xc0) {
16820                    const b1 = bytes[i++] & 0x3f;
16821                    out += String.fromCharCode(((b0 & 0x1f) << 6) | b1);
16822                    continue;
16823                }
16824                if ((b0 & 0xf0) === 0xe0) {
16825                    const b1 = bytes[i++] & 0x3f;
16826                    const b2 = bytes[i++] & 0x3f;
16827                    out += String.fromCharCode(((b0 & 0x0f) << 12) | (b1 << 6) | b2);
16828                    continue;
16829                }
16830                if ((b0 & 0xf8) === 0xf0) {
16831                    const b1 = bytes[i++] & 0x3f;
16832                    const b2 = bytes[i++] & 0x3f;
16833                    const b3 = bytes[i++] & 0x3f;
16834                    let cp = ((b0 & 0x07) << 18) | (b1 << 12) | (b2 << 6) | b3;
16835                    cp -= 0x10000;
16836                    out += String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
16837                    continue;
16838                }
16839            }
16840            return out;
16841        }
16842    }
16843
16844    globalThis.TextDecoder = TextDecoder;
16845}
16846
16847// structuredClone — deep clone using JSON round-trip
16848if (typeof globalThis.structuredClone === 'undefined') {
16849    globalThis.structuredClone = (value) => JSON.parse(JSON.stringify(value));
16850}
16851
16852// queueMicrotask — schedule a microtask
16853if (typeof globalThis.queueMicrotask === 'undefined') {
16854    globalThis.queueMicrotask = (fn) => Promise.resolve().then(fn);
16855}
16856
16857// performance.now() — high-resolution timer
16858if (typeof globalThis.performance === 'undefined') {
16859    const start = Date.now();
16860    globalThis.performance = { now: () => Date.now() - start, timeOrigin: start };
16861}
16862
16863if (typeof globalThis.URLSearchParams === 'undefined') {
16864    class URLSearchParams {
16865        constructor(init) {
16866            this._pairs = [];
16867            if (typeof init === 'string') {
16868                const s = init.replace(/^\?/, '');
16869                if (s.length > 0) {
16870                    for (const part of s.split('&')) {
16871                        const idx = part.indexOf('=');
16872                        if (idx === -1) {
16873                            this.append(decodeURIComponent(part), '');
16874                        } else {
16875                            const k = part.slice(0, idx);
16876                            const v = part.slice(idx + 1);
16877                            this.append(decodeURIComponent(k), decodeURIComponent(v));
16878                        }
16879                    }
16880                }
16881            } else if (Array.isArray(init)) {
16882                for (const entry of init) {
16883                    if (!entry) continue;
16884                    this.append(entry[0], entry[1]);
16885                }
16886            } else if (init && typeof init === 'object') {
16887                for (const k of Object.keys(init)) {
16888                    this.append(k, init[k]);
16889                }
16890            }
16891        }
16892
16893        append(key, value) {
16894            this._pairs.push([String(key), String(value)]);
16895        }
16896
16897        toString() {
16898            const out = [];
16899            for (const [k, v] of this._pairs) {
16900                out.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
16901            }
16902            return out.join('&');
16903        }
16904    }
16905
16906    globalThis.URLSearchParams = URLSearchParams;
16907}
16908
16909if (typeof globalThis.URL === 'undefined') {
16910    class URL {
16911        constructor(input, base) {
16912            const s = base ? new URL(base).href.replace(/\/[^/]*$/, '/') + String(input ?? '') : String(input ?? '');
16913            const m = s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]*)([^?#]*)(\?[^#]*)?(#.*)?$/);
16914            if (m) {
16915                this.protocol = m[1] + ':';
16916                const auth = m[2];
16917                const atIdx = auth.lastIndexOf('@');
16918                if (atIdx !== -1) {
16919                    const userinfo = auth.slice(0, atIdx);
16920                    const ci = userinfo.indexOf(':');
16921                    this.username = ci === -1 ? userinfo : userinfo.slice(0, ci);
16922                    this._pw = ci === -1 ? String() : userinfo.slice(ci + 1);
16923                    this.host = auth.slice(atIdx + 1);
16924                } else {
16925                    this.username = '';
16926                    this._pw = String();
16927                    this.host = auth;
16928                }
16929                const hi = this.host.indexOf(':');
16930                this.hostname = hi === -1 ? this.host : this.host.slice(0, hi);
16931                this.port = hi === -1 ? '' : this.host.slice(hi + 1);
16932                this.pathname = m[3] || '/';
16933                this.search = m[4] || '';
16934                this.hash = m[5] || '';
16935            } else {
16936                this.protocol = '';
16937                this.username = '';
16938                this._pw = String();
16939                this.host = '';
16940                this.hostname = '';
16941                this.port = '';
16942                this.pathname = s;
16943                this.search = '';
16944                this.hash = '';
16945            }
16946            this.searchParams = new globalThis.URLSearchParams(this.search.replace(/^\?/, ''));
16947            this.origin = this.protocol ? `${this.protocol}//${this.host}` : '';
16948            this.href = this.toString();
16949        }
16950        get password() {
16951            return this._pw;
16952        }
16953        set password(value) {
16954            this._pw = value == null ? String() : String(value);
16955        }
16956        toString() {
16957            const auth = this.username ? `${this.username}${this.password ? ':' + this.password : ''}@` : '';
16958            return this.protocol ? `${this.protocol}//${auth}${this.host}${this.pathname}${this.search}${this.hash}` : this.pathname;
16959        }
16960        toJSON() { return this.toString(); }
16961    }
16962    globalThis.URL = URL;
16963}
16964
16965if (typeof globalThis.Buffer === 'undefined') {
16966    class Buffer extends Uint8Array {
16967        static from(input, encoding) {
16968            if (typeof input === 'string') {
16969                const enc = String(encoding || '').toLowerCase();
16970                if (enc === 'base64') {
16971                    const bin = __pi_base64_decode_native(input);
16972                    const out = new Buffer(bin.length);
16973                    for (let i = 0; i < bin.length; i++) {
16974                        out[i] = bin.charCodeAt(i) & 0xff;
16975                    }
16976                    return out;
16977                }
16978                if (enc === 'hex') {
16979                    const hex = input.replace(/[^0-9a-fA-F]/g, '');
16980                    const out = new Buffer(hex.length >> 1);
16981                    for (let i = 0; i < out.length; i++) {
16982                        out[i] = parseInt(hex.substr(i * 2, 2), 16);
16983                    }
16984                    return out;
16985                }
16986                const encoded = new TextEncoder().encode(input);
16987                const out = new Buffer(encoded.length);
16988                out.set(encoded);
16989                return out;
16990            }
16991            if (input instanceof ArrayBuffer) {
16992                const out = new Buffer(input.byteLength);
16993                out.set(new Uint8Array(input));
16994                return out;
16995            }
16996            if (ArrayBuffer.isView && ArrayBuffer.isView(input)) {
16997                const out = new Buffer(input.byteLength);
16998                out.set(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
16999                return out;
17000            }
17001            if (Array.isArray(input)) {
17002                const out = new Buffer(input.length);
17003                for (let i = 0; i < input.length; i++) out[i] = input[i] & 0xff;
17004                return out;
17005            }
17006            throw new Error('Buffer.from: unsupported input');
17007        }
17008        static alloc(size, fill) {
17009            const buf = new Buffer(size);
17010            if (fill !== undefined) buf.fill(typeof fill === 'number' ? fill : 0);
17011            return buf;
17012        }
17013        static allocUnsafe(size) { return new Buffer(size); }
17014        static isBuffer(obj) { return obj instanceof Buffer; }
17015        static isEncoding(enc) {
17016            return ['utf8','utf-8','ascii','latin1','binary','base64','hex','ucs2','ucs-2','utf16le','utf-16le'].includes(String(enc).toLowerCase());
17017        }
17018        static byteLength(str, encoding) {
17019            if (typeof str !== 'string') return str.length || 0;
17020            const enc = String(encoding || 'utf8').toLowerCase();
17021            if (enc === 'base64') return Math.ceil(str.length * 3 / 4);
17022            if (enc === 'hex') return str.length >> 1;
17023            return new TextEncoder().encode(str).length;
17024        }
17025        static concat(list, totalLength) {
17026            if (!Array.isArray(list) || list.length === 0) return Buffer.alloc(0);
17027            const total = totalLength !== undefined ? totalLength : list.reduce((s, b) => s + b.length, 0);
17028            const out = Buffer.alloc(total);
17029            let offset = 0;
17030            for (const buf of list) {
17031                if (offset >= total) break;
17032                const src = buf instanceof Uint8Array ? buf : Buffer.from(buf);
17033                const copyLen = Math.min(src.length, total - offset);
17034                out.set(src.subarray(0, copyLen), offset);
17035                offset += copyLen;
17036            }
17037            return out;
17038        }
17039        static compare(a, b) {
17040            const len = Math.min(a.length, b.length);
17041            for (let i = 0; i < len; i++) {
17042                if (a[i] < b[i]) return -1;
17043                if (a[i] > b[i]) return 1;
17044            }
17045            if (a.length < b.length) return -1;
17046            if (a.length > b.length) return 1;
17047            return 0;
17048        }
17049        toString(encoding, start, end) {
17050            const s = start || 0;
17051            const e = end !== undefined ? end : this.length;
17052            const view = this.subarray(s, e);
17053            const enc = String(encoding || 'utf8').toLowerCase();
17054            if (enc === 'base64') {
17055                let binary = '';
17056                for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]);
17057                return __pi_base64_encode_native(binary);
17058            }
17059            if (enc === 'hex') {
17060                let hex = '';
17061                for (let i = 0; i < view.length; i++) hex += (view[i] < 16 ? '0' : '') + view[i].toString(16);
17062                return hex;
17063            }
17064            return new TextDecoder().decode(view);
17065        }
17066        toJSON() {
17067            return { type: 'Buffer', data: Array.from(this) };
17068        }
17069        equals(other) {
17070            if (this.length !== other.length) return false;
17071            for (let i = 0; i < this.length; i++) {
17072                if (this[i] !== other[i]) return false;
17073            }
17074            return true;
17075        }
17076        compare(other) { return Buffer.compare(this, other); }
17077        copy(target, targetStart, sourceStart, sourceEnd) {
17078            const ts = targetStart || 0;
17079            const ss = sourceStart || 0;
17080            const se = sourceEnd !== undefined ? sourceEnd : this.length;
17081            const src = this.subarray(ss, se);
17082            const copyLen = Math.min(src.length, target.length - ts);
17083            target.set(src.subarray(0, copyLen), ts);
17084            return copyLen;
17085        }
17086        slice(start, end) {
17087            const sliced = super.slice(start, end);
17088            const buf = new Buffer(sliced.length);
17089            buf.set(sliced);
17090            return buf;
17091        }
17092        indexOf(value, byteOffset, encoding) {
17093            const offset = byteOffset || 0;
17094            if (typeof value === 'number') {
17095                for (let i = offset; i < this.length; i++) {
17096                    if (this[i] === (value & 0xff)) return i;
17097                }
17098                return -1;
17099            }
17100            const needle = typeof value === 'string' ? Buffer.from(value, encoding) : value;
17101            outer: for (let i = offset; i <= this.length - needle.length; i++) {
17102                for (let j = 0; j < needle.length; j++) {
17103                    if (this[i + j] !== needle[j]) continue outer;
17104                }
17105                return i;
17106            }
17107            return -1;
17108        }
17109        includes(value, byteOffset, encoding) {
17110            return this.indexOf(value, byteOffset, encoding) !== -1;
17111        }
17112        write(string, offset, length, encoding) {
17113            const o = offset || 0;
17114            const enc = encoding || 'utf8';
17115            const bytes = Buffer.from(string, enc);
17116            const len = length !== undefined ? Math.min(length, bytes.length) : bytes.length;
17117            const copyLen = Math.min(len, this.length - o);
17118            this.set(bytes.subarray(0, copyLen), o);
17119            return copyLen;
17120        }
17121        fill(value, offset, end, encoding) {
17122            const s = offset || 0;
17123            const e = end !== undefined ? end : this.length;
17124            const v = typeof value === 'number' ? (value & 0xff) : 0;
17125            for (let i = s; i < e; i++) this[i] = v;
17126            return this;
17127        }
17128        readUInt8(offset) { return this[offset || 0]; }
17129        readUInt16BE(offset) { const o = offset || 0; return (this[o] << 8) | this[o + 1]; }
17130        readUInt16LE(offset) { const o = offset || 0; return this[o] | (this[o + 1] << 8); }
17131        readUInt32BE(offset) { const o = offset || 0; return ((this[o] << 24) | (this[o+1] << 16) | (this[o+2] << 8) | this[o+3]) >>> 0; }
17132        readUInt32LE(offset) { const o = offset || 0; return (this[o] | (this[o+1] << 8) | (this[o+2] << 16) | (this[o+3] << 24)) >>> 0; }
17133        readInt8(offset) { const v = this[offset || 0]; return v > 127 ? v - 256 : v; }
17134        writeUInt8(value, offset) { this[offset || 0] = value & 0xff; return (offset || 0) + 1; }
17135        writeUInt16BE(value, offset) { const o = offset || 0; this[o] = (value >> 8) & 0xff; this[o+1] = value & 0xff; return o + 2; }
17136        writeUInt16LE(value, offset) { const o = offset || 0; this[o] = value & 0xff; this[o+1] = (value >> 8) & 0xff; return o + 2; }
17137        writeUInt32BE(value, offset) { const o = offset || 0; this[o]=(value>>>24)&0xff; this[o+1]=(value>>>16)&0xff; this[o+2]=(value>>>8)&0xff; this[o+3]=value&0xff; return o+4; }
17138        writeUInt32LE(value, offset) { const o = offset || 0; this[o]=value&0xff; this[o+1]=(value>>>8)&0xff; this[o+2]=(value>>>16)&0xff; this[o+3]=(value>>>24)&0xff; return o+4; }
17139    }
17140    globalThis.Buffer = Buffer;
17141}
17142
17143if (typeof globalThis.crypto === 'undefined') {
17144    globalThis.crypto = {};
17145}
17146
17147if (typeof globalThis.crypto.getRandomValues !== 'function') {
17148    globalThis.crypto.getRandomValues = (arr) => {
17149        const len = Number(arr && arr.length ? arr.length : 0);
17150        const bytes = __pi_crypto_bytes_to_array(__pi_crypto_random_bytes_native(len));
17151        for (let i = 0; i < len; i++) {
17152            arr[i] = bytes[i] || 0;
17153        }
17154        return arr;
17155    };
17156}
17157
17158if (!globalThis.crypto.subtle) {
17159    globalThis.crypto.subtle = {};
17160}
17161
17162if (typeof globalThis.crypto.subtle.digest !== 'function') {
17163    globalThis.crypto.subtle.digest = async (algorithm, data) => {
17164        const name = typeof algorithm === 'string' ? algorithm : (algorithm && algorithm.name ? algorithm.name : '');
17165        const upper = String(name).toUpperCase();
17166        if (upper !== 'SHA-256') {
17167            throw new Error('crypto.subtle.digest: only SHA-256 is supported');
17168        }
17169        const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
17170        let text = '';
17171        for (let i = 0; i < bytes.length; i++) {
17172            text += String.fromCharCode(bytes[i]);
17173        }
17174        const hex = __pi_crypto_sha256_hex_native(text);
17175        const out = new Uint8Array(hex.length / 2);
17176        for (let i = 0; i < out.length; i++) {
17177            out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
17178        }
17179        return out.buffer;
17180    };
17181}
17182
17183if (typeof globalThis.crypto.randomUUID !== 'function') {
17184    globalThis.crypto.randomUUID = () => {
17185        const bytes = __pi_crypto_bytes_to_array(__pi_crypto_random_bytes_native(16));
17186        while (bytes.length < 16) bytes.push(0);
17187        bytes[6] = (bytes[6] & 0x0f) | 0x40;
17188        bytes[8] = (bytes[8] & 0x3f) | 0x80;
17189        const hex = Array.from(bytes, (b) => (b & 0xff).toString(16).padStart(2, '0')).join('');
17190        return (
17191            hex.slice(0, 8) +
17192            '-' +
17193            hex.slice(8, 12) +
17194            '-' +
17195            hex.slice(12, 16) +
17196            '-' +
17197            hex.slice(16, 20) +
17198            '-' +
17199            hex.slice(20)
17200        );
17201    };
17202}
17203
17204if (typeof globalThis.process === 'undefined') {
17205    const rawPlatform =
17206        __pi_env_get_native('PI_PLATFORM') ||
17207        __pi_env_get_native('OSTYPE') ||
17208        __pi_env_get_native('OS') ||
17209        'linux';
17210    // Normalize to Node.js conventions: strip version suffix from OSTYPE
17211    // (e.g. darwin24.0 -> darwin, linux-gnu -> linux, msys -> win32)
17212    const platform = (() => {
17213        const s = String(rawPlatform).replace(/[0-9].*$/, '').split('-')[0].toLowerCase();
17214        if (s === 'darwin') return 'darwin';
17215        if (s === 'msys' || s === 'cygwin' || s === 'windows_nt') return 'win32';
17216        return s || 'linux';
17217    })();
17218    const detHome = __pi_env_get_native('PI_DETERMINISTIC_HOME');
17219    const detCwd = __pi_env_get_native('PI_DETERMINISTIC_CWD');
17220
17221    const envProxy = new Proxy(
17222        {},
17223        {
17224            get(_target, prop) {
17225                if (typeof prop !== 'string') return undefined;
17226                if (prop === 'HOME' && detHome) return detHome;
17227                const value = __pi_env_get_native(prop);
17228                return value === null || value === undefined ? undefined : value;
17229            },
17230            set(_target, prop, _value) {
17231                // Read-only in PiJS — silently ignore writes
17232                return typeof prop === 'string';
17233            },
17234            deleteProperty(_target, prop) {
17235                // Read-only — silently ignore deletes
17236                return typeof prop === 'string';
17237            },
17238            has(_target, prop) {
17239                if (typeof prop !== 'string') return false;
17240                if (prop === 'HOME' && detHome) return true;
17241                const value = __pi_env_get_native(prop);
17242                return value !== null && value !== undefined;
17243            },
17244            ownKeys() {
17245                // Cannot enumerate real env — return empty
17246                return [];
17247            },
17248            getOwnPropertyDescriptor(_target, prop) {
17249                if (typeof prop !== 'string') return undefined;
17250                const value = __pi_env_get_native(prop);
17251                if (value === null || value === undefined) return undefined;
17252                return { value, writable: false, enumerable: true, configurable: true };
17253            },
17254        },
17255    );
17256
17257    // stdout/stderr that route through console output
17258    function makeWritable(level) {
17259        return {
17260            write(chunk) {
17261                if (typeof __pi_console_output_native === 'function') {
17262                    __pi_console_output_native(level, String(chunk));
17263                }
17264                return true;
17265            },
17266            end() { return this; },
17267            on() { return this; },
17268            once() { return this; },
17269            pipe() { return this; },
17270            isTTY: false,
17271        };
17272    }
17273
17274    // Event listener registry
17275    const __evtMap = Object.create(null);
17276    function __on(event, fn) {
17277        if (!__evtMap[event]) __evtMap[event] = [];
17278        __evtMap[event].push(fn);
17279        return globalThis.process;
17280    }
17281    function __off(event, fn) {
17282        const arr = __evtMap[event];
17283        if (!arr) return globalThis.process;
17284        const idx = arr.indexOf(fn);
17285        if (idx >= 0) arr.splice(idx, 1);
17286        return globalThis.process;
17287    }
17288
17289    const startMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
17290
17291    globalThis.process = {
17292        env: envProxy,
17293        argv: __pi_process_args_native(),
17294        cwd: () => detCwd || __pi_process_cwd_native(),
17295        platform: String(platform).split('-')[0],
17296        arch: __pi_env_get_native('PI_TARGET_ARCH') || 'x64',
17297        version: 'v20.0.0',
17298        versions: { node: '20.0.0', v8: '0.0.0', modules: '0' },
17299        pid: 1,
17300        ppid: 0,
17301        title: 'pi',
17302        execPath: (typeof __pi_process_execpath_native === 'function')
17303            ? __pi_process_execpath_native()
17304            : '/usr/bin/pi',
17305        execArgv: [],
17306        stdout: makeWritable('log'),
17307        stderr: makeWritable('error'),
17308        stdin: { on() { return this; }, once() { return this; }, read() {}, resume() { return this; }, pause() { return this; } },
17309        nextTick: (fn, ...args) => { Promise.resolve().then(() => fn(...args)); },
17310        hrtime: Object.assign((prev) => {
17311            const nowMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
17312            const secs = Math.floor(nowMs / 1000);
17313            const nanos = Math.floor((nowMs % 1000) * 1e6);
17314            if (Array.isArray(prev) && prev.length >= 2) {
17315                let ds = secs - prev[0];
17316                let dn = nanos - prev[1];
17317                if (dn < 0) { ds -= 1; dn += 1e9; }
17318                return [ds, dn];
17319            }
17320            return [secs, nanos];
17321        }, {
17322            bigint: () => {
17323                const nowMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
17324                return BigInt(Math.floor(nowMs * 1e6));
17325            },
17326        }),
17327        kill: (pid, sig) => {
17328            const impl = globalThis.__pi_process_kill_impl;
17329            if (typeof impl === 'function') {
17330                return impl(pid, sig);
17331            }
17332            const err = new Error('process.kill is not available in PiJS');
17333            err.code = 'ENOSYS';
17334            throw err;
17335        },
17336        exit: (code) => {
17337            const exitCode = code === undefined ? 0 : Number(code);
17338            // Fire exit listeners
17339            const listeners = __evtMap['exit'];
17340            if (listeners) {
17341                for (const fn of listeners.slice()) {
17342                    try { fn(exitCode); } catch (_) {}
17343                }
17344            }
17345            // Signal native side
17346            if (typeof __pi_process_exit_native === 'function') {
17347                __pi_process_exit_native(exitCode);
17348            }
17349            const err = new Error('process.exit(' + exitCode + ')');
17350            err.code = 'ERR_PROCESS_EXIT';
17351            err.exitCode = exitCode;
17352            throw err;
17353        },
17354        chdir: (_dir) => {
17355            const err = new Error('process.chdir is not supported in PiJS');
17356            err.code = 'ENOSYS';
17357            throw err;
17358        },
17359        uptime: () => {
17360            const nowMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
17361            return Math.floor((nowMs - startMs) / 1000);
17362        },
17363        memoryUsage: () => ({
17364            rss: 0, heapTotal: 0, heapUsed: 0, external: 0, arrayBuffers: 0,
17365        }),
17366        cpuUsage: (_prev) => ({ user: 0, system: 0 }),
17367        emitWarning: (msg) => {
17368            if (typeof __pi_console_output_native === 'function') {
17369                __pi_console_output_native('warn', 'Warning: ' + msg);
17370            }
17371        },
17372        release: { name: 'node', lts: 'PiJS' },
17373        config: { variables: {} },
17374        features: {},
17375        on: __on,
17376        addListener: __on,
17377        off: __off,
17378        removeListener: __off,
17379        once(event, fn) {
17380            const wrapped = (...args) => {
17381                __off(event, wrapped);
17382                fn(...args);
17383            };
17384            wrapped._original = fn;
17385            __on(event, wrapped);
17386            return globalThis.process;
17387        },
17388        removeAllListeners(event) {
17389            if (event) { delete __evtMap[event]; }
17390            else { for (const k in __evtMap) delete __evtMap[k]; }
17391            return globalThis.process;
17392        },
17393        listeners(event) {
17394            return (__evtMap[event] || []).slice();
17395        },
17396        emit(event, ...args) {
17397            const listeners = __evtMap[event];
17398            if (!listeners || listeners.length === 0) return false;
17399            for (const fn of listeners.slice()) {
17400                try { fn(...args); } catch (_) {}
17401            }
17402            return true;
17403        },
17404    };
17405
17406    try { Object.freeze(envProxy); } catch (_) {}
17407    try { Object.freeze(globalThis.process.argv); } catch (_) {}
17408    // Do NOT freeze globalThis.process — extensions may need to monkey-patch it
17409}
17410
17411// Node.js global alias compatibility.
17412if (typeof globalThis.global === 'undefined') {
17413    globalThis.global = globalThis;
17414}
17415
17416if (typeof globalThis.Bun === 'undefined') {
17417    const __pi_bun_require = (specifier) => {
17418        try {
17419            if (typeof require === 'function') {
17420                return require(specifier);
17421            }
17422        } catch (_) {}
17423        return null;
17424    };
17425
17426    const __pi_bun_fs = () => __pi_bun_require('node:fs');
17427    const __pi_bun_import_fs = () => import('node:fs');
17428    const __pi_bun_child_process = () => __pi_bun_require('node:child_process');
17429
17430    const __pi_bun_to_uint8 = (value) => {
17431        if (value instanceof Uint8Array) {
17432            return value;
17433        }
17434        if (value instanceof ArrayBuffer) {
17435            return new Uint8Array(value);
17436        }
17437        if (ArrayBuffer.isView && ArrayBuffer.isView(value)) {
17438            return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
17439        }
17440        if (typeof value === 'string') {
17441            return new TextEncoder().encode(value);
17442        }
17443        if (value === undefined || value === null) {
17444            return new Uint8Array();
17445        }
17446        return new TextEncoder().encode(String(value));
17447    };
17448
17449    const __pi_bun_make_text_stream = (fetchText) => ({
17450        async text() {
17451            return fetchText();
17452        },
17453        async arrayBuffer() {
17454            const text = await fetchText();
17455            const bytes = new TextEncoder().encode(String(text ?? ''));
17456            return bytes.buffer;
17457        },
17458    });
17459
17460    const Bun = {};
17461
17462    Bun.argv = Array.isArray(globalThis.process && globalThis.process.argv)
17463        ? globalThis.process.argv.slice()
17464        : [];
17465
17466    Bun.file = (path) => {
17467        const targetPath = String(path ?? '');
17468        return {
17469            path: targetPath,
17470            name: targetPath,
17471            async exists() {
17472                const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
17473                return Boolean(fs && typeof fs.existsSync === 'function' && fs.existsSync(targetPath));
17474            },
17475            async text() {
17476                const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
17477                if (!fs || typeof fs.readFileSync !== 'function') {
17478                    throw new Error('Bun.file.text: node:fs is unavailable');
17479                }
17480                return String(fs.readFileSync(targetPath, 'utf8'));
17481            },
17482            async arrayBuffer() {
17483                const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
17484                if (!fs || typeof fs.readFileSync !== 'function') {
17485                    throw new Error('Bun.file.arrayBuffer: node:fs is unavailable');
17486                }
17487                const bytes = __pi_bun_to_uint8(fs.readFileSync(targetPath));
17488                return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
17489            },
17490            async json() {
17491                return JSON.parse(await this.text());
17492            },
17493        };
17494    };
17495
17496    Bun.write = async (destination, data) => {
17497        const targetPath =
17498            destination && typeof destination === 'object' && typeof destination.path === 'string'
17499                ? destination.path
17500                : String(destination ?? '');
17501        if (!targetPath) {
17502            throw new Error('Bun.write: destination path is required');
17503        }
17504        const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
17505        if (!fs || typeof fs.writeFileSync !== 'function') {
17506            throw new Error('Bun.write: node:fs is unavailable');
17507        }
17508
17509        let payload = data;
17510        if (payload && typeof payload === 'object' && typeof payload.text === 'function') {
17511            payload = payload.text();
17512        }
17513        if (payload && typeof payload === 'object' && typeof payload.arrayBuffer === 'function') {
17514            payload = payload.arrayBuffer();
17515        }
17516        if (payload && typeof payload.then === 'function') {
17517            payload = await payload;
17518        }
17519
17520        const bytes = __pi_bun_to_uint8(payload);
17521        fs.writeFileSync(targetPath, bytes);
17522        return bytes.byteLength;
17523    };
17524
17525    Bun.which = (command) => {
17526        const name = String(command ?? '').trim();
17527        if (!name) return null;
17528        const cwd =
17529            globalThis.process && typeof globalThis.process.cwd === 'function'
17530                ? globalThis.process.cwd()
17531                : '/';
17532        const raw = __pi_exec_sync_native('which', JSON.stringify([name]), cwd, 2000, undefined);
17533        try {
17534            const parsed = JSON.parse(raw || '{}');
17535            if (Number(parsed && parsed.code) !== 0) return null;
17536            const out = String((parsed && parsed.stdout) || '').trim();
17537            return out ? out.split('\n')[0] : null;
17538        } catch (_) {
17539            return null;
17540        }
17541    };
17542
17543    Bun.spawn = (commandOrArgv, rawOptions = {}) => {
17544        const options = rawOptions && typeof rawOptions === 'object' ? rawOptions : {};
17545
17546        let command = '';
17547        let args = [];
17548        if (Array.isArray(commandOrArgv)) {
17549            if (commandOrArgv.length === 0) {
17550                throw new Error('Bun.spawn: command is required');
17551            }
17552            command = String(commandOrArgv[0] ?? '');
17553            args = commandOrArgv.slice(1).map((arg) => String(arg ?? ''));
17554        } else {
17555            command = String(commandOrArgv ?? '');
17556            if (Array.isArray(options.args)) {
17557                args = options.args.map((arg) => String(arg ?? ''));
17558            }
17559        }
17560
17561        if (!command.trim()) {
17562            throw new Error('Bun.spawn: command is required');
17563        }
17564
17565        const spawnOptions = {
17566            shell: false,
17567            stdio: [
17568                options.stdin === 'pipe' ? 'pipe' : 'ignore',
17569                options.stdout === 'ignore' ? 'ignore' : 'pipe',
17570                options.stderr === 'ignore' ? 'ignore' : 'pipe',
17571            ],
17572        };
17573        if (typeof options.cwd === 'string' && options.cwd.trim().length > 0) {
17574            spawnOptions.cwd = options.cwd;
17575        }
17576        if (
17577            typeof options.timeout === 'number' &&
17578            Number.isFinite(options.timeout) &&
17579            options.timeout >= 0
17580        ) {
17581            spawnOptions.timeout = Math.floor(options.timeout);
17582        }
17583
17584        const childProcess = __pi_bun_child_process();
17585        if (childProcess && typeof childProcess.spawn === 'function') {
17586            const child = childProcess.spawn(command, args, spawnOptions);
17587            let stdoutText = '';
17588            let stderrText = '';
17589
17590            if (child && child.stdout && typeof child.stdout.on === 'function') {
17591                child.stdout.on('data', (chunk) => {
17592                    stdoutText += String(chunk ?? '');
17593                });
17594            }
17595            if (child && child.stderr && typeof child.stderr.on === 'function') {
17596                child.stderr.on('data', (chunk) => {
17597                    stderrText += String(chunk ?? '');
17598                });
17599            }
17600
17601            const exited = new Promise((resolve, reject) => {
17602                let settled = false;
17603                child.on('error', (err) => {
17604                    if (settled) return;
17605                    settled = true;
17606                    reject(err instanceof Error ? err : new Error(String(err)));
17607                });
17608                child.on('close', (code) => {
17609                    if (settled) return;
17610                    settled = true;
17611                    resolve(typeof code === 'number' ? code : null);
17612                });
17613            });
17614
17615            return {
17616                pid: typeof child.pid === 'number' ? child.pid : 0,
17617                stdin: child.stdin || null,
17618                stdout: __pi_bun_make_text_stream(async () => {
17619                    await exited.catch(() => null);
17620                    return stdoutText;
17621                }),
17622                stderr: __pi_bun_make_text_stream(async () => {
17623                    await exited.catch(() => null);
17624                    return stderrText;
17625                }),
17626                exited,
17627                kill(signal) {
17628                    try {
17629                        return child.kill(signal);
17630                    } catch (_) {
17631                        return false;
17632                    }
17633                },
17634                ref() { return this; },
17635                unref() { return this; },
17636            };
17637        }
17638
17639        // Fallback path if node:child_process is unavailable in context.
17640        const execOptions = {};
17641        if (spawnOptions.cwd !== undefined) execOptions.cwd = spawnOptions.cwd;
17642        if (spawnOptions.timeout !== undefined) execOptions.timeout = spawnOptions.timeout;
17643        const execPromise = pi.exec(command, args, execOptions);
17644        let killed = false;
17645
17646        const exited = execPromise.then(
17647            (result) => (killed ? null : (Number(result && result.code) || 0)),
17648            () => (killed ? null : 1),
17649        );
17650
17651        return {
17652            pid: 0,
17653            stdin: null,
17654            stdout: __pi_bun_make_text_stream(async () => {
17655                try {
17656                    const result = await execPromise;
17657                    return String((result && result.stdout) || '');
17658                } catch (_) {
17659                    return '';
17660                }
17661            }),
17662            stderr: __pi_bun_make_text_stream(async () => {
17663                try {
17664                    const result = await execPromise;
17665                    return String((result && result.stderr) || '');
17666                } catch (_) {
17667                    return '';
17668                }
17669            }),
17670            exited,
17671            kill() {
17672                killed = true;
17673                return true;
17674            },
17675            ref() { return this; },
17676            unref() { return this; },
17677        };
17678    };
17679
17680    globalThis.Bun = Bun;
17681}
17682
17683if (typeof globalThis.setTimeout !== 'function') {
17684    globalThis.setTimeout = (callback, delay, ...args) => {
17685        const ms = Number(delay || 0);
17686        const timer_id = __pi_set_timeout_native(ms <= 0 ? 0 : Math.floor(ms));
17687        const captured_id = __pi_current_extension_id;
17688        __pi_register_timer(timer_id, () => {
17689            const prev = __pi_current_extension_id;
17690            __pi_current_extension_id = captured_id;
17691            try {
17692                callback(...args);
17693            } catch (e) {
17694                console.error('setTimeout callback error:', e);
17695            } finally {
17696                __pi_current_extension_id = prev;
17697            }
17698        });
17699        return timer_id;
17700    };
17701}
17702
17703if (typeof globalThis.clearTimeout !== 'function') {
17704    globalThis.clearTimeout = (timer_id) => {
17705        __pi_unregister_timer(timer_id);
17706        try {
17707            __pi_clear_timeout_native(timer_id);
17708        } catch (_) {}
17709    };
17710}
17711
17712// setInterval polyfill using setTimeout
17713const __pi_intervals = new Map();
17714let __pi_interval_id = 0;
17715
17716if (typeof globalThis.setInterval !== 'function') {
17717    globalThis.setInterval = (callback, delay, ...args) => {
17718        const ms = Math.max(0, Number(delay || 0));
17719        const id = ++__pi_interval_id;
17720        const captured_id = __pi_current_extension_id;
17721        const run = () => {
17722            if (!__pi_intervals.has(id)) return;
17723            const prev = __pi_current_extension_id;
17724            __pi_current_extension_id = captured_id;
17725            try {
17726                callback(...args);
17727            } catch (e) {
17728                console.error('setInterval callback error:', e);
17729            } finally {
17730                __pi_current_extension_id = prev;
17731            }
17732            if (__pi_intervals.has(id)) {
17733                __pi_intervals.set(id, globalThis.setTimeout(run, ms));
17734            }
17735        };
17736        __pi_intervals.set(id, globalThis.setTimeout(run, ms));
17737        return id;
17738    };
17739}
17740
17741if (typeof globalThis.clearInterval !== 'function') {
17742    globalThis.clearInterval = (id) => {
17743        const timerId = __pi_intervals.get(id);
17744        if (timerId !== undefined) {
17745            globalThis.clearTimeout(timerId);
17746            __pi_intervals.delete(id);
17747        }
17748    };
17749}
17750
17751if (typeof globalThis.fetch !== 'function') {
17752    class Headers {
17753        constructor(init) {
17754            this._map = {};
17755            if (init && typeof init === 'object') {
17756                if (Array.isArray(init)) {
17757                    for (const pair of init) {
17758                        if (pair && pair.length >= 2) this.set(pair[0], pair[1]);
17759                    }
17760                } else if (typeof init.forEach === 'function') {
17761                    init.forEach((v, k) => this.set(k, v));
17762                } else {
17763                    for (const k of Object.keys(init)) {
17764                        this.set(k, init[k]);
17765                    }
17766                }
17767            }
17768        }
17769
17770        get(name) {
17771            const key = String(name || '').toLowerCase();
17772            return this._map[key] === undefined ? null : this._map[key];
17773        }
17774
17775        set(name, value) {
17776            const key = String(name || '').toLowerCase();
17777            this._map[key] = String(value === undefined || value === null ? '' : value);
17778        }
17779
17780        entries() {
17781            return Object.entries(this._map);
17782        }
17783    }
17784
17785    class Response {
17786        constructor(bodyBytes, init) {
17787            const options = init && typeof init === 'object' ? init : {};
17788            this.status = Number(options.status || 0);
17789            this.ok = this.status >= 200 && this.status < 300;
17790            this.headers = new Headers(options.headers || {});
17791            this._bytes = bodyBytes || new Uint8Array();
17792            this.body = {
17793                getReader: () => {
17794                    let done = false;
17795                    return {
17796                        read: async () => {
17797                            if (done) return { done: true, value: undefined };
17798                            done = true;
17799                            return { done: false, value: this._bytes };
17800                        },
17801                        cancel: async () => {
17802                            done = true;
17803                        },
17804                        releaseLock: () => {},
17805                    };
17806                },
17807            };
17808        }
17809
17810        async text() {
17811            return new TextDecoder().decode(this._bytes);
17812        }
17813
17814        async json() {
17815            return JSON.parse(await this.text());
17816        }
17817
17818        async arrayBuffer() {
17819            const copy = new Uint8Array(this._bytes.length);
17820            copy.set(this._bytes);
17821            return copy.buffer;
17822        }
17823    }
17824
17825    globalThis.Headers = Headers;
17826    globalThis.Response = Response;
17827
17828    if (typeof globalThis.Event === 'undefined') {
17829        class Event {
17830            constructor(type, options) {
17831                const opts = options && typeof options === 'object' ? options : {};
17832                this.type = String(type || '');
17833                this.bubbles = !!opts.bubbles;
17834                this.cancelable = !!opts.cancelable;
17835                this.composed = !!opts.composed;
17836                this.defaultPrevented = false;
17837                this.target = null;
17838                this.currentTarget = null;
17839                this.timeStamp = Date.now();
17840            }
17841            preventDefault() {
17842                if (this.cancelable) this.defaultPrevented = true;
17843            }
17844            stopPropagation() {}
17845            stopImmediatePropagation() {}
17846        }
17847        globalThis.Event = Event;
17848    }
17849
17850    if (typeof globalThis.CustomEvent === 'undefined' && typeof globalThis.Event === 'function') {
17851        class CustomEvent extends globalThis.Event {
17852            constructor(type, options) {
17853                const opts = options && typeof options === 'object' ? options : {};
17854                super(type, opts);
17855                this.detail = opts.detail;
17856            }
17857        }
17858        globalThis.CustomEvent = CustomEvent;
17859    }
17860
17861    if (typeof globalThis.EventTarget === 'undefined') {
17862        class EventTarget {
17863            constructor() {
17864                this.__listeners = Object.create(null);
17865            }
17866            addEventListener(type, listener) {
17867                const key = String(type || '');
17868                if (!key || !listener) return;
17869                if (!this.__listeners[key]) this.__listeners[key] = [];
17870                if (!this.__listeners[key].includes(listener)) this.__listeners[key].push(listener);
17871            }
17872            removeEventListener(type, listener) {
17873                const key = String(type || '');
17874                const list = this.__listeners[key];
17875                if (!list || !listener) return;
17876                this.__listeners[key] = list.filter((fn) => fn !== listener);
17877            }
17878            dispatchEvent(event) {
17879                if (!event || typeof event.type !== 'string') return true;
17880                const key = event.type;
17881                const list = (this.__listeners[key] || []).slice();
17882                try {
17883                    event.target = this;
17884                    event.currentTarget = this;
17885                } catch (_) {}
17886                for (const listener of list) {
17887                    try {
17888                        if (typeof listener === 'function') listener.call(this, event);
17889                        else if (listener && typeof listener.handleEvent === 'function') listener.handleEvent(event);
17890                    } catch (_) {}
17891                }
17892                return !(event && event.defaultPrevented);
17893            }
17894        }
17895        globalThis.EventTarget = EventTarget;
17896    }
17897
17898    if (typeof globalThis.TransformStream === 'undefined') {
17899        class TransformStream {
17900            constructor(_transformer) {
17901                const queue = [];
17902                let closed = false;
17903                this.readable = {
17904                    getReader() {
17905                        return {
17906                            async read() {
17907                                if (queue.length > 0) {
17908                                    return { done: false, value: queue.shift() };
17909                                }
17910                                return { done: closed, value: undefined };
17911                            },
17912                            async cancel() {
17913                                closed = true;
17914                            },
17915                            releaseLock() {},
17916                        };
17917                    },
17918                };
17919                this.writable = {
17920                    getWriter() {
17921                        return {
17922                            async write(chunk) {
17923                                queue.push(chunk);
17924                            },
17925                            async close() {
17926                                closed = true;
17927                            },
17928                            async abort() {
17929                                closed = true;
17930                            },
17931                            releaseLock() {},
17932                        };
17933                    },
17934                };
17935            }
17936        }
17937        globalThis.TransformStream = TransformStream;
17938    }
17939
17940    // AbortController / AbortSignal polyfill — many npm packages check for these
17941    if (typeof globalThis.AbortController === 'undefined') {
17942        class AbortSignal {
17943            constructor() { this.aborted = false; this._listeners = []; }
17944            get reason() { return this.aborted ? new Error('This operation was aborted') : undefined; }
17945            addEventListener(type, fn) { if (type === 'abort') this._listeners.push(fn); }
17946            removeEventListener(type, fn) { if (type === 'abort') this._listeners = this._listeners.filter(f => f !== fn); }
17947            throwIfAborted() { if (this.aborted) throw this.reason; }
17948            static abort(reason) { const s = new AbortSignal(); s.aborted = true; s._reason = reason; return s; }
17949            static timeout(ms) { const s = new AbortSignal(); setTimeout(() => { s.aborted = true; s._listeners.forEach(fn => fn()); }, ms); return s; }
17950        }
17951        class AbortController {
17952            constructor() { this.signal = new AbortSignal(); }
17953            abort(reason) { this.signal.aborted = true; this.signal._reason = reason; this.signal._listeners.forEach(fn => fn()); }
17954        }
17955        globalThis.AbortController = AbortController;
17956        globalThis.AbortSignal = AbortSignal;
17957    }
17958
17959    globalThis.fetch = async (input, init) => {
17960        const url = typeof input === 'string' ? input : String(input && input.url ? input.url : input);
17961        const options = init && typeof init === 'object' ? init : {};
17962        const method = options.method ? String(options.method) : 'GET';
17963
17964        const headers = {};
17965        if (options.headers && typeof options.headers === 'object') {
17966            if (options.headers instanceof Headers) {
17967                for (const [k, v] of options.headers.entries()) headers[k] = v;
17968            } else if (Array.isArray(options.headers)) {
17969                for (const pair of options.headers) {
17970                    if (pair && pair.length >= 2) headers[String(pair[0])] = String(pair[1]);
17971                }
17972            } else {
17973                for (const k of Object.keys(options.headers)) {
17974                    headers[k] = String(options.headers[k]);
17975                }
17976            }
17977        }
17978
17979        let body = undefined;
17980        if (options.body !== undefined && options.body !== null) {
17981            body = typeof options.body === 'string' ? options.body : String(options.body);
17982        }
17983
17984        const resp = await pi.http({ url, method, headers, body });
17985        const status = resp && resp.status !== undefined ? Number(resp.status) : 0;
17986        const respHeaders = resp && resp.headers && typeof resp.headers === 'object' ? resp.headers : {};
17987
17988        let bytes = new Uint8Array();
17989        if (resp && resp.body_bytes) {
17990            const bin = __pi_base64_decode_native(String(resp.body_bytes));
17991            const out = new Uint8Array(bin.length);
17992            for (let i = 0; i < bin.length; i++) {
17993                out[i] = bin.charCodeAt(i) & 0xff;
17994            }
17995            bytes = out;
17996        } else if (resp && resp.body !== undefined && resp.body !== null) {
17997            bytes = new TextEncoder().encode(String(resp.body));
17998        }
17999
18000        return new Response(bytes, { status, headers: respHeaders });
18001    };
18002}
18003";
18004
18005#[cfg(test)]
18006#[allow(clippy::future_not_send)]
18007mod tests {
18008    use super::*;
18009    use crate::scheduler::DeterministicClock;
18010
18011    #[allow(clippy::future_not_send)]
18012    async fn get_global_json<C: SchedulerClock + 'static>(
18013        runtime: &PiJsRuntime<C>,
18014        name: &str,
18015    ) -> serde_json::Value {
18016        runtime
18017            .context
18018            .with(|ctx| {
18019                let global = ctx.globals();
18020                let value: Value<'_> = global.get(name)?;
18021                js_to_json(&value)
18022            })
18023            .await
18024            .expect("js context")
18025    }
18026
18027    #[allow(clippy::future_not_send)]
18028    async fn call_global_fn_json<C: SchedulerClock + 'static>(
18029        runtime: &PiJsRuntime<C>,
18030        name: &str,
18031    ) -> serde_json::Value {
18032        runtime
18033            .context
18034            .with(|ctx| {
18035                let global = ctx.globals();
18036                let function: Function<'_> = global.get(name)?;
18037                let value: Value<'_> = function.call(())?;
18038                js_to_json(&value)
18039            })
18040            .await
18041            .expect("js context")
18042    }
18043
18044    #[allow(clippy::future_not_send)]
18045    async fn runtime_with_sync_exec_enabled(
18046        clock: Arc<DeterministicClock>,
18047    ) -> PiJsRuntime<Arc<DeterministicClock>> {
18048        let config = PiJsRuntimeConfig {
18049            allow_unsafe_sync_exec: true,
18050            ..PiJsRuntimeConfig::default()
18051        };
18052        PiJsRuntime::with_clock_and_config_with_policy(clock, config, None)
18053            .await
18054            .expect("create runtime")
18055    }
18056
18057    #[allow(clippy::future_not_send)]
18058    async fn drain_until_idle(
18059        runtime: &PiJsRuntime<Arc<DeterministicClock>>,
18060        clock: &Arc<DeterministicClock>,
18061    ) {
18062        for _ in 0..10_000 {
18063            if !runtime.has_pending() {
18064                break;
18065            }
18066
18067            let stats = runtime.tick().await.expect("tick");
18068            if stats.ran_macrotask {
18069                continue;
18070            }
18071
18072            let next_deadline = runtime.scheduler.borrow().next_timer_deadline();
18073            let Some(next_deadline) = next_deadline else {
18074                break;
18075            };
18076
18077            let now = runtime.now_ms();
18078            assert!(
18079                next_deadline > now,
18080                "expected future timer deadline (deadline={next_deadline}, now={now})"
18081            );
18082            clock.set(next_deadline);
18083        }
18084    }
18085
18086    #[test]
18087    fn extract_static_require_specifiers_skips_literals_and_comments() {
18088        let source = r#"
18089const fs = require("fs");
18090const text = "require('left-pad')";
18091const tpl = `require("ajv/dist/runtime/validation_error").default`;
18092// require("zlib")
18093/* require("tty") */
18094const path = require('path');
18095"#;
18096
18097        let specifiers = extract_static_require_specifiers(source);
18098        assert_eq!(specifiers, vec!["fs".to_string(), "path".to_string()]);
18099    }
18100
18101    #[test]
18102    fn maybe_cjs_to_esm_ignores_codegen_string_requires() {
18103        let source = r#"
18104const fs = require("fs");
18105const generated = `require("ajv/dist/runtime/validation_error").default`;
18106module.exports = { fs, generated };
18107"#;
18108
18109        let rewritten = maybe_cjs_to_esm(source);
18110        assert!(rewritten.contains(r#"from "fs";"#));
18111        assert!(!rewritten.contains(r#"from "ajv/dist/runtime/validation_error";"#));
18112    }
18113
18114    #[test]
18115    fn extract_import_names_handles_default_plus_named_imports() {
18116        let source = r#"
18117import Ajv, {
18118  KeywordDefinition,
18119  type AnySchema,
18120  ValidationError as AjvValidationError,
18121} from "ajv";
18122"#;
18123
18124        let names = extract_import_names(source, "ajv");
18125        assert_eq!(
18126            names,
18127            vec![
18128                "KeywordDefinition".to_string(),
18129                "ValidationError".to_string()
18130            ]
18131        );
18132    }
18133
18134    #[test]
18135    fn extract_builtin_import_names_collects_node_aliases() {
18136        let source = r#"
18137import { isIP } from "net";
18138import { isIPv4 as netIsIpv4 } from "node:net";
18139"#;
18140        let names = extract_builtin_import_names(source, "node:net", "node:net");
18141        assert_eq!(
18142            names.into_iter().collect::<Vec<_>>(),
18143            vec!["isIP".to_string(), "isIPv4".to_string()]
18144        );
18145    }
18146
18147    #[test]
18148    fn builtin_overlay_generation_scopes_exports_per_importing_module() {
18149        let temp_dir = tempfile::tempdir().expect("tempdir");
18150        let base_a = temp_dir.path().join("a.mjs");
18151        let base_b = temp_dir.path().join("b.mjs");
18152        std::fs::write(&base_a, r#"import { isIP } from "net";"#).expect("write a");
18153        std::fs::write(&base_b, r#"import { isIPv6 } from "node:net";"#).expect("write b");
18154
18155        let mut state = PiJsModuleState::new();
18156        let overlay_a = maybe_register_builtin_compat_overlay(
18157            &mut state,
18158            base_a.to_string_lossy().as_ref(),
18159            "net",
18160            "node:net",
18161        )
18162        .expect("overlay key for a");
18163        let overlay_b = maybe_register_builtin_compat_overlay(
18164            &mut state,
18165            base_b.to_string_lossy().as_ref(),
18166            "node:net",
18167            "node:net",
18168        )
18169        .expect("overlay key for b");
18170        assert!(overlay_a.starts_with("pijs-compat://builtin/node:net/"));
18171        assert!(overlay_b.starts_with("pijs-compat://builtin/node:net/"));
18172        assert_ne!(overlay_a, overlay_b);
18173
18174        let exported_names_a = state
18175            .dynamic_virtual_named_exports
18176            .get(&overlay_a)
18177            .expect("export names for a");
18178        assert!(exported_names_a.contains("isIP"));
18179        assert!(!exported_names_a.contains("isIPv6"));
18180
18181        let exported_names_b = state
18182            .dynamic_virtual_named_exports
18183            .get(&overlay_b)
18184            .expect("export names for b");
18185        assert!(exported_names_b.contains("isIPv6"));
18186        assert!(!exported_names_b.contains("isIP"));
18187
18188        let overlay_source_a = state
18189            .dynamic_virtual_modules
18190            .get(&overlay_a)
18191            .expect("overlay source for a");
18192        assert!(overlay_source_a.contains(r#"import * as __pijs_builtin_ns from "node:net";"#));
18193        assert!(overlay_source_a.contains("export const isIP ="));
18194        assert!(!overlay_source_a.contains("export const isIPv6 ="));
18195
18196        let overlay_source_b = state
18197            .dynamic_virtual_modules
18198            .get(&overlay_b)
18199            .expect("overlay source for b");
18200        assert!(overlay_source_b.contains("export const isIPv6 ="));
18201        assert!(!overlay_source_b.contains("export const isIP ="));
18202    }
18203
18204    #[test]
18205    fn hostcall_completions_run_before_due_timers() {
18206        let clock = Arc::new(ManualClock::new(1_000));
18207        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
18208
18209        let _timer = loop_state.set_timeout(0);
18210        loop_state.enqueue_hostcall_completion("call-1");
18211
18212        let mut seen = Vec::new();
18213        let result = loop_state.tick(|task| seen.push(task.kind), || false);
18214
18215        assert!(result.ran_macrotask);
18216        assert_eq!(
18217            seen,
18218            vec![MacrotaskKind::HostcallComplete {
18219                call_id: "call-1".to_string()
18220            }]
18221        );
18222    }
18223
18224    #[test]
18225    fn hostcall_request_queue_spills_to_overflow_with_stable_order() {
18226        fn req(id: usize) -> HostcallRequest {
18227            HostcallRequest {
18228                call_id: format!("call-{id}"),
18229                kind: HostcallKind::Log,
18230                payload: serde_json::json!({ "n": id }),
18231                trace_id: u64::try_from(id).unwrap_or(u64::MAX),
18232                extension_id: Some("ext.queue".to_string()),
18233            }
18234        }
18235
18236        let mut queue = HostcallRequestQueue::with_capacities(2, 4);
18237        assert!(matches!(
18238            queue.push_back(req(0)),
18239            HostcallQueueEnqueueResult::FastPath { .. }
18240        ));
18241        assert!(matches!(
18242            queue.push_back(req(1)),
18243            HostcallQueueEnqueueResult::FastPath { .. }
18244        ));
18245        assert!(matches!(
18246            queue.push_back(req(2)),
18247            HostcallQueueEnqueueResult::OverflowPath { .. }
18248        ));
18249
18250        let snapshot = queue.snapshot();
18251        assert_eq!(snapshot.fast_depth, 2);
18252        assert_eq!(snapshot.overflow_depth, 1);
18253        assert_eq!(snapshot.total_depth, 3);
18254        assert_eq!(snapshot.overflow_enqueued_total, 1);
18255
18256        let drained = queue.drain_all();
18257        let drained_ids: Vec<_> = drained.into_iter().map(|item| item.call_id).collect();
18258        assert_eq!(
18259            drained_ids,
18260            vec![
18261                "call-0".to_string(),
18262                "call-1".to_string(),
18263                "call-2".to_string()
18264            ]
18265        );
18266    }
18267
18268    #[test]
18269    fn hostcall_request_queue_rejects_when_overflow_capacity_reached() {
18270        fn req(id: usize) -> HostcallRequest {
18271            HostcallRequest {
18272                call_id: format!("reject-{id}"),
18273                kind: HostcallKind::Log,
18274                payload: serde_json::json!({ "n": id }),
18275                trace_id: u64::try_from(id).unwrap_or(u64::MAX),
18276                extension_id: None,
18277            }
18278        }
18279
18280        let mut queue = HostcallRequestQueue::with_capacities(1, 1);
18281        assert!(matches!(
18282            queue.push_back(req(0)),
18283            HostcallQueueEnqueueResult::FastPath { .. }
18284        ));
18285        assert!(matches!(
18286            queue.push_back(req(1)),
18287            HostcallQueueEnqueueResult::OverflowPath { .. }
18288        ));
18289        let reject = queue.push_back(req(2));
18290        assert!(matches!(
18291            reject,
18292            HostcallQueueEnqueueResult::Rejected { .. }
18293        ));
18294
18295        let snapshot = queue.snapshot();
18296        assert_eq!(snapshot.total_depth, 2);
18297        assert_eq!(snapshot.overflow_depth, 1);
18298        assert_eq!(snapshot.overflow_rejected_total, 1);
18299    }
18300
18301    #[test]
18302    fn timers_order_by_deadline_then_schedule_seq() {
18303        let clock = Arc::new(ManualClock::new(0));
18304        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock.clone()));
18305
18306        let t1 = loop_state.set_timeout(10);
18307        let t2 = loop_state.set_timeout(10);
18308        let t3 = loop_state.set_timeout(5);
18309        clock.set(10);
18310
18311        let mut fired = Vec::new();
18312        for _ in 0..3 {
18313            loop_state.tick(
18314                |task| {
18315                    if let MacrotaskKind::TimerFired { timer_id } = task.kind {
18316                        fired.push(timer_id);
18317                    }
18318                },
18319                || false,
18320            );
18321        }
18322
18323        assert_eq!(fired, vec![t3, t1, t2]);
18324    }
18325
18326    #[test]
18327    fn clear_timeout_prevents_fire() {
18328        let clock = Arc::new(ManualClock::new(0));
18329        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock.clone()));
18330
18331        let timer_id = loop_state.set_timeout(5);
18332        assert!(loop_state.clear_timeout(timer_id));
18333        clock.set(10);
18334
18335        let mut fired = Vec::new();
18336        let result = loop_state.tick(
18337            |task| {
18338                if let MacrotaskKind::TimerFired { timer_id } = task.kind {
18339                    fired.push(timer_id);
18340                }
18341            },
18342            || false,
18343        );
18344
18345        assert!(!result.ran_macrotask);
18346        assert!(fired.is_empty());
18347    }
18348
18349    #[test]
18350    fn clear_timeout_nonexistent_returns_false_and_does_not_pollute_cancelled_set() {
18351        let clock = Arc::new(ManualClock::new(0));
18352        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
18353
18354        assert!(!loop_state.clear_timeout(42));
18355        assert!(
18356            loop_state.cancelled_timers.is_empty(),
18357            "unknown timer ids should not be retained"
18358        );
18359    }
18360
18361    #[test]
18362    fn clear_timeout_double_cancel_returns_false() {
18363        let clock = Arc::new(ManualClock::new(0));
18364        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
18365
18366        let timer_id = loop_state.set_timeout(10);
18367        assert!(loop_state.clear_timeout(timer_id));
18368        assert!(!loop_state.clear_timeout(timer_id));
18369    }
18370
18371    #[test]
18372    fn pi_event_loop_timer_id_saturates_at_u64_max() {
18373        let clock = Arc::new(ManualClock::new(0));
18374        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
18375        loop_state.next_timer_id = u64::MAX;
18376
18377        let first = loop_state.set_timeout(10);
18378        let second = loop_state.set_timeout(20);
18379
18380        assert_eq!(first, u64::MAX);
18381        assert_eq!(second, u64::MAX);
18382    }
18383
18384    #[test]
18385    fn audit_ledger_sequence_saturates_at_u64_max() {
18386        let mut ledger = AuditLedger::new();
18387        ledger.next_sequence = u64::MAX;
18388
18389        let first = ledger.append(
18390            1_700_000_000_000,
18391            "ext-a",
18392            AuditEntryKind::Analysis,
18393            "first".to_string(),
18394            Vec::new(),
18395        );
18396        let second = ledger.append(
18397            1_700_000_000_100,
18398            "ext-a",
18399            AuditEntryKind::ProposalGenerated,
18400            "second".to_string(),
18401            Vec::new(),
18402        );
18403
18404        assert_eq!(first, u64::MAX);
18405        assert_eq!(second, u64::MAX);
18406        assert_eq!(ledger.len(), 2);
18407    }
18408
18409    #[test]
18410    fn microtasks_drain_to_fixpoint_after_macrotask() {
18411        let clock = Arc::new(ManualClock::new(0));
18412        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
18413
18414        loop_state.enqueue_inbound_event("evt-1");
18415
18416        let mut drain_calls = 0;
18417        let result = loop_state.tick(
18418            |_task| {},
18419            || {
18420                drain_calls += 1;
18421                drain_calls <= 2
18422            },
18423        );
18424
18425        assert!(result.ran_macrotask);
18426        assert_eq!(result.microtasks_drained, 2);
18427        assert_eq!(drain_calls, 3);
18428    }
18429
18430    #[test]
18431    fn compile_module_source_reports_missing_file() {
18432        let temp_dir = tempfile::tempdir().expect("tempdir");
18433        let missing_path = temp_dir.path().join("missing.js");
18434        let err = compile_module_source(
18435            &HashMap::new(),
18436            &HashMap::new(),
18437            missing_path.to_string_lossy().as_ref(),
18438        )
18439        .expect_err("missing module should error");
18440        let message = err.to_string();
18441        assert!(
18442            message.contains("Module is not a file"),
18443            "unexpected error: {message}"
18444        );
18445    }
18446
18447    #[test]
18448    fn compile_module_source_reports_unsupported_extension() {
18449        let temp_dir = tempfile::tempdir().expect("tempdir");
18450        let bad_path = temp_dir.path().join("module.txt");
18451        std::fs::write(&bad_path, "hello").expect("write module.txt");
18452
18453        let err = compile_module_source(
18454            &HashMap::new(),
18455            &HashMap::new(),
18456            bad_path.to_string_lossy().as_ref(),
18457        )
18458        .expect_err("unsupported extension should error");
18459        let message = err.to_string();
18460        assert!(
18461            message.contains("Unsupported module extension"),
18462            "unexpected error: {message}"
18463        );
18464    }
18465
18466    #[test]
18467    fn module_cache_key_changes_when_virtual_module_changes() {
18468        let static_modules = HashMap::new();
18469        let mut dynamic_modules = HashMap::new();
18470        dynamic_modules.insert("pijs://virt".to_string(), "export const x = 1;".to_string());
18471
18472        let key_before = module_cache_key(&static_modules, &dynamic_modules, "pijs://virt")
18473            .expect("virtual key should exist");
18474
18475        dynamic_modules.insert("pijs://virt".to_string(), "export const x = 2;".to_string());
18476        let key_after = module_cache_key(&static_modules, &dynamic_modules, "pijs://virt")
18477            .expect("virtual key should exist");
18478
18479        assert_ne!(key_before, key_after);
18480    }
18481
18482    #[test]
18483    fn module_cache_key_changes_when_file_size_changes() {
18484        let temp_dir = tempfile::tempdir().expect("tempdir");
18485        let module_path = temp_dir.path().join("module.js");
18486        std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
18487        let name = module_path.to_string_lossy().to_string();
18488
18489        let key_before =
18490            module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("file key");
18491
18492        std::fs::write(&module_path, "export const xyz = 123456;\n").expect("rewrite module");
18493        let key_after =
18494            module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("file key");
18495
18496        assert_ne!(key_before, key_after);
18497    }
18498
18499    #[test]
18500    fn load_compiled_module_source_tracks_hit_miss_and_invalidation_counters() {
18501        let temp_dir = tempfile::tempdir().expect("tempdir");
18502        let module_path = temp_dir.path().join("module.js");
18503        std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
18504        let name = module_path.to_string_lossy().to_string();
18505
18506        let mut state = PiJsModuleState::new();
18507
18508        let _first = load_compiled_module_source(&mut state, &name).expect("first compile");
18509        assert_eq!(state.module_cache_counters.hits, 0);
18510        assert_eq!(state.module_cache_counters.misses, 1);
18511        assert_eq!(state.module_cache_counters.invalidations, 0);
18512        assert_eq!(state.compiled_sources.len(), 1);
18513
18514        let _second = load_compiled_module_source(&mut state, &name).expect("cache hit");
18515        assert_eq!(state.module_cache_counters.hits, 1);
18516        assert_eq!(state.module_cache_counters.misses, 1);
18517        assert_eq!(state.module_cache_counters.invalidations, 0);
18518
18519        std::fs::write(&module_path, "export const xyz = 123456;\n").expect("rewrite module");
18520        let _third = load_compiled_module_source(&mut state, &name).expect("recompile");
18521        assert_eq!(state.module_cache_counters.hits, 1);
18522        assert_eq!(state.module_cache_counters.misses, 2);
18523        assert_eq!(state.module_cache_counters.invalidations, 1);
18524    }
18525
18526    #[test]
18527    fn load_compiled_module_source_uses_disk_cache_between_states() {
18528        let temp_dir = tempfile::tempdir().expect("tempdir");
18529        let cache_dir = temp_dir.path().join("cache");
18530        let module_path = temp_dir.path().join("module.js");
18531        std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
18532        let name = module_path.to_string_lossy().to_string();
18533
18534        let mut first_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir.clone()));
18535        let first = load_compiled_module_source(&mut first_state, &name).expect("first compile");
18536        assert_eq!(first_state.module_cache_counters.misses, 1);
18537        assert_eq!(first_state.module_cache_counters.disk_hits, 0);
18538
18539        let key = module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("file key");
18540        let cache_path = disk_cache_path(&cache_dir, &key);
18541        assert!(
18542            cache_path.exists(),
18543            "expected persisted cache at {cache_path:?}"
18544        );
18545
18546        let mut second_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir));
18547        let second =
18548            load_compiled_module_source(&mut second_state, &name).expect("load from disk cache");
18549        assert_eq!(second_state.module_cache_counters.disk_hits, 1);
18550        assert_eq!(second_state.module_cache_counters.misses, 0);
18551        assert_eq!(second_state.module_cache_counters.hits, 0);
18552        assert_eq!(first, second);
18553    }
18554
18555    #[test]
18556    fn load_compiled_module_source_disk_cache_invalidates_when_file_changes() {
18557        let temp_dir = tempfile::tempdir().expect("tempdir");
18558        let cache_dir = temp_dir.path().join("cache");
18559        let module_path = temp_dir.path().join("module.js");
18560        std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
18561        let name = module_path.to_string_lossy().to_string();
18562
18563        let mut prime_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir.clone()));
18564        let first = load_compiled_module_source(&mut prime_state, &name).expect("first compile");
18565        let first_key = module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("key");
18566
18567        std::fs::write(
18568            &module_path,
18569            "export const xyz = 1234567890;\nexport const more = true;\n",
18570        )
18571        .expect("rewrite module");
18572        let second_key = module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("key");
18573        assert_ne!(first_key, second_key);
18574
18575        let mut second_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir));
18576        let second = load_compiled_module_source(&mut second_state, &name).expect("recompile");
18577        assert_eq!(second_state.module_cache_counters.disk_hits, 0);
18578        assert_eq!(second_state.module_cache_counters.misses, 1);
18579        assert_ne!(first, second);
18580    }
18581
18582    #[test]
18583    fn warm_reset_clears_extension_registry_state() {
18584        futures::executor::block_on(async {
18585            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
18586                .await
18587                .expect("create runtime");
18588
18589            runtime
18590                .eval(
18591                    r#"
18592                    __pi_begin_extension("ext.reset", { name: "ext.reset" });
18593                    pi.registerTool({
18594                        name: "warm_reset_tool",
18595                        execute: async (_callId, _input) => ({ ok: true }),
18596                    });
18597                    pi.registerCommand("warm_reset_cmd", {
18598                        handler: async (_args, _ctx) => ({ ok: true }),
18599                    });
18600                    pi.on("startup", async () => {});
18601                    __pi_end_extension();
18602                    "#,
18603                )
18604                .await
18605                .expect("register extension state");
18606
18607            let before = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
18608            assert_eq!(before["extensions"], serde_json::json!(1));
18609            assert_eq!(before["tools"], serde_json::json!(1));
18610            assert_eq!(before["commands"], serde_json::json!(1));
18611
18612            let report = runtime
18613                .reset_for_warm_reload()
18614                .await
18615                .expect("warm reset should run");
18616            assert!(report.reused, "expected warm reuse, got report: {report:?}");
18617            assert!(
18618                report.reason_code.is_none(),
18619                "unexpected warm-reset reason: {:?}",
18620                report.reason_code
18621            );
18622
18623            let after = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
18624            assert_eq!(after["extensions"], serde_json::json!(0));
18625            assert_eq!(after["tools"], serde_json::json!(0));
18626            assert_eq!(after["commands"], serde_json::json!(0));
18627            assert_eq!(after["hooks"], serde_json::json!(0));
18628            assert_eq!(after["pendingTasks"], serde_json::json!(0));
18629            assert_eq!(after["pendingHostcalls"], serde_json::json!(0));
18630        });
18631    }
18632
18633    #[test]
18634    fn warm_reset_reports_pending_rust_work() {
18635        futures::executor::block_on(async {
18636            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
18637                .await
18638                .expect("create runtime");
18639            let _timer = runtime.set_timeout(10);
18640
18641            let report = runtime
18642                .reset_for_warm_reload()
18643                .await
18644                .expect("warm reset should return report");
18645            assert!(!report.reused);
18646            assert_eq!(report.reason_code.as_deref(), Some("pending_rust_work"));
18647        });
18648    }
18649
18650    #[test]
18651    fn warm_reset_reports_pending_js_work() {
18652        futures::executor::block_on(async {
18653            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
18654                .await
18655                .expect("create runtime");
18656
18657            runtime
18658                .eval(
18659                    r#"
18660                    __pi_tasks.set("pending-task", { status: "pending" });
18661                    "#,
18662                )
18663                .await
18664                .expect("inject pending JS task");
18665
18666            let report = runtime
18667                .reset_for_warm_reload()
18668                .await
18669                .expect("warm reset should return report");
18670            assert!(!report.reused);
18671            assert_eq!(report.reason_code.as_deref(), Some("pending_js_work"));
18672
18673            let after = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
18674            assert_eq!(after["pendingTasks"], serde_json::json!(0));
18675        });
18676    }
18677
18678    #[test]
18679    #[allow(clippy::too_many_lines)]
18680    fn reset_transient_state_preserves_compiled_cache_and_clears_transient_state() {
18681        futures::executor::block_on(async {
18682            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
18683                .await
18684                .expect("create runtime");
18685
18686            let cache_key = "pijs://virtual".to_string();
18687            {
18688                let mut state = runtime.module_state.borrow_mut();
18689                let extension_root = PathBuf::from("/tmp/ext-root");
18690                state.extension_roots.push(extension_root.clone());
18691                state
18692                    .extension_root_tiers
18693                    .insert(extension_root.clone(), ProxyStubSourceTier::Community);
18694                state
18695                    .extension_root_scopes
18696                    .insert(extension_root, "@scope".to_string());
18697                state
18698                    .dynamic_virtual_modules
18699                    .insert(cache_key.clone(), "export const v = 1;".to_string());
18700                let mut exports = BTreeSet::new();
18701                exports.insert("v".to_string());
18702                state
18703                    .dynamic_virtual_named_exports
18704                    .insert(cache_key.clone(), exports);
18705                state.compiled_sources.insert(
18706                    cache_key.clone(),
18707                    CompiledModuleCacheEntry {
18708                        cache_key: Some("cache-v1".to_string()),
18709                        source: b"compiled-source".to_vec().into(),
18710                    },
18711                );
18712                state.module_cache_counters = ModuleCacheCounters {
18713                    hits: 3,
18714                    misses: 4,
18715                    invalidations: 5,
18716                    disk_hits: 6,
18717                };
18718            }
18719
18720            runtime
18721                .hostcall_queue
18722                .borrow_mut()
18723                .push_back(HostcallRequest {
18724                    call_id: "call-1".to_string(),
18725                    kind: HostcallKind::Tool {
18726                        name: "read".to_string(),
18727                    },
18728                    payload: serde_json::json!({}),
18729                    trace_id: 1,
18730                    extension_id: Some("ext.reset".to_string()),
18731                });
18732            runtime
18733                .hostcall_tracker
18734                .borrow_mut()
18735                .register("call-1".to_string(), Some(42), 0);
18736            runtime
18737                .hostcalls_total
18738                .store(11, std::sync::atomic::Ordering::SeqCst);
18739            runtime
18740                .hostcalls_timed_out
18741                .store(2, std::sync::atomic::Ordering::SeqCst);
18742            runtime
18743                .tick_counter
18744                .store(7, std::sync::atomic::Ordering::SeqCst);
18745
18746            runtime.reset_transient_state();
18747
18748            {
18749                let state = runtime.module_state.borrow();
18750                assert!(state.extension_roots.is_empty());
18751                assert!(state.extension_root_tiers.is_empty());
18752                assert!(state.extension_root_scopes.is_empty());
18753                assert!(state.dynamic_virtual_modules.is_empty());
18754                assert!(state.dynamic_virtual_named_exports.is_empty());
18755
18756                let cached = state
18757                    .compiled_sources
18758                    .get(&cache_key)
18759                    .expect("compiled source should persist across reset");
18760                assert_eq!(cached.cache_key.as_deref(), Some("cache-v1"));
18761                assert_eq!(cached.source.as_ref(), b"compiled-source");
18762
18763                assert_eq!(state.module_cache_counters.hits, 0);
18764                assert_eq!(state.module_cache_counters.misses, 0);
18765                assert_eq!(state.module_cache_counters.invalidations, 0);
18766                assert_eq!(state.module_cache_counters.disk_hits, 0);
18767            }
18768
18769            assert!(runtime.hostcall_queue.borrow().is_empty());
18770            assert_eq!(runtime.hostcall_tracker.borrow().pending_count(), 0);
18771            assert_eq!(
18772                runtime
18773                    .hostcalls_total
18774                    .load(std::sync::atomic::Ordering::SeqCst),
18775                0
18776            );
18777            assert_eq!(
18778                runtime
18779                    .hostcalls_timed_out
18780                    .load(std::sync::atomic::Ordering::SeqCst),
18781                0
18782            );
18783            assert_eq!(
18784                runtime
18785                    .tick_counter
18786                    .load(std::sync::atomic::Ordering::SeqCst),
18787                0
18788            );
18789        });
18790    }
18791
18792    #[test]
18793    fn warm_isolate_pool_tracks_created_and_reset_counts() {
18794        let cache_dir = tempfile::tempdir().expect("tempdir");
18795        let template = PiJsRuntimeConfig {
18796            cwd: "/tmp/warm-pool".to_string(),
18797            args: vec!["--flag".to_string()],
18798            env: HashMap::from([("PI_POOL".to_string(), "yes".to_string())]),
18799            deny_env: false,
18800            disk_cache_dir: Some(cache_dir.path().join("module-cache")),
18801            ..PiJsRuntimeConfig::default()
18802        };
18803        let expected_disk_cache_dir = template.disk_cache_dir.clone();
18804
18805        let pool = WarmIsolatePool::new(template.clone());
18806        assert_eq!(pool.created_count(), 0);
18807        assert_eq!(pool.reset_count(), 0);
18808
18809        let cfg_a = pool.make_config();
18810        let cfg_b = pool.make_config();
18811        assert_eq!(pool.created_count(), 2);
18812        assert_eq!(cfg_a.cwd, template.cwd);
18813        assert_eq!(cfg_b.cwd, template.cwd);
18814        assert_eq!(cfg_a.args, template.args);
18815        assert_eq!(cfg_a.env.get("PI_POOL"), Some(&"yes".to_string()));
18816        assert_eq!(cfg_a.deny_env, template.deny_env);
18817        assert_eq!(cfg_a.disk_cache_dir, expected_disk_cache_dir);
18818
18819        pool.record_reset();
18820        pool.record_reset();
18821        assert_eq!(pool.reset_count(), 2);
18822    }
18823
18824    #[test]
18825    fn resolver_error_messages_are_classified_deterministically() {
18826        assert_eq!(
18827            unsupported_module_specifier_message("left-pad"),
18828            "Package module specifiers are not supported in PiJS: left-pad"
18829        );
18830        assert_eq!(
18831            unsupported_module_specifier_message("https://example.com/mod.js"),
18832            "Network module imports are not supported in PiJS: https://example.com/mod.js"
18833        );
18834        assert_eq!(
18835            unsupported_module_specifier_message("pi:internal/foo"),
18836            "Unsupported module specifier: pi:internal/foo"
18837        );
18838    }
18839
18840    #[test]
18841    fn resolve_module_path_uses_documented_candidate_order() {
18842        let temp_dir = tempfile::tempdir().expect("tempdir");
18843        let root = temp_dir.path();
18844        let base = root.join("entry.ts");
18845        std::fs::write(&base, "export {};\n").expect("write base");
18846
18847        let pkg_dir = root.join("pkg");
18848        std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg");
18849        let pkg_index_js = pkg_dir.join("index.js");
18850        let pkg_index_ts = pkg_dir.join("index.ts");
18851        std::fs::write(&pkg_index_js, "export const js = true;\n").expect("write index.js");
18852        std::fs::write(&pkg_index_ts, "export const ts = true;\n").expect("write index.ts");
18853
18854        let module_js = root.join("module.js");
18855        let module_ts = root.join("module.ts");
18856        std::fs::write(&module_js, "export const js = true;\n").expect("write module.js");
18857        std::fs::write(&module_ts, "export const ts = true;\n").expect("write module.ts");
18858
18859        let only_json = root.join("only_json.json");
18860        std::fs::write(&only_json, "{\"ok\":true}\n").expect("write only_json.json");
18861
18862        let mode = RepairMode::default();
18863        let roots = vec![];
18864
18865        let resolved_pkg =
18866            resolve_module_path(base.to_string_lossy().as_ref(), "./pkg", mode, &roots)
18867                .expect("resolve ./pkg");
18868        assert_eq!(resolved_pkg, pkg_index_ts);
18869
18870        let resolved_module =
18871            resolve_module_path(base.to_string_lossy().as_ref(), "./module", mode, &roots)
18872                .expect("resolve ./module");
18873        assert_eq!(resolved_module, module_ts);
18874
18875        let resolved_json =
18876            resolve_module_path(base.to_string_lossy().as_ref(), "./only_json", mode, &roots)
18877                .expect("resolve ./only_json");
18878        assert_eq!(resolved_json, only_json);
18879
18880        let file_url = format!("file://{}", module_ts.display());
18881        let resolved_file_url =
18882            resolve_module_path(base.to_string_lossy().as_ref(), &file_url, mode, &roots)
18883                .expect("file://");
18884        assert_eq!(resolved_file_url, module_ts);
18885    }
18886
18887    #[test]
18888    fn resolve_module_path_blocks_file_url_outside_extension_root() {
18889        let temp_dir = tempfile::tempdir().expect("tempdir");
18890        let root = temp_dir.path();
18891        let extension_root = root.join("ext");
18892        std::fs::create_dir_all(&extension_root).expect("mkdir ext");
18893
18894        let base = extension_root.join("index.ts");
18895        std::fs::write(&base, "export {};\n").expect("write base");
18896
18897        let outside = root.join("secret.ts");
18898        std::fs::write(&outside, "export const secret = 1;\n").expect("write outside");
18899
18900        let mode = RepairMode::default();
18901        let roots = vec![extension_root];
18902        let file_url = format!("file://{}", outside.display());
18903        let resolved =
18904            resolve_module_path(base.to_string_lossy().as_ref(), &file_url, mode, &roots);
18905        assert!(
18906            resolved.is_none(),
18907            "file:// import outside extension root should be blocked, got {resolved:?}"
18908        );
18909    }
18910
18911    #[test]
18912    fn resolve_module_path_allows_file_url_inside_extension_root() {
18913        let temp_dir = tempfile::tempdir().expect("tempdir");
18914        let root = temp_dir.path();
18915        let extension_root = root.join("ext");
18916        std::fs::create_dir_all(&extension_root).expect("mkdir ext");
18917
18918        let base = extension_root.join("index.ts");
18919        std::fs::write(&base, "export {};\n").expect("write base");
18920
18921        let inside = extension_root.join("module.ts");
18922        std::fs::write(&inside, "export const ok = 1;\n").expect("write inside");
18923
18924        let mode = RepairMode::default();
18925        let roots = vec![extension_root];
18926        let file_url = format!("file://{}", inside.display());
18927        let resolved =
18928            resolve_module_path(base.to_string_lossy().as_ref(), &file_url, mode, &roots);
18929        assert_eq!(resolved, Some(inside));
18930    }
18931
18932    #[test]
18933    fn pijs_dynamic_import_reports_deterministic_package_error() {
18934        futures::executor::block_on(async {
18935            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
18936                .await
18937                .expect("create runtime");
18938
18939            runtime
18940                .eval(
18941                    r"
18942                    globalThis.packageImportError = {};
18943                    import('left-pad')
18944                      .then(() => {
18945                        globalThis.packageImportError.done = true;
18946                        globalThis.packageImportError.message = '';
18947                      })
18948                      .catch((err) => {
18949                        globalThis.packageImportError.done = true;
18950                        globalThis.packageImportError.message = String((err && err.message) || err || '');
18951                      });
18952                    ",
18953                )
18954                .await
18955                .expect("eval package import");
18956
18957            let result = get_global_json(&runtime, "packageImportError").await;
18958            assert_eq!(result["done"], serde_json::json!(true));
18959            let message = result["message"].as_str().unwrap_or_default();
18960            assert!(
18961                message.contains("Package module specifiers are not supported in PiJS: left-pad"),
18962                "unexpected message: {message}"
18963            );
18964        });
18965    }
18966
18967    #[test]
18968    fn proxy_stub_allowlist_blocks_sensitive_packages() {
18969        assert!(is_proxy_blocklisted_package("node:fs"));
18970        assert!(is_proxy_blocklisted_package("fs"));
18971        assert!(is_proxy_blocklisted_package("child_process"));
18972        assert!(!is_proxy_blocklisted_package("@aliou/pi-utils-settings"));
18973    }
18974
18975    #[test]
18976    fn proxy_stub_allowlist_accepts_curated_scope_and_pi_pattern() {
18977        assert!(is_proxy_allowlisted_package("@sourcegraph/scip-python"));
18978        assert!(is_proxy_allowlisted_package("@aliou/pi-utils-settings"));
18979        assert!(is_proxy_allowlisted_package("@example/pi-helpers"));
18980        assert!(!is_proxy_allowlisted_package("left-pad"));
18981    }
18982
18983    #[test]
18984    fn proxy_stub_allows_same_scope_packages_for_extension() {
18985        let temp_dir = tempfile::tempdir().expect("tempdir");
18986        let root = temp_dir.path().join("community").join("scope-ext");
18987        std::fs::create_dir_all(&root).expect("mkdir root");
18988        std::fs::write(
18989            root.join("package.json"),
18990            r#"{ "name": "@qualisero/my-ext", "version": "1.0.0" }"#,
18991        )
18992        .expect("write package.json");
18993        let base = root.join("index.mjs");
18994        std::fs::write(&base, "export {};\n").expect("write base");
18995
18996        let mut tiers = HashMap::new();
18997        tiers.insert(root.clone(), ProxyStubSourceTier::Community);
18998        let mut scopes = HashMap::new();
18999        scopes.insert(root.clone(), "@qualisero".to_string());
19000
19001        assert!(should_auto_stub_package(
19002            "@qualisero/shared-lib",
19003            base.to_string_lossy().as_ref(),
19004            &[root],
19005            &tiers,
19006            &scopes,
19007        ));
19008    }
19009
19010    #[test]
19011    fn proxy_stub_allows_non_blocklisted_package_for_community_tier() {
19012        let temp_dir = tempfile::tempdir().expect("tempdir");
19013        let root = temp_dir.path().join("community").join("generic-ext");
19014        std::fs::create_dir_all(&root).expect("mkdir root");
19015        let base = root.join("index.mjs");
19016        std::fs::write(&base, "export {};\n").expect("write base");
19017
19018        let mut tiers = HashMap::new();
19019        tiers.insert(root.clone(), ProxyStubSourceTier::Community);
19020
19021        assert!(should_auto_stub_package(
19022            "left-pad",
19023            base.to_string_lossy().as_ref(),
19024            &[root],
19025            &tiers,
19026            &HashMap::new(),
19027        ));
19028    }
19029
19030    #[test]
19031    fn proxy_stub_disallowed_for_official_tier() {
19032        let temp_dir = tempfile::tempdir().expect("tempdir");
19033        let root = temp_dir.path().join("official-pi-mono").join("my-ext");
19034        std::fs::create_dir_all(&root).expect("mkdir root");
19035        let base = root.join("index.mjs");
19036        std::fs::write(&base, "export {};\n").expect("write base");
19037
19038        let mut tiers = HashMap::new();
19039        tiers.insert(root.clone(), ProxyStubSourceTier::Official);
19040
19041        assert!(!should_auto_stub_package(
19042            "left-pad",
19043            base.to_string_lossy().as_ref(),
19044            &[root],
19045            &tiers,
19046            &HashMap::new(),
19047        ));
19048    }
19049
19050    #[test]
19051    fn pijs_dynamic_import_autostrict_allows_missing_npm_proxy_stub() {
19052        const TEST_PKG: &str = "@aliou/pi-missing-proxy-test";
19053        futures::executor::block_on(async {
19054            let temp_dir = tempfile::tempdir().expect("tempdir");
19055            let ext_dir = temp_dir.path().join("community").join("proxy-ext");
19056            std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
19057            let entry = ext_dir.join("index.mjs");
19058            std::fs::write(
19059                &entry,
19060                r#"
19061import dep from "@aliou/pi-missing-proxy-test";
19062globalThis.__proxyProbe = {
19063  kind: typeof dep,
19064  chain: typeof dep.foo.bar(),
19065  primitive: String(dep),
19066};
19067export default dep;
19068"#,
19069            )
19070            .expect("write extension module");
19071
19072            let config = PiJsRuntimeConfig {
19073                repair_mode: RepairMode::AutoStrict,
19074                ..PiJsRuntimeConfig::default()
19075            };
19076            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
19077                DeterministicClock::new(0),
19078                config,
19079                None,
19080            )
19081            .await
19082            .expect("create runtime");
19083            runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/proxy-ext"));
19084
19085            let entry_spec = format!("file://{}", entry.display());
19086            let script = format!(
19087                r#"
19088                globalThis.proxyImport = {{}};
19089                import({entry_spec:?})
19090                  .then(() => {{
19091                    globalThis.proxyImport.done = true;
19092                    globalThis.proxyImport.error = "";
19093                  }})
19094                  .catch((err) => {{
19095                    globalThis.proxyImport.done = true;
19096                    globalThis.proxyImport.error = String((err && err.message) || err || "");
19097                  }});
19098                "#
19099            );
19100            runtime.eval(&script).await.expect("eval import");
19101
19102            let result = get_global_json(&runtime, "proxyImport").await;
19103            assert_eq!(result["done"], serde_json::json!(true));
19104            assert_eq!(result["error"], serde_json::json!(""));
19105
19106            let probe = get_global_json(&runtime, "__proxyProbe").await;
19107            assert_eq!(probe["kind"], serde_json::json!("function"));
19108            assert_eq!(probe["chain"], serde_json::json!("function"));
19109            assert_eq!(probe["primitive"], serde_json::json!(""));
19110
19111            let events = runtime.drain_repair_events();
19112            assert!(events.iter().any(|event| {
19113                event.pattern == RepairPattern::MissingNpmDep
19114                    && event.repair_action.contains(TEST_PKG)
19115            }));
19116        });
19117    }
19118
19119    #[test]
19120    fn pijs_dynamic_import_autosafe_rejects_missing_npm_proxy_stub() {
19121        const TEST_PKG: &str = "@aliou/pi-missing-proxy-test-safe";
19122        futures::executor::block_on(async {
19123            let temp_dir = tempfile::tempdir().expect("tempdir");
19124            let ext_dir = temp_dir.path().join("community").join("proxy-ext-safe");
19125            std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
19126            let entry = ext_dir.join("index.mjs");
19127            std::fs::write(
19128                &entry,
19129                r#"import dep from "@aliou/pi-missing-proxy-test-safe"; export default dep;"#,
19130            )
19131            .expect("write extension module");
19132
19133            let config = PiJsRuntimeConfig {
19134                repair_mode: RepairMode::AutoSafe,
19135                ..PiJsRuntimeConfig::default()
19136            };
19137            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
19138                DeterministicClock::new(0),
19139                config,
19140                None,
19141            )
19142            .await
19143            .expect("create runtime");
19144            runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/proxy-ext-safe"));
19145
19146            let entry_spec = format!("file://{}", entry.display());
19147            let script = format!(
19148                r#"
19149                globalThis.proxySafeImport = {{}};
19150                import({entry_spec:?})
19151                  .then(() => {{
19152                    globalThis.proxySafeImport.done = true;
19153                    globalThis.proxySafeImport.error = "";
19154                  }})
19155                  .catch((err) => {{
19156                    globalThis.proxySafeImport.done = true;
19157                    globalThis.proxySafeImport.error = String((err && err.message) || err || "");
19158                  }});
19159                "#
19160            );
19161            runtime.eval(&script).await.expect("eval import");
19162
19163            let result = get_global_json(&runtime, "proxySafeImport").await;
19164            assert_eq!(result["done"], serde_json::json!(true));
19165            let message = result["error"].as_str().unwrap_or_default();
19166            // Check error class without the full package name at the tail:
19167            // on macOS the longer temp paths can cause QuickJS error
19168            // formatting to truncate the final characters of the message.
19169            assert!(
19170                message.contains("Package module specifiers are not supported in PiJS"),
19171                "unexpected message: {message}"
19172            );
19173        });
19174    }
19175
19176    #[test]
19177    fn pijs_dynamic_import_existing_virtual_module_does_not_emit_missing_npm_repair() {
19178        futures::executor::block_on(async {
19179            let temp_dir = tempfile::tempdir().expect("tempdir");
19180            let ext_dir = temp_dir.path().join("community").join("proxy-ext-existing");
19181            std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
19182            let entry = ext_dir.join("index.mjs");
19183            std::fs::write(
19184                &entry,
19185                r#"
19186import { ConfigLoader } from "@aliou/pi-utils-settings";
19187globalThis.__existingVirtualProbe = typeof ConfigLoader;
19188export default ConfigLoader;
19189"#,
19190            )
19191            .expect("write extension module");
19192
19193            let config = PiJsRuntimeConfig {
19194                repair_mode: RepairMode::AutoStrict,
19195                ..PiJsRuntimeConfig::default()
19196            };
19197            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
19198                DeterministicClock::new(0),
19199                config,
19200                None,
19201            )
19202            .await
19203            .expect("create runtime");
19204            runtime
19205                .add_extension_root_with_id(ext_dir.clone(), Some("community/proxy-ext-existing"));
19206
19207            let entry_spec = format!("file://{}", entry.display());
19208            let script = format!(
19209                r#"
19210                globalThis.proxyExistingImport = {{}};
19211                import({entry_spec:?})
19212                  .then(() => {{
19213                    globalThis.proxyExistingImport.done = true;
19214                    globalThis.proxyExistingImport.error = "";
19215                  }})
19216                  .catch((err) => {{
19217                    globalThis.proxyExistingImport.done = true;
19218                    globalThis.proxyExistingImport.error = String((err && err.message) || err || "");
19219                  }});
19220                "#
19221            );
19222            runtime.eval(&script).await.expect("eval import");
19223
19224            let result = get_global_json(&runtime, "proxyExistingImport").await;
19225            assert_eq!(result["done"], serde_json::json!(true));
19226            assert_eq!(result["error"], serde_json::json!(""));
19227
19228            let probe = get_global_json(&runtime, "__existingVirtualProbe").await;
19229            assert_eq!(probe, serde_json::json!("function"));
19230
19231            let events = runtime.drain_repair_events();
19232            assert!(
19233                !events
19234                    .iter()
19235                    .any(|event| event.pattern == RepairPattern::MissingNpmDep),
19236                "existing virtual module should suppress missing_npm_dep repair events"
19237            );
19238        });
19239    }
19240
19241    #[test]
19242    fn pijs_dynamic_import_reports_deterministic_network_error() {
19243        futures::executor::block_on(async {
19244            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19245                .await
19246                .expect("create runtime");
19247
19248            runtime
19249                .eval(
19250                    r"
19251                    globalThis.networkImportError = {};
19252                    import('https://example.com/mod.js')
19253                      .then(() => {
19254                        globalThis.networkImportError.done = true;
19255                        globalThis.networkImportError.message = '';
19256                      })
19257                      .catch((err) => {
19258                        globalThis.networkImportError.done = true;
19259                        globalThis.networkImportError.message = String((err && err.message) || err || '');
19260                      });
19261                    ",
19262                )
19263                .await
19264                .expect("eval network import");
19265
19266            let result = get_global_json(&runtime, "networkImportError").await;
19267            assert_eq!(result["done"], serde_json::json!(true));
19268            let message = result["message"].as_str().unwrap_or_default();
19269            assert!(
19270                message.contains(
19271                    "Network module imports are not supported in PiJS: https://example.com/mod.js"
19272                ),
19273                "unexpected message: {message}"
19274            );
19275        });
19276    }
19277
19278    // Tests for the Promise bridge (bd-2ke)
19279
19280    #[test]
19281    fn pijs_runtime_creates_hostcall_request() {
19282        futures::executor::block_on(async {
19283            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19284                .await
19285                .expect("create runtime");
19286
19287            // Call pi.tool() which should enqueue a hostcall request
19288            runtime
19289                .eval(r#"pi.tool("read", { path: "test.txt" });"#)
19290                .await
19291                .expect("eval");
19292
19293            // Check that a hostcall request was enqueued
19294            let requests = runtime.drain_hostcall_requests();
19295            assert_eq!(requests.len(), 1);
19296            let req = &requests[0];
19297            assert!(matches!(&req.kind, HostcallKind::Tool { name } if name == "read"));
19298            assert_eq!(req.payload["path"], "test.txt");
19299            assert_eq!(req.extension_id.as_deref(), None);
19300        });
19301    }
19302
19303    #[test]
19304    fn pijs_runtime_hostcall_request_captures_extension_id() {
19305        futures::executor::block_on(async {
19306            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19307                .await
19308                .expect("create runtime");
19309
19310            runtime
19311                .eval(
19312                    r#"
19313                __pi_begin_extension("ext.test", { name: "Test" });
19314                pi.tool("read", { path: "test.txt" });
19315                __pi_end_extension();
19316            "#,
19317                )
19318                .await
19319                .expect("eval");
19320
19321            let requests = runtime.drain_hostcall_requests();
19322            assert_eq!(requests.len(), 1);
19323            assert_eq!(requests[0].extension_id.as_deref(), Some("ext.test"));
19324        });
19325    }
19326
19327    #[test]
19328    fn pijs_runtime_log_hostcall_request_shape() {
19329        futures::executor::block_on(async {
19330            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19331                .await
19332                .expect("create runtime");
19333
19334            runtime
19335                .eval(
19336                    r#"
19337                pi.log({
19338                    level: "info",
19339                    event: "unit.test",
19340                    message: "hello",
19341                    correlation: { scenario_id: "scn-1" }
19342                });
19343            "#,
19344                )
19345                .await
19346                .expect("eval");
19347
19348            let requests = runtime.drain_hostcall_requests();
19349            assert_eq!(requests.len(), 1);
19350            let req = &requests[0];
19351            assert!(matches!(&req.kind, HostcallKind::Log));
19352            assert_eq!(req.payload["level"], "info");
19353            assert_eq!(req.payload["event"], "unit.test");
19354            assert_eq!(req.payload["message"], "hello");
19355        });
19356    }
19357
19358    #[test]
19359    fn pijs_runtime_get_registered_tools_empty() {
19360        futures::executor::block_on(async {
19361            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19362                .await
19363                .expect("create runtime");
19364
19365            let tools = runtime.get_registered_tools().await.expect("get tools");
19366            assert!(tools.is_empty());
19367        });
19368    }
19369
19370    #[test]
19371    fn pijs_runtime_get_registered_tools_single_tool() {
19372        futures::executor::block_on(async {
19373            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19374                .await
19375                .expect("create runtime");
19376
19377            runtime
19378                .eval(
19379                    r"
19380                __pi_begin_extension('ext.test', { name: 'Test' });
19381                pi.registerTool({
19382                    name: 'my_tool',
19383                    label: 'My Tool',
19384                    description: 'Does stuff',
19385                    parameters: { type: 'object', properties: { path: { type: 'string' } } },
19386                    execute: async (_callId, _input) => { return { ok: true }; },
19387                });
19388                __pi_end_extension();
19389            ",
19390                )
19391                .await
19392                .expect("eval");
19393
19394            let tools = runtime.get_registered_tools().await.expect("get tools");
19395            assert_eq!(tools.len(), 1);
19396            assert_eq!(
19397                tools[0],
19398                ExtensionToolDef {
19399                    name: "my_tool".to_string(),
19400                    label: Some("My Tool".to_string()),
19401                    description: "Does stuff".to_string(),
19402                    parameters: serde_json::json!({
19403                        "type": "object",
19404                        "properties": {
19405                            "path": { "type": "string" }
19406                        }
19407                    }),
19408                }
19409            );
19410        });
19411    }
19412
19413    #[test]
19414    fn pijs_runtime_get_registered_tools_sorts_by_name() {
19415        futures::executor::block_on(async {
19416            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19417                .await
19418                .expect("create runtime");
19419
19420            runtime
19421                .eval(
19422                    r"
19423                __pi_begin_extension('ext.test', { name: 'Test' });
19424                pi.registerTool({ name: 'b', execute: async (_callId, _input) => { return {}; } });
19425                pi.registerTool({ name: 'a', execute: async (_callId, _input) => { return {}; } });
19426                __pi_end_extension();
19427            ",
19428                )
19429                .await
19430                .expect("eval");
19431
19432            let tools = runtime.get_registered_tools().await.expect("get tools");
19433            assert_eq!(
19434                tools
19435                    .iter()
19436                    .map(|tool| tool.name.as_str())
19437                    .collect::<Vec<_>>(),
19438                vec!["a", "b"]
19439            );
19440        });
19441    }
19442
19443    #[test]
19444    fn hostcall_params_hash_is_stable_for_key_ordering() {
19445        let first = serde_json::json!({ "b": 2, "a": 1 });
19446        let second = serde_json::json!({ "a": 1, "b": 2 });
19447
19448        assert_eq!(
19449            hostcall_params_hash("http", &first),
19450            hostcall_params_hash("http", &second)
19451        );
19452        assert_ne!(
19453            hostcall_params_hash("http", &first),
19454            hostcall_params_hash("tool", &first)
19455        );
19456    }
19457
19458    #[test]
19459    #[allow(clippy::too_many_lines)]
19460    fn hostcall_request_params_for_hash_uses_canonical_shapes() {
19461        let cases = vec![
19462            (
19463                HostcallRequest {
19464                    call_id: "tool-case".to_string(),
19465                    kind: HostcallKind::Tool {
19466                        name: "read".to_string(),
19467                    },
19468                    payload: serde_json::json!({ "path": "README.md" }),
19469                    trace_id: 0,
19470                    extension_id: None,
19471                },
19472                serde_json::json!({ "name": "read", "input": { "path": "README.md" } }),
19473            ),
19474            (
19475                HostcallRequest {
19476                    call_id: "exec-case".to_string(),
19477                    kind: HostcallKind::Exec {
19478                        cmd: "echo".to_string(),
19479                    },
19480                    payload: serde_json::json!({
19481                        "command": "legacy alias should be dropped",
19482                        "args": ["hello"],
19483                        "options": { "timeout": 1000 }
19484                    }),
19485                    trace_id: 0,
19486                    extension_id: None,
19487                },
19488                serde_json::json!({
19489                    "cmd": "echo",
19490                    "args": ["hello"],
19491                    "options": { "timeout": 1000 }
19492                }),
19493            ),
19494            (
19495                HostcallRequest {
19496                    call_id: "session-object".to_string(),
19497                    kind: HostcallKind::Session {
19498                        op: "set_model".to_string(),
19499                    },
19500                    payload: serde_json::json!({
19501                        "provider": "openai",
19502                        "modelId": "gpt-4o"
19503                    }),
19504                    trace_id: 0,
19505                    extension_id: None,
19506                },
19507                serde_json::json!({
19508                    "op": "set_model",
19509                    "provider": "openai",
19510                    "modelId": "gpt-4o"
19511                }),
19512            ),
19513            (
19514                HostcallRequest {
19515                    call_id: "ui-non-object".to_string(),
19516                    kind: HostcallKind::Ui {
19517                        op: "set_status".to_string(),
19518                    },
19519                    payload: serde_json::json!("ready"),
19520                    trace_id: 0,
19521                    extension_id: None,
19522                },
19523                serde_json::json!({ "op": "set_status", "payload": "ready" }),
19524            ),
19525            (
19526                HostcallRequest {
19527                    call_id: "events-non-object".to_string(),
19528                    kind: HostcallKind::Events {
19529                        op: "emit".to_string(),
19530                    },
19531                    payload: serde_json::json!(42),
19532                    trace_id: 0,
19533                    extension_id: None,
19534                },
19535                serde_json::json!({ "op": "emit", "payload": 42 }),
19536            ),
19537            (
19538                HostcallRequest {
19539                    call_id: "session-null".to_string(),
19540                    kind: HostcallKind::Session {
19541                        op: "get_state".to_string(),
19542                    },
19543                    payload: serde_json::Value::Null,
19544                    trace_id: 0,
19545                    extension_id: None,
19546                },
19547                serde_json::json!({ "op": "get_state" }),
19548            ),
19549            (
19550                HostcallRequest {
19551                    call_id: "log-entry".to_string(),
19552                    kind: HostcallKind::Log,
19553                    payload: serde_json::json!({
19554                        "level": "info",
19555                        "event": "unit.test",
19556                        "message": "hello",
19557                        "correlation": { "scenario_id": "scn-1" }
19558                    }),
19559                    trace_id: 0,
19560                    extension_id: None,
19561                },
19562                serde_json::json!({
19563                    "level": "info",
19564                    "event": "unit.test",
19565                    "message": "hello",
19566                    "correlation": { "scenario_id": "scn-1" }
19567                }),
19568            ),
19569        ];
19570
19571        for (request, expected) in cases {
19572            assert_eq!(
19573                request.params_for_hash(),
19574                expected,
19575                "canonical params mismatch for {}",
19576                request.call_id
19577            );
19578        }
19579    }
19580
19581    #[test]
19582    fn hostcall_request_params_hash_matches_wasm_contract_for_canonical_requests() {
19583        let requests = vec![
19584            HostcallRequest {
19585                call_id: "hash-session".to_string(),
19586                kind: HostcallKind::Session {
19587                    op: "set_model".to_string(),
19588                },
19589                payload: serde_json::json!({
19590                    "modelId": "gpt-4o",
19591                    "provider": "openai"
19592                }),
19593                trace_id: 0,
19594                extension_id: Some("ext.test".to_string()),
19595            },
19596            HostcallRequest {
19597                call_id: "hash-ui".to_string(),
19598                kind: HostcallKind::Ui {
19599                    op: "set_status".to_string(),
19600                },
19601                payload: serde_json::json!("thinking"),
19602                trace_id: 0,
19603                extension_id: Some("ext.test".to_string()),
19604            },
19605            HostcallRequest {
19606                call_id: "hash-log".to_string(),
19607                kind: HostcallKind::Log,
19608                payload: serde_json::json!({
19609                    "level": "warn",
19610                    "event": "log.test",
19611                    "message": "warn line",
19612                    "correlation": { "scenario_id": "scn-2" }
19613                }),
19614                trace_id: 0,
19615                extension_id: Some("ext.test".to_string()),
19616            },
19617        ];
19618
19619        for request in requests {
19620            let params = request.params_for_hash();
19621            let js_hash = request.params_hash();
19622
19623            // Validate streaming hash matches the reference implementation.
19624            let wasm_contract_hash =
19625                crate::extensions::hostcall_params_hash(request.method(), &params);
19626
19627            assert_eq!(
19628                js_hash, wasm_contract_hash,
19629                "hash parity mismatch for {}",
19630                request.call_id
19631            );
19632        }
19633    }
19634
19635    #[test]
19636    fn hostcall_request_io_uring_capability_and_hint_mappings_are_deterministic() {
19637        let cases = vec![
19638            (
19639                HostcallRequest {
19640                    call_id: "io-read".to_string(),
19641                    kind: HostcallKind::Tool {
19642                        name: "read".to_string(),
19643                    },
19644                    payload: serde_json::Value::Null,
19645                    trace_id: 0,
19646                    extension_id: None,
19647                },
19648                HostcallCapabilityClass::Filesystem,
19649                HostcallIoHint::IoHeavy,
19650            ),
19651            (
19652                HostcallRequest {
19653                    call_id: "io-bash".to_string(),
19654                    kind: HostcallKind::Tool {
19655                        name: "bash".to_string(),
19656                    },
19657                    payload: serde_json::Value::Null,
19658                    trace_id: 0,
19659                    extension_id: None,
19660                },
19661                HostcallCapabilityClass::Execution,
19662                HostcallIoHint::CpuBound,
19663            ),
19664            (
19665                HostcallRequest {
19666                    call_id: "io-http".to_string(),
19667                    kind: HostcallKind::Http,
19668                    payload: serde_json::Value::Null,
19669                    trace_id: 0,
19670                    extension_id: None,
19671                },
19672                HostcallCapabilityClass::Network,
19673                HostcallIoHint::IoHeavy,
19674            ),
19675            (
19676                HostcallRequest {
19677                    call_id: "io-session".to_string(),
19678                    kind: HostcallKind::Session {
19679                        op: "get_state".to_string(),
19680                    },
19681                    payload: serde_json::Value::Null,
19682                    trace_id: 0,
19683                    extension_id: None,
19684                },
19685                HostcallCapabilityClass::Session,
19686                HostcallIoHint::Unknown,
19687            ),
19688            (
19689                HostcallRequest {
19690                    call_id: "io-log".to_string(),
19691                    kind: HostcallKind::Log,
19692                    payload: serde_json::Value::Null,
19693                    trace_id: 0,
19694                    extension_id: None,
19695                },
19696                HostcallCapabilityClass::Telemetry,
19697                HostcallIoHint::Unknown,
19698            ),
19699        ];
19700
19701        for (request, expected_capability, expected_hint) in cases {
19702            assert_eq!(
19703                request.io_uring_capability_class(),
19704                expected_capability,
19705                "capability mismatch for {}",
19706                request.call_id
19707            );
19708            assert_eq!(
19709                request.io_uring_io_hint(),
19710                expected_hint,
19711                "io hint mismatch for {}",
19712                request.call_id
19713            );
19714        }
19715    }
19716
19717    #[test]
19718    fn hostcall_request_io_uring_lane_input_preserves_queue_and_force_flags() {
19719        let request = HostcallRequest {
19720            call_id: "io-lane-input".to_string(),
19721            kind: HostcallKind::Tool {
19722                name: "write".to_string(),
19723            },
19724            payload: serde_json::json!({ "path": "notes.txt", "content": "ok" }),
19725            trace_id: 0,
19726            extension_id: Some("ext.test".to_string()),
19727        };
19728
19729        let input = request.io_uring_lane_input(17, true);
19730        assert_eq!(input.capability, HostcallCapabilityClass::Filesystem);
19731        assert_eq!(input.io_hint, HostcallIoHint::IoHeavy);
19732        assert_eq!(input.queue_depth, 17);
19733        assert!(input.force_compat_lane);
19734    }
19735
19736    #[test]
19737    fn pijs_runtime_multiple_hostcalls() {
19738        futures::executor::block_on(async {
19739            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19740                .await
19741                .expect("create runtime");
19742
19743            runtime
19744                .eval(
19745                    r#"
19746            pi.tool("read", { path: "a.txt" });
19747            pi.exec("ls", ["-la"]);
19748            pi.http({ url: "https://example.com" });
19749        "#,
19750                )
19751                .await
19752                .expect("eval");
19753
19754            let requests = runtime.drain_hostcall_requests();
19755            let kinds = requests
19756                .iter()
19757                .map(|req| format!("{:?}", req.kind))
19758                .collect::<Vec<_>>();
19759            assert_eq!(requests.len(), 3, "hostcalls: {kinds:?}");
19760
19761            assert!(matches!(&requests[0].kind, HostcallKind::Tool { name } if name == "read"));
19762            assert!(matches!(&requests[1].kind, HostcallKind::Exec { cmd } if cmd == "ls"));
19763            assert!(matches!(&requests[2].kind, HostcallKind::Http));
19764        });
19765    }
19766
19767    #[test]
19768    fn pijs_runtime_hostcall_completion_resolves_promise() {
19769        futures::executor::block_on(async {
19770            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19771                .await
19772                .expect("create runtime");
19773
19774            // Set up a promise handler that stores the result
19775            runtime
19776                .eval(
19777                    r#"
19778            globalThis.result = null;
19779            pi.tool("read", { path: "test.txt" }).then(r => {
19780                globalThis.result = r;
19781            });
19782        "#,
19783                )
19784                .await
19785                .expect("eval");
19786
19787            // Get the hostcall request
19788            let requests = runtime.drain_hostcall_requests();
19789            assert_eq!(requests.len(), 1);
19790            let call_id = requests[0].call_id.clone();
19791
19792            // Complete the hostcall
19793            runtime.complete_hostcall(
19794                call_id,
19795                HostcallOutcome::Success(serde_json::json!({ "content": "hello world" })),
19796            );
19797
19798            // Tick to deliver the completion
19799            let stats = runtime.tick().await.expect("tick");
19800            assert!(stats.ran_macrotask);
19801
19802            // Verify the promise was resolved with the correct value
19803            runtime
19804                .eval(
19805                    r#"
19806            if (globalThis.result === null) {
19807                throw new Error("Promise not resolved");
19808            }
19809            if (globalThis.result.content !== "hello world") {
19810                throw new Error("Wrong result: " + JSON.stringify(globalThis.result));
19811            }
19812        "#,
19813                )
19814                .await
19815                .expect("verify result");
19816        });
19817    }
19818
19819    #[test]
19820    fn pijs_runtime_hostcall_error_rejects_promise() {
19821        futures::executor::block_on(async {
19822            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19823                .await
19824                .expect("create runtime");
19825
19826            // Set up a promise handler that captures rejection
19827            runtime
19828                .eval(
19829                    r#"
19830            globalThis.error = null;
19831            pi.tool("read", { path: "nonexistent.txt" }).catch(e => {
19832                globalThis.error = { code: e.code, message: e.message };
19833            });
19834        "#,
19835                )
19836                .await
19837                .expect("eval");
19838
19839            let requests = runtime.drain_hostcall_requests();
19840            let call_id = requests[0].call_id.clone();
19841
19842            // Complete with an error
19843            runtime.complete_hostcall(
19844                call_id,
19845                HostcallOutcome::Error {
19846                    code: "ENOENT".to_string(),
19847                    message: "File not found".to_string(),
19848                },
19849            );
19850
19851            runtime.tick().await.expect("tick");
19852
19853            // Verify the promise was rejected
19854            runtime
19855                .eval(
19856                    r#"
19857            if (globalThis.error === null) {
19858                throw new Error("Promise not rejected");
19859            }
19860            if (globalThis.error.code !== "ENOENT") {
19861                throw new Error("Wrong error code: " + globalThis.error.code);
19862            }
19863        "#,
19864                )
19865                .await
19866                .expect("verify error");
19867        });
19868    }
19869
19870    #[test]
19871    fn pijs_runtime_tick_stats() {
19872        futures::executor::block_on(async {
19873            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19874                .await
19875                .expect("create runtime");
19876
19877            // No pending tasks
19878            let stats = runtime.tick().await.expect("tick");
19879            assert!(!stats.ran_macrotask);
19880            assert_eq!(stats.pending_hostcalls, 0);
19881
19882            // Create a hostcall
19883            runtime.eval(r#"pi.tool("test", {});"#).await.expect("eval");
19884
19885            let requests = runtime.drain_hostcall_requests();
19886            assert_eq!(requests.len(), 1);
19887
19888            // Complete it
19889            runtime.complete_hostcall(
19890                requests[0].call_id.clone(),
19891                HostcallOutcome::Success(serde_json::json!(null)),
19892            );
19893
19894            let stats = runtime.tick().await.expect("tick");
19895            assert!(stats.ran_macrotask);
19896        });
19897    }
19898
19899    #[test]
19900    fn pijs_hostcall_timeout_rejects_promise() {
19901        futures::executor::block_on(async {
19902            let clock = Arc::new(DeterministicClock::new(0));
19903            let mut config = PiJsRuntimeConfig::default();
19904            config.limits.hostcall_timeout_ms = Some(50);
19905
19906            let runtime =
19907                PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
19908                    .await
19909                    .expect("create runtime");
19910
19911            runtime
19912                .eval(
19913                    r#"
19914                    globalThis.done = false;
19915                    globalThis.code = null;
19916                    pi.tool("read", { path: "test.txt" })
19917                        .then(() => { globalThis.done = true; })
19918                        .catch((e) => { globalThis.code = e.code; globalThis.done = true; });
19919                    "#,
19920                )
19921                .await
19922                .expect("eval");
19923
19924            let requests = runtime.drain_hostcall_requests();
19925            assert_eq!(requests.len(), 1);
19926
19927            clock.set(50);
19928            let stats = runtime.tick().await.expect("tick");
19929            assert!(stats.ran_macrotask);
19930            assert_eq!(stats.hostcalls_timed_out, 1);
19931            assert_eq!(
19932                get_global_json(&runtime, "done").await,
19933                serde_json::json!(true)
19934            );
19935            assert_eq!(
19936                get_global_json(&runtime, "code").await,
19937                serde_json::json!("timeout")
19938            );
19939
19940            // Late completions should be ignored.
19941            runtime.complete_hostcall(
19942                requests[0].call_id.clone(),
19943                HostcallOutcome::Success(serde_json::json!({ "ok": true })),
19944            );
19945            let stats = runtime.tick().await.expect("tick late completion");
19946            assert!(stats.ran_macrotask);
19947            assert_eq!(stats.hostcalls_timed_out, 1);
19948        });
19949    }
19950
19951    #[test]
19952    fn pijs_interrupt_budget_aborts_eval() {
19953        futures::executor::block_on(async {
19954            let mut config = PiJsRuntimeConfig::default();
19955            config.limits.interrupt_budget = Some(0);
19956
19957            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
19958                DeterministicClock::new(0),
19959                config,
19960                None,
19961            )
19962            .await
19963            .expect("create runtime");
19964
19965            let err = runtime
19966                .eval(
19967                    r"
19968                    let sum = 0;
19969                    for (let i = 0; i < 1000000; i++) { sum += i; }
19970                    ",
19971                )
19972                .await
19973                .expect_err("expected budget exceed");
19974
19975            assert!(err.to_string().contains("PiJS execution budget exceeded"));
19976        });
19977    }
19978
19979    #[test]
19980    fn pijs_microtasks_drain_before_next_macrotask() {
19981        futures::executor::block_on(async {
19982            let clock = Arc::new(DeterministicClock::new(0));
19983            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
19984                .await
19985                .expect("create runtime");
19986
19987            runtime
19988                .eval(r"globalThis.order = []; globalThis.__pi_done = false;")
19989                .await
19990                .expect("init order");
19991
19992            let timer_id = runtime.set_timeout(10);
19993            runtime
19994                .eval(&format!(
19995                    r#"__pi_register_timer({timer_id}, () => {{
19996                        globalThis.order.push("timer");
19997                        Promise.resolve().then(() => globalThis.order.push("timer-micro"));
19998                    }});"#
19999                ))
20000                .await
20001                .expect("register timer");
20002
20003            runtime
20004                .eval(
20005                    r#"
20006                    pi.tool("read", {}).then(() => {
20007                        globalThis.order.push("hostcall");
20008                        Promise.resolve().then(() => globalThis.order.push("hostcall-micro"));
20009                    });
20010                    "#,
20011                )
20012                .await
20013                .expect("enqueue hostcall");
20014
20015            let requests = runtime.drain_hostcall_requests();
20016            let call_id = requests
20017                .into_iter()
20018                .next()
20019                .expect("hostcall request")
20020                .call_id;
20021
20022            runtime.complete_hostcall(call_id, HostcallOutcome::Success(serde_json::json!(null)));
20023
20024            // Make the timer due as well.
20025            clock.set(10);
20026
20027            // Tick 1: hostcall completion runs first, and its microtasks drain immediately.
20028            runtime.tick().await.expect("tick hostcall");
20029            let after_first = get_global_json(&runtime, "order").await;
20030            assert_eq!(
20031                after_first,
20032                serde_json::json!(["hostcall", "hostcall-micro"])
20033            );
20034
20035            // Tick 2: timer runs, and its microtasks drain before the next macrotask.
20036            runtime.tick().await.expect("tick timer");
20037            let after_second = get_global_json(&runtime, "order").await;
20038            assert_eq!(
20039                after_second,
20040                serde_json::json!(["hostcall", "hostcall-micro", "timer", "timer-micro"])
20041            );
20042        });
20043    }
20044
20045    #[test]
20046    fn pijs_clear_timeout_prevents_timer_callback() {
20047        futures::executor::block_on(async {
20048            let clock = Arc::new(DeterministicClock::new(0));
20049            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
20050                .await
20051                .expect("create runtime");
20052
20053            runtime
20054                .eval(r"globalThis.order = []; ")
20055                .await
20056                .expect("init order");
20057
20058            let timer_id = runtime.set_timeout(10);
20059            runtime
20060                .eval(&format!(
20061                    r#"__pi_register_timer({timer_id}, () => globalThis.order.push("timer"));"#
20062                ))
20063                .await
20064                .expect("register timer");
20065
20066            assert!(runtime.clear_timeout(timer_id));
20067            clock.set(10);
20068
20069            let stats = runtime.tick().await.expect("tick");
20070            assert!(!stats.ran_macrotask);
20071
20072            let order = get_global_json(&runtime, "order").await;
20073            assert_eq!(order, serde_json::json!([]));
20074        });
20075    }
20076
20077    #[test]
20078    fn pijs_env_get_honors_allowlist() {
20079        futures::executor::block_on(async {
20080            let clock = Arc::new(DeterministicClock::new(0));
20081            let mut env = HashMap::new();
20082            env.insert("HOME".to_string(), "/virtual/home".to_string());
20083            env.insert("PI_IMAGE_SAVE_MODE".to_string(), "tmp".to_string());
20084            env.insert(
20085                "AWS_SECRET_ACCESS_KEY".to_string(),
20086                "nope-do-not-expose".to_string(),
20087            );
20088            let config = PiJsRuntimeConfig {
20089                cwd: "/virtual/cwd".to_string(),
20090                args: vec!["--flag".to_string()],
20091                env,
20092                limits: PiJsRuntimeLimits::default(),
20093                repair_mode: RepairMode::default(),
20094                allow_unsafe_sync_exec: false,
20095                deny_env: false,
20096                disk_cache_dir: None,
20097            };
20098            let runtime =
20099                PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
20100                    .await
20101                    .expect("create runtime");
20102
20103            runtime
20104                .eval(
20105                    r#"
20106                    globalThis.home = pi.env.get("HOME");
20107                    globalThis.mode = pi.env.get("PI_IMAGE_SAVE_MODE");
20108                    globalThis.missing_is_undefined = (pi.env.get("NOPE") === undefined);
20109                    globalThis.secret_is_undefined = (pi.env.get("AWS_SECRET_ACCESS_KEY") === undefined);
20110                    globalThis.process_secret_is_undefined = (process.env.AWS_SECRET_ACCESS_KEY === undefined);
20111                    globalThis.secret_in_env = ("AWS_SECRET_ACCESS_KEY" in process.env);
20112                    "#,
20113                )
20114                .await
20115                .expect("eval env");
20116
20117            assert_eq!(
20118                get_global_json(&runtime, "home").await,
20119                serde_json::json!("/virtual/home")
20120            );
20121            assert_eq!(
20122                get_global_json(&runtime, "mode").await,
20123                serde_json::json!("tmp")
20124            );
20125            assert_eq!(
20126                get_global_json(&runtime, "missing_is_undefined").await,
20127                serde_json::json!(true)
20128            );
20129            assert_eq!(
20130                get_global_json(&runtime, "secret_is_undefined").await,
20131                serde_json::json!(true)
20132            );
20133            assert_eq!(
20134                get_global_json(&runtime, "process_secret_is_undefined").await,
20135                serde_json::json!(true)
20136            );
20137            assert_eq!(
20138                get_global_json(&runtime, "secret_in_env").await,
20139                serde_json::json!(false)
20140            );
20141        });
20142    }
20143
20144    #[test]
20145    fn pijs_process_path_crypto_time_apis_smoke() {
20146        futures::executor::block_on(async {
20147            let clock = Arc::new(DeterministicClock::new(123));
20148            let config = PiJsRuntimeConfig {
20149                cwd: "/virtual/cwd".to_string(),
20150                args: vec!["a".to_string(), "b".to_string()],
20151                env: HashMap::new(),
20152                limits: PiJsRuntimeLimits::default(),
20153                repair_mode: RepairMode::default(),
20154                allow_unsafe_sync_exec: false,
20155                deny_env: false,
20156                disk_cache_dir: None,
20157            };
20158            let runtime =
20159                PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
20160                    .await
20161                    .expect("create runtime");
20162
20163            runtime
20164                .eval(
20165                    r#"
20166                    globalThis.cwd = pi.process.cwd;
20167                    globalThis.args = pi.process.args;
20168                    globalThis.pi_process_is_frozen = Object.isFrozen(pi.process);
20169                    globalThis.pi_args_is_frozen = Object.isFrozen(pi.process.args);
20170                    try { pi.process.cwd = "/hacked"; } catch (_) {}
20171                    try { pi.process.args.push("c"); } catch (_) {}
20172                    globalThis.cwd_after_mut = pi.process.cwd;
20173                    globalThis.args_after_mut = pi.process.args;
20174
20175                    globalThis.joined = pi.path.join("/a", "b", "..", "c");
20176                    globalThis.base = pi.path.basename("/a/b/c.txt");
20177                    globalThis.norm = pi.path.normalize("/a/./b//../c/");
20178
20179                    globalThis.hash = pi.crypto.sha256Hex("abc");
20180                    globalThis.bytes = pi.crypto.randomBytes(32);
20181
20182                    globalThis.now = pi.time.nowMs();
20183                    globalThis.done = false;
20184                    pi.time.sleep(10).then(() => { globalThis.done = true; });
20185                    "#,
20186                )
20187                .await
20188                .expect("eval apis");
20189
20190            for (key, expected) in [
20191                ("cwd", serde_json::json!("/virtual/cwd")),
20192                ("args", serde_json::json!(["a", "b"])),
20193                ("pi_process_is_frozen", serde_json::json!(true)),
20194                ("pi_args_is_frozen", serde_json::json!(true)),
20195                ("cwd_after_mut", serde_json::json!("/virtual/cwd")),
20196                ("args_after_mut", serde_json::json!(["a", "b"])),
20197                ("joined", serde_json::json!("/a/c")),
20198                ("base", serde_json::json!("c.txt")),
20199                ("norm", serde_json::json!("/a/c")),
20200                (
20201                    "hash",
20202                    serde_json::json!(
20203                        "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
20204                    ),
20205                ),
20206            ] {
20207                assert_eq!(get_global_json(&runtime, key).await, expected);
20208            }
20209
20210            let bytes = get_global_json(&runtime, "bytes").await;
20211            let bytes_arr = bytes.as_array().expect("bytes array");
20212            assert_eq!(bytes_arr.len(), 32);
20213            assert!(
20214                bytes_arr
20215                    .iter()
20216                    .all(|value| value.as_u64().is_some_and(|n| n <= 255)),
20217                "bytes must be numbers in 0..=255: {bytes}"
20218            );
20219
20220            assert_eq!(
20221                get_global_json(&runtime, "now").await,
20222                serde_json::json!(123)
20223            );
20224            assert_eq!(
20225                get_global_json(&runtime, "done").await,
20226                serde_json::json!(false)
20227            );
20228
20229            clock.set(133);
20230            runtime.tick().await.expect("tick sleep");
20231            assert_eq!(
20232                get_global_json(&runtime, "done").await,
20233                serde_json::json!(true)
20234            );
20235        });
20236    }
20237
20238    #[test]
20239    fn pijs_inbound_event_fifo_and_microtask_fixpoint() {
20240        futures::executor::block_on(async {
20241            let clock = Arc::new(DeterministicClock::new(0));
20242            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
20243                .await
20244                .expect("create runtime");
20245
20246            runtime
20247                .eval(
20248                    r#"
20249                    globalThis.order = [];
20250                    __pi_add_event_listener("evt", (payload) => {
20251                        globalThis.order.push(payload.n);
20252                        Promise.resolve().then(() => globalThis.order.push(payload.n + 1000));
20253                    });
20254                    "#,
20255                )
20256                .await
20257                .expect("install listener");
20258
20259            runtime.enqueue_event("evt", serde_json::json!({ "n": 1 }));
20260            runtime.enqueue_event("evt", serde_json::json!({ "n": 2 }));
20261
20262            runtime.tick().await.expect("tick 1");
20263            let after_first = get_global_json(&runtime, "order").await;
20264            assert_eq!(after_first, serde_json::json!([1, 1001]));
20265
20266            runtime.tick().await.expect("tick 2");
20267            let after_second = get_global_json(&runtime, "order").await;
20268            assert_eq!(after_second, serde_json::json!([1, 1001, 2, 1002]));
20269        });
20270    }
20271
20272    #[derive(Debug, Clone)]
20273    struct XorShift64 {
20274        state: u64,
20275    }
20276
20277    impl XorShift64 {
20278        const fn new(seed: u64) -> Self {
20279            let seed = seed ^ 0x9E37_79B9_7F4A_7C15;
20280            Self { state: seed }
20281        }
20282
20283        fn next_u64(&mut self) -> u64 {
20284            let mut x = self.state;
20285            x ^= x << 13;
20286            x ^= x >> 7;
20287            x ^= x << 17;
20288            self.state = x;
20289            x
20290        }
20291
20292        fn next_range_u64(&mut self, upper_exclusive: u64) -> u64 {
20293            if upper_exclusive == 0 {
20294                return 0;
20295            }
20296            self.next_u64() % upper_exclusive
20297        }
20298
20299        fn next_usize(&mut self, upper_exclusive: usize) -> usize {
20300            let upper = u64::try_from(upper_exclusive).expect("usize fits u64");
20301            let value = self.next_range_u64(upper);
20302            usize::try_from(value).expect("value < upper_exclusive")
20303        }
20304    }
20305
20306    #[allow(clippy::future_not_send)]
20307    async fn run_seeded_runtime_trace(seed: u64) -> serde_json::Value {
20308        let clock = Arc::new(DeterministicClock::new(0));
20309        let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
20310            .await
20311            .expect("create runtime");
20312
20313        runtime
20314            .eval(
20315                r#"
20316                globalThis.order = [];
20317                __pi_add_event_listener("evt", (payload) => {
20318                    globalThis.order.push("event:" + payload.step);
20319                    Promise.resolve().then(() => globalThis.order.push("event-micro:" + payload.step));
20320                });
20321                "#,
20322            )
20323            .await
20324            .expect("init");
20325
20326        let mut rng = XorShift64::new(seed);
20327        let mut timers = Vec::new();
20328
20329        for step in 0..64u64 {
20330            match rng.next_range_u64(6) {
20331                0 => {
20332                    runtime
20333                        .eval(&format!(
20334                            r#"
20335                            pi.tool("test", {{ step: {step} }}).then(() => {{
20336                                globalThis.order.push("hostcall:{step}");
20337                                Promise.resolve().then(() => globalThis.order.push("hostcall-micro:{step}"));
20338                            }});
20339                            "#
20340                        ))
20341                        .await
20342                        .expect("enqueue hostcall");
20343
20344                    for request in runtime.drain_hostcall_requests() {
20345                        runtime.complete_hostcall(
20346                            request.call_id,
20347                            HostcallOutcome::Success(serde_json::json!({ "step": step })),
20348                        );
20349                    }
20350                }
20351                1 => {
20352                    let delay_ms = rng.next_range_u64(25);
20353                    let timer_id = runtime.set_timeout(delay_ms);
20354                    timers.push(timer_id);
20355                    runtime
20356                        .eval(&format!(
20357                            r#"__pi_register_timer({timer_id}, () => {{
20358                                globalThis.order.push("timer:{step}");
20359                                Promise.resolve().then(() => globalThis.order.push("timer-micro:{step}"));
20360                            }});"#
20361                        ))
20362                        .await
20363                        .expect("register timer");
20364                }
20365                2 => {
20366                    runtime.enqueue_event("evt", serde_json::json!({ "step": step }));
20367                }
20368                3 => {
20369                    if !timers.is_empty() {
20370                        let idx = rng.next_usize(timers.len());
20371                        let _ = runtime.clear_timeout(timers[idx]);
20372                    }
20373                }
20374                4 => {
20375                    let delta_ms = rng.next_range_u64(50);
20376                    clock.advance(delta_ms);
20377                }
20378                _ => {}
20379            }
20380
20381            // Drive the loop a bit.
20382            for _ in 0..3 {
20383                if !runtime.has_pending() {
20384                    break;
20385                }
20386                let _ = runtime.tick().await.expect("tick");
20387            }
20388        }
20389
20390        drain_until_idle(&runtime, &clock).await;
20391        get_global_json(&runtime, "order").await
20392    }
20393
20394    #[test]
20395    fn pijs_seeded_trace_is_deterministic() {
20396        futures::executor::block_on(async {
20397            let a = run_seeded_runtime_trace(0x00C0_FFEE).await;
20398            let b = run_seeded_runtime_trace(0x00C0_FFEE).await;
20399            assert_eq!(a, b);
20400        });
20401    }
20402
20403    #[test]
20404    fn pijs_events_on_returns_unsubscribe_and_removes_handler() {
20405        futures::executor::block_on(async {
20406            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20407                .await
20408                .expect("create runtime");
20409
20410            runtime
20411                .eval(
20412                    r#"
20413                    globalThis.seen = [];
20414                    globalThis.done = false;
20415
20416                    __pi_begin_extension("ext.b", { name: "ext.b" });
20417                    const off = pi.events.on("custom_event", (payload, _ctx) => { globalThis.seen.push(payload); });
20418                    if (typeof off !== "function") throw new Error("expected unsubscribe function");
20419                    __pi_end_extension();
20420
20421                    (async () => {
20422                      await __pi_dispatch_extension_event("custom_event", { n: 1 }, {});
20423                      off();
20424                      await __pi_dispatch_extension_event("custom_event", { n: 2 }, {});
20425                      globalThis.done = true;
20426                    })();
20427                "#,
20428                )
20429                .await
20430                .expect("eval");
20431
20432            assert_eq!(
20433                get_global_json(&runtime, "done").await,
20434                serde_json::Value::Bool(true)
20435            );
20436            assert_eq!(
20437                get_global_json(&runtime, "seen").await,
20438                serde_json::json!([{ "n": 1 }])
20439            );
20440        });
20441    }
20442
20443    #[test]
20444    fn pijs_event_dispatch_continues_after_handler_error() {
20445        futures::executor::block_on(async {
20446            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20447                .await
20448                .expect("create runtime");
20449
20450            runtime
20451                .eval(
20452                    r#"
20453                    globalThis.seen = [];
20454                    globalThis.done = false;
20455
20456                    __pi_begin_extension("ext.err", { name: "ext.err" });
20457                    pi.events.on("custom_event", (_payload, _ctx) => { throw new Error("boom"); });
20458                    __pi_end_extension();
20459
20460                    __pi_begin_extension("ext.ok", { name: "ext.ok" });
20461                    pi.events.on("custom_event", (payload, _ctx) => { globalThis.seen.push(payload); });
20462                    __pi_end_extension();
20463
20464                    (async () => {
20465                      await __pi_dispatch_extension_event("custom_event", { hello: "world" }, {});
20466                      globalThis.done = true;
20467                    })();
20468                "#,
20469                )
20470                .await
20471                .expect("eval");
20472
20473            assert_eq!(
20474                get_global_json(&runtime, "done").await,
20475                serde_json::Value::Bool(true)
20476            );
20477            assert_eq!(
20478                get_global_json(&runtime, "seen").await,
20479                serde_json::json!([{ "hello": "world" }])
20480            );
20481        });
20482    }
20483
20484    // ---- Extension crash recovery and isolation tests (bd-m4wc) ----
20485
20486    #[test]
20487    fn pijs_crash_register_throw_host_continues() {
20488        futures::executor::block_on(async {
20489            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20490                .await
20491                .expect("create runtime");
20492
20493            // Extension that throws during registration
20494            runtime
20495                .eval(
20496                    r#"
20497                    globalThis.postCrashResult = null;
20498
20499                    __pi_begin_extension("ext.crash", { name: "ext.crash" });
20500                    // Simulate a throw during registration by registering a handler then
20501                    // throwing - the handler should still be partially registered
20502                    throw new Error("registration boom");
20503                "#,
20504                )
20505                .await
20506                .ok(); // May fail, that's fine
20507
20508            // End the crashed extension context
20509            runtime.eval(r"__pi_end_extension();").await.ok();
20510
20511            // Host can still load another extension after the crash
20512            runtime
20513                .eval(
20514                    r#"
20515                    __pi_begin_extension("ext.ok", { name: "ext.ok" });
20516                    pi.events.on("test_event", (p, _) => { globalThis.postCrashResult = p; });
20517                    __pi_end_extension();
20518                "#,
20519                )
20520                .await
20521                .expect("second extension should load");
20522
20523            // Dispatch event - only the healthy extension should handle it
20524            runtime
20525                .eval(
20526                    r#"
20527                    (async () => {
20528                        await __pi_dispatch_extension_event("test_event", { ok: true }, {});
20529                    })();
20530                "#,
20531                )
20532                .await
20533                .expect("dispatch");
20534
20535            assert_eq!(
20536                get_global_json(&runtime, "postCrashResult").await,
20537                serde_json::json!({ "ok": true })
20538            );
20539        });
20540    }
20541
20542    #[test]
20543    fn pijs_crash_handler_throw_other_handlers_run() {
20544        futures::executor::block_on(async {
20545            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20546                .await
20547                .expect("create runtime");
20548
20549            runtime
20550                .eval(
20551                    r#"
20552                    globalThis.handlerResults = [];
20553                    globalThis.dispatchDone = false;
20554
20555                    // Extension A: will throw
20556                    __pi_begin_extension("ext.a", { name: "ext.a" });
20557                    pi.events.on("multi_test", (_p, _c) => {
20558                        globalThis.handlerResults.push("a-before-throw");
20559                        throw new Error("handler crash");
20560                    });
20561                    __pi_end_extension();
20562
20563                    // Extension B: should still run
20564                    __pi_begin_extension("ext.b", { name: "ext.b" });
20565                    pi.events.on("multi_test", (_p, _c) => {
20566                        globalThis.handlerResults.push("b-ok");
20567                    });
20568                    __pi_end_extension();
20569
20570                    // Extension C: should also still run
20571                    __pi_begin_extension("ext.c", { name: "ext.c" });
20572                    pi.events.on("multi_test", (_p, _c) => {
20573                        globalThis.handlerResults.push("c-ok");
20574                    });
20575                    __pi_end_extension();
20576
20577                    (async () => {
20578                        await __pi_dispatch_extension_event("multi_test", {}, {});
20579                        globalThis.dispatchDone = true;
20580                    })();
20581                "#,
20582                )
20583                .await
20584                .expect("eval");
20585
20586            assert_eq!(
20587                get_global_json(&runtime, "dispatchDone").await,
20588                serde_json::Value::Bool(true)
20589            );
20590
20591            let results = get_global_json(&runtime, "handlerResults").await;
20592            let arr = results.as_array().expect("should be array");
20593            // Handler A ran (at least the part before throw)
20594            assert!(
20595                arr.iter().any(|v| v == "a-before-throw"),
20596                "Handler A should have run before throwing"
20597            );
20598            // Handlers B and C should have run despite A's crash
20599            assert!(
20600                arr.iter().any(|v| v == "b-ok"),
20601                "Handler B should run after A crashes"
20602            );
20603            assert!(
20604                arr.iter().any(|v| v == "c-ok"),
20605                "Handler C should run after A crashes"
20606            );
20607        });
20608    }
20609
20610    #[test]
20611    fn pijs_crash_invalid_hostcall_returns_error_not_panic() {
20612        futures::executor::block_on(async {
20613            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20614                .await
20615                .expect("create runtime");
20616
20617            // Extension makes an invalid hostcall (unknown tool)
20618            runtime
20619                .eval(
20620                    r#"
20621                    globalThis.invalidResult = null;
20622                    globalThis.errCode = null;
20623
20624                    __pi_begin_extension("ext.bad", { name: "ext.bad" });
20625                    pi.tool("completely_nonexistent_tool_xyz", { junk: true })
20626                        .then((r) => { globalThis.invalidResult = r; })
20627                        .catch((e) => { globalThis.errCode = e.code || "unknown"; });
20628                    __pi_end_extension();
20629                "#,
20630                )
20631                .await
20632                .expect("eval");
20633
20634            // The hostcall should be queued but not crash the runtime
20635            let requests = runtime.drain_hostcall_requests();
20636            assert_eq!(requests.len(), 1, "Hostcall should be queued");
20637
20638            // Host can still evaluate JS after the invalid hostcall
20639            runtime
20640                .eval(
20641                    r"
20642                    globalThis.hostStillAlive = true;
20643                ",
20644                )
20645                .await
20646                .expect("host should still work");
20647
20648            assert_eq!(
20649                get_global_json(&runtime, "hostStillAlive").await,
20650                serde_json::Value::Bool(true)
20651            );
20652        });
20653    }
20654
20655    #[test]
20656    fn pijs_crash_after_crash_new_extensions_load() {
20657        futures::executor::block_on(async {
20658            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20659                .await
20660                .expect("create runtime");
20661
20662            // Simulate a crash sequence: extension throws, then new ones load fine
20663            runtime
20664                .eval(
20665                    r#"
20666                    globalThis.loadOrder = [];
20667
20668                    // Extension 1: loads fine
20669                    __pi_begin_extension("ext.1", { name: "ext.1" });
20670                    globalThis.loadOrder.push("1-loaded");
20671                    __pi_end_extension();
20672                "#,
20673                )
20674                .await
20675                .expect("ext 1");
20676
20677            // Extension 2: crashes during eval
20678            runtime
20679                .eval(
20680                    r#"
20681                    __pi_begin_extension("ext.2", { name: "ext.2" });
20682                    globalThis.loadOrder.push("2-before-crash");
20683                    throw new Error("ext 2 crash");
20684                "#,
20685                )
20686                .await
20687                .ok(); // Expected to fail
20688
20689            runtime.eval(r"__pi_end_extension();").await.ok();
20690
20691            // Extension 3: should still load after ext 2's crash
20692            runtime
20693                .eval(
20694                    r#"
20695                    __pi_begin_extension("ext.3", { name: "ext.3" });
20696                    globalThis.loadOrder.push("3-loaded");
20697                    __pi_end_extension();
20698                "#,
20699                )
20700                .await
20701                .expect("ext 3 should load after crash");
20702
20703            // Extension 4: loads fine too
20704            runtime
20705                .eval(
20706                    r#"
20707                    __pi_begin_extension("ext.4", { name: "ext.4" });
20708                    globalThis.loadOrder.push("4-loaded");
20709                    __pi_end_extension();
20710                "#,
20711                )
20712                .await
20713                .expect("ext 4 should load");
20714
20715            let order = get_global_json(&runtime, "loadOrder").await;
20716            let arr = order.as_array().expect("should be array");
20717            assert!(
20718                arr.iter().any(|v| v == "1-loaded"),
20719                "Extension 1 should have loaded"
20720            );
20721            assert!(
20722                arr.iter().any(|v| v == "3-loaded"),
20723                "Extension 3 should load after crash"
20724            );
20725            assert!(
20726                arr.iter().any(|v| v == "4-loaded"),
20727                "Extension 4 should load after crash"
20728            );
20729        });
20730    }
20731
20732    #[test]
20733    fn pijs_crash_no_cross_contamination_between_extensions() {
20734        futures::executor::block_on(async {
20735            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20736                .await
20737                .expect("create runtime");
20738
20739            runtime
20740                .eval(
20741                    r#"
20742                    globalThis.extAData = null;
20743                    globalThis.extBData = null;
20744                    globalThis.eventsDone = false;
20745
20746                    // Extension A: sets its own state
20747                    __pi_begin_extension("ext.isolated.a", { name: "ext.isolated.a" });
20748                    pi.events.on("isolation_test", (_p, _c) => {
20749                        globalThis.extAData = "from-A";
20750                    });
20751                    __pi_end_extension();
20752
20753                    // Extension B: sets its own state independently
20754                    __pi_begin_extension("ext.isolated.b", { name: "ext.isolated.b" });
20755                    pi.events.on("isolation_test", (_p, _c) => {
20756                        globalThis.extBData = "from-B";
20757                    });
20758                    __pi_end_extension();
20759
20760                    (async () => {
20761                        await __pi_dispatch_extension_event("isolation_test", {}, {});
20762                        globalThis.eventsDone = true;
20763                    })();
20764                "#,
20765                )
20766                .await
20767                .expect("eval");
20768
20769            assert_eq!(
20770                get_global_json(&runtime, "eventsDone").await,
20771                serde_json::Value::Bool(true)
20772            );
20773            // Each extension should have set its own global independently
20774            assert_eq!(
20775                get_global_json(&runtime, "extAData").await,
20776                serde_json::json!("from-A")
20777            );
20778            assert_eq!(
20779                get_global_json(&runtime, "extBData").await,
20780                serde_json::json!("from-B")
20781            );
20782        });
20783    }
20784
20785    #[test]
20786    fn pijs_crash_interrupt_budget_stops_infinite_loop() {
20787        futures::executor::block_on(async {
20788            let config = PiJsRuntimeConfig {
20789                limits: PiJsRuntimeLimits {
20790                    // Use a small interrupt budget to catch infinite loops quickly
20791                    interrupt_budget: Some(1000),
20792                    ..Default::default()
20793                },
20794                ..Default::default()
20795            };
20796            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
20797                DeterministicClock::new(0),
20798                config,
20799                None,
20800            )
20801            .await
20802            .expect("create runtime");
20803
20804            // Try to run an infinite loop - should be interrupted by budget
20805            let result = runtime
20806                .eval(
20807                    r"
20808                    let i = 0;
20809                    while (true) { i++; }
20810                    globalThis.loopResult = i;
20811                ",
20812                )
20813                .await;
20814
20815            // The eval should fail due to interrupt
20816            assert!(
20817                result.is_err(),
20818                "Infinite loop should be interrupted by budget"
20819            );
20820
20821            // Host should still be alive after interrupt
20822            let alive_result = runtime.eval(r#"globalThis.postInterrupt = "alive";"#).await;
20823            // After an interrupt, the runtime may or may not accept new evals
20824            // The key assertion is that we didn't hang
20825            if alive_result.is_ok() {
20826                assert_eq!(
20827                    get_global_json(&runtime, "postInterrupt").await,
20828                    serde_json::json!("alive")
20829                );
20830            }
20831        });
20832    }
20833
20834    #[test]
20835    fn pijs_events_emit_queues_events_hostcall() {
20836        futures::executor::block_on(async {
20837            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20838                .await
20839                .expect("create runtime");
20840
20841            runtime
20842                .eval(
20843                    r#"
20844                    __pi_begin_extension("ext.test", { name: "Test" });
20845                    pi.events.emit("custom_event", { a: 1 });
20846                    __pi_end_extension();
20847                "#,
20848                )
20849                .await
20850                .expect("eval");
20851
20852            let requests = runtime.drain_hostcall_requests();
20853            assert_eq!(requests.len(), 1);
20854
20855            let req = &requests[0];
20856            assert_eq!(req.extension_id.as_deref(), Some("ext.test"));
20857            assert!(
20858                matches!(&req.kind, HostcallKind::Events { op } if op == "emit"),
20859                "unexpected hostcall kind: {:?}",
20860                req.kind
20861            );
20862            assert_eq!(
20863                req.payload,
20864                serde_json::json!({ "event": "custom_event", "data": { "a": 1 } })
20865            );
20866        });
20867    }
20868
20869    #[test]
20870    fn pijs_console_global_is_defined_and_callable() {
20871        futures::executor::block_on(async {
20872            let clock = Arc::new(DeterministicClock::new(0));
20873            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
20874                .await
20875                .expect("create runtime");
20876
20877            // Verify console global exists and all standard methods are functions
20878            runtime
20879                .eval(
20880                    r"
20881                    globalThis.console_exists = typeof globalThis.console === 'object';
20882                    globalThis.has_log   = typeof console.log   === 'function';
20883                    globalThis.has_warn  = typeof console.warn  === 'function';
20884                    globalThis.has_error = typeof console.error === 'function';
20885                    globalThis.has_info  = typeof console.info  === 'function';
20886                    globalThis.has_debug = typeof console.debug === 'function';
20887                    globalThis.has_trace = typeof console.trace === 'function';
20888                    globalThis.has_dir   = typeof console.dir   === 'function';
20889                    globalThis.has_assert = typeof console.assert === 'function';
20890                    globalThis.has_table = typeof console.table === 'function';
20891
20892                    // Call each method to ensure they don't throw
20893                    console.log('test log', 42, { key: 'value' });
20894                    console.warn('test warn');
20895                    console.error('test error');
20896                    console.info('test info');
20897                    console.debug('test debug');
20898                    console.trace('test trace');
20899                    console.dir({ a: 1 });
20900                    console.assert(true, 'should not appear');
20901                    console.assert(false, 'assertion failed message');
20902                    console.table([1, 2, 3]);
20903                    console.time();
20904                    console.timeEnd();
20905                    console.group();
20906                    console.groupEnd();
20907                    console.clear();
20908
20909                    globalThis.calls_succeeded = true;
20910                    ",
20911                )
20912                .await
20913                .expect("eval console tests");
20914
20915            assert_eq!(
20916                get_global_json(&runtime, "console_exists").await,
20917                serde_json::json!(true)
20918            );
20919            assert_eq!(
20920                get_global_json(&runtime, "has_log").await,
20921                serde_json::json!(true)
20922            );
20923            assert_eq!(
20924                get_global_json(&runtime, "has_warn").await,
20925                serde_json::json!(true)
20926            );
20927            assert_eq!(
20928                get_global_json(&runtime, "has_error").await,
20929                serde_json::json!(true)
20930            );
20931            assert_eq!(
20932                get_global_json(&runtime, "has_info").await,
20933                serde_json::json!(true)
20934            );
20935            assert_eq!(
20936                get_global_json(&runtime, "has_debug").await,
20937                serde_json::json!(true)
20938            );
20939            assert_eq!(
20940                get_global_json(&runtime, "has_trace").await,
20941                serde_json::json!(true)
20942            );
20943            assert_eq!(
20944                get_global_json(&runtime, "has_dir").await,
20945                serde_json::json!(true)
20946            );
20947            assert_eq!(
20948                get_global_json(&runtime, "has_assert").await,
20949                serde_json::json!(true)
20950            );
20951            assert_eq!(
20952                get_global_json(&runtime, "has_table").await,
20953                serde_json::json!(true)
20954            );
20955            assert_eq!(
20956                get_global_json(&runtime, "calls_succeeded").await,
20957                serde_json::json!(true)
20958            );
20959        });
20960    }
20961
20962    #[test]
20963    fn pijs_node_events_module_provides_event_emitter() {
20964        futures::executor::block_on(async {
20965            let clock = Arc::new(DeterministicClock::new(0));
20966            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
20967                .await
20968                .expect("create runtime");
20969
20970            // Use dynamic import() since eval() runs as a script, not a module
20971            runtime
20972                .eval(
20973                    r"
20974                    globalThis.results = [];
20975                    globalThis.testDone = false;
20976
20977                    import('node:events').then(({ EventEmitter }) => {
20978                        const emitter = new EventEmitter();
20979
20980                        emitter.on('data', (val) => globalThis.results.push('data:' + val));
20981                        emitter.once('done', () => globalThis.results.push('done'));
20982
20983                        emitter.emit('data', 1);
20984                        emitter.emit('data', 2);
20985                        emitter.emit('done');
20986                        emitter.emit('done'); // should not fire again
20987
20988                        globalThis.listenerCount = emitter.listenerCount('data');
20989                        globalThis.eventNames = emitter.eventNames();
20990                        globalThis.testDone = true;
20991                    });
20992                    ",
20993                )
20994                .await
20995                .expect("eval EventEmitter test");
20996
20997            assert_eq!(
20998                get_global_json(&runtime, "testDone").await,
20999                serde_json::json!(true)
21000            );
21001            assert_eq!(
21002                get_global_json(&runtime, "results").await,
21003                serde_json::json!(["data:1", "data:2", "done"])
21004            );
21005            assert_eq!(
21006                get_global_json(&runtime, "listenerCount").await,
21007                serde_json::json!(1)
21008            );
21009            assert_eq!(
21010                get_global_json(&runtime, "eventNames").await,
21011                serde_json::json!(["data"])
21012            );
21013        });
21014    }
21015
21016    #[test]
21017    fn pijs_bare_module_aliases_resolve_correctly() {
21018        futures::executor::block_on(async {
21019            let clock = Arc::new(DeterministicClock::new(0));
21020            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21021                .await
21022                .expect("create runtime");
21023
21024            // Test that bare "events" alias resolves to "node:events"
21025            runtime
21026                .eval(
21027                    r"
21028                    globalThis.bare_events_ok = false;
21029                    import('events').then((mod) => {
21030                        const e = new mod.default();
21031                        globalThis.bare_events_ok = typeof e.on === 'function';
21032                    });
21033                    ",
21034                )
21035                .await
21036                .expect("eval bare events import");
21037
21038            assert_eq!(
21039                get_global_json(&runtime, "bare_events_ok").await,
21040                serde_json::json!(true)
21041            );
21042        });
21043    }
21044
21045    #[test]
21046    fn pijs_path_extended_functions() {
21047        futures::executor::block_on(async {
21048            let clock = Arc::new(DeterministicClock::new(0));
21049            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21050                .await
21051                .expect("create runtime");
21052
21053            runtime
21054                .eval(
21055                    r"
21056                    globalThis.pathResults = {};
21057                    import('node:path').then((path) => {
21058                        globalThis.pathResults.isAbsRoot = path.isAbsolute('/foo/bar');
21059                        globalThis.pathResults.isAbsRel = path.isAbsolute('foo/bar');
21060                        globalThis.pathResults.extJs = path.extname('/a/b/file.js');
21061                        globalThis.pathResults.extNone = path.extname('/a/b/noext');
21062                        globalThis.pathResults.extDot = path.extname('.hidden');
21063                        globalThis.pathResults.norm = path.normalize('/a/b/../c/./d');
21064                        globalThis.pathResults.parseBase = path.parse('/home/user/file.txt').base;
21065                        globalThis.pathResults.parseExt = path.parse('/home/user/file.txt').ext;
21066                        globalThis.pathResults.parseName = path.parse('/home/user/file.txt').name;
21067                        globalThis.pathResults.parseDir = path.parse('/home/user/file.txt').dir;
21068                        globalThis.pathResults.hasPosix = typeof path.posix === 'object';
21069                        globalThis.pathResults.done = true;
21070                    });
21071                    ",
21072                )
21073                .await
21074                .expect("eval path extended");
21075
21076            let r = get_global_json(&runtime, "pathResults").await;
21077            assert_eq!(r["done"], serde_json::json!(true));
21078            assert_eq!(r["isAbsRoot"], serde_json::json!(true));
21079            assert_eq!(r["isAbsRel"], serde_json::json!(false));
21080            assert_eq!(r["extJs"], serde_json::json!(".js"));
21081            assert_eq!(r["extNone"], serde_json::json!(""));
21082            assert_eq!(r["extDot"], serde_json::json!(""));
21083            assert_eq!(r["norm"], serde_json::json!("/a/c/d"));
21084            assert_eq!(r["parseBase"], serde_json::json!("file.txt"));
21085            assert_eq!(r["parseExt"], serde_json::json!(".txt"));
21086            assert_eq!(r["parseName"], serde_json::json!("file"));
21087            assert_eq!(r["parseDir"], serde_json::json!("/home/user"));
21088            assert_eq!(r["hasPosix"], serde_json::json!(true));
21089        });
21090    }
21091
21092    #[test]
21093    fn pijs_fs_callback_apis() {
21094        futures::executor::block_on(async {
21095            let clock = Arc::new(DeterministicClock::new(0));
21096            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21097                .await
21098                .expect("create runtime");
21099
21100            runtime
21101                .eval(
21102                    r"
21103                    globalThis.fsResults = {};
21104                    import('node:fs').then((fs) => {
21105                        fs.writeFileSync('/fake', '');
21106                        // readFile callback
21107                        fs.readFile('/fake', 'utf8', (err, data) => {
21108                            globalThis.fsResults.readFileCallbackCalled = true;
21109                            globalThis.fsResults.readFileData = data;
21110                        });
21111                        // writeFile callback
21112                        fs.writeFile('/fake', 'data', (err) => {
21113                            globalThis.fsResults.writeFileCallbackCalled = true;
21114                        });
21115                        // accessSync throws
21116                        try {
21117                            fs.accessSync('/nonexistent');
21118                            globalThis.fsResults.accessSyncThrew = false;
21119                        } catch (e) {
21120                            globalThis.fsResults.accessSyncThrew = true;
21121                        }
21122                        // access callback with error
21123                        fs.access('/nonexistent', (err) => {
21124                            globalThis.fsResults.accessCallbackErr = !!err;
21125                        });
21126                        globalThis.fsResults.hasLstatSync = typeof fs.lstatSync === 'function';
21127                        globalThis.fsResults.done = true;
21128                    });
21129                    ",
21130                )
21131                .await
21132                .expect("eval fs callbacks");
21133
21134            let r = get_global_json(&runtime, "fsResults").await;
21135            assert_eq!(r["done"], serde_json::json!(true));
21136            assert_eq!(r["readFileCallbackCalled"], serde_json::json!(true));
21137            assert_eq!(r["readFileData"], serde_json::json!(""));
21138            assert_eq!(r["writeFileCallbackCalled"], serde_json::json!(true));
21139            assert_eq!(r["accessSyncThrew"], serde_json::json!(true));
21140            assert_eq!(r["accessCallbackErr"], serde_json::json!(true));
21141            assert_eq!(r["hasLstatSync"], serde_json::json!(true));
21142        });
21143    }
21144
21145    #[test]
21146    fn pijs_fs_sync_roundtrip_and_dirents() {
21147        futures::executor::block_on(async {
21148            let clock = Arc::new(DeterministicClock::new(0));
21149            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21150                .await
21151                .expect("create runtime");
21152
21153            runtime
21154                .eval(
21155                    r"
21156                    globalThis.fsRoundTrip = {};
21157                    import('node:fs').then((fs) => {
21158                        fs.mkdirSync('/tmp/demo', { recursive: true });
21159                        fs.writeFileSync('/tmp/demo/hello.txt', 'hello world');
21160                        fs.writeFileSync('/tmp/demo/raw.bin', Buffer.from([1, 2, 3, 4]));
21161
21162                        globalThis.fsRoundTrip.exists = fs.existsSync('/tmp/demo/hello.txt');
21163                        globalThis.fsRoundTrip.readText = fs.readFileSync('/tmp/demo/hello.txt', 'utf8');
21164                        const raw = fs.readFileSync('/tmp/demo/raw.bin');
21165                        globalThis.fsRoundTrip.rawLen = raw.length;
21166
21167                        const names = fs.readdirSync('/tmp/demo');
21168                        globalThis.fsRoundTrip.names = names;
21169
21170                        const dirents = fs.readdirSync('/tmp/demo', { withFileTypes: true });
21171                        globalThis.fsRoundTrip.direntHasMethods =
21172                          typeof dirents[0].isFile === 'function' &&
21173                          typeof dirents[0].isDirectory === 'function';
21174
21175                        const dirStat = fs.statSync('/tmp/demo');
21176                        const fileStat = fs.statSync('/tmp/demo/hello.txt');
21177                        globalThis.fsRoundTrip.isDir = dirStat.isDirectory();
21178                        globalThis.fsRoundTrip.isFile = fileStat.isFile();
21179                        globalThis.fsRoundTrip.done = true;
21180                    });
21181                    ",
21182                )
21183                .await
21184                .expect("eval fs sync roundtrip");
21185
21186            let r = get_global_json(&runtime, "fsRoundTrip").await;
21187            assert_eq!(r["done"], serde_json::json!(true));
21188            assert_eq!(r["exists"], serde_json::json!(true));
21189            assert_eq!(r["readText"], serde_json::json!("hello world"));
21190            assert_eq!(r["rawLen"], serde_json::json!(4));
21191            assert_eq!(r["isDir"], serde_json::json!(true));
21192            assert_eq!(r["isFile"], serde_json::json!(true));
21193            assert_eq!(r["direntHasMethods"], serde_json::json!(true));
21194            assert_eq!(r["names"], serde_json::json!(["hello.txt", "raw.bin"]));
21195        });
21196    }
21197
21198    #[test]
21199    fn pijs_create_require_supports_node_builtins() {
21200        futures::executor::block_on(async {
21201            let clock = Arc::new(DeterministicClock::new(0));
21202            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21203                .await
21204                .expect("create runtime");
21205
21206            runtime
21207                .eval(
21208                    r"
21209                    globalThis.requireResults = {};
21210                    import('node:module').then(({ createRequire }) => {
21211                        const require = createRequire('/tmp/example.js');
21212                        const path = require('path');
21213                        const fs = require('node:fs');
21214                        const crypto = require('crypto');
21215                        const http2 = require('http2');
21216
21217                        globalThis.requireResults.pathJoinWorks = path.join('a', 'b') === 'a/b';
21218                        globalThis.requireResults.fsReadFileSync = typeof fs.readFileSync === 'function';
21219                        globalThis.requireResults.cryptoHasRandomUUID = typeof crypto.randomUUID === 'function';
21220                        globalThis.requireResults.http2HasConnect = typeof http2.connect === 'function';
21221                        globalThis.requireResults.http2PathHeader = http2.constants.HTTP2_HEADER_PATH;
21222
21223                        try {
21224                            const missing = require('left-pad');
21225                            globalThis.requireResults.missingModuleThrows = false;
21226                            globalThis.requireResults.missingModuleIsStub =
21227                              typeof missing === 'function' &&
21228                              typeof missing.default === 'function' &&
21229                              typeof missing.anyNestedProperty === 'function';
21230                        } catch (err) {
21231                            globalThis.requireResults.missingModuleThrows = true;
21232                            globalThis.requireResults.missingModuleIsStub = false;
21233                        }
21234                        globalThis.requireResults.done = true;
21235                    });
21236                    ",
21237                )
21238                .await
21239                .expect("eval createRequire test");
21240
21241            let r = get_global_json(&runtime, "requireResults").await;
21242            assert_eq!(r["done"], serde_json::json!(true));
21243            assert_eq!(r["pathJoinWorks"], serde_json::json!(true));
21244            assert_eq!(r["fsReadFileSync"], serde_json::json!(true));
21245            assert_eq!(r["cryptoHasRandomUUID"], serde_json::json!(true));
21246            assert_eq!(r["http2HasConnect"], serde_json::json!(true));
21247            assert_eq!(r["http2PathHeader"], serde_json::json!(":path"));
21248            assert_eq!(r["missingModuleThrows"], serde_json::json!(false));
21249            assert_eq!(r["missingModuleIsStub"], serde_json::json!(true));
21250        });
21251    }
21252
21253    #[test]
21254    fn pijs_fs_promises_delegates_to_node_fs_promises_api() {
21255        futures::executor::block_on(async {
21256            let clock = Arc::new(DeterministicClock::new(0));
21257            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21258                .await
21259                .expect("create runtime");
21260
21261            runtime
21262                .eval(
21263                    r"
21264                    globalThis.fsPromisesResults = {};
21265                    import('node:fs/promises').then(async (fsp) => {
21266                        await fsp.mkdir('/tmp/promise-demo', { recursive: true });
21267                        await fsp.writeFile('/tmp/promise-demo/value.txt', 'value');
21268                        const text = await fsp.readFile('/tmp/promise-demo/value.txt', 'utf8');
21269                        const names = await fsp.readdir('/tmp/promise-demo');
21270
21271                        globalThis.fsPromisesResults.readText = text;
21272                        globalThis.fsPromisesResults.names = names;
21273                        globalThis.fsPromisesResults.done = true;
21274                    });
21275                    ",
21276                )
21277                .await
21278                .expect("eval fs promises test");
21279
21280            let r = get_global_json(&runtime, "fsPromisesResults").await;
21281            assert_eq!(r["done"], serde_json::json!(true));
21282            assert_eq!(r["readText"], serde_json::json!("value"));
21283            assert_eq!(r["names"], serde_json::json!(["value.txt"]));
21284        });
21285    }
21286
21287    #[test]
21288    fn pijs_child_process_spawn_emits_data_and_close() {
21289        futures::executor::block_on(async {
21290            let clock = Arc::new(DeterministicClock::new(0));
21291            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21292                .await
21293                .expect("create runtime");
21294
21295            runtime
21296                .eval(
21297                    r"
21298                    globalThis.childProcessResult = { events: [] };
21299                    import('node:child_process').then(({ spawn }) => {
21300                        const child = spawn('pi', ['--version'], {
21301                            shell: false,
21302                            stdio: ['ignore', 'pipe', 'pipe'],
21303                        });
21304                        let stdout = '';
21305                        let stderr = '';
21306                        child.stdout?.on('data', (chunk) => {
21307                            stdout += chunk.toString();
21308                            globalThis.childProcessResult.events.push('stdout');
21309                        });
21310                        child.stderr?.on('data', (chunk) => {
21311                            stderr += chunk.toString();
21312                            globalThis.childProcessResult.events.push('stderr');
21313                        });
21314                        child.on('error', (err) => {
21315                            globalThis.childProcessResult.error =
21316                                String((err && err.message) || err || '');
21317                            globalThis.childProcessResult.done = true;
21318                        });
21319                        child.on('exit', (code, signal) => {
21320                            globalThis.childProcessResult.events.push('exit');
21321                            globalThis.childProcessResult.exitCode = code;
21322                            globalThis.childProcessResult.exitSignal = signal;
21323                        });
21324                        child.on('close', (code) => {
21325                            globalThis.childProcessResult.events.push('close');
21326                            globalThis.childProcessResult.code = code;
21327                            globalThis.childProcessResult.stdout = stdout;
21328                            globalThis.childProcessResult.stderr = stderr;
21329                            globalThis.childProcessResult.killed = child.killed;
21330                            globalThis.childProcessResult.pid = child.pid;
21331                            globalThis.childProcessResult.done = true;
21332                        });
21333                    });
21334                    ",
21335                )
21336                .await
21337                .expect("eval child_process spawn script");
21338
21339            let mut requests = runtime.drain_hostcall_requests();
21340            assert_eq!(requests.len(), 1);
21341            let request = requests.pop_front().expect("exec hostcall");
21342            assert!(
21343                matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "pi"),
21344                "unexpected hostcall kind: {:?}",
21345                request.kind
21346            );
21347
21348            runtime.complete_hostcall(
21349                request.call_id,
21350                HostcallOutcome::Success(serde_json::json!({
21351                    "stdout": "line-1\n",
21352                    "stderr": "warn-1\n",
21353                    "code": 0,
21354                    "killed": false
21355                })),
21356            );
21357
21358            drain_until_idle(&runtime, &clock).await;
21359            let r = get_global_json(&runtime, "childProcessResult").await;
21360            assert_eq!(r["done"], serde_json::json!(true));
21361            assert_eq!(r["code"], serde_json::json!(0));
21362            assert_eq!(r["exitCode"], serde_json::json!(0));
21363            assert_eq!(r["exitSignal"], serde_json::Value::Null);
21364            assert_eq!(r["stdout"], serde_json::json!("line-1\n"));
21365            assert_eq!(r["stderr"], serde_json::json!("warn-1\n"));
21366            assert_eq!(r["killed"], serde_json::json!(false));
21367            assert_eq!(
21368                r["events"],
21369                serde_json::json!(["stdout", "stderr", "exit", "close"])
21370            );
21371        });
21372    }
21373
21374    #[test]
21375    fn pijs_child_process_spawn_forwards_timeout_option_to_hostcall() {
21376        futures::executor::block_on(async {
21377            let clock = Arc::new(DeterministicClock::new(0));
21378            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21379                .await
21380                .expect("create runtime");
21381
21382            runtime
21383                .eval(
21384                    r"
21385                    globalThis.childTimeoutResult = {};
21386                    import('node:child_process').then(({ spawn }) => {
21387                        const child = spawn('pi', ['--version'], {
21388                            shell: false,
21389                            timeout: 250,
21390                            stdio: ['ignore', 'pipe', 'pipe'],
21391                        });
21392                        child.on('close', (code) => {
21393                            globalThis.childTimeoutResult.code = code;
21394                            globalThis.childTimeoutResult.killed = child.killed;
21395                            globalThis.childTimeoutResult.done = true;
21396                        });
21397                    });
21398                    ",
21399                )
21400                .await
21401                .expect("eval child_process timeout script");
21402
21403            let mut requests = runtime.drain_hostcall_requests();
21404            assert_eq!(requests.len(), 1);
21405            let request = requests.pop_front().expect("exec hostcall");
21406            assert!(
21407                matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "pi"),
21408                "unexpected hostcall kind: {:?}",
21409                request.kind
21410            );
21411            assert_eq!(
21412                request.payload["options"]["timeout"].as_i64(),
21413                Some(250),
21414                "spawn timeout should be forwarded to hostcall options"
21415            );
21416
21417            runtime.complete_hostcall(
21418                request.call_id,
21419                HostcallOutcome::Success(serde_json::json!({
21420                    "stdout": "",
21421                    "stderr": "",
21422                    "code": 0,
21423                    "killed": true
21424                })),
21425            );
21426
21427            drain_until_idle(&runtime, &clock).await;
21428            let r = get_global_json(&runtime, "childTimeoutResult").await;
21429            assert_eq!(r["done"], serde_json::json!(true));
21430            assert_eq!(r["killed"], serde_json::json!(true));
21431            assert_eq!(r["code"], serde_json::Value::Null);
21432        });
21433    }
21434
21435    #[test]
21436    fn pijs_child_process_exec_returns_child_and_forwards_timeout() {
21437        futures::executor::block_on(async {
21438            let clock = Arc::new(DeterministicClock::new(0));
21439            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21440                .await
21441                .expect("create runtime");
21442
21443            runtime
21444                .eval(
21445                    r"
21446                    globalThis.execShimResult = {};
21447                    import('node:child_process').then(({ exec }) => {
21448                        const child = exec('echo hello-exec', { timeout: 321 }, (err, stdout, stderr) => {
21449                            globalThis.execShimResult.cbDone = true;
21450                            globalThis.execShimResult.cbErr = err ? String((err && err.message) || err) : null;
21451                            globalThis.execShimResult.stdout = stdout;
21452                            globalThis.execShimResult.stderr = stderr;
21453                        });
21454                        globalThis.execShimResult.hasPid = typeof child.pid === 'number';
21455                        globalThis.execShimResult.hasKill = typeof child.kill === 'function';
21456                        child.on('close', () => {
21457                            globalThis.execShimResult.closed = true;
21458                        });
21459                    });
21460                    ",
21461                )
21462                .await
21463                .expect("eval child_process exec script");
21464
21465            let mut requests = runtime.drain_hostcall_requests();
21466            assert_eq!(requests.len(), 1);
21467            let request = requests.pop_front().expect("exec hostcall");
21468            assert!(
21469                matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "sh"),
21470                "unexpected hostcall kind: {:?}",
21471                request.kind
21472            );
21473            assert_eq!(
21474                request.payload["args"],
21475                serde_json::json!(["-c", "echo hello-exec"])
21476            );
21477            assert_eq!(request.payload["options"]["timeout"].as_i64(), Some(321));
21478
21479            runtime.complete_hostcall(
21480                request.call_id,
21481                HostcallOutcome::Success(serde_json::json!({
21482                    "stdout": "hello-exec\n",
21483                    "stderr": "",
21484                    "code": 0,
21485                    "killed": false
21486                })),
21487            );
21488
21489            drain_until_idle(&runtime, &clock).await;
21490            let r = get_global_json(&runtime, "execShimResult").await;
21491            assert_eq!(r["hasPid"], serde_json::json!(true));
21492            assert_eq!(r["hasKill"], serde_json::json!(true));
21493            assert_eq!(r["closed"], serde_json::json!(true));
21494            assert_eq!(r["cbDone"], serde_json::json!(true));
21495            assert_eq!(r["cbErr"], serde_json::Value::Null);
21496            assert_eq!(r["stdout"], serde_json::json!("hello-exec\n"));
21497            assert_eq!(r["stderr"], serde_json::json!(""));
21498        });
21499    }
21500
21501    #[test]
21502    fn pijs_child_process_exec_file_returns_child_and_forwards_timeout() {
21503        futures::executor::block_on(async {
21504            let clock = Arc::new(DeterministicClock::new(0));
21505            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21506                .await
21507                .expect("create runtime");
21508
21509            runtime
21510                .eval(
21511                    r"
21512                    globalThis.execFileShimResult = {};
21513                    import('node:child_process').then(({ execFile }) => {
21514                        const child = execFile('echo', ['hello-file'], { timeout: 222 }, (err, stdout, stderr) => {
21515                            globalThis.execFileShimResult.cbDone = true;
21516                            globalThis.execFileShimResult.cbErr = err ? String((err && err.message) || err) : null;
21517                            globalThis.execFileShimResult.stdout = stdout;
21518                            globalThis.execFileShimResult.stderr = stderr;
21519                        });
21520                        globalThis.execFileShimResult.hasPid = typeof child.pid === 'number';
21521                        globalThis.execFileShimResult.hasKill = typeof child.kill === 'function';
21522                    });
21523                    ",
21524                )
21525                .await
21526                .expect("eval child_process execFile script");
21527
21528            let mut requests = runtime.drain_hostcall_requests();
21529            assert_eq!(requests.len(), 1);
21530            let request = requests.pop_front().expect("execFile hostcall");
21531            assert!(
21532                matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "echo"),
21533                "unexpected hostcall kind: {:?}",
21534                request.kind
21535            );
21536            assert_eq!(request.payload["args"], serde_json::json!(["hello-file"]));
21537            assert_eq!(request.payload["options"]["timeout"].as_i64(), Some(222));
21538
21539            runtime.complete_hostcall(
21540                request.call_id,
21541                HostcallOutcome::Success(serde_json::json!({
21542                    "stdout": "hello-file\n",
21543                    "stderr": "",
21544                    "code": 0,
21545                    "killed": false
21546                })),
21547            );
21548
21549            drain_until_idle(&runtime, &clock).await;
21550            let r = get_global_json(&runtime, "execFileShimResult").await;
21551            assert_eq!(r["hasPid"], serde_json::json!(true));
21552            assert_eq!(r["hasKill"], serde_json::json!(true));
21553            assert_eq!(r["cbDone"], serde_json::json!(true));
21554            assert_eq!(r["cbErr"], serde_json::Value::Null);
21555            assert_eq!(r["stdout"], serde_json::json!("hello-file\n"));
21556            assert_eq!(r["stderr"], serde_json::json!(""));
21557        });
21558    }
21559
21560    #[test]
21561    fn pijs_child_process_process_kill_targets_spawned_pid() {
21562        futures::executor::block_on(async {
21563            let clock = Arc::new(DeterministicClock::new(0));
21564            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21565                .await
21566                .expect("create runtime");
21567
21568            runtime
21569                .eval(
21570                    r"
21571                    globalThis.childKillResult = {};
21572                    import('node:child_process').then(({ spawn }) => {
21573                        const child = spawn('pi', ['--version'], {
21574                            shell: false,
21575                            detached: true,
21576                            stdio: ['ignore', 'pipe', 'pipe'],
21577                        });
21578                        globalThis.childKillResult.pid = child.pid;
21579                        child.on('close', (code) => {
21580                            globalThis.childKillResult.code = code;
21581                            globalThis.childKillResult.killed = child.killed;
21582                            globalThis.childKillResult.done = true;
21583                        });
21584                        try {
21585                            globalThis.childKillResult.killOk = process.kill(-child.pid, 'SIGKILL') === true;
21586                        } catch (err) {
21587                            globalThis.childKillResult.killErrorCode = String((err && err.code) || '');
21588                            globalThis.childKillResult.killErrorMessage = String((err && err.message) || err || '');
21589                        }
21590                    });
21591                    ",
21592                )
21593                .await
21594                .expect("eval child_process kill script");
21595
21596            let mut requests = runtime.drain_hostcall_requests();
21597            assert_eq!(requests.len(), 1);
21598            let request = requests.pop_front().expect("exec hostcall");
21599            runtime.complete_hostcall(
21600                request.call_id,
21601                HostcallOutcome::Success(serde_json::json!({
21602                    "stdout": "",
21603                    "stderr": "",
21604                    "code": 0,
21605                    "killed": false
21606                })),
21607            );
21608
21609            drain_until_idle(&runtime, &clock).await;
21610            let r = get_global_json(&runtime, "childKillResult").await;
21611            assert_eq!(r["killOk"], serde_json::json!(true));
21612            assert_eq!(r["killed"], serde_json::json!(true));
21613            assert_eq!(r["code"], serde_json::Value::Null);
21614            assert_eq!(r["done"], serde_json::json!(true));
21615        });
21616    }
21617
21618    #[test]
21619    fn pijs_child_process_denied_exec_emits_error_and_close() {
21620        futures::executor::block_on(async {
21621            let clock = Arc::new(DeterministicClock::new(0));
21622            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21623                .await
21624                .expect("create runtime");
21625
21626            runtime
21627                .eval(
21628                    r"
21629                    globalThis.childDeniedResult = {};
21630                    import('node:child_process').then(({ spawn }) => {
21631                        const child = spawn('pi', ['--version'], {
21632                            shell: false,
21633                            stdio: ['ignore', 'pipe', 'pipe'],
21634                        });
21635                        child.on('error', (err) => {
21636                            globalThis.childDeniedResult.errorCode = String((err && err.code) || '');
21637                            globalThis.childDeniedResult.errorMessage = String((err && err.message) || err || '');
21638                        });
21639                        child.on('close', (code) => {
21640                            globalThis.childDeniedResult.code = code;
21641                            globalThis.childDeniedResult.killed = child.killed;
21642                            globalThis.childDeniedResult.done = true;
21643                        });
21644                    });
21645                    ",
21646                )
21647                .await
21648                .expect("eval child_process denied script");
21649
21650            let mut requests = runtime.drain_hostcall_requests();
21651            assert_eq!(requests.len(), 1);
21652            let request = requests.pop_front().expect("exec hostcall");
21653            runtime.complete_hostcall(
21654                request.call_id,
21655                HostcallOutcome::Error {
21656                    code: "denied".to_string(),
21657                    message: "Capability 'exec' denied by policy".to_string(),
21658                },
21659            );
21660
21661            drain_until_idle(&runtime, &clock).await;
21662            let r = get_global_json(&runtime, "childDeniedResult").await;
21663            assert_eq!(r["done"], serde_json::json!(true));
21664            assert_eq!(r["errorCode"], serde_json::json!("denied"));
21665            assert_eq!(
21666                r["errorMessage"],
21667                serde_json::json!("Capability 'exec' denied by policy")
21668            );
21669            assert_eq!(r["code"], serde_json::json!(1));
21670            assert_eq!(r["killed"], serde_json::json!(false));
21671        });
21672    }
21673
21674    #[test]
21675    fn pijs_child_process_rejects_unsupported_shell_option() {
21676        futures::executor::block_on(async {
21677            let clock = Arc::new(DeterministicClock::new(0));
21678            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21679                .await
21680                .expect("create runtime");
21681
21682            runtime
21683                .eval(
21684                    r"
21685                    globalThis.childOptionResult = {};
21686                    import('node:child_process').then(({ spawn }) => {
21687                        try {
21688                            spawn('pi', ['--version'], { shell: true });
21689                            globalThis.childOptionResult.threw = false;
21690                        } catch (err) {
21691                            globalThis.childOptionResult.threw = true;
21692                            globalThis.childOptionResult.message = String((err && err.message) || err || '');
21693                        }
21694                        globalThis.childOptionResult.done = true;
21695                    });
21696                    ",
21697                )
21698                .await
21699                .expect("eval child_process unsupported shell script");
21700
21701            drain_until_idle(&runtime, &clock).await;
21702            let r = get_global_json(&runtime, "childOptionResult").await;
21703            assert_eq!(r["done"], serde_json::json!(true));
21704            assert_eq!(r["threw"], serde_json::json!(true));
21705            assert_eq!(
21706                r["message"],
21707                serde_json::json!(
21708                    "node:child_process.spawn: only shell=false is supported in PiJS"
21709                )
21710            );
21711            assert_eq!(runtime.drain_hostcall_requests().len(), 0);
21712        });
21713    }
21714
21715    // -----------------------------------------------------------------------
21716    // bd-2b9y: Node core shim unit tests
21717    // -----------------------------------------------------------------------
21718
21719    #[test]
21720    fn pijs_node_os_module_exports() {
21721        futures::executor::block_on(async {
21722            let clock = Arc::new(DeterministicClock::new(0));
21723            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21724                .await
21725                .expect("create runtime");
21726
21727            runtime
21728                .eval(
21729                    r"
21730                    globalThis.osResults = {};
21731                    import('node:os').then((os) => {
21732                        globalThis.osResults.homedir = os.homedir();
21733                        globalThis.osResults.tmpdir = os.tmpdir();
21734                        globalThis.osResults.hostname = os.hostname();
21735                        globalThis.osResults.platform = os.platform();
21736                        globalThis.osResults.arch = os.arch();
21737                        globalThis.osResults.type = os.type();
21738                        globalThis.osResults.release = os.release();
21739                        globalThis.osResults.done = true;
21740                    });
21741                    ",
21742                )
21743                .await
21744                .expect("eval node:os");
21745
21746            let r = get_global_json(&runtime, "osResults").await;
21747            assert_eq!(r["done"], serde_json::json!(true));
21748            // homedir returns HOME env or fallback
21749            assert!(r["homedir"].is_string());
21750            // tmpdir matches std::env::temp_dir()
21751            let expected_tmpdir = std::env::temp_dir().display().to_string();
21752            assert_eq!(r["tmpdir"].as_str().unwrap(), expected_tmpdir);
21753            // hostname is a non-empty string (real system hostname)
21754            assert!(
21755                r["hostname"].as_str().is_some_and(|s| !s.is_empty()),
21756                "hostname should be non-empty string"
21757            );
21758            // platform/arch/type match current system
21759            let expected_platform = match std::env::consts::OS {
21760                "macos" => "darwin",
21761                "windows" => "win32",
21762                other => other,
21763            };
21764            assert_eq!(r["platform"].as_str().unwrap(), expected_platform);
21765            let expected_arch = match std::env::consts::ARCH {
21766                "x86_64" => "x64",
21767                "aarch64" => "arm64",
21768                other => other,
21769            };
21770            assert_eq!(r["arch"].as_str().unwrap(), expected_arch);
21771            let expected_type = match std::env::consts::OS {
21772                "linux" => "Linux",
21773                "macos" => "Darwin",
21774                "windows" => "Windows_NT",
21775                other => other,
21776            };
21777            assert_eq!(r["type"].as_str().unwrap(), expected_type);
21778            assert_eq!(r["release"], serde_json::json!("6.0.0"));
21779        });
21780    }
21781
21782    #[test]
21783    fn build_node_os_module_produces_valid_js() {
21784        let source = super::build_node_os_module();
21785        // Verify basic structure - has expected exports
21786        assert!(
21787            source.contains("export function platform()"),
21788            "missing platform"
21789        );
21790        assert!(source.contains("export function cpus()"), "missing cpus");
21791        assert!(source.contains("_numCpus"), "missing _numCpus");
21792        // Print first few lines for debugging
21793        for (i, line) in source.lines().enumerate().take(20) {
21794            eprintln!("  {i}: {line}");
21795        }
21796        let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
21797        assert!(
21798            source.contains(&format!("const _numCpus = {num_cpus}")),
21799            "expected _numCpus = {num_cpus} in module"
21800        );
21801    }
21802
21803    #[test]
21804    fn pijs_node_os_native_values_cpus_and_userinfo() {
21805        futures::executor::block_on(async {
21806            let clock = Arc::new(DeterministicClock::new(0));
21807            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21808                .await
21809                .expect("create runtime");
21810
21811            runtime
21812                .eval(
21813                    r"
21814                    globalThis.nativeOsResults = {};
21815                    import('node:os').then((os) => {
21816                        globalThis.nativeOsResults.cpuCount = os.cpus().length;
21817                        globalThis.nativeOsResults.totalmem = os.totalmem();
21818                        globalThis.nativeOsResults.freemem = os.freemem();
21819                        globalThis.nativeOsResults.eol = os.EOL;
21820                        globalThis.nativeOsResults.endianness = os.endianness();
21821                        globalThis.nativeOsResults.devNull = os.devNull;
21822                        const ui = os.userInfo();
21823                        globalThis.nativeOsResults.uid = ui.uid;
21824                        globalThis.nativeOsResults.username = ui.username;
21825                        globalThis.nativeOsResults.hasShell = typeof ui.shell === 'string';
21826                        globalThis.nativeOsResults.hasHomedir = typeof ui.homedir === 'string';
21827                        globalThis.nativeOsResults.done = true;
21828                    });
21829                    ",
21830                )
21831                .await
21832                .expect("eval node:os native");
21833
21834            let r = get_global_json(&runtime, "nativeOsResults").await;
21835            assert_eq!(r["done"], serde_json::json!(true));
21836            // cpus() returns array with count matching available parallelism
21837            let expected_cpus =
21838                std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
21839            assert_eq!(r["cpuCount"], serde_json::json!(expected_cpus));
21840            // totalmem/freemem are positive numbers
21841            assert!(r["totalmem"].as_f64().unwrap() > 0.0);
21842            assert!(r["freemem"].as_f64().unwrap() > 0.0);
21843            // EOL is correct for platform
21844            let expected_eol = if cfg!(windows) { "\r\n" } else { "\n" };
21845            assert_eq!(r["eol"], serde_json::json!(expected_eol));
21846            assert_eq!(r["endianness"], serde_json::json!("LE"));
21847            let expected_dev_null = if cfg!(windows) {
21848                "\\\\.\\NUL"
21849            } else {
21850                "/dev/null"
21851            };
21852            assert_eq!(r["devNull"], serde_json::json!(expected_dev_null));
21853            // userInfo has real uid and non-empty username
21854            assert!(r["uid"].is_number());
21855            assert!(r["username"].as_str().is_some_and(|s| !s.is_empty()));
21856            assert_eq!(r["hasShell"], serde_json::json!(true));
21857            assert_eq!(r["hasHomedir"], serde_json::json!(true));
21858        });
21859    }
21860
21861    #[test]
21862    fn pijs_node_os_bare_import_alias() {
21863        futures::executor::block_on(async {
21864            let clock = Arc::new(DeterministicClock::new(0));
21865            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21866                .await
21867                .expect("create runtime");
21868
21869            runtime
21870                .eval(
21871                    r"
21872                    globalThis.bare_os_ok = false;
21873                    import('os').then((os) => {
21874                        globalThis.bare_os_ok = typeof os.homedir === 'function'
21875                            && typeof os.platform === 'function';
21876                    });
21877                    ",
21878                )
21879                .await
21880                .expect("eval bare os import");
21881
21882            assert_eq!(
21883                get_global_json(&runtime, "bare_os_ok").await,
21884                serde_json::json!(true)
21885            );
21886        });
21887    }
21888
21889    #[test]
21890    fn pijs_node_url_module_exports() {
21891        futures::executor::block_on(async {
21892            let clock = Arc::new(DeterministicClock::new(0));
21893            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21894                .await
21895                .expect("create runtime");
21896
21897            runtime
21898                .eval(
21899                    r"
21900                    globalThis.urlResults = {};
21901                    import('node:url').then((url) => {
21902                        globalThis.urlResults.fileToPath = url.fileURLToPath('file:///home/user/test.txt');
21903                        globalThis.urlResults.pathToFile = url.pathToFileURL('/home/user/test.txt').href;
21904
21905                        const u = new url.URL('https://example.com/path?key=val#frag');
21906                        globalThis.urlResults.href = u.href;
21907                        globalThis.urlResults.protocol = u.protocol;
21908                        globalThis.urlResults.hostname = u.hostname;
21909                        globalThis.urlResults.pathname = u.pathname;
21910                        globalThis.urlResults.toString = u.toString();
21911
21912                        globalThis.urlResults.done = true;
21913                    });
21914                    ",
21915                )
21916                .await
21917                .expect("eval node:url");
21918
21919            let r = get_global_json(&runtime, "urlResults").await;
21920            assert_eq!(r["done"], serde_json::json!(true));
21921            assert_eq!(r["fileToPath"], serde_json::json!("/home/user/test.txt"));
21922            assert_eq!(
21923                r["pathToFile"],
21924                serde_json::json!("file:///home/user/test.txt")
21925            );
21926            // URL parsing
21927            assert!(r["href"].as_str().unwrap().starts_with("https://"));
21928            assert_eq!(r["protocol"], serde_json::json!("https:"));
21929            assert_eq!(r["hostname"], serde_json::json!("example.com"));
21930            // Shim URL.pathname includes query+fragment (lightweight parser)
21931            assert!(r["pathname"].as_str().unwrap().starts_with("/path"));
21932        });
21933    }
21934
21935    #[test]
21936    fn pijs_node_crypto_create_hash_and_uuid() {
21937        futures::executor::block_on(async {
21938            let clock = Arc::new(DeterministicClock::new(0));
21939            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21940                .await
21941                .expect("create runtime");
21942
21943            runtime
21944                .eval(
21945                    r"
21946                    globalThis.cryptoResults = {};
21947                    import('node:crypto').then((crypto) => {
21948                        // createHash
21949                        const hash = crypto.createHash('sha256');
21950                        hash.update('hello');
21951                        globalThis.cryptoResults.hexDigest = hash.digest('hex');
21952
21953                        // createHash chained
21954                        globalThis.cryptoResults.chainedHex = crypto
21955                            .createHash('sha256')
21956                            .update('world')
21957                            .digest('hex');
21958
21959                        // randomUUID
21960                        const uuid = crypto.randomUUID();
21961                        globalThis.cryptoResults.uuidLength = uuid.length;
21962                        // UUID v4 format: 8-4-4-4-12
21963                        globalThis.cryptoResults.uuidHasDashes = uuid.split('-').length === 5;
21964
21965                        globalThis.cryptoResults.done = true;
21966                    });
21967                    ",
21968                )
21969                .await
21970                .expect("eval node:crypto");
21971
21972            let r = get_global_json(&runtime, "cryptoResults").await;
21973            assert_eq!(r["done"], serde_json::json!(true));
21974            // createHash returns a hex string
21975            assert!(r["hexDigest"].is_string());
21976            let hex = r["hexDigest"].as_str().unwrap();
21977            // djb2-simulated hash, not real SHA-256 — verify it's a non-empty hex string
21978            assert!(!hex.is_empty());
21979            assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
21980            // chained usage also works
21981            assert!(r["chainedHex"].is_string());
21982            let chained = r["chainedHex"].as_str().unwrap();
21983            assert!(!chained.is_empty());
21984            assert!(chained.chars().all(|c| c.is_ascii_hexdigit()));
21985            // Two different inputs produce different hashes
21986            assert_ne!(r["hexDigest"], r["chainedHex"]);
21987            // randomUUID format
21988            assert_eq!(r["uuidLength"], serde_json::json!(36));
21989            assert_eq!(r["uuidHasDashes"], serde_json::json!(true));
21990        });
21991    }
21992
21993    #[test]
21994    fn pijs_web_crypto_get_random_values_smoke() {
21995        futures::executor::block_on(async {
21996            let clock = Arc::new(DeterministicClock::new(0));
21997            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21998                .await
21999                .expect("create runtime");
22000
22001            runtime
22002                .eval(
22003                    r"
22004                    const bytes = new Uint8Array(32);
22005                    crypto.getRandomValues(bytes);
22006                    globalThis.cryptoRng = {
22007                        len: bytes.length,
22008                        inRange: Array.from(bytes).every((n) => Number.isInteger(n) && n >= 0 && n <= 255),
22009                    };
22010                    ",
22011                )
22012                .await
22013                .expect("eval web crypto getRandomValues");
22014
22015            let r = get_global_json(&runtime, "cryptoRng").await;
22016            assert_eq!(r["len"], serde_json::json!(32));
22017            assert_eq!(r["inRange"], serde_json::json!(true));
22018        });
22019    }
22020
22021    #[test]
22022    fn pijs_buffer_global_operations() {
22023        futures::executor::block_on(async {
22024            let clock = Arc::new(DeterministicClock::new(0));
22025            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22026                .await
22027                .expect("create runtime");
22028
22029            runtime
22030                .eval(
22031                    r"
22032                    globalThis.bufResults = {};
22033                    // Test the global Buffer polyfill (set up during runtime init)
22034                    const B = globalThis.Buffer;
22035                    globalThis.bufResults.hasBuffer = typeof B === 'function';
22036                    globalThis.bufResults.hasFrom = typeof B.from === 'function';
22037
22038                    // Buffer.from with array input
22039                    const arr = B.from([65, 66, 67]);
22040                    globalThis.bufResults.fromArrayLength = arr.length;
22041
22042                    // Uint8Array allocation
22043                    const zeroed = new Uint8Array(16);
22044                    globalThis.bufResults.allocLength = zeroed.length;
22045
22046                    globalThis.bufResults.done = true;
22047                    ",
22048                )
22049                .await
22050                .expect("eval Buffer");
22051
22052            let r = get_global_json(&runtime, "bufResults").await;
22053            assert_eq!(r["done"], serde_json::json!(true));
22054            assert_eq!(r["hasBuffer"], serde_json::json!(true));
22055            assert_eq!(r["hasFrom"], serde_json::json!(true));
22056            assert_eq!(r["fromArrayLength"], serde_json::json!(3));
22057            assert_eq!(r["allocLength"], serde_json::json!(16));
22058        });
22059    }
22060
22061    #[test]
22062    fn pijs_node_fs_promises_async_roundtrip() {
22063        futures::executor::block_on(async {
22064            let clock = Arc::new(DeterministicClock::new(0));
22065            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22066                .await
22067                .expect("create runtime");
22068
22069            runtime
22070                .eval(
22071                    r"
22072                    globalThis.fspResults = {};
22073                    import('node:fs/promises').then(async (fsp) => {
22074                        // Write then read back
22075                        await fsp.writeFile('/test/hello.txt', 'async content');
22076                        const data = await fsp.readFile('/test/hello.txt', 'utf8');
22077                        globalThis.fspResults.readBack = data;
22078
22079                        // stat
22080                        const st = await fsp.stat('/test/hello.txt');
22081                        globalThis.fspResults.statIsFile = st.isFile();
22082                        globalThis.fspResults.statSize = st.size;
22083
22084                        // mkdir + readdir
22085                        await fsp.mkdir('/test/subdir');
22086                        await fsp.writeFile('/test/subdir/a.txt', 'aaa');
22087                        const entries = await fsp.readdir('/test/subdir');
22088                        globalThis.fspResults.dirEntries = entries;
22089
22090                        // unlink
22091                        await fsp.unlink('/test/subdir/a.txt');
22092                        const exists = await fsp.access('/test/subdir/a.txt').then(() => true).catch(() => false);
22093                        globalThis.fspResults.deletedFileExists = exists;
22094
22095                        globalThis.fspResults.done = true;
22096                    });
22097                    ",
22098                )
22099                .await
22100                .expect("eval fs/promises");
22101
22102            drain_until_idle(&runtime, &clock).await;
22103
22104            let r = get_global_json(&runtime, "fspResults").await;
22105            assert_eq!(r["done"], serde_json::json!(true));
22106            assert_eq!(r["readBack"], serde_json::json!("async content"));
22107            assert_eq!(r["statIsFile"], serde_json::json!(true));
22108            assert!(r["statSize"].as_u64().unwrap() > 0);
22109            assert_eq!(r["dirEntries"], serde_json::json!(["a.txt"]));
22110            assert_eq!(r["deletedFileExists"], serde_json::json!(false));
22111        });
22112    }
22113
22114    #[test]
22115    fn pijs_node_process_module_exports() {
22116        futures::executor::block_on(async {
22117            let clock = Arc::new(DeterministicClock::new(0));
22118            let config = PiJsRuntimeConfig {
22119                cwd: "/test/project".to_string(),
22120                args: vec!["arg1".to_string(), "arg2".to_string()],
22121                env: HashMap::new(),
22122                limits: PiJsRuntimeLimits::default(),
22123                repair_mode: RepairMode::default(),
22124                allow_unsafe_sync_exec: false,
22125                deny_env: false,
22126                disk_cache_dir: None,
22127            };
22128            let runtime =
22129                PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
22130                    .await
22131                    .expect("create runtime");
22132
22133            runtime
22134                .eval(
22135                    r"
22136                    globalThis.procResults = {};
22137                    import('node:process').then((proc) => {
22138                        globalThis.procResults.platform = proc.platform;
22139                        globalThis.procResults.arch = proc.arch;
22140                        globalThis.procResults.version = proc.version;
22141                        globalThis.procResults.pid = proc.pid;
22142                        globalThis.procResults.cwdType = typeof proc.cwd;
22143                        globalThis.procResults.cwdValue = typeof proc.cwd === 'function'
22144                            ? proc.cwd() : proc.cwd;
22145                        globalThis.procResults.hasEnv = typeof proc.env === 'object';
22146                        globalThis.procResults.hasStdout = typeof proc.stdout === 'object';
22147                        globalThis.procResults.hasStderr = typeof proc.stderr === 'object';
22148                        globalThis.procResults.hasNextTick = typeof proc.nextTick === 'function';
22149
22150                        // nextTick should schedule microtask
22151                        globalThis.procResults.nextTickRan = false;
22152                        proc.nextTick(() => { globalThis.procResults.nextTickRan = true; });
22153
22154                        // hrtime should return array
22155                        const hr = proc.hrtime();
22156                        globalThis.procResults.hrtimeIsArray = Array.isArray(hr);
22157                        globalThis.procResults.hrtimeLength = hr.length;
22158
22159                        globalThis.procResults.done = true;
22160                    });
22161                    ",
22162                )
22163                .await
22164                .expect("eval node:process");
22165
22166            drain_until_idle(&runtime, &clock).await;
22167
22168            let r = get_global_json(&runtime, "procResults").await;
22169            assert_eq!(r["done"], serde_json::json!(true));
22170            // platform/arch are determined at runtime from env/cfg
22171            assert!(r["platform"].is_string(), "platform should be a string");
22172            let expected_arch = if cfg!(target_arch = "aarch64") {
22173                "arm64"
22174            } else {
22175                "x64"
22176            };
22177            assert_eq!(r["arch"], serde_json::json!(expected_arch));
22178            assert!(r["version"].is_string());
22179            assert_eq!(r["pid"], serde_json::json!(1));
22180            assert!(r["hasEnv"] == serde_json::json!(true));
22181            assert!(r["hasStdout"] == serde_json::json!(true));
22182            assert!(r["hasStderr"] == serde_json::json!(true));
22183            assert!(r["hasNextTick"] == serde_json::json!(true));
22184            // nextTick is scheduled as microtask — should have run
22185            assert_eq!(r["nextTickRan"], serde_json::json!(true));
22186            assert_eq!(r["hrtimeIsArray"], serde_json::json!(true));
22187            assert_eq!(r["hrtimeLength"], serde_json::json!(2));
22188        });
22189    }
22190
22191    #[test]
22192    fn pijs_pi_path_join_behavior() {
22193        futures::executor::block_on(async {
22194            let clock = Arc::new(DeterministicClock::new(0));
22195            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22196                .await
22197                .expect("create runtime");
22198
22199            runtime
22200                .eval(
22201                    r"
22202                    globalThis.joinResults = {};
22203                    globalThis.joinResults.concatAbs = pi.path.join('/a', '/b');
22204                    globalThis.joinResults.normal = pi.path.join('a', 'b');
22205                    globalThis.joinResults.root = pi.path.join('/', 'a');
22206                    globalThis.joinResults.dots = pi.path.join('/a', '..', 'b');
22207                    globalThis.joinResults.done = true;
22208                    ",
22209                )
22210                .await
22211                .expect("eval pi.path.join");
22212
22213            let r = get_global_json(&runtime, "joinResults").await;
22214            assert_eq!(r["done"], serde_json::json!(true));
22215            // Should be /a/b, NOT /b (bug fix)
22216            assert_eq!(r["concatAbs"], serde_json::json!("/a/b"));
22217            assert_eq!(r["normal"], serde_json::json!("a/b"));
22218            assert_eq!(r["root"], serde_json::json!("/a"));
22219            assert_eq!(r["dots"], serde_json::json!("/b"));
22220        });
22221    }
22222
22223    #[test]
22224    fn pijs_node_path_relative_resolve_format() {
22225        futures::executor::block_on(async {
22226            let clock = Arc::new(DeterministicClock::new(0));
22227            let config = PiJsRuntimeConfig {
22228                cwd: "/home/user/project".to_string(),
22229                args: Vec::new(),
22230                env: HashMap::new(),
22231                limits: PiJsRuntimeLimits::default(),
22232                repair_mode: RepairMode::default(),
22233                allow_unsafe_sync_exec: false,
22234                deny_env: false,
22235                disk_cache_dir: None,
22236            };
22237            let runtime =
22238                PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
22239                    .await
22240                    .expect("create runtime");
22241
22242            runtime
22243                .eval(
22244                    r"
22245                    globalThis.pathResults2 = {};
22246                    import('node:path').then((path) => {
22247                        // relative
22248                        globalThis.pathResults2.relSameDir = path.relative('/a/b/c', '/a/b/c/d');
22249                        globalThis.pathResults2.relUp = path.relative('/a/b/c', '/a/b');
22250                        globalThis.pathResults2.relSame = path.relative('/a/b', '/a/b');
22251
22252                        // resolve uses cwd as base
22253                        globalThis.pathResults2.resolveAbs = path.resolve('/absolute/path');
22254                        globalThis.pathResults2.resolveRel = path.resolve('relative');
22255
22256                        // format
22257                        globalThis.pathResults2.formatFull = path.format({
22258                            dir: '/home/user',
22259                            base: 'file.txt'
22260                        });
22261
22262                        // sep and delimiter constants
22263                        globalThis.pathResults2.sep = path.sep;
22264                        globalThis.pathResults2.delimiter = path.delimiter;
22265
22266                        // dirname edge cases
22267                        globalThis.pathResults2.dirnameRoot = path.dirname('/');
22268                        globalThis.pathResults2.dirnameNested = path.dirname('/a/b/c');
22269
22270                        // join edge cases
22271                        globalThis.pathResults2.joinEmpty = path.join();
22272                        globalThis.pathResults2.joinDots = path.join('a', '..', 'b');
22273
22274                        globalThis.pathResults2.done = true;
22275                    });
22276                    ",
22277                )
22278                .await
22279                .expect("eval path extended 2");
22280
22281            let r = get_global_json(&runtime, "pathResults2").await;
22282            assert_eq!(r["done"], serde_json::json!(true));
22283            assert_eq!(r["relSameDir"], serde_json::json!("d"));
22284            assert_eq!(r["relUp"], serde_json::json!(".."));
22285            assert_eq!(r["relSame"], serde_json::json!("."));
22286            assert_eq!(r["resolveAbs"], serde_json::json!("/absolute/path"));
22287            // resolve('relative') should resolve against cwd
22288            assert!(r["resolveRel"].as_str().unwrap().ends_with("/relative"));
22289            assert_eq!(r["formatFull"], serde_json::json!("/home/user/file.txt"));
22290            assert_eq!(r["sep"], serde_json::json!("/"));
22291            assert_eq!(r["delimiter"], serde_json::json!(":"));
22292            assert_eq!(r["dirnameRoot"], serde_json::json!("/"));
22293            assert_eq!(r["dirnameNested"], serde_json::json!("/a/b"));
22294            // join doesn't normalize; normalize is separate
22295            let join_dots = r["joinDots"].as_str().unwrap();
22296            assert!(join_dots == "b" || join_dots == "a/../b");
22297        });
22298    }
22299
22300    #[test]
22301    fn pijs_node_util_module_exports() {
22302        futures::executor::block_on(async {
22303            let clock = Arc::new(DeterministicClock::new(0));
22304            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22305                .await
22306                .expect("create runtime");
22307
22308            runtime
22309                .eval(
22310                    r"
22311                    globalThis.utilResults = {};
22312                    import('node:util').then((util) => {
22313                        globalThis.utilResults.hasInspect = typeof util.inspect === 'function';
22314                        globalThis.utilResults.hasPromisify = typeof util.promisify === 'function';
22315                        globalThis.utilResults.inspectResult = util.inspect({ a: 1, b: [2, 3] });
22316                        globalThis.utilResults.done = true;
22317                    });
22318                    ",
22319                )
22320                .await
22321                .expect("eval node:util");
22322
22323            let r = get_global_json(&runtime, "utilResults").await;
22324            assert_eq!(r["done"], serde_json::json!(true));
22325            assert_eq!(r["hasInspect"], serde_json::json!(true));
22326            assert_eq!(r["hasPromisify"], serde_json::json!(true));
22327            // inspect should return some string representation
22328            assert!(r["inspectResult"].is_string());
22329        });
22330    }
22331
22332    #[test]
22333    fn pijs_node_assert_module_pass_and_fail() {
22334        futures::executor::block_on(async {
22335            let clock = Arc::new(DeterministicClock::new(0));
22336            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22337                .await
22338                .expect("create runtime");
22339
22340            runtime
22341                .eval(
22342                    r"
22343                    globalThis.assertResults = {};
22344                    import('node:assert').then((mod) => {
22345                        const assert = mod.default;
22346
22347                        // Passing assertions should not throw
22348                        assert.ok(true);
22349                        assert.strictEqual(1, 1);
22350                        assert.deepStrictEqual({ a: 1 }, { a: 1 });
22351                        assert.notStrictEqual(1, 2);
22352
22353                        // Failing assertion should throw
22354                        try {
22355                            assert.strictEqual(1, 2);
22356                            globalThis.assertResults.failDidNotThrow = true;
22357                        } catch (e) {
22358                            globalThis.assertResults.failThrew = true;
22359                            globalThis.assertResults.failMessage = e.message || String(e);
22360                        }
22361
22362                        globalThis.assertResults.done = true;
22363                    });
22364                    ",
22365                )
22366                .await
22367                .expect("eval node:assert");
22368
22369            let r = get_global_json(&runtime, "assertResults").await;
22370            assert_eq!(r["done"], serde_json::json!(true));
22371            assert_eq!(r["failThrew"], serde_json::json!(true));
22372            assert!(r["failMessage"].is_string());
22373        });
22374    }
22375
22376    #[test]
22377    fn pijs_node_fs_sync_edge_cases() {
22378        futures::executor::block_on(async {
22379            let clock = Arc::new(DeterministicClock::new(0));
22380            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22381                .await
22382                .expect("create runtime");
22383
22384            runtime
22385                .eval(
22386                    r"
22387                    globalThis.fsEdge = {};
22388                    import('node:fs').then((fs) => {
22389                        // Write, overwrite, read back
22390                        fs.writeFileSync('/edge/file.txt', 'first');
22391                        fs.writeFileSync('/edge/file.txt', 'second');
22392                        globalThis.fsEdge.overwrite = fs.readFileSync('/edge/file.txt', 'utf8');
22393
22394                        // existsSync for existing vs non-existing
22395                        globalThis.fsEdge.existsTrue = fs.existsSync('/edge/file.txt');
22396                        globalThis.fsEdge.existsFalse = fs.existsSync('/nonexistent/file.txt');
22397
22398                        // mkdirSync + readdirSync with withFileTypes
22399                        fs.mkdirSync('/edge/dir');
22400                        fs.writeFileSync('/edge/dir/a.txt', 'aaa');
22401                        fs.mkdirSync('/edge/dir/sub');
22402                        const dirents = fs.readdirSync('/edge/dir', { withFileTypes: true });
22403                        globalThis.fsEdge.direntCount = dirents.length;
22404                        const fileDirent = dirents.find(d => d.name === 'a.txt');
22405                        const dirDirent = dirents.find(d => d.name === 'sub');
22406                        globalThis.fsEdge.fileIsFile = fileDirent ? fileDirent.isFile() : null;
22407                        globalThis.fsEdge.dirIsDir = dirDirent ? dirDirent.isDirectory() : null;
22408
22409                        // rmSync recursive
22410                        fs.writeFileSync('/edge/dir/sub/deep.txt', 'deep');
22411                        fs.rmSync('/edge/dir', { recursive: true });
22412                        globalThis.fsEdge.rmRecursiveGone = !fs.existsSync('/edge/dir');
22413
22414                        // accessSync on non-existing file should throw
22415                        try {
22416                            fs.accessSync('/nope');
22417                            globalThis.fsEdge.accessThrew = false;
22418                        } catch (e) {
22419                            globalThis.fsEdge.accessThrew = true;
22420                        }
22421
22422                        // statSync on directory
22423                        fs.mkdirSync('/edge/statdir');
22424                        const dStat = fs.statSync('/edge/statdir');
22425                        globalThis.fsEdge.dirStatIsDir = dStat.isDirectory();
22426                        globalThis.fsEdge.dirStatIsFile = dStat.isFile();
22427
22428                        globalThis.fsEdge.done = true;
22429                    });
22430                    ",
22431                )
22432                .await
22433                .expect("eval fs edge cases");
22434
22435            let r = get_global_json(&runtime, "fsEdge").await;
22436            assert_eq!(r["done"], serde_json::json!(true));
22437            assert_eq!(r["overwrite"], serde_json::json!("second"));
22438            assert_eq!(r["existsTrue"], serde_json::json!(true));
22439            assert_eq!(r["existsFalse"], serde_json::json!(false));
22440            assert_eq!(r["direntCount"], serde_json::json!(2));
22441            assert_eq!(r["fileIsFile"], serde_json::json!(true));
22442            assert_eq!(r["dirIsDir"], serde_json::json!(true));
22443            assert_eq!(r["rmRecursiveGone"], serde_json::json!(true));
22444            assert_eq!(r["accessThrew"], serde_json::json!(true));
22445            assert_eq!(r["dirStatIsDir"], serde_json::json!(true));
22446            assert_eq!(r["dirStatIsFile"], serde_json::json!(false));
22447        });
22448    }
22449
22450    #[test]
22451    fn pijs_node_net_and_http_stubs_throw() {
22452        futures::executor::block_on(async {
22453            let clock = Arc::new(DeterministicClock::new(0));
22454            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22455                .await
22456                .expect("create runtime");
22457
22458            runtime
22459                .eval(
22460                    r"
22461                    globalThis.stubResults = {};
22462                    (async () => {
22463                        // node:net createServer should throw
22464                        const net = await import('node:net');
22465                        try {
22466                            net.createServer();
22467                            globalThis.stubResults.netThrew = false;
22468                        } catch (e) {
22469                            globalThis.stubResults.netThrew = true;
22470                        }
22471
22472                        // node:http createServer should throw
22473                        const http = await import('node:http');
22474                        try {
22475                            http.createServer();
22476                            globalThis.stubResults.httpThrew = false;
22477                        } catch (e) {
22478                            globalThis.stubResults.httpThrew = true;
22479                        }
22480
22481                        // node:https createServer should throw
22482                        const https = await import('node:https');
22483                        try {
22484                            https.createServer();
22485                            globalThis.stubResults.httpsThrew = false;
22486                        } catch (e) {
22487                            globalThis.stubResults.httpsThrew = true;
22488                        }
22489
22490                        globalThis.stubResults.done = true;
22491                    })();
22492                    ",
22493                )
22494                .await
22495                .expect("eval stub throws");
22496
22497            drain_until_idle(&runtime, &clock).await;
22498
22499            let r = get_global_json(&runtime, "stubResults").await;
22500            assert_eq!(r["done"], serde_json::json!(true));
22501            assert_eq!(r["netThrew"], serde_json::json!(true));
22502            assert_eq!(r["httpThrew"], serde_json::json!(true));
22503            assert_eq!(r["httpsThrew"], serde_json::json!(true));
22504        });
22505    }
22506
22507    #[test]
22508    fn pijs_node_readline_stub_exports() {
22509        futures::executor::block_on(async {
22510            let clock = Arc::new(DeterministicClock::new(0));
22511            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22512                .await
22513                .expect("create runtime");
22514
22515            runtime
22516                .eval(
22517                    r"
22518                    globalThis.rlResult = {};
22519                    import('node:readline').then((rl) => {
22520                        globalThis.rlResult.hasCreateInterface = typeof rl.createInterface === 'function';
22521                        globalThis.rlResult.done = true;
22522                    });
22523                    ",
22524                )
22525                .await
22526                .expect("eval readline");
22527
22528            let r = get_global_json(&runtime, "rlResult").await;
22529            assert_eq!(r["done"], serde_json::json!(true));
22530            assert_eq!(r["hasCreateInterface"], serde_json::json!(true));
22531        });
22532    }
22533
22534    #[test]
22535    fn pijs_node_stream_promises_pipeline_pass_through() {
22536        futures::executor::block_on(async {
22537            let clock = Arc::new(DeterministicClock::new(0));
22538            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22539                .await
22540                .expect("create runtime");
22541
22542            runtime
22543                .eval(
22544                    r#"
22545                    globalThis.streamInterop = { done: false };
22546                    (async () => {
22547                        const { Readable, PassThrough, Writable } = await import("node:stream");
22548                        const { pipeline } = await import("node:stream/promises");
22549
22550                        const collected = [];
22551                        const source = Readable.from(["alpha", "-", "omega"]);
22552                        const through = new PassThrough();
22553                        const sink = new Writable({
22554                          write(chunk, _encoding, callback) {
22555                            collected.push(String(chunk));
22556                            callback(null);
22557                          }
22558                        });
22559
22560                        await pipeline(source, through, sink);
22561                        globalThis.streamInterop.value = collected.join("");
22562                        globalThis.streamInterop.done = true;
22563                    })().catch((e) => {
22564                        globalThis.streamInterop.error = String(e && e.message ? e.message : e);
22565                        globalThis.streamInterop.done = false;
22566                    });
22567                    "#,
22568                )
22569                .await
22570                .expect("eval node:stream pipeline");
22571
22572            drain_until_idle(&runtime, &clock).await;
22573
22574            let result = get_global_json(&runtime, "streamInterop").await;
22575            assert_eq!(result["done"], serde_json::json!(true));
22576            assert_eq!(result["value"], serde_json::json!("alpha-omega"));
22577        });
22578    }
22579
22580    #[test]
22581    fn pijs_fs_create_stream_pipeline_copies_content() {
22582        futures::executor::block_on(async {
22583            let clock = Arc::new(DeterministicClock::new(0));
22584            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22585                .await
22586                .expect("create runtime");
22587
22588            runtime
22589                .eval(
22590                    r#"
22591                    globalThis.fsStreamCopy = { done: false };
22592                    (async () => {
22593                        const fs = await import("node:fs");
22594                        const { pipeline } = await import("node:stream/promises");
22595
22596                        fs.writeFileSync("/tmp/source.txt", "stream-data-123");
22597                        const src = fs.createReadStream("/tmp/source.txt");
22598                        const dst = fs.createWriteStream("/tmp/dest.txt");
22599                        await pipeline(src, dst);
22600
22601                        globalThis.fsStreamCopy.value = fs.readFileSync("/tmp/dest.txt", "utf8");
22602                        globalThis.fsStreamCopy.done = true;
22603                    })().catch((e) => {
22604                        globalThis.fsStreamCopy.error = String(e && e.message ? e.message : e);
22605                        globalThis.fsStreamCopy.done = false;
22606                    });
22607                    "#,
22608                )
22609                .await
22610                .expect("eval fs stream copy");
22611
22612            drain_until_idle(&runtime, &clock).await;
22613
22614            let result = get_global_json(&runtime, "fsStreamCopy").await;
22615            assert_eq!(result["done"], serde_json::json!(true));
22616            assert_eq!(result["value"], serde_json::json!("stream-data-123"));
22617        });
22618    }
22619
22620    #[test]
22621    fn pijs_node_stream_web_stream_bridge_roundtrip() {
22622        futures::executor::block_on(async {
22623            let clock = Arc::new(DeterministicClock::new(0));
22624            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22625                .await
22626                .expect("create runtime");
22627
22628            runtime
22629                .eval(
22630                    r#"
22631                    globalThis.webBridge = { done: false, skipped: false };
22632                    (async () => {
22633                        if (typeof ReadableStream !== "function" || typeof WritableStream !== "function") {
22634                            globalThis.webBridge.skipped = true;
22635                            globalThis.webBridge.done = true;
22636                            return;
22637                        }
22638
22639                        const { Readable, Writable } = await import("node:stream");
22640                        const { pipeline } = await import("node:stream/promises");
22641
22642                        const webReadable = new ReadableStream({
22643                          start(controller) {
22644                            controller.enqueue("ab");
22645                            controller.enqueue("cd");
22646                            controller.close();
22647                          }
22648                        });
22649                        const nodeReadable = Readable.fromWeb(webReadable);
22650
22651                        const fromWebChunks = [];
22652                        const webWritable = new WritableStream({
22653                          write(chunk) {
22654                            fromWebChunks.push(String(chunk));
22655                          }
22656                        });
22657                        const nodeWritable = Writable.fromWeb(webWritable);
22658                        await pipeline(nodeReadable, nodeWritable);
22659
22660                        const nodeReadableRoundtrip = Readable.from(["x", "y"]);
22661                        const webReadableRoundtrip = Readable.toWeb(nodeReadableRoundtrip);
22662                        const reader = webReadableRoundtrip.getReader();
22663                        const toWebChunks = [];
22664                        while (true) {
22665                          const { done, value } = await reader.read();
22666                          if (done) break;
22667                          toWebChunks.push(String(value));
22668                        }
22669
22670                        globalThis.webBridge.fromWeb = fromWebChunks.join("");
22671                        globalThis.webBridge.toWeb = toWebChunks.join("");
22672                        globalThis.webBridge.done = true;
22673                    })().catch((e) => {
22674                        globalThis.webBridge.error = String(e && e.message ? e.message : e);
22675                        globalThis.webBridge.done = false;
22676                    });
22677                    "#,
22678                )
22679                .await
22680                .expect("eval web stream bridge");
22681
22682            drain_until_idle(&runtime, &clock).await;
22683
22684            let result = get_global_json(&runtime, "webBridge").await;
22685            assert_eq!(result["done"], serde_json::json!(true));
22686            if result["skipped"] == serde_json::json!(true) {
22687                return;
22688            }
22689            assert_eq!(result["fromWeb"], serde_json::json!("abcd"));
22690            assert_eq!(result["toWeb"], serde_json::json!("xy"));
22691        });
22692    }
22693
22694    // ── Streaming hostcall tests ────────────────────────────────────────
22695
22696    #[test]
22697    fn pijs_stream_chunks_delivered_via_async_iterator() {
22698        futures::executor::block_on(async {
22699            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22700                .await
22701                .expect("create runtime");
22702
22703            // Start a streaming exec call
22704            runtime
22705                .eval(
22706                    r#"
22707            globalThis.chunks = [];
22708            globalThis.done = false;
22709            (async () => {
22710                const stream = pi.exec("cat", ["big.txt"], { stream: true });
22711                for await (const chunk of stream) {
22712                    globalThis.chunks.push(chunk);
22713                }
22714                globalThis.done = true;
22715            })();
22716            "#,
22717                )
22718                .await
22719                .expect("eval");
22720
22721            let requests = runtime.drain_hostcall_requests();
22722            assert_eq!(requests.len(), 1);
22723            let call_id = requests[0].call_id.clone();
22724
22725            // Send three non-final chunks then a final one
22726            for seq in 0..3 {
22727                runtime.complete_hostcall(
22728                    call_id.clone(),
22729                    HostcallOutcome::StreamChunk {
22730                        sequence: seq,
22731                        chunk: serde_json::json!({ "line": seq }),
22732                        is_final: false,
22733                    },
22734                );
22735                let stats = runtime.tick().await.expect("tick chunk");
22736                assert!(stats.ran_macrotask);
22737            }
22738
22739            // Hostcall should still be pending (tracker not yet completed)
22740            assert!(
22741                runtime.hostcall_tracker.borrow().is_pending(&call_id),
22742                "hostcall should still be pending after non-final chunks"
22743            );
22744
22745            // Send final chunk
22746            runtime.complete_hostcall(
22747                call_id.clone(),
22748                HostcallOutcome::StreamChunk {
22749                    sequence: 3,
22750                    chunk: serde_json::json!({ "line": 3 }),
22751                    is_final: true,
22752                },
22753            );
22754            let stats = runtime.tick().await.expect("tick final");
22755            assert!(stats.ran_macrotask);
22756
22757            // Hostcall is now completed
22758            assert!(
22759                !runtime.hostcall_tracker.borrow().is_pending(&call_id),
22760                "hostcall should be completed after final chunk"
22761            );
22762
22763            // Run microtasks to let the async iterator resolve
22764            runtime.tick().await.expect("tick settle");
22765
22766            let chunks = get_global_json(&runtime, "chunks").await;
22767            let arr = chunks.as_array().expect("chunks is array");
22768            assert_eq!(arr.len(), 4, "expected 4 chunks, got {arr:?}");
22769            for (i, c) in arr.iter().enumerate() {
22770                assert_eq!(c["line"], serde_json::json!(i), "chunk {i}");
22771            }
22772
22773            let done = get_global_json(&runtime, "done").await;
22774            assert_eq!(
22775                done,
22776                serde_json::json!(true),
22777                "async loop should have completed"
22778            );
22779        });
22780    }
22781
22782    #[test]
22783    fn pijs_stream_error_rejects_async_iterator() {
22784        futures::executor::block_on(async {
22785            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22786                .await
22787                .expect("create runtime");
22788
22789            runtime
22790                .eval(
22791                    r#"
22792            globalThis.chunks = [];
22793            globalThis.errMsg = null;
22794            (async () => {
22795                try {
22796                    const stream = pi.exec("fail", [], { stream: true });
22797                    for await (const chunk of stream) {
22798                        globalThis.chunks.push(chunk);
22799                    }
22800                } catch (e) {
22801                    globalThis.errMsg = e.message;
22802                }
22803            })();
22804            "#,
22805                )
22806                .await
22807                .expect("eval");
22808
22809            let requests = runtime.drain_hostcall_requests();
22810            let call_id = requests[0].call_id.clone();
22811
22812            // Send one good chunk
22813            runtime.complete_hostcall(
22814                call_id.clone(),
22815                HostcallOutcome::StreamChunk {
22816                    sequence: 0,
22817                    chunk: serde_json::json!("first"),
22818                    is_final: false,
22819                },
22820            );
22821            runtime.tick().await.expect("tick chunk 0");
22822
22823            // Now error the hostcall
22824            runtime.complete_hostcall(
22825                call_id,
22826                HostcallOutcome::Error {
22827                    code: "STREAM_ERR".into(),
22828                    message: "broken pipe".into(),
22829                },
22830            );
22831            runtime.tick().await.expect("tick error");
22832            runtime.tick().await.expect("tick settle");
22833
22834            let chunks = get_global_json(&runtime, "chunks").await;
22835            assert_eq!(
22836                chunks.as_array().expect("array").len(),
22837                1,
22838                "should have received 1 chunk before error"
22839            );
22840
22841            let err = get_global_json(&runtime, "errMsg").await;
22842            assert_eq!(err, serde_json::json!("broken pipe"));
22843        });
22844    }
22845
22846    #[test]
22847    fn pijs_stream_http_returns_async_iterator() {
22848        futures::executor::block_on(async {
22849            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22850                .await
22851                .expect("create runtime");
22852
22853            runtime
22854                .eval(
22855                    r#"
22856            globalThis.chunks = [];
22857            globalThis.done = false;
22858            (async () => {
22859                const stream = pi.http({ url: "http://example.com", stream: true });
22860                for await (const chunk of stream) {
22861                    globalThis.chunks.push(chunk);
22862                }
22863                globalThis.done = true;
22864            })();
22865            "#,
22866                )
22867                .await
22868                .expect("eval");
22869
22870            let requests = runtime.drain_hostcall_requests();
22871            assert_eq!(requests.len(), 1);
22872            let call_id = requests[0].call_id.clone();
22873
22874            // Two chunks: non-final then final
22875            runtime.complete_hostcall(
22876                call_id.clone(),
22877                HostcallOutcome::StreamChunk {
22878                    sequence: 0,
22879                    chunk: serde_json::json!("chunk-a"),
22880                    is_final: false,
22881                },
22882            );
22883            runtime.tick().await.expect("tick a");
22884
22885            runtime.complete_hostcall(
22886                call_id,
22887                HostcallOutcome::StreamChunk {
22888                    sequence: 1,
22889                    chunk: serde_json::json!("chunk-b"),
22890                    is_final: true,
22891                },
22892            );
22893            runtime.tick().await.expect("tick b");
22894            runtime.tick().await.expect("tick settle");
22895
22896            let chunks = get_global_json(&runtime, "chunks").await;
22897            let arr = chunks.as_array().expect("array");
22898            assert_eq!(arr.len(), 2);
22899            assert_eq!(arr[0], serde_json::json!("chunk-a"));
22900            assert_eq!(arr[1], serde_json::json!("chunk-b"));
22901
22902            assert_eq!(
22903                get_global_json(&runtime, "done").await,
22904                serde_json::json!(true)
22905            );
22906        });
22907    }
22908
22909    #[test]
22910    #[allow(clippy::too_many_lines)]
22911    fn pijs_stream_concurrent_exec_calls_have_independent_lifecycle() {
22912        futures::executor::block_on(async {
22913            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22914                .await
22915                .expect("create runtime");
22916
22917            runtime
22918                .eval(
22919                    r#"
22920            globalThis.streamA = [];
22921            globalThis.streamB = [];
22922            globalThis.doneA = false;
22923            globalThis.doneB = false;
22924            (async () => {
22925                const stream = pi.exec("cmd-a", [], { stream: true });
22926                for await (const chunk of stream) {
22927                    globalThis.streamA.push(chunk);
22928                }
22929                globalThis.doneA = true;
22930            })();
22931            (async () => {
22932                const stream = pi.exec("cmd-b", [], { stream: true });
22933                for await (const chunk of stream) {
22934                    globalThis.streamB.push(chunk);
22935                }
22936                globalThis.doneB = true;
22937            })();
22938            "#,
22939                )
22940                .await
22941                .expect("eval");
22942
22943            let requests = runtime.drain_hostcall_requests();
22944            assert_eq!(requests.len(), 2, "expected two streaming exec requests");
22945
22946            let mut call_a: Option<String> = None;
22947            let mut call_b: Option<String> = None;
22948            for request in &requests {
22949                match &request.kind {
22950                    HostcallKind::Exec { cmd } if cmd == "cmd-a" => {
22951                        call_a = Some(request.call_id.clone());
22952                    }
22953                    HostcallKind::Exec { cmd } if cmd == "cmd-b" => {
22954                        call_b = Some(request.call_id.clone());
22955                    }
22956                    _ => {}
22957                }
22958            }
22959
22960            let call_a = call_a.expect("call_id for cmd-a");
22961            let call_b = call_b.expect("call_id for cmd-b");
22962            assert_ne!(call_a, call_b, "concurrent calls must have distinct ids");
22963            assert_eq!(runtime.pending_hostcall_count(), 2);
22964
22965            runtime.complete_hostcall(
22966                call_a.clone(),
22967                HostcallOutcome::StreamChunk {
22968                    sequence: 0,
22969                    chunk: serde_json::json!("a0"),
22970                    is_final: false,
22971                },
22972            );
22973            runtime.tick().await.expect("tick a0");
22974
22975            runtime.complete_hostcall(
22976                call_b.clone(),
22977                HostcallOutcome::StreamChunk {
22978                    sequence: 0,
22979                    chunk: serde_json::json!("b0"),
22980                    is_final: false,
22981                },
22982            );
22983            runtime.tick().await.expect("tick b0");
22984            assert_eq!(runtime.pending_hostcall_count(), 2);
22985
22986            runtime.complete_hostcall(
22987                call_b.clone(),
22988                HostcallOutcome::StreamChunk {
22989                    sequence: 1,
22990                    chunk: serde_json::json!("b1"),
22991                    is_final: true,
22992                },
22993            );
22994            runtime.tick().await.expect("tick b1");
22995            assert_eq!(runtime.pending_hostcall_count(), 1);
22996            assert!(runtime.is_hostcall_pending(&call_a));
22997            assert!(!runtime.is_hostcall_pending(&call_b));
22998
22999            runtime.complete_hostcall(
23000                call_a.clone(),
23001                HostcallOutcome::StreamChunk {
23002                    sequence: 1,
23003                    chunk: serde_json::json!("a1"),
23004                    is_final: true,
23005                },
23006            );
23007            runtime.tick().await.expect("tick a1");
23008            assert_eq!(runtime.pending_hostcall_count(), 0);
23009            assert!(!runtime.is_hostcall_pending(&call_a));
23010
23011            runtime.tick().await.expect("tick settle 1");
23012            runtime.tick().await.expect("tick settle 2");
23013
23014            let stream_a = get_global_json(&runtime, "streamA").await;
23015            let stream_b = get_global_json(&runtime, "streamB").await;
23016            assert_eq!(
23017                stream_a.as_array().expect("streamA array"),
23018                &vec![serde_json::json!("a0"), serde_json::json!("a1")]
23019            );
23020            assert_eq!(
23021                stream_b.as_array().expect("streamB array"),
23022                &vec![serde_json::json!("b0"), serde_json::json!("b1")]
23023            );
23024            assert_eq!(
23025                get_global_json(&runtime, "doneA").await,
23026                serde_json::json!(true)
23027            );
23028            assert_eq!(
23029                get_global_json(&runtime, "doneB").await,
23030                serde_json::json!(true)
23031            );
23032        });
23033    }
23034
23035    #[test]
23036    fn pijs_stream_chunk_ignored_after_hostcall_completed() {
23037        futures::executor::block_on(async {
23038            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23039                .await
23040                .expect("create runtime");
23041
23042            runtime
23043                .eval(
23044                    r#"
23045            globalThis.result = null;
23046            pi.tool("read", { path: "test.txt" }).then(r => {
23047                globalThis.result = r;
23048            });
23049            "#,
23050                )
23051                .await
23052                .expect("eval");
23053
23054            let requests = runtime.drain_hostcall_requests();
23055            let call_id = requests[0].call_id.clone();
23056
23057            // Complete normally first
23058            runtime.complete_hostcall(
23059                call_id.clone(),
23060                HostcallOutcome::Success(serde_json::json!({ "content": "done" })),
23061            );
23062            runtime.tick().await.expect("tick success");
23063
23064            // Now try to deliver a stream chunk to the same call_id — should be ignored
23065            runtime.complete_hostcall(
23066                call_id,
23067                HostcallOutcome::StreamChunk {
23068                    sequence: 0,
23069                    chunk: serde_json::json!("stale"),
23070                    is_final: false,
23071                },
23072            );
23073            // This should not panic
23074            let stats = runtime.tick().await.expect("tick stale chunk");
23075            assert!(stats.ran_macrotask, "macrotask should run (and be ignored)");
23076
23077            let result = get_global_json(&runtime, "result").await;
23078            assert_eq!(result["content"], serde_json::json!("done"));
23079        });
23080    }
23081
23082    // ── node:child_process sync tests ──────────────────────────────────
23083
23084    #[test]
23085    fn pijs_exec_sync_denied_by_default_security_policy() {
23086        futures::executor::block_on(async {
23087            let clock = Arc::new(DeterministicClock::new(0));
23088            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23089                .await
23090                .expect("create runtime");
23091
23092            runtime
23093                .eval(
23094                    r"
23095                    globalThis.syncDenied = {};
23096                    import('node:child_process').then(({ execSync }) => {
23097                        try {
23098                            execSync('echo should-not-run');
23099                            globalThis.syncDenied.threw = false;
23100                        } catch (e) {
23101                            globalThis.syncDenied.threw = true;
23102                            globalThis.syncDenied.msg = String((e && e.message) || e || '');
23103                        }
23104                        globalThis.syncDenied.done = true;
23105                    });
23106                    ",
23107                )
23108                .await
23109                .expect("eval execSync deny");
23110
23111            let r = get_global_json(&runtime, "syncDenied").await;
23112            assert_eq!(r["done"], serde_json::json!(true));
23113            assert_eq!(r["threw"], serde_json::json!(true));
23114            assert!(
23115                r["msg"]
23116                    .as_str()
23117                    .unwrap_or("")
23118                    .contains("disabled by default"),
23119                "unexpected denial message: {}",
23120                r["msg"]
23121            );
23122        });
23123    }
23124
23125    #[test]
23126    fn pijs_exec_sync_enforces_exec_mediation_for_critical_commands() {
23127        futures::executor::block_on(async {
23128            let clock = Arc::new(DeterministicClock::new(0));
23129            let config = PiJsRuntimeConfig {
23130                allow_unsafe_sync_exec: true,
23131                ..PiJsRuntimeConfig::default()
23132            };
23133            let policy = crate::extensions::PolicyProfile::Permissive.to_policy();
23134            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
23135                Arc::clone(&clock),
23136                config,
23137                Some(policy),
23138            )
23139            .await
23140            .expect("create runtime");
23141
23142            runtime
23143                .eval(
23144                    r"
23145                    globalThis.syncMediation = {};
23146                    import('node:child_process').then(({ execSync }) => {
23147                        try {
23148                            execSync('dd if=/dev/zero of=/dev/null count=1');
23149                            globalThis.syncMediation.threw = false;
23150                        } catch (e) {
23151                            globalThis.syncMediation.threw = true;
23152                            globalThis.syncMediation.msg = String((e && e.message) || e || '');
23153                        }
23154                        globalThis.syncMediation.done = true;
23155                    });
23156                    ",
23157                )
23158                .await
23159                .expect("eval execSync mediation");
23160
23161            let r = get_global_json(&runtime, "syncMediation").await;
23162            assert_eq!(r["done"], serde_json::json!(true));
23163            assert_eq!(r["threw"], serde_json::json!(true));
23164            assert!(
23165                r["msg"].as_str().unwrap_or("").contains("exec mediation"),
23166                "unexpected mediation denial message: {}",
23167                r["msg"]
23168            );
23169        });
23170    }
23171
23172    #[test]
23173    fn pijs_exec_sync_runs_command_and_returns_stdout() {
23174        futures::executor::block_on(async {
23175            let clock = Arc::new(DeterministicClock::new(0));
23176            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23177
23178            runtime
23179                .eval(
23180                    r"
23181                    globalThis.syncResult = {};
23182                    import('node:child_process').then(({ execSync }) => {
23183                        try {
23184                            const output = execSync('echo hello-sync');
23185                            globalThis.syncResult.stdout = output.trim();
23186                            globalThis.syncResult.done = true;
23187                        } catch (e) {
23188                            globalThis.syncResult.error = String(e);
23189                            globalThis.syncResult.stack = e.stack || '';
23190                            globalThis.syncResult.done = false;
23191                        }
23192                    }).catch(e => {
23193                        globalThis.syncResult.promiseError = String(e);
23194                    });
23195                    ",
23196                )
23197                .await
23198                .expect("eval execSync test");
23199
23200            let r = get_global_json(&runtime, "syncResult").await;
23201            assert!(
23202                r["done"] == serde_json::json!(true),
23203                "execSync test failed: error={}, stack={}, promiseError={}",
23204                r["error"],
23205                r["stack"],
23206                r["promiseError"]
23207            );
23208            assert_eq!(r["stdout"], serde_json::json!("hello-sync"));
23209        });
23210    }
23211
23212    #[test]
23213    fn pijs_exec_sync_throws_on_nonzero_exit() {
23214        futures::executor::block_on(async {
23215            let clock = Arc::new(DeterministicClock::new(0));
23216            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23217
23218            runtime
23219                .eval(
23220                    r"
23221                    globalThis.syncErr = {};
23222                    import('node:child_process').then(({ execSync }) => {
23223                        try {
23224                            execSync('exit 42');
23225                            globalThis.syncErr.threw = false;
23226                        } catch (e) {
23227                            globalThis.syncErr.threw = true;
23228                            globalThis.syncErr.status = e.status;
23229                            globalThis.syncErr.hasStderr = typeof e.stderr === 'string';
23230                        }
23231                        globalThis.syncErr.done = true;
23232                    });
23233                    ",
23234                )
23235                .await
23236                .expect("eval execSync nonzero");
23237
23238            let r = get_global_json(&runtime, "syncErr").await;
23239            assert_eq!(r["done"], serde_json::json!(true));
23240            assert_eq!(r["threw"], serde_json::json!(true));
23241            // Status is a JS number (always f64 in QuickJS), so compare as f64
23242            assert_eq!(r["status"].as_f64(), Some(42.0));
23243            assert_eq!(r["hasStderr"], serde_json::json!(true));
23244        });
23245    }
23246
23247    #[test]
23248    fn pijs_exec_sync_empty_command_throws() {
23249        futures::executor::block_on(async {
23250            let clock = Arc::new(DeterministicClock::new(0));
23251            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23252
23253            runtime
23254                .eval(
23255                    r"
23256                    globalThis.emptyResult = {};
23257                    import('node:child_process').then(({ execSync }) => {
23258                        try {
23259                            execSync('');
23260                            globalThis.emptyResult.threw = false;
23261                        } catch (e) {
23262                            globalThis.emptyResult.threw = true;
23263                            globalThis.emptyResult.msg = e.message;
23264                        }
23265                        globalThis.emptyResult.done = true;
23266                    });
23267                    ",
23268                )
23269                .await
23270                .expect("eval execSync empty");
23271
23272            let r = get_global_json(&runtime, "emptyResult").await;
23273            assert_eq!(r["done"], serde_json::json!(true));
23274            assert_eq!(r["threw"], serde_json::json!(true));
23275            assert!(
23276                r["msg"]
23277                    .as_str()
23278                    .unwrap_or("")
23279                    .contains("command is required")
23280            );
23281        });
23282    }
23283
23284    #[test]
23285    fn pijs_spawn_sync_returns_result_object() {
23286        futures::executor::block_on(async {
23287            let clock = Arc::new(DeterministicClock::new(0));
23288            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23289
23290            runtime
23291                .eval(
23292                    r"
23293                    globalThis.spawnSyncResult = {};
23294                    import('node:child_process').then(({ spawnSync }) => {
23295                        const r = spawnSync('echo', ['spawn-test']);
23296                        globalThis.spawnSyncResult.stdout = r.stdout.trim();
23297                        globalThis.spawnSyncResult.status = r.status;
23298                        globalThis.spawnSyncResult.hasOutput = Array.isArray(r.output);
23299                        globalThis.spawnSyncResult.noError = r.error === undefined;
23300                        globalThis.spawnSyncResult.done = true;
23301                    });
23302                    ",
23303                )
23304                .await
23305                .expect("eval spawnSync test");
23306
23307            let r = get_global_json(&runtime, "spawnSyncResult").await;
23308            assert_eq!(r["done"], serde_json::json!(true));
23309            assert_eq!(r["stdout"], serde_json::json!("spawn-test"));
23310            assert_eq!(r["status"].as_f64(), Some(0.0));
23311            assert_eq!(r["hasOutput"], serde_json::json!(true));
23312            assert_eq!(r["noError"], serde_json::json!(true));
23313        });
23314    }
23315
23316    #[test]
23317    fn pijs_spawn_sync_captures_nonzero_exit() {
23318        futures::executor::block_on(async {
23319            let clock = Arc::new(DeterministicClock::new(0));
23320            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23321
23322            runtime
23323                .eval(
23324                    r"
23325                    globalThis.spawnSyncFail = {};
23326                    import('node:child_process').then(({ spawnSync }) => {
23327                        const r = spawnSync('sh', ['-c', 'exit 7']);
23328                        globalThis.spawnSyncFail.status = r.status;
23329                        globalThis.spawnSyncFail.signal = r.signal;
23330                        globalThis.spawnSyncFail.done = true;
23331                    });
23332                    ",
23333                )
23334                .await
23335                .expect("eval spawnSync fail");
23336
23337            let r = get_global_json(&runtime, "spawnSyncFail").await;
23338            assert_eq!(r["done"], serde_json::json!(true));
23339            assert_eq!(r["status"].as_f64(), Some(7.0));
23340            assert_eq!(r["signal"], serde_json::json!(null));
23341        });
23342    }
23343
23344    #[test]
23345    fn pijs_spawn_sync_bad_command_returns_error() {
23346        futures::executor::block_on(async {
23347            let clock = Arc::new(DeterministicClock::new(0));
23348            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23349
23350            runtime
23351                .eval(
23352                    r"
23353                    globalThis.badCmd = {};
23354                    import('node:child_process').then(({ spawnSync }) => {
23355                        const r = spawnSync('__nonexistent_binary_xyzzy__');
23356                        globalThis.badCmd.hasError = r.error !== undefined;
23357                        globalThis.badCmd.statusNull = r.status === null;
23358                        globalThis.badCmd.done = true;
23359                    });
23360                    ",
23361                )
23362                .await
23363                .expect("eval spawnSync bad cmd");
23364
23365            let r = get_global_json(&runtime, "badCmd").await;
23366            assert_eq!(r["done"], serde_json::json!(true));
23367            assert_eq!(r["hasError"], serde_json::json!(true));
23368            assert_eq!(r["statusNull"], serde_json::json!(true));
23369        });
23370    }
23371
23372    #[test]
23373    fn pijs_exec_file_sync_runs_binary_directly() {
23374        futures::executor::block_on(async {
23375            let clock = Arc::new(DeterministicClock::new(0));
23376            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23377
23378            runtime
23379                .eval(
23380                    r"
23381                    globalThis.execFileResult = {};
23382                    import('node:child_process').then(({ execFileSync }) => {
23383                        const output = execFileSync('echo', ['file-sync-test']);
23384                        globalThis.execFileResult.stdout = output.trim();
23385                        globalThis.execFileResult.done = true;
23386                    });
23387                    ",
23388                )
23389                .await
23390                .expect("eval execFileSync test");
23391
23392            let r = get_global_json(&runtime, "execFileResult").await;
23393            assert_eq!(r["done"], serde_json::json!(true));
23394            assert_eq!(r["stdout"], serde_json::json!("file-sync-test"));
23395        });
23396    }
23397
23398    #[test]
23399    fn pijs_exec_sync_captures_stderr() {
23400        futures::executor::block_on(async {
23401            let clock = Arc::new(DeterministicClock::new(0));
23402            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23403
23404            runtime
23405                .eval(
23406                    r"
23407                    globalThis.stderrResult = {};
23408                    import('node:child_process').then(({ execSync }) => {
23409                        try {
23410                            execSync('echo err-msg >&2 && exit 1');
23411                            globalThis.stderrResult.threw = false;
23412                        } catch (e) {
23413                            globalThis.stderrResult.threw = true;
23414                            globalThis.stderrResult.stderr = e.stderr.trim();
23415                        }
23416                        globalThis.stderrResult.done = true;
23417                    });
23418                    ",
23419                )
23420                .await
23421                .expect("eval execSync stderr");
23422
23423            let r = get_global_json(&runtime, "stderrResult").await;
23424            assert_eq!(r["done"], serde_json::json!(true));
23425            assert_eq!(r["threw"], serde_json::json!(true));
23426            assert_eq!(r["stderr"], serde_json::json!("err-msg"));
23427        });
23428    }
23429
23430    #[test]
23431    #[cfg(unix)]
23432    fn pijs_exec_sync_with_cwd_option() {
23433        futures::executor::block_on(async {
23434            let clock = Arc::new(DeterministicClock::new(0));
23435            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23436
23437            runtime
23438                .eval(
23439                    r"
23440                    globalThis.cwdResult = {};
23441                    import('node:child_process').then(({ execSync }) => {
23442                        const output = execSync('pwd', { cwd: '/tmp' });
23443                        globalThis.cwdResult.dir = output.trim();
23444                        globalThis.cwdResult.done = true;
23445                    });
23446                    ",
23447                )
23448                .await
23449                .expect("eval execSync cwd");
23450
23451            let r = get_global_json(&runtime, "cwdResult").await;
23452            assert_eq!(r["done"], serde_json::json!(true));
23453            // /tmp may resolve to /private/tmp on macOS
23454            let dir = r["dir"].as_str().unwrap_or("");
23455            assert!(
23456                dir == "/tmp" || dir.ends_with("/tmp"),
23457                "expected /tmp, got: {dir}"
23458            );
23459        });
23460    }
23461
23462    #[test]
23463    fn pijs_spawn_sync_empty_command_throws() {
23464        futures::executor::block_on(async {
23465            let clock = Arc::new(DeterministicClock::new(0));
23466            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23467
23468            runtime
23469                .eval(
23470                    r"
23471                    globalThis.emptySpawn = {};
23472                    import('node:child_process').then(({ spawnSync }) => {
23473                        try {
23474                            spawnSync('');
23475                            globalThis.emptySpawn.threw = false;
23476                        } catch (e) {
23477                            globalThis.emptySpawn.threw = true;
23478                            globalThis.emptySpawn.msg = e.message;
23479                        }
23480                        globalThis.emptySpawn.done = true;
23481                    });
23482                    ",
23483                )
23484                .await
23485                .expect("eval spawnSync empty");
23486
23487            let r = get_global_json(&runtime, "emptySpawn").await;
23488            assert_eq!(r["done"], serde_json::json!(true));
23489            assert_eq!(r["threw"], serde_json::json!(true));
23490            assert!(
23491                r["msg"]
23492                    .as_str()
23493                    .unwrap_or("")
23494                    .contains("command is required")
23495            );
23496        });
23497    }
23498
23499    #[test]
23500    #[cfg(unix)]
23501    fn pijs_spawn_sync_options_as_second_arg() {
23502        futures::executor::block_on(async {
23503            let clock = Arc::new(DeterministicClock::new(0));
23504            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23505
23506            // spawnSync(cmd, options) with no args array — options is 2nd param
23507            runtime
23508                .eval(
23509                    r"
23510                    globalThis.optsResult = {};
23511                    import('node:child_process').then(({ spawnSync }) => {
23512                        const r = spawnSync('pwd', { cwd: '/tmp' });
23513                        globalThis.optsResult.stdout = r.stdout.trim();
23514                        globalThis.optsResult.done = true;
23515                    });
23516                    ",
23517                )
23518                .await
23519                .expect("eval spawnSync opts as 2nd arg");
23520
23521            let r = get_global_json(&runtime, "optsResult").await;
23522            assert_eq!(r["done"], serde_json::json!(true));
23523            let stdout = r["stdout"].as_str().unwrap_or("");
23524            assert!(
23525                stdout == "/tmp" || stdout.ends_with("/tmp"),
23526                "expected /tmp, got: {stdout}"
23527            );
23528        });
23529    }
23530
23531    // ── node:os expanded API tests ─────────────────────────────────────
23532
23533    #[test]
23534    fn pijs_os_expanded_apis() {
23535        futures::executor::block_on(async {
23536            let clock = Arc::new(DeterministicClock::new(0));
23537            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23538                .await
23539                .expect("create runtime");
23540
23541            runtime
23542                .eval(
23543                    r"
23544                    globalThis.osEx = {};
23545                    import('node:os').then((os) => {
23546                        const cpuArr = os.cpus();
23547                        globalThis.osEx.cpusIsArray = Array.isArray(cpuArr);
23548                        globalThis.osEx.cpusLen = cpuArr.length;
23549                        globalThis.osEx.cpuHasModel = typeof cpuArr[0].model === 'string';
23550                        globalThis.osEx.cpuHasSpeed = typeof cpuArr[0].speed === 'number';
23551                        globalThis.osEx.cpuHasTimes = typeof cpuArr[0].times === 'object';
23552
23553                        globalThis.osEx.totalmem = os.totalmem();
23554                        globalThis.osEx.totalMemPositive = os.totalmem() > 0;
23555                        globalThis.osEx.freeMemPositive = os.freemem() > 0;
23556                        globalThis.osEx.freeMemLessTotal = os.freemem() <= os.totalmem();
23557
23558                        globalThis.osEx.uptimePositive = os.uptime() > 0;
23559
23560                        const la = os.loadavg();
23561                        globalThis.osEx.loadavgIsArray = Array.isArray(la);
23562                        globalThis.osEx.loadavgLen = la.length;
23563
23564                        globalThis.osEx.networkInterfacesIsObj = typeof os.networkInterfaces() === 'object';
23565
23566                        const ui = os.userInfo();
23567                        globalThis.osEx.userInfoHasUid = typeof ui.uid === 'number';
23568                        globalThis.osEx.userInfoHasUsername = typeof ui.username === 'string';
23569                        globalThis.osEx.userInfoHasHomedir = typeof ui.homedir === 'string';
23570                        globalThis.osEx.userInfoHasShell = typeof ui.shell === 'string';
23571
23572                        globalThis.osEx.endianness = os.endianness();
23573                        globalThis.osEx.eol = os.EOL;
23574                        globalThis.osEx.devNull = os.devNull;
23575                        globalThis.osEx.hasConstants = typeof os.constants === 'object';
23576
23577                        globalThis.osEx.done = true;
23578                    });
23579                    ",
23580                )
23581                .await
23582                .expect("eval node:os expanded");
23583
23584            let r = get_global_json(&runtime, "osEx").await;
23585            assert_eq!(r["done"], serde_json::json!(true));
23586            // cpus()
23587            assert_eq!(r["cpusIsArray"], serde_json::json!(true));
23588            assert!(r["cpusLen"].as_f64().unwrap_or(0.0) >= 1.0);
23589            assert_eq!(r["cpuHasModel"], serde_json::json!(true));
23590            assert_eq!(r["cpuHasSpeed"], serde_json::json!(true));
23591            assert_eq!(r["cpuHasTimes"], serde_json::json!(true));
23592            // totalmem/freemem
23593            assert_eq!(r["totalMemPositive"], serde_json::json!(true));
23594            assert_eq!(r["freeMemPositive"], serde_json::json!(true));
23595            assert_eq!(r["freeMemLessTotal"], serde_json::json!(true));
23596            // uptime
23597            assert_eq!(r["uptimePositive"], serde_json::json!(true));
23598            // loadavg
23599            assert_eq!(r["loadavgIsArray"], serde_json::json!(true));
23600            assert_eq!(r["loadavgLen"].as_f64(), Some(3.0));
23601            // networkInterfaces
23602            assert_eq!(r["networkInterfacesIsObj"], serde_json::json!(true));
23603            // userInfo
23604            assert_eq!(r["userInfoHasUid"], serde_json::json!(true));
23605            assert_eq!(r["userInfoHasUsername"], serde_json::json!(true));
23606            assert_eq!(r["userInfoHasHomedir"], serde_json::json!(true));
23607            assert_eq!(r["userInfoHasShell"], serde_json::json!(true));
23608            // endianness / EOL / devNull / constants
23609            assert_eq!(r["endianness"], serde_json::json!("LE"));
23610            let expected_eol = if cfg!(windows) { "\r\n" } else { "\n" };
23611            assert_eq!(r["eol"], serde_json::json!(expected_eol));
23612            let expected_dev_null = if cfg!(windows) {
23613                "\\\\.\\NUL"
23614            } else {
23615                "/dev/null"
23616            };
23617            assert_eq!(r["devNull"], serde_json::json!(expected_dev_null));
23618            assert_eq!(r["hasConstants"], serde_json::json!(true));
23619        });
23620    }
23621
23622    // ── Buffer expanded API tests ──────────────────────────────────────
23623
23624    #[test]
23625    fn pijs_buffer_expanded_apis() {
23626        futures::executor::block_on(async {
23627            let clock = Arc::new(DeterministicClock::new(0));
23628            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23629                .await
23630                .expect("create runtime");
23631
23632            runtime
23633                .eval(
23634                    r"
23635                    globalThis.bufResult = {};
23636                    (() => {
23637                        const B = globalThis.Buffer;
23638
23639                        // alloc
23640                        const a = B.alloc(4, 0xAB);
23641                        globalThis.bufResult.allocFill = Array.from(a);
23642
23643                        // from string + hex encoding
23644                        const hex = B.from('48656c6c6f', 'hex');
23645                        globalThis.bufResult.hexDecode = hex.toString('utf8');
23646
23647                        // concat
23648                        const c = B.concat([B.from('Hello'), B.from(' World')]);
23649                        globalThis.bufResult.concat = c.toString();
23650
23651                        // byteLength
23652                        globalThis.bufResult.byteLength = B.byteLength('Hello');
23653
23654                        // compare
23655                        globalThis.bufResult.compareEqual = B.compare(B.from('abc'), B.from('abc'));
23656                        globalThis.bufResult.compareLess = B.compare(B.from('abc'), B.from('abd'));
23657                        globalThis.bufResult.compareGreater = B.compare(B.from('abd'), B.from('abc'));
23658
23659                        // isEncoding
23660                        globalThis.bufResult.isEncodingUtf8 = B.isEncoding('utf8');
23661                        globalThis.bufResult.isEncodingFake = B.isEncoding('fake');
23662
23663                        // isBuffer
23664                        globalThis.bufResult.isBufferTrue = B.isBuffer(B.from('x'));
23665                        globalThis.bufResult.isBufferFalse = B.isBuffer('x');
23666
23667                        // instance methods
23668                        const b = B.from('Hello World');
23669                        globalThis.bufResult.indexOf = b.indexOf('World');
23670                        globalThis.bufResult.includes = b.includes('World');
23671                        globalThis.bufResult.notIncludes = b.includes('xyz');
23672
23673                        const sliced = b.slice(0, 5);
23674                        globalThis.bufResult.slice = sliced.toString();
23675
23676                        globalThis.bufResult.toJSON = b.toJSON().type;
23677
23678                        const eq1 = B.from('abc');
23679                        const eq2 = B.from('abc');
23680                        const eq3 = B.from('xyz');
23681                        globalThis.bufResult.equalsTrue = eq1.equals(eq2);
23682                        globalThis.bufResult.equalsFalse = eq1.equals(eq3);
23683
23684                        // copy
23685                        const src = B.from('Hello');
23686                        const dst = B.alloc(5);
23687                        src.copy(dst);
23688                        globalThis.bufResult.copy = dst.toString();
23689
23690                        // write
23691                        const wb = B.alloc(10);
23692                        wb.write('Hi');
23693                        globalThis.bufResult.write = wb.toString('utf8', 0, 2);
23694
23695                        // readUInt / writeUInt
23696                        const nb = B.alloc(4);
23697                        nb.writeUInt16BE(0x1234, 0);
23698                        globalThis.bufResult.readUInt16BE = nb.readUInt16BE(0);
23699                        nb.writeUInt32LE(0xDEADBEEF, 0);
23700                        globalThis.bufResult.readUInt32LE = nb.readUInt32LE(0);
23701
23702                        // hex encoding
23703                        const hb = B.from([0xDE, 0xAD]);
23704                        globalThis.bufResult.toHex = hb.toString('hex');
23705
23706                        // base64 round-trip
23707                        const b64 = B.from('Hello').toString('base64');
23708                        const roundTrip = B.from(b64, 'base64').toString();
23709                        globalThis.bufResult.base64Round = roundTrip;
23710
23711                        globalThis.bufResult.done = true;
23712                    })();
23713                    ",
23714                )
23715                .await
23716                .expect("eval Buffer expanded");
23717
23718            let r = get_global_json(&runtime, "bufResult").await;
23719            assert_eq!(r["done"], serde_json::json!(true));
23720            // alloc with fill
23721            assert_eq!(r["allocFill"], serde_json::json!([0xAB, 0xAB, 0xAB, 0xAB]));
23722            // hex decode
23723            assert_eq!(r["hexDecode"], serde_json::json!("Hello"));
23724            // concat
23725            assert_eq!(r["concat"], serde_json::json!("Hello World"));
23726            // byteLength
23727            assert_eq!(r["byteLength"].as_f64(), Some(5.0));
23728            // compare
23729            assert_eq!(r["compareEqual"].as_f64(), Some(0.0));
23730            assert!(r["compareLess"].as_f64().unwrap_or(0.0) < 0.0);
23731            assert!(r["compareGreater"].as_f64().unwrap_or(0.0) > 0.0);
23732            // isEncoding
23733            assert_eq!(r["isEncodingUtf8"], serde_json::json!(true));
23734            assert_eq!(r["isEncodingFake"], serde_json::json!(false));
23735            // isBuffer
23736            assert_eq!(r["isBufferTrue"], serde_json::json!(true));
23737            assert_eq!(r["isBufferFalse"], serde_json::json!(false));
23738            // indexOf / includes
23739            assert_eq!(r["indexOf"].as_f64(), Some(6.0));
23740            assert_eq!(r["includes"], serde_json::json!(true));
23741            assert_eq!(r["notIncludes"], serde_json::json!(false));
23742            // slice
23743            assert_eq!(r["slice"], serde_json::json!("Hello"));
23744            // toJSON
23745            assert_eq!(r["toJSON"], serde_json::json!("Buffer"));
23746            // equals
23747            assert_eq!(r["equalsTrue"], serde_json::json!(true));
23748            assert_eq!(r["equalsFalse"], serde_json::json!(false));
23749            // copy
23750            assert_eq!(r["copy"], serde_json::json!("Hello"));
23751            // write
23752            assert_eq!(r["write"], serde_json::json!("Hi"));
23753            // readUInt16BE
23754            assert_eq!(r["readUInt16BE"].as_f64(), Some(f64::from(0x1234)));
23755            // readUInt32LE
23756            assert_eq!(r["readUInt32LE"].as_f64(), Some(f64::from(0xDEAD_BEEF_u32)));
23757            // hex
23758            assert_eq!(r["toHex"], serde_json::json!("dead"));
23759            // base64 round-trip
23760            assert_eq!(r["base64Round"], serde_json::json!("Hello"));
23761        });
23762    }
23763}