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;
60
61// ============================================================================
62// Environment variable filtering (bd-1av0.9)
63// ============================================================================
64
65use crate::extensions::{
66    ExecMediationResult, ExtensionPolicy, ExtensionPolicyMode, SecretBrokerPolicy,
67    evaluate_exec_mediation,
68};
69
70/// Helper to check `exec` capability for sync execution where we cannot prompt.
71fn check_exec_capability(policy: &ExtensionPolicy, extension_id: Option<&str>) -> bool {
72    let cap = "exec";
73
74    // 1. Per-extension overrides
75    if let Some(id) = extension_id {
76        if let Some(override_config) = policy.per_extension.get(id) {
77            if override_config.deny.iter().any(|c| c == cap) {
78                return false;
79            }
80            if override_config.allow.iter().any(|c| c == cap) {
81                return true;
82            }
83            if let Some(mode) = override_config.mode {
84                return match mode {
85                    ExtensionPolicyMode::Permissive => true,
86                    ExtensionPolicyMode::Strict | ExtensionPolicyMode::Prompt => false, // Prompt = deny for sync
87                };
88            }
89        }
90    }
91
92    // 2. Global deny
93    if policy.deny_caps.iter().any(|c| c == cap) {
94        return false;
95    }
96
97    // 3. Global allow (default_caps)
98    if policy.default_caps.iter().any(|c| c == cap) {
99        return true;
100    }
101
102    // 4. Mode fallback
103    match policy.mode {
104        ExtensionPolicyMode::Permissive => true,
105        ExtensionPolicyMode::Strict | ExtensionPolicyMode::Prompt => false, // Prompt = deny for sync
106    }
107}
108
109/// Determine whether an environment variable is safe to expose to extensions.
110///
111/// Uses the default `SecretBrokerPolicy` to block known sensitive patterns
112/// (API keys, secrets, tokens, passwords, credentials).
113pub fn is_env_var_allowed(key: &str) -> bool {
114    let policy = SecretBrokerPolicy::default();
115    // is_secret returns true if it IS a secret (should be blocked).
116    // So we allow it if it is NOT a secret.
117    !policy.is_secret(key)
118}
119
120fn parse_truthy_flag(value: &str) -> bool {
121    matches!(
122        value.trim().to_ascii_lowercase().as_str(),
123        "1" | "true" | "yes" | "on"
124    )
125}
126
127fn is_global_compat_scan_mode() -> bool {
128    cfg!(feature = "ext-conformance")
129        || std::env::var("PI_EXT_COMPAT_SCAN").is_ok_and(|value| parse_truthy_flag(&value))
130}
131
132fn is_compat_scan_mode(env: &HashMap<String, String>) -> bool {
133    is_global_compat_scan_mode()
134        || env
135            .get("PI_EXT_COMPAT_SCAN")
136            .is_some_and(|value| parse_truthy_flag(value))
137}
138
139/// Compatibility-mode fallback values for environment-gated extension registration.
140///
141/// This keeps conformance scans deterministic while preserving the default secret
142/// filtering behavior in normal runtime mode.
143fn compat_env_fallback_value(key: &str, env: &HashMap<String, String>) -> Option<String> {
144    if !is_compat_scan_mode(env) {
145        return None;
146    }
147
148    let upper = key.to_ascii_uppercase();
149    if upper.ends_with("_API_KEY") {
150        return Some(format!("pi-compat-{}", upper.to_ascii_lowercase()));
151    }
152    if upper == "PI_SEMANTIC_LEGACY" {
153        return Some("1".to_string());
154    }
155
156    None
157}
158
159// ============================================================================
160// Promise Bridge Types (bd-2ke)
161// ============================================================================
162
163/// Type of hostcall being requested from JavaScript.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum HostcallKind {
166    /// pi.tool(name, input) - invoke a tool
167    Tool { name: String },
168    /// pi.exec(cmd, args) - execute a shell command
169    Exec { cmd: String },
170    /// pi.http(request) - make an HTTP request
171    Http,
172    /// pi.session(op, args) - session operations
173    Session { op: String },
174    /// pi.ui(op, args) - UI operations
175    Ui { op: String },
176    /// pi.events(op, args) - event operations
177    Events { op: String },
178    /// pi.log(entry) - structured log emission
179    Log,
180}
181
182/// A hostcall request enqueued from JavaScript.
183#[derive(Debug, Clone)]
184pub struct HostcallRequest {
185    /// Unique identifier for correlation.
186    pub call_id: String,
187    /// Type of hostcall.
188    pub kind: HostcallKind,
189    /// JSON payload for the hostcall.
190    pub payload: serde_json::Value,
191    /// Trace ID for correlation with macrotask.
192    pub trace_id: u64,
193    /// Active extension id (when known) for policy/log correlation.
194    pub extension_id: Option<String>,
195}
196
197impl QueueTenant for HostcallRequest {
198    fn tenant_key(&self) -> Option<&str> {
199        self.extension_id.as_deref()
200    }
201}
202
203/// Tool definition registered by a JS extension.
204#[allow(clippy::derive_partial_eq_without_eq)]
205#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
206pub struct ExtensionToolDef {
207    pub name: String,
208    #[serde(default)]
209    pub label: Option<String>,
210    pub description: String,
211    pub parameters: serde_json::Value,
212}
213
214/// Delegates to the canonical streaming implementation in `extensions.rs`.
215fn hostcall_params_hash(method: &str, params: &serde_json::Value) -> String {
216    crate::extensions::hostcall_params_hash(method, params)
217}
218
219fn canonical_exec_params(cmd: &str, payload: &serde_json::Value) -> serde_json::Value {
220    let mut obj = match payload {
221        serde_json::Value::Object(map) => {
222            let mut out = map.clone();
223            out.remove("command");
224            out
225        }
226        serde_json::Value::Null => serde_json::Map::new(),
227        other => {
228            let mut out = serde_json::Map::new();
229            out.insert("payload".to_string(), other.clone());
230            out
231        }
232    };
233
234    obj.insert(
235        "cmd".to_string(),
236        serde_json::Value::String(cmd.to_string()),
237    );
238    serde_json::Value::Object(obj)
239}
240
241fn canonical_op_params(op: &str, payload: &serde_json::Value) -> serde_json::Value {
242    // Fast path: null payload (common for get_state, get_name, etc.) — build
243    // result directly without creating an intermediate Map.
244    if payload.is_null() {
245        return serde_json::json!({ "op": op });
246    }
247
248    let mut obj = match payload {
249        serde_json::Value::Object(map) => map.clone(),
250        other => {
251            let mut out = serde_json::Map::new();
252            // Reserved key for non-object args to avoid dropping semantics.
253            out.insert("payload".to_string(), other.clone());
254            out
255        }
256    };
257
258    // Explicit op from hostcall kind always wins.
259    obj.insert("op".to_string(), serde_json::Value::String(op.to_string()));
260    serde_json::Value::Object(obj)
261}
262
263fn builtin_tool_required_capability(name: &str) -> &'static str {
264    let name = name.trim();
265    if name.eq_ignore_ascii_case("read")
266        || name.eq_ignore_ascii_case("grep")
267        || name.eq_ignore_ascii_case("find")
268        || name.eq_ignore_ascii_case("ls")
269    {
270        "read"
271    } else if name.eq_ignore_ascii_case("write") || name.eq_ignore_ascii_case("edit") {
272        "write"
273    } else if name.eq_ignore_ascii_case("bash") {
274        "exec"
275    } else {
276        "tool"
277    }
278}
279
280impl HostcallRequest {
281    #[must_use]
282    pub const fn method(&self) -> &'static str {
283        match self.kind {
284            HostcallKind::Tool { .. } => "tool",
285            HostcallKind::Exec { .. } => "exec",
286            HostcallKind::Http => "http",
287            HostcallKind::Session { .. } => "session",
288            HostcallKind::Ui { .. } => "ui",
289            HostcallKind::Events { .. } => "events",
290            HostcallKind::Log => "log",
291        }
292    }
293
294    #[must_use]
295    pub fn required_capability(&self) -> &'static str {
296        match &self.kind {
297            HostcallKind::Tool { name } => builtin_tool_required_capability(name),
298            HostcallKind::Exec { .. } => "exec",
299            HostcallKind::Http => "http",
300            HostcallKind::Session { .. } => "session",
301            HostcallKind::Ui { .. } => "ui",
302            HostcallKind::Events { .. } => "events",
303            HostcallKind::Log => "log",
304        }
305    }
306
307    #[must_use]
308    pub fn io_uring_capability_class(&self) -> HostcallCapabilityClass {
309        HostcallCapabilityClass::from_capability(self.required_capability())
310    }
311
312    #[must_use]
313    pub fn io_uring_io_hint(&self) -> HostcallIoHint {
314        match &self.kind {
315            HostcallKind::Http => HostcallIoHint::IoHeavy,
316            HostcallKind::Exec { .. } => HostcallIoHint::CpuBound,
317            HostcallKind::Tool { name } => {
318                let name = name.trim();
319                if name.eq_ignore_ascii_case("read")
320                    || name.eq_ignore_ascii_case("write")
321                    || name.eq_ignore_ascii_case("edit")
322                    || name.eq_ignore_ascii_case("grep")
323                    || name.eq_ignore_ascii_case("find")
324                    || name.eq_ignore_ascii_case("ls")
325                {
326                    HostcallIoHint::IoHeavy
327                } else if name.eq_ignore_ascii_case("bash") {
328                    HostcallIoHint::CpuBound
329                } else {
330                    HostcallIoHint::Unknown
331                }
332            }
333            HostcallKind::Session { .. }
334            | HostcallKind::Ui { .. }
335            | HostcallKind::Events { .. }
336            | HostcallKind::Log => HostcallIoHint::Unknown,
337        }
338    }
339
340    #[must_use]
341    pub fn io_uring_lane_input(
342        &self,
343        queue_depth: usize,
344        force_compat_lane: bool,
345    ) -> IoUringLaneDecisionInput {
346        IoUringLaneDecisionInput {
347            capability: self.io_uring_capability_class(),
348            io_hint: self.io_uring_io_hint(),
349            queue_depth,
350            force_compat_lane,
351        }
352    }
353
354    /// Build the canonical params shape for hashing.
355    ///
356    /// **Canonical shapes** (must match `hostcall_request_to_payload()` in `extensions.rs`):
357    /// - `tool`:  `{ "name": <tool_name>, "input": <payload> }`
358    /// - `exec`:  `{ "cmd": <string>, ...payload_fields }`
359    /// - `http`:  payload passthrough
360    /// - `session/ui/events`:  `{ "op": <string>, ...payload_fields }` (flattened)
361    ///
362    /// For non-object args to `session/ui/events`, payload is preserved under
363    /// a reserved `"payload"` key (e.g. `{ "op": "set_status", "payload": "ready" }`).
364    #[must_use]
365    pub fn params_for_hash(&self) -> serde_json::Value {
366        match &self.kind {
367            HostcallKind::Tool { name } => {
368                serde_json::json!({ "name": name, "input": self.payload.clone() })
369            }
370            HostcallKind::Exec { cmd } => canonical_exec_params(cmd, &self.payload),
371            HostcallKind::Http | HostcallKind::Log => self.payload.clone(),
372            HostcallKind::Session { op }
373            | HostcallKind::Ui { op }
374            | HostcallKind::Events { op } => canonical_op_params(op, &self.payload),
375        }
376    }
377
378    #[must_use]
379    pub fn params_hash(&self) -> String {
380        hostcall_params_hash(self.method(), &self.params_for_hash())
381    }
382}
383
384const MAX_JSON_DEPTH: usize = 64;
385const MAX_JOBS_PER_TICK: usize = 10_000;
386
387/// Convert a serde_json::Value to a rquickjs Value.
388#[allow(clippy::option_if_let_else)]
389pub(crate) fn json_to_js<'js>(
390    ctx: &Ctx<'js>,
391    value: &serde_json::Value,
392) -> rquickjs::Result<Value<'js>> {
393    json_to_js_inner(ctx, value, 0)
394}
395
396fn json_to_js_inner<'js>(
397    ctx: &Ctx<'js>,
398    value: &serde_json::Value,
399    depth: usize,
400) -> rquickjs::Result<Value<'js>> {
401    if depth > MAX_JSON_DEPTH {
402        return Err(rquickjs::Error::new_into_js_message(
403            "json",
404            "parse",
405            "JSON object too deep",
406        ));
407    }
408
409    match value {
410        serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
411        serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
412        serde_json::Value::Number(n) => n.as_i64().and_then(|i| i32::try_from(i).ok()).map_or_else(
413            || {
414                n.as_f64().map_or_else(
415                    || Ok(Value::new_null(ctx.clone())),
416                    |f| Ok(Value::new_float(ctx.clone(), f)),
417                )
418            },
419            |i| Ok(Value::new_int(ctx.clone(), i)),
420        ),
421        // Gap D4: avoid cloning the String — pass &str directly to QuickJS.
422        serde_json::Value::String(s) => s.as_str().into_js(ctx),
423        serde_json::Value::Array(arr) => {
424            let js_arr = rquickjs::Array::new(ctx.clone())?;
425            for (i, v) in arr.iter().enumerate() {
426                let js_v = json_to_js_inner(ctx, v, depth + 1)?;
427                js_arr.set(i, js_v)?;
428            }
429            Ok(js_arr.into_value())
430        }
431        serde_json::Value::Object(obj) => {
432            let js_obj = Object::new(ctx.clone())?;
433            for (k, v) in obj {
434                let js_v = json_to_js_inner(ctx, v, depth + 1)?;
435                js_obj.set(k.as_str(), js_v)?;
436            }
437            Ok(js_obj.into_value())
438        }
439    }
440}
441
442/// Convert a rquickjs Value to a serde_json::Value.
443pub(crate) fn js_to_json(value: &Value<'_>) -> rquickjs::Result<serde_json::Value> {
444    js_to_json_inner(value, 0)
445}
446
447fn js_to_json_inner(value: &Value<'_>, depth: usize) -> rquickjs::Result<serde_json::Value> {
448    if depth > MAX_JSON_DEPTH {
449        return Err(rquickjs::Error::new_into_js_message(
450            "json",
451            "stringify",
452            "Object too deep or contains cycles",
453        ));
454    }
455
456    if value.is_null() || value.is_undefined() {
457        return Ok(serde_json::Value::Null);
458    }
459    if let Some(b) = value.as_bool() {
460        return Ok(serde_json::Value::Bool(b));
461    }
462    if let Some(i) = value.as_int() {
463        return Ok(serde_json::json!(i));
464    }
465    if let Some(f) = value.as_float() {
466        return Ok(serde_json::json!(f));
467    }
468    if let Some(s) = value.as_string() {
469        let s = s.to_string()?;
470        return Ok(serde_json::Value::String(s));
471    }
472    if let Some(arr) = value.as_array() {
473        let len = arr.len();
474        if len > 100_000 {
475            return Err(rquickjs::Error::new_into_js_message(
476                "json",
477                "stringify",
478                format!("Array length ({len}) exceeds maximum allowed limit of 100,000"),
479            ));
480        }
481        let mut result = Vec::with_capacity(std::cmp::min(len, 1024));
482        for i in 0..len {
483            let v: Value<'_> = arr.get(i)?;
484            result.push(js_to_json_inner(&v, depth + 1)?);
485        }
486        return Ok(serde_json::Value::Array(result));
487    }
488    if let Some(obj) = value.as_object() {
489        let mut result = serde_json::Map::new();
490        for (count, item) in obj.props::<String, Value<'_>>().enumerate() {
491            if count >= 100_000 {
492                return Err(rquickjs::Error::new_into_js_message(
493                    "json",
494                    "stringify",
495                    "Object properties count exceeds maximum allowed limit of 100,000",
496                ));
497            }
498            let (k, v) = item?;
499            if v.is_undefined() || v.is_function() || v.is_symbol() {
500                continue;
501            }
502            result.insert(k, js_to_json_inner(&v, depth + 1)?);
503        }
504        return Ok(serde_json::Value::Object(result));
505    }
506    // Fallback for functions, symbols, etc.
507    Ok(serde_json::Value::Null)
508}
509
510pub type HostcallQueue = Rc<RefCell<HostcallRequestQueue<HostcallRequest>>>;
511
512// ============================================================================
513// Deterministic PiJS Event Loop Scheduler (bd-8mm)
514// ============================================================================
515
516pub trait Clock: Send + Sync {
517    fn now_ms(&self) -> u64;
518}
519
520#[derive(Clone)]
521pub struct ClockHandle(Arc<dyn Clock>);
522
523impl ClockHandle {
524    pub fn new(clock: Arc<dyn Clock>) -> Self {
525        Self(clock)
526    }
527}
528
529impl Clock for ClockHandle {
530    fn now_ms(&self) -> u64 {
531        self.0.now_ms()
532    }
533}
534
535pub struct SystemClock;
536
537impl Clock for SystemClock {
538    fn now_ms(&self) -> u64 {
539        let now = SystemTime::now()
540            .duration_since(UNIX_EPOCH)
541            .unwrap_or_default();
542        u64::try_from(now.as_millis()).unwrap_or(u64::MAX)
543    }
544}
545
546#[derive(Debug)]
547pub struct ManualClock {
548    now_ms: AtomicU64,
549}
550
551impl ManualClock {
552    pub const fn new(start_ms: u64) -> Self {
553        Self {
554            now_ms: AtomicU64::new(start_ms),
555        }
556    }
557
558    pub fn set(&self, ms: u64) {
559        self.now_ms.store(ms, AtomicOrdering::SeqCst);
560    }
561
562    pub fn advance(&self, delta_ms: u64) {
563        self.now_ms.fetch_add(delta_ms, AtomicOrdering::SeqCst);
564    }
565}
566
567impl Clock for ManualClock {
568    fn now_ms(&self) -> u64 {
569        self.now_ms.load(AtomicOrdering::SeqCst)
570    }
571}
572
573#[derive(Debug, Clone, PartialEq, Eq)]
574pub enum MacrotaskKind {
575    TimerFired { timer_id: u64 },
576    HostcallComplete { call_id: String },
577    InboundEvent { event_id: String },
578}
579
580#[derive(Debug, Clone, PartialEq, Eq)]
581pub struct Macrotask {
582    pub seq: u64,
583    pub trace_id: u64,
584    pub kind: MacrotaskKind,
585}
586
587#[derive(Debug, Clone, PartialEq, Eq)]
588struct MacrotaskEntry {
589    seq: u64,
590    trace_id: u64,
591    kind: MacrotaskKind,
592}
593
594impl Ord for MacrotaskEntry {
595    fn cmp(&self, other: &Self) -> Ordering {
596        self.seq.cmp(&other.seq)
597    }
598}
599
600impl PartialOrd for MacrotaskEntry {
601    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
602        Some(self.cmp(other))
603    }
604}
605
606#[derive(Debug, Clone, PartialEq, Eq)]
607struct TimerEntry {
608    deadline_ms: u64,
609    order_seq: u64,
610    timer_id: u64,
611    trace_id: u64,
612}
613
614impl Ord for TimerEntry {
615    fn cmp(&self, other: &Self) -> Ordering {
616        (self.deadline_ms, self.order_seq, self.timer_id).cmp(&(
617            other.deadline_ms,
618            other.order_seq,
619            other.timer_id,
620        ))
621    }
622}
623
624impl PartialOrd for TimerEntry {
625    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
626        Some(self.cmp(other))
627    }
628}
629
630#[derive(Debug, Clone, PartialEq, Eq)]
631struct PendingMacrotask {
632    trace_id: u64,
633    kind: MacrotaskKind,
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637pub struct TickResult {
638    pub ran_macrotask: bool,
639    pub microtasks_drained: usize,
640}
641
642pub struct PiEventLoop {
643    clock: ClockHandle,
644    seq: u64,
645    next_timer_id: u64,
646    pending: VecDeque<PendingMacrotask>,
647    macro_queue: BinaryHeap<std::cmp::Reverse<MacrotaskEntry>>,
648    timers: BinaryHeap<std::cmp::Reverse<TimerEntry>>,
649    cancelled_timers: HashSet<u64>,
650}
651
652impl PiEventLoop {
653    pub fn new(clock: ClockHandle) -> Self {
654        Self {
655            clock,
656            seq: 0,
657            next_timer_id: 1,
658            pending: VecDeque::new(),
659            macro_queue: BinaryHeap::new(),
660            timers: BinaryHeap::new(),
661            cancelled_timers: HashSet::new(),
662        }
663    }
664
665    pub fn enqueue_hostcall_completion(&mut self, call_id: impl Into<String>) {
666        let trace_id = self.next_seq();
667        self.pending.push_back(PendingMacrotask {
668            trace_id,
669            kind: MacrotaskKind::HostcallComplete {
670                call_id: call_id.into(),
671            },
672        });
673    }
674
675    pub fn enqueue_inbound_event(&mut self, event_id: impl Into<String>) {
676        let trace_id = self.next_seq();
677        self.pending.push_back(PendingMacrotask {
678            trace_id,
679            kind: MacrotaskKind::InboundEvent {
680                event_id: event_id.into(),
681            },
682        });
683    }
684
685    pub fn set_timeout(&mut self, delay_ms: u64) -> u64 {
686        let timer_id = self.next_timer_id;
687        self.next_timer_id = self.next_timer_id.saturating_add(1);
688        let order_seq = self.next_seq();
689        let deadline_ms = self.clock.now_ms().saturating_add(delay_ms);
690        self.timers.push(std::cmp::Reverse(TimerEntry {
691            deadline_ms,
692            order_seq,
693            timer_id,
694            trace_id: order_seq,
695        }));
696        timer_id
697    }
698
699    pub fn clear_timeout(&mut self, timer_id: u64) -> bool {
700        let pending = self.timers.iter().any(|entry| entry.0.timer_id == timer_id)
701            && !self.cancelled_timers.contains(&timer_id);
702
703        if pending {
704            self.cancelled_timers.insert(timer_id)
705        } else {
706            false
707        }
708    }
709
710    pub fn tick(
711        &mut self,
712        mut on_macrotask: impl FnMut(Macrotask),
713        mut drain_microtasks: impl FnMut() -> bool,
714    ) -> TickResult {
715        self.ingest_pending();
716        self.enqueue_due_timers();
717
718        let mut ran_macrotask = false;
719        if let Some(task) = self.pop_next_macrotask() {
720            ran_macrotask = true;
721            on_macrotask(task);
722        }
723
724        let mut microtasks_drained = 0;
725        if ran_macrotask {
726            while drain_microtasks() {
727                microtasks_drained += 1;
728            }
729        }
730
731        TickResult {
732            ran_macrotask,
733            microtasks_drained,
734        }
735    }
736
737    fn ingest_pending(&mut self) {
738        while let Some(pending) = self.pending.pop_front() {
739            self.enqueue_macrotask(pending.trace_id, pending.kind);
740        }
741    }
742
743    fn enqueue_due_timers(&mut self) {
744        let now = self.clock.now_ms();
745        while let Some(std::cmp::Reverse(entry)) = self.timers.peek().cloned() {
746            if entry.deadline_ms > now {
747                break;
748            }
749            let _ = self.timers.pop();
750            if self.cancelled_timers.remove(&entry.timer_id) {
751                continue;
752            }
753            self.enqueue_macrotask(
754                entry.trace_id,
755                MacrotaskKind::TimerFired {
756                    timer_id: entry.timer_id,
757                },
758            );
759        }
760    }
761
762    fn enqueue_macrotask(&mut self, trace_id: u64, kind: MacrotaskKind) {
763        let seq = self.next_seq();
764        self.macro_queue.push(std::cmp::Reverse(MacrotaskEntry {
765            seq,
766            trace_id,
767            kind,
768        }));
769    }
770
771    fn pop_next_macrotask(&mut self) -> Option<Macrotask> {
772        self.macro_queue.pop().map(|entry| {
773            let entry = entry.0;
774            Macrotask {
775                seq: entry.seq,
776                trace_id: entry.trace_id,
777                kind: entry.kind,
778            }
779        })
780    }
781
782    const fn next_seq(&mut self) -> u64 {
783        let current = self.seq;
784        self.seq = self.seq.saturating_add(1);
785        current
786    }
787}
788
789fn map_js_error(err: &rquickjs::Error) -> Error {
790    Error::extension(format!("QuickJS: {err:?}"))
791}
792
793fn format_quickjs_exception<'js>(ctx: &Ctx<'js>, caught: Value<'js>) -> String {
794    if let Ok(obj) = caught.clone().try_into_object() {
795        if let Some(exception) = Exception::from_object(obj) {
796            if let Some(message) = exception.message() {
797                if let Some(stack) = exception.stack() {
798                    return format!("{message}\n{stack}");
799                }
800                return message;
801            }
802            if let Some(stack) = exception.stack() {
803                return stack;
804            }
805        }
806    }
807
808    match Coerced::<String>::from_js(ctx, caught) {
809        Ok(value) => value.0,
810        Err(err) => format!("(failed to stringify QuickJS exception: {err})"),
811    }
812}
813
814// ============================================================================
815// Integrated PiJS Runtime with Promise Bridge (bd-2ke)
816// ============================================================================
817
818/// Classification of auto-repair patterns applied at extension load time.
819#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
820pub enum RepairPattern {
821    /// Pattern 1: `./dist/X.js` resolved to `./src/X.ts` because the build
822    /// output directory was missing.
823    DistToSrc,
824    /// Pattern 2: `readFileSync` on a missing bundled asset (HTML/CSS/JS)
825    /// within the extension directory returned an empty string fallback.
826    MissingAsset,
827    /// Pattern 3: a monorepo sibling import (`../../shared`) was replaced
828    /// with a generated stub module.
829    MonorepoEscape,
830    /// Pattern 4: a bare npm package specifier was satisfied by a proxy-based
831    /// universal stub.
832    MissingNpmDep,
833    /// Pattern 5: CJS/ESM default-export mismatch was corrected by trying
834    /// alternative lifecycle method names.
835    ExportShape,
836    /// Pattern 6 (bd-k5q5.9.3.2): Extension manifest field normalization
837    /// (deprecated keys, schema version migration).
838    ManifestNormalization,
839    /// Pattern 7 (bd-k5q5.9.3.3): AST-based codemod for known API renames
840    /// or signature migrations.
841    ApiMigration,
842}
843
844/// Risk tier for repair patterns (bd-k5q5.9.1.4).
845///
846/// `Safe` patterns only remap file paths within the extension root and cannot
847/// alter runtime behaviour.  `Aggressive` patterns may introduce stub modules,
848/// proxy objects, or change export shapes, potentially altering the extension's
849/// observable behaviour.
850#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
851pub enum RepairRisk {
852    /// Path-only remapping; no new code introduced.
853    Safe,
854    /// May inject stub modules or change export wiring.
855    Aggressive,
856}
857
858impl RepairPattern {
859    /// The risk tier of this pattern.
860    pub const fn risk(self) -> RepairRisk {
861        match self {
862            // Patterns 1, 2, 6: path remaps, empty strings, manifest JSON.
863            Self::DistToSrc | Self::MissingAsset | Self::ManifestNormalization => RepairRisk::Safe,
864            // Patterns 3-5 and 7: inject stubs, rewrite exports, or modify AST.
865            Self::MonorepoEscape | Self::MissingNpmDep | Self::ExportShape | Self::ApiMigration => {
866                RepairRisk::Aggressive
867            }
868        }
869    }
870
871    /// Whether this pattern is allowed under the given `RepairMode`.
872    pub const fn is_allowed_by(self, mode: RepairMode) -> bool {
873        match self.risk() {
874            RepairRisk::Safe => mode.should_apply(),
875            RepairRisk::Aggressive => mode.allows_aggressive(),
876        }
877    }
878}
879
880impl std::fmt::Display for RepairPattern {
881    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
882        match self {
883            Self::DistToSrc => write!(f, "dist_to_src"),
884            Self::MissingAsset => write!(f, "missing_asset"),
885            Self::MonorepoEscape => write!(f, "monorepo_escape"),
886            Self::MissingNpmDep => write!(f, "missing_npm_dep"),
887            Self::ExportShape => write!(f, "export_shape"),
888            Self::ManifestNormalization => write!(f, "manifest_normalization"),
889            Self::ApiMigration => write!(f, "api_migration"),
890        }
891    }
892}
893
894/// A structured record emitted whenever the runtime auto-repairs an extension
895/// load failure.
896#[derive(Debug, Clone)]
897pub struct ExtensionRepairEvent {
898    /// Which extension triggered the repair.
899    pub extension_id: String,
900    /// Which pattern was applied.
901    pub pattern: RepairPattern,
902    /// The original error message that triggered the repair attempt.
903    pub original_error: String,
904    /// Human-readable description of the corrective action taken.
905    pub repair_action: String,
906    /// Whether the repair successfully resolved the load failure.
907    pub success: bool,
908    /// Wall-clock timestamp (ms since UNIX epoch).
909    pub timestamp_ms: u64,
910}
911
912// ---------------------------------------------------------------------------
913// Deterministic rule registry (bd-k5q5.9.3.1)
914// ---------------------------------------------------------------------------
915
916/// A static repair rule with deterministic ordering and versioning.
917#[derive(Debug, Clone)]
918pub struct RepairRule {
919    /// Unique identifier for the rule (e.g. `"dist_to_src_v1"`).
920    pub id: &'static str,
921    /// Semantic version of this rule's logic.
922    pub version: &'static str,
923    /// Which `RepairPattern` this rule implements.
924    pub pattern: RepairPattern,
925    /// Human-readable description of what the rule does.
926    pub description: &'static str,
927}
928
929impl RepairRule {
930    /// Risk tier inherited from the pattern.
931    pub const fn risk(&self) -> RepairRisk {
932        self.pattern.risk()
933    }
934
935    /// Whether this rule can fire under the given mode.
936    pub const fn is_allowed_by(&self, mode: RepairMode) -> bool {
937        self.pattern.is_allowed_by(mode)
938    }
939}
940
941/// The canonical, deterministic rule registry.
942///
943/// Rules are evaluated **in array order** — the first applicable rule wins.
944/// New rules MUST be appended; existing order must never change to preserve
945/// determinism across versions.
946pub static REPAIR_RULES: &[RepairRule] = &[
947    RepairRule {
948        id: "dist_to_src_v1",
949        pattern: RepairPattern::DistToSrc,
950        version: "1.0.0",
951        description: "Remap ./dist/X.js to ./src/X.ts when build output is missing",
952    },
953    RepairRule {
954        id: "missing_asset_v1",
955        pattern: RepairPattern::MissingAsset,
956        version: "1.0.0",
957        description: "Return empty string for missing bundled asset reads",
958    },
959    RepairRule {
960        id: "monorepo_escape_v1",
961        pattern: RepairPattern::MonorepoEscape,
962        version: "1.0.0",
963        description: "Stub monorepo sibling imports (../../shared) with empty module",
964    },
965    RepairRule {
966        id: "missing_npm_dep_v1",
967        pattern: RepairPattern::MissingNpmDep,
968        version: "1.0.0",
969        description: "Provide proxy-based stub for unresolvable npm bare specifiers",
970    },
971    RepairRule {
972        id: "export_shape_v1",
973        pattern: RepairPattern::ExportShape,
974        version: "1.0.0",
975        description: "Try alternative lifecycle exports (CJS default, named activate)",
976    },
977    // ── Manifest normalization rules (bd-k5q5.9.3.2) ──
978    RepairRule {
979        id: "manifest_schema_v1",
980        pattern: RepairPattern::ManifestNormalization,
981        version: "1.0.0",
982        description: "Migrate deprecated manifest fields to current schema",
983    },
984    // ── AST codemod rules (bd-k5q5.9.3.3) ──
985    RepairRule {
986        id: "api_migration_v1",
987        pattern: RepairPattern::ApiMigration,
988        version: "1.0.0",
989        description: "Rewrite known deprecated API calls to current equivalents",
990    },
991];
992
993/// Find all rules applicable under the given mode, in registry order.
994pub fn applicable_rules(mode: RepairMode) -> Vec<&'static RepairRule> {
995    REPAIR_RULES
996        .iter()
997        .filter(|rule| rule.is_allowed_by(mode))
998        .collect()
999}
1000
1001/// Look up a rule by its ID.
1002pub fn rule_by_id(id: &str) -> Option<&'static RepairRule> {
1003    REPAIR_RULES.iter().find(|r| r.id == id)
1004}
1005
1006/// The registry version: bumped whenever rules are added or modified.
1007pub const REPAIR_REGISTRY_VERSION: &str = "1.1.0";
1008
1009// ---------------------------------------------------------------------------
1010// Model patch primitive whitelist (bd-k5q5.9.4.1)
1011// ---------------------------------------------------------------------------
1012
1013/// Primitive patch operations that model-generated repair proposals may use.
1014///
1015/// Each variant represents a constrained, validatable operation. The union of
1016/// all variants defines the complete vocabulary available to the model repair
1017/// adapter — anything outside this enum is rejected at the schema level.
1018#[derive(Debug, Clone, PartialEq, Eq)]
1019pub enum PatchOp {
1020    /// Replace a module import path with a different path.
1021    /// Both paths must resolve within the extension root.
1022    ReplaceModulePath { from: String, to: String },
1023    /// Add a named export to a module's source text.
1024    AddExport {
1025        module_path: String,
1026        export_name: String,
1027        export_value: String,
1028    },
1029    /// Remove an import statement by specifier.
1030    RemoveImport {
1031        module_path: String,
1032        specifier: String,
1033    },
1034    /// Inject a stub module at the given virtual path.
1035    InjectStub {
1036        virtual_path: String,
1037        source: String,
1038    },
1039    /// Rewrite a `require()` call to use a different specifier.
1040    RewriteRequire {
1041        module_path: String,
1042        from_specifier: String,
1043        to_specifier: String,
1044    },
1045}
1046
1047impl PatchOp {
1048    /// The risk tier of this operation.
1049    pub const fn risk(&self) -> RepairRisk {
1050        match self {
1051            // Path remapping and require rewriting are safe (no new code).
1052            Self::ReplaceModulePath { .. } | Self::RewriteRequire { .. } => RepairRisk::Safe,
1053            // Adding exports, removing imports, or injecting stubs change code.
1054            Self::AddExport { .. } | Self::RemoveImport { .. } | Self::InjectStub { .. } => {
1055                RepairRisk::Aggressive
1056            }
1057        }
1058    }
1059
1060    /// Short tag for logging and telemetry.
1061    pub const fn tag(&self) -> &'static str {
1062        match self {
1063            Self::ReplaceModulePath { .. } => "replace_module_path",
1064            Self::AddExport { .. } => "add_export",
1065            Self::RemoveImport { .. } => "remove_import",
1066            Self::InjectStub { .. } => "inject_stub",
1067            Self::RewriteRequire { .. } => "rewrite_require",
1068        }
1069    }
1070}
1071
1072/// A model-generated repair proposal.
1073///
1074/// Contains one or more `PatchOp`s plus metadata for audit. Proposals are
1075/// validated against the current `RepairMode` and monotonicity checker before
1076/// any operations are applied.
1077#[derive(Debug, Clone)]
1078pub struct PatchProposal {
1079    /// Which rule triggered this proposal.
1080    pub rule_id: String,
1081    /// Ordered list of operations to apply.
1082    pub ops: Vec<PatchOp>,
1083    /// Model-provided rationale (for audit log).
1084    pub rationale: String,
1085    /// Confidence score (0.0–1.0) from the model, if available.
1086    pub confidence: Option<f64>,
1087}
1088
1089impl PatchProposal {
1090    /// The highest risk across all ops in the proposal.
1091    pub fn max_risk(&self) -> RepairRisk {
1092        if self
1093            .ops
1094            .iter()
1095            .any(|op| op.risk() == RepairRisk::Aggressive)
1096        {
1097            RepairRisk::Aggressive
1098        } else {
1099            RepairRisk::Safe
1100        }
1101    }
1102
1103    /// Whether this proposal is allowed under the given mode.
1104    pub fn is_allowed_by(&self, mode: RepairMode) -> bool {
1105        match self.max_risk() {
1106            RepairRisk::Safe => mode.should_apply(),
1107            RepairRisk::Aggressive => mode.allows_aggressive(),
1108        }
1109    }
1110
1111    /// Number of patch operations in this proposal.
1112    pub fn op_count(&self) -> usize {
1113        self.ops.len()
1114    }
1115}
1116
1117// ---------------------------------------------------------------------------
1118// Minimal-diff candidate selector and conflict resolver (bd-k5q5.9.3.4)
1119// ---------------------------------------------------------------------------
1120
1121/// Outcome of conflict detection between two proposals.
1122#[derive(Debug, Clone, PartialEq, Eq)]
1123pub enum ConflictKind {
1124    /// No conflict: the proposals touch different files/paths.
1125    None,
1126    /// Both proposals modify the same module path.
1127    SameModulePath(String),
1128    /// Both proposals inject stubs at the same virtual path.
1129    SameVirtualPath(String),
1130}
1131
1132impl ConflictKind {
1133    /// True if there is no conflict.
1134    pub const fn is_clear(&self) -> bool {
1135        matches!(self, Self::None)
1136    }
1137}
1138
1139/// Detect conflicts between two `PatchProposal`s.
1140///
1141/// Two proposals conflict if they modify the same module path or inject
1142/// stubs at the same virtual path. This is a conservative check — any
1143/// overlap is treated as a conflict.
1144pub fn detect_conflict(a: &PatchProposal, b: &PatchProposal) -> ConflictKind {
1145    for op_a in &a.ops {
1146        for op_b in &b.ops {
1147            if let Some(conflict) = ops_conflict(op_a, op_b) {
1148                return conflict;
1149            }
1150        }
1151    }
1152    ConflictKind::None
1153}
1154
1155/// Check if two individual ops conflict.
1156fn ops_conflict(a: &PatchOp, b: &PatchOp) -> Option<ConflictKind> {
1157    match (a, b) {
1158        (
1159            PatchOp::ReplaceModulePath { from: fa, .. },
1160            PatchOp::ReplaceModulePath { from: fb, .. },
1161        ) if fa == fb => Some(ConflictKind::SameModulePath(fa.clone())),
1162
1163        (
1164            PatchOp::AddExport {
1165                module_path: pa, ..
1166            },
1167            PatchOp::AddExport {
1168                module_path: pb, ..
1169            },
1170        ) if pa == pb => Some(ConflictKind::SameModulePath(pa.clone())),
1171
1172        (
1173            PatchOp::RemoveImport {
1174                module_path: pa, ..
1175            },
1176            PatchOp::RemoveImport {
1177                module_path: pb, ..
1178            },
1179        ) if pa == pb => Some(ConflictKind::SameModulePath(pa.clone())),
1180
1181        (
1182            PatchOp::InjectStub {
1183                virtual_path: va, ..
1184            },
1185            PatchOp::InjectStub {
1186                virtual_path: vb, ..
1187            },
1188        ) if va == vb => Some(ConflictKind::SameVirtualPath(va.clone())),
1189
1190        (
1191            PatchOp::RewriteRequire {
1192                module_path: pa,
1193                from_specifier: sa,
1194                ..
1195            },
1196            PatchOp::RewriteRequire {
1197                module_path: pb,
1198                from_specifier: sb,
1199                ..
1200            },
1201        ) if pa == pb && sa == sb => Some(ConflictKind::SameModulePath(pa.clone())),
1202
1203        _ => Option::None,
1204    }
1205}
1206
1207/// Select the best candidate from a set of proposals.
1208///
1209/// Candidates are ranked by:
1210/// 1. Lowest risk (Safe before Aggressive)
1211/// 2. Fewest operations (minimal diff)
1212/// 3. Highest confidence (if provided)
1213/// 4. Earliest rule ID (deterministic tiebreak)
1214///
1215/// Only candidates allowed by the given `RepairMode` are considered.
1216/// Returns `None` if no candidate is allowed.
1217pub fn select_best_candidate(
1218    candidates: &[PatchProposal],
1219    mode: RepairMode,
1220) -> Option<&PatchProposal> {
1221    candidates
1222        .iter()
1223        .filter(|p| p.is_allowed_by(mode))
1224        .min_by(|a, b| compare_proposals(a, b))
1225}
1226
1227/// Compare two proposals for selection ordering.
1228fn compare_proposals(a: &PatchProposal, b: &PatchProposal) -> std::cmp::Ordering {
1229    // 1. Lower risk wins.
1230    let risk_ord = risk_rank(a.max_risk()).cmp(&risk_rank(b.max_risk()));
1231    if risk_ord != std::cmp::Ordering::Equal {
1232        return risk_ord;
1233    }
1234
1235    // 2. Fewer ops wins.
1236    let ops_ord = a.op_count().cmp(&b.op_count());
1237    if ops_ord != std::cmp::Ordering::Equal {
1238        return ops_ord;
1239    }
1240
1241    // 3. Higher confidence wins (reverse order).
1242    let conf_a = a.confidence.unwrap_or(0.0);
1243    let conf_b = b.confidence.unwrap_or(0.0);
1244    // Reverse: higher confidence = better = Less in ordering.
1245    let conf_ord = conf_b
1246        .partial_cmp(&conf_a)
1247        .unwrap_or(std::cmp::Ordering::Equal);
1248    if conf_ord != std::cmp::Ordering::Equal {
1249        return conf_ord;
1250    }
1251
1252    // 4. Lexicographic rule_id tiebreak.
1253    a.rule_id.cmp(&b.rule_id)
1254}
1255
1256/// Map `RepairRisk` to a numeric rank for ordering.
1257const fn risk_rank(risk: RepairRisk) -> u8 {
1258    match risk {
1259        RepairRisk::Safe => 0,
1260        RepairRisk::Aggressive => 1,
1261    }
1262}
1263
1264/// Resolve conflicts among a set of proposals.
1265///
1266/// When two proposals conflict, the lower-ranked one (by
1267/// `compare_proposals`) is dropped. Returns a conflict-free subset.
1268pub fn resolve_conflicts(proposals: &[PatchProposal]) -> Vec<&PatchProposal> {
1269    if proposals.is_empty() {
1270        return vec![];
1271    }
1272
1273    // Sort by selection order.
1274    let mut indexed: Vec<(usize, &PatchProposal)> = proposals.iter().enumerate().collect();
1275    indexed.sort_by(|(_, a), (_, b)| compare_proposals(a, b));
1276
1277    let mut accepted: Vec<&PatchProposal> = Vec::new();
1278    for (_, candidate) in indexed {
1279        let conflicts_with_accepted = accepted
1280            .iter()
1281            .any(|acc| !detect_conflict(acc, candidate).is_clear());
1282        if !conflicts_with_accepted {
1283            accepted.push(candidate);
1284        }
1285    }
1286
1287    accepted
1288}
1289
1290// ---------------------------------------------------------------------------
1291// Bounded-context model proposer adapter (bd-k5q5.9.4.2)
1292// ---------------------------------------------------------------------------
1293
1294/// Curated context provided to the model for repair proposal generation.
1295///
1296/// This struct is the *only* information the model sees. It deliberately
1297/// excludes secrets, full file contents, and anything outside the extension's
1298/// scope. The model can only produce proposals using the allowed primitives.
1299#[derive(Debug, Clone)]
1300pub struct RepairContext {
1301    /// Extension identity.
1302    pub extension_id: String,
1303    /// The gating verdict (includes confidence and reason codes).
1304    pub gating: GatingVerdict,
1305    /// Normalized intent graph.
1306    pub intent: IntentGraph,
1307    /// Tolerant parse result.
1308    pub parse: TolerantParseResult,
1309    /// Current repair mode.
1310    pub mode: RepairMode,
1311    /// Diagnostic messages from the failed load attempt.
1312    pub diagnostics: Vec<String>,
1313    /// Allowed `PatchOp` tags for this mode.
1314    pub allowed_op_tags: Vec<&'static str>,
1315}
1316
1317impl RepairContext {
1318    /// Build a repair context from constituent parts.
1319    pub fn new(
1320        extension_id: String,
1321        gating: GatingVerdict,
1322        intent: IntentGraph,
1323        parse: TolerantParseResult,
1324        mode: RepairMode,
1325        diagnostics: Vec<String>,
1326    ) -> Self {
1327        let allowed_op_tags = allowed_op_tags_for_mode(mode);
1328        Self {
1329            extension_id,
1330            gating,
1331            intent,
1332            parse,
1333            mode,
1334            diagnostics,
1335            allowed_op_tags,
1336        }
1337    }
1338}
1339
1340/// Return the `PatchOp` tags allowed under the given repair mode.
1341pub fn allowed_op_tags_for_mode(mode: RepairMode) -> Vec<&'static str> {
1342    let mut tags = Vec::new();
1343    if mode.should_apply() {
1344        // Safe ops always allowed when repairs are active.
1345        tags.extend_from_slice(&["replace_module_path", "rewrite_require"]);
1346    }
1347    if mode.allows_aggressive() {
1348        // Aggressive ops only in AutoStrict.
1349        tags.extend_from_slice(&["add_export", "remove_import", "inject_stub"]);
1350    }
1351    tags
1352}
1353
1354// ---------------------------------------------------------------------------
1355// Proposal validator and constrained applicator (bd-k5q5.9.4.3)
1356// ---------------------------------------------------------------------------
1357
1358/// Validation error for a model-generated proposal.
1359#[derive(Debug, Clone, PartialEq, Eq)]
1360pub enum ProposalValidationError {
1361    /// Proposal contains zero operations.
1362    EmptyProposal,
1363    /// An operation uses a tag not allowed by the current mode.
1364    DisallowedOp { tag: String },
1365    /// Risk level exceeds what the mode permits.
1366    RiskExceedsMode { risk: RepairRisk, mode: RepairMode },
1367    /// The `rule_id` does not match any known rule.
1368    UnknownRule { rule_id: String },
1369    /// Proposal references a path that escapes the extension root.
1370    MonotonicityViolation { path: String },
1371}
1372
1373impl std::fmt::Display for ProposalValidationError {
1374    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1375        match self {
1376            Self::EmptyProposal => write!(f, "proposal has no operations"),
1377            Self::DisallowedOp { tag } => write!(f, "op '{tag}' not allowed in current mode"),
1378            Self::RiskExceedsMode { risk, mode } => {
1379                write!(f, "{risk:?} risk not allowed in {mode:?} mode")
1380            }
1381            Self::UnknownRule { rule_id } => write!(f, "unknown rule: {rule_id}"),
1382            Self::MonotonicityViolation { path } => {
1383                write!(f, "path escapes extension root: {path}")
1384            }
1385        }
1386    }
1387}
1388
1389/// Validate a `PatchProposal` against policy constraints.
1390///
1391/// Checks:
1392/// 1. Proposal is non-empty.
1393/// 2. All ops are in the allowed tag set for the mode.
1394/// 3. Overall risk does not exceed mode permissions.
1395/// 4. The rule_id references a known rule (if non-empty).
1396/// 5. Module paths stay within the extension root (monotonicity).
1397pub fn validate_proposal(
1398    proposal: &PatchProposal,
1399    mode: RepairMode,
1400    extension_root: Option<&Path>,
1401) -> Vec<ProposalValidationError> {
1402    let mut errors = Vec::new();
1403
1404    // 1. Non-empty.
1405    if proposal.ops.is_empty() {
1406        errors.push(ProposalValidationError::EmptyProposal);
1407        return errors;
1408    }
1409
1410    // 2. Allowed ops.
1411    let allowed = allowed_op_tags_for_mode(mode);
1412    for op in &proposal.ops {
1413        if !allowed.contains(&op.tag()) {
1414            errors.push(ProposalValidationError::DisallowedOp {
1415                tag: op.tag().to_string(),
1416            });
1417        }
1418    }
1419
1420    // 3. Risk check.
1421    if !proposal.is_allowed_by(mode) {
1422        errors.push(ProposalValidationError::RiskExceedsMode {
1423            risk: proposal.max_risk(),
1424            mode,
1425        });
1426    }
1427
1428    // 4. Known rule.
1429    if !proposal.rule_id.is_empty() && rule_by_id(&proposal.rule_id).is_none() {
1430        errors.push(ProposalValidationError::UnknownRule {
1431            rule_id: proposal.rule_id.clone(),
1432        });
1433    }
1434
1435    // 5. Monotonicity for path-bearing ops.
1436    if let Some(root) = extension_root {
1437        for op in &proposal.ops {
1438            let mut paths_to_check = vec![op_target_path(op)];
1439            if let PatchOp::ReplaceModulePath { from, .. } = op {
1440                paths_to_check.push(from.clone());
1441            }
1442
1443            for path_str in paths_to_check {
1444                let target = Path::new(&path_str);
1445                let resolved = if target.is_absolute() {
1446                    target.to_path_buf()
1447                } else {
1448                    root.join(target)
1449                };
1450                let verdict = verify_repair_monotonicity(root, root, &resolved);
1451                if !verdict.is_safe() {
1452                    errors.push(ProposalValidationError::MonotonicityViolation { path: path_str });
1453                }
1454            }
1455        }
1456    }
1457
1458    errors
1459}
1460
1461/// Extract the target path from a `PatchOp`.
1462fn op_target_path(op: &PatchOp) -> String {
1463    match op {
1464        PatchOp::ReplaceModulePath { to, .. } => to.clone(),
1465        PatchOp::AddExport { module_path, .. }
1466        | PatchOp::RemoveImport { module_path, .. }
1467        | PatchOp::RewriteRequire { module_path, .. } => module_path.clone(),
1468        PatchOp::InjectStub { virtual_path, .. } => virtual_path.clone(),
1469    }
1470}
1471
1472/// Result of applying a validated proposal.
1473#[derive(Debug, Clone)]
1474pub struct ApplicationResult {
1475    /// Whether the application succeeded.
1476    pub success: bool,
1477    /// Number of operations applied.
1478    pub ops_applied: usize,
1479    /// Human-readable summary.
1480    pub summary: String,
1481}
1482
1483/// Apply a validated proposal (dry-run: only validates and reports).
1484///
1485/// In the current implementation, actual file modifications are deferred
1486/// to the module loader. This function validates and produces an audit
1487/// record of what would be applied.
1488pub fn apply_proposal(
1489    proposal: &PatchProposal,
1490    mode: RepairMode,
1491    extension_root: Option<&Path>,
1492) -> std::result::Result<ApplicationResult, Vec<ProposalValidationError>> {
1493    let errors = validate_proposal(proposal, mode, extension_root);
1494    if !errors.is_empty() {
1495        return Err(errors);
1496    }
1497
1498    Ok(ApplicationResult {
1499        success: true,
1500        ops_applied: proposal.ops.len(),
1501        summary: format!(
1502            "Applied {} op(s) from rule '{}'",
1503            proposal.ops.len(),
1504            proposal.rule_id
1505        ),
1506    })
1507}
1508
1509// ---------------------------------------------------------------------------
1510// Fail-closed human approval workflow (bd-k5q5.9.4.4)
1511// ---------------------------------------------------------------------------
1512
1513/// Whether a proposal requires human approval before application.
1514#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1515pub enum ApprovalRequirement {
1516    /// No approval needed — proposal can be applied automatically.
1517    AutoApproved,
1518    /// Human review required before applying.
1519    RequiresApproval,
1520}
1521
1522impl ApprovalRequirement {
1523    /// True if human review is required.
1524    pub const fn needs_approval(&self) -> bool {
1525        matches!(self, Self::RequiresApproval)
1526    }
1527}
1528
1529impl std::fmt::Display for ApprovalRequirement {
1530    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1531        match self {
1532            Self::AutoApproved => write!(f, "auto_approved"),
1533            Self::RequiresApproval => write!(f, "requires_approval"),
1534        }
1535    }
1536}
1537
1538/// An approval request presented to the human reviewer.
1539#[derive(Debug, Clone)]
1540pub struct ApprovalRequest {
1541    /// Extension being repaired.
1542    pub extension_id: String,
1543    /// The proposal awaiting approval.
1544    pub proposal: PatchProposal,
1545    /// Overall risk level.
1546    pub risk: RepairRisk,
1547    /// Confidence score from the scoring model.
1548    pub confidence_score: f64,
1549    /// Human-readable rationale from the proposal.
1550    pub rationale: String,
1551    /// Summary of what each operation does.
1552    pub op_summaries: Vec<String>,
1553}
1554
1555/// Human response to an approval request.
1556#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1557pub enum ApprovalResponse {
1558    /// Approved: apply the proposal.
1559    Approved,
1560    /// Rejected: discard the proposal.
1561    Rejected,
1562}
1563
1564/// Determine whether a proposal requires human approval.
1565///
1566/// The decision is fail-closed: any high-risk indicator triggers the
1567/// approval requirement. A proposal requires approval if:
1568/// - It contains any Aggressive-risk operations, OR
1569/// - The confidence score is below the repairable threshold (0.5), OR
1570/// - The mode is `AutoStrict` and the proposal touches 3+ operations.
1571pub fn check_approval_requirement(
1572    proposal: &PatchProposal,
1573    confidence_score: f64,
1574) -> ApprovalRequirement {
1575    if proposal.max_risk() == RepairRisk::Aggressive {
1576        return ApprovalRequirement::RequiresApproval;
1577    }
1578    if confidence_score < 0.5 {
1579        return ApprovalRequirement::RequiresApproval;
1580    }
1581    if proposal.ops.len() >= 3 {
1582        return ApprovalRequirement::RequiresApproval;
1583    }
1584    ApprovalRequirement::AutoApproved
1585}
1586
1587/// Build an approval request for human review.
1588pub fn build_approval_request(
1589    extension_id: &str,
1590    proposal: &PatchProposal,
1591    confidence_score: f64,
1592) -> ApprovalRequest {
1593    let op_summaries = proposal
1594        .ops
1595        .iter()
1596        .map(|op| format!("[{}] {}", op.tag(), op_target_path(op)))
1597        .collect();
1598
1599    ApprovalRequest {
1600        extension_id: extension_id.to_string(),
1601        proposal: proposal.clone(),
1602        risk: proposal.max_risk(),
1603        confidence_score,
1604        rationale: proposal.rationale.clone(),
1605        op_summaries,
1606    }
1607}
1608
1609// ---------------------------------------------------------------------------
1610// Structural validation gate (bd-k5q5.9.5.1)
1611// ---------------------------------------------------------------------------
1612
1613/// Outcome of a structural validation check on a repaired artifact.
1614#[derive(Debug, Clone, PartialEq, Eq)]
1615pub enum StructuralVerdict {
1616    /// The artifact passed all structural checks.
1617    Valid,
1618    /// The file could not be read.
1619    Unreadable { path: PathBuf, reason: String },
1620    /// The file has an unsupported extension.
1621    UnsupportedExtension { path: PathBuf, extension: String },
1622    /// The file failed to parse as valid JS/TS/JSON.
1623    ParseError { path: PathBuf, message: String },
1624}
1625
1626impl StructuralVerdict {
1627    /// Returns `true` when the artifact passed all checks.
1628    pub const fn is_valid(&self) -> bool {
1629        matches!(self, Self::Valid)
1630    }
1631}
1632
1633impl std::fmt::Display for StructuralVerdict {
1634    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1635        match self {
1636            Self::Valid => write!(f, "valid"),
1637            Self::Unreadable { path, reason } => {
1638                write!(f, "unreadable: {} ({})", path.display(), reason)
1639            }
1640            Self::UnsupportedExtension { path, extension } => {
1641                write!(
1642                    f,
1643                    "unsupported extension: {} (.{})",
1644                    path.display(),
1645                    extension
1646                )
1647            }
1648            Self::ParseError { path, message } => {
1649                write!(f, "parse error: {} ({})", path.display(), message)
1650            }
1651        }
1652    }
1653}
1654
1655/// Validate that a repaired artifact is structurally sound.
1656///
1657/// Performs three checks in order:
1658/// 1. **Readable** — the file can be read as UTF-8 text.
1659/// 2. **Supported extension** — `.ts`, `.tsx`, `.js`, `.mjs`, or `.json`.
1660/// 3. **Parseable** — SWC can parse `.ts`/`.tsx` files; JSON files are valid
1661///    JSON; `.js`/`.mjs` files are read successfully (syntax errors surface at
1662///    load time via QuickJS, but we verify readability here).
1663pub fn validate_repaired_artifact(path: &Path) -> StructuralVerdict {
1664    // 1. Readable check.
1665    let source = match fs::read_to_string(path) {
1666        Ok(s) => s,
1667        Err(err) => {
1668            return StructuralVerdict::Unreadable {
1669                path: path.to_path_buf(),
1670                reason: err.to_string(),
1671            };
1672        }
1673    };
1674
1675    // 2. Extension check.
1676    let ext = path
1677        .extension()
1678        .and_then(|e| e.to_str())
1679        .unwrap_or("")
1680        .to_ascii_lowercase();
1681
1682    match ext.as_str() {
1683        "ts" | "tsx" => validate_typescript_parse(path, &source, &ext),
1684        "js" | "mjs" => {
1685            // JS files are loaded by QuickJS which reports its own syntax
1686            // errors. We only verify readability (done above).
1687            StructuralVerdict::Valid
1688        }
1689        "json" => validate_json_parse(path, &source),
1690        _ => StructuralVerdict::UnsupportedExtension {
1691            path: path.to_path_buf(),
1692            extension: ext,
1693        },
1694    }
1695}
1696
1697/// Try to parse a TypeScript/TSX source with SWC.
1698fn validate_typescript_parse(path: &Path, source: &str, ext: &str) -> StructuralVerdict {
1699    use swc_common::{FileName, GLOBALS, Globals};
1700    use swc_ecma_parser::{Parser as SwcParser, StringInput, Syntax, TsSyntax};
1701
1702    let globals = Globals::new();
1703    GLOBALS.set(&globals, || {
1704        let cm: swc_common::sync::Lrc<swc_common::SourceMap> = swc_common::sync::Lrc::default();
1705        let fm = cm.new_source_file(
1706            FileName::Custom(path.display().to_string()).into(),
1707            source.to_string(),
1708        );
1709        let syntax = Syntax::Typescript(TsSyntax {
1710            tsx: ext == "tsx",
1711            decorators: true,
1712            ..Default::default()
1713        });
1714        let mut parser = SwcParser::new(syntax, StringInput::from(&*fm), None);
1715        match parser.parse_module() {
1716            Ok(_) => StructuralVerdict::Valid,
1717            Err(err) => StructuralVerdict::ParseError {
1718                path: path.to_path_buf(),
1719                message: format!("{err:?}"),
1720            },
1721        }
1722    })
1723}
1724
1725/// Validate that JSON source is well-formed.
1726fn validate_json_parse(path: &Path, source: &str) -> StructuralVerdict {
1727    match serde_json::from_str::<serde_json::Value>(source) {
1728        Ok(_) => StructuralVerdict::Valid,
1729        Err(err) => StructuralVerdict::ParseError {
1730            path: path.to_path_buf(),
1731            message: err.to_string(),
1732        },
1733    }
1734}
1735
1736// ---------------------------------------------------------------------------
1737// Tolerant AST recovery and ambiguity detection (bd-k5q5.9.2.2)
1738// ---------------------------------------------------------------------------
1739
1740/// A construct in the source that reduces repair confidence.
1741#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1742pub enum AmbiguitySignal {
1743    /// Source contains `eval(...)` — arbitrary code execution.
1744    DynamicEval,
1745    /// Source contains `new Function(...)` — dynamic function construction.
1746    DynamicFunction,
1747    /// Source contains `import(...)` — dynamic import expression.
1748    DynamicImport,
1749    /// Source contains `export * from` — star re-export hides export shape.
1750    StarReExport,
1751    /// Source contains `require(` with a non-literal argument.
1752    DynamicRequire,
1753    /// Source contains `new Proxy(` — metaprogramming.
1754    ProxyUsage,
1755    /// Source contains `with (` — deprecated scope-altering statement.
1756    WithStatement,
1757    /// SWC parser produced recoverable errors.
1758    RecoverableParseErrors { count: usize },
1759}
1760
1761impl AmbiguitySignal {
1762    /// Severity weight (0.0–1.0) for confidence scoring.
1763    pub fn weight(&self) -> f64 {
1764        match self {
1765            Self::DynamicEval | Self::DynamicFunction => 0.9,
1766            Self::ProxyUsage | Self::WithStatement => 0.7,
1767            Self::DynamicImport | Self::DynamicRequire => 0.5,
1768            Self::StarReExport => 0.3,
1769            Self::RecoverableParseErrors { count } => {
1770                // More errors → more ambiguous, capped at 1.0.
1771                (f64::from(u32::try_from(*count).unwrap_or(u32::MAX)) * 0.2).min(1.0)
1772            }
1773        }
1774    }
1775
1776    /// Short tag for logging.
1777    pub const fn tag(&self) -> &'static str {
1778        match self {
1779            Self::DynamicEval => "dynamic_eval",
1780            Self::DynamicFunction => "dynamic_function",
1781            Self::DynamicImport => "dynamic_import",
1782            Self::StarReExport => "star_reexport",
1783            Self::DynamicRequire => "dynamic_require",
1784            Self::ProxyUsage => "proxy_usage",
1785            Self::WithStatement => "with_statement",
1786            Self::RecoverableParseErrors { .. } => "recoverable_parse_errors",
1787        }
1788    }
1789}
1790
1791impl std::fmt::Display for AmbiguitySignal {
1792    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1793        match self {
1794            Self::RecoverableParseErrors { count } => {
1795                write!(f, "{}({})", self.tag(), count)
1796            }
1797            _ => write!(f, "{}", self.tag()),
1798        }
1799    }
1800}
1801
1802/// Result of tolerant parsing: partial analysis even when source has errors.
1803#[derive(Debug, Clone)]
1804pub struct TolerantParseResult {
1805    /// Whether the source parsed without fatal errors.
1806    pub parsed_ok: bool,
1807    /// Number of top-level statements recovered (0 if parse failed fatally).
1808    pub statement_count: usize,
1809    /// Number of import/export declarations found.
1810    pub import_export_count: usize,
1811    /// Detected ambiguity signals that reduce repair confidence.
1812    pub ambiguities: Vec<AmbiguitySignal>,
1813}
1814
1815impl TolerantParseResult {
1816    /// Overall ambiguity score (0.0 = fully legible, 1.0 = fully opaque).
1817    pub fn ambiguity_score(&self) -> f64 {
1818        if self.ambiguities.is_empty() {
1819            return 0.0;
1820        }
1821        // Take the max weight — one high-severity signal dominates.
1822        self.ambiguities
1823            .iter()
1824            .map(AmbiguitySignal::weight)
1825            .fold(0.0_f64, f64::max)
1826    }
1827
1828    /// True if the source is sufficiently legible for automated repair.
1829    pub fn is_legible(&self) -> bool {
1830        self.parsed_ok && self.ambiguity_score() < 0.8
1831    }
1832}
1833
1834/// Perform tolerant parsing and ambiguity detection on source text.
1835///
1836/// Attempts SWC parse for `.ts`/`.tsx` files to count statements
1837/// and imports. For all supported extensions, scans source text
1838/// for ambiguity patterns. Returns partial results even on parse
1839/// failure.
1840pub fn tolerant_parse(source: &str, filename: &str) -> TolerantParseResult {
1841    let ext = Path::new(filename)
1842        .extension()
1843        .and_then(|e| e.to_str())
1844        .unwrap_or("")
1845        .to_ascii_lowercase();
1846
1847    let (parsed_ok, statement_count, import_export_count, parse_errors) = match ext.as_str() {
1848        "ts" | "tsx" | "js" | "mjs" => try_swc_parse(source, filename, &ext),
1849        _ => (false, 0, 0, 0),
1850    };
1851
1852    let mut ambiguities = detect_ambiguity_patterns(source);
1853    if parse_errors > 0 {
1854        ambiguities.push(AmbiguitySignal::RecoverableParseErrors {
1855            count: parse_errors,
1856        });
1857    }
1858
1859    // Deduplicate.
1860    let mut seen = std::collections::HashSet::new();
1861    ambiguities.retain(|s| seen.insert(s.clone()));
1862
1863    TolerantParseResult {
1864        parsed_ok,
1865        statement_count,
1866        import_export_count,
1867        ambiguities,
1868    }
1869}
1870
1871/// Attempt SWC parse and return (ok, stmts, imports, error_count).
1872fn try_swc_parse(source: &str, filename: &str, ext: &str) -> (bool, usize, usize, usize) {
1873    use swc_common::{FileName, GLOBALS, Globals};
1874    use swc_ecma_parser::{Parser as SwcParser, StringInput, Syntax, TsSyntax};
1875
1876    let globals = Globals::new();
1877    GLOBALS.set(&globals, || {
1878        let cm: swc_common::sync::Lrc<swc_common::SourceMap> = swc_common::sync::Lrc::default();
1879        let fm = cm.new_source_file(
1880            FileName::Custom(filename.to_string()).into(),
1881            source.to_string(),
1882        );
1883        let is_ts = ext == "ts" || ext == "tsx";
1884        let syntax = if is_ts {
1885            Syntax::Typescript(TsSyntax {
1886                tsx: ext == "tsx",
1887                decorators: true,
1888                ..Default::default()
1889            })
1890        } else {
1891            Syntax::Es(swc_ecma_parser::EsSyntax {
1892                jsx: true,
1893                ..Default::default()
1894            })
1895        };
1896        let mut parser = SwcParser::new(syntax, StringInput::from(&*fm), None);
1897        if let Ok(module) = parser.parse_module() {
1898            let errors = parser.take_errors();
1899            let stmts = module.body.len();
1900            let imports = module
1901                .body
1902                .iter()
1903                .filter(|item| {
1904                    matches!(
1905                        item,
1906                        swc_ecma_ast::ModuleItem::ModuleDecl(
1907                            swc_ecma_ast::ModuleDecl::Import(_)
1908                                | swc_ecma_ast::ModuleDecl::ExportAll(_)
1909                                | swc_ecma_ast::ModuleDecl::ExportNamed(_)
1910                                | swc_ecma_ast::ModuleDecl::ExportDefaultDecl(_)
1911                                | swc_ecma_ast::ModuleDecl::ExportDefaultExpr(_)
1912                                | swc_ecma_ast::ModuleDecl::ExportDecl(_)
1913                        )
1914                    )
1915                })
1916                .count();
1917            (true, stmts, imports, errors.len())
1918        } else {
1919            let errors = parser.take_errors();
1920            // Fatal parse error — report 0 statements but count errors.
1921            (false, 0, 0, errors.len() + 1)
1922        }
1923    })
1924}
1925
1926/// Detect ambiguity patterns in source text.
1927fn detect_ambiguity_patterns(source: &str) -> Vec<AmbiguitySignal> {
1928    use std::sync::OnceLock;
1929
1930    static PATTERNS: OnceLock<Vec<(regex::Regex, AmbiguitySignal)>> = OnceLock::new();
1931    static DYN_REQUIRE: OnceLock<regex::Regex> = OnceLock::new();
1932
1933    let patterns = PATTERNS.get_or_init(|| {
1934        vec![
1935            (
1936                regex::Regex::new(r"\beval\s*\(").expect("regex"),
1937                AmbiguitySignal::DynamicEval,
1938            ),
1939            (
1940                regex::Regex::new(r"\bnew\s+Function\s*\(").expect("regex"),
1941                AmbiguitySignal::DynamicFunction,
1942            ),
1943            (
1944                regex::Regex::new(r"\bimport\s*\(").expect("regex"),
1945                AmbiguitySignal::DynamicImport,
1946            ),
1947            (
1948                regex::Regex::new(r"export\s+\*\s+from\b").expect("regex"),
1949                AmbiguitySignal::StarReExport,
1950            ),
1951            (
1952                regex::Regex::new(r"\bnew\s+Proxy\s*\(").expect("regex"),
1953                AmbiguitySignal::ProxyUsage,
1954            ),
1955            (
1956                regex::Regex::new(r"\bwith\s*\(").expect("regex"),
1957                AmbiguitySignal::WithStatement,
1958            ),
1959        ]
1960    });
1961
1962    let dyn_require = DYN_REQUIRE
1963        .get_or_init(|| regex::Regex::new(r#"\brequire\s*\(\s*[^"'`\s)]"#).expect("regex"));
1964
1965    let mut signals = Vec::new();
1966    for (re, signal) in patterns {
1967        if re.is_match(source) {
1968            signals.push(signal.clone());
1969        }
1970    }
1971    if dyn_require.is_match(source) {
1972        signals.push(AmbiguitySignal::DynamicRequire);
1973    }
1974
1975    signals
1976}
1977
1978// ---------------------------------------------------------------------------
1979// Intent graph extractor (bd-k5q5.9.2.1)
1980// ---------------------------------------------------------------------------
1981
1982/// A normalized intent signal extracted from an extension.
1983#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1984pub enum IntentSignal {
1985    /// The extension registers a tool with the given name.
1986    RegistersTool(String),
1987    /// The extension registers a slash command with the given name.
1988    RegistersCommand(String),
1989    /// The extension registers a keyboard shortcut.
1990    RegistersShortcut(String),
1991    /// The extension registers a feature flag with the given name.
1992    RegistersFlag(String),
1993    /// The extension registers a custom LLM provider.
1994    RegistersProvider(String),
1995    /// The extension hooks into a lifecycle event.
1996    HooksEvent(String),
1997    /// The extension declares a capability requirement.
1998    RequiresCapability(String),
1999    /// The extension registers a message renderer.
2000    RegistersRenderer(String),
2001}
2002
2003impl IntentSignal {
2004    /// Category tag for logging and grouping.
2005    pub const fn category(&self) -> &'static str {
2006        match self {
2007            Self::RegistersTool(_) => "tool",
2008            Self::RegistersCommand(_) => "command",
2009            Self::RegistersShortcut(_) => "shortcut",
2010            Self::RegistersFlag(_) => "flag",
2011            Self::RegistersProvider(_) => "provider",
2012            Self::HooksEvent(_) => "event_hook",
2013            Self::RequiresCapability(_) => "capability",
2014            Self::RegistersRenderer(_) => "renderer",
2015        }
2016    }
2017
2018    /// The name/identifier within the signal.
2019    pub fn name(&self) -> &str {
2020        match self {
2021            Self::RegistersTool(n)
2022            | Self::RegistersCommand(n)
2023            | Self::RegistersShortcut(n)
2024            | Self::RegistersFlag(n)
2025            | Self::RegistersProvider(n)
2026            | Self::HooksEvent(n)
2027            | Self::RequiresCapability(n)
2028            | Self::RegistersRenderer(n) => n,
2029        }
2030    }
2031}
2032
2033impl std::fmt::Display for IntentSignal {
2034    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2035        write!(f, "{}:{}", self.category(), self.name())
2036    }
2037}
2038
2039/// Normalized intent graph for a single extension.
2040///
2041/// Captures every registration and capability declaration the extension makes,
2042/// providing a complete picture of what the extension *intends* to do. Used by
2043/// the confidence scoring model to decide whether automated repair is safe.
2044#[derive(Debug, Clone, Default)]
2045pub struct IntentGraph {
2046    /// Extension identity.
2047    pub extension_id: String,
2048    /// All extracted intent signals (deduplicated).
2049    pub signals: Vec<IntentSignal>,
2050}
2051
2052impl IntentGraph {
2053    /// Build an intent graph from a `RegisterPayload` and capability list.
2054    pub fn from_register_payload(
2055        extension_id: &str,
2056        payload: &serde_json::Value,
2057        capabilities: &[String],
2058    ) -> Self {
2059        let mut signals = Vec::new();
2060
2061        // Extract tools.
2062        if let Some(tools) = payload.get("tools").and_then(|v| v.as_array()) {
2063            for tool in tools {
2064                if let Some(name) = tool.get("name").and_then(|n| n.as_str()) {
2065                    signals.push(IntentSignal::RegistersTool(name.to_string()));
2066                }
2067            }
2068        }
2069
2070        // Extract slash commands.
2071        if let Some(cmds) = payload.get("slash_commands").and_then(|v| v.as_array()) {
2072            for cmd in cmds {
2073                if let Some(name) = cmd.get("name").and_then(|n| n.as_str()) {
2074                    signals.push(IntentSignal::RegistersCommand(name.to_string()));
2075                }
2076            }
2077        }
2078
2079        // Extract shortcuts.
2080        if let Some(shortcuts) = payload.get("shortcuts").and_then(|v| v.as_array()) {
2081            for sc in shortcuts {
2082                let label = sc
2083                    .get("name")
2084                    .or_else(|| sc.get("key"))
2085                    .and_then(|n| n.as_str())
2086                    .unwrap_or("unknown");
2087                signals.push(IntentSignal::RegistersShortcut(label.to_string()));
2088            }
2089        }
2090
2091        // Extract flags.
2092        if let Some(flags) = payload.get("flags").and_then(|v| v.as_array()) {
2093            for flag in flags {
2094                if let Some(name) = flag.get("name").and_then(|n| n.as_str()) {
2095                    signals.push(IntentSignal::RegistersFlag(name.to_string()));
2096                }
2097            }
2098        }
2099
2100        // Extract event hooks.
2101        if let Some(hooks) = payload.get("event_hooks").and_then(|v| v.as_array()) {
2102            for hook in hooks {
2103                if let Some(name) = hook.as_str() {
2104                    signals.push(IntentSignal::HooksEvent(name.to_string()));
2105                }
2106            }
2107        }
2108
2109        // Capability declarations.
2110        for cap in capabilities {
2111            signals.push(IntentSignal::RequiresCapability(cap.clone()));
2112        }
2113
2114        // Deduplicate while preserving order.
2115        let mut seen = std::collections::HashSet::new();
2116        signals.retain(|s| seen.insert(s.clone()));
2117
2118        Self {
2119            extension_id: extension_id.to_string(),
2120            signals,
2121        }
2122    }
2123
2124    /// Return signals of a specific category.
2125    pub fn signals_by_category(&self, category: &str) -> Vec<&IntentSignal> {
2126        self.signals
2127            .iter()
2128            .filter(|s| s.category() == category)
2129            .collect()
2130    }
2131
2132    /// Number of distinct signal categories present.
2133    pub fn category_count(&self) -> usize {
2134        let cats: std::collections::HashSet<&str> =
2135            self.signals.iter().map(IntentSignal::category).collect();
2136        cats.len()
2137    }
2138
2139    /// True if the graph contains no signals at all.
2140    pub fn is_empty(&self) -> bool {
2141        self.signals.is_empty()
2142    }
2143
2144    /// Total number of signals.
2145    pub fn signal_count(&self) -> usize {
2146        self.signals.len()
2147    }
2148}
2149
2150// ---------------------------------------------------------------------------
2151// Confidence scoring model (bd-k5q5.9.2.3)
2152// ---------------------------------------------------------------------------
2153
2154/// An individual reason contributing to the confidence score.
2155#[derive(Debug, Clone)]
2156pub struct ConfidenceReason {
2157    /// Short machine-readable code (e.g., "parsed_ok", "has_tools").
2158    pub code: String,
2159    /// Human-readable explanation.
2160    pub explanation: String,
2161    /// How much this reason contributes (+) or penalizes (-) the score.
2162    pub delta: f64,
2163}
2164
2165/// Result of confidence scoring: a score plus explainable reasons.
2166#[derive(Debug, Clone)]
2167pub struct ConfidenceReport {
2168    /// Overall confidence (0.0–1.0). Higher = more legible / safer to repair.
2169    pub score: f64,
2170    /// Ordered list of reasons that contributed to the score.
2171    pub reasons: Vec<ConfidenceReason>,
2172}
2173
2174impl ConfidenceReport {
2175    /// True if the confidence is high enough for automated repair.
2176    pub fn is_repairable(&self) -> bool {
2177        self.score >= 0.5
2178    }
2179
2180    /// True if the confidence is high enough only for suggest mode.
2181    pub fn is_suggestable(&self) -> bool {
2182        self.score >= 0.2
2183    }
2184}
2185
2186/// Compute legibility confidence from intent graph and parse results.
2187///
2188/// The model is deterministic: same inputs always produce the same score.
2189/// The score starts at a base of 0.5 and is adjusted by weighted signals:
2190///
2191/// **Positive signals** (increase confidence):
2192/// - Source parsed successfully
2193/// - Extension registers at least one tool/command/hook
2194/// - Multiple intent categories present (well-structured extension)
2195///
2196/// **Negative signals** (decrease confidence):
2197/// - Parse failed
2198/// - Ambiguity detected (weighted by severity)
2199/// - No registrations (opaque extension)
2200/// - Zero statements recovered
2201#[allow(clippy::too_many_lines)]
2202pub fn compute_confidence(intent: &IntentGraph, parse: &TolerantParseResult) -> ConfidenceReport {
2203    let mut score: f64 = 0.5;
2204    let mut reasons = Vec::new();
2205
2206    // ── Parse quality ────────────────────────────────────────────────────
2207    if parse.parsed_ok {
2208        let delta = 0.15;
2209        score += delta;
2210        reasons.push(ConfidenceReason {
2211            code: "parsed_ok".to_string(),
2212            explanation: "Source parsed without fatal errors".to_string(),
2213            delta,
2214        });
2215    } else {
2216        let delta = -0.3;
2217        score += delta;
2218        reasons.push(ConfidenceReason {
2219            code: "parse_failed".to_string(),
2220            explanation: "Source failed to parse".to_string(),
2221            delta,
2222        });
2223    }
2224
2225    // ── Statement count ──────────────────────────────────────────────────
2226    if parse.statement_count == 0 && parse.parsed_ok {
2227        let delta = -0.1;
2228        score += delta;
2229        reasons.push(ConfidenceReason {
2230            code: "empty_module".to_string(),
2231            explanation: "Module has no statements".to_string(),
2232            delta,
2233        });
2234    }
2235
2236    // ── Import/export presence ───────────────────────────────────────────
2237    if parse.import_export_count > 0 {
2238        let delta = 0.05;
2239        score += delta;
2240        reasons.push(ConfidenceReason {
2241            code: "has_imports_exports".to_string(),
2242            explanation: format!(
2243                "{} import/export declarations found",
2244                parse.import_export_count
2245            ),
2246            delta,
2247        });
2248    }
2249
2250    // ── Ambiguity penalties ──────────────────────────────────────────────
2251    for ambiguity in &parse.ambiguities {
2252        let weight = ambiguity.weight();
2253        let delta = -weight * 0.3;
2254        score += delta;
2255        reasons.push(ConfidenceReason {
2256            code: format!("ambiguity_{}", ambiguity.tag()),
2257            explanation: format!("Ambiguity detected: {ambiguity} (weight={weight:.1})"),
2258            delta,
2259        });
2260    }
2261
2262    // ── Intent signal richness ───────────────────────────────────────────
2263    let tool_count = intent.signals_by_category("tool").len();
2264    if tool_count > 0 {
2265        let delta = 0.1;
2266        score += delta;
2267        reasons.push(ConfidenceReason {
2268            code: "has_tools".to_string(),
2269            explanation: format!("{tool_count} tool(s) registered"),
2270            delta,
2271        });
2272    }
2273
2274    let hook_count = intent.signals_by_category("event_hook").len();
2275    if hook_count > 0 {
2276        let delta = 0.05;
2277        score += delta;
2278        reasons.push(ConfidenceReason {
2279            code: "has_event_hooks".to_string(),
2280            explanation: format!("{hook_count} event hook(s) registered"),
2281            delta,
2282        });
2283    }
2284
2285    let categories = intent.category_count();
2286    if categories >= 3 {
2287        let delta = 0.1;
2288        score += delta;
2289        reasons.push(ConfidenceReason {
2290            code: "multi_category".to_string(),
2291            explanation: format!("{categories} distinct intent categories"),
2292            delta,
2293        });
2294    }
2295
2296    if intent.is_empty() && parse.parsed_ok {
2297        let delta = -0.15;
2298        score += delta;
2299        reasons.push(ConfidenceReason {
2300            code: "no_registrations".to_string(),
2301            explanation: "No tools, commands, or hooks registered".to_string(),
2302            delta,
2303        });
2304    }
2305
2306    // Clamp to [0.0, 1.0].
2307    score = score.clamp(0.0, 1.0);
2308
2309    ConfidenceReport { score, reasons }
2310}
2311
2312// ---------------------------------------------------------------------------
2313// Gating decision API (bd-k5q5.9.2.4)
2314// ---------------------------------------------------------------------------
2315
2316/// The repair gating decision: what action the system should take.
2317#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2318pub enum GatingDecision {
2319    /// Extension is legible and safe for automated repair.
2320    Allow,
2321    /// Extension is partially legible; suggest repairs but do not auto-apply.
2322    Suggest,
2323    /// Extension is too opaque or risky; deny automated repair.
2324    Deny,
2325}
2326
2327impl GatingDecision {
2328    /// Short label for structured logging.
2329    pub const fn label(&self) -> &'static str {
2330        match self {
2331            Self::Allow => "allow",
2332            Self::Suggest => "suggest",
2333            Self::Deny => "deny",
2334        }
2335    }
2336}
2337
2338impl std::fmt::Display for GatingDecision {
2339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2340        f.write_str(self.label())
2341    }
2342}
2343
2344/// A structured reason code explaining why a gating decision was made.
2345#[derive(Debug, Clone, PartialEq, Eq)]
2346pub struct GatingReasonCode {
2347    /// Machine-readable code (e.g., "low_confidence", "parse_failed").
2348    pub code: String,
2349    /// Human-readable remediation guidance.
2350    pub remediation: String,
2351}
2352
2353/// Full gating verdict: decision + confidence + reason codes.
2354#[derive(Debug, Clone)]
2355pub struct GatingVerdict {
2356    /// The decision: allow / suggest / deny.
2357    pub decision: GatingDecision,
2358    /// The underlying confidence report.
2359    pub confidence: ConfidenceReport,
2360    /// Structured reason codes (empty for Allow).
2361    pub reason_codes: Vec<GatingReasonCode>,
2362}
2363
2364impl GatingVerdict {
2365    /// Whether the verdict permits automated repair.
2366    pub fn allows_repair(&self) -> bool {
2367        self.decision == GatingDecision::Allow
2368    }
2369
2370    /// Whether the verdict permits at least suggestion output.
2371    pub const fn allows_suggestion(&self) -> bool {
2372        matches!(
2373            self.decision,
2374            GatingDecision::Allow | GatingDecision::Suggest
2375        )
2376    }
2377}
2378
2379/// Compute the gating verdict from intent graph and parse results.
2380///
2381/// Combines `compute_confidence` with threshold-based decision logic:
2382/// - score >= 0.5 → Allow
2383/// - 0.2 <= score < 0.5 → Suggest
2384/// - score < 0.2 → Deny
2385///
2386/// Reason codes are generated for Suggest and Deny decisions to guide
2387/// the user on what needs to change for the extension to become repairable.
2388pub fn compute_gating_verdict(intent: &IntentGraph, parse: &TolerantParseResult) -> GatingVerdict {
2389    let confidence = compute_confidence(intent, parse);
2390    let decision = if confidence.is_repairable() {
2391        GatingDecision::Allow
2392    } else if confidence.is_suggestable() {
2393        GatingDecision::Suggest
2394    } else {
2395        GatingDecision::Deny
2396    };
2397
2398    let reason_codes = if decision == GatingDecision::Allow {
2399        vec![]
2400    } else {
2401        build_reason_codes(&confidence, parse)
2402    };
2403
2404    GatingVerdict {
2405        decision,
2406        confidence,
2407        reason_codes,
2408    }
2409}
2410
2411/// Generate structured reason codes with remediation guidance.
2412fn build_reason_codes(
2413    confidence: &ConfidenceReport,
2414    parse: &TolerantParseResult,
2415) -> Vec<GatingReasonCode> {
2416    let mut codes = Vec::new();
2417
2418    if !parse.parsed_ok {
2419        codes.push(GatingReasonCode {
2420            code: "parse_failed".to_string(),
2421            remediation: "Fix syntax errors in the extension source code".to_string(),
2422        });
2423    }
2424
2425    for ambiguity in &parse.ambiguities {
2426        if ambiguity.weight() >= 0.7 {
2427            codes.push(GatingReasonCode {
2428                code: format!("high_ambiguity_{}", ambiguity.tag()),
2429                remediation: format!(
2430                    "Remove or refactor {} usage to improve repair safety",
2431                    ambiguity.tag().replace('_', " ")
2432                ),
2433            });
2434        }
2435    }
2436
2437    if confidence.score < 0.2 {
2438        codes.push(GatingReasonCode {
2439            code: "very_low_confidence".to_string(),
2440            remediation: "Extension is too opaque for automated analysis; \
2441                          add explicit tool/hook registrations and remove dynamic constructs"
2442                .to_string(),
2443        });
2444    }
2445
2446    codes
2447}
2448
2449/// Statistics from a tick execution.
2450#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2451pub struct PiJsTickStats {
2452    /// Whether a macrotask was executed.
2453    pub ran_macrotask: bool,
2454    /// Number of microtask drain iterations.
2455    pub microtask_drains: usize,
2456    /// Number of pending QuickJS jobs drained.
2457    pub jobs_drained: usize,
2458    /// Number of pending hostcalls (in-flight Promises).
2459    pub pending_hostcalls: usize,
2460    /// Total hostcalls issued by this runtime.
2461    pub hostcalls_total: u64,
2462    /// Total hostcalls timed out by this runtime.
2463    pub hostcalls_timed_out: u64,
2464    /// Last observed QuickJS `memory_used_size` in bytes.
2465    pub memory_used_bytes: u64,
2466    /// Peak observed QuickJS `memory_used_size` in bytes.
2467    pub peak_memory_used_bytes: u64,
2468    /// Number of auto-repair events recorded since the runtime was created.
2469    pub repairs_total: u64,
2470    /// Number of module cache hits accumulated by this runtime.
2471    pub module_cache_hits: u64,
2472    /// Number of module cache misses accumulated by this runtime.
2473    pub module_cache_misses: u64,
2474    /// Number of module cache invalidations accumulated by this runtime.
2475    pub module_cache_invalidations: u64,
2476    /// Number of module entries currently retained in the cache.
2477    pub module_cache_entries: u64,
2478    /// Number of disk cache hits (transpiled source loaded from persistent storage).
2479    pub module_disk_cache_hits: u64,
2480}
2481
2482#[derive(Debug, Clone, Default)]
2483pub struct PiJsRuntimeLimits {
2484    /// Limit runtime heap usage (QuickJS allocator). `None` means unlimited.
2485    pub memory_limit_bytes: Option<usize>,
2486    /// Limit runtime stack usage. `None` uses QuickJS default.
2487    pub max_stack_bytes: Option<usize>,
2488    /// Interrupt budget to bound JS execution. `None` disables budget enforcement.
2489    ///
2490    /// This is implemented via QuickJS's interrupt hook. For deterministic unit tests,
2491    /// setting this to `Some(0)` forces an immediate abort.
2492    pub interrupt_budget: Option<u64>,
2493    /// Default timeout (ms) for hostcalls issued via `pi.*`.
2494    pub hostcall_timeout_ms: Option<u64>,
2495    /// Fast-path ring capacity for JS->host hostcall handoff.
2496    ///
2497    /// `0` means use the runtime default.
2498    pub hostcall_fast_queue_capacity: usize,
2499    /// Overflow capacity once the fast-path ring is saturated.
2500    ///
2501    /// `0` means use the runtime default.
2502    pub hostcall_overflow_queue_capacity: usize,
2503}
2504
2505/// Controls how the auto-repair pipeline behaves at extension load time.
2506///
2507/// Precedence (highest to lowest): CLI flag → environment variable
2508/// `PI_REPAIR_MODE` → config file → default (`AutoSafe`).
2509#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2510pub enum RepairMode {
2511    /// No repairs are attempted; extensions that fail to load fail normally.
2512    Off,
2513    /// Log suggested repairs but do not apply them. Useful for auditing what
2514    /// would change before enabling auto-repair in production.
2515    Suggest,
2516    /// Apply only provably safe repairs: file-path fallbacks (Pattern 1) and
2517    /// missing-asset stubs (Pattern 2). These never grant new privileges.
2518    #[default]
2519    AutoSafe,
2520    /// Apply all repairs including aggressive heuristics (monorepo escape
2521    /// stubs, proxy-based npm stubs, export shape fixups). May change
2522    /// observable extension behavior.
2523    AutoStrict,
2524}
2525
2526impl RepairMode {
2527    /// Whether repairs should actually be applied (not just logged).
2528    pub const fn should_apply(self) -> bool {
2529        matches!(self, Self::AutoSafe | Self::AutoStrict)
2530    }
2531
2532    /// Whether any repair activity (logging or applying) is enabled.
2533    pub const fn is_active(self) -> bool {
2534        !matches!(self, Self::Off)
2535    }
2536
2537    /// Whether aggressive/heuristic patterns (3-5) are allowed.
2538    pub const fn allows_aggressive(self) -> bool {
2539        matches!(self, Self::AutoStrict)
2540    }
2541
2542    /// Parse from a string (env var, CLI flag, config value).
2543    pub fn from_str_lossy(s: &str) -> Self {
2544        match s.trim().to_ascii_lowercase().as_str() {
2545            "off" | "none" | "disabled" | "false" | "0" => Self::Off,
2546            "suggest" | "log" | "dry-run" | "dry_run" => Self::Suggest,
2547            "auto-strict" | "auto_strict" | "strict" | "all" => Self::AutoStrict,
2548            // "auto-safe", "safe", "true", "1", or any unrecognised value → default
2549            _ => Self::AutoSafe,
2550        }
2551    }
2552}
2553
2554impl std::fmt::Display for RepairMode {
2555    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2556        match self {
2557            Self::Off => write!(f, "off"),
2558            Self::Suggest => write!(f, "suggest"),
2559            Self::AutoSafe => write!(f, "auto-safe"),
2560            Self::AutoStrict => write!(f, "auto-strict"),
2561        }
2562    }
2563}
2564
2565// ---------------------------------------------------------------------------
2566// Privilege monotonicity checker (bd-k5q5.9.1.3)
2567// ---------------------------------------------------------------------------
2568
2569/// Result of a privilege monotonicity check on a proposed repair.
2570#[derive(Debug, Clone, PartialEq, Eq)]
2571pub enum MonotonicityVerdict {
2572    /// The repair is safe: the resolved path does not broaden privileges.
2573    Safe,
2574    /// The repair would escape the extension root directory.
2575    EscapesRoot {
2576        extension_root: PathBuf,
2577        resolved: PathBuf,
2578    },
2579    /// The repaired path crosses into a different extension's directory.
2580    CrossExtension {
2581        original_extension: String,
2582        resolved: PathBuf,
2583    },
2584}
2585
2586impl MonotonicityVerdict {
2587    pub const fn is_safe(&self) -> bool {
2588        matches!(self, Self::Safe)
2589    }
2590}
2591
2592/// Check that a repair-resolved path stays within the extension root.
2593///
2594/// Ensures repaired artifacts cannot broaden the extension's effective
2595/// capability surface by reaching into unrelated code.
2596///
2597/// # Guarantees
2598/// 1. `resolved_path` must be a descendant of `extension_root`.
2599/// 2. `resolved_path` must not traverse above the common ancestor of
2600///    `extension_root` and `original_path`.
2601pub fn verify_repair_monotonicity(
2602    extension_root: &Path,
2603    _original_path: &Path,
2604    resolved_path: &Path,
2605) -> MonotonicityVerdict {
2606    // Canonicalise the root to resolve symlinks. Fall back to the raw path
2607    // if canonicalization fails (the directory might not exist in tests).
2608    let canonical_root = crate::extensions::safe_canonicalize(extension_root);
2609
2610    let canonical_resolved = crate::extensions::safe_canonicalize(resolved_path);
2611
2612    // The resolved path MUST be a descendant of the extension root.
2613    if !canonical_resolved.starts_with(&canonical_root) {
2614        return MonotonicityVerdict::EscapesRoot {
2615            extension_root: canonical_root,
2616            resolved: canonical_resolved,
2617        };
2618    }
2619
2620    MonotonicityVerdict::Safe
2621}
2622
2623// ---------------------------------------------------------------------------
2624// Capability monotonicity proof reports (bd-k5q5.9.5.2)
2625// ---------------------------------------------------------------------------
2626
2627/// A single capability change between before and after `IntentGraph`s.
2628#[derive(Debug, Clone, PartialEq, Eq)]
2629pub enum CapabilityDelta {
2630    /// A signal present in both before and after — no change.
2631    Retained(IntentSignal),
2632    /// A signal present in before but absent in after — removed.
2633    Removed(IntentSignal),
2634    /// A signal absent in before but present in after — added (violation).
2635    Added(IntentSignal),
2636}
2637
2638impl CapabilityDelta {
2639    /// True when this delta represents a privilege escalation.
2640    pub const fn is_escalation(&self) -> bool {
2641        matches!(self, Self::Added(_))
2642    }
2643
2644    /// True when the capability was preserved unchanged.
2645    pub const fn is_retained(&self) -> bool {
2646        matches!(self, Self::Retained(_))
2647    }
2648
2649    /// True when the capability was dropped.
2650    pub const fn is_removed(&self) -> bool {
2651        matches!(self, Self::Removed(_))
2652    }
2653
2654    /// Short label for logging and telemetry.
2655    pub const fn label(&self) -> &'static str {
2656        match self {
2657            Self::Retained(_) => "retained",
2658            Self::Removed(_) => "removed",
2659            Self::Added(_) => "added",
2660        }
2661    }
2662
2663    /// The underlying signal.
2664    pub const fn signal(&self) -> &IntentSignal {
2665        match self {
2666            Self::Retained(s) | Self::Removed(s) | Self::Added(s) => s,
2667        }
2668    }
2669}
2670
2671impl std::fmt::Display for CapabilityDelta {
2672    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2673        write!(f, "{}: {}", self.label(), self.signal())
2674    }
2675}
2676
2677/// Proof verdict for capability monotonicity.
2678#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2679pub enum CapabilityMonotonicityVerdict {
2680    /// No capabilities were added — repair is monotonic (safe).
2681    Monotonic,
2682    /// One or more capabilities were added — privilege escalation detected.
2683    Escalation,
2684}
2685
2686impl CapabilityMonotonicityVerdict {
2687    /// True when the repair passed monotonicity (no escalation).
2688    pub const fn is_safe(&self) -> bool {
2689        matches!(self, Self::Monotonic)
2690    }
2691}
2692
2693impl std::fmt::Display for CapabilityMonotonicityVerdict {
2694    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2695        match self {
2696            Self::Monotonic => write!(f, "monotonic"),
2697            Self::Escalation => write!(f, "escalation"),
2698        }
2699    }
2700}
2701
2702/// Full capability monotonicity proof report.
2703///
2704/// Compares the before-repair and after-repair `IntentGraph`s signal by
2705/// signal. A repair is monotonic if and only if it introduces no new
2706/// capabilities — it may remove or retain existing ones, but never add.
2707#[derive(Debug, Clone)]
2708pub struct CapabilityProofReport {
2709    /// Extension identity.
2710    pub extension_id: String,
2711    /// Overall verdict.
2712    pub verdict: CapabilityMonotonicityVerdict,
2713    /// Per-signal deltas.
2714    pub deltas: Vec<CapabilityDelta>,
2715    /// Number of retained capabilities.
2716    pub retained_count: usize,
2717    /// Number of removed capabilities.
2718    pub removed_count: usize,
2719    /// Number of added capabilities (escalations).
2720    pub added_count: usize,
2721}
2722
2723impl CapabilityProofReport {
2724    /// True when the proof passed (no escalation).
2725    pub const fn is_safe(&self) -> bool {
2726        self.verdict.is_safe()
2727    }
2728
2729    /// Return only the escalation deltas.
2730    pub fn escalations(&self) -> Vec<&CapabilityDelta> {
2731        self.deltas.iter().filter(|d| d.is_escalation()).collect()
2732    }
2733}
2734
2735/// Compute a capability monotonicity proof by diffing two intent graphs.
2736///
2737/// The `before` graph represents the original extension's capabilities.
2738/// The `after` graph represents the repaired extension's capabilities.
2739///
2740/// A repair is *monotonic* (safe) if and only if `after` introduces no
2741/// signals that were absent from `before`. Removals are allowed.
2742pub fn compute_capability_proof(
2743    before: &IntentGraph,
2744    after: &IntentGraph,
2745) -> CapabilityProofReport {
2746    use std::collections::HashSet;
2747
2748    let before_set: HashSet<&IntentSignal> = before.signals.iter().collect();
2749    let after_set: HashSet<&IntentSignal> = after.signals.iter().collect();
2750
2751    let mut deltas = Vec::new();
2752
2753    // Signals retained or removed (iterate `before`).
2754    for signal in &before.signals {
2755        if after_set.contains(signal) {
2756            deltas.push(CapabilityDelta::Retained(signal.clone()));
2757        } else {
2758            deltas.push(CapabilityDelta::Removed(signal.clone()));
2759        }
2760    }
2761
2762    // Signals added (in `after` but not in `before`).
2763    for signal in &after.signals {
2764        if !before_set.contains(signal) {
2765            deltas.push(CapabilityDelta::Added(signal.clone()));
2766        }
2767    }
2768
2769    let retained_count = deltas.iter().filter(|d| d.is_retained()).count();
2770    let removed_count = deltas.iter().filter(|d| d.is_removed()).count();
2771    let added_count = deltas.iter().filter(|d| d.is_escalation()).count();
2772
2773    let verdict = if added_count == 0 {
2774        CapabilityMonotonicityVerdict::Monotonic
2775    } else {
2776        CapabilityMonotonicityVerdict::Escalation
2777    };
2778
2779    CapabilityProofReport {
2780        extension_id: before.extension_id.clone(),
2781        verdict,
2782        deltas,
2783        retained_count,
2784        removed_count,
2785        added_count,
2786    }
2787}
2788
2789// ---------------------------------------------------------------------------
2790// Hostcall parity and semantic delta proof (bd-k5q5.9.5.3)
2791// ---------------------------------------------------------------------------
2792
2793/// Categories of hostcall surface that an extension can exercise.
2794#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2795pub enum HostcallCategory {
2796    /// `pi.events(op, ...)` — lifecycle event dispatch.
2797    Events(String),
2798    /// `pi.session(op, ...)` — session metadata operations.
2799    Session(String),
2800    /// `pi.register(...)` — registration (tools, commands, etc.).
2801    Register,
2802    /// `pi.tool(op, ...)` — tool management.
2803    Tool(String),
2804    /// `require(...)` / `import(...)` — module resolution.
2805    ModuleResolution(String),
2806}
2807
2808impl HostcallCategory {
2809    /// Short tag for logging.
2810    pub fn tag(&self) -> String {
2811        match self {
2812            Self::Events(op) => format!("events:{op}"),
2813            Self::Session(op) => format!("session:{op}"),
2814            Self::Register => "register".to_string(),
2815            Self::Tool(op) => format!("tool:{op}"),
2816            Self::ModuleResolution(spec) => format!("module:{spec}"),
2817        }
2818    }
2819}
2820
2821impl std::fmt::Display for HostcallCategory {
2822    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2823        f.write_str(&self.tag())
2824    }
2825}
2826
2827/// A delta between before/after hostcall surfaces.
2828#[derive(Debug, Clone, PartialEq, Eq)]
2829pub enum HostcallDelta {
2830    /// Hostcall present in both before and after.
2831    Retained(HostcallCategory),
2832    /// Hostcall present before but absent after.
2833    Removed(HostcallCategory),
2834    /// Hostcall absent before but present after — new surface.
2835    Added(HostcallCategory),
2836}
2837
2838impl HostcallDelta {
2839    /// True when this delta introduces new hostcall surface.
2840    pub const fn is_expansion(&self) -> bool {
2841        matches!(self, Self::Added(_))
2842    }
2843
2844    /// Short label for logging.
2845    pub const fn label(&self) -> &'static str {
2846        match self {
2847            Self::Retained(_) => "retained",
2848            Self::Removed(_) => "removed",
2849            Self::Added(_) => "added",
2850        }
2851    }
2852
2853    /// The underlying category.
2854    pub const fn category(&self) -> &HostcallCategory {
2855        match self {
2856            Self::Retained(c) | Self::Removed(c) | Self::Added(c) => c,
2857        }
2858    }
2859}
2860
2861impl std::fmt::Display for HostcallDelta {
2862    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2863        write!(f, "{}: {}", self.label(), self.category())
2864    }
2865}
2866
2867/// Semantic drift severity classification.
2868#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
2869pub enum SemanticDriftSeverity {
2870    /// No meaningful behavioral change detected.
2871    None,
2872    /// Minor changes that don't affect core functionality.
2873    Low,
2874    /// Changes that may affect behavior but within expected scope.
2875    Medium,
2876    /// Significant behavioral divergence — likely beyond fix scope.
2877    High,
2878}
2879
2880impl SemanticDriftSeverity {
2881    /// True if drift is within acceptable bounds.
2882    pub const fn is_acceptable(&self) -> bool {
2883        matches!(self, Self::None | Self::Low)
2884    }
2885}
2886
2887impl std::fmt::Display for SemanticDriftSeverity {
2888    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2889        match self {
2890            Self::None => write!(f, "none"),
2891            Self::Low => write!(f, "low"),
2892            Self::Medium => write!(f, "medium"),
2893            Self::High => write!(f, "high"),
2894        }
2895    }
2896}
2897
2898/// Overall verdict for hostcall parity and semantic delta proof.
2899#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2900pub enum SemanticParityVerdict {
2901    /// Repair preserves hostcall surface and semantic behavior.
2902    Equivalent,
2903    /// Minor acceptable drift (e.g., removed dead hostcalls).
2904    AcceptableDrift,
2905    /// Repair introduces new hostcall surface or significant semantic drift.
2906    Divergent,
2907}
2908
2909impl SemanticParityVerdict {
2910    /// True if the repair passes semantic parity.
2911    pub const fn is_safe(&self) -> bool {
2912        matches!(self, Self::Equivalent | Self::AcceptableDrift)
2913    }
2914}
2915
2916impl std::fmt::Display for SemanticParityVerdict {
2917    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2918        match self {
2919            Self::Equivalent => write!(f, "equivalent"),
2920            Self::AcceptableDrift => write!(f, "acceptable_drift"),
2921            Self::Divergent => write!(f, "divergent"),
2922        }
2923    }
2924}
2925
2926/// Full hostcall parity and semantic delta proof report.
2927#[derive(Debug, Clone)]
2928pub struct SemanticParityReport {
2929    /// Extension identity.
2930    pub extension_id: String,
2931    /// Overall verdict.
2932    pub verdict: SemanticParityVerdict,
2933    /// Hostcall surface deltas.
2934    pub hostcall_deltas: Vec<HostcallDelta>,
2935    /// Semantic drift severity assessment.
2936    pub drift_severity: SemanticDriftSeverity,
2937    /// Number of new hostcall surfaces introduced.
2938    pub expanded_count: usize,
2939    /// Number of hostcalls removed.
2940    pub removed_count: usize,
2941    /// Number of hostcalls retained.
2942    pub retained_count: usize,
2943    /// Explanatory notes for the verdict.
2944    pub notes: Vec<String>,
2945}
2946
2947impl SemanticParityReport {
2948    /// True if the proof passed (repair is safe).
2949    pub const fn is_safe(&self) -> bool {
2950        self.verdict.is_safe()
2951    }
2952
2953    /// Return only the expansion deltas (new hostcall surface).
2954    pub fn expansions(&self) -> Vec<&HostcallDelta> {
2955        self.hostcall_deltas
2956            .iter()
2957            .filter(|d| d.is_expansion())
2958            .collect()
2959    }
2960}
2961
2962/// Extract hostcall categories from an intent graph.
2963///
2964/// Maps `IntentSignal`s to the hostcall categories they exercise at runtime.
2965/// This provides a static approximation of the extension's hostcall surface.
2966pub fn extract_hostcall_surface(
2967    intent: &IntentGraph,
2968) -> std::collections::HashSet<HostcallCategory> {
2969    let mut surface = std::collections::HashSet::new();
2970
2971    for signal in &intent.signals {
2972        match signal {
2973            IntentSignal::RegistersTool(_)
2974            | IntentSignal::RegistersCommand(_)
2975            | IntentSignal::RegistersShortcut(_)
2976            | IntentSignal::RegistersFlag(_)
2977            | IntentSignal::RegistersProvider(_)
2978            | IntentSignal::RegistersRenderer(_) => {
2979                surface.insert(HostcallCategory::Register);
2980            }
2981            IntentSignal::HooksEvent(name) => {
2982                surface.insert(HostcallCategory::Events(name.clone()));
2983            }
2984            IntentSignal::RequiresCapability(cap) => {
2985                if cap == "session" {
2986                    surface.insert(HostcallCategory::Session("*".to_string()));
2987                } else if cap == "tool" {
2988                    surface.insert(HostcallCategory::Tool("*".to_string()));
2989                }
2990            }
2991        }
2992    }
2993
2994    surface
2995}
2996
2997/// Compute hostcall parity and semantic delta proof.
2998///
2999/// Compares the before-repair and after-repair hostcall surfaces and
3000/// assesses semantic drift. A repair passes if it does not expand the
3001/// hostcall surface beyond the declared fix scope.
3002pub fn compute_semantic_parity(
3003    before: &IntentGraph,
3004    after: &IntentGraph,
3005    patch_ops: &[PatchOp],
3006) -> SemanticParityReport {
3007    let before_surface = extract_hostcall_surface(before);
3008    let after_surface = extract_hostcall_surface(after);
3009
3010    let mut hostcall_deltas = Vec::new();
3011
3012    // Retained and removed.
3013    for cat in &before_surface {
3014        if after_surface.contains(cat) {
3015            hostcall_deltas.push(HostcallDelta::Retained(cat.clone()));
3016        } else {
3017            hostcall_deltas.push(HostcallDelta::Removed(cat.clone()));
3018        }
3019    }
3020
3021    // Added.
3022    for cat in &after_surface {
3023        if !before_surface.contains(cat) {
3024            hostcall_deltas.push(HostcallDelta::Added(cat.clone()));
3025        }
3026    }
3027
3028    let expanded_count = hostcall_deltas.iter().filter(|d| d.is_expansion()).count();
3029    let removed_count = hostcall_deltas
3030        .iter()
3031        .filter(|d| matches!(d, HostcallDelta::Removed(_)))
3032        .count();
3033    let retained_count = hostcall_deltas
3034        .iter()
3035        .filter(|d| matches!(d, HostcallDelta::Retained(_)))
3036        .count();
3037
3038    // Assess semantic drift based on patch operations.
3039    let mut notes = Vec::new();
3040    let drift_severity = assess_drift(patch_ops, expanded_count, removed_count, &mut notes);
3041
3042    let verdict = if expanded_count == 0 && drift_severity.is_acceptable() {
3043        if removed_count == 0 {
3044            SemanticParityVerdict::Equivalent
3045        } else {
3046            notes.push(format!(
3047                "{removed_count} hostcall(s) removed — acceptable reduction"
3048            ));
3049            SemanticParityVerdict::AcceptableDrift
3050        }
3051    } else {
3052        if expanded_count > 0 {
3053            notes.push(format!(
3054                "{expanded_count} new hostcall surface(s) introduced"
3055            ));
3056        }
3057        SemanticParityVerdict::Divergent
3058    };
3059
3060    SemanticParityReport {
3061        extension_id: before.extension_id.clone(),
3062        verdict,
3063        hostcall_deltas,
3064        drift_severity,
3065        expanded_count,
3066        removed_count,
3067        retained_count,
3068        notes,
3069    }
3070}
3071
3072/// Assess semantic drift severity from patch operations and hostcall changes.
3073fn assess_drift(
3074    patch_ops: &[PatchOp],
3075    expanded_hostcalls: usize,
3076    _removed_hostcalls: usize,
3077    notes: &mut Vec<String>,
3078) -> SemanticDriftSeverity {
3079    // Any hostcall expansion is High severity.
3080    if expanded_hostcalls > 0 {
3081        notes.push("new hostcall surface detected".to_string());
3082        return SemanticDriftSeverity::High;
3083    }
3084
3085    let mut has_aggressive = false;
3086    let mut stub_count = 0_usize;
3087
3088    for op in patch_ops {
3089        match op {
3090            PatchOp::InjectStub { .. } => {
3091                stub_count += 1;
3092                has_aggressive = true;
3093            }
3094            PatchOp::AddExport { .. } | PatchOp::RemoveImport { .. } => {
3095                has_aggressive = true;
3096            }
3097            PatchOp::ReplaceModulePath { .. } | PatchOp::RewriteRequire { .. } => {}
3098        }
3099    }
3100
3101    if stub_count > 2 {
3102        notes.push(format!("{stub_count} stubs injected — medium drift"));
3103        return SemanticDriftSeverity::Medium;
3104    }
3105
3106    if has_aggressive {
3107        notes.push("aggressive ops present — low drift".to_string());
3108        return SemanticDriftSeverity::Low;
3109    }
3110
3111    SemanticDriftSeverity::None
3112}
3113
3114// ---------------------------------------------------------------------------
3115// Conformance replay and golden checksum evidence (bd-k5q5.9.5.4)
3116// ---------------------------------------------------------------------------
3117
3118/// SHA-256 checksum of an artifact (hex-encoded, lowercase).
3119pub type ArtifactChecksum = String;
3120
3121/// Compute a SHA-256 checksum for the given byte content.
3122pub fn compute_artifact_checksum(content: &[u8]) -> ArtifactChecksum {
3123    use sha2::{Digest, Sha256};
3124    let hash = Sha256::digest(content);
3125    format!("{hash:x}")
3126}
3127
3128/// A single artifact entry in a golden checksum manifest.
3129#[derive(Debug, Clone, PartialEq, Eq)]
3130pub struct ChecksumEntry {
3131    /// Relative path of the artifact within the extension root.
3132    pub relative_path: String,
3133    /// SHA-256 checksum.
3134    pub checksum: ArtifactChecksum,
3135    /// Byte size of the artifact.
3136    pub size_bytes: u64,
3137}
3138
3139/// Overall verdict of a conformance replay check.
3140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3141pub enum ConformanceReplayVerdict {
3142    /// All replayed fixtures matched expected behavior.
3143    Pass,
3144    /// One or more fixtures produced unexpected results.
3145    Fail,
3146    /// No fixtures were available to replay (vacuously safe).
3147    NoFixtures,
3148}
3149
3150impl ConformanceReplayVerdict {
3151    /// True if the replay passed or had no fixtures.
3152    pub const fn is_acceptable(&self) -> bool {
3153        matches!(self, Self::Pass | Self::NoFixtures)
3154    }
3155}
3156
3157impl std::fmt::Display for ConformanceReplayVerdict {
3158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3159        match self {
3160            Self::Pass => write!(f, "pass"),
3161            Self::Fail => write!(f, "fail"),
3162            Self::NoFixtures => write!(f, "no_fixtures"),
3163        }
3164    }
3165}
3166
3167/// A single conformance fixture for replay.
3168#[derive(Debug, Clone)]
3169pub struct ConformanceFixture {
3170    /// Descriptive name of the fixture.
3171    pub name: String,
3172    /// Expected behavior or output pattern.
3173    pub expected: String,
3174    /// Actual behavior or output observed during replay.
3175    pub actual: Option<String>,
3176    /// Whether this fixture passed.
3177    pub passed: bool,
3178}
3179
3180/// Result of replaying conformance fixtures.
3181#[derive(Debug, Clone)]
3182pub struct ConformanceReplayReport {
3183    /// Extension identity.
3184    pub extension_id: String,
3185    /// Overall verdict.
3186    pub verdict: ConformanceReplayVerdict,
3187    /// Individual fixture results.
3188    pub fixtures: Vec<ConformanceFixture>,
3189    /// Number of fixtures that passed.
3190    pub passed_count: usize,
3191    /// Total number of fixtures replayed.
3192    pub total_count: usize,
3193}
3194
3195impl ConformanceReplayReport {
3196    /// True if the replay is acceptable.
3197    pub const fn is_acceptable(&self) -> bool {
3198        self.verdict.is_acceptable()
3199    }
3200}
3201
3202/// Replay conformance fixtures and produce a report.
3203///
3204/// Each fixture is checked: if `actual` is provided and matches `expected`,
3205/// the fixture passes. If no fixtures are provided, the verdict is
3206/// `NoFixtures` (vacuously safe — conformance cannot be disproven).
3207pub fn replay_conformance_fixtures(
3208    extension_id: &str,
3209    fixtures: &[ConformanceFixture],
3210) -> ConformanceReplayReport {
3211    if fixtures.is_empty() {
3212        return ConformanceReplayReport {
3213            extension_id: extension_id.to_string(),
3214            verdict: ConformanceReplayVerdict::NoFixtures,
3215            fixtures: Vec::new(),
3216            passed_count: 0,
3217            total_count: 0,
3218        };
3219    }
3220
3221    let passed_count = fixtures.iter().filter(|f| f.passed).count();
3222    let total_count = fixtures.len();
3223    let verdict = if passed_count == total_count {
3224        ConformanceReplayVerdict::Pass
3225    } else {
3226        ConformanceReplayVerdict::Fail
3227    };
3228
3229    ConformanceReplayReport {
3230        extension_id: extension_id.to_string(),
3231        verdict,
3232        fixtures: fixtures.to_vec(),
3233        passed_count,
3234        total_count,
3235    }
3236}
3237
3238/// A golden checksum manifest for reproducible evidence.
3239///
3240/// Records the checksums of all repaired artifacts at the time of repair.
3241/// This provides tamper-evident proof that the artifacts were not modified
3242/// after the repair pipeline produced them.
3243#[derive(Debug, Clone)]
3244pub struct GoldenChecksumManifest {
3245    /// Extension identity.
3246    pub extension_id: String,
3247    /// Entries (one per artifact).
3248    pub entries: Vec<ChecksumEntry>,
3249    /// When the manifest was generated (unix millis).
3250    pub generated_at_ms: u64,
3251}
3252
3253impl GoldenChecksumManifest {
3254    /// Number of artifacts in the manifest.
3255    pub fn artifact_count(&self) -> usize {
3256        self.entries.len()
3257    }
3258
3259    /// Verify that a given file's content matches its entry in the manifest.
3260    pub fn verify_entry(&self, relative_path: &str, content: &[u8]) -> Option<bool> {
3261        self.entries
3262            .iter()
3263            .find(|e| e.relative_path == relative_path)
3264            .map(|e| e.checksum == compute_artifact_checksum(content))
3265    }
3266}
3267
3268/// Build a golden checksum manifest from file contents.
3269///
3270/// Takes an extension_id, a list of (relative_path, content) tuples, and a
3271/// timestamp. Computes SHA-256 for each artifact.
3272pub fn build_golden_manifest(
3273    extension_id: &str,
3274    artifacts: &[(&str, &[u8])],
3275    timestamp_ms: u64,
3276) -> GoldenChecksumManifest {
3277    let mut entries: Vec<ChecksumEntry> = artifacts
3278        .iter()
3279        .map(|(path, content)| ChecksumEntry {
3280            relative_path: (*path).to_string(),
3281            checksum: compute_artifact_checksum(content),
3282            size_bytes: content.len() as u64,
3283        })
3284        .collect();
3285
3286    entries.sort_by(|a, b| {
3287        a.relative_path
3288            .cmp(&b.relative_path)
3289            .then_with(|| a.checksum.cmp(&b.checksum))
3290            .then_with(|| a.size_bytes.cmp(&b.size_bytes))
3291    });
3292
3293    GoldenChecksumManifest {
3294        extension_id: extension_id.to_string(),
3295        entries,
3296        generated_at_ms: timestamp_ms,
3297    }
3298}
3299
3300/// Unified verification evidence bundle.
3301///
3302/// Collects all proof artifacts from LISR-5 into a single bundle that
3303/// serves as the activation gate. A repair candidate cannot be activated
3304/// unless ALL proofs pass.
3305#[derive(Debug, Clone)]
3306pub struct VerificationBundle {
3307    /// Extension identity.
3308    pub extension_id: String,
3309    /// Structural validation (LISR-5.1).
3310    pub structural: StructuralVerdict,
3311    /// Capability monotonicity proof (LISR-5.2).
3312    pub capability_proof: CapabilityProofReport,
3313    /// Semantic parity proof (LISR-5.3).
3314    pub semantic_proof: SemanticParityReport,
3315    /// Conformance replay (LISR-5.4).
3316    pub conformance: ConformanceReplayReport,
3317    /// Golden checksum manifest (LISR-5.4).
3318    pub checksum_manifest: GoldenChecksumManifest,
3319}
3320
3321impl VerificationBundle {
3322    /// True if ALL proofs pass — the activation gate.
3323    pub const fn is_verified(&self) -> bool {
3324        self.structural.is_valid()
3325            && self.capability_proof.is_safe()
3326            && self.semantic_proof.is_safe()
3327            && self.conformance.is_acceptable()
3328    }
3329
3330    /// Collect failure reasons for logging.
3331    pub fn failure_reasons(&self) -> Vec<String> {
3332        let mut reasons = Vec::new();
3333        if !self.structural.is_valid() {
3334            reasons.push(format!("structural: {}", self.structural));
3335        }
3336        if !self.capability_proof.is_safe() {
3337            reasons.push(format!(
3338                "capability: {} ({} escalation(s))",
3339                self.capability_proof.verdict, self.capability_proof.added_count
3340            ));
3341        }
3342        if !self.semantic_proof.is_safe() {
3343            reasons.push(format!(
3344                "semantic: {} (drift={})",
3345                self.semantic_proof.verdict, self.semantic_proof.drift_severity
3346            ));
3347        }
3348        if !self.conformance.is_acceptable() {
3349            reasons.push(format!(
3350                "conformance: {} ({}/{} passed)",
3351                self.conformance.verdict,
3352                self.conformance.passed_count,
3353                self.conformance.total_count
3354            ));
3355        }
3356        reasons
3357    }
3358}
3359
3360// ---------------------------------------------------------------------------
3361// Overlay artifact format and lifecycle storage (bd-k5q5.9.6.1)
3362// ---------------------------------------------------------------------------
3363
3364/// Lifecycle state of an overlay artifact.
3365#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3366pub enum OverlayState {
3367    /// Just created, not yet deployed.
3368    Staged,
3369    /// In canary — serving to a controlled cohort.
3370    Canary,
3371    /// Promoted to stable after successful canary window.
3372    Stable,
3373    /// Rolled back due to failure or manual action.
3374    RolledBack,
3375    /// Superseded by a newer repair.
3376    Superseded,
3377}
3378
3379impl OverlayState {
3380    /// True if the overlay is currently serving traffic.
3381    pub const fn is_active(&self) -> bool {
3382        matches!(self, Self::Canary | Self::Stable)
3383    }
3384
3385    /// True if the overlay has reached a terminal state.
3386    pub const fn is_terminal(&self) -> bool {
3387        matches!(self, Self::RolledBack | Self::Superseded)
3388    }
3389}
3390
3391impl std::fmt::Display for OverlayState {
3392    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3393        match self {
3394            Self::Staged => write!(f, "staged"),
3395            Self::Canary => write!(f, "canary"),
3396            Self::Stable => write!(f, "stable"),
3397            Self::RolledBack => write!(f, "rolled_back"),
3398            Self::Superseded => write!(f, "superseded"),
3399        }
3400    }
3401}
3402
3403/// An overlay artifact bundle: the unit of repair deployment.
3404///
3405/// Contains the repaired payload, original artifact hash, proof metadata,
3406/// policy decisions, and full lineage for auditability.
3407#[derive(Debug, Clone)]
3408pub struct OverlayArtifact {
3409    /// Unique identifier for this overlay.
3410    pub overlay_id: String,
3411    /// Extension identity.
3412    pub extension_id: String,
3413    /// Extension version.
3414    pub extension_version: String,
3415    /// SHA-256 of the original (broken) artifact.
3416    pub original_checksum: ArtifactChecksum,
3417    /// SHA-256 of the repaired artifact.
3418    pub repaired_checksum: ArtifactChecksum,
3419    /// Current lifecycle state.
3420    pub state: OverlayState,
3421    /// Rule that produced this repair.
3422    pub rule_id: String,
3423    /// Repair mode active when the overlay was created.
3424    pub repair_mode: RepairMode,
3425    /// Verification bundle summary (pass/fail per layer).
3426    pub verification_passed: bool,
3427    /// Creation timestamp (unix millis).
3428    pub created_at_ms: u64,
3429    /// Last state-transition timestamp (unix millis).
3430    pub updated_at_ms: u64,
3431}
3432
3433impl OverlayArtifact {
3434    /// True if the overlay is currently serving.
3435    pub const fn is_active(&self) -> bool {
3436        self.state.is_active()
3437    }
3438}
3439
3440/// State transition error.
3441#[derive(Debug, Clone, PartialEq, Eq)]
3442pub enum OverlayTransitionError {
3443    /// Attempted transition is not valid from the current state.
3444    InvalidTransition {
3445        from: OverlayState,
3446        to: OverlayState,
3447    },
3448    /// Verification must pass before deployment.
3449    VerificationRequired,
3450}
3451
3452impl std::fmt::Display for OverlayTransitionError {
3453    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3454        match self {
3455            Self::InvalidTransition { from, to } => {
3456                write!(f, "invalid transition: {from} → {to}")
3457            }
3458            Self::VerificationRequired => {
3459                write!(f, "verification must pass before deployment")
3460            }
3461        }
3462    }
3463}
3464
3465/// Advance an overlay through its lifecycle.
3466///
3467/// Valid transitions:
3468/// - Staged → Canary (requires verification_passed)
3469/// - Canary → Stable
3470/// - Canary → `RolledBack`
3471/// - Stable → `RolledBack`
3472/// - Stable → Superseded
3473/// - Staged → `RolledBack`
3474pub fn transition_overlay(
3475    artifact: &mut OverlayArtifact,
3476    target: OverlayState,
3477    now_ms: u64,
3478) -> std::result::Result<(), OverlayTransitionError> {
3479    let valid = matches!(
3480        (artifact.state, target),
3481        (
3482            OverlayState::Staged,
3483            OverlayState::Canary | OverlayState::RolledBack
3484        ) | (
3485            OverlayState::Canary,
3486            OverlayState::Stable | OverlayState::RolledBack
3487        ) | (
3488            OverlayState::Stable,
3489            OverlayState::RolledBack | OverlayState::Superseded
3490        )
3491    );
3492
3493    if !valid {
3494        return Err(OverlayTransitionError::InvalidTransition {
3495            from: artifact.state,
3496            to: target,
3497        });
3498    }
3499
3500    // Verification gate for deployment.
3501    if target == OverlayState::Canary && !artifact.verification_passed {
3502        return Err(OverlayTransitionError::VerificationRequired);
3503    }
3504
3505    artifact.state = target;
3506    artifact.updated_at_ms = now_ms;
3507    Ok(())
3508}
3509
3510// ---------------------------------------------------------------------------
3511// Per-extension/version canary routing (bd-k5q5.9.6.2)
3512// ---------------------------------------------------------------------------
3513
3514/// Canary routing decision for a specific request.
3515#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3516pub enum CanaryRoute {
3517    /// Use the original (unrepaired) artifact.
3518    Original,
3519    /// Use the repaired overlay artifact.
3520    Overlay,
3521}
3522
3523impl std::fmt::Display for CanaryRoute {
3524    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3525        match self {
3526            Self::Original => write!(f, "original"),
3527            Self::Overlay => write!(f, "overlay"),
3528        }
3529    }
3530}
3531
3532/// Canary configuration for a specific extension/version pair.
3533#[derive(Debug, Clone)]
3534pub struct CanaryConfig {
3535    /// Extension identity.
3536    pub extension_id: String,
3537    /// Extension version.
3538    pub extension_version: String,
3539    /// Percentage of requests to route to overlay (0–100).
3540    pub overlay_percent: u8,
3541    /// Whether canary is currently active.
3542    pub enabled: bool,
3543}
3544
3545impl CanaryConfig {
3546    /// Route a request using a deterministic hash value (0–99).
3547    pub const fn route(&self, hash_bucket: u8) -> CanaryRoute {
3548        if self.enabled && hash_bucket < self.overlay_percent {
3549            CanaryRoute::Overlay
3550        } else {
3551            CanaryRoute::Original
3552        }
3553    }
3554
3555    /// True if all traffic is routed to overlay (100% canary).
3556    pub const fn is_full_rollout(&self) -> bool {
3557        self.enabled && self.overlay_percent >= 100
3558    }
3559}
3560
3561/// Compute a deterministic hash bucket (0–99) from extension ID and environment.
3562pub fn compute_canary_bucket(extension_id: &str, environment: &str) -> u8 {
3563    use sha2::{Digest, Sha256};
3564    let mut hasher = Sha256::new();
3565    hasher.update(extension_id.as_bytes());
3566    hasher.update(b":");
3567    hasher.update(environment.as_bytes());
3568    let hash = hasher.finalize();
3569    // Use u16 to reduce modulo bias (65536 % 100 = 36 vs 256 % 100 = 56).
3570    let val = u16::from_be_bytes([hash[0], hash[1]]);
3571    (val % 100) as u8
3572}
3573
3574// ---------------------------------------------------------------------------
3575// Health/SLO monitors and automatic rollback triggers (bd-k5q5.9.6.3)
3576// ---------------------------------------------------------------------------
3577
3578/// A health signal observed during canary.
3579#[derive(Debug, Clone)]
3580pub struct HealthSignal {
3581    /// Signal name (e.g., "load_success", "hostcall_error_rate").
3582    pub name: String,
3583    /// Current value.
3584    pub value: f64,
3585    /// SLO threshold (value must not exceed this for the signal to be healthy).
3586    pub threshold: f64,
3587}
3588
3589impl HealthSignal {
3590    /// True if the signal is within SLO bounds.
3591    pub fn is_healthy(&self) -> bool {
3592        self.value <= self.threshold
3593    }
3594}
3595
3596/// SLO verdict for a canary window.
3597#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3598pub enum SloVerdict {
3599    /// All signals within thresholds.
3600    Healthy,
3601    /// One or more signals violated their SLO.
3602    Violated,
3603}
3604
3605impl SloVerdict {
3606    /// True if the canary is healthy.
3607    pub const fn is_healthy(&self) -> bool {
3608        matches!(self, Self::Healthy)
3609    }
3610}
3611
3612impl std::fmt::Display for SloVerdict {
3613    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3614        match self {
3615            Self::Healthy => write!(f, "healthy"),
3616            Self::Violated => write!(f, "violated"),
3617        }
3618    }
3619}
3620
3621/// Health assessment report for a canary window.
3622#[derive(Debug, Clone)]
3623pub struct HealthReport {
3624    /// Extension identity.
3625    pub extension_id: String,
3626    /// Overall verdict.
3627    pub verdict: SloVerdict,
3628    /// Individual signal assessments.
3629    pub signals: Vec<HealthSignal>,
3630    /// Signals that violated their SLO.
3631    pub violations: Vec<String>,
3632}
3633
3634impl HealthReport {
3635    /// True if the canary is healthy.
3636    pub const fn is_healthy(&self) -> bool {
3637        self.verdict.is_healthy()
3638    }
3639}
3640
3641/// Evaluate health signals against SLO thresholds.
3642pub fn evaluate_health(extension_id: &str, signals: &[HealthSignal]) -> HealthReport {
3643    let violations: Vec<String> = signals
3644        .iter()
3645        .filter(|s| !s.is_healthy())
3646        .map(|s| format!("{}: {:.3} > {:.3}", s.name, s.value, s.threshold))
3647        .collect();
3648
3649    let verdict = if violations.is_empty() {
3650        SloVerdict::Healthy
3651    } else {
3652        SloVerdict::Violated
3653    };
3654
3655    HealthReport {
3656        extension_id: extension_id.to_string(),
3657        verdict,
3658        signals: signals.to_vec(),
3659        violations,
3660    }
3661}
3662
3663/// Automatic rollback trigger: should the canary be rolled back?
3664pub const fn should_auto_rollback(health: &HealthReport) -> bool {
3665    !health.is_healthy()
3666}
3667
3668// ---------------------------------------------------------------------------
3669// Promotion and deterministic rollback workflow (bd-k5q5.9.6.4)
3670// ---------------------------------------------------------------------------
3671
3672/// Promotion decision for a canary overlay.
3673#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3674pub enum PromotionDecision {
3675    /// Promote to stable — canary window passed.
3676    Promote,
3677    /// Keep in canary — more observation needed.
3678    Hold,
3679    /// Rollback — SLO violations detected.
3680    Rollback,
3681}
3682
3683impl std::fmt::Display for PromotionDecision {
3684    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3685        match self {
3686            Self::Promote => write!(f, "promote"),
3687            Self::Hold => write!(f, "hold"),
3688            Self::Rollback => write!(f, "rollback"),
3689        }
3690    }
3691}
3692
3693/// Decide whether to promote, hold, or rollback a canary overlay.
3694///
3695/// Rules:
3696/// - If health is violated → Rollback.
3697/// - If canary duration has exceeded the window → Promote.
3698/// - Otherwise → Hold.
3699pub const fn decide_promotion(
3700    health: &HealthReport,
3701    canary_start_ms: u64,
3702    now_ms: u64,
3703    canary_window_ms: u64,
3704) -> PromotionDecision {
3705    if !health.is_healthy() {
3706        return PromotionDecision::Rollback;
3707    }
3708    if now_ms.saturating_sub(canary_start_ms) >= canary_window_ms {
3709        return PromotionDecision::Promote;
3710    }
3711    PromotionDecision::Hold
3712}
3713
3714/// Execute a promotion: transitions overlay to Stable.
3715pub fn execute_promotion(
3716    artifact: &mut OverlayArtifact,
3717    now_ms: u64,
3718) -> std::result::Result<(), OverlayTransitionError> {
3719    transition_overlay(artifact, OverlayState::Stable, now_ms)
3720}
3721
3722/// Execute a rollback: transitions overlay to `RolledBack`.
3723pub fn execute_rollback(
3724    artifact: &mut OverlayArtifact,
3725    now_ms: u64,
3726) -> std::result::Result<(), OverlayTransitionError> {
3727    transition_overlay(artifact, OverlayState::RolledBack, now_ms)
3728}
3729
3730// ---------------------------------------------------------------------------
3731// Append-only repair audit ledger (bd-k5q5.9.7.1)
3732// ---------------------------------------------------------------------------
3733
3734/// Kind of audit ledger entry.
3735#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3736pub enum AuditEntryKind {
3737    /// Analysis phase: intent extraction and confidence scoring.
3738    Analysis,
3739    /// Gating decision: allow / suggest / deny.
3740    GatingDecision,
3741    /// Proposal generated by rule or model.
3742    ProposalGenerated,
3743    /// Proposal validated against policy.
3744    ProposalValidated,
3745    /// Verification bundle evaluated.
3746    VerificationEvaluated,
3747    /// Human approval requested.
3748    ApprovalRequested,
3749    /// Human approval response.
3750    ApprovalResponse,
3751    /// Overlay activated (canary or stable).
3752    Activated,
3753    /// Overlay rolled back.
3754    RolledBack,
3755    /// Overlay promoted to stable.
3756    Promoted,
3757    /// Overlay superseded by newer repair.
3758    Superseded,
3759}
3760
3761impl std::fmt::Display for AuditEntryKind {
3762    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3763        match self {
3764            Self::Analysis => write!(f, "analysis"),
3765            Self::GatingDecision => write!(f, "gating_decision"),
3766            Self::ProposalGenerated => write!(f, "proposal_generated"),
3767            Self::ProposalValidated => write!(f, "proposal_validated"),
3768            Self::VerificationEvaluated => write!(f, "verification_evaluated"),
3769            Self::ApprovalRequested => write!(f, "approval_requested"),
3770            Self::ApprovalResponse => write!(f, "approval_response"),
3771            Self::Activated => write!(f, "activated"),
3772            Self::RolledBack => write!(f, "rolled_back"),
3773            Self::Promoted => write!(f, "promoted"),
3774            Self::Superseded => write!(f, "superseded"),
3775        }
3776    }
3777}
3778
3779/// A single entry in the repair audit ledger.
3780#[derive(Debug, Clone)]
3781pub struct AuditEntry {
3782    /// Monotonically increasing sequence number.
3783    pub sequence: u64,
3784    /// Timestamp (unix millis).
3785    pub timestamp_ms: u64,
3786    /// Extension being repaired.
3787    pub extension_id: String,
3788    /// Kind of event.
3789    pub kind: AuditEntryKind,
3790    /// Human-readable summary.
3791    pub summary: String,
3792    /// Structured detail fields (key-value pairs for machine consumption).
3793    pub details: Vec<(String, String)>,
3794}
3795
3796/// Append-only audit ledger for repair lifecycle events.
3797///
3798/// Entries are ordered by sequence number and cannot be mutated or deleted.
3799/// This provides tamper-evident evidence for incident forensics.
3800#[derive(Debug, Clone, Default)]
3801pub struct AuditLedger {
3802    entries: Vec<AuditEntry>,
3803    next_sequence: u64,
3804}
3805
3806impl AuditLedger {
3807    /// Create an empty ledger.
3808    pub const fn new() -> Self {
3809        Self {
3810            entries: Vec::new(),
3811            next_sequence: 0,
3812        }
3813    }
3814
3815    /// Append an entry to the ledger.
3816    pub fn append(
3817        &mut self,
3818        timestamp_ms: u64,
3819        extension_id: &str,
3820        kind: AuditEntryKind,
3821        summary: String,
3822        details: Vec<(String, String)>,
3823    ) -> u64 {
3824        let seq = self.next_sequence;
3825        self.entries.push(AuditEntry {
3826            sequence: seq,
3827            timestamp_ms,
3828            extension_id: extension_id.to_string(),
3829            kind,
3830            summary,
3831            details,
3832        });
3833        self.next_sequence = self.next_sequence.saturating_add(1);
3834        seq
3835    }
3836
3837    /// Number of entries in the ledger.
3838    pub fn len(&self) -> usize {
3839        self.entries.len()
3840    }
3841
3842    /// True if the ledger is empty.
3843    pub fn is_empty(&self) -> bool {
3844        self.entries.is_empty()
3845    }
3846
3847    /// Get an entry by sequence number.
3848    pub fn get(&self, sequence: u64) -> Option<&AuditEntry> {
3849        self.entries.iter().find(|e| e.sequence == sequence)
3850    }
3851
3852    /// Query entries by extension ID.
3853    pub fn entries_for_extension(&self, extension_id: &str) -> Vec<&AuditEntry> {
3854        self.entries
3855            .iter()
3856            .filter(|e| e.extension_id == extension_id)
3857            .collect()
3858    }
3859
3860    /// Query entries by kind.
3861    pub fn entries_by_kind(&self, kind: AuditEntryKind) -> Vec<&AuditEntry> {
3862        self.entries.iter().filter(|e| e.kind == kind).collect()
3863    }
3864
3865    /// All entries, ordered by sequence.
3866    pub fn all_entries(&self) -> &[AuditEntry] {
3867        &self.entries
3868    }
3869}
3870
3871// ---------------------------------------------------------------------------
3872// Telemetry taxonomy and metrics pipeline (bd-k5q5.9.7.2)
3873// ---------------------------------------------------------------------------
3874
3875/// Telemetry event kind for repair lifecycle metrics.
3876#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3877pub enum TelemetryMetric {
3878    /// A repair was attempted.
3879    RepairAttempted,
3880    /// Extension was eligible for repair.
3881    RepairEligible,
3882    /// Extension was ineligible (gating denied).
3883    RepairDenied,
3884    /// Verification proof failed.
3885    VerificationFailed,
3886    /// Overlay was rolled back.
3887    OverlayRolledBack,
3888    /// Overlay was promoted.
3889    OverlayPromoted,
3890    /// Human approval was requested.
3891    ApprovalLatencyMs,
3892}
3893
3894impl std::fmt::Display for TelemetryMetric {
3895    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3896        match self {
3897            Self::RepairAttempted => write!(f, "repair.attempted"),
3898            Self::RepairEligible => write!(f, "repair.eligible"),
3899            Self::RepairDenied => write!(f, "repair.denied"),
3900            Self::VerificationFailed => write!(f, "verification.failed"),
3901            Self::OverlayRolledBack => write!(f, "overlay.rolled_back"),
3902            Self::OverlayPromoted => write!(f, "overlay.promoted"),
3903            Self::ApprovalLatencyMs => write!(f, "approval.latency_ms"),
3904        }
3905    }
3906}
3907
3908/// A single telemetry data point.
3909#[derive(Debug, Clone)]
3910pub struct TelemetryPoint {
3911    /// Metric name.
3912    pub metric: TelemetryMetric,
3913    /// Numeric value (count=1 for counters, millis for latency, etc.).
3914    pub value: f64,
3915    /// Timestamp (unix millis).
3916    pub timestamp_ms: u64,
3917    /// Tags for dimensional filtering.
3918    pub tags: Vec<(String, String)>,
3919}
3920
3921/// Telemetry collector for repair lifecycle metrics.
3922#[derive(Debug, Clone, Default)]
3923pub struct TelemetryCollector {
3924    points: Vec<TelemetryPoint>,
3925}
3926
3927impl TelemetryCollector {
3928    /// Create an empty collector.
3929    pub const fn new() -> Self {
3930        Self { points: Vec::new() }
3931    }
3932
3933    /// Record a telemetry data point.
3934    pub fn record(
3935        &mut self,
3936        metric: TelemetryMetric,
3937        value: f64,
3938        timestamp_ms: u64,
3939        tags: Vec<(String, String)>,
3940    ) {
3941        self.points.push(TelemetryPoint {
3942            metric,
3943            value,
3944            timestamp_ms,
3945            tags,
3946        });
3947    }
3948
3949    /// Record a counter increment (value=1).
3950    pub fn increment(
3951        &mut self,
3952        metric: TelemetryMetric,
3953        timestamp_ms: u64,
3954        tags: Vec<(String, String)>,
3955    ) {
3956        self.record(metric, 1.0, timestamp_ms, tags);
3957    }
3958
3959    /// Count total occurrences of a metric.
3960    pub fn count(&self, metric: TelemetryMetric) -> usize {
3961        self.points.iter().filter(|p| p.metric == metric).count()
3962    }
3963
3964    /// Sum values for a metric.
3965    pub fn sum(&self, metric: TelemetryMetric) -> f64 {
3966        self.points
3967            .iter()
3968            .filter(|p| p.metric == metric)
3969            .map(|p| p.value)
3970            .sum()
3971    }
3972
3973    /// All points.
3974    pub fn all_points(&self) -> &[TelemetryPoint] {
3975        &self.points
3976    }
3977
3978    /// Number of recorded points.
3979    pub fn len(&self) -> usize {
3980        self.points.len()
3981    }
3982
3983    /// True if no points recorded.
3984    pub fn is_empty(&self) -> bool {
3985        self.points.is_empty()
3986    }
3987}
3988
3989// ---------------------------------------------------------------------------
3990// Operator CLI for repair inspect/explain/diff (bd-k5q5.9.7.3)
3991// ---------------------------------------------------------------------------
3992
3993/// A formatted inspection record for operator review.
3994#[derive(Debug, Clone)]
3995pub struct InspectionRecord {
3996    /// Extension identity.
3997    pub extension_id: String,
3998    /// Timeline of audit entries (formatted).
3999    pub timeline: Vec<String>,
4000    /// Gating decision summary.
4001    pub gating_summary: String,
4002    /// Current overlay state (if any).
4003    pub overlay_state: Option<String>,
4004    /// Verification result summary.
4005    pub verification_summary: String,
4006}
4007
4008/// Build an inspection record from audit ledger and current state.
4009pub fn build_inspection(
4010    extension_id: &str,
4011    ledger: &AuditLedger,
4012    overlay_state: Option<OverlayState>,
4013    verification_passed: bool,
4014) -> InspectionRecord {
4015    let entries = ledger.entries_for_extension(extension_id);
4016    let timeline: Vec<String> = entries
4017        .iter()
4018        .map(|e| format!("[seq={}] {} — {}", e.sequence, e.kind, e.summary))
4019        .collect();
4020
4021    let gating_entries = entries
4022        .iter()
4023        .filter(|e| e.kind == AuditEntryKind::GatingDecision)
4024        .collect::<Vec<_>>();
4025    let gating_summary = gating_entries.last().map_or_else(
4026        || "no gating decision recorded".to_string(),
4027        |e| e.summary.clone(),
4028    );
4029
4030    let verification_summary = if verification_passed {
4031        "all proofs passed".to_string()
4032    } else {
4033        "one or more proofs failed".to_string()
4034    };
4035
4036    InspectionRecord {
4037        extension_id: extension_id.to_string(),
4038        timeline,
4039        gating_summary,
4040        overlay_state: overlay_state.map(|s| s.to_string()),
4041        verification_summary,
4042    }
4043}
4044
4045/// Explain a gating decision in human-readable format.
4046pub fn explain_gating(verdict: &GatingVerdict) -> Vec<String> {
4047    let mut lines = Vec::new();
4048    lines.push(format!(
4049        "Decision: {} (confidence: {:.2})",
4050        verdict.decision, verdict.confidence.score
4051    ));
4052    for reason in &verdict.confidence.reasons {
4053        lines.push(format!(
4054            "  [{:+.2}] {} — {}",
4055            reason.delta, reason.code, reason.explanation
4056        ));
4057    }
4058    for code in &verdict.reason_codes {
4059        lines.push(format!("  REASON: {} — {}", code.code, code.remediation));
4060    }
4061    lines
4062}
4063
4064/// Format a patch proposal diff for operator review.
4065pub fn format_proposal_diff(proposal: &PatchProposal) -> Vec<String> {
4066    let mut lines = Vec::new();
4067    lines.push(format!(
4068        "Rule: {} ({} op(s), risk: {:?})",
4069        proposal.rule_id,
4070        proposal.op_count(),
4071        proposal.max_risk()
4072    ));
4073    if !proposal.rationale.is_empty() {
4074        lines.push(format!("Rationale: {}", proposal.rationale));
4075    }
4076    for (i, op) in proposal.ops.iter().enumerate() {
4077        lines.push(format!(
4078            "  Op {}: [{}] {}",
4079            i + 1,
4080            op.tag(),
4081            op_target_path(op)
4082        ));
4083    }
4084    lines
4085}
4086
4087// ---------------------------------------------------------------------------
4088// Forensic bundle export and incident handoff (bd-k5q5.9.7.4)
4089// ---------------------------------------------------------------------------
4090
4091/// A complete forensic bundle for incident analysis.
4092///
4093/// Contains everything needed to understand what happened during a repair:
4094/// the artifacts, proofs, audit trail, telemetry snapshot, and health signals.
4095#[derive(Debug, Clone)]
4096pub struct ForensicBundle {
4097    /// Extension identity.
4098    pub extension_id: String,
4099    /// Overlay artifact details.
4100    pub overlay: Option<OverlayArtifact>,
4101    /// Verification bundle.
4102    pub verification: Option<VerificationBundle>,
4103    /// Relevant audit entries.
4104    pub audit_entries: Vec<AuditEntry>,
4105    /// Telemetry snapshot for this extension.
4106    pub telemetry_points: Vec<TelemetryPoint>,
4107    /// Health report (if canary was active).
4108    pub health_report: Option<HealthReport>,
4109    /// Golden checksum manifest.
4110    pub checksum_manifest: Option<GoldenChecksumManifest>,
4111    /// Export timestamp (unix millis).
4112    pub exported_at_ms: u64,
4113}
4114
4115impl ForensicBundle {
4116    /// Number of audit entries in this bundle.
4117    pub fn audit_count(&self) -> usize {
4118        self.audit_entries.len()
4119    }
4120
4121    /// True if the bundle has verification evidence.
4122    pub const fn has_verification(&self) -> bool {
4123        self.verification.is_some()
4124    }
4125
4126    /// True if the bundle has health data.
4127    pub const fn has_health_data(&self) -> bool {
4128        self.health_report.is_some()
4129    }
4130}
4131
4132/// Build a forensic bundle from available state.
4133#[allow(clippy::too_many_arguments)]
4134pub fn build_forensic_bundle(
4135    extension_id: &str,
4136    overlay: Option<&OverlayArtifact>,
4137    verification: Option<&VerificationBundle>,
4138    ledger: &AuditLedger,
4139    collector: &TelemetryCollector,
4140    health_report: Option<&HealthReport>,
4141    checksum_manifest: Option<&GoldenChecksumManifest>,
4142    exported_at_ms: u64,
4143) -> ForensicBundle {
4144    let audit_entries = ledger
4145        .entries_for_extension(extension_id)
4146        .into_iter()
4147        .cloned()
4148        .collect();
4149
4150    let telemetry_points = collector
4151        .all_points()
4152        .iter()
4153        .filter(|p| {
4154            p.tags
4155                .iter()
4156                .any(|(k, v)| k == "extension_id" && v == extension_id)
4157        })
4158        .cloned()
4159        .collect();
4160
4161    ForensicBundle {
4162        extension_id: extension_id.to_string(),
4163        overlay: overlay.cloned(),
4164        verification: verification.cloned(),
4165        audit_entries,
4166        telemetry_points,
4167        health_report: health_report.cloned(),
4168        checksum_manifest: checksum_manifest.cloned(),
4169        exported_at_ms,
4170    }
4171}
4172
4173// ---------------------------------------------------------------------------
4174// Architecture ADR and threat-model rationale (bd-k5q5.9.8.1)
4175// ---------------------------------------------------------------------------
4176
4177/// Architecture Decision Record for the LISR system.
4178///
4179/// Records why LISR exists, what threats it addresses, and why fail-closed
4180/// constraints were chosen. This is embedded in code to prevent drift from
4181/// the original safety intent.
4182pub struct LisrAdr;
4183
4184impl LisrAdr {
4185    /// ADR identifier.
4186    pub const ID: &'static str = "ADR-LISR-001";
4187
4188    /// Title of the architecture decision.
4189    pub const TITLE: &'static str =
4190        "Dynamic Secure Extension Repair with Intent-Legible Self-Healing";
4191
4192    /// Why LISR exists.
4193    pub const CONTEXT: &'static str = "\
4194Extensions frequently break during updates when build artifacts (dist/) \
4195diverge from source (src/). Manual repair is slow, error-prone, and blocks \
4196the agent workflow. LISR provides automated repair within strict safety \
4197boundaries to restore extension functionality without human intervention.";
4198
4199    /// The core architectural decision.
4200    pub const DECISION: &'static str = "\
4201Adopt a layered repair pipeline with fail-closed defaults: \
4202(1) security policy framework bounds all repairs, \
4203(2) intent legibility analysis gates repair eligibility, \
4204(3) deterministic rules execute safe repairs, \
4205(4) model-assisted repairs are constrained to whitelisted primitives, \
4206(5) all repairs require structural + capability + semantic proof, \
4207(6) overlay deployment uses canary routing with health rollback, \
4208(7) every action is recorded in an append-only audit ledger, \
4209(8) governance checks are codified in the release process.";
4210
4211    /// Threats addressed by the design.
4212    pub const THREATS: &'static [&'static str] = &[
4213        "T1: Privilege escalation via repair adding new capabilities",
4214        "T2: Code injection via model-generated repair proposals",
4215        "T3: Supply-chain compromise via path traversal beyond extension root",
4216        "T4: Silent behavioral drift from opaque automated repairs",
4217        "T5: Loss of auditability preventing incident forensics",
4218        "T6: Governance decay from undocumented safety invariants",
4219    ];
4220
4221    /// Why fail-closed was chosen.
4222    pub const FAIL_CLOSED_RATIONALE: &'static str = "\
4223Any uncertainty in repair safety defaults to denial. A broken extension \
4224that remains broken is safer than a repaired extension that silently \
4225escalates privileges or introduces semantic drift. The cost of a false \
4226negative (missed repair) is low; the cost of a false positive (unsafe \
4227repair applied) is catastrophic.";
4228
4229    /// Key safety invariants enforced by the system.
4230    pub const INVARIANTS: &'static [&'static str] = &[
4231        "I1: Repairs never add capabilities absent from the original extension",
4232        "I2: All file paths stay within the extension root (monotonicity)",
4233        "I3: Model proposals are restricted to whitelisted PatchOp primitives",
4234        "I4: Structural validity is verified via SWC parse before activation",
4235        "I5: Every repair decision is recorded in the append-only audit ledger",
4236        "I6: Canary rollback triggers automatically on SLO violation",
4237    ];
4238}
4239
4240// ---------------------------------------------------------------------------
4241// Operator rollout and incident playbook (bd-k5q5.9.8.2)
4242// ---------------------------------------------------------------------------
4243
4244/// Operational procedure for repair mode selection.
4245pub struct OperatorPlaybook;
4246
4247impl OperatorPlaybook {
4248    /// Available repair modes and when to use each.
4249    pub const MODE_GUIDANCE: &'static [(&'static str, &'static str)] = &[
4250        (
4251            "Off",
4252            "Disable all automated repairs. Use when investigating a repair-related incident.",
4253        ),
4254        (
4255            "Suggest",
4256            "Log repair suggestions without applying. Use during initial rollout or audit.",
4257        ),
4258        (
4259            "AutoSafe",
4260            "Apply only safe (path-remap) repairs automatically. Default for production.",
4261        ),
4262        (
4263            "AutoStrict",
4264            "Apply both safe and aggressive repairs. Use only with explicit approval.",
4265        ),
4266    ];
4267
4268    /// Canary rollout procedure.
4269    pub const CANARY_PROCEDURE: &'static [&'static str] = &[
4270        "1. Create overlay artifact from repair pipeline",
4271        "2. Verify all proofs pass (structural, capability, semantic, conformance)",
4272        "3. Transition to Canary state with initial overlay_percent (e.g., 10%)",
4273        "4. Monitor health signals for canary_window_ms (default: 300_000)",
4274        "5. If SLO violated → automatic rollback",
4275        "6. If canary window passes → promote to Stable",
4276        "7. Record all transitions in audit ledger",
4277    ];
4278
4279    /// Incident response steps.
4280    pub const INCIDENT_RESPONSE: &'static [&'static str] = &[
4281        "1. Set repair_mode to Off immediately",
4282        "2. Export forensic bundle for the affected extension",
4283        "3. Review audit ledger for the repair timeline",
4284        "4. Check verification bundle for proof failures",
4285        "5. Inspect health signals that triggered rollback",
4286        "6. Root-cause the repair rule or model proposal",
4287        "7. File ADR amendment if safety invariant was violated",
4288    ];
4289}
4290
4291// ---------------------------------------------------------------------------
4292// Developer guide for adding safe repair rules (bd-k5q5.9.8.3)
4293// ---------------------------------------------------------------------------
4294
4295/// Developer guide for contributing new repair rules.
4296pub struct DeveloperGuide;
4297
4298impl DeveloperGuide {
4299    /// Steps to add a new deterministic repair rule.
4300    pub const ADD_RULE_CHECKLIST: &'static [&'static str] = &[
4301        "1. Define a RepairPattern variant with clear trigger semantics",
4302        "2. Define a RepairRule with: id, name, pattern, description, risk, ops",
4303        "3. Risk must be Safe unless the rule modifies code (then Aggressive)",
4304        "4. Add the rule to REPAIR_RULES static registry",
4305        "5. Implement matching logic in the extension loader",
4306        "6. Add unit tests covering: match, no-match, edge cases",
4307        "7. Add integration test with real extension fixture",
4308        "8. Verify monotonicity: rule must not escape extension root",
4309        "9. Verify capability monotonicity: rule must not add capabilities",
4310        "10. Run full conformance suite to check for regressions",
4311    ];
4312
4313    /// Anti-patterns to avoid.
4314    pub const ANTI_PATTERNS: &'static [(&'static str, &'static str)] = &[
4315        (
4316            "Unconstrained path rewriting",
4317            "Always validate target paths are within extension root via verify_repair_monotonicity()",
4318        ),
4319        (
4320            "Model-generated code execution",
4321            "Model proposals must use PatchOp primitives only — never eval or Function()",
4322        ),
4323        (
4324            "Skipping verification",
4325            "Every repair must pass the full VerificationBundle gate before activation",
4326        ),
4327        (
4328            "Mutable audit entries",
4329            "AuditLedger is append-only — never expose delete or update methods",
4330        ),
4331        (
4332            "Implicit capability grants",
4333            "compute_capability_proof() must show no Added deltas for the repair to pass",
4334        ),
4335    ];
4336
4337    /// Testing expectations for new rules.
4338    pub const TESTING_EXPECTATIONS: &'static [&'static str] = &[
4339        "Unit test: rule matches intended pattern and rejects non-matching input",
4340        "Unit test: generated PatchOps have correct risk classification",
4341        "Integration test: repair applied to real extension fixture succeeds",
4342        "Monotonicity test: repaired path stays within extension root",
4343        "Capability test: compute_capability_proof returns Monotonic",
4344        "Semantic test: compute_semantic_parity returns Equivalent or AcceptableDrift",
4345        "Conformance test: extension still passes conformance replay after repair",
4346    ];
4347}
4348
4349// ---------------------------------------------------------------------------
4350// Governance checklist for CI/release (bd-k5q5.9.8.4)
4351// ---------------------------------------------------------------------------
4352
4353/// A single governance check item.
4354#[derive(Debug, Clone)]
4355pub struct GovernanceCheck {
4356    /// Check identifier.
4357    pub id: String,
4358    /// Human-readable description.
4359    pub description: String,
4360    /// Whether the check passed.
4361    pub passed: bool,
4362    /// Detail message (empty if passed).
4363    pub detail: String,
4364}
4365
4366/// Result of running the governance checklist.
4367#[derive(Debug, Clone)]
4368pub struct GovernanceReport {
4369    /// Individual check results.
4370    pub checks: Vec<GovernanceCheck>,
4371    /// Number of checks that passed.
4372    pub passed_count: usize,
4373    /// Total number of checks.
4374    pub total_count: usize,
4375}
4376
4377impl GovernanceReport {
4378    /// True if all governance checks passed.
4379    pub const fn all_passed(&self) -> bool {
4380        self.passed_count == self.total_count
4381    }
4382
4383    /// Return failing checks.
4384    pub fn failures(&self) -> Vec<&GovernanceCheck> {
4385        self.checks.iter().filter(|c| !c.passed).collect()
4386    }
4387}
4388
4389/// Run the governance checklist against current system state.
4390///
4391/// Checks:
4392/// 1. Repair registry has at least one rule.
4393/// 2. All rule IDs are non-empty.
4394/// 3. ADR invariants are non-empty (documentation exists).
4395/// 4. Audit ledger is available (can be constructed).
4396/// 5. Telemetry collector is available (can be constructed).
4397/// 6. `VerificationBundle` checks all four proof layers.
4398pub fn run_governance_checklist() -> GovernanceReport {
4399    let mut checks = Vec::new();
4400
4401    // Check 1: Registry has rules.
4402    checks.push(GovernanceCheck {
4403        id: "GOV-001".to_string(),
4404        description: "Repair registry contains at least one rule".to_string(),
4405        passed: !REPAIR_RULES.is_empty(),
4406        detail: if REPAIR_RULES.is_empty() {
4407            "REPAIR_RULES is empty".to_string()
4408        } else {
4409            String::new()
4410        },
4411    });
4412
4413    // Check 2: All rule IDs are non-empty.
4414    let empty_ids: Vec<_> = REPAIR_RULES
4415        .iter()
4416        .filter(|r| r.id.is_empty())
4417        .map(|r| r.description)
4418        .collect();
4419    checks.push(GovernanceCheck {
4420        id: "GOV-002".to_string(),
4421        description: "All repair rules have non-empty IDs".to_string(),
4422        passed: empty_ids.is_empty(),
4423        detail: if empty_ids.is_empty() {
4424            String::new()
4425        } else {
4426            format!("Rules with empty IDs: {empty_ids:?}")
4427        },
4428    });
4429
4430    // Check 3: ADR exists.
4431    checks.push(GovernanceCheck {
4432        id: "GOV-003".to_string(),
4433        description: "Architecture ADR is defined".to_string(),
4434        passed: !LisrAdr::INVARIANTS.is_empty(),
4435        detail: String::new(),
4436    });
4437
4438    // Check 4: ADR threats are documented.
4439    checks.push(GovernanceCheck {
4440        id: "GOV-004".to_string(),
4441        description: "Threat model is documented".to_string(),
4442        passed: !LisrAdr::THREATS.is_empty(),
4443        detail: String::new(),
4444    });
4445
4446    // Check 5: Governance invariant count matches expected.
4447    let invariant_count = LisrAdr::INVARIANTS.len();
4448    checks.push(GovernanceCheck {
4449        id: "GOV-005".to_string(),
4450        description: "Safety invariants cover all critical areas (>=6)".to_string(),
4451        passed: invariant_count >= 6,
4452        detail: if invariant_count < 6 {
4453            format!("Only {invariant_count} invariants defined (need >=6)")
4454        } else {
4455            String::new()
4456        },
4457    });
4458
4459    // Check 6: Developer guide has testing expectations.
4460    checks.push(GovernanceCheck {
4461        id: "GOV-006".to_string(),
4462        description: "Developer testing expectations are documented".to_string(),
4463        passed: !DeveloperGuide::TESTING_EXPECTATIONS.is_empty(),
4464        detail: String::new(),
4465    });
4466
4467    let passed_count = checks.iter().filter(|c| c.passed).count();
4468    let total_count = checks.len();
4469
4470    GovernanceReport {
4471        checks,
4472        passed_count,
4473        total_count,
4474    }
4475}
4476
4477#[derive(Debug, Clone)]
4478pub struct PiJsRuntimeConfig {
4479    pub cwd: String,
4480    pub args: Vec<String>,
4481    pub env: HashMap<String, String>,
4482    pub limits: PiJsRuntimeLimits,
4483    /// Controls the auto-repair pipeline behavior. Default: `AutoSafe`.
4484    pub repair_mode: RepairMode,
4485    /// UNSAFE escape hatch: enable synchronous process execution used by
4486    /// `node:child_process` sync APIs (`execSync`/`spawnSync`/`execFileSync`).
4487    ///
4488    /// Security default is `false` so extensions cannot bypass capability/risk
4489    /// mediation through direct synchronous subprocess execution.
4490    pub allow_unsafe_sync_exec: bool,
4491    /// Explicitly deny environment variable access regardless of `is_env_var_allowed` blocklist.
4492    /// Used to enforce `ExtensionPolicy` with `deny_caps=[\"env\"]` for synchronous `pi.env` access.
4493    pub deny_env: bool,
4494    /// Directory for persistent transpiled-source disk cache.
4495    ///
4496    /// When set, transpiled module sources are cached on disk keyed by a
4497    /// content-aware hash so that SWC transpilation is skipped across process
4498    /// restarts. Defaults to `~/.pi/agent/cache/modules/` (overridden by
4499    /// `PIJS_MODULE_CACHE_DIR`). Set to `None` to disable.
4500    pub disk_cache_dir: Option<PathBuf>,
4501}
4502
4503impl PiJsRuntimeConfig {
4504    /// Convenience: check if repairs should be applied.
4505    pub const fn auto_repair_enabled(&self) -> bool {
4506        self.repair_mode.should_apply()
4507    }
4508}
4509
4510impl Default for PiJsRuntimeConfig {
4511    fn default() -> Self {
4512        Self {
4513            cwd: ".".to_string(),
4514            args: Vec::new(),
4515            env: HashMap::new(),
4516            limits: PiJsRuntimeLimits::default(),
4517            repair_mode: RepairMode::default(),
4518            allow_unsafe_sync_exec: false,
4519            deny_env: true,
4520            disk_cache_dir: runtime_disk_cache_dir(),
4521        }
4522    }
4523}
4524
4525/// Resolve the persistent module disk cache directory.
4526///
4527/// Priority: `PIJS_MODULE_CACHE_DIR` env var > `~/.pi/agent/cache/modules/`.
4528/// Set `PIJS_MODULE_CACHE_DIR=""` to explicitly disable the disk cache.
4529fn runtime_disk_cache_dir() -> Option<PathBuf> {
4530    if let Some(raw) = std::env::var_os("PIJS_MODULE_CACHE_DIR") {
4531        return if raw.is_empty() {
4532            None
4533        } else {
4534            Some(PathBuf::from(raw))
4535        };
4536    }
4537    dirs::home_dir().map(|home| home.join(".pi").join("agent").join("cache").join("modules"))
4538}
4539
4540#[derive(Debug)]
4541struct InterruptBudget {
4542    configured: Option<u64>,
4543    remaining: std::cell::Cell<Option<u64>>,
4544    tripped: std::cell::Cell<bool>,
4545}
4546
4547impl InterruptBudget {
4548    const fn new(configured: Option<u64>) -> Self {
4549        Self {
4550            configured,
4551            remaining: std::cell::Cell::new(None),
4552            tripped: std::cell::Cell::new(false),
4553        }
4554    }
4555
4556    fn reset(&self) {
4557        self.remaining.set(self.configured);
4558        self.tripped.set(false);
4559    }
4560
4561    fn on_interrupt(&self) -> bool {
4562        let Some(remaining) = self.remaining.get() else {
4563            return false;
4564        };
4565        if remaining == 0 {
4566            self.tripped.set(true);
4567            return true;
4568        }
4569        self.remaining.set(Some(remaining - 1));
4570        false
4571    }
4572
4573    fn did_trip(&self) -> bool {
4574        self.tripped.get()
4575    }
4576
4577    fn clear_trip(&self) {
4578        self.tripped.set(false);
4579    }
4580}
4581
4582#[derive(Debug, Default)]
4583struct HostcallTracker {
4584    pending: HashSet<String>,
4585    cancelled: HashSet<String>,
4586    call_to_timer: HashMap<String, u64>,
4587    timer_to_call: HashMap<u64, String>,
4588    enqueued_at_ms: HashMap<String, u64>,
4589    stream_last_seq: HashMap<String, u64>,
4590}
4591
4592enum HostcallCompletion {
4593    Delivered {
4594        #[allow(dead_code)]
4595        timer_id: Option<u64>,
4596    },
4597    Unknown,
4598}
4599
4600impl HostcallTracker {
4601    fn clear(&mut self) {
4602        self.pending.clear();
4603        self.cancelled.clear();
4604        self.call_to_timer.clear();
4605        self.timer_to_call.clear();
4606        self.enqueued_at_ms.clear();
4607        self.stream_last_seq.clear();
4608    }
4609
4610    fn register(&mut self, call_id: String, timer_id: Option<u64>, enqueued_at_ms: u64) {
4611        self.pending.insert(call_id.clone());
4612        self.cancelled.remove(&call_id);
4613        self.stream_last_seq.remove(&call_id);
4614        if let Some(timer_id) = timer_id {
4615            self.call_to_timer.insert(call_id.clone(), timer_id);
4616            self.timer_to_call.insert(timer_id, call_id.clone());
4617        }
4618        // Last insert consumes call_id, avoiding one clone.
4619        self.enqueued_at_ms.insert(call_id, enqueued_at_ms);
4620    }
4621
4622    fn pending_count(&self) -> usize {
4623        self.pending.len()
4624    }
4625
4626    fn is_pending(&self, call_id: &str) -> bool {
4627        self.pending.contains(call_id)
4628    }
4629
4630    fn is_active(&self, call_id: &str) -> bool {
4631        self.pending.contains(call_id) && !self.cancelled.contains(call_id)
4632    }
4633
4634    fn cancel(&mut self, call_id: &str) -> Option<u64> {
4635        if !self.pending.contains(call_id) {
4636            return None;
4637        }
4638        self.cancelled.insert(call_id.to_string());
4639        let timer_id = self.call_to_timer.remove(call_id);
4640        if let Some(timer_id) = timer_id {
4641            self.timer_to_call.remove(&timer_id);
4642        }
4643        timer_id
4644    }
4645
4646    fn record_stream_seq(&mut self, call_id: &str, sequence: u64) {
4647        if !self.pending.contains(call_id) {
4648            return;
4649        }
4650        let entry = self
4651            .stream_last_seq
4652            .entry(call_id.to_string())
4653            .or_insert(sequence);
4654        if sequence > *entry {
4655            *entry = sequence;
4656        }
4657    }
4658
4659    fn stream_next_seq(&self, call_id: &str) -> Option<u64> {
4660        if !self.pending.contains(call_id) {
4661            return None;
4662        }
4663        Some(
4664            self.stream_last_seq
4665                .get(call_id)
4666                .copied()
4667                .map_or(0, |seq| seq.saturating_add(1)),
4668        )
4669    }
4670
4671    fn queue_wait_ms(&self, call_id: &str, now_ms: u64) -> Option<u64> {
4672        self.enqueued_at_ms
4673            .get(call_id)
4674            .copied()
4675            .map(|enqueued| now_ms.saturating_sub(enqueued))
4676    }
4677
4678    fn on_complete(&mut self, call_id: &str) -> HostcallCompletion {
4679        if !self.pending.remove(call_id) {
4680            return HostcallCompletion::Unknown;
4681        }
4682
4683        let timer_id = self.call_to_timer.remove(call_id);
4684        self.enqueued_at_ms.remove(call_id);
4685        self.cancelled.remove(call_id);
4686        self.stream_last_seq.remove(call_id);
4687        if let Some(timer_id) = timer_id {
4688            self.timer_to_call.remove(&timer_id);
4689        }
4690
4691        HostcallCompletion::Delivered { timer_id }
4692    }
4693
4694    fn take_timed_out_call(&mut self, timer_id: u64) -> Option<String> {
4695        let call_id = self.timer_to_call.remove(&timer_id)?;
4696        self.call_to_timer.remove(&call_id);
4697        self.enqueued_at_ms.remove(&call_id);
4698        self.cancelled.remove(&call_id);
4699        self.stream_last_seq.remove(&call_id);
4700        if !self.pending.remove(&call_id) {
4701            return None;
4702        }
4703        Some(call_id)
4704    }
4705}
4706
4707fn enqueue_hostcall_request_with_backpressure<C: SchedulerClock>(
4708    queue: &HostcallQueue,
4709    tracker: &Rc<RefCell<HostcallTracker>>,
4710    scheduler: &Rc<RefCell<Scheduler<C>>>,
4711    request: HostcallRequest,
4712) {
4713    let call_id = request.call_id.clone();
4714    let trace_id = request.trace_id;
4715    let extension_id = request.extension_id.clone();
4716    match queue.borrow_mut().push_back(request) {
4717        HostcallQueueEnqueueResult::FastPath { depth } => {
4718            tracing::trace!(
4719                event = "pijs.hostcall.queue.fast_path",
4720                call_id = %call_id,
4721                trace_id,
4722                extension_id = ?extension_id,
4723                depth,
4724                "Hostcall queued on fast-path ring"
4725            );
4726        }
4727        HostcallQueueEnqueueResult::OverflowPath {
4728            depth,
4729            overflow_depth,
4730        } => {
4731            tracing::debug!(
4732                event = "pijs.hostcall.queue.overflow_path",
4733                call_id = %call_id,
4734                trace_id,
4735                extension_id = ?extension_id,
4736                depth,
4737                overflow_depth,
4738                "Hostcall spilled to overflow queue"
4739            );
4740        }
4741        HostcallQueueEnqueueResult::Rejected {
4742            depth,
4743            overflow_depth,
4744        } => {
4745            let completion = tracker.borrow_mut().on_complete(&call_id);
4746            if let HostcallCompletion::Delivered { timer_id } = completion {
4747                if let Some(timer_id) = timer_id {
4748                    let _ = scheduler.borrow_mut().clear_timeout(timer_id);
4749                }
4750                scheduler.borrow_mut().enqueue_hostcall_complete(
4751                    call_id.clone(),
4752                    HostcallOutcome::Error {
4753                        code: "overloaded".to_string(),
4754                        message: format!(
4755                            "Hostcall queue overloaded (depth={depth}, overflow_depth={overflow_depth})"
4756                        ),
4757                    },
4758                );
4759            }
4760            tracing::warn!(
4761                event = "pijs.hostcall.queue.rejected",
4762                call_id = %call_id,
4763                trace_id,
4764                extension_id = ?extension_id,
4765                depth,
4766                overflow_depth,
4767                "Hostcall rejected by queue backpressure policy"
4768            );
4769        }
4770    }
4771}
4772
4773// ============================================================================
4774// PiJS Module Loader (TypeScript + virtual modules)
4775// ============================================================================
4776
4777#[derive(Debug)]
4778struct PiJsModuleState {
4779    /// Immutable built-in virtual modules shared across runtimes.
4780    static_virtual_modules: Arc<HashMap<String, String>>,
4781    /// Runtime-local virtual modules generated by repairs / dynamic stubs.
4782    dynamic_virtual_modules: HashMap<String, String>,
4783    /// Tracked named exports for dynamic virtual modules keyed by specifier.
4784    dynamic_virtual_named_exports: HashMap<String, BTreeSet<String>>,
4785    compiled_sources: HashMap<String, CompiledModuleCacheEntry>,
4786    module_cache_counters: ModuleCacheCounters,
4787    /// Repair mode propagated from `PiJsRuntimeConfig` so the resolver can
4788    /// gate fallback patterns without executing any broken code.
4789    repair_mode: RepairMode,
4790    /// Extension root directories used to detect monorepo escape (Pattern 3).
4791    /// Populated as extensions are loaded via [`PiJsRuntime::add_extension_root`].
4792    extension_roots: Vec<PathBuf>,
4793    /// Pre-canonicalized extension roots to avoid doing filesystem IO during import resolution.
4794    canonical_extension_roots: Vec<PathBuf>,
4795    /// Source-tier classification per extension root. Used by Pattern 4 to
4796    /// avoid proxy stubs for official/first-party extensions.
4797    extension_root_tiers: HashMap<PathBuf, ProxyStubSourceTier>,
4798    /// Package scope (`@scope`) per extension root (when discoverable from
4799    /// package.json name). Pattern 4 allows same-scope packages.
4800    extension_root_scopes: HashMap<PathBuf, String>,
4801    /// Canonical extension roots grouped by extension id for runtime
4802    /// filesystem access checks. This keeps sync host reads/writes scoped to
4803    /// the currently executing extension instead of all registered roots.
4804    extension_roots_by_id: HashMap<String, Vec<PathBuf>>,
4805    /// Canonical extension roots registered without extension metadata.
4806    /// These remain available to the active extension for legacy callers that
4807    /// still use `add_extension_root()`.
4808    extension_roots_without_id: Vec<PathBuf>,
4809    /// Shared handle for recording repair events from the resolver.
4810    repair_events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>>,
4811    /// Directory for persistent transpiled-source disk cache.
4812    disk_cache_dir: Option<PathBuf>,
4813}
4814
4815#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4816enum ProxyStubSourceTier {
4817    Official,
4818    Community,
4819    Unknown,
4820}
4821
4822#[derive(Debug, Clone)]
4823struct CompiledModuleCacheEntry {
4824    cache_key: Option<String>,
4825    source: Arc<[u8]>,
4826}
4827
4828#[derive(Debug, Clone, Copy, Default)]
4829struct ModuleCacheCounters {
4830    hits: u64,
4831    misses: u64,
4832    invalidations: u64,
4833    disk_hits: u64,
4834}
4835
4836impl PiJsModuleState {
4837    fn new() -> Self {
4838        Self {
4839            static_virtual_modules: default_virtual_modules_shared(),
4840            dynamic_virtual_modules: HashMap::new(),
4841            dynamic_virtual_named_exports: HashMap::new(),
4842            compiled_sources: HashMap::new(),
4843            module_cache_counters: ModuleCacheCounters::default(),
4844            repair_mode: RepairMode::default(),
4845            extension_roots: Vec::new(),
4846            canonical_extension_roots: Vec::new(),
4847            extension_root_tiers: HashMap::new(),
4848            extension_root_scopes: HashMap::new(),
4849            extension_roots_by_id: HashMap::new(),
4850            extension_roots_without_id: Vec::new(),
4851            repair_events: Arc::new(std::sync::Mutex::new(Vec::new())),
4852            disk_cache_dir: None,
4853        }
4854    }
4855
4856    const fn with_repair_mode(mut self, mode: RepairMode) -> Self {
4857        self.repair_mode = mode;
4858        self
4859    }
4860
4861    fn with_repair_events(
4862        mut self,
4863        events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>>,
4864    ) -> Self {
4865        self.repair_events = events;
4866        self
4867    }
4868
4869    fn with_disk_cache_dir(mut self, dir: Option<PathBuf>) -> Self {
4870        self.disk_cache_dir = dir;
4871        self
4872    }
4873}
4874
4875fn current_extension_id(ctx: &Ctx<'_>) -> Option<String> {
4876    ctx.globals()
4877        .get::<_, Option<String>>("__pi_current_extension_id")
4878        .ok()
4879        .flatten()
4880        .map(|value| value.trim().to_string())
4881        .filter(|value| !value.is_empty())
4882}
4883
4884fn extension_roots_for_fs_access(
4885    extension_id: Option<&str>,
4886    module_state: &Rc<RefCell<PiJsModuleState>>,
4887    fallback_roots: &Arc<std::sync::Mutex<Vec<PathBuf>>>,
4888) -> Vec<PathBuf> {
4889    if let Some(extension_id) = extension_id {
4890        let state = module_state.borrow();
4891        let mut roots = state.extension_roots_without_id.clone();
4892        if let Some(scoped_roots) = state.extension_roots_by_id.get(extension_id) {
4893            for root in scoped_roots {
4894                if !roots.contains(root) {
4895                    roots.push(root.clone());
4896                }
4897            }
4898        }
4899        return roots;
4900    }
4901
4902    fallback_roots
4903        .lock()
4904        .map(|roots| roots.clone())
4905        .unwrap_or_default()
4906}
4907
4908fn path_is_in_allowed_extension_root(
4909    path: &Path,
4910    extension_id: Option<&str>,
4911    module_state: &Rc<RefCell<PiJsModuleState>>,
4912    fallback_roots: &Arc<std::sync::Mutex<Vec<PathBuf>>>,
4913) -> bool {
4914    extension_roots_for_fs_access(extension_id, module_state, fallback_roots)
4915        .iter()
4916        .any(|root| path.starts_with(root))
4917}
4918
4919#[derive(Clone, Debug)]
4920struct PiJsResolver {
4921    state: Rc<RefCell<PiJsModuleState>>,
4922}
4923
4924fn canonical_node_builtin(spec: &str) -> Option<&'static str> {
4925    match spec {
4926        "fs" | "node:fs" => Some("node:fs"),
4927        "fs/promises" | "node:fs/promises" => Some("node:fs/promises"),
4928        "path" | "node:path" => Some("node:path"),
4929        "os" | "node:os" => Some("node:os"),
4930        "child_process" | "node:child_process" => Some("node:child_process"),
4931        "crypto" | "node:crypto" => Some("node:crypto"),
4932        "http" | "node:http" => Some("node:http"),
4933        "https" | "node:https" => Some("node:https"),
4934        "http2" | "node:http2" => Some("node:http2"),
4935        "timers" | "node:timers" => Some("node:timers"),
4936        "util" | "node:util" => Some("node:util"),
4937        "readline" | "node:readline" => Some("node:readline"),
4938        "readline/promises" | "node:readline/promises" => Some("node:readline/promises"),
4939        "url" | "node:url" => Some("node:url"),
4940        "net" | "node:net" => Some("node:net"),
4941        "events" | "node:events" => Some("node:events"),
4942        "buffer" | "node:buffer" => Some("node:buffer"),
4943        "assert" | "node:assert" => Some("node:assert"),
4944        "assert/strict" | "node:assert/strict" => Some("node:assert/strict"),
4945        "test" | "node:test" => Some("node:test"),
4946        "stream" | "node:stream" => Some("node:stream"),
4947        "stream/web" | "node:stream/web" => Some("node:stream/web"),
4948        "module" | "node:module" => Some("node:module"),
4949        "string_decoder" | "node:string_decoder" => Some("node:string_decoder"),
4950        "querystring" | "node:querystring" => Some("node:querystring"),
4951        "process" | "node:process" => Some("node:process"),
4952        "stream/promises" | "node:stream/promises" => Some("node:stream/promises"),
4953        "constants" | "node:constants" => Some("node:constants"),
4954        "tls" | "node:tls" => Some("node:tls"),
4955        "tty" | "node:tty" => Some("node:tty"),
4956        "zlib" | "node:zlib" => Some("node:zlib"),
4957        "perf_hooks" | "node:perf_hooks" => Some("node:perf_hooks"),
4958        "vm" | "node:vm" => Some("node:vm"),
4959        "v8" | "node:v8" => Some("node:v8"),
4960        "worker_threads" | "node:worker_threads" => Some("node:worker_threads"),
4961        _ => None,
4962    }
4963}
4964
4965fn is_network_specifier(spec: &str) -> bool {
4966    spec.starts_with("http://")
4967        || spec.starts_with("https://")
4968        || spec.starts_with("http:")
4969        || spec.starts_with("https:")
4970}
4971
4972fn is_bare_package_specifier(spec: &str) -> bool {
4973    if spec.starts_with("./")
4974        || spec.starts_with("../")
4975        || spec.starts_with('/')
4976        || spec.starts_with("file://")
4977        || spec.starts_with("node:")
4978    {
4979        return false;
4980    }
4981    !spec.contains(':')
4982}
4983
4984fn unsupported_module_specifier_message(spec: &str) -> String {
4985    if is_network_specifier(spec) {
4986        return format!("Network module imports are not supported in PiJS: {spec}");
4987    }
4988    if is_bare_package_specifier(spec) {
4989        return format!("Package module specifiers are not supported in PiJS: {spec}");
4990    }
4991    format!("Unsupported module specifier: {spec}")
4992}
4993
4994fn split_scoped_package(spec: &str) -> Option<(&str, &str)> {
4995    if !spec.starts_with('@') {
4996        return None;
4997    }
4998    let mut parts = spec.split('/');
4999    let scope = parts.next()?;
5000    let package = parts.next()?;
5001    Some((scope, package))
5002}
5003
5004fn package_scope(spec: &str) -> Option<&str> {
5005    split_scoped_package(spec).map(|(scope, _)| scope)
5006}
5007
5008fn read_extension_package_scope(root: &Path) -> Option<String> {
5009    let package_json = root.join("package.json");
5010    let raw = fs::read_to_string(package_json).ok()?;
5011    let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
5012    let name = parsed.get("name").and_then(serde_json::Value::as_str)?;
5013    let (scope, _) = split_scoped_package(name.trim())?;
5014    Some(scope.to_string())
5015}
5016
5017fn root_path_hint_tier(root: &Path) -> ProxyStubSourceTier {
5018    let normalized = root
5019        .to_string_lossy()
5020        .replace('\\', "/")
5021        .to_ascii_lowercase();
5022    let community_hints = [
5023        "/community/",
5024        "/npm/",
5025        "/agents-",
5026        "/third-party",
5027        "/third_party",
5028        "/plugins-community/",
5029    ];
5030    if community_hints.iter().any(|hint| normalized.contains(hint)) {
5031        return ProxyStubSourceTier::Community;
5032    }
5033
5034    let official_hints = ["/official-pi-mono/", "/plugins-official/", "/official/"];
5035    if official_hints.iter().any(|hint| normalized.contains(hint)) {
5036        return ProxyStubSourceTier::Official;
5037    }
5038
5039    ProxyStubSourceTier::Unknown
5040}
5041
5042fn classify_proxy_stub_source_tier(extension_id: &str, root: &Path) -> ProxyStubSourceTier {
5043    let id = extension_id.trim().to_ascii_lowercase();
5044    if id.starts_with("community/")
5045        || id.starts_with("npm/")
5046        || id.starts_with("agents-")
5047        || id.starts_with("plugins-community/")
5048        || id.starts_with("third-party")
5049        || id.starts_with("third_party")
5050    {
5051        return ProxyStubSourceTier::Community;
5052    }
5053
5054    if id.starts_with("plugins-official/") {
5055        return ProxyStubSourceTier::Official;
5056    }
5057
5058    root_path_hint_tier(root)
5059}
5060
5061fn resolve_extension_root_for_base<'a>(base: &str, roots: &'a [PathBuf]) -> Option<&'a PathBuf> {
5062    let base_path = Path::new(base);
5063    let canonical_base = crate::extensions::safe_canonicalize(base_path);
5064    roots
5065        .iter()
5066        .filter(|root| {
5067            let canonical_root = crate::extensions::safe_canonicalize(root);
5068            canonical_base.starts_with(&canonical_root)
5069        })
5070        .max_by_key(|root| root.components().count())
5071}
5072
5073fn is_proxy_blocklisted_package(spec: &str) -> bool {
5074    if spec.starts_with("node:") {
5075        return true;
5076    }
5077
5078    let top = spec.split('/').next().unwrap_or(spec);
5079    matches!(
5080        top,
5081        "fs" | "path"
5082            | "child_process"
5083            | "net"
5084            | "http"
5085            | "https"
5086            | "crypto"
5087            | "tls"
5088            | "dgram"
5089            | "dns"
5090            | "vm"
5091            | "worker_threads"
5092            | "cluster"
5093            | "module"
5094            | "os"
5095            | "process"
5096    )
5097}
5098
5099fn is_proxy_allowlisted_package(spec: &str) -> bool {
5100    const ALLOWLIST_SCOPES: &[&str] = &["@sourcegraph", "@marckrenn", "@aliou"];
5101    const ALLOWLIST_PACKAGES: &[&str] = &[
5102        "openai",
5103        "adm-zip",
5104        "linkedom",
5105        "p-limit",
5106        "unpdf",
5107        "node-pty",
5108        "chokidar",
5109        "jsdom",
5110        "turndown",
5111        "beautiful-mermaid",
5112    ];
5113
5114    if ALLOWLIST_PACKAGES.contains(&spec) {
5115        return true;
5116    }
5117
5118    if let Some((scope, package)) = split_scoped_package(spec) {
5119        if ALLOWLIST_SCOPES.contains(&scope) {
5120            return true;
5121        }
5122
5123        // Generic ecosystem package pattern (`@scope/pi-*`).
5124        if package.starts_with("pi-") {
5125            return true;
5126        }
5127    }
5128
5129    false
5130}
5131
5132fn capture_with_max_buffer(
5133    mut reader: impl std::io::Read,
5134    limit_bytes: usize,
5135    limit_exceeded: &std::sync::atomic::AtomicBool,
5136    stream_name: &'static str,
5137) -> (Vec<u8>, Option<String>) {
5138    let mut buf = Vec::new();
5139    let mut chunk = [0u8; 8192];
5140    let mut overflowed = false;
5141    loop {
5142        let n = match reader.read(&mut chunk) {
5143            Ok(n) => n,
5144            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
5145            Err(e) => return (buf, Some(e.to_string())),
5146        };
5147        if n == 0 {
5148            break;
5149        }
5150
5151        let remaining = limit_bytes.saturating_sub(buf.len());
5152        if remaining > 0 {
5153            let keep = remaining.min(n);
5154            buf.extend_from_slice(&chunk[..keep]);
5155        }
5156
5157        if n > remaining {
5158            overflowed = true;
5159            limit_exceeded.store(true, AtomicOrdering::Relaxed);
5160        }
5161    }
5162
5163    let error = overflowed.then(|| format!("ENOBUFS: {stream_name} maxBuffer length exceeded"));
5164    (buf, error)
5165}
5166
5167// Limit extension source size to prevent OOM/DoS during load.
5168const MAX_MODULE_SOURCE_BYTES: u64 = 1024 * 1024 * 1024;
5169
5170fn should_auto_stub_package(
5171    spec: &str,
5172    base: &str,
5173    extension_roots: &[PathBuf],
5174    extension_root_tiers: &HashMap<PathBuf, ProxyStubSourceTier>,
5175    extension_root_scopes: &HashMap<PathBuf, String>,
5176) -> bool {
5177    if !is_bare_package_specifier(spec) || is_proxy_blocklisted_package(spec) {
5178        return false;
5179    }
5180
5181    let (tier, root_for_scope) = resolve_extension_root_for_base(base, extension_roots).map_or(
5182        (ProxyStubSourceTier::Unknown, None),
5183        |root| {
5184            (
5185                extension_root_tiers
5186                    .get(root)
5187                    .copied()
5188                    .unwrap_or(ProxyStubSourceTier::Unknown),
5189                Some(root),
5190            )
5191        },
5192    );
5193
5194    let same_scope = if let Some(spec_scope) = package_scope(spec)
5195        && let Some(root) = root_for_scope
5196        && let Some(extension_scope) = extension_root_scopes.get(root)
5197    {
5198        extension_scope == spec_scope
5199    } else {
5200        false
5201    };
5202
5203    if is_proxy_allowlisted_package(spec) {
5204        return true;
5205    }
5206
5207    if same_scope {
5208        return true;
5209    }
5210
5211    // Aggressive repair mode (Pattern 4) is only enabled in AutoStrict. In that
5212    // mode, community and unknown extension sources are allowed to auto-stub any
5213    // unresolved non-blocklisted package so registration can proceed deterministically.
5214    //
5215    // Official first-party extensions keep a narrower posture: only curated
5216    // allowlist or same-scope packages are stubbed.
5217    tier != ProxyStubSourceTier::Official
5218}
5219
5220fn is_valid_js_export_name(name: &str) -> bool {
5221    let mut chars = name.chars();
5222    let Some(first) = chars.next() else {
5223        return false;
5224    };
5225    let is_start = first == '_' || first == '$' || first.is_ascii_alphabetic();
5226    if !is_start {
5227        return false;
5228    }
5229    chars.all(|c| c == '_' || c == '$' || c.is_ascii_alphanumeric())
5230}
5231
5232fn generate_proxy_stub_module(spec: &str, named_exports: &BTreeSet<String>) -> String {
5233    let spec_literal = serde_json::to_string(spec).unwrap_or_else(|_| "\"<unknown>\"".to_string());
5234    let mut source = format!(
5235        r"// Auto-generated npm proxy stub (Pattern 4) for {spec_literal}
5236const __pkg = {spec_literal};
5237const __handler = {{
5238  get(_target, prop) {{
5239    if (typeof prop === 'symbol') {{
5240      if (prop === Symbol.toPrimitive) return () => '';
5241      return undefined;
5242    }}
5243    if (prop === '__esModule') return true;
5244    if (prop === 'default') return __stub;
5245    if (prop === 'toString') return () => '';
5246    if (prop === 'valueOf') return () => '';
5247    if (prop === 'name') return __pkg;
5248    // Promise assimilation guard: do not pretend to be then-able.
5249    if (prop === 'then') return undefined;
5250    return __stub;
5251  }},
5252  apply() {{ return __stub; }},
5253  construct() {{ return __stub; }},
5254  has() {{ return false; }},
5255  ownKeys() {{ return []; }},
5256  getOwnPropertyDescriptor() {{
5257    return {{ configurable: true, enumerable: false }};
5258  }},
5259}};
5260const __stub = new Proxy(function __pijs_noop() {{}}, __handler);
5261"
5262    );
5263
5264    for name in named_exports {
5265        if name == "default" || name == "__esModule" || !is_valid_js_export_name(name) {
5266            continue;
5267        }
5268        let _ = writeln!(source, "export const {name} = __stub;");
5269    }
5270
5271    source.push_str("export default __stub;\n");
5272    source.push_str("export const __pijs_proxy_stub = __stub;\n");
5273    source.push_str("export const __esModule = true;\n");
5274    source
5275}
5276
5277fn builtin_specifier_aliases(spec: &str, canonical: &str) -> Vec<String> {
5278    let mut aliases = Vec::new();
5279    let mut seen = HashSet::new();
5280    let mut push_alias = |candidate: &str| {
5281        if candidate.is_empty() {
5282            return;
5283        }
5284        if seen.insert(candidate.to_string()) {
5285            aliases.push(candidate.to_string());
5286        }
5287    };
5288
5289    push_alias(spec);
5290    push_alias(canonical);
5291
5292    if let Some(bare) = spec.strip_prefix("node:") {
5293        push_alias(bare);
5294    }
5295    if let Some(bare) = canonical.strip_prefix("node:") {
5296        push_alias(bare);
5297    }
5298
5299    aliases
5300}
5301
5302fn extract_builtin_import_names(source: &str, spec: &str, canonical: &str) -> BTreeSet<String> {
5303    let mut names = BTreeSet::new();
5304    for alias in builtin_specifier_aliases(spec, canonical) {
5305        for name in extract_import_names(source, &alias) {
5306            if name == "default" || name == "__esModule" {
5307                continue;
5308            }
5309            if is_valid_js_export_name(&name) {
5310                names.insert(name);
5311            }
5312        }
5313    }
5314    names
5315}
5316
5317fn generate_builtin_compat_overlay_module(
5318    canonical: &str,
5319    named_exports: &BTreeSet<String>,
5320) -> String {
5321    let spec_literal =
5322        serde_json::to_string(canonical).unwrap_or_else(|_| "\"node:unknown\"".to_string());
5323    let mut source = format!(
5324        r"// Auto-generated Node builtin compatibility overlay for {canonical}
5325import * as __pijs_builtin_ns from {spec_literal};
5326const __pijs_builtin_default =
5327  __pijs_builtin_ns.default !== undefined ? __pijs_builtin_ns.default : __pijs_builtin_ns;
5328export default __pijs_builtin_default;
5329"
5330    );
5331
5332    for name in named_exports {
5333        if !is_valid_js_export_name(name) || name == "default" || name == "__esModule" {
5334            continue;
5335        }
5336        let _ = writeln!(
5337            source,
5338            "export const {name} = __pijs_builtin_ns.{name} !== undefined ? __pijs_builtin_ns.{name} : (__pijs_builtin_default && __pijs_builtin_default.{name});"
5339        );
5340    }
5341
5342    source.push_str("export const __esModule = true;\n");
5343    source
5344}
5345
5346fn builtin_overlay_module_key(base: &str, canonical: &str) -> String {
5347    let mut hasher = Sha256::new();
5348    hasher.update(base.as_bytes());
5349    let digest = format!("{:x}", hasher.finalize());
5350    let short = &digest[..16];
5351    format!("pijs-compat://builtin/{canonical}/{short}")
5352}
5353
5354/// Read up to 1MB of a source file for import extraction.
5355/// This prevents OOM vulnerabilities if a module path resolves to a massive file or /dev/zero.
5356fn read_source_for_import_extraction(path: &str) -> Option<String> {
5357    use std::io::Read;
5358    let file = std::fs::File::open(path).ok()?;
5359    let mut handle = file.take(1024 * 1024); // 1MB limit
5360    let mut buffer = Vec::new();
5361    handle.read_to_end(&mut buffer).ok()?;
5362    Some(String::from_utf8_lossy(&buffer).into_owned())
5363}
5364
5365fn maybe_register_builtin_compat_overlay(
5366    state: &mut PiJsModuleState,
5367    base: &str,
5368    spec: &str,
5369    canonical: &str,
5370) -> Option<String> {
5371    if !canonical.starts_with("node:") {
5372        return None;
5373    }
5374
5375    let source = read_source_for_import_extraction(base)?;
5376    let extracted_names = extract_builtin_import_names(&source, spec, canonical);
5377    if extracted_names.is_empty() {
5378        return None;
5379    }
5380
5381    let overlay_key = builtin_overlay_module_key(base, canonical);
5382    let needs_rebuild = state
5383        .dynamic_virtual_named_exports
5384        .get(&overlay_key)
5385        .is_none_or(|existing| existing != &extracted_names)
5386        || !state.dynamic_virtual_modules.contains_key(&overlay_key);
5387
5388    if needs_rebuild {
5389        state
5390            .dynamic_virtual_named_exports
5391            .insert(overlay_key.clone(), extracted_names.clone());
5392        let overlay = generate_builtin_compat_overlay_module(canonical, &extracted_names);
5393        state
5394            .dynamic_virtual_modules
5395            .insert(overlay_key.clone(), overlay);
5396        if state.compiled_sources.remove(&overlay_key).is_some() {
5397            state.module_cache_counters.invalidations =
5398                state.module_cache_counters.invalidations.saturating_add(1);
5399        }
5400    }
5401
5402    Some(overlay_key)
5403}
5404
5405impl JsModuleResolver for PiJsResolver {
5406    #[allow(clippy::too_many_lines)]
5407    fn resolve(&mut self, _ctx: &Ctx<'_>, base: &str, name: &str) -> rquickjs::Result<String> {
5408        let spec = name.trim();
5409        if spec.is_empty() {
5410            return Err(rquickjs::Error::new_resolving(base, name));
5411        }
5412
5413        // Alias bare Node.js builtins to their node: prefixed virtual modules.
5414        let canonical = canonical_node_builtin(spec).unwrap_or(spec);
5415        let compat_scan_mode = is_global_compat_scan_mode();
5416
5417        let repair_mode = {
5418            let mut state = self.state.borrow_mut();
5419            if state.dynamic_virtual_modules.contains_key(canonical)
5420                || state.static_virtual_modules.contains_key(canonical)
5421            {
5422                if compat_scan_mode
5423                    && let Some(overlay_key) =
5424                        maybe_register_builtin_compat_overlay(&mut state, base, spec, canonical)
5425                {
5426                    tracing::debug!(
5427                        event = "pijs.compat.builtin_overlay",
5428                        base = %base,
5429                        specifier = %spec,
5430                        canonical = %canonical,
5431                        overlay = %overlay_key,
5432                        "compat overlay for builtin named imports"
5433                    );
5434                    return Ok(overlay_key);
5435                }
5436                return Ok(canonical.to_string());
5437            }
5438            state.repair_mode
5439        };
5440
5441        let canonical_roots = {
5442            let state = self.state.borrow();
5443            state.canonical_extension_roots.clone()
5444        };
5445        if let Some(path) = resolve_module_path(base, spec, repair_mode, &canonical_roots) {
5446            // Canonicalize to collapse `.` / `..` segments and normalise
5447            // separators (Windows backslashes → forward slashes for QuickJS).
5448            let canonical = crate::extensions::safe_canonicalize(&path);
5449
5450            let is_safe = canonical_roots
5451                .iter()
5452                .any(|canonical_root| canonical.starts_with(canonical_root));
5453
5454            if !is_safe {
5455                tracing::warn!(
5456                    event = "pijs.resolve.escape",
5457                    base = %base,
5458                    specifier = %spec,
5459                    resolved = %canonical.display(),
5460                    "import resolved to path outside extension roots"
5461                );
5462                return Err(rquickjs::Error::new_resolving(base, name));
5463            }
5464
5465            return Ok(canonical.to_string_lossy().replace('\\', "/"));
5466        }
5467
5468        // Pattern 3 (bd-k5q5.8.4): monorepo sibling module stubs.
5469        // When a relative import escapes all known extension roots and
5470        // the repair mode is aggressive, generate a virtual stub module
5471        // containing no-op exports matching the import declaration.
5472        if spec.starts_with('.') && repair_mode.allows_aggressive() {
5473            let state = self.state.borrow();
5474            let canonical_roots = state.canonical_extension_roots.clone();
5475            drop(state);
5476
5477            if let Some(escaped_path) = detect_monorepo_escape(base, spec, &canonical_roots) {
5478                // Read the importing file to extract import names.
5479                let source = read_source_for_import_extraction(base).unwrap_or_default();
5480                let names = extract_import_names(&source, spec);
5481
5482                let stub = generate_monorepo_stub(&names);
5483                let virtual_key = format!("pijs-repair://monorepo/{}", escaped_path.display());
5484
5485                tracing::info!(
5486                    event = "pijs.repair.monorepo_escape",
5487                    base = %base,
5488                    specifier = %spec,
5489                    resolved = %escaped_path.display(),
5490                    exports = ?names,
5491                    "auto-repair: generated monorepo escape stub"
5492                );
5493
5494                // Record repair event.
5495                let state = self.state.borrow();
5496                if let Ok(mut events) = state.repair_events.lock() {
5497                    events.push(ExtensionRepairEvent {
5498                        extension_id: String::new(),
5499                        pattern: RepairPattern::MonorepoEscape,
5500                        original_error: format!(
5501                            "monorepo escape: {} from {base}",
5502                            escaped_path.display()
5503                        ),
5504                        repair_action: format!(
5505                            "generated stub with {} exports: {virtual_key}",
5506                            names.len()
5507                        ),
5508                        success: true,
5509                        timestamp_ms: 0,
5510                    });
5511                }
5512                drop(state);
5513
5514                // Register and return the virtual module.
5515                let mut state = self.state.borrow_mut();
5516                state
5517                    .dynamic_virtual_modules
5518                    .insert(virtual_key.clone(), stub);
5519                return Ok(virtual_key);
5520            }
5521        }
5522
5523        // Pattern 4 (bd-k5q5.8.5): proxy-based stubs for allowlisted npm deps.
5524        // This fires in aggressive mode, and also in compatibility-scan mode
5525        // (ext-conformance / PI_EXT_COMPAT_SCAN) so corpus runs can continue
5526        // past optional or non-essential package holes deterministically.
5527        // Blocklisted/system packages are never stubbed. Existing hand-written
5528        // virtual modules continue to win because we only reach this branch
5529        // after the initial lookup misses.
5530        if is_bare_package_specifier(spec) && (repair_mode.allows_aggressive() || compat_scan_mode)
5531        {
5532            let state = self.state.borrow();
5533            let roots = state.extension_roots.clone();
5534            let tiers = state.extension_root_tiers.clone();
5535            let scopes = state.extension_root_scopes.clone();
5536            drop(state);
5537
5538            if should_auto_stub_package(spec, base, &roots, &tiers, &scopes) {
5539                tracing::info!(
5540                    event = "pijs.repair.missing_npm_dep",
5541                    base = %base,
5542                    specifier = %spec,
5543                    "auto-repair: generated proxy stub for missing npm dependency"
5544                );
5545
5546                let source = read_source_for_import_extraction(base).unwrap_or_default();
5547                let extracted_names = extract_import_names(&source, spec);
5548                let mut state = self.state.borrow_mut();
5549                let entry_key = spec.to_string();
5550                let mut exports_changed = false;
5551                {
5552                    let exports = state
5553                        .dynamic_virtual_named_exports
5554                        .entry(entry_key.clone())
5555                        .or_default();
5556                    for name in extracted_names {
5557                        exports_changed |= exports.insert(name);
5558                    }
5559                }
5560
5561                let export_names = state
5562                    .dynamic_virtual_named_exports
5563                    .get(&entry_key)
5564                    .cloned()
5565                    .unwrap_or_default();
5566                if exports_changed || !state.dynamic_virtual_modules.contains_key(spec) {
5567                    let stub = generate_proxy_stub_module(spec, &export_names);
5568                    state.dynamic_virtual_modules.insert(entry_key, stub);
5569                    if state.compiled_sources.remove(spec).is_some() {
5570                        state.module_cache_counters.invalidations =
5571                            state.module_cache_counters.invalidations.saturating_add(1);
5572                    }
5573                }
5574
5575                if let Ok(mut events) = state.repair_events.lock() {
5576                    events.push(ExtensionRepairEvent {
5577                        extension_id: String::new(),
5578                        pattern: RepairPattern::MissingNpmDep,
5579                        original_error: format!("missing npm dependency: {spec} from {base}"),
5580                        repair_action: format!(
5581                            "generated proxy stub for package '{spec}' with {} named export(s)",
5582                            export_names.len()
5583                        ),
5584                        success: true,
5585                        timestamp_ms: 0,
5586                    });
5587                }
5588
5589                return Ok(spec.to_string());
5590            }
5591        }
5592
5593        let canonical_roots = {
5594            let state = self.state.borrow();
5595            state.canonical_extension_roots.clone()
5596        };
5597        if let Some(escaped_path) = detect_monorepo_escape(base, spec, &canonical_roots) {
5598            return Err(rquickjs::Error::new_resolving_message(
5599                base,
5600                name,
5601                format!(
5602                    "Module path escapes extension root: {}",
5603                    escaped_path.display()
5604                ),
5605            ));
5606        }
5607
5608        Err(rquickjs::Error::new_resolving_message(
5609            base,
5610            name,
5611            unsupported_module_specifier_message(spec),
5612        ))
5613    }
5614}
5615
5616#[derive(Clone, Debug)]
5617struct PiJsLoader {
5618    state: Rc<RefCell<PiJsModuleState>>,
5619}
5620
5621impl JsModuleLoader for PiJsLoader {
5622    fn load<'js>(
5623        &mut self,
5624        ctx: &Ctx<'js>,
5625        name: &str,
5626    ) -> rquickjs::Result<Module<'js, JsModuleDeclared>> {
5627        let source = {
5628            let mut state = self.state.borrow_mut();
5629            load_compiled_module_source(&mut state, name)?
5630        };
5631
5632        Module::declare(ctx.clone(), name, source)
5633    }
5634}
5635
5636fn compile_module_source(
5637    static_virtual_modules: &HashMap<String, String>,
5638    dynamic_virtual_modules: &HashMap<String, String>,
5639    name: &str,
5640) -> rquickjs::Result<Vec<u8>> {
5641    if let Some(source) = dynamic_virtual_modules
5642        .get(name)
5643        .or_else(|| static_virtual_modules.get(name))
5644    {
5645        return Ok(prefix_import_meta_url(name, source));
5646    }
5647
5648    let path = Path::new(name);
5649    if !path.is_file() {
5650        return Err(rquickjs::Error::new_loading_message(
5651            name,
5652            "Module is not a file",
5653        ));
5654    }
5655
5656    let metadata = fs::metadata(path)
5657        .map_err(|err| rquickjs::Error::new_loading_message(name, format!("metadata: {err}")))?;
5658    if metadata.len() > MAX_MODULE_SOURCE_BYTES {
5659        return Err(rquickjs::Error::new_loading_message(
5660            name,
5661            format!(
5662                "Module source exceeds size limit: {} > {}",
5663                metadata.len(),
5664                MAX_MODULE_SOURCE_BYTES
5665            ),
5666        ));
5667    }
5668
5669    let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
5670    let file = fs::File::open(path)
5671        .map_err(|err| rquickjs::Error::new_loading_message(name, format!("open: {err}")))?;
5672    let mut handle = std::io::Read::take(file, MAX_MODULE_SOURCE_BYTES + 1);
5673    let mut raw = String::new();
5674    std::io::Read::read_to_string(&mut handle, &mut raw)
5675        .map_err(|err| rquickjs::Error::new_loading_message(name, format!("read: {err}")))?;
5676
5677    if raw.len() as u64 > MAX_MODULE_SOURCE_BYTES {
5678        return Err(rquickjs::Error::new_loading_message(
5679            name,
5680            format!(
5681                "Module source exceeds size limit: {} > {}",
5682                raw.len(),
5683                MAX_MODULE_SOURCE_BYTES
5684            ),
5685        ));
5686    }
5687
5688    let compiled = match extension {
5689        "ts" | "tsx" | "cts" | "mts" | "jsx" => {
5690            let transpiled = transpile_typescript_module(&raw, name).map_err(|message| {
5691                rquickjs::Error::new_loading_message(name, format!("transpile: {message}"))
5692            })?;
5693            rewrite_legacy_private_identifiers(&maybe_cjs_to_esm(&transpiled))
5694        }
5695        "js" | "mjs" | "cjs" => rewrite_legacy_private_identifiers(&maybe_cjs_to_esm(&raw)),
5696        "json" => json_module_to_esm(&raw, name).map_err(|message| {
5697            rquickjs::Error::new_loading_message(name, format!("json: {message}"))
5698        })?,
5699        other => {
5700            return Err(rquickjs::Error::new_loading_message(
5701                name,
5702                format!("Unsupported module extension: {other}"),
5703            ));
5704        }
5705    };
5706
5707    Ok(prefix_import_meta_url(name, &compiled))
5708}
5709
5710fn module_cache_key(
5711    static_virtual_modules: &HashMap<String, String>,
5712    dynamic_virtual_modules: &HashMap<String, String>,
5713    name: &str,
5714) -> Option<String> {
5715    if let Some(source) = dynamic_virtual_modules
5716        .get(name)
5717        .or_else(|| static_virtual_modules.get(name))
5718    {
5719        let mut hasher = Sha256::new();
5720        hasher.update(b"virtual\0");
5721        hasher.update(name.as_bytes());
5722        hasher.update(b"\0");
5723        hasher.update(source.as_bytes());
5724        return Some(format!("v:{:x}", hasher.finalize()));
5725    }
5726
5727    let path = Path::new(name);
5728    if !path.is_file() {
5729        return None;
5730    }
5731
5732    let metadata = fs::metadata(path).ok()?;
5733    let modified_nanos = metadata
5734        .modified()
5735        .ok()
5736        .and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
5737        .map_or(0, |duration| duration.as_nanos());
5738
5739    Some(format!("f:{name}:{}:{modified_nanos}", metadata.len()))
5740}
5741
5742// ============================================================================
5743// Persistent disk cache for transpiled module sources (bd-3ar8v.4.16)
5744// ============================================================================
5745
5746/// Build the on-disk path for a cached transpiled module.
5747///
5748/// Layout: `{cache_dir}/{first_2_hex}/{full_hex}.js` to shard entries and
5749/// avoid a single flat directory with thousands of files.
5750fn disk_cache_path(cache_dir: &Path, cache_key: &str) -> PathBuf {
5751    let mut hasher = Sha256::new();
5752    hasher.update(cache_key.as_bytes());
5753    let hex = format!("{:x}", hasher.finalize());
5754    let prefix = &hex[..2];
5755    cache_dir.join(prefix).join(format!("{hex}.js"))
5756}
5757
5758/// Attempt to load a transpiled module source from persistent disk cache.
5759fn try_load_from_disk_cache(cache_dir: &Path, cache_key: &str) -> Option<Vec<u8>> {
5760    let path = disk_cache_path(cache_dir, cache_key);
5761    fs::read(path).ok()
5762}
5763
5764/// Persist a transpiled module source to the disk cache (best-effort).
5765fn store_to_disk_cache(cache_dir: &Path, cache_key: &str, source: &[u8]) {
5766    let path = disk_cache_path(cache_dir, cache_key);
5767    if let Some(parent) = path.parent() {
5768        if let Err(err) = fs::create_dir_all(parent) {
5769            tracing::debug!(event = "pijs.module_cache.disk.mkdir_failed", path = %parent.display(), %err);
5770            return;
5771        }
5772    }
5773
5774    let temp_path = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4().simple()));
5775    if let Err(err) = fs::write(&temp_path, source) {
5776        tracing::debug!(event = "pijs.module_cache.disk.write_failed", path = %temp_path.display(), %err);
5777        return;
5778    }
5779
5780    if let Err(err) = fs::rename(&temp_path, &path) {
5781        tracing::debug!(event = "pijs.module_cache.disk.rename_failed", from = %temp_path.display(), to = %path.display(), %err);
5782        let _ = fs::remove_file(&temp_path);
5783    }
5784}
5785
5786fn load_compiled_module_source(
5787    state: &mut PiJsModuleState,
5788    name: &str,
5789) -> rquickjs::Result<Vec<u8>> {
5790    let cache_key = module_cache_key(
5791        &state.static_virtual_modules,
5792        &state.dynamic_virtual_modules,
5793        name,
5794    );
5795
5796    // 1. Check in-memory cache — Arc clone is O(1) atomic increment.
5797    if let Some(cached) = state.compiled_sources.get(name) {
5798        if cached.cache_key == cache_key {
5799            state.module_cache_counters.hits = state.module_cache_counters.hits.saturating_add(1);
5800            return Ok(cached.source.to_vec());
5801        }
5802
5803        state.module_cache_counters.invalidations =
5804            state.module_cache_counters.invalidations.saturating_add(1);
5805    }
5806
5807    // 2. Check persistent disk cache.
5808    if let Some(cache_key_str) = cache_key.as_deref()
5809        && let Some(cache_dir) = state.disk_cache_dir.as_deref()
5810        && let Some(disk_cached) = try_load_from_disk_cache(cache_dir, cache_key_str)
5811    {
5812        state.module_cache_counters.disk_hits =
5813            state.module_cache_counters.disk_hits.saturating_add(1);
5814        let source: Arc<[u8]> = disk_cached.into();
5815        state.compiled_sources.insert(
5816            name.to_string(),
5817            CompiledModuleCacheEntry {
5818                cache_key,
5819                source: Arc::clone(&source),
5820            },
5821        );
5822        return Ok(source.to_vec());
5823    }
5824
5825    // 3. Compile from source (SWC transpile + CJS->ESM rewrite).
5826    state.module_cache_counters.misses = state.module_cache_counters.misses.saturating_add(1);
5827    let compiled = compile_module_source(
5828        &state.static_virtual_modules,
5829        &state.dynamic_virtual_modules,
5830        name,
5831    )?;
5832    let source: Arc<[u8]> = compiled.into();
5833    state.compiled_sources.insert(
5834        name.to_string(),
5835        CompiledModuleCacheEntry {
5836            cache_key: cache_key.clone(),
5837            source: Arc::clone(&source),
5838        },
5839    );
5840
5841    // 4. Persist to disk cache for next session.
5842    if let Some(cache_key_str) = cache_key.as_deref()
5843        && let Some(cache_dir) = state.disk_cache_dir.as_deref()
5844    {
5845        store_to_disk_cache(cache_dir, cache_key_str, &source);
5846    }
5847
5848    Ok(source.to_vec())
5849}
5850
5851// ============================================================================
5852// Warm Isolate Pool (bd-3ar8v.4.16)
5853// ============================================================================
5854
5855/// Configuration holder and factory for pre-warmed JS extension runtimes.
5856///
5857/// Since `PiJsRuntime` uses `Rc` internally and cannot cross thread
5858/// boundaries, the pool does not hold live runtime instances. Instead, it
5859/// provides a factory that produces pre-configured `PiJsRuntimeConfig` values,
5860/// and runtimes can be returned to a "warm" state via
5861/// [`PiJsRuntime::reset_transient_state`].
5862///
5863/// # Lifecycle
5864///
5865/// 1. Create pool with desired config via [`WarmIsolatePool::new`].
5866/// 2. Call [`make_config`](WarmIsolatePool::make_config) to get a pre-warmed
5867///    `PiJsRuntimeConfig` for each runtime thread.
5868/// 3. After use, call [`PiJsRuntime::reset_transient_state`] to return the
5869///    runtime to a clean state (keeping the transpiled source cache).
5870#[derive(Debug, Clone)]
5871pub struct WarmIsolatePool {
5872    /// Template configuration for new runtimes.
5873    template: PiJsRuntimeConfig,
5874    /// Number of runtimes created from this pool.
5875    created_count: Arc<AtomicU64>,
5876    /// Number of resets performed.
5877    reset_count: Arc<AtomicU64>,
5878}
5879
5880impl WarmIsolatePool {
5881    /// Create a new warm isolate pool with the given template config.
5882    pub fn new(template: PiJsRuntimeConfig) -> Self {
5883        Self {
5884            template,
5885            created_count: Arc::new(AtomicU64::new(0)),
5886            reset_count: Arc::new(AtomicU64::new(0)),
5887        }
5888    }
5889
5890    /// Create a pre-configured `PiJsRuntimeConfig` with shared pool state.
5891    pub fn make_config(&self) -> PiJsRuntimeConfig {
5892        self.created_count.fetch_add(1, AtomicOrdering::Relaxed);
5893        self.template.clone()
5894    }
5895
5896    /// Record that a runtime was reset for reuse.
5897    pub fn record_reset(&self) {
5898        self.reset_count.fetch_add(1, AtomicOrdering::Relaxed);
5899    }
5900
5901    /// Number of runtimes created from this pool.
5902    pub fn created_count(&self) -> u64 {
5903        self.created_count.load(AtomicOrdering::Relaxed)
5904    }
5905
5906    /// Number of runtime resets performed.
5907    pub fn reset_count(&self) -> u64 {
5908        self.reset_count.load(AtomicOrdering::Relaxed)
5909    }
5910}
5911
5912impl Default for WarmIsolatePool {
5913    fn default() -> Self {
5914        Self::new(PiJsRuntimeConfig::default())
5915    }
5916}
5917
5918fn prefix_import_meta_url(module_name: &str, body: &str) -> Vec<u8> {
5919    let url = if module_name.starts_with('/') {
5920        format!("file://{module_name}")
5921    } else if module_name.starts_with("file://") {
5922        module_name.to_string()
5923    } else if module_name.len() > 2
5924        && module_name.as_bytes()[1] == b':'
5925        && (module_name.as_bytes()[2] == b'/' || module_name.as_bytes()[2] == b'\\')
5926    {
5927        // Windows absolute path: `C:/Users/...` or `C:\Users\...`
5928        format!("file:///{module_name}")
5929    } else {
5930        format!("pi://{module_name}")
5931    };
5932    let url_literal = serde_json::to_string(&url).unwrap_or_else(|_| "\"\"".to_string());
5933    format!("import.meta.url = {url_literal};\n{body}").into_bytes()
5934}
5935
5936#[allow(clippy::too_many_lines)]
5937fn resolve_module_path(
5938    base: &str,
5939    specifier: &str,
5940    repair_mode: RepairMode,
5941    canonical_roots: &[PathBuf],
5942) -> Option<PathBuf> {
5943    let specifier = specifier.trim();
5944    if specifier.is_empty() {
5945        return None;
5946    }
5947
5948    if let Some(path) = specifier.strip_prefix("file://") {
5949        if canonical_roots.is_empty() {
5950            return None;
5951        }
5952        let path_buf = PathBuf::from(path);
5953        let canonical = crate::extensions::safe_canonicalize(&path_buf);
5954        let allowed = canonical_roots
5955            .iter()
5956            .any(|canonical_root| canonical.starts_with(canonical_root));
5957        if !allowed {
5958            tracing::warn!(
5959                event = "pijs.resolve.monotonicity_violation",
5960                original = %path_buf.display(),
5961                "resolution blocked: file:// path escapes extension root"
5962            );
5963            return None;
5964        }
5965
5966        let resolved = resolve_existing_file(path_buf)?;
5967
5968        // Second check after resolution (in case of symlinks)
5969        let canonical_resolved = crate::extensions::safe_canonicalize(&resolved);
5970        let allowed_resolved = canonical_roots
5971            .iter()
5972            .any(|canonical_root| canonical_resolved.starts_with(canonical_root));
5973
5974        if !allowed_resolved {
5975            tracing::warn!(
5976                event = "pijs.resolve.monotonicity_violation",
5977                resolved = %resolved.display(),
5978                "resolution blocked: resolved file:// path escapes extension root"
5979            );
5980            return None;
5981        }
5982        return Some(resolved);
5983    }
5984
5985    let path = if specifier.starts_with('/') {
5986        PathBuf::from(specifier)
5987    } else if specifier.len() > 2
5988        && specifier.as_bytes()[1] == b':'
5989        && (specifier.as_bytes()[2] == b'/' || specifier.as_bytes()[2] == b'\\')
5990    {
5991        // Windows absolute path: `C:/Users/...` or `C:\Users\...`
5992        PathBuf::from(specifier)
5993    } else if specifier.starts_with('.') {
5994        let base_path = Path::new(base);
5995        let base_dir = base_path.parent()?;
5996        base_dir.join(specifier)
5997    } else {
5998        return None;
5999    };
6000
6001    // SEC-FIX: Enforce scope monotonicity before checking file existence (bd-k5q5.9.1.3).
6002    // This prevents directory traversal probes from revealing existence of files
6003    // outside the extension root (e.g. `../../../../etc/passwd`).
6004    if canonical_roots.is_empty() {
6005        return None;
6006    }
6007    let canonical = crate::extensions::safe_canonicalize(&path);
6008    let allowed = canonical_roots
6009        .iter()
6010        .any(|canonical_root| canonical.starts_with(canonical_root));
6011
6012    if !allowed {
6013        return None;
6014    }
6015
6016    if let Some(resolved) = resolve_existing_module_candidate(path.clone()) {
6017        // SEC-FIX: Enforce scope monotonicity on the *resolved* path (bd-k5q5.9.1.3).
6018        // This handles cases where `resolve_existing_module_candidate` finds a file
6019        // (e.g. .ts sibling) that is a symlink escaping the root, even if the base path was safe.
6020        if canonical_roots.is_empty() {
6021            return None;
6022        }
6023        let canonical_resolved = crate::extensions::safe_canonicalize(&resolved);
6024        let allowed = canonical_roots
6025            .iter()
6026            .any(|canonical_root| canonical_resolved.starts_with(canonical_root));
6027
6028        if !allowed {
6029            tracing::warn!(
6030                event = "pijs.resolve.monotonicity_violation",
6031                original = %path.display(),
6032                resolved = %resolved.display(),
6033                "resolution blocked: resolved path escapes extension root"
6034            );
6035            return None;
6036        }
6037        return Some(resolved);
6038    }
6039
6040    // Pattern 1 (bd-k5q5.8.2): dist/ → src/ fallback for missing build artifacts.
6041    // Gated by repair_mode (bd-k5q5.9.1.2): only static-analysis operations
6042    // (path existence checks) happen here — broken code is never executed.
6043    let fallback_resolved = if repair_mode.should_apply() {
6044        try_dist_to_src_fallback(&path)
6045    } else {
6046        if repair_mode == RepairMode::Suggest {
6047            // Log what would have been repaired without applying it.
6048            if let Some(resolved) = try_dist_to_src_fallback(&path) {
6049                tracing::info!(
6050                    event = "pijs.repair.suggest",
6051                    pattern = "dist_to_src",
6052                    original = %path.display(),
6053                    resolved = %resolved.display(),
6054                    "repair suggestion: would resolve dist/ → src/ (mode=suggest)"
6055                );
6056            }
6057        }
6058        None
6059    };
6060
6061    if let Some(resolved) = fallback_resolved {
6062        let canonical_resolved = crate::extensions::safe_canonicalize(&resolved);
6063        let allowed = canonical_roots
6064            .iter()
6065            .any(|canonical_root| canonical_resolved.starts_with(canonical_root));
6066
6067        if !allowed {
6068            tracing::warn!(
6069                event = "pijs.resolve.monotonicity_violation",
6070                original = %path.display(),
6071                resolved = %resolved.display(),
6072                "resolution blocked: repaired path escapes extension root"
6073            );
6074            return None;
6075        }
6076        return Some(resolved);
6077    }
6078
6079    None
6080}
6081
6082/// Auto-repair Pattern 1: when a module path contains `/dist/` and the file
6083/// does not exist, try the equivalent path under `/src/` with `.ts`/`.tsx`
6084/// extensions.  This handles the common case where an npm-published extension
6085/// references compiled output that was never built.
6086fn try_dist_to_src_fallback(path: &Path) -> Option<PathBuf> {
6087    let path_str = path.to_string_lossy();
6088
6089    // Normalize to handle both Windows backslashes and Unix forward slashes.
6090    let normalized = path_str.replace('\\', "/");
6091    let idx = normalized.find("/dist/")?;
6092
6093    // The extension root is the directory containing /dist/.
6094    let extension_root = PathBuf::from(&path_str[..idx]);
6095
6096    let sep = std::path::MAIN_SEPARATOR;
6097    let src_path = format!("{}{sep}src{sep}{}", &path_str[..idx], &path_str[idx + 6..]);
6098
6099    let candidate = PathBuf::from(&src_path);
6100
6101    if let Some(resolved) = resolve_existing_module_candidate(candidate) {
6102        // Privilege monotonicity check (bd-k5q5.9.1.3): ensure the
6103        // resolved path stays within the extension root.
6104        let verdict = verify_repair_monotonicity(&extension_root, path, &resolved);
6105        if !verdict.is_safe() {
6106            tracing::warn!(
6107                event = "pijs.repair.monotonicity_violation",
6108                original = %path_str,
6109                resolved = %resolved.display(),
6110                verdict = ?verdict,
6111                "repair blocked: resolved path escapes extension root"
6112            );
6113            return None;
6114        }
6115
6116        // Structural validation gate (bd-k5q5.9.5.1): verify the
6117        // resolved file is parseable before accepting the repair.
6118        let structural = validate_repaired_artifact(&resolved);
6119        if !structural.is_valid() {
6120            tracing::warn!(
6121                event = "pijs.repair.structural_validation_failed",
6122                original = %path_str,
6123                resolved = %resolved.display(),
6124                verdict = %structural,
6125                "repair blocked: resolved artifact failed structural validation"
6126            );
6127            return None;
6128        }
6129
6130        tracing::info!(
6131            event = "pijs.repair.dist_to_src",
6132            original = %path_str,
6133            resolved = %resolved.display(),
6134            "auto-repair: resolved dist/ → src/ fallback"
6135        );
6136        return Some(resolved);
6137    }
6138
6139    None
6140}
6141
6142fn resolve_existing_file(path: PathBuf) -> Option<PathBuf> {
6143    if path.is_file() {
6144        return Some(path);
6145    }
6146    None
6147}
6148
6149fn resolve_existing_module_candidate(path: PathBuf) -> Option<PathBuf> {
6150    if path.is_file() {
6151        return Some(path);
6152    }
6153
6154    if path.is_dir() {
6155        for candidate in [
6156            "index.ts",
6157            "index.tsx",
6158            "index.jsx",
6159            "index.cts",
6160            "index.mts",
6161            "index.js",
6162            "index.mjs",
6163            "index.cjs",
6164            "index.json",
6165        ] {
6166            let full = path.join(candidate);
6167            if full.is_file() {
6168                return Some(full);
6169            }
6170        }
6171        return None;
6172    }
6173
6174    let extension = path.extension().and_then(|ext| ext.to_str());
6175    match extension {
6176        Some("js" | "mjs" | "cjs" | "jsx") => {
6177            for ext in ["ts", "tsx", "cts", "mts"] {
6178                let fallback = path.with_extension(ext);
6179                if fallback.is_file() {
6180                    return Some(fallback);
6181                }
6182            }
6183        }
6184        None => {
6185            for ext in ["ts", "tsx", "jsx", "cts", "mts", "js", "mjs", "cjs", "json"] {
6186                let candidate = path.with_extension(ext);
6187                if candidate.is_file() {
6188                    return Some(candidate);
6189                }
6190            }
6191        }
6192        _ => {}
6193    }
6194
6195    None
6196}
6197
6198// ─── Pattern 3 (bd-k5q5.8.4): Monorepo Sibling Module Stubs ─────────────────
6199
6200/// Regex that captures named imports from an ESM import statement:
6201///   `import { a, b, type C } from "specifier"`
6202///
6203/// Group 1: the names inside braces.
6204static IMPORT_NAMES_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6205
6206fn import_names_regex() -> &'static regex::Regex {
6207    IMPORT_NAMES_RE.get_or_init(|| {
6208        regex::Regex::new(r#"(?ms)import\s+(?:[^{};]*?,\s*)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#)
6209            .expect("import names regex")
6210    })
6211}
6212
6213/// Regex for CJS destructured require:
6214///   `const { a, b } = require("specifier")`
6215static REQUIRE_DESTRUCTURE_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6216
6217fn require_destructure_regex() -> &'static regex::Regex {
6218    REQUIRE_DESTRUCTURE_RE.get_or_init(|| {
6219        regex::Regex::new(
6220            r#"(?m)(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]"#,
6221        )
6222        .expect("require destructure regex")
6223    })
6224}
6225
6226/// Detect if a relative specifier resolves to a path outside all known
6227/// extension roots.  Returns the resolved absolute path if it's an escape.
6228fn detect_monorepo_escape(
6229    base: &str,
6230    specifier: &str,
6231    canonical_extension_roots: &[PathBuf],
6232) -> Option<PathBuf> {
6233    if !specifier.starts_with('.') {
6234        return None;
6235    }
6236    let base_dir = Path::new(base).parent()?;
6237    let resolved = base_dir.join(specifier);
6238
6239    // Safely canonicalize resolving all .. and . segments logically
6240    // if the path doesn't exist on disk, avoiding path traversal bypasses.
6241    let effective = crate::extensions::safe_canonicalize(&resolved);
6242
6243    for canonical_root in canonical_extension_roots {
6244        if effective.starts_with(canonical_root) {
6245            return None; // Within an extension root — not an escape
6246        }
6247    }
6248
6249    Some(resolved)
6250}
6251
6252/// Extract the named imports that a source file pulls from a given specifier.
6253///
6254/// Handles both ESM `import { x, y } from "spec"` and CJS
6255/// `const { x, y } = require("spec")`.  Type-only imports (`type Foo`)
6256/// are excluded because TypeScript erases them.
6257pub fn extract_import_names(source: &str, specifier: &str) -> Vec<String> {
6258    let mut names = Vec::new();
6259    let re_esm = import_names_regex();
6260    let re_cjs = require_destructure_regex();
6261
6262    for cap in re_esm.captures_iter(source) {
6263        let spec_in_source = &cap[2];
6264        if spec_in_source != specifier {
6265            continue;
6266        }
6267        parse_import_list(&cap[1], &mut names);
6268    }
6269
6270    for cap in re_cjs.captures_iter(source) {
6271        let spec_in_source = &cap[2];
6272        if spec_in_source != specifier {
6273            continue;
6274        }
6275        parse_import_list(&cap[1], &mut names);
6276    }
6277
6278    names.sort();
6279    names.dedup();
6280    names
6281}
6282
6283/// Parse a comma-separated list of import names, skipping `type`-only imports.
6284fn parse_import_list(raw: &str, out: &mut Vec<String>) {
6285    for token in raw.split(',') {
6286        let token = token.trim();
6287        if token.is_empty() {
6288            continue;
6289        }
6290        // Skip `type Foo` (TypeScript type-only import)
6291        if token.starts_with("type ") || token.starts_with("type\t") {
6292            continue;
6293        }
6294        // Handle `X as Y` — we export the original name `X`.
6295        let name = token.split_whitespace().next().unwrap_or(token).trim();
6296        if !name.is_empty() {
6297            out.push(name.to_string());
6298        }
6299    }
6300}
6301
6302/// Generate a synthetic ESM stub module that exports no-op values for each
6303/// requested name.  Uses simple heuristics to choose the export shape:
6304///
6305/// - Names starting with `is`/`has`/`check` → `() => false`
6306/// - Names starting with `get`/`detect`/`find`/`create`/`make` → `() => ({})`
6307/// - Names starting with `set`/`play`/`send`/`run`/`do`/`emit` → `() => {}`
6308/// - `ALL_CAPS` names → `[]` (constants are often arrays)
6309/// - Names starting with uppercase → `class Name {}` (likely class/type)
6310/// - Everything else → `() => {}`
6311pub fn generate_monorepo_stub(names: &[String]) -> String {
6312    let mut lines = Vec::with_capacity(names.len() + 1);
6313    lines.push("// Auto-generated monorepo escape stub (Pattern 3)".to_string());
6314
6315    for name in names {
6316        if !is_valid_js_export_name(name) {
6317            continue;
6318        }
6319
6320        let export = if name == "default" {
6321            "export default () => {};".to_string()
6322        } else if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && !name.is_empty() {
6323            // ALL_CAPS constant
6324            format!("export const {name} = [];")
6325        } else if name.starts_with("is") || name.starts_with("has") || name.starts_with("check") {
6326            format!("export const {name} = () => false;")
6327        } else if name.starts_with("get")
6328            || name.starts_with("detect")
6329            || name.starts_with("find")
6330            || name.starts_with("create")
6331            || name.starts_with("make")
6332        {
6333            format!("export const {name} = () => ({{}});")
6334        } else if name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
6335            // Likely a class or type — export as class
6336            format!("export class {name} {{}}")
6337        } else {
6338            // Generic function stub
6339            format!("export const {name} = () => {{}};")
6340        };
6341        lines.push(export);
6342    }
6343
6344    lines.join("\n")
6345}
6346
6347#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
6348enum DeclState {
6349    #[default]
6350    None,
6351    AfterExport,
6352    AfterAsync,
6353    AfterDeclKeyword,
6354}
6355
6356#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
6357enum BindingLexMode {
6358    #[default]
6359    Normal,
6360    SingleQuoted,
6361    DoubleQuoted,
6362    Template,
6363    LineComment,
6364    BlockComment,
6365}
6366
6367#[derive(Debug, Default)]
6368struct BindingScanner {
6369    mode: BindingLexMode,
6370    escaped: bool,
6371    state: DeclState,
6372    brace_depth: usize,
6373}
6374
6375impl BindingScanner {
6376    fn consume_context(&mut self, b: u8, next: Option<u8>, index: &mut usize) -> bool {
6377        match self.mode {
6378            BindingLexMode::Normal => false,
6379            BindingLexMode::LineComment => {
6380                if b == b'\n' {
6381                    self.mode = BindingLexMode::Normal;
6382                }
6383                *index += 1;
6384                true
6385            }
6386            BindingLexMode::BlockComment => {
6387                if b == b'*' && next == Some(b'/') {
6388                    *index += 2;
6389                    self.mode = BindingLexMode::Normal;
6390                } else {
6391                    *index += 1;
6392                }
6393                true
6394            }
6395            BindingLexMode::SingleQuoted => {
6396                consume_quoted_context(&mut self.mode, &mut self.escaped, b, b'\'');
6397                *index += 1;
6398                true
6399            }
6400            BindingLexMode::DoubleQuoted => {
6401                consume_quoted_context(&mut self.mode, &mut self.escaped, b, b'"');
6402                *index += 1;
6403                true
6404            }
6405            BindingLexMode::Template => {
6406                consume_quoted_context(&mut self.mode, &mut self.escaped, b, b'`');
6407                *index += 1;
6408                true
6409            }
6410        }
6411    }
6412
6413    fn enter_context(&mut self, b: u8, next: Option<u8>, index: &mut usize) -> bool {
6414        if b == b'/' && next == Some(b'/') {
6415            self.mode = BindingLexMode::LineComment;
6416            *index += 2;
6417            return true;
6418        }
6419
6420        if b == b'/' && next == Some(b'*') {
6421            self.mode = BindingLexMode::BlockComment;
6422            *index += 2;
6423            return true;
6424        }
6425
6426        if b == b'\'' {
6427            self.mode = BindingLexMode::SingleQuoted;
6428            *index += 1;
6429            return true;
6430        }
6431
6432        if b == b'"' {
6433            self.mode = BindingLexMode::DoubleQuoted;
6434            *index += 1;
6435            return true;
6436        }
6437
6438        if b == b'`' {
6439            self.mode = BindingLexMode::Template;
6440            *index += 1;
6441            return true;
6442        }
6443
6444        false
6445    }
6446
6447    fn advance_state(&mut self, token: &str, name: &str) -> bool {
6448        self.state = match self.state {
6449            DeclState::None => match token {
6450                "export" => DeclState::AfterExport,
6451                "const" | "let" | "var" | "function" | "class" => DeclState::AfterDeclKeyword,
6452                _ => DeclState::None,
6453            },
6454            DeclState::AfterExport => match token {
6455                "const" | "let" | "var" | "function" | "class" => DeclState::AfterDeclKeyword,
6456                "async" => DeclState::AfterAsync,
6457                _ => DeclState::None,
6458            },
6459            DeclState::AfterAsync => {
6460                if token == "function" {
6461                    DeclState::AfterDeclKeyword
6462                } else {
6463                    DeclState::None
6464                }
6465            }
6466            DeclState::AfterDeclKeyword => {
6467                if token == name {
6468                    return true;
6469                }
6470                DeclState::None
6471            }
6472        };
6473
6474        false
6475    }
6476}
6477
6478const fn consume_quoted_context(
6479    mode: &mut BindingLexMode,
6480    escaped: &mut bool,
6481    b: u8,
6482    terminator: u8,
6483) {
6484    if *escaped {
6485        *escaped = false;
6486    } else if b == b'\\' {
6487        *escaped = true;
6488    } else if b == terminator {
6489        *mode = BindingLexMode::Normal;
6490    }
6491}
6492
6493fn consume_js_identifier<'a>(source: &'a str, bytes: &[u8], index: &mut usize) -> &'a str {
6494    let start = *index;
6495    *index += 1;
6496    while *index < bytes.len() && is_js_ident_continue(bytes[*index]) {
6497        *index += 1;
6498    }
6499    &source[start..*index]
6500}
6501
6502fn source_declares_binding(source: &str, name: &str) -> bool {
6503    if name.is_empty() || !name.is_ascii() {
6504        return false;
6505    }
6506
6507    let bytes = source.as_bytes();
6508    let mut i = 0usize;
6509    let mut scanner = BindingScanner::default();
6510
6511    while i < bytes.len() {
6512        let b = bytes[i];
6513        let next = bytes.get(i + 1).copied();
6514
6515        if scanner.consume_context(b, next, &mut i) || scanner.enter_context(b, next, &mut i) {
6516            continue;
6517        }
6518
6519        if b.is_ascii_whitespace() {
6520            i += 1;
6521            continue;
6522        }
6523
6524        if b == b'{' {
6525            scanner.brace_depth = scanner.brace_depth.saturating_add(1);
6526            scanner.state = DeclState::None;
6527            i += 1;
6528            continue;
6529        }
6530
6531        if b == b'}' {
6532            scanner.brace_depth = scanner.brace_depth.saturating_sub(1);
6533            scanner.state = DeclState::None;
6534            i += 1;
6535            continue;
6536        }
6537
6538        if is_js_ident_start(b) {
6539            let token = consume_js_identifier(source, bytes, &mut i);
6540            if scanner.brace_depth == 0 && scanner.advance_state(token, name) {
6541                return true;
6542            }
6543            if scanner.brace_depth > 0 {
6544                scanner.state = DeclState::None;
6545            }
6546            continue;
6547        }
6548
6549        if scanner.state == DeclState::AfterDeclKeyword && b == b'*' {
6550            i += 1;
6551            continue;
6552        }
6553
6554        scanner.state = DeclState::None;
6555        i += 1;
6556    }
6557
6558    false
6559}
6560
6561/// Extract static `require("specifier")` calls from JavaScript source.
6562///
6563/// This scanner is intentionally lexical: it ignores matches inside comments
6564/// and string/template literals so code-generation strings like
6565/// `` `require("pkg/path").default` `` do not become false-positive imports.
6566#[allow(clippy::too_many_lines)]
6567fn extract_static_require_specifiers(source: &str) -> Vec<String> {
6568    const REQUIRE: &[u8] = b"require";
6569
6570    let bytes = source.as_bytes();
6571    let mut out = Vec::new();
6572    let mut seen = HashSet::new();
6573
6574    let mut i = 0usize;
6575    let mut in_line_comment = false;
6576    let mut in_block_comment = false;
6577    let mut in_single = false;
6578    let mut in_double = false;
6579    let mut in_template = false;
6580    let mut escaped = false;
6581
6582    while i < bytes.len() {
6583        let b = bytes[i];
6584
6585        if in_line_comment {
6586            if b == b'\n' {
6587                in_line_comment = false;
6588            }
6589            i += 1;
6590            continue;
6591        }
6592
6593        if in_block_comment {
6594            if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
6595                in_block_comment = false;
6596                i += 2;
6597            } else {
6598                i += 1;
6599            }
6600            continue;
6601        }
6602
6603        if in_single {
6604            if escaped {
6605                escaped = false;
6606            } else if b == b'\\' {
6607                escaped = true;
6608            } else if b == b'\'' {
6609                in_single = false;
6610            }
6611            i += 1;
6612            continue;
6613        }
6614
6615        if in_double {
6616            if escaped {
6617                escaped = false;
6618            } else if b == b'\\' {
6619                escaped = true;
6620            } else if b == b'"' {
6621                in_double = false;
6622            }
6623            i += 1;
6624            continue;
6625        }
6626
6627        if in_template {
6628            if escaped {
6629                escaped = false;
6630            } else if b == b'\\' {
6631                escaped = true;
6632            } else if b == b'`' {
6633                in_template = false;
6634            }
6635            i += 1;
6636            continue;
6637        }
6638
6639        if b == b'/' && i + 1 < bytes.len() {
6640            match bytes[i + 1] {
6641                b'/' => {
6642                    in_line_comment = true;
6643                    i += 2;
6644                    continue;
6645                }
6646                b'*' => {
6647                    in_block_comment = true;
6648                    i += 2;
6649                    continue;
6650                }
6651                _ => {}
6652            }
6653        }
6654
6655        if b == b'\'' {
6656            in_single = true;
6657            i += 1;
6658            continue;
6659        }
6660        if b == b'"' {
6661            in_double = true;
6662            i += 1;
6663            continue;
6664        }
6665        if b == b'`' {
6666            in_template = true;
6667            i += 1;
6668            continue;
6669        }
6670
6671        if i + REQUIRE.len() <= bytes.len() && &bytes[i..i + REQUIRE.len()] == REQUIRE {
6672            let has_ident_before = i > 0 && is_js_ident_continue(bytes[i - 1]);
6673            let after_ident_idx = i + REQUIRE.len();
6674            let has_ident_after =
6675                after_ident_idx < bytes.len() && is_js_ident_continue(bytes[after_ident_idx]);
6676            if has_ident_before || has_ident_after {
6677                i += 1;
6678                continue;
6679            }
6680
6681            let mut j = after_ident_idx;
6682            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
6683                j += 1;
6684            }
6685            if j >= bytes.len() || bytes[j] != b'(' {
6686                i += 1;
6687                continue;
6688            }
6689
6690            j += 1;
6691            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
6692                j += 1;
6693            }
6694            if j >= bytes.len() || (bytes[j] != b'"' && bytes[j] != b'\'') {
6695                i += 1;
6696                continue;
6697            }
6698
6699            let quote = bytes[j];
6700            let spec_start = j + 1;
6701            j += 1;
6702            let mut lit_escaped = false;
6703            while j < bytes.len() {
6704                let c = bytes[j];
6705                if lit_escaped {
6706                    lit_escaped = false;
6707                    j += 1;
6708                    continue;
6709                }
6710                if c == b'\\' {
6711                    lit_escaped = true;
6712                    j += 1;
6713                    continue;
6714                }
6715                if c == quote {
6716                    break;
6717                }
6718                j += 1;
6719            }
6720            if j >= bytes.len() {
6721                break;
6722            }
6723
6724            let spec = &source[spec_start..j];
6725            j += 1;
6726            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
6727                j += 1;
6728            }
6729            if j < bytes.len() && bytes[j] == b')' && seen.insert(spec.to_string()) {
6730                out.push(spec.to_string());
6731                i = j + 1;
6732                continue;
6733            }
6734        }
6735
6736        i += 1;
6737    }
6738
6739    out
6740}
6741
6742/// Detect if a JavaScript source uses CommonJS patterns (`require(...)` or
6743/// `module.exports`) and transform it into an ESM-compatible wrapper.
6744///
6745/// Handles two cases:
6746/// 1. **Pure CJS** (no ESM `import`/`export`): full wrapper with
6747///    `module`/`exports`/`require` shim + `export default module.exports`
6748/// 2. **Mixed** (ESM imports + `require()` calls): inject `import` statements
6749///    for require targets and a `require()` function, preserving existing ESM
6750#[allow(clippy::too_many_lines)]
6751fn maybe_cjs_to_esm(source: &str) -> String {
6752    let has_require = source.contains("require(");
6753    let has_module_exports = source.contains("module.exports")
6754        || source.contains("module[\"exports\"]")
6755        || source.contains("module['exports']");
6756    let has_exports_usage = source.contains("exports.") || source.contains("exports[");
6757    let has_filename_refs = source.contains("__filename");
6758    let has_dirname_refs = source.contains("__dirname");
6759
6760    if !has_require
6761        && !has_module_exports
6762        && !has_exports_usage
6763        && !has_filename_refs
6764        && !has_dirname_refs
6765    {
6766        return source.to_string();
6767    }
6768
6769    let has_esm = source.lines().any(|line| {
6770        let trimmed = line.trim();
6771        (trimmed.starts_with("import ") || trimmed.starts_with("export "))
6772            && !trimmed.starts_with("//")
6773    });
6774    let has_export_default = source.contains("export default");
6775
6776    // Extract all require() specifiers
6777    let specifiers = extract_static_require_specifiers(source);
6778
6779    if specifiers.is_empty()
6780        && !has_module_exports
6781        && !has_exports_usage
6782        && !has_filename_refs
6783        && !has_dirname_refs
6784    {
6785        return source.to_string();
6786    }
6787    if specifiers.is_empty()
6788        && has_esm
6789        && !has_module_exports
6790        && !has_exports_usage
6791        && !has_filename_refs
6792        && !has_dirname_refs
6793    {
6794        return source.to_string();
6795    }
6796
6797    let mut output = String::with_capacity(source.len() + 512);
6798
6799    // Generate ESM imports for require targets
6800    for (i, spec) in specifiers.iter().enumerate() {
6801        let _ = writeln!(output, "import * as __cjs_req_{i} from {spec:?};");
6802    }
6803
6804    // Build require map + function
6805    let has_require_binding = source_declares_binding(source, "require");
6806    if !specifiers.is_empty() && !has_require_binding {
6807        output.push_str("const __cjs_req_map = {");
6808        for (i, spec) in specifiers.iter().enumerate() {
6809            if i > 0 {
6810                output.push(',');
6811            }
6812            let _ = write!(output, "\n  {spec:?}: __cjs_req_{i}");
6813        }
6814        output.push_str("\n};\n");
6815        output.push_str(
6816            "function require(s) {\n\
6817             \x20 const m = __cjs_req_map[s];\n\
6818             \x20 if (!m) throw new Error('Cannot find module: ' + s);\n\
6819             \x20 return m.default !== undefined && typeof m.default === 'object' \
6820                  ? m.default : m;\n\
6821             }\n",
6822        );
6823    }
6824
6825    let has_filename_binding = source_declares_binding(source, "__filename");
6826    let has_dirname_binding = source_declares_binding(source, "__dirname");
6827    let has_module_binding = source_declares_binding(source, "module");
6828    let has_exports_binding = source_declares_binding(source, "exports");
6829    let needs_filename = has_filename_refs && !has_filename_binding;
6830    let needs_dirname = has_dirname_refs && !has_dirname_binding;
6831    let needs_module = (has_module_exports || has_exports_usage) && !has_module_binding;
6832    let needs_exports = (has_module_exports || has_exports_usage) && !has_exports_binding;
6833
6834    if needs_filename || needs_dirname || needs_module || needs_exports {
6835        // Provide CJS compatibility globals only for bindings not declared by source.
6836        if needs_filename {
6837            output.push_str(
6838                "const __filename = (() => {\n\
6839                 \x20 try { return new URL(import.meta.url).pathname || ''; } catch { return ''; }\n\
6840                 })();\n",
6841            );
6842        }
6843        if needs_dirname {
6844            output.push_str(
6845                "const __dirname = (() => {\n\
6846                 \x20 try {\n\
6847                 \x20\x20 const __pi_pathname = new URL(import.meta.url).pathname || '';\n\
6848                 \x20\x20 return __pi_pathname ? __pi_pathname.replace(/[/\\\\][^/\\\\]*$/, '') : '.';\n\
6849                 \x20 } catch { return '.'; }\n\
6850                 })();\n",
6851            );
6852        }
6853        if needs_module {
6854            output.push_str("const module = { exports: {} };\n");
6855        }
6856        if needs_exports {
6857            output.push_str("const exports = module.exports;\n");
6858        }
6859    }
6860
6861    output.push_str(source);
6862    output.push('\n');
6863
6864    if !has_export_default && (!has_esm || has_module_exports || has_exports_usage) {
6865        // Export CommonJS entrypoint for loaders that require a default init fn.
6866        output.push_str("export default module.exports;\n");
6867    }
6868
6869    output
6870}
6871
6872const fn is_js_ident_start(byte: u8) -> bool {
6873    (byte as char).is_ascii_alphabetic() || byte == b'_' || byte == b'$'
6874}
6875
6876const fn is_js_ident_continue(byte: u8) -> bool {
6877    is_js_ident_start(byte) || (byte as char).is_ascii_digit()
6878}
6879
6880/// Rewrite private identifier syntax (`#field`) to legacy-safe identifiers for
6881/// runtimes that do not parse private fields. This is intentionally a lexical
6882/// compatibility transform, not a semantic class-fields implementation.
6883#[allow(clippy::too_many_lines)]
6884fn rewrite_legacy_private_identifiers(source: &str) -> String {
6885    if !source.contains('#') || !source.is_ascii() {
6886        return source.to_string();
6887    }
6888
6889    let bytes = source.as_bytes();
6890    let mut out = String::with_capacity(source.len() + 32);
6891    let mut i = 0usize;
6892    let mut in_single = false;
6893    let mut in_double = false;
6894    let mut in_template = false;
6895    let mut escaped = false;
6896    let mut line_comment = false;
6897    let mut block_comment = false;
6898
6899    while i < bytes.len() {
6900        let b = bytes[i];
6901        let next = bytes.get(i + 1).copied();
6902
6903        if line_comment {
6904            out.push(b as char);
6905            if b == b'\n' {
6906                line_comment = false;
6907            }
6908            i += 1;
6909            continue;
6910        }
6911
6912        if block_comment {
6913            if b == b'*' && next == Some(b'/') {
6914                out.push('*');
6915                out.push('/');
6916                i += 2;
6917                block_comment = false;
6918                continue;
6919            }
6920            out.push(b as char);
6921            i += 1;
6922            continue;
6923        }
6924
6925        if in_single {
6926            out.push(b as char);
6927            if escaped {
6928                escaped = false;
6929            } else if b == b'\\' {
6930                escaped = true;
6931            } else if b == b'\'' {
6932                in_single = false;
6933            }
6934            i += 1;
6935            continue;
6936        }
6937
6938        if in_double {
6939            out.push(b as char);
6940            if escaped {
6941                escaped = false;
6942            } else if b == b'\\' {
6943                escaped = true;
6944            } else if b == b'"' {
6945                in_double = false;
6946            }
6947            i += 1;
6948            continue;
6949        }
6950
6951        if in_template {
6952            out.push(b as char);
6953            if escaped {
6954                escaped = false;
6955            } else if b == b'\\' {
6956                escaped = true;
6957            } else if b == b'`' {
6958                in_template = false;
6959            }
6960            i += 1;
6961            continue;
6962        }
6963
6964        if b == b'/' && next == Some(b'/') {
6965            line_comment = true;
6966            out.push('/');
6967            i += 1;
6968            continue;
6969        }
6970        if b == b'/' && next == Some(b'*') {
6971            block_comment = true;
6972            out.push('/');
6973            i += 1;
6974            continue;
6975        }
6976        if b == b'\'' {
6977            in_single = true;
6978            out.push('\'');
6979            i += 1;
6980            continue;
6981        }
6982        if b == b'"' {
6983            in_double = true;
6984            out.push('"');
6985            i += 1;
6986            continue;
6987        }
6988        if b == b'`' {
6989            in_template = true;
6990            out.push('`');
6991            i += 1;
6992            continue;
6993        }
6994
6995        if b == b'#' && next.is_some_and(is_js_ident_start) {
6996            let prev_is_ident = i > 0 && is_js_ident_continue(bytes[i - 1]);
6997            if !prev_is_ident {
6998                out.push_str("__pijs_private_");
6999                i += 1;
7000                while i < bytes.len() && is_js_ident_continue(bytes[i]) {
7001                    out.push(bytes[i] as char);
7002                    i += 1;
7003                }
7004                continue;
7005            }
7006        }
7007
7008        out.push(b as char);
7009        i += 1;
7010    }
7011
7012    out
7013}
7014
7015fn json_module_to_esm(raw: &str, name: &str) -> std::result::Result<String, String> {
7016    let value: serde_json::Value =
7017        serde_json::from_str(raw).map_err(|err| format!("parse {name}: {err}"))?;
7018    let literal = serde_json::to_string(&value).map_err(|err| format!("encode {name}: {err}"))?;
7019    Ok(format!("export default {literal};\n"))
7020}
7021
7022fn transpile_typescript_module(source: &str, name: &str) -> std::result::Result<String, String> {
7023    let globals = Globals::new();
7024    GLOBALS.set(&globals, || {
7025        let cm: Lrc<SourceMap> = Lrc::default();
7026        let fm = cm.new_source_file(
7027            FileName::Custom(name.to_string()).into(),
7028            source.to_string(),
7029        );
7030
7031        let syntax = Syntax::Typescript(TsSyntax {
7032            tsx: Path::new(name).extension().is_some_and(|ext| {
7033                ext.eq_ignore_ascii_case("tsx") || ext.eq_ignore_ascii_case("jsx")
7034            }),
7035            decorators: true,
7036            ..Default::default()
7037        });
7038
7039        let mut parser = SwcParser::new(syntax, StringInput::from(&*fm), None);
7040        let module: SwcModule = parser
7041            .parse_module()
7042            .map_err(|err| format!("parse {name}: {err:?}"))?;
7043
7044        let unresolved_mark = Mark::new();
7045        let top_level_mark = Mark::new();
7046        let mut program = SwcProgram::Module(module);
7047        {
7048            let mut pass = resolver(unresolved_mark, top_level_mark, false);
7049            pass.process(&mut program);
7050        }
7051        {
7052            let mut pass = strip(unresolved_mark, top_level_mark);
7053            pass.process(&mut program);
7054        }
7055        let SwcProgram::Module(module) = program else {
7056            return Err(format!("transpile {name}: expected module"));
7057        };
7058
7059        let mut buf = Vec::new();
7060        {
7061            let mut emitter = Emitter {
7062                cfg: swc_ecma_codegen::Config::default(),
7063                comments: None,
7064                cm: cm.clone(),
7065                wr: JsWriter::new(cm, "\n", &mut buf, None),
7066            };
7067            emitter
7068                .emit_module(&module)
7069                .map_err(|err| format!("emit {name}: {err}"))?;
7070        }
7071
7072        String::from_utf8(buf).map_err(|err| format!("utf8 {name}: {err}"))
7073    })
7074}
7075
7076/// Build the `node:os` virtual module with real system values injected at init
7077/// time. Values are captured once and cached in the JS module source, so no
7078/// per-call hostcalls are needed.
7079#[allow(clippy::too_many_lines)]
7080fn build_node_os_module() -> String {
7081    // Map Rust target constants to Node.js conventions.
7082    let node_platform = match std::env::consts::OS {
7083        "macos" => "darwin",
7084        "windows" => "win32",
7085        other => other, // "linux", "freebsd", etc.
7086    };
7087    let node_arch = match std::env::consts::ARCH {
7088        "x86_64" => "x64",
7089        "aarch64" => "arm64",
7090        "x86" => "ia32",
7091        "arm" => "arm",
7092        other => other,
7093    };
7094    let node_type = match std::env::consts::OS {
7095        "linux" => "Linux",
7096        "macos" => "Darwin",
7097        "windows" => "Windows_NT",
7098        other => other,
7099    };
7100    // Escape backslashes for safe JS string interpolation (Windows paths).
7101    let tmpdir = std::env::temp_dir()
7102        .display()
7103        .to_string()
7104        .replace('\\', "\\\\");
7105    let homedir = std::env::var("HOME")
7106        .or_else(|_| std::env::var("USERPROFILE"))
7107        .unwrap_or_else(|_| "/home/unknown".to_string())
7108        .replace('\\', "\\\\");
7109    // Read hostname from /etc/hostname (Linux) or fall back to env/default.
7110    let hostname = std::fs::read_to_string("/etc/hostname")
7111        .ok()
7112        .map(|s| s.trim().to_string())
7113        .filter(|s| !s.is_empty())
7114        .or_else(|| std::env::var("HOSTNAME").ok())
7115        .or_else(|| std::env::var("COMPUTERNAME").ok())
7116        .unwrap_or_else(|| "localhost".to_string());
7117    let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
7118    let eol = if cfg!(windows) { "\\r\\n" } else { "\\n" };
7119    let dev_null = if cfg!(windows) {
7120        "\\\\\\\\.\\\\NUL"
7121    } else {
7122        "/dev/null"
7123    };
7124    let username = std::env::var("USER")
7125        .or_else(|_| std::env::var("USERNAME"))
7126        .unwrap_or_else(|_| "unknown".to_string());
7127    let shell = std::env::var("SHELL").unwrap_or_else(|_| {
7128        if cfg!(windows) {
7129            "cmd.exe".to_string()
7130        } else {
7131            "/bin/sh".to_string()
7132        }
7133    });
7134    // Read uid/gid from /proc/self/status on Linux, fall back to defaults.
7135    let (uid, gid) = read_proc_uid_gid().unwrap_or((1000, 1000));
7136
7137    // Store CPU count; the JS module builds the array at import time.
7138    // This avoids emitting potentially thousands of chars of identical entries.
7139
7140    format!(
7141        r#"
7142const _platform = "{node_platform}";
7143const _arch = "{node_arch}";
7144const _type = "{node_type}";
7145const _tmpdir = "{tmpdir}";
7146const _homedir = "{homedir}";
7147const _hostname = "{hostname}";
7148const _eol = "{eol}";
7149const _devNull = "{dev_null}";
7150const _uid = {uid};
7151const _gid = {gid};
7152const _username = "{username}";
7153const _shell = "{shell}";
7154const _numCpus = {num_cpus};
7155const _cpus = [];
7156for (let i = 0; i < _numCpus; i++) _cpus.push({{ model: "cpu", speed: 2400, times: {{ user: 0, nice: 0, sys: 0, idle: 0, irq: 0 }} }});
7157
7158export function homedir() {{
7159  const env_home =
7160    globalThis.pi && globalThis.pi.env && typeof globalThis.pi.env.get === "function"
7161      ? globalThis.pi.env.get("HOME")
7162      : undefined;
7163  return env_home || _homedir;
7164}}
7165export function tmpdir() {{ return _tmpdir; }}
7166export function hostname() {{ return _hostname; }}
7167export function platform() {{ return _platform; }}
7168export function arch() {{ return _arch; }}
7169export function type() {{ return _type; }}
7170export function release() {{ return "6.0.0"; }}
7171export function cpus() {{ return _cpus; }}
7172export function totalmem() {{ return 8 * 1024 * 1024 * 1024; }}
7173export function freemem() {{ return 4 * 1024 * 1024 * 1024; }}
7174export function uptime() {{ return Math.floor(Date.now() / 1000); }}
7175export function loadavg() {{ return [0.0, 0.0, 0.0]; }}
7176export function networkInterfaces() {{ return {{}}; }}
7177export function userInfo(_options) {{
7178  return {{
7179    uid: _uid,
7180    gid: _gid,
7181    username: _username,
7182    homedir: homedir(),
7183    shell: _shell,
7184  }};
7185}}
7186export function endianness() {{ return "LE"; }}
7187export const EOL = _eol;
7188export const devNull = _devNull;
7189export const constants = {{
7190  signals: {{}},
7191  errno: {{}},
7192  priority: {{ PRIORITY_LOW: 19, PRIORITY_BELOW_NORMAL: 10, PRIORITY_NORMAL: 0, PRIORITY_ABOVE_NORMAL: -7, PRIORITY_HIGH: -14, PRIORITY_HIGHEST: -20 }},
7193}};
7194export default {{ homedir, tmpdir, hostname, platform, arch, type, release, cpus, totalmem, freemem, uptime, loadavg, networkInterfaces, userInfo, endianness, EOL, devNull, constants }};
7195"#
7196    )
7197    .trim()
7198    .to_string()
7199}
7200
7201/// Parse uid/gid from `/proc/self/status` (Linux). Returns `None` on other
7202/// platforms or if the file is unreadable.
7203fn read_proc_uid_gid() -> Option<(u32, u32)> {
7204    let status = std::fs::read_to_string("/proc/self/status").ok()?;
7205    let mut uid = None;
7206    let mut gid = None;
7207    for line in status.lines() {
7208        if let Some(rest) = line.strip_prefix("Uid:") {
7209            uid = rest.split_whitespace().next().and_then(|v| v.parse().ok());
7210        } else if let Some(rest) = line.strip_prefix("Gid:") {
7211            gid = rest.split_whitespace().next().and_then(|v| v.parse().ok());
7212        }
7213        if uid.is_some() && gid.is_some() {
7214            break;
7215        }
7216    }
7217    Some((uid?, gid?))
7218}
7219
7220#[allow(clippy::too_many_lines)]
7221fn default_virtual_modules() -> HashMap<String, String> {
7222    let mut modules = HashMap::new();
7223
7224    modules.insert(
7225        "@sinclair/typebox".to_string(),
7226        r#"
7227export const Type = {
7228  String: (opts = {}) => ({ type: "string", ...opts }),
7229  Number: (opts = {}) => ({ type: "number", ...opts }),
7230  Boolean: (opts = {}) => ({ type: "boolean", ...opts }),
7231  Array: (items, opts = {}) => ({ type: "array", items, ...opts }),
7232  Object: (props = {}, opts = {}) => {
7233    const required = [];
7234    const properties = {};
7235    for (const [k, v] of Object.entries(props)) {
7236      if (v && typeof v === "object" && v.__pi_optional) {
7237        properties[k] = v.schema;
7238      } else {
7239        properties[k] = v;
7240        required.push(k);
7241      }
7242    }
7243    const out = { type: "object", properties, ...opts };
7244    if (required.length) out.required = required;
7245    return out;
7246  },
7247  Optional: (schema) => ({ __pi_optional: true, schema }),
7248  Literal: (value, opts = {}) => ({ const: value, ...opts }),
7249  Any: (opts = {}) => ({ ...opts }),
7250  Union: (schemas, opts = {}) => ({ anyOf: schemas, ...opts }),
7251  Enum: (values, opts = {}) => ({ enum: values, ...opts }),
7252  Integer: (opts = {}) => ({ type: "integer", ...opts }),
7253  Null: (opts = {}) => ({ type: "null", ...opts }),
7254  Unknown: (opts = {}) => ({ ...opts }),
7255  Tuple: (items, opts = {}) => ({ type: "array", items, minItems: items.length, maxItems: items.length, ...opts }),
7256  Record: (keySchema, valueSchema, opts = {}) => ({ type: "object", additionalProperties: valueSchema, ...opts }),
7257  Ref: (ref, opts = {}) => ({ $ref: ref, ...opts }),
7258  Intersect: (schemas, opts = {}) => ({ allOf: schemas, ...opts }),
7259};
7260export default { Type };
7261"#
7262        .trim()
7263        .to_string(),
7264    );
7265
7266    modules.insert(
7267        "@mariozechner/pi-ai".to_string(),
7268        r#"
7269export function StringEnum(values, opts = {}) {
7270  const list = Array.isArray(values) ? values.map((v) => String(v)) : [];
7271  return { type: "string", enum: list, ...opts };
7272}
7273
7274export function calculateCost(model, usage) {
7275  const usageObj = usage && typeof usage === 'object' ? usage : {};
7276  const cost = usageObj.cost && typeof usageObj.cost === 'object' ? usageObj.cost : {};
7277  const modelCost = model && typeof model === 'object' ? (model.cost || {}) : {};
7278
7279  const inputTokens = Number(usageObj.input ?? usageObj.inputTokens ?? usageObj.input_tokens ?? 0);
7280  const outputTokens = Number(usageObj.output ?? usageObj.outputTokens ?? usageObj.output_tokens ?? 0);
7281  const cacheReadTokens = Number(usageObj.cacheRead ?? usageObj.cache_read ?? 0);
7282  const cacheWriteTokens = Number(usageObj.cacheWrite ?? usageObj.cache_write ?? 0);
7283
7284  const inputRate = Number(modelCost.input ?? 0);
7285  const outputRate = Number(modelCost.output ?? 0);
7286  const cacheReadRate = Number(modelCost.cacheRead ?? modelCost.cache_read ?? 0);
7287  const cacheWriteRate = Number(modelCost.cacheWrite ?? modelCost.cache_write ?? 0);
7288
7289  cost.input = (inputRate / 1000000) * inputTokens;
7290  cost.output = (outputRate / 1000000) * outputTokens;
7291  cost.cacheRead = (cacheReadRate / 1000000) * cacheReadTokens;
7292  cost.cacheWrite = (cacheWriteRate / 1000000) * cacheWriteTokens;
7293  cost.total = cost.input + cost.output + cost.cacheRead + cost.cacheWrite;
7294
7295  usageObj.cost = cost;
7296  if (!Number.isFinite(Number(usageObj.totalTokens))) {
7297    usageObj.totalTokens = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
7298  }
7299
7300  return cost;
7301}
7302
7303function getEnvValue(name) {
7304  if (globalThis.pi && globalThis.pi.env && typeof globalThis.pi.env.get === "function") {
7305    const value = globalThis.pi.env.get(name);
7306    if (value !== undefined && value !== null) {
7307      return String(value);
7308    }
7309  }
7310  if (typeof process !== "undefined" && process.env) {
7311    return process.env[name];
7312  }
7313  return undefined;
7314}
7315
7316export function getEnvApiKey(provider) {
7317  const p = String(provider ?? "").trim();
7318  if (!p) return undefined;
7319
7320  if (p === "github-copilot") {
7321    return (
7322      getEnvValue("COPILOT_GITHUB_TOKEN") ||
7323      getEnvValue("GH_TOKEN") ||
7324      getEnvValue("GITHUB_TOKEN")
7325    );
7326  }
7327
7328  if (p === "anthropic") {
7329    return getEnvValue("ANTHROPIC_OAUTH_TOKEN") || getEnvValue("ANTHROPIC_API_KEY");
7330  }
7331
7332  if (p === "google-vertex") {
7333    const hasCredentials = !!getEnvValue("GOOGLE_APPLICATION_CREDENTIALS");
7334    const hasProject = !!(getEnvValue("GOOGLE_CLOUD_PROJECT") || getEnvValue("GCLOUD_PROJECT"));
7335    const hasLocation = !!getEnvValue("GOOGLE_CLOUD_LOCATION");
7336    if (hasCredentials && (hasProject || hasLocation)) {
7337      return "<authenticated>";
7338    }
7339    if (hasProject && hasLocation) {
7340      return "<authenticated>";
7341    }
7342  }
7343
7344  if (p === "amazon-bedrock") {
7345    if (
7346      getEnvValue("AWS_PROFILE") ||
7347      (getEnvValue("AWS_ACCESS_KEY_ID") && getEnvValue("AWS_SECRET_ACCESS_KEY")) ||
7348      getEnvValue("AWS_BEARER_TOKEN_BEDROCK") ||
7349      getEnvValue("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") ||
7350      getEnvValue("AWS_CONTAINER_CREDENTIALS_FULL_URI") ||
7351      getEnvValue("AWS_WEB_IDENTITY_TOKEN_FILE")
7352    ) {
7353      return "<authenticated>";
7354    }
7355  }
7356
7357  const envMap = {
7358    openai: "OPENAI_API_KEY",
7359    "azure-openai-responses": "AZURE_OPENAI_API_KEY",
7360    google: "GEMINI_API_KEY",
7361    groq: "GROQ_API_KEY",
7362    cerebras: "CEREBRAS_API_KEY",
7363    xai: "XAI_API_KEY",
7364    openrouter: "OPENROUTER_API_KEY",
7365    "vercel-ai-gateway": "AI_GATEWAY_API_KEY",
7366    zai: "ZAI_API_KEY",
7367    mistral: "MISTRAL_API_KEY",
7368    minimax: "MINIMAX_API_KEY",
7369    "minimax-cn": "MINIMAX_CN_API_KEY",
7370    huggingface: "HF_TOKEN",
7371    opencode: "OPENCODE_API_KEY",
7372    "kimi-coding": "KIMI_API_KEY",
7373  };
7374
7375  const envVar = envMap[p];
7376  return envVar ? getEnvValue(envVar) : undefined;
7377}
7378
7379export function createAssistantMessageEventStream() {
7380  return {
7381    push: () => {},
7382    end: () => {},
7383  };
7384}
7385
7386export function streamSimpleAnthropic() {
7387  throw new Error("@mariozechner/pi-ai.streamSimpleAnthropic is not available in PiJS");
7388}
7389
7390export function streamSimpleOpenAIResponses() {
7391  throw new Error("@mariozechner/pi-ai.streamSimpleOpenAIResponses is not available in PiJS");
7392}
7393
7394export function streamSimpleOpenAICompletions() {
7395  throw new Error("@mariozechner/pi-ai.streamSimpleOpenAICompletions is not available in PiJS");
7396}
7397
7398export async function complete(_model, _messages, _opts = {}) {
7399  // Return a minimal completion response stub
7400  return { content: "", model: _model ?? "unknown", usage: { input_tokens: 0, output_tokens: 0 } };
7401}
7402
7403// Stub: completeSimple returns a simple text completion without streaming
7404export async function completeSimple(_model, _prompt, _opts = {}) {
7405  // Return an empty string completion
7406  return "";
7407}
7408
7409export function getModel() {
7410  // Return a default model identifier
7411  return "claude-sonnet-4-5";
7412}
7413
7414export function getApiProvider() {
7415  // Return a default provider identifier
7416  return "anthropic";
7417}
7418
7419export function getModels() {
7420  // Return a list of available model identifiers
7421  return ["claude-sonnet-4-5", "claude-haiku-3-5"];
7422}
7423
7424export async function loginOpenAICodex(_opts = {}) {
7425  return { accessToken: "", refreshToken: "", expiresAt: Date.now() + 3600000 };
7426}
7427
7428export async function refreshOpenAICodexToken(_refreshToken) {
7429  return { accessToken: "", refreshToken: "", expiresAt: Date.now() + 3600000 };
7430}
7431
7432export default { StringEnum, calculateCost, createAssistantMessageEventStream, streamSimpleAnthropic, streamSimpleOpenAIResponses, streamSimpleOpenAICompletions, complete, completeSimple, getModel, getApiProvider, getModels, loginOpenAICodex, refreshOpenAICodexToken };
7433"#
7434        .trim()
7435        .to_string(),
7436    );
7437
7438    modules.insert(
7439        "@mariozechner/pi-tui".to_string(),
7440        r#"
7441export function matchesKey(_data, _key) {
7442  return false;
7443}
7444
7445export function truncateToWidth(text, width) {
7446  const s = String(text ?? "");
7447  const w = Number(width ?? 0);
7448  if (!w || w <= 0) return "";
7449  return s.length <= w ? s : s.slice(0, w);
7450}
7451
7452export class Text {
7453  constructor(text, x = 0, y = 0) {
7454    this.text = String(text ?? "");
7455    this.x = x;
7456    this.y = y;
7457  }
7458}
7459
7460export class TruncatedText extends Text {
7461  constructor(text, width = 80, x = 0, y = 0) {
7462    super(text, x, y);
7463    this.width = Number(width ?? 80);
7464  }
7465}
7466
7467export class Container {
7468  constructor(..._args) {}
7469}
7470
7471export class Markdown {
7472  constructor(..._args) {}
7473}
7474
7475export class Spacer {
7476  constructor(..._args) {}
7477}
7478
7479export function visibleWidth(str) {
7480  return String(str ?? "").length;
7481}
7482
7483export function wrapTextWithAnsi(text, _width) {
7484  return String(text ?? "");
7485}
7486
7487export class Editor {
7488  constructor(_opts = {}) {
7489    this.value = "";
7490  }
7491}
7492
7493export const CURSOR_MARKER = "▌";
7494
7495export function isKeyRelease(_data) {
7496  return false;
7497}
7498
7499export function parseKey(key) {
7500  return { key: String(key ?? "") };
7501}
7502
7503export class Box {
7504  constructor(_padX = 0, _padY = 0, _styleFn = null) {
7505    this.children = [];
7506  }
7507
7508  addChild(child) {
7509    this.children.push(child);
7510  }
7511}
7512
7513export class SelectList {
7514  constructor(items = [], _opts = {}) {
7515    this.items = Array.isArray(items) ? items : [];
7516    this.selected = 0;
7517  }
7518
7519  setItems(items) {
7520    this.items = Array.isArray(items) ? items : [];
7521  }
7522
7523  select(index) {
7524    const i = Number(index ?? 0);
7525    this.selected = Number.isFinite(i) ? i : 0;
7526  }
7527}
7528
7529export class Input {
7530  constructor(_opts = {}) {
7531    this.value = "";
7532  }
7533}
7534
7535export class ProcessTerminal {
7536  constructor(_proc, _opts = {}) {
7537    this.proc = _proc;
7538  }
7539  on(_event, _handler) { return this; }
7540  write(_data) {}
7541  resize(_cols, _rows) {}
7542  destroy() {}
7543}
7544
7545export const Key = {
7546  // Special keys
7547  escape: "escape",
7548  esc: "esc",
7549  enter: "enter",
7550  tab: "tab",
7551  space: "space",
7552  backspace: "backspace",
7553  delete: "delete",
7554  home: "home",
7555  end: "end",
7556  pageUp: "pageUp",
7557  pageDown: "pageDown",
7558  up: "up",
7559  down: "down",
7560  left: "left",
7561  right: "right",
7562  // Single modifiers
7563  ctrl: (key) => `ctrl+${key}`,
7564  shift: (key) => `shift+${key}`,
7565  alt: (key) => `alt+${key}`,
7566  // Combined modifiers
7567  ctrlShift: (key) => `ctrl+shift+${key}`,
7568  shiftCtrl: (key) => `shift+ctrl+${key}`,
7569  ctrlAlt: (key) => `ctrl+alt+${key}`,
7570  altCtrl: (key) => `alt+ctrl+${key}`,
7571  shiftAlt: (key) => `shift+alt+${key}`,
7572  altShift: (key) => `alt+shift+${key}`,
7573  ctrlAltShift: (key) => `ctrl+alt+shift+${key}`,
7574};
7575
7576export class DynamicBorder {
7577  constructor(_styleFn = null) {
7578    this.styleFn = _styleFn;
7579  }
7580}
7581
7582export class SettingsList {
7583  constructor(_opts = {}) {
7584    this.items = [];
7585  }
7586
7587  setItems(items) {
7588    this.items = Array.isArray(items) ? items : [];
7589  }
7590}
7591
7592// Fuzzy string matching for filtering lists
7593export function fuzzyMatch(query, text, _opts = {}) {
7594  const q = String(query ?? '').toLowerCase();
7595  const t = String(text ?? '').toLowerCase();
7596  if (!q) return { match: true, score: 0, positions: [] };
7597  if (!t) return { match: false, score: 0, positions: [] };
7598
7599  const positions = [];
7600  let qi = 0;
7601  for (let ti = 0; ti < t.length && qi < q.length; ti++) {
7602    if (t[ti] === q[qi]) {
7603      positions.push(ti);
7604      qi++;
7605    }
7606  }
7607
7608  const match = qi === q.length;
7609  const score = match ? (q.length / t.length) * 100 : 0;
7610  return { match, score, positions };
7611}
7612
7613// Get editor keybindings configuration
7614export function getEditorKeybindings() {
7615  return {
7616    save: 'ctrl+s',
7617    quit: 'ctrl+q',
7618    copy: 'ctrl+c',
7619    paste: 'ctrl+v',
7620    undo: 'ctrl+z',
7621    redo: 'ctrl+y',
7622    find: 'ctrl+f',
7623    replace: 'ctrl+h',
7624  };
7625}
7626
7627// Filter an array of items using fuzzy matching
7628export function fuzzyFilter(query, items, _opts = {}) {
7629  const q = String(query ?? '').toLowerCase();
7630  if (!q) return items;
7631  if (!Array.isArray(items)) return [];
7632  return items.filter(item => {
7633    const text = typeof item === 'string' ? item : String(item?.label ?? item?.name ?? item);
7634    return fuzzyMatch(q, text).match;
7635  });
7636}
7637
7638// Cancellable loader widget - shows loading state with optional cancel
7639export class CancellableLoader {
7640  constructor(message = 'Loading...', opts = {}) {
7641    this.message = String(message ?? 'Loading...');
7642    this.cancelled = false;
7643    this.onCancel = opts.onCancel ?? null;
7644  }
7645
7646  cancel() {
7647    this.cancelled = true;
7648    if (typeof this.onCancel === 'function') {
7649      this.onCancel();
7650    }
7651  }
7652
7653  render() {
7654    return this.cancelled ? [] : [this.message];
7655  }
7656}
7657
7658export class Image {
7659  constructor(src, _opts = {}) {
7660    this.src = String(src ?? "");
7661    this.width = 0;
7662    this.height = 0;
7663  }
7664}
7665
7666export default { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, Text, TruncatedText, Container, Markdown, Spacer, Editor, Box, SelectList, Input, ProcessTerminal, Image, CURSOR_MARKER, isKeyRelease, parseKey, Key, DynamicBorder, SettingsList, fuzzyMatch, getEditorKeybindings, fuzzyFilter, CancellableLoader };
7667"#
7668        .trim()
7669        .to_string(),
7670    );
7671
7672    modules.insert(
7673        "@mariozechner/pi-coding-agent".to_string(),
7674        r#"
7675export const VERSION = "0.0.0";
7676
7677export const DEFAULT_MAX_LINES = 2000;
7678export const DEFAULT_MAX_BYTES = 1_000_000;
7679
7680export function formatSize(bytes) {
7681  const b = Number(bytes ?? 0);
7682  const KB = 1024;
7683  const MB = 1024 * 1024;
7684  if (b >= MB) return `${(b / MB).toFixed(1)}MB`;
7685  if (b >= KB) return `${(b / KB).toFixed(1)}KB`;
7686  return `${Math.trunc(b)}B`;
7687}
7688
7689function jsBytes(value) {
7690  return String(value ?? "").length;
7691}
7692
7693export function truncateHead(text, opts = {}) {
7694  const raw = String(text ?? "");
7695  const maxLines = Number(opts.maxLines ?? DEFAULT_MAX_LINES);
7696  const maxBytes = Number(opts.maxBytes ?? DEFAULT_MAX_BYTES);
7697
7698  const lines = raw.split("\n");
7699  const totalLines = lines.length;
7700  const totalBytes = jsBytes(raw);
7701
7702  const out = [];
7703  let outBytes = 0;
7704  let truncatedBy = null;
7705
7706  for (const line of lines) {
7707    if (out.length >= maxLines) {
7708      truncatedBy = "lines";
7709      break;
7710    }
7711
7712    const candidate = out.length ? `\n${line}` : line;
7713    const candidateBytes = jsBytes(candidate);
7714    if (outBytes + candidateBytes > maxBytes) {
7715      truncatedBy = "bytes";
7716      break;
7717    }
7718    out.push(line);
7719    outBytes += candidateBytes;
7720  }
7721
7722  const content = out.join("\n");
7723  return {
7724    content,
7725    truncated: truncatedBy != null,
7726    truncatedBy,
7727    totalLines,
7728    totalBytes,
7729    outputLines: out.length,
7730    outputBytes: jsBytes(content),
7731    lastLinePartial: false,
7732    firstLineExceedsLimit: false,
7733    maxLines,
7734    maxBytes,
7735  };
7736}
7737
7738export function truncateTail(text, opts = {}) {
7739  const raw = String(text ?? "");
7740  const maxLines = Number(opts.maxLines ?? DEFAULT_MAX_LINES);
7741  const maxBytes = Number(opts.maxBytes ?? DEFAULT_MAX_BYTES);
7742
7743  const lines = raw.split("\n");
7744  const totalLines = lines.length;
7745  const totalBytes = jsBytes(raw);
7746
7747  const out = [];
7748  let outBytes = 0;
7749  let truncatedBy = null;
7750
7751  for (let i = lines.length - 1; i >= 0; i--) {
7752    if (out.length >= maxLines) {
7753      truncatedBy = "lines";
7754      break;
7755    }
7756    const line = lines[i];
7757    const candidate = out.length ? `${line}\n` : line;
7758    const candidateBytes = jsBytes(candidate);
7759    if (outBytes + candidateBytes > maxBytes) {
7760      truncatedBy = "bytes";
7761      break;
7762    }
7763    out.unshift(line);
7764    outBytes += candidateBytes;
7765  }
7766
7767  const content = out.join("\n");
7768  return {
7769    content,
7770    truncated: truncatedBy != null,
7771    truncatedBy,
7772    totalLines,
7773    totalBytes,
7774    outputLines: out.length,
7775    outputBytes: jsBytes(content),
7776    lastLinePartial: false,
7777    firstLineExceedsLimit: false,
7778    maxLines,
7779    maxBytes,
7780  };
7781}
7782
7783export function parseSessionEntries(text) {
7784  const raw = String(text ?? "");
7785  const out = [];
7786  for (const line of raw.split(/\r?\n/)) {
7787    const trimmed = line.trim();
7788    if (!trimmed) continue;
7789    try {
7790      out.push(JSON.parse(trimmed));
7791    } catch {
7792      // ignore malformed lines
7793    }
7794  }
7795  return out;
7796}
7797
7798export function convertToLlm(entries) {
7799  return entries;
7800}
7801
7802export function serializeConversation(entries) {
7803  try {
7804    return JSON.stringify(entries ?? []);
7805  } catch {
7806    return String(entries ?? "");
7807  }
7808}
7809
7810export function buildSessionContext(entries = [], _leafId = null, _byId = null) {
7811  const list = Array.isArray(entries) ? entries.slice() : [];
7812  return {
7813    messages: list,
7814    thinkingLevel: null,
7815    model: null,
7816  };
7817}
7818
7819export function parseFrontmatter(text) {
7820  const raw = String(text ?? "");
7821  if (!raw.startsWith("---")) return { frontmatter: {}, body: raw };
7822  const end = raw.indexOf("\n---", 3);
7823  if (end === -1) return { frontmatter: {}, body: raw };
7824
7825  const header = raw.slice(3, end).trim();
7826  const body = raw.slice(end + 4).replace(/^\n/, "");
7827  const frontmatter = {};
7828  for (const line of header.split(/\r?\n/)) {
7829    const idx = line.indexOf(":");
7830    if (idx === -1) continue;
7831    const key = line.slice(0, idx).trim();
7832    const val = line.slice(idx + 1).trim();
7833    if (!key) continue;
7834    frontmatter[key] = val;
7835  }
7836  return { frontmatter, body };
7837}
7838
7839export function getMarkdownTheme() {
7840  return {};
7841}
7842
7843export function getSettingsListTheme() {
7844  return {};
7845}
7846
7847export function getSelectListTheme() {
7848  return {};
7849}
7850
7851export class DynamicBorder {
7852  constructor(..._args) {}
7853}
7854
7855export class BorderedLoader {
7856  constructor(..._args) {}
7857}
7858
7859export class CustomEditor {
7860  constructor(_opts = {}) {
7861    this.value = "";
7862  }
7863
7864  handleInput(_data) {}
7865
7866  render(_width) {
7867    return [];
7868  }
7869}
7870
7871export function createBashTool(_cwd, _opts = {}) {
7872  return {
7873    name: "bash",
7874    label: "bash",
7875    description: "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 1MB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.",
7876    parameters: {
7877      type: "object",
7878      properties: {
7879        command: { type: "string", description: "The bash command to execute" },
7880        timeout: { type: "number", description: "Optional timeout in seconds" },
7881      },
7882      required: ["command"],
7883    },
7884    async execute(_id, params) {
7885      return { content: [{ type: "text", text: String(params?.command ?? "") }], details: {} };
7886    },
7887  };
7888}
7889
7890export function createReadTool(_cwd, _opts = {}) {
7891  return {
7892    name: "read",
7893    label: "read",
7894    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 1MB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.",
7895    parameters: {
7896      type: "object",
7897      properties: {
7898        path: { type: "string", description: "The path to the file to read" },
7899        offset: { type: "number", description: "Line offset to start reading from (0-indexed)" },
7900        limit: { type: "number", description: "Maximum number of lines to read" },
7901      },
7902      required: ["path"],
7903    },
7904    async execute(_id, _params) {
7905      return { content: [{ type: "text", text: "" }], details: {} };
7906    },
7907  };
7908}
7909
7910export function createLsTool(_cwd, _opts = {}) {
7911  return {
7912    name: "ls",
7913    label: "ls",
7914    description: "List files and directories. Returns names, sizes, and metadata.",
7915    parameters: {
7916      type: "object",
7917      properties: {
7918        path: { type: "string", description: "The path to list" },
7919      },
7920      required: ["path"],
7921    },
7922    async execute(_id, _params) {
7923      return { content: [{ type: "text", text: "" }], details: {} };
7924    },
7925  };
7926}
7927
7928export function createGrepTool(_cwd, _opts = {}) {
7929  return {
7930    name: "grep",
7931    label: "grep",
7932    description: "Search file contents using regular expressions.",
7933    parameters: {
7934      type: "object",
7935      properties: {
7936        pattern: { type: "string", description: "The regex pattern to search for" },
7937        path: { type: "string", description: "The path to search in" },
7938      },
7939      required: ["pattern"],
7940    },
7941    async execute(_id, _params) {
7942      return { content: [{ type: "text", text: "" }], details: {} };
7943    },
7944  };
7945}
7946
7947export function createWriteTool(_cwd, _opts = {}) {
7948  return {
7949    name: "write",
7950    label: "write",
7951    description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
7952    parameters: {
7953      type: "object",
7954      properties: {
7955        path: { type: "string", description: "The path to the file to write" },
7956        content: { type: "string", description: "The content to write to the file" },
7957      },
7958      required: ["path", "content"],
7959    },
7960    async execute(_id, _params) {
7961      return { content: [{ type: "text", text: "" }], details: {} };
7962    },
7963  };
7964}
7965
7966export function createEditTool(_cwd, _opts = {}) {
7967  return {
7968    name: "edit",
7969    label: "edit",
7970    description: "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
7971    parameters: {
7972      type: "object",
7973      properties: {
7974        path: { type: "string", description: "The path to the file to edit" },
7975        oldText: { type: "string", minLength: 1, description: "The exact text to find and replace" },
7976        newText: { type: "string", description: "The text to replace oldText with" },
7977      },
7978      required: ["path", "oldText", "newText"],
7979    },
7980    async execute(_id, _params) {
7981      return { content: [{ type: "text", text: "" }], details: {} };
7982    },
7983  };
7984}
7985
7986export function copyToClipboard(_text) {
7987  return;
7988}
7989
7990export function getAgentDir() {
7991  const home =
7992    globalThis.pi && globalThis.pi.env && typeof globalThis.pi.env.get === "function"
7993      ? globalThis.pi.env.get("HOME")
7994      : undefined;
7995  return home ? `${home}/.pi/agent` : "/home/unknown/.pi/agent";
7996}
7997
7998// Stub: keyHint returns a keyboard shortcut hint string for UI display
7999export function keyHint(action, fallback = "") {
8000  // Map action names to default key bindings
8001  const keyMap = {
8002    expandTools: "Ctrl+E",
8003    copy: "Ctrl+C",
8004    paste: "Ctrl+V",
8005    save: "Ctrl+S",
8006    quit: "Ctrl+Q",
8007    help: "?",
8008  };
8009  return keyMap[action] || fallback || action;
8010}
8011
8012export function rawKeyHint(action, fallback = "") {
8013  return keyHint(action, fallback);
8014}
8015
8016// Stub: compact performs conversation compaction via LLM
8017export async function compact(_preparation, _model, _apiKey, _customInstructions, _signal) {
8018  // Return a minimal compaction result
8019  return {
8020    summary: "Conversation summary placeholder",
8021    firstKeptEntryId: null,
8022    tokensBefore: 0,
8023    tokensAfter: 0,
8024  };
8025}
8026
8027/// Stub: AssistantMessageComponent for rendering assistant messages
8028export class AssistantMessageComponent {
8029  constructor(message, editable = false) {
8030    this.message = message;
8031    this.editable = editable;
8032  }
8033
8034  render() {
8035    return [];
8036  }
8037}
8038
8039// Stub: ToolExecutionComponent for rendering tool executions
8040export class ToolExecutionComponent {
8041  constructor(toolName, args, opts = {}, result, ui) {
8042    this.toolName = toolName;
8043    this.args = args;
8044    this.opts = opts;
8045    this.result = result;
8046    this.ui = ui;
8047  }
8048
8049  render() {
8050    return [];
8051  }
8052}
8053
8054// Stub: UserMessageComponent for rendering user messages
8055export class UserMessageComponent {
8056  constructor(text) {
8057    this.text = text;
8058  }
8059
8060  render() {
8061    return [];
8062  }
8063}
8064
8065export class ModelSelectorComponent {
8066  constructor(_opts = {}) {
8067    this.opts = _opts;
8068  }
8069
8070  render() {
8071    return [];
8072  }
8073}
8074
8075export class SessionManager {
8076  constructor() {}
8077  static inMemory() { return new SessionManager(); }
8078  getSessionFile() { return ""; }
8079  getSessionDir() { return ""; }
8080  getSessionId() { return ""; }
8081  buildSessionContext() { return buildSessionContext([]); }
8082}
8083
8084export class SettingsManager {
8085  constructor(cwd = "", agentDir = "") {
8086    this.cwd = String(cwd ?? "");
8087    this.agentDir = String(agentDir ?? "");
8088  }
8089  static create(cwd, agentDir) { return new SettingsManager(cwd, agentDir); }
8090}
8091
8092export class DefaultResourceLoader {
8093  constructor(opts = {}) {
8094    this.opts = opts;
8095  }
8096  async reload() { return; }
8097}
8098
8099export function highlightCode(code, _lang, _theme) {
8100  return String(code ?? "");
8101}
8102
8103export function getLanguageFromPath(filePath) {
8104  const ext = String(filePath ?? "").split(".").pop() || "";
8105  const map = { ts: "typescript", js: "javascript", py: "python", rs: "rust", go: "go", md: "markdown", json: "json", html: "html", css: "css", sh: "bash" };
8106  return map[ext] || ext;
8107}
8108
8109export function isBashToolResult(result) {
8110  return result && typeof result === "object" && result.name === "bash";
8111}
8112
8113export async function loadSkills() {
8114  return [];
8115}
8116
8117export function truncateToVisualLines(text, maxLines = DEFAULT_MAX_LINES) {
8118  const raw = String(text ?? "");
8119  const lines = raw.split(/\r?\n/);
8120  if (!Number.isFinite(maxLines) || maxLines <= 0) return "";
8121  return lines.slice(0, Math.floor(maxLines)).join("\n");
8122}
8123
8124export function estimateTokens(input) {
8125  const raw = typeof input === "string" ? input : JSON.stringify(input ?? "");
8126  // Deterministic rough heuristic (chars / 4).
8127  return Math.max(1, Math.ceil(String(raw).length / 4));
8128}
8129
8130export function isToolCallEventType(value) {
8131  const t = String(value?.type ?? value ?? "").toLowerCase();
8132  return t === "tool_call" || t === "tool-call" || t === "toolcall";
8133}
8134
8135export class AuthStorage {
8136  constructor() {}
8137  static load() { return new AuthStorage(); }
8138  static async loadAsync() { return new AuthStorage(); }
8139  resolveApiKey(_provider) { return undefined; }
8140  get(_provider) { return undefined; }
8141}
8142
8143export function createAgentSession(opts = {}) {
8144  const state = {
8145    id: String(opts.id ?? "session"),
8146    messages: Array.isArray(opts.messages) ? opts.messages.slice() : [],
8147  };
8148  return {
8149    id: state.id,
8150    messages: state.messages,
8151    append(entry) { state.messages.push(entry); },
8152    toJSON() { return { id: state.id, messages: state.messages.slice() }; },
8153  };
8154}
8155
8156export default {
8157  VERSION,
8158  DEFAULT_MAX_LINES,
8159  DEFAULT_MAX_BYTES,
8160  formatSize,
8161  truncateHead,
8162  truncateTail,
8163  parseSessionEntries,
8164  convertToLlm,
8165  serializeConversation,
8166  buildSessionContext,
8167  parseFrontmatter,
8168  getMarkdownTheme,
8169  getSettingsListTheme,
8170  getSelectListTheme,
8171  DynamicBorder,
8172  BorderedLoader,
8173  CustomEditor,
8174  createBashTool,
8175  createReadTool,
8176  createLsTool,
8177  createGrepTool,
8178  createWriteTool,
8179  createEditTool,
8180  copyToClipboard,
8181  getAgentDir,
8182  keyHint,
8183  rawKeyHint,
8184  compact,
8185  AssistantMessageComponent,
8186  ToolExecutionComponent,
8187  UserMessageComponent,
8188  ModelSelectorComponent,
8189  SessionManager,
8190  SettingsManager,
8191  DefaultResourceLoader,
8192  highlightCode,
8193  getLanguageFromPath,
8194  isBashToolResult,
8195  loadSkills,
8196  truncateToVisualLines,
8197  estimateTokens,
8198  isToolCallEventType,
8199  AuthStorage,
8200  createAgentSession,
8201};
8202"#
8203        .trim()
8204        .to_string(),
8205    );
8206
8207    modules.insert(
8208        "@anthropic-ai/sdk".to_string(),
8209        r"
8210export default class Anthropic {
8211  constructor(_opts = {}) {}
8212}
8213"
8214        .trim()
8215        .to_string(),
8216    );
8217
8218    modules.insert(
8219        "@anthropic-ai/sandbox-runtime".to_string(),
8220        r"
8221export const SandboxManager = {
8222  initialize: async (_config) => {},
8223  reset: async () => {},
8224};
8225export default { SandboxManager };
8226"
8227        .trim()
8228        .to_string(),
8229    );
8230
8231    modules.insert(
8232        "ms".to_string(),
8233        r#"
8234function parseMs(text) {
8235  const s = String(text ?? "").trim();
8236  if (!s) return undefined;
8237
8238  const match = s.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i);
8239  if (!match) return undefined;
8240  const value = Number(match[1]);
8241  const unit = (match[2] || "ms").toLowerCase();
8242  const mult = unit === "ms" ? 1 :
8243               unit === "s"  ? 1000 :
8244               unit === "m"  ? 60000 :
8245               unit === "h"  ? 3600000 :
8246               unit === "d"  ? 86400000 :
8247               unit === "w"  ? 604800000 :
8248               unit === "y"  ? 31536000000 : 1;
8249  return Math.round(value * mult);
8250}
8251
8252export default function ms(value) {
8253  return parseMs(value);
8254}
8255
8256export const parse = parseMs;
8257"#
8258        .trim()
8259        .to_string(),
8260    );
8261
8262    modules.insert(
8263        "jsonwebtoken".to_string(),
8264        r#"
8265export function sign() {
8266  throw new Error("jsonwebtoken.sign is not available in PiJS");
8267}
8268
8269export function verify() {
8270  throw new Error("jsonwebtoken.verify is not available in PiJS");
8271}
8272
8273export function decode() {
8274  return null;
8275}
8276
8277export default { sign, verify, decode };
8278"#
8279        .trim()
8280        .to_string(),
8281    );
8282
8283    // ── shell-quote ──────────────────────────────────────────────────
8284    modules.insert(
8285        "shell-quote".to_string(),
8286        r#"
8287export function parse(cmd) {
8288  if (typeof cmd !== 'string') return [];
8289  const args = [];
8290  let current = '';
8291  let inSingle = false;
8292  let inDouble = false;
8293  let escaped = false;
8294  for (let i = 0; i < cmd.length; i++) {
8295    const ch = cmd[i];
8296    if (escaped) { current += ch; escaped = false; continue; }
8297    if (ch === '\\' && !inSingle) { escaped = true; continue; }
8298    if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
8299    if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
8300    if ((ch === ' ' || ch === '\t') && !inSingle && !inDouble) {
8301      if (current) { args.push(current); current = ''; }
8302      continue;
8303    }
8304    current += ch;
8305  }
8306  if (current) args.push(current);
8307  return args;
8308}
8309export function quote(args) {
8310  if (!Array.isArray(args)) return '';
8311  return args.map(a => {
8312    if (/[^a-zA-Z0-9_\-=:./]/.test(a)) return "'" + a.replace(/'/g, "'\\''") + "'";
8313    return a;
8314  }).join(' ');
8315}
8316export default { parse, quote };
8317"#
8318        .trim()
8319        .to_string(),
8320    );
8321
8322    // ── vscode-languageserver-protocol ──────────────────────────────
8323    {
8324        let vls = r"
8325export const DiagnosticSeverity = { Error: 1, Warning: 2, Information: 3, Hint: 4 };
8326export const CodeActionKind = { QuickFix: 'quickfix', Refactor: 'refactor', RefactorExtract: 'refactor.extract', RefactorInline: 'refactor.inline', RefactorRewrite: 'refactor.rewrite', Source: 'source', SourceOrganizeImports: 'source.organizeImports', SourceFixAll: 'source.fixAll' };
8327export const DocumentDiagnosticReportKind = { Full: 'full', Unchanged: 'unchanged' };
8328export 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 };
8329function makeReqType(m) { return { type: { get method() { return m; } }, method: m }; }
8330function makeNotifType(m) { return { type: { get method() { return m; } }, method: m }; }
8331export const InitializeRequest = makeReqType('initialize');
8332export const ShutdownRequest = makeReqType('shutdown');
8333export const DefinitionRequest = makeReqType('textDocument/definition');
8334export const ReferencesRequest = makeReqType('textDocument/references');
8335export const HoverRequest = makeReqType('textDocument/hover');
8336export const SignatureHelpRequest = makeReqType('textDocument/signatureHelp');
8337export const DocumentSymbolRequest = makeReqType('textDocument/documentSymbol');
8338export const RenameRequest = makeReqType('textDocument/rename');
8339export const CodeActionRequest = makeReqType('textDocument/codeAction');
8340export const DocumentDiagnosticRequest = makeReqType('textDocument/diagnostic');
8341export const WorkspaceDiagnosticRequest = makeReqType('workspace/diagnostic');
8342export const InitializedNotification = makeNotifType('initialized');
8343export const DidOpenTextDocumentNotification = makeNotifType('textDocument/didOpen');
8344export const DidChangeTextDocumentNotification = makeNotifType('textDocument/didChange');
8345export const DidCloseTextDocumentNotification = makeNotifType('textDocument/didClose');
8346export const DidSaveTextDocumentNotification = makeNotifType('textDocument/didSave');
8347export const PublishDiagnosticsNotification = makeNotifType('textDocument/publishDiagnostics');
8348export function createMessageConnection(_reader, _writer) {
8349  return {
8350    listen() {},
8351    sendRequest() { return Promise.resolve(null); },
8352    sendNotification() {},
8353    onNotification() {},
8354    onRequest() {},
8355    onClose() {},
8356    dispose() {},
8357  };
8358}
8359export class StreamMessageReader { constructor(_s) {} }
8360export class StreamMessageWriter { constructor(_s) {} }
8361"
8362        .trim()
8363        .to_string();
8364
8365        modules.insert("vscode-languageserver-protocol".to_string(), vls.clone());
8366        modules.insert(
8367            "vscode-languageserver-protocol/node.js".to_string(),
8368            vls.clone(),
8369        );
8370        modules.insert("vscode-languageserver-protocol/node".to_string(), vls);
8371    }
8372
8373    // ── @modelcontextprotocol/sdk ──────────────────────────────────
8374    {
8375        let mcp_client = r"
8376export class Client {
8377  constructor(_opts = {}) {}
8378  async connect(_transport) {}
8379  async listTools() { return { tools: [] }; }
8380  async listResources() { return { resources: [] }; }
8381  async callTool(_name, _args) { return { content: [] }; }
8382  async close() {}
8383}
8384"
8385        .trim()
8386        .to_string();
8387
8388        let mcp_transport = r"
8389export class StdioClientTransport {
8390  constructor(_opts = {}) {}
8391  async start() {}
8392  async close() {}
8393}
8394"
8395        .trim()
8396        .to_string();
8397
8398        modules.insert(
8399            "@modelcontextprotocol/sdk/client/index.js".to_string(),
8400            mcp_client.clone(),
8401        );
8402        modules.insert(
8403            "@modelcontextprotocol/sdk/client/index".to_string(),
8404            mcp_client,
8405        );
8406        modules.insert(
8407            "@modelcontextprotocol/sdk/client/stdio.js".to_string(),
8408            mcp_transport,
8409        );
8410        modules.insert(
8411            "@modelcontextprotocol/sdk/client/streamableHttp.js".to_string(),
8412            r"
8413export class StreamableHTTPClientTransport {
8414  constructor(_opts = {}) {}
8415  async start() {}
8416  async close() {}
8417}
8418"
8419            .trim()
8420            .to_string(),
8421        );
8422        modules.insert(
8423            "@modelcontextprotocol/sdk/client/sse.js".to_string(),
8424            r"
8425export class SSEClientTransport {
8426  constructor(_opts = {}) {}
8427  async start() {}
8428  async close() {}
8429}
8430"
8431            .trim()
8432            .to_string(),
8433        );
8434    }
8435
8436    // ── glob ────────────────────────────────────────────────────────
8437    modules.insert(
8438        "glob".to_string(),
8439        r#"
8440import "node:fs";
8441
8442function __pi_glob_vfs() {
8443  return globalThis.__pi_vfs_state || null;
8444}
8445
8446function __pi_is_abs(path) {
8447  const raw = String(path ?? "");
8448  return raw.startsWith("/") || /^[A-Za-z]:[\\/]/.test(raw);
8449}
8450
8451function __pi_normalize(path, cwd) {
8452  const vfs = __pi_glob_vfs();
8453  const raw = String(path ?? "");
8454  if (vfs && typeof vfs.normalizePath === "function") {
8455    if (__pi_is_abs(raw)) return vfs.normalizePath(raw);
8456    const base =
8457      cwd ??
8458      (globalThis.process && typeof globalThis.process.cwd === "function"
8459        ? globalThis.process.cwd()
8460        : "/");
8461    return vfs.normalizePath(`${base}/${raw}`);
8462  }
8463  if (__pi_is_abs(raw)) return raw.replace(/\\/g, "/");
8464  const base = String(cwd ?? "/").replace(/\\/g, "/");
8465  return `${base.replace(/\/$/, "")}/${raw.replace(/^\//, "")}`;
8466}
8467
8468function __pi_glob_regex(pattern) {
8469  let out = "^";
8470  for (let i = 0; i < pattern.length; i++) {
8471    const ch = pattern[i];
8472    if (ch === "*") {
8473      if (pattern[i + 1] === "*") {
8474        while (pattern[i + 1] === "*") i++;
8475        if (pattern[i + 1] === "/") {
8476          out += "(?:.*/)?";
8477          i++;
8478        } else {
8479          out += ".*";
8480        }
8481      } else {
8482        out += "[^/]*";
8483      }
8484    } else if (ch === "?") {
8485      out += "[^/]";
8486    } else if ("\\\\.^$+()[]{}|".includes(ch)) {
8487      out += "\\" + ch;
8488    } else {
8489      out += ch;
8490    }
8491  }
8492  out += "$";
8493  return new RegExp(out);
8494}
8495
8496function __pi_make_relative(base, full) {
8497  if (!base) return full;
8498  if (base === "/") {
8499    return full.startsWith("/") ? full.slice(1) : full;
8500  }
8501  const prefix = base.endsWith("/") ? base : `${base}/`;
8502  if (full.startsWith(prefix)) return full.slice(prefix.length);
8503  return full;
8504}
8505
8506function __pi_collect(patterns, opts) {
8507  const vfs = __pi_glob_vfs();
8508  if (!vfs) return [];
8509  const cwd = opts && typeof opts.cwd === "string" ? opts.cwd : undefined;
8510  const absolute = !!(opts && opts.absolute);
8511  const nodir = !!(opts && opts.nodir);
8512  const base =
8513    vfs && typeof vfs.normalizePath === "function"
8514      ? vfs.normalizePath(
8515          cwd ??
8516            (globalThis.process && typeof globalThis.process.cwd === "function"
8517              ? globalThis.process.cwd()
8518              : "/"),
8519        )
8520      : String(cwd ?? "/").replace(/\\/g, "/");
8521
8522  const results = new Set();
8523  for (const rawPattern of patterns) {
8524    const normalized = __pi_normalize(rawPattern, cwd);
8525    const regex = __pi_glob_regex(normalized);
8526    for (const file of vfs.files.keys()) {
8527      if (regex.test(file)) results.add(file);
8528    }
8529    if (!nodir) {
8530      for (const dir of vfs.dirs.values()) {
8531        if (regex.test(dir)) results.add(dir);
8532      }
8533      if (vfs.symlinks && typeof vfs.symlinks.keys === "function") {
8534        for (const link of vfs.symlinks.keys()) {
8535          if (regex.test(link)) results.add(link);
8536        }
8537      }
8538    }
8539  }
8540
8541  const out = Array.from(results);
8542  out.sort();
8543  if (!absolute) {
8544    const allAbsolute = patterns.every((pat) => __pi_is_abs(pat));
8545    if (!allAbsolute) {
8546      return out.map((path) => __pi_make_relative(base, path));
8547    }
8548  }
8549  return out;
8550}
8551
8552export function globSync(pattern, opts = {}) {
8553  const patterns = Array.isArray(pattern) ? pattern : [pattern];
8554  return __pi_collect(patterns, opts);
8555}
8556
8557export function glob(pattern, optsOrCb, cb) {
8558  const callback = typeof optsOrCb === "function" ? optsOrCb : cb;
8559  const opts = typeof optsOrCb === "object" && optsOrCb ? optsOrCb : {};
8560  try {
8561    const result = globSync(pattern, opts);
8562    if (typeof callback === "function") {
8563      callback(null, result);
8564      return;
8565    }
8566    return Promise.resolve(result);
8567  } catch (err) {
8568    if (typeof callback === "function") {
8569      callback(err, []);
8570      return;
8571    }
8572    return Promise.reject(err);
8573  }
8574}
8575
8576export class Glob {
8577  constructor(pattern, opts = {}) {
8578    this.found = globSync(pattern, opts);
8579  }
8580  on() { return this; }
8581}
8582
8583export default { globSync, glob, Glob };
8584"#
8585        .trim()
8586        .to_string(),
8587    );
8588
8589    // ── uuid ────────────────────────────────────────────────────────
8590    modules.insert(
8591        "uuid".to_string(),
8592        r#"
8593function randomHex(n) {
8594  let out = "";
8595  for (let i = 0; i < n; i++) out += Math.floor(Math.random() * 16).toString(16);
8596  return out;
8597}
8598export function v4() {
8599  return [randomHex(8), randomHex(4), "4" + randomHex(3), ((8 + Math.floor(Math.random() * 4)).toString(16)) + randomHex(3), randomHex(12)].join("-");
8600}
8601export function v7() {
8602  const ts = Date.now().toString(16).padStart(12, "0");
8603  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("-");
8604}
8605export function v1() { return v4(); }
8606export function v3() { return v4(); }
8607export function v5() { return v4(); }
8608export 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 ?? "")); }
8609export function version(uuid) { return parseInt(String(uuid ?? "").charAt(14), 16) || 0; }
8610export default { v1, v3, v4, v5, v7, validate, version };
8611"#
8612        .trim()
8613        .to_string(),
8614    );
8615
8616    // ── diff ────────────────────────────────────────────────────────
8617    modules.insert(
8618        "diff".to_string(),
8619        r#"
8620export function createTwoFilesPatch(oldFile, newFile, oldStr, newStr, _oldHeader, _newHeader, _opts) {
8621  const oldLines = String(oldStr ?? "").split("\n");
8622  const newLines = String(newStr ?? "").split("\n");
8623  let patch = [`--- ${oldFile}`, `+++ ${newFile}`, `@@ -1,${oldLines.length} +1,${newLines.length} @@`];
8624  for (const line of oldLines) patch.push(`-${line}`);
8625  for (const line of newLines) patch.push(`+${line}`);
8626  return patch.join('\n') + '\n';
8627}
8628export function createPatch(fileName, oldStr, newStr, oldH, newH, opts) {
8629  return createTwoFilesPatch(fileName, fileName, oldStr, newStr, oldH, newH, opts);
8630}
8631export function diffLines(oldStr, newStr) {
8632  return [{ value: String(oldStr ?? ""), removed: true, added: false }, { value: String(newStr ?? ""), removed: false, added: true }];
8633}
8634export function diffChars(o, n) { return diffLines(o, n); }
8635export function diffWords(o, n) { return diffLines(o, n); }
8636export function applyPatch() { return false; }
8637export default { createTwoFilesPatch, createPatch, diffLines, diffChars, diffWords, applyPatch };
8638"#
8639        .trim()
8640        .to_string(),
8641    );
8642
8643    // ── just-bash ──────────────────────────────────────────────────
8644    modules.insert(
8645        "just-bash".to_string(),
8646        r#"
8647export function bash(_cmd, _opts) { return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 }); }
8648export { bash as Bash };
8649export default bash;
8650"#
8651        .trim()
8652        .to_string(),
8653    );
8654
8655    // ── bunfig ─────────────────────────────────────────────────────
8656    modules.insert(
8657        "bunfig".to_string(),
8658        r"
8659export function define(_schema) { return {}; }
8660export async function loadConfig(opts) {
8661  const defaults = (opts && opts.defaultConfig) ? opts.defaultConfig : {};
8662  return { ...defaults };
8663}
8664export default { define, loadConfig };
8665"
8666        .trim()
8667        .to_string(),
8668    );
8669
8670    // ── bun ────────────────────────────────────────────────────────
8671    modules.insert(
8672        "bun".to_string(),
8673        r"
8674const bun = globalThis.Bun || {};
8675export const argv = bun.argv || [];
8676export const file = (...args) => bun.file(...args);
8677export const write = (...args) => bun.write(...args);
8678export const spawn = (...args) => bun.spawn(...args);
8679export const which = (...args) => bun.which(...args);
8680export default bun;
8681"
8682        .trim()
8683        .to_string(),
8684    );
8685
8686    // ── dotenv ─────────────────────────────────────────────────────
8687    modules.insert(
8688        "dotenv".to_string(),
8689        r#"
8690export function config(_opts) { return { parsed: {} }; }
8691export function parse(src) {
8692  const result = {};
8693  for (const line of String(src ?? "").split("\n")) {
8694    const idx = line.indexOf("=");
8695    if (idx === -1) continue;
8696    const key = line.slice(0, idx).trim();
8697    const val = line.slice(idx + 1).trim().replace(/^["']|["']$/g, "");
8698    if (key) result[key] = val;
8699  }
8700  return result;
8701}
8702export default { config, parse };
8703"#
8704        .trim()
8705        .to_string(),
8706    );
8707
8708    modules.insert(
8709        "node:path".to_string(),
8710        r#"
8711function __pi_is_abs(s) {
8712  return s.startsWith("/") || (s.length >= 3 && s[1] === ":" && s[2] === "/");
8713}
8714
8715export function join(...parts) {
8716  const cleaned = parts.map((p) => String(p ?? "").replace(/\\/g, "/")).filter((p) => p.length > 0);
8717  if (cleaned.length === 0) return ".";
8718  return normalize(cleaned.join("/"));
8719}
8720
8721export function dirname(p) {
8722  const s = String(p ?? "").replace(/\\/g, "/");
8723  const idx = s.lastIndexOf("/");
8724  if (idx <= 0) return s.startsWith("/") ? "/" : ".";
8725  const dir = s.slice(0, idx);
8726  // Keep trailing slash for drive root: D:/ not D:
8727  if (dir.length === 2 && dir[1] === ":") return dir + "/";
8728  return dir;
8729}
8730
8731export function resolve(...parts) {
8732  const base =
8733    globalThis.pi && globalThis.pi.process && typeof globalThis.pi.process.cwd === "string"
8734      ? globalThis.pi.process.cwd
8735      : "/";
8736  const cleaned = parts
8737    .map((p) => String(p ?? "").replace(/\\/g, "/"))
8738    .filter((p) => p.length > 0);
8739
8740  let out = "";
8741  for (const part of cleaned) {
8742    if (__pi_is_abs(part)) {
8743      out = part;
8744      continue;
8745    }
8746    out = out === "" || out.endsWith("/") ? out + part : out + "/" + part;
8747  }
8748  if (!__pi_is_abs(out)) {
8749    out = base.endsWith("/") ? base + out : base + "/" + out;
8750  }
8751  return normalize(out);
8752}
8753
8754export function basename(p, ext) {
8755  const s = String(p ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
8756  const idx = s.lastIndexOf("/");
8757  const name = idx === -1 ? s : s.slice(idx + 1);
8758  if (ext && name.endsWith(ext)) {
8759    return name.slice(0, -ext.length);
8760  }
8761  return name;
8762}
8763
8764export function relative(from, to) {
8765  const fromParts = String(from ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
8766  const toParts = String(to ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
8767
8768  let common = 0;
8769  while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
8770    common++;
8771  }
8772
8773  const up = fromParts.length - common;
8774  const downs = toParts.slice(common);
8775  const result = [...Array(up).fill(".."), ...downs];
8776  return result.join("/") || ".";
8777}
8778
8779export function isAbsolute(p) {
8780  const s = String(p ?? "").replace(/\\/g, "/");
8781  return __pi_is_abs(s);
8782}
8783
8784export function extname(p) {
8785  const s = String(p ?? "").replace(/\\/g, "/");
8786  const b = s.lastIndexOf("/");
8787  const name = b === -1 ? s : s.slice(b + 1);
8788  const dot = name.lastIndexOf(".");
8789  if (dot <= 0) return "";
8790  return name.slice(dot);
8791}
8792
8793export function normalize(p) {
8794  const s = String(p ?? "").replace(/\\/g, "/");
8795  const isAbs = __pi_is_abs(s);
8796  const parts = s.split("/").filter(Boolean);
8797  const out = [];
8798  for (const part of parts) {
8799    if (part === "..") { if (out.length > 0 && out[out.length - 1] !== "..") out.pop(); else if (!isAbs) out.push(part); }
8800    else if (part !== ".") out.push(part);
8801  }
8802  const result = out.join("/");
8803  if (out.length > 0 && out[0].length === 2 && out[0][1] === ":") return result;
8804  return isAbs ? "/" + result : result || ".";
8805}
8806
8807export function parse(p) {
8808  const s = String(p ?? "").replace(/\\/g, "/");
8809  const isAbs = s.startsWith("/");
8810  const lastSlash = s.lastIndexOf("/");
8811  const dir = lastSlash === -1 ? "" : s.slice(0, lastSlash) || (isAbs ? "/" : "");
8812  const base = lastSlash === -1 ? s : s.slice(lastSlash + 1);
8813  const ext = extname(base);
8814  const name = ext ? base.slice(0, -ext.length) : base;
8815  const root = isAbs ? "/" : "";
8816  return { root, dir, base, ext, name };
8817}
8818
8819export function format(pathObj) {
8820  const dir = pathObj.dir || pathObj.root || "";
8821  const base = pathObj.base || (pathObj.name || "") + (pathObj.ext || "");
8822  if (!dir) return base;
8823  return dir === pathObj.root ? dir + base : dir + "/" + base;
8824}
8825
8826export const sep = "/";
8827export const delimiter = ":";
8828export const posix = { join, dirname, resolve, basename, relative, isAbsolute, extname, normalize, parse, format, sep, delimiter };
8829
8830const win32Stub = new Proxy({}, { get(_, prop) { throw new Error("path.win32." + String(prop) + " is not supported (Pi runs on POSIX only)"); } });
8831export const win32 = win32Stub;
8832
8833export default { join, dirname, resolve, basename, relative, isAbsolute, extname, normalize, parse, format, sep, delimiter, posix, win32 };
8834"#
8835        .trim()
8836        .to_string(),
8837    );
8838
8839    modules.insert("node:os".to_string(), build_node_os_module());
8840
8841    modules.insert(
8842        "node:child_process".to_string(),
8843        r#"
8844const __pi_child_process_state = (() => {
8845  if (globalThis.__pi_child_process_state) {
8846    return globalThis.__pi_child_process_state;
8847  }
8848  const state = {
8849    nextPid: 1000,
8850    children: new Map(),
8851  };
8852  globalThis.__pi_child_process_state = state;
8853  return state;
8854})();
8855
8856function __makeEmitter() {
8857  const listeners = new Map();
8858  const emitter = {
8859    on(event, listener) {
8860      const key = String(event);
8861      if (!listeners.has(key)) listeners.set(key, []);
8862      listeners.get(key).push(listener);
8863      return emitter;
8864    },
8865    once(event, listener) {
8866      const wrapper = (...args) => {
8867        emitter.off(event, wrapper);
8868        listener(...args);
8869      };
8870      return emitter.on(event, wrapper);
8871    },
8872    off(event, listener) {
8873      const key = String(event);
8874      const bucket = listeners.get(key);
8875      if (!bucket) return emitter;
8876      const idx = bucket.indexOf(listener);
8877      if (idx >= 0) bucket.splice(idx, 1);
8878      if (bucket.length === 0) listeners.delete(key);
8879      return emitter;
8880    },
8881    removeListener(event, listener) {
8882      return emitter.off(event, listener);
8883    },
8884    emit(event, ...args) {
8885      const key = String(event);
8886      const bucket = listeners.get(key) || [];
8887      for (const listener of [...bucket]) {
8888        try {
8889          listener(...args);
8890        } catch (_) {}
8891      }
8892      return emitter;
8893    },
8894  };
8895  return emitter;
8896}
8897
8898function __emitCloseOnce(child, code, signal = null) {
8899  if (child.__pi_done) return;
8900  child.__pi_done = true;
8901  child.exitCode = code;
8902  child.signalCode = signal;
8903  __pi_child_process_state.children.delete(child.pid);
8904  child.emit("exit", code, signal);
8905  child.emit("close", code, signal);
8906}
8907
8908function __parseSpawnOptions(raw) {
8909  const options = raw && typeof raw === "object" ? raw : {};
8910  const allowed = new Set(["cwd", "detached", "shell", "stdio", "timeout"]);
8911  for (const key of Object.keys(options)) {
8912    if (!allowed.has(key)) {
8913      throw new Error(`node:child_process.spawn: unsupported option '${key}'`);
8914    }
8915  }
8916
8917  if (options.shell !== undefined && options.shell !== false) {
8918    throw new Error("node:child_process.spawn: only shell=false is supported in PiJS");
8919  }
8920
8921  let stdio = ["pipe", "pipe", "pipe"];
8922  if (options.stdio !== undefined) {
8923    if (!Array.isArray(options.stdio)) {
8924      throw new Error("node:child_process.spawn: options.stdio must be an array");
8925    }
8926    if (options.stdio.length !== 3) {
8927      throw new Error("node:child_process.spawn: options.stdio must have exactly 3 entries");
8928    }
8929    stdio = options.stdio.map((entry, idx) => {
8930      const value = String(entry ?? "");
8931      if (value !== "ignore" && value !== "pipe") {
8932        throw new Error(
8933          `node:child_process.spawn: unsupported stdio[${idx}] value '${value}'`,
8934        );
8935      }
8936      return value;
8937    });
8938  }
8939
8940  const cwd =
8941    typeof options.cwd === "string" && options.cwd.trim().length > 0
8942      ? options.cwd
8943      : undefined;
8944  let timeoutMs = undefined;
8945  if (options.timeout !== undefined) {
8946    if (
8947      typeof options.timeout !== "number" ||
8948      !Number.isFinite(options.timeout) ||
8949      options.timeout < 0
8950    ) {
8951      throw new Error(
8952        "node:child_process.spawn: options.timeout must be a non-negative number",
8953      );
8954    }
8955    timeoutMs = Math.floor(options.timeout);
8956  }
8957
8958  return {
8959    cwd,
8960    detached: Boolean(options.detached),
8961    stdio,
8962    timeoutMs,
8963  };
8964}
8965
8966function __installProcessKillBridge() {
8967  globalThis.__pi_process_kill_impl = (pidValue, signal = "SIGTERM") => {
8968    const pidNumeric = Number(pidValue);
8969    if (!Number.isFinite(pidNumeric) || pidNumeric === 0) {
8970      const err = new Error(`kill EINVAL: invalid pid ${String(pidValue)}`);
8971      err.code = "EINVAL";
8972      throw err;
8973    }
8974    const pid = Math.abs(Math.trunc(pidNumeric));
8975    const child = __pi_child_process_state.children.get(pid);
8976    if (!child) {
8977      const err = new Error(`kill ESRCH: no such process ${pid}`);
8978      err.code = "ESRCH";
8979      throw err;
8980    }
8981    child.kill(signal);
8982    return true;
8983  };
8984}
8985
8986__installProcessKillBridge();
8987
8988export function spawn(command, args = [], options = {}) {
8989  const cmd = String(command ?? "").trim();
8990  if (!cmd) {
8991    throw new Error("node:child_process.spawn: command is required");
8992  }
8993  if (!Array.isArray(args)) {
8994    throw new Error("node:child_process.spawn: args must be an array");
8995  }
8996
8997  const argv = args.map((arg) => String(arg));
8998  const opts = __parseSpawnOptions(options);
8999
9000  const child = __makeEmitter();
9001  child.pid = __pi_child_process_state.nextPid++;
9002  child.killed = false;
9003  child.exitCode = null;
9004  child.signalCode = null;
9005  child.__pi_done = false;
9006  child.__pi_kill_resolver = null;
9007  child.stdout = opts.stdio[1] === "pipe" ? __makeEmitter() : null;
9008  child.stderr = opts.stdio[2] === "pipe" ? __makeEmitter() : null;
9009  child.stdin = opts.stdio[0] === "pipe" ? __makeEmitter() : null;
9010
9011  child.kill = (signal = "SIGTERM") => {
9012    if (child.__pi_done) return false;
9013    child.killed = true;
9014    if (typeof child.__pi_kill_resolver === "function") {
9015      child.__pi_kill_resolver({
9016        kind: "killed",
9017        signal: String(signal || "SIGTERM"),
9018      });
9019      child.__pi_kill_resolver = null;
9020    }
9021    __emitCloseOnce(child, null, String(signal || "SIGTERM"));
9022    return true;
9023  };
9024
9025  __pi_child_process_state.children.set(child.pid, child);
9026
9027  const execOptions = {};
9028  if (opts.cwd !== undefined) execOptions.cwd = opts.cwd;
9029  if (opts.timeoutMs !== undefined) execOptions.timeout = opts.timeoutMs;
9030  const execPromise = pi.exec(cmd, argv, execOptions).then(
9031    (result) => ({ kind: "result", result }),
9032    (error) => ({ kind: "error", error }),
9033  );
9034
9035  const killPromise = new Promise((resolve) => {
9036    child.__pi_kill_resolver = resolve;
9037  });
9038
9039  Promise.race([execPromise, killPromise]).then((outcome) => {
9040    if (!outcome || child.__pi_done) return;
9041
9042    if (outcome.kind === "result") {
9043      const result = outcome.result || {};
9044      if (child.stdout && result.stdout !== undefined && result.stdout !== null && result.stdout !== "") {
9045        child.stdout.emit("data", String(result.stdout));
9046      }
9047      if (child.stderr && result.stderr !== undefined && result.stderr !== null && result.stderr !== "") {
9048        child.stderr.emit("data", String(result.stderr));
9049      }
9050      if (result.killed) {
9051        child.killed = true;
9052      }
9053      const code =
9054        typeof result.code === "number" && Number.isFinite(result.code)
9055          ? result.code
9056          : 0;
9057      const signal =
9058        result.killed || child.killed
9059          ? String(result.signal || "SIGTERM")
9060          : null;
9061      __emitCloseOnce(child, signal ? null : code, signal);
9062      return;
9063    }
9064
9065    if (outcome.kind === "error") {
9066      const source = outcome.error || {};
9067      const error =
9068        source instanceof Error
9069          ? source
9070          : new Error(String(source.message || source || "spawn failed"));
9071      if (!error.code && source && source.code !== undefined) {
9072        error.code = String(source.code);
9073      }
9074      child.emit("error", error);
9075      __emitCloseOnce(child, 1, null);
9076    }
9077  });
9078
9079  return child;
9080}
9081
9082function __parseExecSyncResult(raw, command) {
9083  const result = JSON.parse(raw);
9084  if (result.error) {
9085    const err = new Error(`Command failed: ${command}\n${result.error}`);
9086    err.status = null;
9087    err.stdout = result.stdout || "";
9088    err.stderr = result.stderr || "";
9089    err.pid = result.pid || 0;
9090    err.signal = null;
9091    throw err;
9092  }
9093  if (result.killed) {
9094    const err = new Error(`Command timed out: ${command}`);
9095    err.killed = true;
9096    err.status = result.status;
9097    err.stdout = result.stdout || "";
9098    err.stderr = result.stderr || "";
9099    err.pid = result.pid || 0;
9100    err.signal = "SIGTERM";
9101    throw err;
9102  }
9103  return result;
9104}
9105
9106export function spawnSync(command, argsInput, options) {
9107  const cmd = String(command ?? "").trim();
9108  if (!cmd) {
9109    throw new Error("node:child_process.spawnSync: command is required");
9110  }
9111  const args = Array.isArray(argsInput) ? argsInput.map(String) : [];
9112  const opts = (typeof argsInput === "object" && !Array.isArray(argsInput))
9113    ? argsInput
9114    : (options || {});
9115  const cwd = typeof opts.cwd === "string" ? opts.cwd : "";
9116  const timeout = typeof opts.timeout === "number" ? opts.timeout : 0;
9117  const maxBuffer = typeof opts.maxBuffer === "number" ? opts.maxBuffer : 1024 * 1024;
9118
9119  let result;
9120  try {
9121    const raw = __pi_exec_sync_native(cmd, JSON.stringify(args), cwd, timeout, maxBuffer);
9122    result = JSON.parse(raw);
9123  } catch (e) {
9124    return {
9125      pid: 0,
9126      output: [null, "", e.message || ""],
9127      stdout: "",
9128      stderr: e.message || "",
9129      status: null,
9130      signal: null,
9131      error: e,
9132    };
9133  }
9134
9135  if (result.error) {
9136    const err = new Error(result.error);
9137    return {
9138      pid: result.pid || 0,
9139      output: [null, result.stdout || "", result.stderr || ""],
9140      stdout: result.stdout || "",
9141      stderr: result.stderr || "",
9142      status: null,
9143      signal: result.killed ? "SIGTERM" : null,
9144      error: err,
9145    };
9146  }
9147
9148  return {
9149    pid: result.pid || 0,
9150    output: [null, result.stdout || "", result.stderr || ""],
9151    stdout: result.stdout || "",
9152    stderr: result.stderr || "",
9153    status: result.status ?? 0,
9154    signal: result.killed ? "SIGTERM" : null,
9155    error: undefined,
9156  };
9157}
9158
9159export function execSync(command, options) {
9160  const cmdStr = String(command ?? "").trim();
9161  if (!cmdStr) {
9162    throw new Error("node:child_process.execSync: command is required");
9163  }
9164  const opts = options || {};
9165  const cwd = typeof opts.cwd === "string" ? opts.cwd : "";
9166  const timeout = typeof opts.timeout === "number" ? opts.timeout : 0;
9167  const maxBuffer = typeof opts.maxBuffer === "number" ? opts.maxBuffer : 1024 * 1024;
9168
9169  // execSync runs through a shell, so pass via sh -c
9170  const raw = __pi_exec_sync_native("sh", JSON.stringify(["-c", cmdStr]), cwd, timeout, maxBuffer);
9171  const result = __parseExecSyncResult(raw, cmdStr);
9172
9173  if (result.error) {
9174    result.error.status = result.status;
9175    result.error.stdout = result.stdout || "";
9176    result.error.stderr = result.stderr || "";
9177    result.error.pid = result.pid || 0;
9178    result.error.signal = result.signal;
9179    throw result.error;
9180  }
9181
9182  if (result.status !== 0 && result.status !== null) {
9183    const err = new Error(
9184      `Command failed: ${cmdStr}\n${result.stderr || ""}`,
9185    );
9186    err.status = result.status;
9187    err.stdout = result.stdout || "";
9188    err.stderr = result.stderr || "";
9189    err.pid = result.pid || 0;
9190    err.signal = null;
9191    throw err;
9192  }
9193
9194  const stdout = result.stdout || "";
9195  if (stdout.length > maxBuffer) {
9196    const err = new Error(`stdout maxBuffer length exceeded`);
9197    err.stdout = stdout.slice(0, maxBuffer);
9198    err.stderr = result.stderr || "";
9199    throw err;
9200  }
9201
9202  const encoding = opts.encoding;
9203  if (encoding === "buffer" || encoding === null) {
9204    // Return a "buffer-like" string (QuickJS doesn't have real Buffer)
9205    return stdout;
9206  }
9207  return stdout;
9208}
9209
9210function __normalizeExecOptions(raw) {
9211  const options = raw && typeof raw === "object" ? raw : {};
9212  let timeoutMs = undefined;
9213  if (
9214    typeof options.timeout === "number" &&
9215    Number.isFinite(options.timeout) &&
9216    options.timeout >= 0
9217  ) {
9218    timeoutMs = Math.floor(options.timeout);
9219  }
9220  const maxBuffer =
9221    typeof options.maxBuffer === "number" &&
9222    Number.isFinite(options.maxBuffer) &&
9223    options.maxBuffer > 0
9224      ? Math.floor(options.maxBuffer)
9225      : 1024 * 1024;
9226  return {
9227    cwd: typeof options.cwd === "string" && options.cwd.trim().length > 0 ? options.cwd : undefined,
9228    timeoutMs,
9229    maxBuffer,
9230    encoding: options.encoding,
9231  };
9232}
9233
9234function __wrapExecLike(commandForError, child, opts, callback) {
9235  let stdoutChunks = [];
9236  let stderrChunks = [];
9237  let callbackDone = false;
9238  const finish = (err, outStr, errOutStr) => {
9239    if (callbackDone) return;
9240    callbackDone = true;
9241    const out = outStr !== undefined ? outStr : stdoutChunks.join("");
9242    const errOut = errOutStr !== undefined ? errOutStr : stderrChunks.join("");
9243    if (typeof callback === "function") {
9244      callback(err, out, errOut);
9245    }
9246  };
9247
9248  let stdoutLen = 0;
9249  let stderrLen = 0;
9250  let killedForMaxBuffer = false;
9251
9252  const checkMaxBuffer = (isStdout) => {
9253    if (stdoutLen > opts.maxBuffer || stderrLen > opts.maxBuffer) {
9254      if (!killedForMaxBuffer) {
9255        killedForMaxBuffer = true;
9256        child.kill("SIGTERM");
9257        const out = stdoutChunks.join("");
9258        const errOut = stderrChunks.join("");
9259        const err = new Error(`${isStdout ? "stdout" : "stderr"} maxBuffer length exceeded`);
9260        err.stdout = out.slice(0, opts.maxBuffer);
9261        err.stderr = errOut.slice(0, opts.maxBuffer);
9262        finish(err, err.stdout, err.stderr);
9263      }
9264    }
9265  };
9266
9267  child.stdout?.on("data", (chunk) => {
9268    if (killedForMaxBuffer) return;
9269    const str = String(chunk ?? "");
9270    stdoutChunks.push(str);
9271    stdoutLen += str.length;
9272    checkMaxBuffer(true);
9273  });
9274  child.stderr?.on("data", (chunk) => {
9275    if (killedForMaxBuffer) return;
9276    const str = String(chunk ?? "");
9277    stderrChunks.push(str);
9278    stderrLen += str.length;
9279    checkMaxBuffer(false);
9280  });
9281
9282  child.on("error", (error) => {
9283    if (killedForMaxBuffer) return;
9284    finish(
9285      error instanceof Error ? error : new Error(String(error)),
9286      "",
9287      "",
9288    );
9289  });
9290
9291  child.on("close", (code) => {
9292    if (killedForMaxBuffer) return;
9293    let out = stdoutChunks.join("");
9294    let errOut = stderrChunks.join("");
9295
9296    if (out.length > opts.maxBuffer) {
9297      const err = new Error("stdout maxBuffer length exceeded");
9298      err.stdout = out.slice(0, opts.maxBuffer);
9299      err.stderr = errOut;
9300      finish(err, err.stdout, errOut);
9301      return;
9302    }
9303
9304    if (errOut.length > opts.maxBuffer) {
9305      const err = new Error("stderr maxBuffer length exceeded");
9306      err.stdout = out;
9307      err.stderr = errOut.slice(0, opts.maxBuffer);
9308      finish(err, out, err.stderr);
9309      return;
9310    }
9311
9312    if (opts.encoding !== "buffer" && opts.encoding !== null) {
9313      out = String(out);
9314      errOut = String(errOut);
9315    }
9316
9317    if (code !== 0 && code !== undefined && code !== null) {
9318      const err = new Error(`Command failed: ${commandForError}`);
9319      err.code = code;
9320      err.killed = Boolean(child.killed);
9321      err.stdout = out;
9322      err.stderr = errOut;
9323      finish(err, out, errOut);
9324      return;
9325    }
9326
9327    if (child.killed) {
9328      const err = new Error(`Command timed out: ${commandForError}`);
9329      err.code = null;
9330      err.killed = true;
9331      err.signal = child.signalCode || "SIGTERM";
9332      err.stdout = out;
9333      err.stderr = errOut;
9334      finish(err, out, errOut);
9335      return;
9336    }
9337
9338    finish(null, out, errOut);
9339  });
9340
9341  return child;
9342}
9343
9344export function exec(command, optionsOrCallback, callbackArg) {
9345  const opts = typeof optionsOrCallback === "object" ? optionsOrCallback : {};
9346  const callback = typeof optionsOrCallback === "function"
9347    ? optionsOrCallback
9348    : callbackArg;
9349  const cmdStr = String(command ?? "").trim();
9350  const normalized = __normalizeExecOptions(opts);
9351  const spawnOpts = {
9352    shell: false,
9353    stdio: ["ignore", "pipe", "pipe"],
9354  };
9355  if (normalized.cwd !== undefined) spawnOpts.cwd = normalized.cwd;
9356  if (normalized.timeoutMs !== undefined) spawnOpts.timeout = normalized.timeoutMs;
9357  const child = spawn("sh", ["-c", cmdStr], spawnOpts);
9358  return __wrapExecLike(cmdStr, child, normalized, callback);
9359}
9360
9361export function execFileSync(file, argsInput, options) {
9362  const fileStr = String(file ?? "").trim();
9363  if (!fileStr) {
9364    throw new Error("node:child_process.execFileSync: file is required");
9365  }
9366  const args = Array.isArray(argsInput) ? argsInput.map(String) : [];
9367  const opts = (typeof argsInput === "object" && !Array.isArray(argsInput))
9368    ? argsInput
9369    : (options || {});
9370  const cwd = typeof opts.cwd === "string" ? opts.cwd : "";
9371  const timeout = typeof opts.timeout === "number" ? opts.timeout : 0;
9372  const maxBuffer = typeof opts.maxBuffer === "number" ? opts.maxBuffer : 1024 * 1024;
9373
9374  const raw = __pi_exec_sync_native(fileStr, JSON.stringify(args), cwd, timeout, maxBuffer);
9375  const result = __parseExecSyncResult(raw, fileStr);
9376
9377  if (result.error) {
9378    result.error.status = result.status;
9379    result.error.stdout = result.stdout || "";
9380    result.error.stderr = result.stderr || "";
9381    result.error.pid = result.pid || 0;
9382    result.error.signal = result.signal;
9383    throw result.error;
9384  }
9385
9386  if (result.status !== 0 && result.status !== null) {
9387    const err = new Error(
9388      `Command failed: ${fileStr}\n${result.stderr || ""}`,
9389    );
9390    err.status = result.status;
9391    err.stdout = result.stdout || "";
9392    err.stderr = result.stderr || "";
9393    err.pid = result.pid || 0;
9394    throw err;
9395  }
9396
9397  return result.stdout || "";
9398}
9399
9400export function execFile(file, argsOrOptsOrCb, optsOrCb, callbackArg) {
9401  const fileStr = String(file ?? "").trim();
9402  let args = [];
9403  let opts = {};
9404  let callback;
9405  if (typeof argsOrOptsOrCb === "function") {
9406    callback = argsOrOptsOrCb;
9407  } else if (Array.isArray(argsOrOptsOrCb)) {
9408    args = argsOrOptsOrCb.map(String);
9409    if (typeof optsOrCb === "function") {
9410      callback = optsOrCb;
9411    } else {
9412      opts = optsOrCb || {};
9413      callback = callbackArg;
9414    }
9415  } else if (typeof argsOrOptsOrCb === "object") {
9416    opts = argsOrOptsOrCb || {};
9417    callback = typeof optsOrCb === "function" ? optsOrCb : callbackArg;
9418  }
9419
9420  const normalized = __normalizeExecOptions(opts);
9421  const spawnOpts = {
9422    shell: false,
9423    stdio: ["ignore", "pipe", "pipe"],
9424  };
9425  if (normalized.cwd !== undefined) spawnOpts.cwd = normalized.cwd;
9426  if (normalized.timeoutMs !== undefined) spawnOpts.timeout = normalized.timeoutMs;
9427  const child = spawn(fileStr, args, spawnOpts);
9428  return __wrapExecLike(fileStr, child, normalized, callback);
9429}
9430
9431export function fork(_modulePath, _args, _opts) {
9432  throw new Error("node:child_process.fork is not available in PiJS");
9433}
9434
9435export default { spawn, spawnSync, execSync, execFileSync, exec, execFile, fork };
9436"#
9437        .trim()
9438        .to_string(),
9439    );
9440
9441    modules.insert(
9442        "node:module".to_string(),
9443        r#"
9444import * as fs from "node:fs";
9445import * as fsPromises from "node:fs/promises";
9446import * as path from "node:path";
9447import * as os from "node:os";
9448import * as crypto from "node:crypto";
9449import * as url from "node:url";
9450import * as processMod from "node:process";
9451import * as timersMod from "node:timers";
9452import * as buffer from "node:buffer";
9453import * as childProcess from "node:child_process";
9454import * as http from "node:http";
9455import * as https from "node:https";
9456import * as net from "node:net";
9457import * as events from "node:events";
9458import * as stream from "node:stream";
9459import * as streamPromises from "node:stream/promises";
9460import * as streamWeb from "node:stream/web";
9461import * as stringDecoder from "node:string_decoder";
9462import * as http2 from "node:http2";
9463import * as util from "node:util";
9464import * as readline from "node:readline";
9465import * as readlinePromises from "node:readline/promises";
9466import * as querystring from "node:querystring";
9467import * as assertMod from "node:assert";
9468import * as assertStrict from "node:assert/strict";
9469import * as constantsMod from "node:constants";
9470import * as tls from "node:tls";
9471import * as tty from "node:tty";
9472import * as zlib from "node:zlib";
9473import * as perfHooks from "node:perf_hooks";
9474import * as vm from "node:vm";
9475import * as v8 from "node:v8";
9476import * as workerThreads from "node:worker_threads";
9477import * as testMod from "node:test";
9478
9479function __normalizeBuiltin(id) {
9480  const spec = String(id ?? "");
9481  switch (spec) {
9482    case "fs":
9483    case "node:fs":
9484      return "node:fs";
9485    case "fs/promises":
9486    case "node:fs/promises":
9487      return "node:fs/promises";
9488    case "path":
9489    case "node:path":
9490      return "node:path";
9491    case "os":
9492    case "node:os":
9493      return "node:os";
9494    case "crypto":
9495    case "node:crypto":
9496      return "node:crypto";
9497    case "url":
9498    case "node:url":
9499      return "node:url";
9500    case "process":
9501    case "node:process":
9502      return "node:process";
9503    case "timers":
9504    case "node:timers":
9505      return "node:timers";
9506    case "buffer":
9507    case "node:buffer":
9508      return "node:buffer";
9509    case "child_process":
9510    case "node:child_process":
9511      return "node:child_process";
9512    case "http":
9513    case "node:http":
9514      return "node:http";
9515    case "https":
9516    case "node:https":
9517      return "node:https";
9518    case "net":
9519    case "node:net":
9520      return "node:net";
9521    case "events":
9522    case "node:events":
9523      return "node:events";
9524    case "stream":
9525    case "node:stream":
9526      return "node:stream";
9527    case "stream/web":
9528    case "node:stream/web":
9529      return "node:stream/web";
9530    case "stream/promises":
9531    case "node:stream/promises":
9532      return "node:stream/promises";
9533    case "string_decoder":
9534    case "node:string_decoder":
9535      return "node:string_decoder";
9536    case "http2":
9537    case "node:http2":
9538      return "node:http2";
9539    case "util":
9540    case "node:util":
9541      return "node:util";
9542    case "readline":
9543    case "node:readline":
9544      return "node:readline";
9545    case "readline/promises":
9546    case "node:readline/promises":
9547      return "node:readline/promises";
9548    case "querystring":
9549    case "node:querystring":
9550      return "node:querystring";
9551    case "assert":
9552    case "node:assert":
9553      return "node:assert";
9554    case "assert/strict":
9555    case "node:assert/strict":
9556      return "node:assert/strict";
9557    case "test":
9558    case "node:test":
9559      return "node:test";
9560    case "module":
9561    case "node:module":
9562      return "node:module";
9563    case "constants":
9564    case "node:constants":
9565      return "node:constants";
9566    case "tls":
9567    case "node:tls":
9568      return "node:tls";
9569    case "tty":
9570    case "node:tty":
9571      return "node:tty";
9572    case "zlib":
9573    case "node:zlib":
9574      return "node:zlib";
9575    case "perf_hooks":
9576    case "node:perf_hooks":
9577      return "node:perf_hooks";
9578    case "vm":
9579    case "node:vm":
9580      return "node:vm";
9581    case "v8":
9582    case "node:v8":
9583      return "node:v8";
9584    case "worker_threads":
9585    case "node:worker_threads":
9586      return "node:worker_threads";
9587    default:
9588      return spec;
9589  }
9590}
9591
9592const __builtinModules = {
9593  "node:fs": fs,
9594  "node:fs/promises": fsPromises,
9595  "node:path": path,
9596  "node:os": os,
9597  "node:crypto": crypto,
9598  "node:url": url,
9599  "node:process": processMod,
9600  "node:timers": timersMod,
9601  "node:buffer": buffer,
9602  "node:child_process": childProcess,
9603  "node:http": http,
9604  "node:https": https,
9605  "node:net": net,
9606  "node:events": events,
9607  "node:stream": stream,
9608  "node:stream/web": streamWeb,
9609  "node:stream/promises": streamPromises,
9610  "node:string_decoder": stringDecoder,
9611  "node:http2": http2,
9612  "node:util": util,
9613  "node:readline": readline,
9614  "node:readline/promises": readlinePromises,
9615  "node:querystring": querystring,
9616  "node:assert": assertMod,
9617  "node:assert/strict": assertStrict,
9618  "node:test": testMod,
9619  "node:module": { createRequire },
9620  "node:constants": constantsMod,
9621  "node:tls": tls,
9622  "node:tty": tty,
9623  "node:zlib": zlib,
9624  "node:perf_hooks": perfHooks,
9625  "node:vm": vm,
9626  "node:v8": v8,
9627  "node:worker_threads": workerThreads,
9628};
9629
9630const __missingRequireCache = Object.create(null);
9631
9632function __isBarePackageSpecifier(spec) {
9633  return (
9634    typeof spec === "string" &&
9635    spec.length > 0 &&
9636    !spec.startsWith("./") &&
9637    !spec.startsWith("../") &&
9638    !spec.startsWith("/") &&
9639    !spec.startsWith("file://") &&
9640    !spec.includes(":")
9641  );
9642}
9643
9644function __makeMissingRequireStub(spec) {
9645  if (__missingRequireCache[spec]) {
9646    return __missingRequireCache[spec];
9647  }
9648  const handler = {
9649    get(_target, prop) {
9650      if (typeof prop === "symbol") {
9651        if (prop === Symbol.toPrimitive) return () => "";
9652        return undefined;
9653      }
9654      if (prop === "__esModule") return true;
9655      if (prop === "default") return stub;
9656      if (prop === "toString") return () => "";
9657      if (prop === "valueOf") return () => "";
9658      if (prop === "name") return spec;
9659      if (prop === "then") return undefined;
9660      return stub;
9661    },
9662    apply() { return stub; },
9663    construct() { return stub; },
9664    has() { return false; },
9665    ownKeys() { return []; },
9666    getOwnPropertyDescriptor() {
9667      return { configurable: true, enumerable: false };
9668    },
9669  };
9670  const stub = new Proxy(function __pijs_missing_require_stub() {}, handler);
9671  __missingRequireCache[spec] = stub;
9672  return stub;
9673}
9674
9675export function createRequire(_path) {
9676  function require(id) {
9677    const normalized = __normalizeBuiltin(id);
9678    const builtIn = __builtinModules[normalized];
9679    if (builtIn) {
9680      if (builtIn && Object.prototype.hasOwnProperty.call(builtIn, "default") && builtIn.default !== undefined) {
9681        return builtIn.default;
9682      }
9683      return builtIn;
9684    }
9685    const raw = String(id ?? "");
9686    if (raw.startsWith("node:") || __isBarePackageSpecifier(raw)) {
9687      return __makeMissingRequireStub(raw);
9688    }
9689    throw new Error(`Cannot find module '${raw}' in PiJS require()`);
9690  }
9691  require.resolve = function resolve(id) {
9692    // Return a synthetic path for the requested module.  This satisfies
9693    // extensions that call require.resolve() to locate a binary entry
9694    // point (e.g. @sourcegraph/scip-python) without actually needing the
9695    // real node_modules tree.
9696    return `/pijs-virtual/${String(id ?? "unknown")}`;
9697  };
9698  require.resolve.paths = function() { return []; };
9699  return require;
9700}
9701
9702export default { createRequire };
9703"#
9704        .trim()
9705        .to_string(),
9706    );
9707
9708    modules.insert(
9709        "node:fs".to_string(),
9710        r#"
9711import { Readable, Writable } from "node:stream";
9712
9713export const constants = {
9714  R_OK: 4,
9715  W_OK: 2,
9716  X_OK: 1,
9717  F_OK: 0,
9718  O_RDONLY: 0,
9719  O_WRONLY: 1,
9720  O_RDWR: 2,
9721  O_CREAT: 64,
9722  O_EXCL: 128,
9723  O_TRUNC: 512,
9724  O_APPEND: 1024,
9725};
9726const __pi_vfs = (() => {
9727  if (globalThis.__pi_vfs_state) {
9728    return globalThis.__pi_vfs_state;
9729  }
9730
9731  const state = {
9732    files: new Map(),
9733    dirs: new Set(["/"]),
9734    symlinks: new Map(),
9735    fds: new Map(),
9736    nextFd: 100,
9737  };
9738
9739  function checkWriteAccess(resolved) {
9740    if (typeof globalThis.__pi_host_check_write_access === "function") {
9741      globalThis.__pi_host_check_write_access(resolved);
9742    }
9743  }
9744
9745  function normalizePath(input) {
9746    let raw = String(input ?? "").replace(/\\/g, "/");
9747    // Strip Windows UNC verbatim prefix that canonicalize() produces.
9748    // \\?\C:\... becomes /?/C:/... after separator normalization.
9749    if (raw.startsWith("/?/") && raw.length > 5 && /^[A-Za-z]:/.test(raw.substring(3, 5))) {
9750      raw = raw.slice(3);
9751    }
9752    // Detect Windows drive-letter absolute paths (e.g. "C:/Users/...")
9753    const hasDriveLetter = raw.length >= 3 && /^[A-Za-z]:\//.test(raw);
9754    const isAbsolute = raw.startsWith("/") || hasDriveLetter;
9755    const base = isAbsolute
9756      ? raw
9757      : `${(globalThis.process && typeof globalThis.process.cwd === "function" ? globalThis.process.cwd() : "/").replace(/\\/g, "/")}/${raw}`;
9758    const parts = [];
9759    for (const part of base.split("/")) {
9760      if (!part || part === ".") continue;
9761      if (part === "..") {
9762        if (parts.length > 0) parts.pop();
9763        continue;
9764      }
9765      parts.push(part);
9766    }
9767    // Preserve drive letter prefix on Windows (D:/...) instead of /D:/...
9768    if (parts.length > 0 && /^[A-Za-z]:$/.test(parts[0])) {
9769      return `${parts[0]}/${parts.slice(1).join("/")}`;
9770    }
9771    return `/${parts.join("/")}`;
9772  }
9773
9774  function dirname(path) {
9775    const normalized = normalizePath(path);
9776    if (normalized === "/") return "/";
9777    const idx = normalized.lastIndexOf("/");
9778    return idx <= 0 ? "/" : normalized.slice(0, idx);
9779  }
9780
9781  function ensureDir(path) {
9782    const normalized = normalizePath(path);
9783    if (normalized === "/") return "/";
9784    const parts = normalized.slice(1).split("/");
9785    let current = "";
9786    for (const part of parts) {
9787      current = `${current}/${part}`;
9788      state.dirs.add(current);
9789    }
9790    return normalized;
9791  }
9792
9793  function toBytes(data, opts) {
9794    const encoding =
9795      typeof opts === "string"
9796        ? opts
9797        : opts && typeof opts === "object" && typeof opts.encoding === "string"
9798          ? opts.encoding
9799          : undefined;
9800    const normalizedEncoding = encoding ? String(encoding).toLowerCase() : "utf8";
9801
9802    if (typeof data === "string") {
9803      if (normalizedEncoding === "base64") {
9804        return Buffer.from(data, "base64");
9805      }
9806      return new TextEncoder().encode(data);
9807    }
9808    if (data instanceof Uint8Array) {
9809      return new Uint8Array(data);
9810    }
9811    if (data instanceof ArrayBuffer) {
9812      return new Uint8Array(data);
9813    }
9814    if (ArrayBuffer.isView(data)) {
9815      return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
9816    }
9817    if (Array.isArray(data)) {
9818      return new Uint8Array(data);
9819    }
9820    return new TextEncoder().encode(String(data ?? ""));
9821  }
9822
9823  function decodeBytes(bytes, opts) {
9824    const encoding =
9825      typeof opts === "string"
9826        ? opts
9827        : opts && typeof opts === "object" && typeof opts.encoding === "string"
9828          ? opts.encoding
9829          : undefined;
9830    if (!encoding || String(encoding).toLowerCase() === "buffer") {
9831      return Buffer.from(bytes);
9832    }
9833    const normalized = String(encoding).toLowerCase();
9834    if (normalized === "base64") {
9835      let binChunks = [];
9836      let chunk = [];
9837      for (let i = 0; i < bytes.length; i++) {
9838        chunk.push(bytes[i]);
9839        if (chunk.length >= 4096) {
9840          binChunks.push(String.fromCharCode.apply(null, chunk));
9841          chunk.length = 0;
9842        }
9843      }
9844      if (chunk.length > 0) {
9845        binChunks.push(String.fromCharCode.apply(null, chunk));
9846      }
9847      return btoa(binChunks.join(''));
9848    }
9849    return new TextDecoder().decode(bytes);
9850  }
9851
9852  function resolveSymlinkPath(linkPath, target) {
9853    const raw = String(target ?? "");
9854    if (raw.startsWith("/")) {
9855      return normalizePath(raw);
9856    }
9857    return normalizePath(`${dirname(linkPath)}/${raw}`);
9858  }
9859
9860  function resolvePath(path, followSymlinks = true) {
9861    let normalized = normalizePath(path);
9862    if (!followSymlinks) {
9863      return normalized;
9864    }
9865
9866    const seen = new Set();
9867    while (state.symlinks.has(normalized)) {
9868      if (seen.has(normalized)) {
9869        throw new Error(`ELOOP: too many symbolic links encountered, stat '${String(path ?? "")}'`);
9870      }
9871      seen.add(normalized);
9872      normalized = resolveSymlinkPath(normalized, state.symlinks.get(normalized));
9873    }
9874    return normalized;
9875  }
9876
9877  function parseOpenFlags(rawFlags) {
9878    if (typeof rawFlags === "number" && Number.isFinite(rawFlags)) {
9879      const flags = rawFlags | 0;
9880      const accessMode = flags & 3;
9881      const readable = accessMode === constants.O_RDONLY || accessMode === constants.O_RDWR;
9882      const writable = accessMode === constants.O_WRONLY || accessMode === constants.O_RDWR;
9883      return {
9884        readable,
9885        writable,
9886        append: (flags & constants.O_APPEND) !== 0,
9887        create: (flags & constants.O_CREAT) !== 0,
9888        truncate: (flags & constants.O_TRUNC) !== 0,
9889        exclusive: (flags & constants.O_EXCL) !== 0,
9890      };
9891    }
9892
9893    const normalized = String(rawFlags ?? "r");
9894    switch (normalized) {
9895      case "r":
9896      case "rs":
9897        return { readable: true, writable: false, append: false, create: false, truncate: false, exclusive: false };
9898      case "r+":
9899      case "rs+":
9900        return { readable: true, writable: true, append: false, create: false, truncate: false, exclusive: false };
9901      case "w":
9902        return { readable: false, writable: true, append: false, create: true, truncate: true, exclusive: false };
9903      case "w+":
9904        return { readable: true, writable: true, append: false, create: true, truncate: true, exclusive: false };
9905      case "wx":
9906        return { readable: false, writable: true, append: false, create: true, truncate: true, exclusive: true };
9907      case "wx+":
9908        return { readable: true, writable: true, append: false, create: true, truncate: true, exclusive: true };
9909      case "a":
9910      case "as":
9911        return { readable: false, writable: true, append: true, create: true, truncate: false, exclusive: false };
9912      case "a+":
9913      case "as+":
9914        return { readable: true, writable: true, append: true, create: true, truncate: false, exclusive: false };
9915      case "ax":
9916        return { readable: false, writable: true, append: true, create: true, truncate: false, exclusive: true };
9917      case "ax+":
9918        return { readable: true, writable: true, append: true, create: true, truncate: false, exclusive: true };
9919      default:
9920        throw new Error(`EINVAL: invalid open flags '${normalized}'`);
9921    }
9922  }
9923
9924  function getFdEntry(fd) {
9925    const entry = state.fds.get(fd);
9926    if (!entry) {
9927      throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
9928    }
9929    return entry;
9930  }
9931
9932  function toWritableView(buffer) {
9933    if (buffer instanceof Uint8Array) {
9934      return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
9935    }
9936    if (buffer instanceof ArrayBuffer) {
9937      return new Uint8Array(buffer);
9938    }
9939    if (ArrayBuffer.isView(buffer)) {
9940      return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
9941    }
9942    throw new Error("TypeError: buffer must be an ArrayBuffer view");
9943  }
9944
9945  function makeDirent(name, entryKind) {
9946    return {
9947      name,
9948      isDirectory() { return entryKind === "dir"; },
9949      isFile() { return entryKind === "file"; },
9950      isSymbolicLink() { return entryKind === "symlink"; },
9951    };
9952  }
9953
9954  function listChildren(path, withFileTypes) {
9955    const normalized = normalizePath(path);
9956    const prefix = normalized === "/" ? "/" : `${normalized}/`;
9957    const children = new Map();
9958
9959    for (const dir of state.dirs) {
9960      if (!dir.startsWith(prefix) || dir === normalized) continue;
9961      const rest = dir.slice(prefix.length);
9962      if (!rest || rest.includes("/")) continue;
9963      children.set(rest, "dir");
9964    }
9965    for (const file of state.files.keys()) {
9966      if (!file.startsWith(prefix)) continue;
9967      const rest = file.slice(prefix.length);
9968      if (!rest || rest.includes("/")) continue;
9969      if (!children.has(rest)) children.set(rest, "file");
9970    }
9971    for (const link of state.symlinks.keys()) {
9972      if (!link.startsWith(prefix)) continue;
9973      const rest = link.slice(prefix.length);
9974      if (!rest || rest.includes("/")) continue;
9975      if (!children.has(rest)) children.set(rest, "symlink");
9976    }
9977
9978    const names = Array.from(children.keys()).sort();
9979    if (withFileTypes) {
9980      return names.map((name) => makeDirent(name, children.get(name)));
9981    }
9982    return names;
9983  }
9984
9985  function makeStat(path, followSymlinks = true) {
9986    const normalized = normalizePath(path);
9987    const linkTarget = state.symlinks.get(normalized);
9988    if (linkTarget !== undefined) {
9989      if (!followSymlinks) {
9990        const size = new TextEncoder().encode(String(linkTarget)).byteLength;
9991        return {
9992          isFile() { return false; },
9993          isDirectory() { return false; },
9994          isSymbolicLink() { return true; },
9995          isBlockDevice() { return false; },
9996          isCharacterDevice() { return false; },
9997          isFIFO() { return false; },
9998          isSocket() { return false; },
9999          size,
10000          mode: 0o777,
10001          uid: 0,
10002          gid: 0,
10003          atimeMs: 0,
10004          mtimeMs: 0,
10005          ctimeMs: 0,
10006          birthtimeMs: 0,
10007          atime: new Date(0),
10008          mtime: new Date(0),
10009          ctime: new Date(0),
10010          birthtime: new Date(0),
10011          dev: 0,
10012          ino: 0,
10013          nlink: 1,
10014          rdev: 0,
10015          blksize: 4096,
10016          blocks: 0,
10017        };
10018      }
10019      return makeStat(resolvePath(normalized, true), true);
10020    }
10021
10022    const isDir = state.dirs.has(normalized);
10023    let bytes = state.files.get(normalized);
10024    if (!isDir && bytes === undefined && typeof globalThis.__pi_host_read_file_sync === "function") {
10025      try {
10026        const content = globalThis.__pi_host_read_file_sync(normalized);
10027        // Host read payload is base64-encoded to preserve binary file fidelity.
10028        bytes = toBytes(content, "base64");
10029        ensureDir(dirname(normalized));
10030        state.files.set(normalized, bytes);
10031      } catch (e) {
10032        const message = String((e && e.message) ? e.message : e);
10033        if (message.includes("host read denied")) {
10034          throw e;
10035        }
10036        /* not on host FS */
10037      }
10038    }
10039    const isFile = bytes !== undefined;
10040    if (!isDir && !isFile) {
10041      throw new Error(`ENOENT: no such file or directory, stat '${String(path ?? "")}'`);
10042    }
10043    const size = isFile ? bytes.byteLength : 0;
10044    return {
10045      isFile() { return isFile; },
10046      isDirectory() { return isDir; },
10047      isSymbolicLink() { return false; },
10048      isBlockDevice() { return false; },
10049      isCharacterDevice() { return false; },
10050      isFIFO() { return false; },
10051      isSocket() { return false; },
10052      size,
10053      mode: isDir ? 0o755 : 0o644,
10054      uid: 0,
10055      gid: 0,
10056      atimeMs: 0,
10057      mtimeMs: 0,
10058      ctimeMs: 0,
10059      birthtimeMs: 0,
10060      atime: new Date(0),
10061      mtime: new Date(0),
10062      ctime: new Date(0),
10063      birthtime: new Date(0),
10064      dev: 0,
10065      ino: 0,
10066      nlink: 1,
10067      rdev: 0,
10068      blksize: 4096,
10069      blocks: 0,
10070    };
10071  }
10072
10073  state.normalizePath = normalizePath;
10074  state.dirname = dirname;
10075  state.ensureDir = ensureDir;
10076  state.toBytes = toBytes;
10077  state.decodeBytes = decodeBytes;
10078  state.listChildren = listChildren;
10079  state.makeStat = makeStat;
10080  state.resolvePath = resolvePath;
10081  state.checkWriteAccess = checkWriteAccess;
10082  state.parseOpenFlags = parseOpenFlags;
10083  state.getFdEntry = getFdEntry;
10084  state.toWritableView = toWritableView;
10085  globalThis.__pi_vfs_state = state;
10086  return state;
10087})();
10088
10089export function existsSync(path) {
10090  try {
10091    statSync(path);
10092    return true;
10093  } catch (_err) {
10094    return false;
10095  }
10096}
10097
10098export function readFileSync(path, encoding) {
10099  const resolved = __pi_vfs.resolvePath(path, true);
10100  let bytes = __pi_vfs.files.get(resolved);
10101  let hostError;
10102  if (!bytes && typeof globalThis.__pi_host_read_file_sync === "function") {
10103    try {
10104      const content = globalThis.__pi_host_read_file_sync(resolved);
10105      // Host read payload is base64-encoded to preserve binary file fidelity.
10106      bytes = __pi_vfs.toBytes(content, "base64");
10107      __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
10108      __pi_vfs.files.set(resolved, bytes);
10109    } catch (e) {
10110      const message = String((e && e.message) ? e.message : e);
10111      if (message.includes("host read denied")) {
10112        throw e;
10113      }
10114      hostError = message;
10115      /* fall through to ENOENT */
10116    }
10117  }
10118  if (!bytes) {
10119    const detail = hostError ? ` (host: ${hostError})` : "";
10120    throw new Error(`ENOENT: no such file or directory, open '${String(path ?? "")}'${detail}`);
10121  }
10122  return __pi_vfs.decodeBytes(bytes, encoding);
10123}
10124
10125export function appendFileSync(path, data, opts) {
10126  const resolved = __pi_vfs.resolvePath(path, true);
10127  __pi_vfs.checkWriteAccess(resolved);
10128  const current = __pi_vfs.files.get(resolved) || new Uint8Array();
10129  const next = __pi_vfs.toBytes(data, opts);
10130  const merged = new Uint8Array(current.byteLength + next.byteLength);
10131  merged.set(current, 0);
10132  merged.set(next, current.byteLength);
10133  __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
10134  __pi_vfs.files.set(resolved, merged);
10135}
10136
10137export function writeFileSync(path, data, opts) {
10138  const resolved = __pi_vfs.resolvePath(path, true);
10139  __pi_vfs.checkWriteAccess(resolved);
10140  __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
10141  __pi_vfs.files.set(resolved, __pi_vfs.toBytes(data, opts));
10142}
10143
10144export function readdirSync(path, opts) {
10145  const resolved = __pi_vfs.resolvePath(path, true);
10146  if (!__pi_vfs.dirs.has(resolved)) {
10147    throw new Error(`ENOENT: no such file or directory, scandir '${String(path ?? "")}'`);
10148  }
10149  const withFileTypes = !!(opts && typeof opts === "object" && opts.withFileTypes);
10150  return __pi_vfs.listChildren(resolved, withFileTypes);
10151}
10152
10153const __fakeStat = {
10154  isFile() { return false; },
10155  isDirectory() { return false; },
10156  isSymbolicLink() { return false; },
10157  isBlockDevice() { return false; },
10158  isCharacterDevice() { return false; },
10159  isFIFO() { return false; },
10160  isSocket() { return false; },
10161  size: 0, mode: 0o644, uid: 0, gid: 0,
10162  atimeMs: 0, mtimeMs: 0, ctimeMs: 0, birthtimeMs: 0,
10163  atime: new Date(0), mtime: new Date(0), ctime: new Date(0), birthtime: new Date(0),
10164  dev: 0, ino: 0, nlink: 1, rdev: 0, blksize: 4096, blocks: 0,
10165};
10166export function statSync(path) { return __pi_vfs.makeStat(path, true); }
10167export function lstatSync(path) { return __pi_vfs.makeStat(path, false); }
10168export function mkdtempSync(prefix, _opts) {
10169  const p = String(prefix ?? "/tmp/tmp-");
10170  const out = `${p}${Date.now().toString(36)}`;
10171  __pi_vfs.checkWriteAccess(__pi_vfs.normalizePath(out));
10172  __pi_vfs.ensureDir(out);
10173  return out;
10174}
10175export function realpathSync(path, _opts) {
10176  return __pi_vfs.resolvePath(path, true);
10177}
10178export function unlinkSync(path) {
10179  const normalized = __pi_vfs.normalizePath(path);
10180  __pi_vfs.checkWriteAccess(normalized);
10181  if (__pi_vfs.symlinks.delete(normalized)) {
10182    return;
10183  }
10184  if (!__pi_vfs.files.delete(normalized)) {
10185    throw new Error(`ENOENT: no such file or directory, unlink '${String(path ?? "")}'`);
10186  }
10187}
10188export function rmdirSync(path, _opts) {
10189  const normalized = __pi_vfs.normalizePath(path);
10190  __pi_vfs.checkWriteAccess(normalized);
10191  if (normalized === "/") {
10192    throw new Error("EBUSY: resource busy or locked, rmdir '/'");
10193  }
10194  if (__pi_vfs.symlinks.has(normalized)) {
10195    throw new Error(`ENOTDIR: not a directory, rmdir '${String(path ?? "")}'`);
10196  }
10197  for (const filePath of __pi_vfs.files.keys()) {
10198    if (filePath.startsWith(`${normalized}/`)) {
10199      throw new Error(`ENOTEMPTY: directory not empty, rmdir '${String(path ?? "")}'`);
10200    }
10201  }
10202  for (const dirPath of __pi_vfs.dirs) {
10203    if (dirPath.startsWith(`${normalized}/`)) {
10204      throw new Error(`ENOTEMPTY: directory not empty, rmdir '${String(path ?? "")}'`);
10205    }
10206  }
10207  for (const linkPath of __pi_vfs.symlinks.keys()) {
10208    if (linkPath.startsWith(`${normalized}/`)) {
10209      throw new Error(`ENOTEMPTY: directory not empty, rmdir '${String(path ?? "")}'`);
10210    }
10211  }
10212  if (!__pi_vfs.dirs.delete(normalized)) {
10213    throw new Error(`ENOENT: no such file or directory, rmdir '${String(path ?? "")}'`);
10214  }
10215}
10216export function rmSync(path, opts) {
10217  const normalized = __pi_vfs.normalizePath(path);
10218  __pi_vfs.checkWriteAccess(normalized);
10219  if (__pi_vfs.files.has(normalized)) {
10220    __pi_vfs.files.delete(normalized);
10221    return;
10222  }
10223  if (__pi_vfs.symlinks.has(normalized)) {
10224    __pi_vfs.symlinks.delete(normalized);
10225    return;
10226  }
10227  if (__pi_vfs.dirs.has(normalized)) {
10228    const recursive = !!(opts && typeof opts === "object" && opts.recursive);
10229    if (!recursive) {
10230      rmdirSync(normalized);
10231      return;
10232    }
10233    for (const filePath of Array.from(__pi_vfs.files.keys())) {
10234      if (filePath === normalized || filePath.startsWith(`${normalized}/`)) {
10235        __pi_vfs.files.delete(filePath);
10236      }
10237    }
10238    for (const dirPath of Array.from(__pi_vfs.dirs)) {
10239      if (dirPath === normalized || dirPath.startsWith(`${normalized}/`)) {
10240        __pi_vfs.dirs.delete(dirPath);
10241      }
10242    }
10243    for (const linkPath of Array.from(__pi_vfs.symlinks.keys())) {
10244      if (linkPath === normalized || linkPath.startsWith(`${normalized}/`)) {
10245        __pi_vfs.symlinks.delete(linkPath);
10246      }
10247    }
10248    if (!__pi_vfs.dirs.has("/")) {
10249      __pi_vfs.dirs.add("/");
10250    }
10251    return;
10252  }
10253  throw new Error(`ENOENT: no such file or directory, rm '${String(path ?? "")}'`);
10254}
10255export function copyFileSync(src, dest, _mode) {
10256  writeFileSync(dest, readFileSync(src));
10257}
10258export function renameSync(oldPath, newPath) {
10259  const src = __pi_vfs.normalizePath(oldPath);
10260  const dst = __pi_vfs.normalizePath(newPath);
10261  __pi_vfs.checkWriteAccess(src);
10262  __pi_vfs.checkWriteAccess(dst);
10263  const linkTarget = __pi_vfs.symlinks.get(src);
10264  if (linkTarget !== undefined) {
10265    __pi_vfs.ensureDir(__pi_vfs.dirname(dst));
10266    __pi_vfs.symlinks.set(dst, linkTarget);
10267    __pi_vfs.symlinks.delete(src);
10268    return;
10269  }
10270  const bytes = __pi_vfs.files.get(src);
10271  if (bytes !== undefined) {
10272    __pi_vfs.ensureDir(__pi_vfs.dirname(dst));
10273    __pi_vfs.files.set(dst, bytes);
10274    __pi_vfs.files.delete(src);
10275    return;
10276  }
10277  throw new Error(`ENOENT: no such file or directory, rename '${String(oldPath ?? "")}'`);
10278}
10279export function mkdirSync(path, _opts) {
10280  const resolved = __pi_vfs.resolvePath(path, true);
10281  __pi_vfs.checkWriteAccess(resolved);
10282  __pi_vfs.ensureDir(path);
10283  return __pi_vfs.normalizePath(path);
10284}
10285export function accessSync(path, _mode) {
10286  if (!existsSync(path)) {
10287    throw new Error("ENOENT: no such file or directory");
10288  }
10289}
10290export function chmodSync(_path, _mode) { return; }
10291export function chownSync(_path, _uid, _gid) { return; }
10292export function readlinkSync(path, opts) {
10293  const normalized = __pi_vfs.normalizePath(path);
10294  if (!__pi_vfs.symlinks.has(normalized)) {
10295    if (__pi_vfs.files.has(normalized) || __pi_vfs.dirs.has(normalized)) {
10296      throw new Error(`EINVAL: invalid argument, readlink '${String(path ?? "")}'`);
10297    }
10298    throw new Error(`ENOENT: no such file or directory, readlink '${String(path ?? "")}'`);
10299  }
10300  const target = String(__pi_vfs.symlinks.get(normalized));
10301  const encoding =
10302    typeof opts === "string"
10303      ? opts
10304      : opts && typeof opts === "object" && typeof opts.encoding === "string"
10305        ? opts.encoding
10306        : undefined;
10307  if (encoding && String(encoding).toLowerCase() === "buffer") {
10308    return Buffer.from(target, "utf8");
10309  }
10310  return target;
10311}
10312export function symlinkSync(target, path, _type) {
10313  const normalized = __pi_vfs.normalizePath(path);
10314  __pi_vfs.checkWriteAccess(normalized);
10315  const parent = __pi_vfs.dirname(normalized);
10316  if (!__pi_vfs.dirs.has(parent)) {
10317    throw new Error(`ENOENT: no such file or directory, symlink '${String(path ?? "")}'`);
10318  }
10319  if (__pi_vfs.files.has(normalized) || __pi_vfs.dirs.has(normalized) || __pi_vfs.symlinks.has(normalized)) {
10320    throw new Error(`EEXIST: file already exists, symlink '${String(path ?? "")}'`);
10321  }
10322  __pi_vfs.symlinks.set(normalized, String(target ?? ""));
10323}
10324export function openSync(path, flags = "r", _mode) {
10325  const resolved = __pi_vfs.resolvePath(path, true);
10326  const opts = __pi_vfs.parseOpenFlags(flags);
10327
10328  if (opts.writable || opts.create || opts.append || opts.truncate) {
10329    __pi_vfs.checkWriteAccess(resolved);
10330  }
10331
10332  if (__pi_vfs.dirs.has(resolved)) {
10333    throw new Error(`EISDIR: illegal operation on a directory, open '${String(path ?? "")}'`);
10334  }
10335
10336  const exists = __pi_vfs.files.has(resolved);
10337  if (!exists && !opts.create) {
10338    throw new Error(`ENOENT: no such file or directory, open '${String(path ?? "")}'`);
10339  }
10340  if (exists && opts.create && opts.exclusive) {
10341    throw new Error(`EEXIST: file already exists, open '${String(path ?? "")}'`);
10342  }
10343  if (!exists && opts.create) {
10344    __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
10345    __pi_vfs.files.set(resolved, new Uint8Array());
10346  }
10347  if (opts.truncate && opts.writable) {
10348    __pi_vfs.files.set(resolved, new Uint8Array());
10349  }
10350
10351  const fd = __pi_vfs.nextFd++;
10352  const current = __pi_vfs.files.get(resolved) || new Uint8Array();
10353  __pi_vfs.fds.set(fd, {
10354    path: resolved,
10355    readable: opts.readable,
10356    writable: opts.writable,
10357    append: opts.append,
10358    position: opts.append ? current.byteLength : 0,
10359  });
10360  return fd;
10361}
10362export function closeSync(fd) {
10363  if (!__pi_vfs.fds.delete(fd)) {
10364    throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
10365  }
10366}
10367export function readSync(fd, buffer, offset = 0, length, position = null) {
10368  const entry = __pi_vfs.getFdEntry(fd);
10369  if (!entry.readable) {
10370    throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
10371  }
10372  const out = __pi_vfs.toWritableView(buffer);
10373  const start = Number.isInteger(offset) && offset >= 0 ? offset : 0;
10374  const maxLen =
10375    Number.isInteger(length) && length >= 0
10376      ? length
10377      : Math.max(0, out.byteLength - start);
10378  let cursor =
10379    typeof position === "number" && Number.isFinite(position) && position >= 0
10380      ? Math.floor(position)
10381      : entry.position;
10382  const source = __pi_vfs.files.get(entry.path) || new Uint8Array();
10383  if (cursor >= source.byteLength || maxLen <= 0 || start >= out.byteLength) {
10384    return 0;
10385  }
10386  const readLen = Math.min(maxLen, out.byteLength - start, source.byteLength - cursor);
10387  out.set(source.subarray(cursor, cursor + readLen), start);
10388  if (position === null || position === undefined) {
10389    entry.position = cursor + readLen;
10390  }
10391  return readLen;
10392}
10393export function writeSync(fd, buffer, offset, length, position) {
10394  const entry = __pi_vfs.getFdEntry(fd);
10395  if (!entry.writable) {
10396    throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
10397  }
10398
10399  let chunk;
10400  let explicitPosition = false;
10401  let cursor = null;
10402
10403  if (typeof buffer === "string") {
10404    const encoding =
10405      typeof length === "string"
10406        ? length
10407        : typeof offset === "string"
10408          ? offset
10409          : undefined;
10410    chunk = __pi_vfs.toBytes(buffer, encoding);
10411    if (
10412      arguments.length >= 3 &&
10413      typeof offset === "number" &&
10414      Number.isFinite(offset) &&
10415      offset >= 0
10416    ) {
10417      explicitPosition = true;
10418      cursor = Math.floor(offset);
10419    }
10420  } else {
10421    const input = __pi_vfs.toWritableView(buffer);
10422    const start = Number.isInteger(offset) && offset >= 0 ? offset : 0;
10423    const maxLen =
10424      Number.isInteger(length) && length >= 0
10425        ? length
10426        : Math.max(0, input.byteLength - start);
10427    chunk = input.subarray(start, Math.min(input.byteLength, start + maxLen));
10428    if (typeof position === "number" && Number.isFinite(position) && position >= 0) {
10429      explicitPosition = true;
10430      cursor = Math.floor(position);
10431    }
10432  }
10433
10434  if (!explicitPosition) {
10435    cursor = entry.append
10436      ? (__pi_vfs.files.get(entry.path)?.byteLength || 0)
10437      : entry.position;
10438  }
10439
10440  const current = __pi_vfs.files.get(entry.path) || new Uint8Array();
10441  const required = cursor + chunk.byteLength;
10442  const next = new Uint8Array(Math.max(current.byteLength, required));
10443  next.set(current, 0);
10444  next.set(chunk, cursor);
10445  __pi_vfs.files.set(entry.path, next);
10446
10447  if (!explicitPosition) {
10448    entry.position = cursor + chunk.byteLength;
10449  }
10450  return chunk.byteLength;
10451}
10452export function fstatSync(fd) {
10453  const entry = __pi_vfs.getFdEntry(fd);
10454  return __pi_vfs.makeStat(entry.path, true);
10455}
10456export function ftruncateSync(fd, len = 0) {
10457  const entry = __pi_vfs.getFdEntry(fd);
10458  if (!entry.writable) {
10459    throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
10460  }
10461  const targetLen =
10462    Number.isInteger(len) && len >= 0 ? len : 0;
10463  const current = __pi_vfs.files.get(entry.path) || new Uint8Array();
10464  const next = new Uint8Array(targetLen);
10465  next.set(current.subarray(0, Math.min(current.byteLength, targetLen)));
10466  __pi_vfs.files.set(entry.path, next);
10467  if (entry.position > targetLen) {
10468    entry.position = targetLen;
10469  }
10470}
10471export function futimesSync(_fd, _atime, _mtime) { return; }
10472function __fakeWatcher() {
10473  const w = { close() {}, unref() { return w; }, ref() { return w; }, on() { return w; }, once() { return w; }, removeListener() { return w; }, removeAllListeners() { return w; } };
10474  return w;
10475}
10476export function watch(_path, _optsOrListener, _listener) { return __fakeWatcher(); }
10477export function watchFile(_path, _optsOrListener, _listener) { return __fakeWatcher(); }
10478export function unwatchFile(_path, _listener) { return; }
10479function __queueMicrotaskPolyfill(fn) {
10480  if (typeof queueMicrotask === "function") {
10481    queueMicrotask(fn);
10482    return;
10483  }
10484  Promise.resolve().then(fn);
10485}
10486export function createReadStream(path, opts) {
10487  const options = opts && typeof opts === "object" ? opts : {};
10488  const encoding = typeof options.encoding === "string" ? options.encoding : null;
10489  const highWaterMark =
10490    Number.isInteger(options.highWaterMark) && options.highWaterMark > 0
10491      ? options.highWaterMark
10492      : 64 * 1024;
10493
10494  const stream = new Readable({ encoding: encoding || undefined, autoDestroy: false });
10495  stream.path = __pi_vfs.normalizePath(path);
10496
10497  __queueMicrotaskPolyfill(() => {
10498    try {
10499      const bytes = readFileSync(path, "buffer");
10500      const source =
10501        bytes instanceof Uint8Array
10502          ? bytes
10503          : (typeof Buffer !== "undefined" && Buffer.from
10504              ? Buffer.from(bytes)
10505              : __pi_vfs.toBytes(bytes));
10506
10507      if (source.byteLength === 0) {
10508        stream.push(null);
10509        return;
10510      }
10511
10512      let offset = 0;
10513      while (offset < source.byteLength) {
10514        const nextOffset = Math.min(source.byteLength, offset + highWaterMark);
10515        const slice = source.subarray(offset, nextOffset);
10516        if (encoding && typeof Buffer !== "undefined" && Buffer.from) {
10517          stream.push(Buffer.from(slice).toString(encoding));
10518        } else {
10519          stream.push(slice);
10520        }
10521        offset = nextOffset;
10522      }
10523      stream.push(null);
10524    } catch (err) {
10525      stream.emit("error", err instanceof Error ? err : new Error(String(err)));
10526    }
10527  });
10528
10529  return stream;
10530}
10531export function createWriteStream(path, opts) {
10532  const options = opts && typeof opts === "object" ? opts : {};
10533  const encoding = typeof options.encoding === "string" ? options.encoding : "utf8";
10534  const flags = typeof options.flags === "string" ? options.flags : "w";
10535  const appendMode = flags.startsWith("a");
10536  const bufferedChunks = [];
10537
10538  const stream = new Writable({
10539    autoDestroy: false,
10540    write(chunk, chunkEncoding, callback) {
10541      try {
10542        const normalizedEncoding =
10543          typeof chunkEncoding === "string" && chunkEncoding
10544            ? chunkEncoding
10545            : encoding;
10546        const bytes = __pi_vfs.toBytes(chunk, normalizedEncoding);
10547        bufferedChunks.push(bytes);
10548        this.bytesWritten += bytes.byteLength;
10549        callback(null);
10550      } catch (err) {
10551        callback(err instanceof Error ? err : new Error(String(err)));
10552      }
10553    },
10554    final(callback) {
10555      try {
10556        if (appendMode) {
10557          const resolved = __pi_vfs.resolvePath(path, true);
10558          __pi_vfs.checkWriteAccess(resolved);
10559          const current = __pi_vfs.files.get(resolved) || new Uint8Array();
10560          const totalSize = current.byteLength + bufferedChunks.reduce((sum, bytes) => sum + bytes.byteLength, 0);
10561          const merged = new Uint8Array(totalSize);
10562          merged.set(current, 0);
10563          let offset = current.byteLength;
10564          for (const bytes of bufferedChunks) {
10565            merged.set(bytes, offset);
10566            offset += bytes.byteLength;
10567          }
10568          __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
10569          __pi_vfs.files.set(resolved, merged);
10570        } else {
10571          const totalSize = bufferedChunks.reduce((sum, bytes) => sum + bytes.byteLength, 0);
10572          const merged = new Uint8Array(totalSize);
10573          let offset = 0;
10574          for (const bytes of bufferedChunks) {
10575            merged.set(bytes, offset);
10576            offset += bytes.byteLength;
10577          }
10578          writeFileSync(path, merged);
10579        }
10580        callback(null);
10581      } catch (err) {
10582        callback(err instanceof Error ? err : new Error(String(err)));
10583      }
10584    },
10585  });
10586  stream.path = __pi_vfs.normalizePath(path);
10587  stream.bytesWritten = 0;
10588  stream.cork = () => stream;
10589  stream.uncork = () => stream;
10590  return stream;
10591}
10592export function readFile(path, optOrCb, cb) {
10593  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10594  const encoding = typeof optOrCb === 'function' ? undefined : optOrCb;
10595  if (typeof callback === 'function') {
10596    try { callback(null, readFileSync(path, encoding)); }
10597    catch (err) { callback(err); }
10598  }
10599}
10600export function writeFile(path, data, optOrCb, cb) {
10601  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10602  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10603  if (typeof callback === 'function') {
10604    try { writeFileSync(path, data, opts); callback(null); }
10605    catch (err) { callback(err); }
10606  }
10607}
10608export function stat(path, optOrCb, cb) {
10609  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10610  if (typeof callback === 'function') {
10611    try { callback(null, statSync(path)); }
10612    catch (err) { callback(err); }
10613  }
10614}
10615export function readdir(path, optOrCb, cb) {
10616  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10617  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10618  if (typeof callback === 'function') {
10619    try { callback(null, readdirSync(path, opts)); }
10620    catch (err) { callback(err); }
10621  }
10622}
10623export function mkdir(path, optOrCb, cb) {
10624  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10625  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10626  if (typeof callback === 'function') {
10627    try { callback(null, mkdirSync(path, opts)); }
10628    catch (err) { callback(err); }
10629  }
10630}
10631export function unlink(path, cb) {
10632  if (typeof cb === 'function') {
10633    try { unlinkSync(path); cb(null); }
10634    catch (err) { cb(err); }
10635  }
10636}
10637export function readlink(path, optOrCb, cb) {
10638  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10639  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10640  if (typeof callback === 'function') {
10641    try { callback(null, readlinkSync(path, opts)); }
10642    catch (err) { callback(err); }
10643  }
10644}
10645export function symlink(target, path, typeOrCb, cb) {
10646  const callback = typeof typeOrCb === 'function' ? typeOrCb : cb;
10647  const type = typeof typeOrCb === 'function' ? undefined : typeOrCb;
10648  if (typeof callback === 'function') {
10649    try { symlinkSync(target, path, type); callback(null); }
10650    catch (err) { callback(err); }
10651  }
10652}
10653export function lstat(path, optOrCb, cb) {
10654  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10655  if (typeof callback === 'function') {
10656    try { callback(null, lstatSync(path)); }
10657    catch (err) { callback(err); }
10658  }
10659}
10660export function rmdir(path, optOrCb, cb) {
10661  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10662  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10663  if (typeof callback === 'function') {
10664    try { rmdirSync(path, opts); callback(null); }
10665    catch (err) { callback(err); }
10666  }
10667}
10668export function rm(path, optOrCb, cb) {
10669  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10670  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10671  if (typeof callback === 'function') {
10672    try { rmSync(path, opts); callback(null); }
10673    catch (err) { callback(err); }
10674  }
10675}
10676export function rename(oldPath, newPath, cb) {
10677  if (typeof cb === 'function') {
10678    try { renameSync(oldPath, newPath); cb(null); }
10679    catch (err) { cb(err); }
10680  }
10681}
10682export function copyFile(src, dest, flagsOrCb, cb) {
10683  const callback = typeof flagsOrCb === 'function' ? flagsOrCb : cb;
10684  if (typeof callback === 'function') {
10685    try { copyFileSync(src, dest); callback(null); }
10686    catch (err) { callback(err); }
10687  }
10688}
10689export function appendFile(path, data, optOrCb, cb) {
10690  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10691  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10692  if (typeof callback === 'function') {
10693    try { appendFileSync(path, data, opts); callback(null); }
10694    catch (err) { callback(err); }
10695  }
10696}
10697export function chmod(path, mode, cb) {
10698  if (typeof cb === 'function') {
10699    try { chmodSync(path, mode); cb(null); }
10700    catch (err) { cb(err); }
10701  }
10702}
10703export function chown(path, uid, gid, cb) {
10704  if (typeof cb === 'function') {
10705    try { chownSync(path, uid, gid); cb(null); }
10706    catch (err) { cb(err); }
10707  }
10708}
10709export function realpath(path, optOrCb, cb) {
10710  const callback = typeof optOrCb === 'function' ? optOrCb : cb;
10711  const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
10712  if (typeof callback === 'function') {
10713    try { callback(null, realpathSync(path, opts)); }
10714    catch (err) { callback(err); }
10715  }
10716}
10717export function access(_path, modeOrCb, cb) {
10718  const callback = typeof modeOrCb === 'function' ? modeOrCb : cb;
10719  if (typeof callback === 'function') {
10720    try {
10721      accessSync(_path);
10722      callback(null);
10723    } catch (err) {
10724      callback(err);
10725    }
10726  }
10727}
10728export const promises = {
10729  access: async (path, _mode) => accessSync(path),
10730  mkdir: async (path, opts) => mkdirSync(path, opts),
10731  mkdtemp: async (prefix, _opts) => {
10732    return mkdtempSync(prefix, _opts);
10733  },
10734  readFile: async (path, opts) => readFileSync(path, opts),
10735  writeFile: async (path, data, opts) => writeFileSync(path, data, opts),
10736  unlink: async (path) => unlinkSync(path),
10737  readlink: async (path, opts) => readlinkSync(path, opts),
10738  symlink: async (target, path, type) => symlinkSync(target, path, type),
10739  rmdir: async (path, opts) => rmdirSync(path, opts),
10740  stat: async (path) => statSync(path),
10741  lstat: async (path) => lstatSync(path),
10742  realpath: async (path, _opts) => realpathSync(path, _opts),
10743  readdir: async (path, opts) => readdirSync(path, opts),
10744  rm: async (path, opts) => rmSync(path, opts),
10745  rename: async (oldPath, newPath) => renameSync(oldPath, newPath),
10746  copyFile: async (src, dest, mode) => copyFileSync(src, dest, mode),
10747  cp: async (src, dest, opts) => {
10748    if (opts && opts.recursive) {
10749      throw new Error("node:fs.promises.cp recursive copy is not supported in PiJS");
10750    }
10751    return copyFileSync(src, dest);
10752  },
10753  appendFile: async (path, data, opts) => appendFileSync(path, data, opts),
10754  chmod: async (_path, _mode) => {},
10755};
10756export 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 };
10757"#
10758        .trim()
10759        .to_string(),
10760    );
10761
10762    modules.insert(
10763        "node:fs/promises".to_string(),
10764        r"
10765import fs from 'node:fs';
10766
10767export async function access(path, mode) { return fs.promises.access(path, mode); }
10768export async function mkdir(path, opts) { return fs.promises.mkdir(path, opts); }
10769export async function mkdtemp(prefix, opts) { return fs.promises.mkdtemp(prefix, opts); }
10770export async function readFile(path, opts) { return fs.promises.readFile(path, opts); }
10771export async function writeFile(path, data, opts) { return fs.promises.writeFile(path, data, opts); }
10772export async function unlink(path) { return fs.promises.unlink(path); }
10773export async function readlink(path, opts) { return fs.promises.readlink(path, opts); }
10774export async function symlink(target, path, type) { return fs.promises.symlink(target, path, type); }
10775export async function rmdir(path, opts) { return fs.promises.rmdir(path, opts); }
10776export async function stat(path) { return fs.promises.stat(path); }
10777export async function realpath(path, opts) { return fs.promises.realpath(path, opts); }
10778export async function readdir(path, opts) { return fs.promises.readdir(path, opts); }
10779export async function rm(path, opts) { return fs.promises.rm(path, opts); }
10780export async function lstat(path) { return fs.promises.lstat(path); }
10781export async function copyFile(src, dest) { return fs.promises.copyFile(src, dest); }
10782export async function cp(src, dest, opts = {}) {
10783  if (typeof fs.promises.cp === 'function') {
10784    return fs.promises.cp(src, dest, opts);
10785  }
10786  if (opts && opts.recursive) {
10787    throw new Error('node:fs/promises.cp recursive copy is not supported in PiJS');
10788  }
10789  return fs.promises.copyFile(src, dest);
10790}
10791export async function rename(oldPath, newPath) { return fs.promises.rename(oldPath, newPath); }
10792export async function chmod(path, mode) { return; }
10793export async function chown(path, uid, gid) { return; }
10794export async function utimes(path, atime, mtime) { return; }
10795export async function appendFile(path, data, opts) { return fs.promises.appendFile(path, data, opts); }
10796export async function open(path, flags, mode) { return { close: async () => {} }; }
10797export async function truncate(path, len) { return; }
10798export default { access, mkdir, mkdtemp, readFile, writeFile, unlink, readlink, symlink, rmdir, stat, lstat, realpath, readdir, rm, copyFile, cp, rename, chmod, chown, utimes, appendFile, open, truncate };
10799"
10800        .trim()
10801        .to_string(),
10802    );
10803
10804    modules.insert(
10805        "node:http".to_string(),
10806        crate::http_shim::NODE_HTTP_JS.trim().to_string(),
10807    );
10808
10809    modules.insert(
10810        "node:https".to_string(),
10811        crate::http_shim::NODE_HTTPS_JS.trim().to_string(),
10812    );
10813
10814    modules.insert(
10815        "node:http2".to_string(),
10816        r#"
10817import EventEmitter from "node:events";
10818
10819export const constants = {
10820  HTTP2_HEADER_STATUS: ":status",
10821  HTTP2_HEADER_METHOD: ":method",
10822  HTTP2_HEADER_PATH: ":path",
10823  HTTP2_HEADER_AUTHORITY: ":authority",
10824  HTTP2_HEADER_SCHEME: ":scheme",
10825  HTTP2_HEADER_PROTOCOL: ":protocol",
10826  HTTP2_HEADER_CONTENT_TYPE: "content-type",
10827  NGHTTP2_CANCEL: 8,
10828};
10829
10830function __makeStream() {
10831  const stream = new EventEmitter();
10832  stream.end = (_data, _encoding, cb) => {
10833    if (typeof cb === "function") cb();
10834    stream.emit("finish");
10835  };
10836  stream.close = () => stream.emit("close");
10837  stream.destroy = (err) => {
10838    if (err) stream.emit("error", err);
10839    stream.emit("close");
10840  };
10841  stream.respond = () => {};
10842  stream.setEncoding = () => stream;
10843  stream.setTimeout = (_ms, cb) => {
10844    if (typeof cb === "function") cb();
10845    return stream;
10846  };
10847  return stream;
10848}
10849
10850function __makeSession() {
10851  const session = new EventEmitter();
10852  session.closed = false;
10853  session.connecting = false;
10854  session.request = (_headers, _opts) => __makeStream();
10855  session.close = () => {
10856    session.closed = true;
10857    session.emit("close");
10858  };
10859  session.destroy = (err) => {
10860    session.closed = true;
10861    if (err) session.emit("error", err);
10862    session.emit("close");
10863  };
10864  session.ref = () => session;
10865  session.unref = () => session;
10866  return session;
10867}
10868
10869export function connect(_authority, _options, listener) {
10870  const session = __makeSession();
10871  if (typeof listener === "function") {
10872    try {
10873      listener(session);
10874    } catch (_err) {}
10875  }
10876  return session;
10877}
10878
10879export class ClientHttp2Session extends EventEmitter {}
10880export class ClientHttp2Stream extends EventEmitter {}
10881
10882export default { connect, constants, ClientHttp2Session, ClientHttp2Stream };
10883"#
10884        .trim()
10885        .to_string(),
10886    );
10887
10888    modules.insert(
10889        "node:util".to_string(),
10890        r#"
10891export function inspect(value, opts) {
10892  const depth = (opts && typeof opts.depth === 'number') ? opts.depth : 2;
10893  const seen = new Set();
10894  function fmt(v, d) {
10895    if (v === null) return 'null';
10896    if (v === undefined) return 'undefined';
10897    const t = typeof v;
10898    if (t === 'string') return d > 0 ? "'" + v + "'" : v;
10899    if (t === 'number' || t === 'boolean' || t === 'bigint') return String(v);
10900    if (t === 'symbol') return v.toString();
10901    if (t === 'function') return '[Function: ' + (v.name || 'anonymous') + ']';
10902    if (v instanceof Date) return v.toISOString();
10903    if (v instanceof RegExp) return v.toString();
10904    if (v instanceof Error) return v.stack || v.message || String(v);
10905    if (seen.has(v)) return '[Circular]';
10906    seen.add(v);
10907    if (d > depth) { seen.delete(v); return Array.isArray(v) ? '[Array]' : '[Object]'; }
10908    if (Array.isArray(v)) {
10909      const items = v.map(x => fmt(x, d + 1));
10910      seen.delete(v);
10911      return '[ ' + items.join(', ') + ' ]';
10912    }
10913    const keys = Object.keys(v);
10914    if (keys.length === 0) { seen.delete(v); return '{}'; }
10915    const pairs = keys.map(k => k + ': ' + fmt(v[k], d + 1));
10916    seen.delete(v);
10917    return '{ ' + pairs.join(', ') + ' }';
10918  }
10919  return fmt(value, 0);
10920}
10921
10922export function promisify(fn) {
10923  return (...args) => new Promise((resolve, reject) => {
10924    try {
10925      fn(...args, (err, result) => {
10926        if (err) reject(err);
10927        else resolve(result);
10928      });
10929    } catch (e) {
10930      reject(e);
10931    }
10932  });
10933}
10934
10935export function stripVTControlCharacters(str) {
10936  // eslint-disable-next-line no-control-regex
10937  return (str || '').replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '').replace(/\x1B\][^\x07]*\x07/g, '');
10938}
10939
10940export function deprecate(fn, msg) {
10941  let warned = false;
10942  return function(...args) {
10943    if (!warned) { warned = true; if (typeof console !== 'undefined') console.error('DeprecationWarning: ' + (msg || '')); }
10944    return fn.apply(this, args);
10945  };
10946}
10947export function inherits(ctor, superCtor) {
10948  if (!ctor || !superCtor) return ctor;
10949  const ctorProto = ctor && ctor.prototype;
10950  const superProto = superCtor && superCtor.prototype;
10951  if (!ctorProto || !superProto || typeof ctorProto !== 'object' || typeof superProto !== 'object') {
10952    try { ctor.super_ = superCtor; } catch (_) {}
10953    return ctor;
10954  }
10955  try {
10956    Object.setPrototypeOf(ctorProto, superProto);
10957    ctor.super_ = superCtor;
10958  } catch (_) {
10959    try { ctor.super_ = superCtor; } catch (_ignored) {}
10960  }
10961  return ctor;
10962}
10963export function debuglog(section) {
10964  const env = (typeof process !== 'undefined' && process.env && process.env.NODE_DEBUG) || '';
10965  const enabled = env.split(',').some(s => s.trim().toLowerCase() === (section || '').toLowerCase());
10966  if (!enabled) return () => {};
10967  return (...args) => { if (typeof console !== 'undefined') console.error(section.toUpperCase() + ': ' + args.map(String).join(' ')); };
10968}
10969export function format(f, ...args) {
10970  if (typeof f !== 'string') return [f, ...args].map(v => typeof v === 'string' ? v : inspect(v)).join(' ');
10971  let i = 0;
10972  let result = f.replace(/%[sdifjoO%]/g, (m) => {
10973    if (m === '%%') return '%';
10974    if (i >= args.length) return m;
10975    const a = args[i++];
10976    switch (m) {
10977      case '%s': return String(a);
10978      case '%d': case '%f': return Number(a).toString();
10979      case '%i': return parseInt(a, 10).toString();
10980      case '%j': try { return JSON.stringify(a); } catch { return '[Circular]'; }
10981      case '%o': case '%O': return inspect(a);
10982      default: return m;
10983    }
10984  });
10985  while (i < args.length) result += ' ' + (typeof args[i] === 'string' ? args[i] : inspect(args[i])), i++;
10986  return result;
10987}
10988export function callbackify(fn) {
10989  return function(...args) {
10990    const cb = args.pop();
10991    fn(...args).then(r => cb(null, r), e => cb(e));
10992  };
10993}
10994export const types = {
10995  isAsyncFunction: (fn) => typeof fn === 'function' && fn.constructor && fn.constructor.name === 'AsyncFunction',
10996  isPromise: (v) => v instanceof Promise,
10997  isDate: (v) => v instanceof Date,
10998  isRegExp: (v) => v instanceof RegExp,
10999  isNativeError: (v) => v instanceof Error,
11000  isSet: (v) => v instanceof Set,
11001  isMap: (v) => v instanceof Map,
11002  isTypedArray: (v) => ArrayBuffer.isView(v) && !(v instanceof DataView),
11003  isArrayBuffer: (v) => v instanceof ArrayBuffer,
11004  isArrayBufferView: (v) => ArrayBuffer.isView(v),
11005  isDataView: (v) => v instanceof DataView,
11006  isGeneratorFunction: (fn) => typeof fn === 'function' && fn.constructor && fn.constructor.name === 'GeneratorFunction',
11007  isGeneratorObject: (v) => v && typeof v.next === 'function' && typeof v.throw === 'function',
11008  isBooleanObject: (v) => typeof v === 'object' && v instanceof Boolean,
11009  isNumberObject: (v) => typeof v === 'object' && v instanceof Number,
11010  isStringObject: (v) => typeof v === 'object' && v instanceof String,
11011  isSymbolObject: () => false,
11012  isWeakMap: (v) => v instanceof WeakMap,
11013  isWeakSet: (v) => v instanceof WeakSet,
11014};
11015export const TextEncoder = globalThis.TextEncoder;
11016export const TextDecoder = globalThis.TextDecoder;
11017
11018export default { inspect, promisify, stripVTControlCharacters, deprecate, inherits, debuglog, format, callbackify, types, TextEncoder, TextDecoder };
11019"#
11020        .trim()
11021        .to_string(),
11022    );
11023
11024    modules.insert(
11025        "node:crypto".to_string(),
11026        crate::crypto_shim::NODE_CRYPTO_JS.trim().to_string(),
11027    );
11028
11029    modules.insert(
11030        "node:readline".to_string(),
11031        r"
11032// Readline shim backed by pi.ui('input') when UI is available.
11033
11034function __pi_readline_prompt(query) {
11035  const message = String(query === undefined || query === null ? '' : query);
11036  const piRef = globalThis.pi;
11037  if (piRef && typeof piRef.ui === 'function') {
11038    try {
11039      return Promise.resolve(
11040        piRef.ui('input', { title: message })
11041      ).then((value) => (value === undefined || value === null ? '' : String(value)))
11042        .catch(() => '');
11043    } catch (_err) {
11044      return Promise.resolve('');
11045    }
11046  }
11047  return Promise.resolve('');
11048}
11049
11050export function createInterface(_opts) {
11051  return {
11052    question: (query, optionsOrCb, maybeCb) => {
11053      const cb = typeof optionsOrCb === 'function' ? optionsOrCb : maybeCb;
11054      if (typeof cb !== 'function') {
11055        void __pi_readline_prompt(query);
11056        return;
11057      }
11058      if (!globalThis.pi || typeof globalThis.pi.ui !== 'function') {
11059        cb('');
11060        return;
11061      }
11062      void __pi_readline_prompt(query).then((answer) => {
11063        cb(answer);
11064      });
11065    },
11066    close: () => {},
11067    on: () => {},
11068    once: () => {},
11069  };
11070}
11071
11072export const promises = {
11073  createInterface: (_opts) => ({
11074    question: async (query) => __pi_readline_prompt(query),
11075    close: () => {},
11076    [Symbol.asyncIterator]: async function* () {},
11077  }),
11078};
11079
11080export default { createInterface, promises };
11081"
11082        .trim()
11083        .to_string(),
11084    );
11085
11086    modules.insert(
11087        "node:readline/promises".to_string(),
11088        r#"
11089import { promises as readlinePromises } from "node:readline";
11090
11091export const createInterface = readlinePromises.createInterface;
11092export default { createInterface };
11093"#
11094        .trim()
11095        .to_string(),
11096    );
11097
11098    modules.insert(
11099        "node:url".to_string(),
11100        r"
11101export function fileURLToPath(url) {
11102  const u = String(url ?? '');
11103  if (u.startsWith('file://')) {
11104    let p = decodeURIComponent(u.slice(7));
11105    // file:///C:/... → C:/... (strip leading / before Windows drive letter)
11106    if (p.length >= 3 && p[0] === '/' && p[2] === ':') { p = p.slice(1); }
11107    return p;
11108  }
11109  return u;
11110}
11111export function pathToFileURL(path) {
11112  return new URL('file://' + encodeURI(String(path ?? '')));
11113}
11114
11115// Use built-in URL if available (QuickJS may have it), else provide polyfill
11116const _URL = globalThis.URL || (() => {
11117  class URLPolyfill {
11118    constructor(input, base) {
11119      let u = String(input ?? '');
11120      if (base !== undefined) {
11121        const b = String(base);
11122        if (u.startsWith('/')) {
11123          const m = b.match(/^([^:]+:\/\/[^\/]+)/);
11124          u = m ? m[1] + u : b + u;
11125        } else if (!/^[a-z][a-z0-9+.-]*:/i.test(u)) {
11126          u = b.replace(/[^\/]*$/, '') + u;
11127        }
11128      }
11129      this.href = u;
11130      const protoEnd = u.indexOf(':');
11131      this.protocol = protoEnd >= 0 ? u.slice(0, protoEnd + 1) : '';
11132      let rest = protoEnd >= 0 ? u.slice(protoEnd + 1) : u;
11133      this.username = ''; this.password  = '';
11134      if (rest.startsWith('//')) {
11135        rest = rest.slice(2);
11136        const pathStart = rest.indexOf('/');
11137        const authority = pathStart >= 0 ? rest.slice(0, pathStart) : rest;
11138        rest = pathStart >= 0 ? rest.slice(pathStart) : '/';
11139        const atIdx = authority.indexOf('@');
11140        let hostPart = authority;
11141        if (atIdx >= 0) {
11142          const userInfo = authority.slice(0, atIdx);
11143          hostPart = authority.slice(atIdx + 1);
11144          const colonIdx = userInfo.indexOf(':');
11145          if (colonIdx >= 0) {
11146            this.username = userInfo.slice(0, colonIdx);
11147            this.password  = userInfo.slice(colonIdx + 1);
11148          } else {
11149            this.username = userInfo;
11150          }
11151        }
11152        const portIdx = hostPart.lastIndexOf(':');
11153        if (portIdx >= 0 && /^\d+$/.test(hostPart.slice(portIdx + 1))) {
11154          this.hostname = hostPart.slice(0, portIdx);
11155          this.port = hostPart.slice(portIdx + 1);
11156        } else {
11157          this.hostname = hostPart;
11158          this.port = '';
11159        }
11160        this.host = this.port ? this.hostname + ':' + this.port : this.hostname;
11161        this.origin = this.protocol + '//' + this.host;
11162      } else {
11163        this.hostname = ''; this.host = ''; this.port = '';
11164        this.origin = 'null';
11165      }
11166      const hashIdx = rest.indexOf('#');
11167      if (hashIdx >= 0) {
11168        this.hash = rest.slice(hashIdx);
11169        rest = rest.slice(0, hashIdx);
11170      } else {
11171        this.hash = '';
11172      }
11173      const qIdx = rest.indexOf('?');
11174      if (qIdx >= 0) {
11175        this.search = rest.slice(qIdx);
11176        this.pathname = rest.slice(0, qIdx) || '/';
11177      } else {
11178        this.search = '';
11179        this.pathname = rest || '/';
11180      }
11181      this.searchParams = new _URLSearchParams(this.search.slice(1));
11182    }
11183    toString() { return this.href; }
11184    toJSON() { return this.href; }
11185  }
11186  return URLPolyfill;
11187})();
11188
11189// Always use our polyfill — QuickJS built-in URLSearchParams may not support string init
11190const _URLSearchParams = class URLSearchParamsPolyfill {
11191  constructor(init) {
11192    this._entries = [];
11193    if (typeof init === 'string') {
11194      const s = init.startsWith('?') ? init.slice(1) : init;
11195      if (s) {
11196        for (const pair of s.split('&')) {
11197          const eqIdx = pair.indexOf('=');
11198          if (eqIdx >= 0) {
11199            this._entries.push([decodeURIComponent(pair.slice(0, eqIdx)), decodeURIComponent(pair.slice(eqIdx + 1))]);
11200          } else {
11201            this._entries.push([decodeURIComponent(pair), '']);
11202          }
11203        }
11204      }
11205    }
11206  }
11207  get(key) {
11208    for (const [k, v] of this._entries) { if (k === key) return v; }
11209    return null;
11210  }
11211  set(key, val) {
11212    let found = false;
11213    this._entries = this._entries.filter(([k]) => {
11214      if (k === key && !found) { found = true; return true; }
11215      return k !== key;
11216    });
11217    if (found) {
11218      for (let i = 0; i < this._entries.length; i++) {
11219        if (this._entries[i][0] === key) { this._entries[i][1] = String(val); break; }
11220      }
11221    } else {
11222      this._entries.push([key, String(val)]);
11223    }
11224  }
11225  has(key) { return this._entries.some(([k]) => k === key); }
11226  delete(key) { this._entries = this._entries.filter(([k]) => k !== key); }
11227  append(key, val) { this._entries.push([key, String(val)]); }
11228  getAll(key) { return this._entries.filter(([k]) => k === key).map(([, v]) => v); }
11229  keys() { return this._entries.map(([k]) => k)[Symbol.iterator](); }
11230  values() { return this._entries.map(([, v]) => v)[Symbol.iterator](); }
11231  entries() { return this._entries.slice()[Symbol.iterator](); }
11232  forEach(fn, thisArg) { for (const [k, v] of this._entries) fn.call(thisArg, v, k, this); }
11233  toString() {
11234    return this._entries.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
11235  }
11236  [Symbol.iterator]() { return this.entries(); }
11237  get size() { return this._entries.length; }
11238};
11239
11240export { _URL as URL, _URLSearchParams as URLSearchParams };
11241export function format(urlObj) {
11242  if (typeof urlObj === 'string') return urlObj;
11243  return urlObj && typeof urlObj.href === 'string' ? urlObj.href : String(urlObj);
11244}
11245export function parse(urlStr) {
11246  try { return new _URL(urlStr); } catch (_) { return null; }
11247}
11248export function resolve(from, to) {
11249  try { return new _URL(to, from).href; } catch (_) { return to; }
11250}
11251export default { URL: _URL, URLSearchParams: _URLSearchParams, fileURLToPath, pathToFileURL, format, parse, resolve };
11252"
11253        .trim()
11254        .to_string(),
11255    );
11256
11257    modules.insert(
11258        "node:net".to_string(),
11259        r"
11260import EventEmitter from 'node:events';
11261
11262// Stub net module - socket operations are not available in PiJS (no network I/O)
11263
11264function __pi_net_schedule(fn) {
11265  if (typeof globalThis.setTimeout === 'function') {
11266    globalThis.setTimeout(fn, 0);
11267    return;
11268  }
11269  if (typeof queueMicrotask === 'function') {
11270    queueMicrotask(fn);
11271    return;
11272  }
11273  fn();
11274}
11275
11276function __pi_net_bytes(data) {
11277  if (typeof data === 'string') return data.length;
11278  if (data && typeof data.byteLength === 'number') return data.byteLength;
11279  if (data && typeof data.length === 'number') return data.length;
11280  return 0;
11281}
11282
11283function __pi_net_parse_args(args) {
11284  let options = {};
11285  let connectListener = null;
11286  if (!args || args.length === 0) return { options, connectListener };
11287
11288  const first = args[0];
11289  if (typeof first === 'function') {
11290    connectListener = first;
11291    return { options, connectListener };
11292  }
11293
11294  if (first && typeof first === 'object' && !Array.isArray(first)) {
11295    options = { ...first };
11296    if (typeof args[1] === 'function') connectListener = args[1];
11297    return { options, connectListener };
11298  }
11299
11300  if (typeof first === 'number' || typeof first === 'string') {
11301    options.port = first;
11302    if (typeof args[1] === 'string') {
11303      options.host = args[1];
11304      if (typeof args[2] === 'function') connectListener = args[2];
11305    } else if (typeof args[1] === 'function') {
11306      connectListener = args[1];
11307    }
11308  }
11309
11310  return { options, connectListener };
11311}
11312
11313function __pi_net_apply_options(socket, options) {
11314  const opts = options && typeof options === 'object' ? options : {};
11315  const host = opts.host ?? opts.hostname ?? socket.remoteAddress ?? '127.0.0.1';
11316  const port = opts.port ?? socket.remotePort ?? 0;
11317  socket.remoteAddress = String(host);
11318  socket.remotePort = Number(port) || 0;
11319  socket.localAddress = socket.localAddress || '127.0.0.1';
11320  socket.localPort = socket.localPort || 0;
11321}
11322
11323function __pi_net_finish_connect(socket) {
11324  __pi_net_schedule(() => {
11325    if (socket.destroyed) return;
11326    socket.connecting = false;
11327    socket.readyState = 'open';
11328    socket.emit('connect');
11329  });
11330}
11331
11332export class Socket extends EventEmitter {
11333  constructor(options = {}) {
11334    super();
11335    this.destroyed = false;
11336    this.connecting = false;
11337    this.readyState = 'closed';
11338    this.bytesWritten = 0;
11339    this.bytesRead = 0;
11340    this.localAddress = '127.0.0.1';
11341    this.localPort = 0;
11342    this.remoteAddress = '127.0.0.1';
11343    this.remotePort = 0;
11344    __pi_net_apply_options(this, options);
11345  }
11346
11347  connect(...args) {
11348    const { options, connectListener } = __pi_net_parse_args(args);
11349    __pi_net_apply_options(this, options);
11350    if (typeof connectListener === 'function') this.once('connect', connectListener);
11351    this.connecting = true;
11352    this.readyState = 'opening';
11353    __pi_net_finish_connect(this);
11354    return this;
11355  }
11356
11357  write(data, _encoding, cb) {
11358    this.bytesWritten += __pi_net_bytes(data);
11359    if (typeof cb === 'function') cb(null);
11360    return true;
11361  }
11362
11363  end(data, _encoding, cb) {
11364    if (data !== undefined) this.write(data);
11365    if (typeof cb === 'function') cb(null);
11366    this.destroy();
11367    return this;
11368  }
11369
11370  destroy(err) {
11371    if (this.destroyed) return this;
11372    this.destroyed = true;
11373    this.connecting = false;
11374    this.readyState = 'closed';
11375    if (err) this.emit('error', err);
11376    this.emit('close');
11377    return this;
11378  }
11379
11380  setTimeout(ms, cb) {
11381    if (typeof cb === 'function' && typeof globalThis.setTimeout === 'function') {
11382      globalThis.setTimeout(cb, ms);
11383    }
11384    return this;
11385  }
11386
11387  setNoDelay() { return this; }
11388  setKeepAlive() { return this; }
11389  ref() { return this; }
11390  unref() { return this; }
11391  address() { return { address: this.localAddress, port: this.localPort, family: 'IPv4' }; }
11392}
11393
11394export function createConnection(...args) {
11395  const socket = new Socket();
11396  socket.connect(...args);
11397  return socket;
11398}
11399
11400export function connect(...args) {
11401  return createConnection(...args);
11402}
11403
11404export function createServer(_opts, _callback) {
11405  throw new Error('node:net.createServer is not available in PiJS');
11406}
11407
11408function __pi_net_is_ipv4(input) {
11409  const value = String(input ?? '');
11410  const parts = value.split('.');
11411  if (parts.length !== 4) return false;
11412  for (const part of parts) {
11413    if (!/^\d{1,3}$/.test(part)) return false;
11414    const num = Number(part);
11415    if (!Number.isFinite(num) || num < 0 || num > 255) return false;
11416  }
11417  return true;
11418}
11419
11420function __pi_net_ipv6_segment_count(segments) {
11421  if (segments.length === 1 && segments[0] === '') return 0;
11422  let count = 0;
11423  for (const seg of segments) {
11424    if (seg === '') return null;
11425    if (seg.includes('.')) {
11426      if (!__pi_net_is_ipv4(seg)) return null;
11427      count += 2;
11428      continue;
11429    }
11430    if (!/^[0-9a-fA-F]{1,4}$/.test(seg)) return null;
11431    count += 1;
11432  }
11433  return count;
11434}
11435
11436function __pi_net_is_ipv6(input) {
11437  const value = String(input ?? '').toLowerCase();
11438  if (!value.includes(':')) return false;
11439  if (value.indexOf('::') !== value.lastIndexOf('::')) return false;
11440  const parts = value.split('::');
11441  const head = parts[0] ? parts[0].split(':') : [''];
11442  const tail = parts[1] ? parts[1].split(':') : [''];
11443  const headCount = __pi_net_ipv6_segment_count(head);
11444  const tailCount = __pi_net_ipv6_segment_count(tail);
11445  if (headCount === null || tailCount === null) return false;
11446  if (parts.length === 1) return headCount === 8;
11447  return headCount + tailCount <= 8;
11448}
11449
11450export function isIP(input) {
11451  if (__pi_net_is_ipv4(input)) return 4;
11452  if (__pi_net_is_ipv6(input)) return 6;
11453  return 0;
11454}
11455
11456export function isIPv4(input) { return __pi_net_is_ipv4(input); }
11457export function isIPv6(input) { return __pi_net_is_ipv6(input); }
11458
11459export class Server {
11460  constructor() {
11461    throw new Error('node:net.Server is not available in PiJS');
11462  }
11463}
11464
11465export default { createConnection, createServer, connect, isIP, isIPv4, isIPv6, Socket, Server };
11466"
11467        .trim()
11468        .to_string(),
11469    );
11470
11471    // ── node:events ──────────────────────────────────────────────────
11472    modules.insert(
11473        "node:events".to_string(),
11474        r"
11475class EventEmitter {
11476  constructor() {
11477    this._events = Object.create(null);
11478    this._maxListeners = 10;
11479  }
11480
11481  on(event, listener) {
11482    if (!this._events[event]) this._events[event] = [];
11483    this._events[event].push(listener);
11484    return this;
11485  }
11486
11487  addListener(event, listener) { return this.on(event, listener); }
11488
11489  once(event, listener) {
11490    const wrapper = (...args) => {
11491      this.removeListener(event, wrapper);
11492      listener.apply(this, args);
11493    };
11494    wrapper._original = listener;
11495    return this.on(event, wrapper);
11496  }
11497
11498  off(event, listener) { return this.removeListener(event, listener); }
11499
11500  removeListener(event, listener) {
11501    const list = this._events[event];
11502    if (!list) return this;
11503    this._events[event] = list.filter(
11504      fn => fn !== listener && fn._original !== listener
11505    );
11506    if (this._events[event].length === 0) delete this._events[event];
11507    return this;
11508  }
11509
11510  removeAllListeners(event) {
11511    if (event === undefined) {
11512      this._events = Object.create(null);
11513    } else {
11514      delete this._events[event];
11515    }
11516    return this;
11517  }
11518
11519  emit(event, ...args) {
11520    const list = this._events[event];
11521    if (!list || list.length === 0) return false;
11522    for (const fn of list.slice()) {
11523      try { fn.apply(this, args); } catch (e) {
11524        if (event !== 'error') this.emit('error', e);
11525      }
11526    }
11527    return true;
11528  }
11529
11530  listeners(event) {
11531    const list = this._events[event];
11532    if (!list) return [];
11533    return list.map(fn => fn._original || fn);
11534  }
11535
11536  listenerCount(event) {
11537    const list = this._events[event];
11538    return list ? list.length : 0;
11539  }
11540
11541  eventNames() { return Object.keys(this._events); }
11542
11543  setMaxListeners(n) { this._maxListeners = n; return this; }
11544  getMaxListeners() { return this._maxListeners; }
11545
11546  prependListener(event, listener) {
11547    if (!this._events[event]) this._events[event] = [];
11548    this._events[event].unshift(listener);
11549    return this;
11550  }
11551
11552  prependOnceListener(event, listener) {
11553    const wrapper = (...args) => {
11554      this.removeListener(event, wrapper);
11555      listener.apply(this, args);
11556    };
11557    wrapper._original = listener;
11558    return this.prependListener(event, wrapper);
11559  }
11560
11561  rawListeners(event) {
11562    return this._events[event] ? this._events[event].slice() : [];
11563  }
11564}
11565
11566EventEmitter.EventEmitter = EventEmitter;
11567EventEmitter.defaultMaxListeners = 10;
11568
11569export { EventEmitter };
11570export default EventEmitter;
11571"
11572        .trim()
11573        .to_string(),
11574    );
11575
11576    // ── node:buffer ──────────────────────────────────────────────────
11577    modules.insert(
11578        "node:buffer".to_string(),
11579        crate::buffer_shim::NODE_BUFFER_JS.trim().to_string(),
11580    );
11581
11582    // ── node:assert ──────────────────────────────────────────────────
11583    modules.insert(
11584        "node:assert".to_string(),
11585        r"
11586function assert(value, message) {
11587  if (!value) throw new Error(message || 'Assertion failed');
11588}
11589assert.ok = assert;
11590assert.equal = (a, b, msg) => { if (a != b) throw new Error(msg || `${a} != ${b}`); };
11591assert.strictEqual = (a, b, msg) => { if (a !== b) throw new Error(msg || `${a} !== ${b}`); };
11592assert.notEqual = (a, b, msg) => { if (a == b) throw new Error(msg || `${a} == ${b}`); };
11593assert.notStrictEqual = (a, b, msg) => { if (a === b) throw new Error(msg || `${a} === ${b}`); };
11594assert.deepEqual = assert.deepStrictEqual = (a, b, msg) => {
11595  if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(msg || 'Deep equality failed');
11596};
11597assert.notDeepEqual = assert.notDeepStrictEqual = (a, b, msg) => {
11598  if (JSON.stringify(a) === JSON.stringify(b)) throw new Error(msg || 'Expected values to differ');
11599};
11600assert.throws = (fn, _expected, msg) => {
11601  let threw = false;
11602  try { fn(); } catch (_) { threw = true; }
11603  if (!threw) throw new Error(msg || 'Expected function to throw');
11604};
11605assert.doesNotThrow = (fn, _expected, msg) => {
11606  try { fn(); } catch (e) { throw new Error(msg || `Got unwanted exception: ${e}`); }
11607};
11608assert.fail = (msg) => { throw new Error(msg || 'assert.fail()'); };
11609
11610export default assert;
11611export { assert };
11612"
11613        .trim()
11614        .to_string(),
11615    );
11616
11617    // ── node:assert/strict ───────────────────────────────────────────
11618    modules.insert(
11619        "node:assert/strict".to_string(),
11620        r#"
11621import assert from "node:assert";
11622
11623export default assert;
11624export const strict = assert;
11625export const ok = assert.ok;
11626export const equal = assert.equal;
11627export const strictEqual = assert.strictEqual;
11628export const deepStrictEqual = assert.deepStrictEqual;
11629export const notEqual = assert.notEqual;
11630export const notStrictEqual = assert.notStrictEqual;
11631export const deepEqual = assert.deepEqual;
11632export const notDeepEqual = assert.notDeepEqual;
11633export const throws = assert.throws;
11634export const doesNotThrow = assert.doesNotThrow;
11635export const fail = assert.fail;
11636export { assert };
11637"#
11638        .trim()
11639        .to_string(),
11640    );
11641
11642    // ── node:test ────────────────────────────────────────────────────
11643    modules.insert(
11644        "node:test".to_string(),
11645        r"
11646function __noop() {}
11647
11648const __state = {
11649  tests: [],
11650  beforeAll: [],
11651  afterAll: [],
11652  beforeEach: [],
11653  afterEach: [],
11654  suiteStack: [],
11655};
11656
11657function __isFn(value) {
11658  return typeof value === 'function';
11659}
11660
11661function __suiteMode() {
11662  let mode = 'run';
11663  for (const entry of __state.suiteStack) {
11664    if (entry.mode === 'skip' || entry.mode === 'todo') return 'skip';
11665    if (entry.mode === 'only') mode = 'only';
11666  }
11667  return mode;
11668}
11669
11670function __registerTest(name, fn, options, modeOverride) {
11671  const suiteMode = __suiteMode();
11672  let mode = modeOverride || 'run';
11673  if (suiteMode === 'skip') {
11674    mode = 'skip';
11675  } else if (suiteMode === 'only' && mode === 'run') {
11676    mode = 'only';
11677  }
11678  const entry = {
11679    name: name === undefined ? '' : String(name),
11680    fn: __isFn(fn) ? fn : null,
11681    options: options || {},
11682    mode,
11683  };
11684  __state.tests.push(entry);
11685  return entry;
11686}
11687
11688function __enterSuite(name, fn, mode) {
11689  const suite = { name: name === undefined ? '' : String(name), mode };
11690  __state.suiteStack.push(suite);
11691  try {
11692    if (mode !== 'skip' && mode !== 'todo' && __isFn(fn)) {
11693      fn();
11694    }
11695  } finally {
11696    __state.suiteStack.pop();
11697  }
11698  return suite;
11699}
11700
11701export function test(name, fn, options) {
11702  return __registerTest(name, fn, options, 'run');
11703}
11704test.skip = (name, fn, options) => __registerTest(name, fn, options, 'skip');
11705test.todo = (name, fn, options) => __registerTest(name, fn, options, 'todo');
11706test.only = (name, fn, options) => __registerTest(name, fn, options, 'only');
11707
11708export function describe(name, fn) {
11709  return __enterSuite(name, fn, 'run');
11710}
11711describe.skip = (name, fn) => __enterSuite(name, fn, 'skip');
11712describe.todo = (name, fn) => __enterSuite(name, fn, 'todo');
11713describe.only = (name, fn) => __enterSuite(name, fn, 'only');
11714
11715export const it = test;
11716it.skip = test.skip;
11717it.todo = test.todo;
11718it.only = test.only;
11719
11720export const before = (fn) => {
11721  if (__isFn(fn)) __state.beforeAll.push(fn);
11722};
11723export const after = (fn) => {
11724  if (__isFn(fn)) __state.afterAll.push(fn);
11725};
11726export const beforeEach = (fn) => {
11727  if (__isFn(fn)) __state.beforeEach.push(fn);
11728};
11729export const afterEach = (fn) => {
11730  if (__isFn(fn)) __state.afterEach.push(fn);
11731};
11732
11733async function __runHookList(list) {
11734  for (const fn of list) {
11735    await fn();
11736  }
11737}
11738
11739async function __runTest(entry) {
11740  if (entry.mode === 'skip' || entry.mode === 'todo') {
11741    return { name: entry.name, status: entry.mode };
11742  }
11743  if (!entry.fn) {
11744    return { name: entry.name, status: 'error', error: 'missing test function' };
11745  }
11746  try {
11747    await __runHookList(__state.beforeEach);
11748    await entry.fn();
11749    await __runHookList(__state.afterEach);
11750    return { name: entry.name, status: 'pass' };
11751  } catch (err) {
11752    try { await __runHookList(__state.afterEach); } catch (_) {}
11753    const message = err && err.message ? err.message : err;
11754    return { name: entry.name, status: 'fail', error: String(message) };
11755  }
11756}
11757
11758export const mock = {
11759  fn: (impl) => (__isFn(impl) ? impl : __noop),
11760  reset: __noop,
11761  restoreAll: __noop,
11762};
11763
11764export async function run() {
11765  const hasOnly = __state.tests.some(entry => entry.mode === 'only');
11766  const selected = hasOnly
11767    ? __state.tests.filter(entry => entry.mode === 'only')
11768    : __state.tests.slice();
11769
11770  let passed = 0;
11771  let failed = 0;
11772  let skipped = 0;
11773  let todo = 0;
11774  const results = [];
11775
11776  await __runHookList(__state.beforeAll);
11777
11778  for (const entry of selected) {
11779    const result = await __runTest(entry);
11780    results.push(result);
11781    if (result.status === 'pass') passed += 1;
11782    else if (result.status === 'fail' || result.status === 'error') failed += 1;
11783    else if (result.status === 'skip') skipped += 1;
11784    else if (result.status === 'todo') todo += 1;
11785  }
11786
11787  await __runHookList(__state.afterAll);
11788
11789  return {
11790    ok: failed === 0,
11791    summary: {
11792      total: selected.length,
11793      passed,
11794      failed,
11795      skipped,
11796      todo,
11797    },
11798    results,
11799  };
11800}
11801
11802export default { test, describe, it, before, after, beforeEach, afterEach, run, mock };
11803"
11804        .trim()
11805        .to_string(),
11806    );
11807
11808    // ── node:stream ──────────────────────────────────────────────────
11809    modules.insert(
11810        "node:stream".to_string(),
11811        r#"
11812import EventEmitter from "node:events";
11813
11814function __streamToError(err) {
11815  return err instanceof Error ? err : new Error(String(err ?? "stream error"));
11816}
11817
11818function __streamQueueMicrotask(fn) {
11819  if (typeof queueMicrotask === "function") {
11820    queueMicrotask(fn);
11821    return;
11822  }
11823  Promise.resolve().then(fn);
11824}
11825
11826function __normalizeChunk(chunk, encoding) {
11827  if (chunk === null || chunk === undefined) return chunk;
11828  if (typeof chunk === "string") return chunk;
11829  if (typeof Buffer !== "undefined" && Buffer.isBuffer && Buffer.isBuffer(chunk)) {
11830    return encoding ? chunk.toString(encoding) : chunk;
11831  }
11832  if (chunk instanceof Uint8Array) {
11833    return encoding && typeof Buffer !== "undefined" && Buffer.from
11834      ? Buffer.from(chunk).toString(encoding)
11835      : chunk;
11836  }
11837  if (chunk instanceof ArrayBuffer) {
11838    const view = new Uint8Array(chunk);
11839    return encoding && typeof Buffer !== "undefined" && Buffer.from
11840      ? Buffer.from(view).toString(encoding)
11841      : view;
11842  }
11843  if (ArrayBuffer.isView(chunk)) {
11844    const view = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
11845    return encoding && typeof Buffer !== "undefined" && Buffer.from
11846      ? Buffer.from(view).toString(encoding)
11847      : view;
11848  }
11849  return encoding ? String(chunk) : chunk;
11850}
11851
11852class Stream extends EventEmitter {
11853  constructor() {
11854    super();
11855    this.destroyed = false;
11856  }
11857
11858  destroy(err) {
11859    if (this.destroyed) return this;
11860    this.destroyed = true;
11861    if (err) this.emit("error", __streamToError(err));
11862    this.emit("close");
11863    return this;
11864  }
11865}
11866
11867class Readable extends Stream {
11868  constructor(opts = {}) {
11869    super();
11870    this._readableState = { flowing: null, ended: false, encoding: opts.encoding || null };
11871    this.readable = true;
11872    this._queue = [];
11873    this._pipeCleanup = new Map();
11874    this._autoDestroy = opts.autoDestroy !== false;
11875  }
11876
11877  push(chunk) {
11878    if (chunk === null) {
11879      if (this._readableState.ended) return false;
11880      this._readableState.ended = true;
11881      __streamQueueMicrotask(() => {
11882        this.emit("end");
11883        if (this._autoDestroy) this.emit("close");
11884      });
11885      return false;
11886    }
11887    const normalized = __normalizeChunk(chunk, this._readableState.encoding);
11888    this._queue.push(normalized);
11889    this.emit("data", normalized);
11890    return true;
11891  }
11892
11893  read(_size) {
11894    return this._queue.length > 0 ? this._queue.shift() : null;
11895  }
11896
11897  pipe(dest) {
11898    if (!dest || typeof dest.write !== "function") {
11899      throw new Error("stream.pipe destination must implement write()");
11900    }
11901
11902    const onData = (chunk) => {
11903      const writable = dest.write(chunk);
11904      if (writable === false && typeof this.pause === "function") {
11905        this.pause();
11906      }
11907    };
11908    const onDrain = () => {
11909      if (typeof this.resume === "function") this.resume();
11910    };
11911    const onEnd = () => {
11912      if (typeof dest.end === "function") dest.end();
11913      cleanup();
11914    };
11915    const onError = (err) => {
11916      cleanup();
11917      if (typeof dest.destroy === "function") {
11918        dest.destroy(err);
11919      } else if (typeof dest.emit === "function") {
11920        dest.emit("error", err);
11921      }
11922    };
11923    const cleanup = () => {
11924      this.removeListener("data", onData);
11925      this.removeListener("end", onEnd);
11926      this.removeListener("error", onError);
11927      if (typeof dest.removeListener === "function") {
11928        dest.removeListener("drain", onDrain);
11929      }
11930      this._pipeCleanup.delete(dest);
11931    };
11932
11933    this.on("data", onData);
11934    this.on("end", onEnd);
11935    this.on("error", onError);
11936    if (typeof dest.on === "function") {
11937      dest.on("drain", onDrain);
11938    }
11939    this._pipeCleanup.set(dest, cleanup);
11940    return dest;
11941  }
11942
11943  unpipe(dest) {
11944    if (dest) {
11945      const cleanup = this._pipeCleanup.get(dest);
11946      if (cleanup) cleanup();
11947      return this;
11948    }
11949    for (const cleanup of this._pipeCleanup.values()) {
11950      cleanup();
11951    }
11952    this._pipeCleanup.clear();
11953    return this;
11954  }
11955
11956  resume() {
11957    this._readableState.flowing = true;
11958    return this;
11959  }
11960
11961  pause() {
11962    this._readableState.flowing = false;
11963    return this;
11964  }
11965
11966  [Symbol.asyncIterator]() {
11967    const stream = this;
11968    const queue = [];
11969    const waiters = [];
11970    let done = false;
11971    let failure = null;
11972
11973    const settleDone = () => {
11974      done = true;
11975      while (waiters.length > 0) {
11976        waiters.shift().resolve({ value: undefined, done: true });
11977      }
11978    };
11979    const settleError = (err) => {
11980      failure = __streamToError(err);
11981      while (waiters.length > 0) {
11982        waiters.shift().reject(failure);
11983      }
11984    };
11985    const onData = (value) => {
11986      if (waiters.length > 0) {
11987        waiters.shift().resolve({ value, done: false });
11988      } else {
11989        queue.push(value);
11990      }
11991    };
11992    const onEnd = () => settleDone();
11993    const onError = (err) => settleError(err);
11994    const cleanup = () => {
11995      stream.removeListener("data", onData);
11996      stream.removeListener("end", onEnd);
11997      stream.removeListener("error", onError);
11998    };
11999
12000    stream.on("data", onData);
12001    stream.on("end", onEnd);
12002    stream.on("error", onError);
12003
12004    return {
12005      async next() {
12006        if (queue.length > 0) return { value: queue.shift(), done: false };
12007        if (failure) throw failure;
12008        if (done) return { value: undefined, done: true };
12009        return await new Promise((resolve, reject) => waiters.push({ resolve, reject }));
12010      },
12011      async return() {
12012        cleanup();
12013        settleDone();
12014        return { value: undefined, done: true };
12015      },
12016      [Symbol.asyncIterator]() { return this; },
12017    };
12018  }
12019
12020  static from(iterable, opts = {}) {
12021    const readable = new Readable(opts);
12022    (async () => {
12023      try {
12024        for await (const chunk of iterable) {
12025          readable.push(chunk);
12026        }
12027        readable.push(null);
12028      } catch (err) {
12029        readable.emit("error", __streamToError(err));
12030      }
12031    })();
12032    return readable;
12033  }
12034
12035  static fromWeb(webReadable, opts = {}) {
12036    if (!webReadable || typeof webReadable.getReader !== "function") {
12037      throw new Error("Readable.fromWeb expects a Web ReadableStream");
12038    }
12039    const reader = webReadable.getReader();
12040    const readable = new Readable(opts);
12041    (async () => {
12042      try {
12043        while (true) {
12044          const { done, value } = await reader.read();
12045          if (done) break;
12046          readable.push(value);
12047        }
12048        readable.push(null);
12049      } catch (err) {
12050        readable.emit("error", __streamToError(err));
12051      } finally {
12052        try { reader.releaseLock(); } catch (_) {}
12053      }
12054    })();
12055    return readable;
12056  }
12057
12058  static toWeb(nodeReadable) {
12059    if (typeof ReadableStream !== "function") {
12060      throw new Error("Readable.toWeb requires global ReadableStream");
12061    }
12062    if (!nodeReadable || typeof nodeReadable.on !== "function") {
12063      throw new Error("Readable.toWeb expects a Node Readable stream");
12064    }
12065    return new ReadableStream({
12066      start(controller) {
12067        const onData = (chunk) => controller.enqueue(chunk);
12068        const onEnd = () => {
12069          cleanup();
12070          controller.close();
12071        };
12072        const onError = (err) => {
12073          cleanup();
12074          controller.error(__streamToError(err));
12075        };
12076        const cleanup = () => {
12077          nodeReadable.removeListener?.("data", onData);
12078          nodeReadable.removeListener?.("end", onEnd);
12079          nodeReadable.removeListener?.("error", onError);
12080        };
12081        nodeReadable.on("data", onData);
12082        nodeReadable.on("end", onEnd);
12083        nodeReadable.on("error", onError);
12084        if (typeof nodeReadable.resume === "function") nodeReadable.resume();
12085      },
12086      cancel(reason) {
12087        if (typeof nodeReadable.destroy === "function") {
12088          nodeReadable.destroy(__streamToError(reason ?? "stream cancelled"));
12089        }
12090      },
12091    });
12092  }
12093}
12094
12095class Writable extends Stream {
12096  constructor(opts = {}) {
12097    super();
12098    this._writableState = { ended: false, finished: false };
12099    this.writable = true;
12100    this._autoDestroy = opts.autoDestroy !== false;
12101    this._writeImpl = typeof opts.write === "function" ? opts.write.bind(this) : null;
12102    this._finalImpl = typeof opts.final === "function" ? opts.final.bind(this) : null;
12103  }
12104
12105  _write(chunk, encoding, callback) {
12106    if (this._writeImpl) {
12107      this._writeImpl(chunk, encoding, callback);
12108      return;
12109    }
12110    callback(null);
12111  }
12112
12113  write(chunk, encoding, callback) {
12114    let cb = callback;
12115    let enc = encoding;
12116    if (typeof encoding === "function") {
12117      cb = encoding;
12118      enc = undefined;
12119    }
12120    if (this._writableState.ended) {
12121      const err = new Error("write after end");
12122      if (typeof cb === "function") cb(err);
12123      this.emit("error", err);
12124      return false;
12125    }
12126
12127    try {
12128      this._write(chunk, enc, (err) => {
12129        if (err) {
12130          const normalized = __streamToError(err);
12131          if (typeof cb === "function") cb(normalized);
12132          this.emit("error", normalized);
12133          return;
12134        }
12135        if (typeof cb === "function") cb(null);
12136        this.emit("drain");
12137      });
12138    } catch (err) {
12139      const normalized = __streamToError(err);
12140      if (typeof cb === "function") cb(normalized);
12141      this.emit("error", normalized);
12142      return false;
12143    }
12144    return true;
12145  }
12146
12147  _finish(callback) {
12148    if (this._finalImpl) {
12149      try {
12150        this._finalImpl(callback);
12151      } catch (err) {
12152        callback(__streamToError(err));
12153      }
12154      return;
12155    }
12156    callback(null);
12157  }
12158
12159  end(chunk, encoding, callback) {
12160    let cb = callback;
12161    let enc = encoding;
12162    if (typeof encoding === "function") {
12163      cb = encoding;
12164      enc = undefined;
12165    }
12166
12167    const finalize = () => {
12168      if (this._writableState.ended) {
12169        if (typeof cb === "function") cb(null);
12170        return;
12171      }
12172      this._writableState.ended = true;
12173      this._finish((err) => {
12174        if (err) {
12175          const normalized = __streamToError(err);
12176          if (typeof cb === "function") cb(normalized);
12177          this.emit("error", normalized);
12178          return;
12179        }
12180        this._writableState.finished = true;
12181        this.emit("finish");
12182        if (this._autoDestroy) this.emit("close");
12183        if (typeof cb === "function") cb(null);
12184      });
12185    };
12186
12187    if (chunk !== undefined && chunk !== null) {
12188      this.write(chunk, enc, (err) => {
12189        if (err) {
12190          if (typeof cb === "function") cb(err);
12191          return;
12192        }
12193        finalize();
12194      });
12195      return this;
12196    }
12197
12198    finalize();
12199    return this;
12200  }
12201
12202  static fromWeb(webWritable, opts = {}) {
12203    if (!webWritable || typeof webWritable.getWriter !== "function") {
12204      throw new Error("Writable.fromWeb expects a Web WritableStream");
12205    }
12206    const writer = webWritable.getWriter();
12207    return new Writable({
12208      ...opts,
12209      write(chunk, _encoding, callback) {
12210        Promise.resolve(writer.write(chunk))
12211          .then(() => callback(null))
12212          .catch((err) => callback(__streamToError(err)));
12213      },
12214      final(callback) {
12215        Promise.resolve(writer.close())
12216          .then(() => {
12217            try { writer.releaseLock(); } catch (_) {}
12218            callback(null);
12219          })
12220          .catch((err) => callback(__streamToError(err)));
12221      },
12222    });
12223  }
12224
12225  static toWeb(nodeWritable) {
12226    if (typeof WritableStream !== "function") {
12227      throw new Error("Writable.toWeb requires global WritableStream");
12228    }
12229    if (!nodeWritable || typeof nodeWritable.write !== "function") {
12230      throw new Error("Writable.toWeb expects a Node Writable stream");
12231    }
12232    return new WritableStream({
12233      write(chunk) {
12234        return new Promise((resolve, reject) => {
12235          try {
12236            const ok = nodeWritable.write(chunk, (err) => {
12237              if (err) reject(__streamToError(err));
12238              else resolve();
12239            });
12240            if (ok === true) resolve();
12241          } catch (err) {
12242            reject(__streamToError(err));
12243          }
12244        });
12245      },
12246      close() {
12247        return new Promise((resolve, reject) => {
12248          try {
12249            nodeWritable.end((err) => {
12250              if (err) reject(__streamToError(err));
12251              else resolve();
12252            });
12253          } catch (err) {
12254            reject(__streamToError(err));
12255          }
12256        });
12257      },
12258      abort(reason) {
12259        if (typeof nodeWritable.destroy === "function") {
12260          nodeWritable.destroy(__streamToError(reason ?? "stream aborted"));
12261        }
12262      },
12263    });
12264  }
12265}
12266
12267class Duplex extends Readable {
12268  constructor(opts = {}) {
12269    super(opts);
12270    this._writableState = { ended: false, finished: false };
12271    this.writable = true;
12272    this._autoDestroy = opts.autoDestroy !== false;
12273    this._writeImpl = typeof opts.write === "function" ? opts.write.bind(this) : null;
12274    this._finalImpl = typeof opts.final === "function" ? opts.final.bind(this) : null;
12275  }
12276
12277  _write(chunk, encoding, callback) {
12278    if (this._writeImpl) {
12279      this._writeImpl(chunk, encoding, callback);
12280      return;
12281    }
12282    callback(null);
12283  }
12284
12285  _finish(callback) {
12286    if (this._finalImpl) {
12287      try {
12288        this._finalImpl(callback);
12289      } catch (err) {
12290        callback(__streamToError(err));
12291      }
12292      return;
12293    }
12294    callback(null);
12295  }
12296
12297  write(chunk, encoding, callback) {
12298    return Writable.prototype.write.call(this, chunk, encoding, callback);
12299  }
12300
12301  end(chunk, encoding, callback) {
12302    return Writable.prototype.end.call(this, chunk, encoding, callback);
12303  }
12304}
12305
12306class Transform extends Duplex {
12307  constructor(opts = {}) {
12308    super(opts);
12309    this._transformImpl = typeof opts.transform === "function" ? opts.transform.bind(this) : null;
12310  }
12311
12312  _transform(chunk, encoding, callback) {
12313    if (this._transformImpl) {
12314      this._transformImpl(chunk, encoding, callback);
12315      return;
12316    }
12317    callback(null, chunk);
12318  }
12319
12320  write(chunk, encoding, callback) {
12321    let cb = callback;
12322    let enc = encoding;
12323    if (typeof encoding === "function") {
12324      cb = encoding;
12325      enc = undefined;
12326    }
12327    try {
12328      this._transform(chunk, enc, (err, data) => {
12329        if (err) {
12330          const normalized = __streamToError(err);
12331          if (typeof cb === "function") cb(normalized);
12332          this.emit("error", normalized);
12333          return;
12334        }
12335        if (data !== undefined && data !== null) {
12336          this.push(data);
12337        }
12338        if (typeof cb === "function") cb(null);
12339      });
12340    } catch (err) {
12341      const normalized = __streamToError(err);
12342      if (typeof cb === "function") cb(normalized);
12343      this.emit("error", normalized);
12344      return false;
12345    }
12346    return true;
12347  }
12348
12349  end(chunk, encoding, callback) {
12350    let cb = callback;
12351    let enc = encoding;
12352    if (typeof encoding === "function") {
12353      cb = encoding;
12354      enc = undefined;
12355    }
12356    const finalize = () => {
12357      this.push(null);
12358      this.emit("finish");
12359      this.emit("close");
12360      if (typeof cb === "function") cb(null);
12361    };
12362    if (chunk !== undefined && chunk !== null) {
12363      this.write(chunk, enc, (err) => {
12364        if (err) {
12365          if (typeof cb === "function") cb(err);
12366          return;
12367        }
12368        finalize();
12369      });
12370      return this;
12371    }
12372    finalize();
12373    return this;
12374  }
12375}
12376
12377class PassThrough extends Transform {
12378  _transform(chunk, _encoding, callback) { callback(null, chunk); }
12379}
12380
12381function finished(stream, callback) {
12382  if (!stream || typeof stream.on !== "function") {
12383    const err = new Error("finished expects a stream-like object");
12384    if (typeof callback === "function") callback(err);
12385    return Promise.reject(err);
12386  }
12387  return new Promise((resolve, reject) => {
12388    let settled = false;
12389    const cleanup = () => {
12390      stream.removeListener?.("finish", onDone);
12391      stream.removeListener?.("end", onDone);
12392      stream.removeListener?.("close", onDone);
12393      stream.removeListener?.("error", onError);
12394    };
12395    const settle = (fn, value) => {
12396      if (settled) return;
12397      settled = true;
12398      cleanup();
12399      fn(value);
12400    };
12401    const onDone = () => {
12402      if (typeof callback === "function") callback(null, stream);
12403      settle(resolve, stream);
12404    };
12405    const onError = (err) => {
12406      const normalized = __streamToError(err);
12407      if (typeof callback === "function") callback(normalized);
12408      settle(reject, normalized);
12409    };
12410    stream.on("finish", onDone);
12411    stream.on("end", onDone);
12412    stream.on("close", onDone);
12413    stream.on("error", onError);
12414  });
12415}
12416
12417function pipeline(...args) {
12418  const callback = typeof args[args.length - 1] === "function" ? args.pop() : null;
12419  const streams = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
12420  if (!Array.isArray(streams) || streams.length < 2) {
12421    const err = new Error("pipeline requires at least two streams");
12422    if (callback) callback(err);
12423    throw err;
12424  }
12425
12426  for (let i = 0; i < streams.length - 1; i += 1) {
12427    streams[i].pipe(streams[i + 1]);
12428  }
12429  const last = streams[streams.length - 1];
12430  const done = (err) => {
12431    if (callback) callback(err || null, last);
12432  };
12433  last.on?.("finish", () => done(null));
12434  last.on?.("end", () => done(null));
12435  last.on?.("error", (err) => done(__streamToError(err)));
12436  return last;
12437}
12438
12439const promises = {
12440  pipeline: (...args) =>
12441    new Promise((resolve, reject) => {
12442      try {
12443        pipeline(...args, (err, stream) => {
12444          if (err) reject(err);
12445          else resolve(stream);
12446        });
12447      } catch (err) {
12448        reject(__streamToError(err));
12449      }
12450    }),
12451  finished: (stream) => finished(stream),
12452};
12453
12454export { Stream, Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished, promises };
12455export default { Stream, Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished, promises };
12456"#
12457        .trim()
12458        .to_string(),
12459    );
12460
12461    // node:stream/promises — promise-based stream utilities
12462    modules.insert(
12463        "node:stream/promises".to_string(),
12464        r"
12465import { Readable, Writable } from 'node:stream';
12466
12467function __streamToError(err) {
12468  return err instanceof Error ? err : new Error(String(err ?? 'stream error'));
12469}
12470
12471function __isReadableLike(stream) {
12472  return !!stream && typeof stream.pipe === 'function' && typeof stream.on === 'function';
12473}
12474
12475function __isWritableLike(stream) {
12476  return !!stream && typeof stream.write === 'function' && typeof stream.on === 'function';
12477}
12478
12479export async function pipeline(...streams) {
12480  if (streams.length === 1 && Array.isArray(streams[0])) {
12481    streams = streams[0];
12482  }
12483  if (streams.length < 2) {
12484    throw new Error('pipeline requires at least two streams');
12485  }
12486
12487  if (!__isReadableLike(streams[0]) && streams[0] && (typeof streams[0][Symbol.asyncIterator] === 'function' || typeof streams[0][Symbol.iterator] === 'function')) {
12488    streams = [Readable.from(streams[0]), ...streams.slice(1)];
12489  }
12490
12491  return await new Promise((resolve, reject) => {
12492    let settled = false;
12493    const cleanups = [];
12494    const cleanup = () => {
12495      while (cleanups.length > 0) {
12496        try { cleanups.pop()(); } catch (_) {}
12497      }
12498    };
12499    const settleResolve = (value) => {
12500      if (settled) return;
12501      settled = true;
12502      cleanup();
12503      resolve(value);
12504    };
12505    const settleReject = (err) => {
12506      if (settled) return;
12507      settled = true;
12508      cleanup();
12509      reject(__streamToError(err));
12510    };
12511    const addListener = (target, event, handler) => {
12512      if (!target || typeof target.on !== 'function') return;
12513      target.on(event, handler);
12514      cleanups.push(() => {
12515        if (typeof target.removeListener === 'function') {
12516          target.removeListener(event, handler);
12517        }
12518      });
12519    };
12520
12521    for (let i = 0; i < streams.length - 1; i += 1) {
12522      const source = streams[i];
12523      const dest = streams[i + 1];
12524      if (!__isReadableLike(source)) {
12525        settleReject(new Error(`pipeline source at index ${i} is not readable`));
12526        return;
12527      }
12528      if (!__isWritableLike(dest)) {
12529        settleReject(new Error(`pipeline destination at index ${i + 1} is not writable`));
12530        return;
12531      }
12532      try {
12533        source.pipe(dest);
12534      } catch (err) {
12535        settleReject(err);
12536        return;
12537      }
12538    }
12539
12540    const last = streams[streams.length - 1];
12541    for (const stream of streams) {
12542      addListener(stream, 'error', settleReject);
12543    }
12544    addListener(last, 'finish', () => settleResolve(last));
12545    addListener(last, 'end', () => settleResolve(last));
12546    addListener(last, 'close', () => settleResolve(last));
12547
12548    const first = streams[0];
12549    if (first && typeof first.resume === 'function') {
12550      try { first.resume(); } catch (_) {}
12551    }
12552  });
12553}
12554
12555export async function finished(stream) {
12556  if (!stream || typeof stream.on !== 'function') {
12557    throw new Error('finished expects a stream-like object');
12558  }
12559  return await new Promise((resolve, reject) => {
12560    let settled = false;
12561    const cleanup = () => {
12562      if (typeof stream.removeListener !== 'function') return;
12563      stream.removeListener('finish', onDone);
12564      stream.removeListener('end', onDone);
12565      stream.removeListener('close', onDone);
12566      stream.removeListener('error', onError);
12567    };
12568    const onDone = () => {
12569      if (settled) return;
12570      settled = true;
12571      cleanup();
12572      resolve(stream);
12573    };
12574    const onError = (err) => {
12575      if (settled) return;
12576      settled = true;
12577      cleanup();
12578      reject(__streamToError(err));
12579    };
12580    stream.on('finish', onDone);
12581    stream.on('end', onDone);
12582    stream.on('close', onDone);
12583    stream.on('error', onError);
12584  });
12585}
12586export default { pipeline, finished };
12587"
12588        .trim()
12589        .to_string(),
12590    );
12591
12592    // node:stream/web — bridge to global Web Streams when available
12593    modules.insert(
12594        "node:stream/web".to_string(),
12595        r"
12596const _ReadableStream = globalThis.ReadableStream;
12597const _WritableStream = globalThis.WritableStream;
12598const _TransformStream = globalThis.TransformStream;
12599const _TextEncoderStream = globalThis.TextEncoderStream;
12600const _TextDecoderStream = globalThis.TextDecoderStream;
12601const _CompressionStream = globalThis.CompressionStream;
12602const _DecompressionStream = globalThis.DecompressionStream;
12603const _ByteLengthQueuingStrategy = globalThis.ByteLengthQueuingStrategy;
12604const _CountQueuingStrategy = globalThis.CountQueuingStrategy;
12605
12606export const ReadableStream = _ReadableStream;
12607export const WritableStream = _WritableStream;
12608export const TransformStream = _TransformStream;
12609export const TextEncoderStream = _TextEncoderStream;
12610export const TextDecoderStream = _TextDecoderStream;
12611export const CompressionStream = _CompressionStream;
12612export const DecompressionStream = _DecompressionStream;
12613export const ByteLengthQueuingStrategy = _ByteLengthQueuingStrategy;
12614export const CountQueuingStrategy = _CountQueuingStrategy;
12615
12616export default {
12617  ReadableStream,
12618  WritableStream,
12619  TransformStream,
12620  TextEncoderStream,
12621  TextDecoderStream,
12622  CompressionStream,
12623  DecompressionStream,
12624  ByteLengthQueuingStrategy,
12625  CountQueuingStrategy,
12626};
12627"
12628        .trim()
12629        .to_string(),
12630    );
12631
12632    // node:string_decoder — often imported by stream consumers
12633    modules.insert(
12634        "node:string_decoder".to_string(),
12635        r"
12636export class StringDecoder {
12637  constructor(encoding) { this.encoding = encoding || 'utf8'; }
12638  write(buf) { return typeof buf === 'string' ? buf : String(buf ?? ''); }
12639  end(buf) { return buf ? this.write(buf) : ''; }
12640}
12641export default { StringDecoder };
12642"
12643        .trim()
12644        .to_string(),
12645    );
12646
12647    // node:querystring — URL query string encoding/decoding
12648    modules.insert(
12649        "node:querystring".to_string(),
12650        r"
12651export function parse(qs, sep, eq) {
12652  const s = String(qs ?? '');
12653  const sepStr = sep || '&';
12654  const eqStr = eq || '=';
12655  const result = {};
12656  if (!s) return result;
12657  for (const pair of s.split(sepStr)) {
12658    const idx = pair.indexOf(eqStr);
12659    const key = idx === -1 ? decodeURIComponent(pair) : decodeURIComponent(pair.slice(0, idx));
12660    const val = idx === -1 ? '' : decodeURIComponent(pair.slice(idx + eqStr.length));
12661    if (Object.prototype.hasOwnProperty.call(result, key)) {
12662      if (Array.isArray(result[key])) result[key].push(val);
12663      else result[key] = [result[key], val];
12664    } else {
12665      result[key] = val;
12666    }
12667  }
12668  return result;
12669}
12670export function stringify(obj, sep, eq) {
12671  const sepStr = sep || '&';
12672  const eqStr = eq || '=';
12673  if (!obj || typeof obj !== 'object') return '';
12674  return Object.entries(obj).map(([k, v]) => {
12675    if (Array.isArray(v)) return v.map(i => encodeURIComponent(k) + eqStr + encodeURIComponent(i)).join(sepStr);
12676    return encodeURIComponent(k) + eqStr + encodeURIComponent(v ?? '');
12677  }).join(sepStr);
12678}
12679export const decode = parse;
12680export const encode = stringify;
12681export function escape(str) { return encodeURIComponent(str); }
12682export function unescape(str) { return decodeURIComponent(str); }
12683export default { parse, stringify, decode, encode, escape, unescape };
12684"
12685        .trim()
12686        .to_string(),
12687    );
12688
12689    // node:constants — compatibility map for libraries probing process constants
12690    modules.insert(
12691        "node:constants".to_string(),
12692        r"
12693const _constants = {
12694  EOL: '\n',
12695  F_OK: 0,
12696  R_OK: 4,
12697  W_OK: 2,
12698  X_OK: 1,
12699  UV_UDP_REUSEADDR: 4,
12700  SSL_OP_NO_SSLv2: 0,
12701  SSL_OP_NO_SSLv3: 0,
12702  SSL_OP_NO_TLSv1: 0,
12703  SSL_OP_NO_TLSv1_1: 0,
12704};
12705
12706const constants = new Proxy(_constants, {
12707  get(target, prop) {
12708    if (prop in target) return target[prop];
12709    return 0;
12710  },
12711});
12712
12713export default constants;
12714export { constants };
12715"
12716        .trim()
12717        .to_string(),
12718    );
12719
12720    // node:tty — terminal capability probes
12721    modules.insert(
12722        "node:tty".to_string(),
12723        r"
12724import EventEmitter from 'node:events';
12725
12726export function isatty(_fd) { return false; }
12727
12728export class ReadStream extends EventEmitter {
12729  constructor(_fd) {
12730    super();
12731    this.isTTY = false;
12732    this.columns = 80;
12733    this.rows = 24;
12734  }
12735  setRawMode(_mode) { return this; }
12736}
12737
12738export class WriteStream extends EventEmitter {
12739  constructor(_fd) {
12740    super();
12741    this.isTTY = false;
12742    this.columns = 80;
12743    this.rows = 24;
12744  }
12745  getColorDepth() { return 1; }
12746  hasColors() { return false; }
12747  getWindowSize() { return [this.columns, this.rows]; }
12748}
12749
12750export default { isatty, ReadStream, WriteStream };
12751"
12752        .trim()
12753        .to_string(),
12754    );
12755
12756    // node:tls — secure socket APIs are intentionally unavailable in PiJS
12757    modules.insert(
12758        "node:tls".to_string(),
12759        r"
12760import EventEmitter from 'node:events';
12761
12762export const DEFAULT_MIN_VERSION = 'TLSv1.2';
12763export const DEFAULT_MAX_VERSION = 'TLSv1.3';
12764
12765export class TLSSocket extends EventEmitter {
12766  constructor(_socket, _options) {
12767    super();
12768    this.authorized = false;
12769    this.encrypted = true;
12770  }
12771}
12772
12773export function connect(_portOrOptions, _host, _options, _callback) {
12774  throw new Error('node:tls.connect is not available in PiJS');
12775}
12776
12777export function createServer(_options, _secureConnectionListener) {
12778  throw new Error('node:tls.createServer is not available in PiJS');
12779}
12780
12781export default { connect, createServer, TLSSocket, DEFAULT_MIN_VERSION, DEFAULT_MAX_VERSION };
12782"
12783        .trim()
12784        .to_string(),
12785    );
12786
12787    // node:zlib — compression streams are not implemented in PiJS
12788    modules.insert(
12789        "node:zlib".to_string(),
12790        r"
12791const constants = {
12792  Z_NO_COMPRESSION: 0,
12793  Z_BEST_SPEED: 1,
12794  Z_BEST_COMPRESSION: 9,
12795  Z_DEFAULT_COMPRESSION: -1,
12796};
12797
12798function unsupported(name) {
12799  throw new Error(`node:zlib.${name} is not available in PiJS`);
12800}
12801
12802export function gzip(_buffer, callback) {
12803  if (typeof callback === 'function') callback(new Error('node:zlib.gzip is not available in PiJS'));
12804}
12805export function gunzip(_buffer, callback) {
12806  if (typeof callback === 'function') callback(new Error('node:zlib.gunzip is not available in PiJS'));
12807}
12808
12809export function createGzip() { unsupported('createGzip'); }
12810export function createGunzip() { unsupported('createGunzip'); }
12811export function createDeflate() { unsupported('createDeflate'); }
12812export function createInflate() { unsupported('createInflate'); }
12813export function createBrotliCompress() { unsupported('createBrotliCompress'); }
12814export function createBrotliDecompress() { unsupported('createBrotliDecompress'); }
12815
12816export const promises = {
12817  gzip: async () => { unsupported('promises.gzip'); },
12818  gunzip: async () => { unsupported('promises.gunzip'); },
12819};
12820
12821export default {
12822  constants,
12823  gzip,
12824  gunzip,
12825  createGzip,
12826  createGunzip,
12827  createDeflate,
12828  createInflate,
12829  createBrotliCompress,
12830  createBrotliDecompress,
12831  promises,
12832};
12833"
12834        .trim()
12835        .to_string(),
12836    );
12837
12838    // node:perf_hooks — expose lightweight performance clock surface
12839    modules.insert(
12840        "node:perf_hooks".to_string(),
12841        r"
12842const perf =
12843  globalThis.performance ||
12844  {
12845    now: () => Date.now(),
12846    mark: () => {},
12847    measure: () => {},
12848    clearMarks: () => {},
12849    clearMeasures: () => {},
12850    getEntries: () => [],
12851    getEntriesByType: () => [],
12852    getEntriesByName: () => [],
12853  };
12854
12855export const performance = perf;
12856export const constants = {};
12857export class PerformanceObserver {
12858  constructor(_callback) {}
12859  observe(_opts) {}
12860  disconnect() {}
12861}
12862
12863export default { performance, constants, PerformanceObserver };
12864"
12865        .trim()
12866        .to_string(),
12867    );
12868
12869    // node:vm — disabled in PiJS for safety
12870    modules.insert(
12871        "node:vm".to_string(),
12872        r"
12873function unsupported(name) {
12874  throw new Error(`node:vm.${name} is not available in PiJS`);
12875}
12876
12877export function runInContext() { unsupported('runInContext'); }
12878export function runInNewContext() { unsupported('runInNewContext'); }
12879export function runInThisContext() { unsupported('runInThisContext'); }
12880export function createContext(_sandbox) { return _sandbox || {}; }
12881
12882export class Script {
12883  constructor(_code, _options) { unsupported('Script'); }
12884}
12885
12886export default { runInContext, runInNewContext, runInThisContext, createContext, Script };
12887"
12888        .trim()
12889        .to_string(),
12890    );
12891
12892    // node:v8 — lightweight serialization fallback used by some libs
12893    modules.insert(
12894        "node:v8".to_string(),
12895        r"
12896function __toBuffer(str) {
12897  if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
12898    return Buffer.from(str, 'utf8');
12899  }
12900  if (typeof TextEncoder !== 'undefined') {
12901    return new TextEncoder().encode(str);
12902  }
12903  return str;
12904}
12905
12906function __fromBuffer(buf) {
12907  if (buf == null) return '';
12908  if (typeof Buffer !== 'undefined' && typeof Buffer.isBuffer === 'function' && Buffer.isBuffer(buf)) {
12909    return buf.toString('utf8');
12910  }
12911  if (buf instanceof Uint8Array && typeof TextDecoder !== 'undefined') {
12912    return new TextDecoder().decode(buf);
12913  }
12914  return String(buf);
12915}
12916
12917export function serialize(value) {
12918  return __toBuffer(JSON.stringify(value));
12919}
12920
12921export function deserialize(value) {
12922  return JSON.parse(__fromBuffer(value));
12923}
12924
12925export default { serialize, deserialize };
12926"
12927        .trim()
12928        .to_string(),
12929    );
12930
12931    // node:worker_threads — workers are not supported in PiJS
12932    modules.insert(
12933        "node:worker_threads".to_string(),
12934        r"
12935export const isMainThread = true;
12936export const threadId = 0;
12937export const workerData = null;
12938export const parentPort = null;
12939
12940export class Worker {
12941  constructor(_filename, _options) {
12942    throw new Error('node:worker_threads.Worker is not available in PiJS');
12943  }
12944}
12945
12946export default { isMainThread, threadId, workerData, parentPort, Worker };
12947"
12948        .trim()
12949        .to_string(),
12950    );
12951
12952    // node:process — re-exports globalThis.process
12953    modules.insert(
12954        "node:process".to_string(),
12955        r"
12956const p = globalThis.process || {};
12957export const env = p.env || {};
12958export const argv = p.argv || [];
12959export const cwd = typeof p.cwd === 'function' ? p.cwd : () => '/';
12960export const chdir = typeof p.chdir === 'function' ? p.chdir : () => { throw new Error('ENOSYS'); };
12961export const platform = p.platform || 'linux';
12962export const arch = p.arch || 'x64';
12963export const version = p.version || 'v20.0.0';
12964export const versions = p.versions || {};
12965export const pid = p.pid || 1;
12966export const ppid = p.ppid || 0;
12967export const title = p.title || 'pi';
12968export const execPath = p.execPath || '/usr/bin/pi';
12969export const execArgv = p.execArgv || [];
12970export const stdout = p.stdout || { write() {} };
12971export const stderr = p.stderr || { write() {} };
12972export const stdin = p.stdin || {};
12973export const nextTick = p.nextTick || ((fn, ...a) => Promise.resolve().then(() => fn(...a)));
12974export const hrtime = p.hrtime || Object.assign(() => [0, 0], { bigint: () => BigInt(0) });
12975export const exit = p.exit || (() => {});
12976export const kill = p.kill || (() => {});
12977export const on = p.on || (() => p);
12978export const off = p.off || (() => p);
12979export const once = p.once || (() => p);
12980export const addListener = p.addListener || (() => p);
12981export const removeListener = p.removeListener || (() => p);
12982export const removeAllListeners = p.removeAllListeners || (() => p);
12983export const listeners = p.listeners || (() => []);
12984export const emit = p.emit || (() => false);
12985export const emitWarning = p.emitWarning || (() => {});
12986export const uptime = p.uptime || (() => 0);
12987export const memoryUsage = p.memoryUsage || (() => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0, arrayBuffers: 0 }));
12988export const cpuUsage = p.cpuUsage || (() => ({ user: 0, system: 0 }));
12989export const release = p.release || { name: 'node' };
12990export default p;
12991"
12992        .trim()
12993        .to_string(),
12994    );
12995
12996    // node:timers — expose timer helpers backed by the PiJS event loop
12997    modules.insert(
12998        "node:timers".to_string(),
12999        r"
13000export const setTimeout = globalThis.setTimeout;
13001export const clearTimeout = globalThis.clearTimeout;
13002export const setInterval = globalThis.setInterval;
13003export const clearInterval = globalThis.clearInterval;
13004export const setImmediate = globalThis.setImmediate || ((fn, ...args) => globalThis.setTimeout(fn, 0, ...args));
13005export const clearImmediate = globalThis.clearImmediate || ((id) => globalThis.clearTimeout(id));
13006export const queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn));
13007
13008export default {
13009  setTimeout,
13010  clearTimeout,
13011  setInterval,
13012  clearInterval,
13013  setImmediate,
13014  clearImmediate,
13015  queueMicrotask,
13016};
13017"
13018        .trim()
13019        .to_string(),
13020    );
13021
13022    // ── npm package stubs ──────────────────────────────────────────────
13023    // Minimal virtual modules for npm packages that cannot run in the
13024    // QuickJS sandbox (native bindings, large dependency trees, or
13025    // companion packages). These stubs let extensions *load* and register
13026    // tools/commands even though the actual library behaviour is absent.
13027
13028    modules.insert(
13029        "@mariozechner/clipboard".to_string(),
13030        r"
13031export async function getText() { return ''; }
13032export async function setText(_text) {}
13033export default { getText, setText };
13034"
13035        .trim()
13036        .to_string(),
13037    );
13038
13039    modules.insert(
13040        "node-pty".to_string(),
13041        r"
13042let _pid = 1000;
13043export function spawn(shell, args, options) {
13044    const pid = _pid++;
13045    const handlers = {};
13046    return {
13047        pid,
13048        onData(cb) { handlers.data = cb; },
13049        onExit(cb) { if (cb) setTimeout(() => cb({ exitCode: 1, signal: undefined }), 0); },
13050        write(d) {},
13051        resize(c, r) {},
13052        kill(s) {},
13053    };
13054}
13055export default { spawn };
13056"
13057        .trim()
13058        .to_string(),
13059    );
13060
13061    modules.insert(
13062        "chokidar".to_string(),
13063        r"
13064function makeWatcher() {
13065    const w = {
13066        on(ev, cb) { return w; },
13067        once(ev, cb) { return w; },
13068        close() { return Promise.resolve(); },
13069        add(p) { return w; },
13070        unwatch(p) { return w; },
13071        getWatched() { return {}; },
13072    };
13073    return w;
13074}
13075export function watch(paths, options) { return makeWatcher(); }
13076export default { watch };
13077"
13078        .trim()
13079        .to_string(),
13080    );
13081
13082    modules.insert(
13083        "jsdom".to_string(),
13084        r"
13085class Element {
13086    constructor(tag, html) { this.tagName = tag; this._html = html || ''; this.childNodes = []; }
13087    get innerHTML() { return this._html; }
13088    set innerHTML(v) { this._html = v; }
13089    get textContent() { return this._html.replace(/<[^>]*>/g, ''); }
13090    get outerHTML() { return `<${this.tagName}>${this._html}</${this.tagName}>`; }
13091    get parentNode() { return null; }
13092    querySelectorAll() { return []; }
13093    querySelector() { return null; }
13094    getElementsByTagName() { return []; }
13095    getElementById() { return null; }
13096    remove() {}
13097    getAttribute() { return null; }
13098    setAttribute() {}
13099    cloneNode() { return new Element(this.tagName, this._html); }
13100}
13101export class JSDOM {
13102    constructor(html, opts) {
13103        const doc = new Element('html', html || '');
13104        doc.body = new Element('body', html || '');
13105        doc.title = '';
13106        doc.querySelectorAll = () => [];
13107        doc.querySelector = () => null;
13108        doc.getElementsByTagName = () => [];
13109        doc.getElementById = () => null;
13110        doc.createElement = (t) => new Element(t, '');
13111        doc.documentElement = doc;
13112        this.window = { document: doc, location: { href: (opts && opts.url) || '' } };
13113    }
13114}
13115"
13116        .trim()
13117        .to_string(),
13118    );
13119
13120    modules.insert(
13121        "@mozilla/readability".to_string(),
13122        r"
13123export class Readability {
13124    constructor(doc, opts) { this._doc = doc; }
13125    parse() {
13126        const text = (this._doc && this._doc.body && this._doc.body.textContent) || '';
13127        return { title: '', content: text, textContent: text, length: text.length, excerpt: '', byline: '', dir: '', siteName: '', lang: '' };
13128    }
13129}
13130"
13131        .trim()
13132        .to_string(),
13133    );
13134
13135    modules.insert(
13136        "beautiful-mermaid".to_string(),
13137        r"
13138export function renderMermaidAscii(source) {
13139    const firstLine = (source || '').split('\n')[0] || 'diagram';
13140    return '[mermaid: ' + firstLine.trim() + ']';
13141}
13142"
13143        .trim()
13144        .to_string(),
13145    );
13146
13147    modules.insert(
13148        "@aliou/pi-utils-settings".to_string(),
13149        r"
13150export class ConfigLoader {
13151    constructor(name, defaultConfig, options) {
13152        this._name = name;
13153        this._default = defaultConfig || {};
13154        this._opts = options || {};
13155        this._data = structuredClone(this._default);
13156    }
13157    async load() { return this._data; }
13158    save(d) { this._data = d; }
13159    get() { return this._data; }
13160    getConfig() { return this._data; }
13161    set(k, v) { this._data[k] = v; }
13162}
13163export class ArrayEditor {
13164    constructor(arr) { this._arr = arr || []; }
13165    add(item) { this._arr.push(item); return this; }
13166    remove(idx) { this._arr.splice(idx, 1); return this; }
13167    toArray() { return this._arr; }
13168}
13169export function registerSettingsCommand(pi, opts) {}
13170export function getNestedValue(obj, path) {
13171    const keys = (path || '').split('.');
13172    let cur = obj;
13173    for (const k of keys) { if (cur == null) return undefined; cur = cur[k]; }
13174    return cur;
13175}
13176export function setNestedValue(obj, path, value) {
13177    const keys = (path || '').split('.');
13178    let cur = obj;
13179    for (let i = 0; i < keys.length - 1; i++) {
13180        if (cur[keys[i]] == null) cur[keys[i]] = {};
13181        cur = cur[keys[i]];
13182    }
13183    cur[keys[keys.length - 1]] = value;
13184}
13185"
13186        .trim()
13187        .to_string(),
13188    );
13189
13190    modules.insert(
13191        "@aliou/sh".to_string(),
13192        r#"
13193export class ParseError extends Error { constructor(msg) { super(msg); this.name = 'ParseError'; } }
13194export function tokenize(cmd) {
13195    const source = String(cmd ?? '');
13196    const tokens = [];
13197    let buf = '';
13198    let inSingle = false;
13199    let inDouble = false;
13200    for (let i = 0; i < source.length; i++) {
13201        const ch = source[i];
13202        if (inSingle) {
13203            if (ch === "'") { inSingle = false; } else { buf += ch; }
13204            continue;
13205        }
13206        if (inDouble) {
13207            if (ch === '"') { inDouble = false; } else { buf += ch; }
13208            continue;
13209        }
13210        if (ch === "'") { inSingle = true; continue; }
13211        if (ch === '"') { inDouble = true; continue; }
13212        if (/\s/.test(ch)) {
13213            if (buf.length) { tokens.push(buf); buf = ''; }
13214            continue;
13215        }
13216        buf += ch;
13217    }
13218    if (inSingle || inDouble) { throw new ParseError('Unclosed quote'); }
13219    if (buf.length) tokens.push(buf);
13220    return tokens;
13221}
13222function isValidName(name) {
13223    return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
13224}
13225function makeLiteralWord(value) {
13226    return { parts: [{ type: 'Literal', value }] };
13227}
13228export function parse(cmd) {
13229    const source = String(cmd ?? '');
13230    if (!source.trim()) {
13231        return { ast: { type: 'Program', body: [] } };
13232    }
13233    if (/[|;&()<>]/.test(source)) {
13234        throw new ParseError('Unsupported shell construct');
13235    }
13236    const tokens = tokenize(source);
13237    let idx = 0;
13238    const assignments = [];
13239    while (idx < tokens.length) {
13240        const token = tokens[idx];
13241        const eq = token.indexOf('=');
13242        if (eq > 0 && isValidName(token.slice(0, eq))) {
13243            assignments.push({ name: token.slice(0, eq) });
13244            idx += 1;
13245            continue;
13246        }
13247        break;
13248    }
13249    const words = tokens.slice(idx).map(makeLiteralWord);
13250    if (words.length === 0) {
13251        return { ast: { type: 'Program', body: [] } };
13252    }
13253    return {
13254        ast: {
13255            type: 'Program',
13256            body: [
13257                {
13258                    type: 'Statement',
13259                    command: { type: 'SimpleCommand', words, assignments },
13260                },
13261            ],
13262        },
13263    };
13264}
13265export function quote(s) { return "'" + (s || '').replace(/'/g, "'\\''") + "'"; }
13266"#
13267        .trim()
13268        .to_string(),
13269    );
13270
13271    modules.insert(
13272        "@marckrenn/pi-sub-shared".to_string(),
13273        r#"
13274export const PROVIDERS = ["anthropic", "openai", "google", "aws", "azure"];
13275export const MODEL_MULTIPLIERS = {};
13276const _meta = (name) => ({
13277    name, displayName: name.charAt(0).toUpperCase() + name.slice(1),
13278    detection: { envVars: [], configPaths: [] },
13279    status: { operational: true },
13280});
13281export const PROVIDER_METADATA = Object.fromEntries(PROVIDERS.map(p => [p, _meta(p)]));
13282export const PROVIDER_DISPLAY_NAMES = Object.fromEntries(
13283    PROVIDERS.map(p => [p, p.charAt(0).toUpperCase() + p.slice(1)])
13284);
13285export function getDefaultCoreSettings() {
13286    return { providers: {}, behavior: { autoSwitch: false } };
13287}
13288"#
13289        .trim()
13290        .to_string(),
13291    );
13292
13293    modules.insert(
13294        "turndown".to_string(),
13295        r"
13296class TurndownService {
13297    constructor(opts) { this._opts = opts || {}; }
13298    turndown(html) { return (html || '').replace(/<[^>]*>/g, ''); }
13299    addRule(name, rule) { return this; }
13300    use(plugin) { return this; }
13301    remove(filter) { return this; }
13302}
13303export default TurndownService;
13304"
13305        .trim()
13306        .to_string(),
13307    );
13308
13309    modules.insert(
13310        "@xterm/headless".to_string(),
13311        r"
13312export class Terminal {
13313    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 } }; }
13314    write(data) {}
13315    writeln(data) {}
13316    resize(cols, rows) { this.cols = cols; this.rows = rows; }
13317    dispose() {}
13318    onData(cb) { return { dispose() {} }; }
13319    onLineFeed(cb) { return { dispose() {} }; }
13320}
13321export default { Terminal };
13322"
13323        .trim()
13324        .to_string(),
13325    );
13326
13327    modules.insert(
13328        "@opentelemetry/api".to_string(),
13329        r"
13330export const SpanStatusCode = { UNSET: 0, OK: 1, ERROR: 2 };
13331const noopSpan = {
13332    setAttribute() { return this; },
13333    setAttributes() { return this; },
13334    addEvent() { return this; },
13335    setStatus() { return this; },
13336    end() {},
13337    isRecording() { return false; },
13338    recordException() {},
13339    spanContext() { return { traceId: '', spanId: '', traceFlags: 0 }; },
13340};
13341const noopTracer = {
13342    startSpan() { return noopSpan; },
13343    startActiveSpan(name, optsOrFn, fn) {
13344        const cb = typeof optsOrFn === 'function' ? optsOrFn : fn;
13345        return cb ? cb(noopSpan) : noopSpan;
13346    },
13347};
13348export const trace = {
13349    getTracer() { return noopTracer; },
13350    getActiveSpan() { return noopSpan; },
13351    setSpan(ctx) { return ctx; },
13352};
13353export const context = {
13354    active() { return {}; },
13355    with(ctx, fn) { return fn(); },
13356};
13357"
13358        .trim()
13359        .to_string(),
13360    );
13361
13362    modules.insert(
13363        "@juanibiapina/pi-extension-settings".to_string(),
13364        r"
13365export function getSetting(pi, key, defaultValue) { return defaultValue; }
13366export function setSetting(pi, key, value) {}
13367export function getSettings(pi) { return {}; }
13368"
13369        .trim()
13370        .to_string(),
13371    );
13372
13373    modules.insert(
13374        "@xterm/addon-serialize".to_string(),
13375        r"
13376export class SerializeAddon {
13377    activate(terminal) {}
13378    serialize(opts) { return ''; }
13379    dispose() {}
13380}
13381"
13382        .trim()
13383        .to_string(),
13384    );
13385
13386    modules.insert(
13387        "turndown-plugin-gfm".to_string(),
13388        r"
13389export function gfm(service) {}
13390export function tables(service) {}
13391export function strikethrough(service) {}
13392export function taskListItems(service) {}
13393"
13394        .trim()
13395        .to_string(),
13396    );
13397
13398    modules.insert(
13399        "@opentelemetry/exporter-trace-otlp-http".to_string(),
13400        r"
13401export class OTLPTraceExporter {
13402    constructor(opts) { this._opts = opts || {}; }
13403    export(spans, cb) { if (cb) cb({ code: 0 }); }
13404    shutdown() { return Promise.resolve(); }
13405}
13406"
13407        .trim()
13408        .to_string(),
13409    );
13410
13411    modules.insert(
13412        "@opentelemetry/resources".to_string(),
13413        r"
13414export class Resource {
13415    constructor(attrs) { this.attributes = attrs || {}; }
13416    merge(other) { return new Resource({ ...this.attributes, ...(other?.attributes || {}) }); }
13417}
13418export function resourceFromAttributes(attrs) { return new Resource(attrs); }
13419"
13420        .trim()
13421        .to_string(),
13422    );
13423
13424    modules.insert(
13425        "@opentelemetry/sdk-trace-base".to_string(),
13426        r"
13427const noopSpan = { setAttribute() { return this; }, end() {}, isRecording() { return false; }, spanContext() { return {}; } };
13428export class BasicTracerProvider {
13429    constructor(opts) { this._opts = opts || {}; }
13430    addSpanProcessor(p) {}
13431    register() {}
13432    getTracer() { return { startSpan() { return noopSpan; }, startActiveSpan(n, fn) { return fn(noopSpan); } }; }
13433    shutdown() { return Promise.resolve(); }
13434}
13435export class SimpleSpanProcessor {
13436    constructor(exporter) {}
13437    onStart() {}
13438    onEnd() {}
13439    shutdown() { return Promise.resolve(); }
13440    forceFlush() { return Promise.resolve(); }
13441}
13442export class BatchSpanProcessor extends SimpleSpanProcessor {}
13443"
13444        .trim()
13445        .to_string(),
13446    );
13447
13448    modules.insert(
13449        "@opentelemetry/semantic-conventions".to_string(),
13450        r"
13451export const SemanticResourceAttributes = {
13452    SERVICE_NAME: 'service.name',
13453    SERVICE_VERSION: 'service.version',
13454    DEPLOYMENT_ENVIRONMENT: 'deployment.environment',
13455};
13456export const SEMRESATTRS_SERVICE_NAME = 'service.name';
13457export const SEMRESATTRS_SERVICE_VERSION = 'service.version';
13458"
13459        .trim()
13460        .to_string(),
13461    );
13462
13463    // ── npm package stubs for extension conformance ──
13464
13465    {
13466        let openclaw_plugin_sdk = r#"
13467export function definePlugin(spec = {}) { return spec; }
13468export function createPlugin(spec = {}) { return spec; }
13469export function tool(spec = {}) { return { ...spec, type: "tool" }; }
13470export function command(spec = {}) { return { ...spec, type: "command" }; }
13471export function provider(spec = {}) { return { ...spec, type: "provider" }; }
13472export const DEFAULT_ACCOUNT_ID = "default";
13473const __schema = {
13474  parse(value) { return value; },
13475  safeParse(value) { return { success: true, data: value }; },
13476  optional() { return this; },
13477  nullable() { return this; },
13478  default() { return this; },
13479  array() { return this; },
13480  transform() { return this; },
13481  refine() { return this; },
13482};
13483export const emptyPluginConfigSchema = __schema;
13484export function createReplyPrefixContext() { return {}; }
13485export function stringEnum(values = []) { return values[0] ?? ""; }
13486export function getChatChannelMeta() { return {}; }
13487export function addWildcardAllowFrom() { return []; }
13488export function listFeishuAccountIds() { return []; }
13489export function normalizeAccountId(value) { return String(value ?? ""); }
13490export function jsonResult(value) {
13491  return {
13492    content: [{ type: "text", text: JSON.stringify(value ?? null) }],
13493    details: { value },
13494  };
13495}
13496export function stripAnsi(value) {
13497  return String(value ?? "").replace(/\u001b\[[0-9;]*m/g, "");
13498}
13499export function recordInboundSession() { return undefined; }
13500export class OpenClawPlugin {
13501  constructor(spec = {}) { this.spec = spec; }
13502  async activate(pi) {
13503    const plugin = this.spec || {};
13504    if (Array.isArray(plugin.tools)) {
13505      for (const t of plugin.tools) {
13506        if (!t || !t.name) continue;
13507        const execute = typeof t.execute === "function" ? t.execute : async () => ({ content: [] });
13508        pi.registerTool?.({ ...t, execute });
13509      }
13510    }
13511    if (Array.isArray(plugin.commands)) {
13512      for (const c of plugin.commands) {
13513        if (!c || !c.name) continue;
13514        const handler = typeof c.handler === "function" ? c.handler : async () => ({});
13515        pi.registerCommand?.(c.name, { ...c, handler });
13516      }
13517    }
13518    if (typeof plugin.activate === "function") {
13519      await plugin.activate(pi);
13520    }
13521  }
13522}
13523export async function registerOpenClaw(pi, plugin) {
13524  if (typeof plugin === "function") {
13525    return await plugin(pi);
13526  }
13527  if (plugin && typeof plugin.default === "function") {
13528    return await plugin.default(pi);
13529  }
13530  if (plugin && typeof plugin.activate === "function") {
13531    return await plugin.activate(pi);
13532  }
13533  return undefined;
13534}
13535export default {
13536  definePlugin,
13537  createPlugin,
13538  tool,
13539  command,
13540  provider,
13541  DEFAULT_ACCOUNT_ID,
13542  emptyPluginConfigSchema,
13543  createReplyPrefixContext,
13544  stringEnum,
13545  getChatChannelMeta,
13546  addWildcardAllowFrom,
13547  listFeishuAccountIds,
13548  normalizeAccountId,
13549  jsonResult,
13550  stripAnsi,
13551  recordInboundSession,
13552  registerOpenClaw,
13553  OpenClawPlugin,
13554};
13555"#
13556        .trim()
13557        .to_string();
13558
13559        modules.insert(
13560            "openclaw/plugin-sdk".to_string(),
13561            openclaw_plugin_sdk.clone(),
13562        );
13563        modules.insert(
13564            "openclaw/plugin-sdk/index.js".to_string(),
13565            openclaw_plugin_sdk.clone(),
13566        );
13567        modules.insert(
13568            "clawdbot/plugin-sdk".to_string(),
13569            openclaw_plugin_sdk.clone(),
13570        );
13571        modules.insert(
13572            "clawdbot/plugin-sdk/index.js".to_string(),
13573            openclaw_plugin_sdk,
13574        );
13575    }
13576
13577    modules.insert(
13578        "zod".to_string(),
13579        r"
13580const __schema = {
13581  parse(value) { return value; },
13582  safeParse(value) { return { success: true, data: value }; },
13583  optional() { return this; },
13584  nullable() { return this; },
13585  nullish() { return this; },
13586  default() { return this; },
13587  array() { return this; },
13588  transform() { return this; },
13589  refine() { return this; },
13590  describe() { return this; },
13591  min() { return this; },
13592  max() { return this; },
13593  length() { return this; },
13594  regex() { return this; },
13595  url() { return this; },
13596  email() { return this; },
13597  uuid() { return this; },
13598  int() { return this; },
13599  positive() { return this; },
13600  nonnegative() { return this; },
13601  nonempty() { return this; },
13602};
13603function makeSchema() { return Object.create(__schema); }
13604export const z = {
13605  string() { return makeSchema(); },
13606  number() { return makeSchema(); },
13607  boolean() { return makeSchema(); },
13608  object() { return makeSchema(); },
13609  array() { return makeSchema(); },
13610  enum() { return makeSchema(); },
13611  literal() { return makeSchema(); },
13612  union() { return makeSchema(); },
13613  intersection() { return makeSchema(); },
13614  record() { return makeSchema(); },
13615  any() { return makeSchema(); },
13616  unknown() { return makeSchema(); },
13617  null() { return makeSchema(); },
13618  undefined() { return makeSchema(); },
13619  optional(inner) { return inner ?? makeSchema(); },
13620  nullable(inner) { return inner ?? makeSchema(); },
13621};
13622export default z;
13623"
13624        .trim()
13625        .to_string(),
13626    );
13627
13628    modules.insert(
13629        "yaml".to_string(),
13630        r##"
13631export function parse(input) {
13632    const text = String(input ?? "").trim();
13633    if (!text) return {};
13634    const out = {};
13635    for (const rawLine of text.split(/\r?\n/)) {
13636        const line = rawLine.trim();
13637        if (!line || line.startsWith("#")) continue;
13638        const idx = line.indexOf(":");
13639        if (idx === -1) continue;
13640        const key = line.slice(0, idx).trim();
13641        const value = line.slice(idx + 1).trim();
13642        if (key) out[key] = value;
13643    }
13644    return out;
13645}
13646export function stringify(value) {
13647    if (!value || typeof value !== "object") return "";
13648    const lines = Object.entries(value).map(([k, v]) => `${k}: ${v ?? ""}`);
13649    return lines.length ? `${lines.join("\n")}\n` : "";
13650}
13651export default { parse, stringify };
13652"##
13653        .trim()
13654        .to_string(),
13655    );
13656
13657    modules.insert(
13658        "better-sqlite3".to_string(),
13659        r#"
13660class Statement {
13661    all() { return []; }
13662    get() { return undefined; }
13663    run() { return { changes: 0, lastInsertRowid: 0 }; }
13664}
13665
13666function BetterSqlite3(filename, options = {}) {
13667    if (!(this instanceof BetterSqlite3)) return new BetterSqlite3(filename, options);
13668    this.filename = String(filename ?? "");
13669    this.options = options;
13670}
13671
13672BetterSqlite3.prototype.prepare = function(_sql) { return new Statement(); };
13673BetterSqlite3.prototype.exec = function(_sql) { return this; };
13674BetterSqlite3.prototype.pragma = function(_sql) { return []; };
13675BetterSqlite3.prototype.transaction = function(fn) {
13676    const wrapped = (...args) => (typeof fn === "function" ? fn(...args) : undefined);
13677    wrapped.immediate = wrapped;
13678    wrapped.deferred = wrapped;
13679    wrapped.exclusive = wrapped;
13680    return wrapped;
13681};
13682BetterSqlite3.prototype.close = function() {};
13683
13684BetterSqlite3.Statement = Statement;
13685BetterSqlite3.Database = BetterSqlite3;
13686
13687export { Statement };
13688export default BetterSqlite3;
13689"#
13690        .trim()
13691        .to_string(),
13692    );
13693
13694    modules.insert(
13695        "ws".to_string(),
13696        r#"
13697function __makeEmitter(target) {
13698  target._listeners = {};
13699  target.on = function(event, handler) {
13700    if (!this._listeners[event]) this._listeners[event] = [];
13701    this._listeners[event].push(handler);
13702    return this;
13703  };
13704  target.once = function(event, handler) {
13705    const wrapper = (...args) => {
13706      this.off(event, wrapper);
13707      handler(...args);
13708    };
13709    return this.on(event, wrapper);
13710  };
13711  target.off = function(event, handler) {
13712    const list = this._listeners[event];
13713    if (!list) return this;
13714    const idx = list.indexOf(handler);
13715    if (idx >= 0) list.splice(idx, 1);
13716    return this;
13717  };
13718  target.emit = function(event, ...args) {
13719    const list = this._listeners[event];
13720    if (!list) return false;
13721    list.slice().forEach(fn => fn(...args));
13722    return true;
13723  };
13724  target.addEventListener = target.on;
13725  target.removeEventListener = target.off;
13726  return target;
13727}
13728
13729export class WebSocket {
13730  constructor(url, protocols) {
13731    this.url = String(url ?? "");
13732    this.protocol = Array.isArray(protocols) ? (protocols[0] ?? "") : (protocols || "");
13733    this.extensions = "";
13734    this.readyState = WebSocket.CONNECTING;
13735    this.binaryType = "nodebuffer";
13736    this.bufferedAmount = 0;
13737    __makeEmitter(this);
13738    const schedule = (fn) => {
13739      if (typeof globalThis.queueMicrotask === "function") {
13740        globalThis.queueMicrotask(fn);
13741        return;
13742      }
13743      if (typeof globalThis.setTimeout === "function") {
13744        globalThis.setTimeout(fn, 0);
13745        return;
13746      }
13747      try {
13748        Promise.resolve().then(fn);
13749      } catch (_err) {
13750        fn();
13751      }
13752    };
13753    schedule(() => {
13754      this.readyState = WebSocket.OPEN;
13755      const evt = { type: "open" };
13756      if (typeof this.onopen === "function") this.onopen(evt);
13757      this.emit("open", evt);
13758    });
13759  }
13760  send(_data, cb) { if (typeof cb === "function") cb(); }
13761  close(code, reason) {
13762    if (this.readyState === WebSocket.CLOSED) return;
13763    this.readyState = WebSocket.CLOSING;
13764    this.readyState = WebSocket.CLOSED;
13765    const evt = { code: code ?? 1000, reason: reason ?? "", wasClean: true };
13766    if (typeof this.onclose === "function") this.onclose(evt);
13767    this.emit("close", evt);
13768  }
13769  terminate() { this.close(); }
13770}
13771WebSocket.CONNECTING = 0;
13772WebSocket.OPEN = 1;
13773WebSocket.CLOSING = 2;
13774WebSocket.CLOSED = 3;
13775
13776export class WebSocketServer {
13777  constructor(options = {}) {
13778    this.options = options;
13779    this.clients = new Set();
13780    __makeEmitter(this);
13781  }
13782  handleUpgrade(_req, _socket, _head, cb) {
13783    const ws = new WebSocket("ws://stub");
13784    this.clients.add(ws);
13785    if (typeof cb === "function") cb(ws);
13786    this.emit("connection", ws);
13787  }
13788  close(cb) { if (typeof cb === "function") cb(); }
13789}
13790
13791WebSocket.Server = WebSocketServer;
13792WebSocket.WebSocketServer = WebSocketServer;
13793export const Server = WebSocketServer;
13794export default WebSocket;
13795"#
13796        .trim()
13797        .to_string(),
13798    );
13799
13800    modules.insert(
13801        "axios".to_string(),
13802        r#"
13803function __makeResponse(config, data) {
13804  return {
13805    data: data ?? null,
13806    status: 200,
13807    statusText: "OK",
13808    headers: {},
13809    config: config ?? {},
13810    request: {},
13811  };
13812}
13813
13814async function axios(config = {}) {
13815  return __makeResponse(config, config.data);
13816}
13817
13818axios.request = (config) => axios(config);
13819axios.defaults = {};
13820axios.get = (url, config = {}) => axios({ ...config, url, method: "get" });
13821axios.delete = (url, config = {}) => axios({ ...config, url, method: "delete" });
13822axios.head = (url, config = {}) => axios({ ...config, url, method: "head" });
13823axios.options = (url, config = {}) => axios({ ...config, url, method: "options" });
13824axios.post = (url, data, config = {}) => axios({ ...config, url, data, method: "post" });
13825axios.put = (url, data, config = {}) => axios({ ...config, url, data, method: "put" });
13826axios.patch = (url, data, config = {}) => axios({ ...config, url, data, method: "patch" });
13827axios.create = (defaults = {}) => {
13828  const instance = (config = {}) => axios({ ...defaults, ...config });
13829  instance.request = (config) => instance(config);
13830  instance.get = (url, config = {}) => instance({ ...config, url, method: "get" });
13831  instance.delete = (url, config = {}) => instance({ ...config, url, method: "delete" });
13832  instance.head = (url, config = {}) => instance({ ...config, url, method: "head" });
13833  instance.options = (url, config = {}) => instance({ ...config, url, method: "options" });
13834  instance.post = (url, data, config = {}) => instance({ ...config, url, data, method: "post" });
13835  instance.put = (url, data, config = {}) => instance({ ...config, url, data, method: "put" });
13836  instance.patch = (url, data, config = {}) => instance({ ...config, url, data, method: "patch" });
13837  instance.defaults = { ...defaults };
13838  return instance;
13839};
13840axios.isAxiosError = (err) => !!(err && err.isAxiosError);
13841axios.AxiosError = class AxiosError extends Error {
13842  constructor(message) {
13843    super(message || "AxiosError");
13844    this.isAxiosError = true;
13845  }
13846};
13847axios.Cancel = class Cancel extends Error {
13848  constructor(message) {
13849    super(message || "Cancel");
13850    this.__CANCEL__ = true;
13851  }
13852};
13853axios.CancelToken = {
13854  source() { return { token: {}, cancel: () => {} }; },
13855};
13856axios.all = (promises) => Promise.all(promises);
13857axios.spread = (cb) => (arr) => cb(...arr);
13858
13859export default axios;
13860"#
13861        .trim()
13862        .to_string(),
13863    );
13864
13865    modules.insert(
13866        "open".to_string(),
13867        r"
13868async function open(_target, _options = {}) {
13869  return {
13870    pid: 0,
13871    stdout: null,
13872    stderr: null,
13873    stdin: null,
13874    unref() {},
13875    kill() {},
13876    on() {},
13877    once() {},
13878  };
13879}
13880
13881open.apps = {};
13882open.openApp = open;
13883
13884export const apps = open.apps;
13885export const openApp = open.openApp;
13886export default open;
13887"
13888        .trim()
13889        .to_string(),
13890    );
13891
13892    modules.insert(
13893        "commander".to_string(),
13894        r#"
13895export class Option {
13896  constructor(flags, description) {
13897    this.flags = flags;
13898    this.description = description;
13899    this.defaultValue = undefined;
13900  }
13901  default(value) { this.defaultValue = value; return this; }
13902}
13903
13904export class Argument {
13905  constructor(name, description) {
13906    this.name = name;
13907    this.description = description;
13908    this.defaultValue = undefined;
13909  }
13910  default(value) { this.defaultValue = value; return this; }
13911}
13912
13913export class Command {
13914  constructor(name) {
13915    this._name = name || "";
13916    this._options = [];
13917    this._args = [];
13918    this._commands = [];
13919    this._action = null;
13920    this.args = [];
13921  }
13922  name(value) { if (value !== undefined) this._name = value; return this; }
13923  description(_value) { return this; }
13924  version(_value, _flags, _desc) { return this; }
13925  option(_flags, _desc, _defaultValue) { return this; }
13926  argument(_name, _desc, _defaultValue) { return this; }
13927  addOption(_opt) { return this; }
13928  addCommand(cmd) { this._commands.push(cmd); return this; }
13929  command(name) { const cmd = new Command(name); this._commands.push(cmd); return cmd; }
13930  action(fn) { this._action = fn; return this; }
13931  parse(_argv, _opts) { if (typeof this._action === "function") this._action(); return this; }
13932  parseAsync(argv, opts) { this.parse(argv, opts); return Promise.resolve(this); }
13933  opts() { return {}; }
13934  optsWithGlobals() { return {}; }
13935  requiredOption() { return this; }
13936  allowUnknownOption() { return this; }
13937  allowExcessArguments() { return this; }
13938  help() { return this; }
13939  outputHelp() { return this; }
13940}
13941
13942export function createCommand(name) { return new Command(name); }
13943export const program = new Command();
13944export default { Command, Option, Argument, program, createCommand };
13945"#
13946        .trim()
13947        .to_string(),
13948    );
13949
13950    modules.insert(
13951        "chalk".to_string(),
13952        r#"
13953function chalk(...args) {
13954  return args.join("");
13955}
13956
13957const styles = [
13958  "reset", "bold", "dim", "italic", "underline", "inverse", "hidden", "strikethrough",
13959  "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray",
13960  "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta", "bgCyan", "bgWhite",
13961];
13962
13963for (const style of styles) {
13964  chalk[style] = chalk;
13965}
13966
13967chalk.hex = () => chalk;
13968chalk.rgb = () => chalk;
13969chalk.ansi256 = () => chalk;
13970chalk.level = 0;
13971chalk.supportsColor = false;
13972chalk.enabled = true;
13973
13974export class Chalk {
13975  constructor() { return chalk; }
13976}
13977
13978export default chalk;
13979export { chalk };
13980"#
13981        .trim()
13982        .to_string(),
13983    );
13984
13985    modules.insert(
13986        "@mariozechner/pi-agent-core".to_string(),
13987        r#"
13988export const ThinkingLevel = {
13989    low: "low",
13990    medium: "medium",
13991    high: "high",
13992};
13993export class AgentTool {}
13994export default { ThinkingLevel, AgentTool };
13995"#
13996        .trim()
13997        .to_string(),
13998    );
13999
14000    modules.insert(
14001        "@mariozechner/pi-agent-core/index.js".to_string(),
14002        r#"
14003export const ThinkingLevel = {
14004    low: "low",
14005    medium: "medium",
14006    high: "high",
14007};
14008export class AgentTool {}
14009export default { ThinkingLevel, AgentTool };
14010"#
14011        .trim()
14012        .to_string(),
14013    );
14014
14015    modules.insert(
14016        "openai".to_string(),
14017        r#"
14018class OpenAI {
14019    constructor(config = {}) { this.config = config; }
14020    get chat() {
14021        return { completions: { create: async () => ({ choices: [{ message: { content: "" } }] }) } };
14022    }
14023}
14024export default OpenAI;
14025export { OpenAI };
14026"#
14027        .trim()
14028        .to_string(),
14029    );
14030
14031    modules.insert(
14032        "adm-zip".to_string(),
14033        r#"
14034class AdmZip {
14035    constructor(path) { this.path = path; this.entries = []; }
14036    getEntries() { return this.entries; }
14037    readAsText() { return ""; }
14038    extractAllTo() {}
14039    addFile() {}
14040    writeZip() {}
14041}
14042export default AdmZip;
14043"#
14044        .trim()
14045        .to_string(),
14046    );
14047
14048    modules.insert(
14049        "linkedom".to_string(),
14050        r#"
14051export function parseHTML(html) {
14052    const doc = {
14053        documentElement: { outerHTML: html || "" },
14054        querySelector: () => null,
14055        querySelectorAll: () => [],
14056        createElement: (tag) => ({ tagName: tag, textContent: "", innerHTML: "", children: [], appendChild() {} }),
14057        body: { textContent: "", innerHTML: "", children: [] },
14058        title: "",
14059    };
14060    return { document: doc, window: { document: doc } };
14061}
14062"#
14063        .trim()
14064        .to_string(),
14065    );
14066
14067    modules.insert(
14068        "@sourcegraph/scip-typescript".to_string(),
14069        r"
14070export const scip = { Index: class {} };
14071export default { scip };
14072"
14073        .trim()
14074        .to_string(),
14075    );
14076
14077    modules.insert(
14078        "@sourcegraph/scip-typescript/dist/src/scip.js".to_string(),
14079        r"
14080export const scip = { Index: class {} };
14081export default { scip };
14082"
14083        .trim()
14084        .to_string(),
14085    );
14086
14087    modules.insert(
14088        "p-limit".to_string(),
14089        r"
14090export default function pLimit(concurrency) {
14091    const queue = [];
14092    let active = 0;
14093    const next = () => {
14094        active--;
14095        if (queue.length > 0) queue.shift()();
14096    };
14097    const run = async (fn, resolve, ...args) => {
14098        active++;
14099        const result = (async () => fn(...args))();
14100        resolve(result);
14101        try { await result; } catch {}
14102        next();
14103    };
14104    const enqueue = (fn, resolve, ...args) => {
14105        queue.push(run.bind(null, fn, resolve, ...args));
14106        (async () => { if (active < concurrency && queue.length > 0) queue.shift()(); })();
14107    };
14108    const generator = (fn, ...args) => new Promise(resolve => enqueue(fn, resolve, ...args));
14109    Object.defineProperties(generator, {
14110        activeCount: { get: () => active },
14111        pendingCount: { get: () => queue.length },
14112        clearQueue: { value: () => { queue.length = 0; } },
14113    });
14114    return generator;
14115}
14116"
14117        .trim()
14118        .to_string(),
14119    );
14120
14121    // Also register the CLI entrypoint used by qualisero-pi-agent-scip
14122    modules.insert(
14123        "@sourcegraph/scip-typescript/dist/src/main.js".to_string(),
14124        r"
14125export function main() { return 0; }
14126export function run() { return 0; }
14127export default main;
14128"
14129        .trim()
14130        .to_string(),
14131    );
14132
14133    modules.insert(
14134        "unpdf".to_string(),
14135        r#"
14136export async function getDocumentProxy(data) {
14137    return { numPages: 0, getPage: async () => ({ getTextContent: async () => ({ items: [] }) }) };
14138}
14139export async function extractText(data) { return { totalPages: 0, text: "" }; }
14140export async function renderPageAsImage() { return new Uint8Array(); }
14141"#
14142        .trim()
14143        .to_string(),
14144    );
14145
14146    modules.insert(
14147        "@sourcegraph/scip-python".to_string(),
14148        r"
14149export class PythonIndexer { async index() { return []; } }
14150export default { PythonIndexer };
14151"
14152        .trim()
14153        .to_string(),
14154    );
14155
14156    modules.insert(
14157        "@sourcegraph/scip-python/index.js".to_string(),
14158        r"
14159export class PythonIndexer { async index() { return []; } }
14160export default { PythonIndexer };
14161"
14162        .trim()
14163        .to_string(),
14164    );
14165
14166    modules
14167}
14168
14169fn default_virtual_modules_shared() -> Arc<HashMap<String, String>> {
14170    static DEFAULT_VIRTUAL_MODULES: std::sync::OnceLock<Arc<HashMap<String, String>>> =
14171        std::sync::OnceLock::new();
14172    Arc::clone(DEFAULT_VIRTUAL_MODULES.get_or_init(|| Arc::new(default_virtual_modules())))
14173}
14174
14175/// Returns the set of all module specifiers available as virtual modules.
14176///
14177/// Used by the preflight analyzer to determine whether an extension's
14178/// imports can be resolved without hitting the filesystem.
14179#[must_use]
14180pub fn available_virtual_module_names() -> std::collections::BTreeSet<String> {
14181    default_virtual_modules_shared().keys().cloned().collect()
14182}
14183
14184/// Sampling cadence for memory usage snapshots when no hard memory limit is configured.
14185///
14186/// `AsyncRuntime::memory_usage()` triggers QuickJS heap traversal and is expensive on hot
14187/// tick paths. When runtime memory is unbounded, periodic sampling preserves observability
14188/// while avoiding per-tick full-heap scans.
14189const UNBOUNDED_MEMORY_USAGE_SAMPLE_EVERY_TICKS: u64 = 32;
14190
14191/// Integrated PiJS runtime combining QuickJS, scheduler, and Promise bridge.
14192///
14193/// This is the main entry point for running JavaScript extensions with
14194/// proper async hostcall support. It provides:
14195///
14196/// - Promise-based `pi.*` methods that enqueue hostcall requests
14197/// - Deterministic event loop scheduling
14198/// - Automatic microtask draining after macrotasks
14199/// - Hostcall completion → Promise resolution/rejection
14200///
14201/// # Example
14202///
14203/// ```ignore
14204/// // Create runtime
14205/// let runtime = PiJsRuntime::new().await?;
14206///
14207/// // Evaluate extension code
14208/// runtime.eval("
14209///     pi.tool('read', { path: 'foo.txt' }).then(result => {
14210///         console.log('Got:', result);
14211///     });
14212/// ").await?;
14213///
14214/// // Process hostcall requests
14215/// while let Some(request) = runtime.drain_hostcall_requests().pop_front() {
14216///     // Execute the hostcall
14217///     let result = execute_tool(&request.kind, &request.payload).await;
14218///     // Deliver completion back to JS
14219///     runtime.complete_hostcall(&request.call_id, result)?;
14220/// }
14221///
14222/// // Tick the event loop to deliver completions
14223/// let stats = runtime.tick().await?;
14224/// ```
14225pub struct PiJsRuntime<C: SchedulerClock = WallClock> {
14226    runtime: AsyncRuntime,
14227    context: AsyncContext,
14228    scheduler: Rc<RefCell<Scheduler<C>>>,
14229    hostcall_queue: HostcallQueue,
14230    trace_seq: Arc<AtomicU64>,
14231    hostcall_tracker: Rc<RefCell<HostcallTracker>>,
14232    hostcalls_total: Arc<AtomicU64>,
14233    hostcalls_timed_out: Arc<AtomicU64>,
14234    last_memory_used_bytes: Arc<AtomicU64>,
14235    peak_memory_used_bytes: Arc<AtomicU64>,
14236    tick_counter: Arc<AtomicU64>,
14237    interrupt_budget: Rc<InterruptBudget>,
14238    config: PiJsRuntimeConfig,
14239    /// Additional filesystem roots that `readFileSync` may access (e.g.
14240    /// extension directories).  Populated lazily as extensions are loaded.
14241    allowed_read_roots: Arc<std::sync::Mutex<Vec<PathBuf>>>,
14242    /// Accumulated auto-repair events.  Use [`Self::record_repair`] to append
14243    /// and [`Self::drain_repair_events`] to retrieve and clear.
14244    repair_events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>>,
14245    /// Shared module state used by the resolver and loader.  Stored here so
14246    /// that [`Self::add_extension_root`] can push extension roots into the
14247    /// resolver after construction.
14248    module_state: Rc<RefCell<PiJsModuleState>>,
14249    /// Extension policy for synchronous capability checks.
14250    policy: Option<ExtensionPolicy>,
14251}
14252
14253#[derive(Debug, Clone, Default, serde::Deserialize)]
14254#[serde(rename_all = "camelCase")]
14255struct JsRuntimeRegistrySnapshot {
14256    extensions: u64,
14257    tools: u64,
14258    commands: u64,
14259    hooks: u64,
14260    event_bus_hooks: u64,
14261    providers: u64,
14262    shortcuts: u64,
14263    message_renderers: u64,
14264    pending_tasks: u64,
14265    pending_hostcalls: u64,
14266    pending_timers: u64,
14267    pending_event_listener_lists: u64,
14268    provider_streams: u64,
14269}
14270
14271#[derive(Debug, Clone, serde::Deserialize)]
14272struct JsRuntimeResetPayload {
14273    before: JsRuntimeRegistrySnapshot,
14274    after: JsRuntimeRegistrySnapshot,
14275    clean: bool,
14276}
14277
14278#[derive(Debug, Clone, Default)]
14279pub struct PiJsWarmResetReport {
14280    pub reused: bool,
14281    pub reason_code: Option<String>,
14282    pub rust_pending_hostcalls: u64,
14283    pub rust_pending_hostcall_queue: u64,
14284    pub rust_scheduler_pending: bool,
14285    pub pending_tasks_before: u64,
14286    pub pending_hostcalls_before: u64,
14287    pub pending_timers_before: u64,
14288    pub residual_entries_after: u64,
14289    pub dynamic_module_invalidations: u64,
14290    pub module_cache_hits: u64,
14291    pub module_cache_misses: u64,
14292    pub module_cache_invalidations: u64,
14293    pub module_cache_entries: u64,
14294}
14295
14296#[allow(clippy::future_not_send)]
14297impl PiJsRuntime<WallClock> {
14298    /// Create a new PiJS runtime with the default wall clock.
14299    #[allow(clippy::future_not_send)]
14300    pub async fn new() -> Result<Self> {
14301        Self::with_clock(WallClock).await
14302    }
14303}
14304
14305#[allow(clippy::future_not_send)]
14306impl<C: SchedulerClock + 'static> PiJsRuntime<C> {
14307    /// Create a new PiJS runtime with a custom clock.
14308    #[allow(clippy::future_not_send)]
14309    pub async fn with_clock(clock: C) -> Result<Self> {
14310        Self::with_clock_and_config(clock, PiJsRuntimeConfig::default()).await
14311    }
14312
14313    /// Create a new PiJS runtime with a custom clock and runtime config.
14314    #[allow(clippy::future_not_send)]
14315    pub async fn with_clock_and_config(clock: C, config: PiJsRuntimeConfig) -> Result<Self> {
14316        Self::with_clock_and_config_with_policy(clock, config, None).await
14317    }
14318
14319    /// Create a new PiJS runtime with a custom clock, runtime config, and optional policy.
14320    #[allow(clippy::future_not_send, clippy::too_many_lines)]
14321    pub async fn with_clock_and_config_with_policy(
14322        clock: C,
14323        mut config: PiJsRuntimeConfig,
14324        policy: Option<ExtensionPolicy>,
14325    ) -> Result<Self> {
14326        // Inject target architecture so JS process.arch can read it
14327        #[cfg(target_arch = "x86_64")]
14328        config
14329            .env
14330            .entry("PI_TARGET_ARCH".to_string())
14331            .or_insert_with(|| "x64".to_string());
14332        #[cfg(target_arch = "aarch64")]
14333        config
14334            .env
14335            .entry("PI_TARGET_ARCH".to_string())
14336            .or_insert_with(|| "arm64".to_string());
14337        #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
14338        config
14339            .env
14340            .entry("PI_TARGET_ARCH".to_string())
14341            .or_insert_with(|| "x64".to_string());
14342
14343        // Inject target platform so JS process.platform matches os.platform().
14344        // OSTYPE env var is a shell variable and not always exported.
14345        {
14346            let platform = match std::env::consts::OS {
14347                "macos" => "darwin",
14348                "windows" => "win32",
14349                other => other,
14350            };
14351            config
14352                .env
14353                .entry("PI_PLATFORM".to_string())
14354                .or_insert_with(|| platform.to_string());
14355        }
14356
14357        let runtime = AsyncRuntime::new().map_err(|err| map_js_error(&err))?;
14358        if let Some(limit) = config.limits.memory_limit_bytes {
14359            runtime.set_memory_limit(limit).await;
14360        }
14361        if let Some(limit) = config.limits.max_stack_bytes {
14362            runtime.set_max_stack_size(limit).await;
14363        }
14364
14365        let interrupt_budget = Rc::new(InterruptBudget::new(config.limits.interrupt_budget));
14366        if config.limits.interrupt_budget.is_some() {
14367            let budget = Rc::clone(&interrupt_budget);
14368            runtime
14369                .set_interrupt_handler(Some(Box::new(move || budget.on_interrupt())))
14370                .await;
14371        }
14372
14373        let repair_events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>> =
14374            Arc::new(std::sync::Mutex::new(Vec::new()));
14375        let module_state = Rc::new(RefCell::new(
14376            PiJsModuleState::new()
14377                .with_repair_mode(config.repair_mode)
14378                .with_repair_events(Arc::clone(&repair_events))
14379                .with_disk_cache_dir(config.disk_cache_dir.clone()),
14380        ));
14381        runtime
14382            .set_loader(
14383                PiJsResolver {
14384                    state: Rc::clone(&module_state),
14385                },
14386                PiJsLoader {
14387                    state: Rc::clone(&module_state),
14388                },
14389            )
14390            .await;
14391
14392        let context = AsyncContext::full(&runtime)
14393            .await
14394            .map_err(|err| map_js_error(&err))?;
14395
14396        let scheduler = Rc::new(RefCell::new(Scheduler::with_clock(clock)));
14397        let fast_queue_capacity = if config.limits.hostcall_fast_queue_capacity == 0 {
14398            HOSTCALL_FAST_RING_CAPACITY
14399        } else {
14400            config.limits.hostcall_fast_queue_capacity
14401        };
14402        let overflow_queue_capacity = if config.limits.hostcall_overflow_queue_capacity == 0 {
14403            HOSTCALL_OVERFLOW_CAPACITY
14404        } else {
14405            config.limits.hostcall_overflow_queue_capacity
14406        };
14407        let hostcall_queue: HostcallQueue = Rc::new(RefCell::new(
14408            HostcallRequestQueue::with_capacities(fast_queue_capacity, overflow_queue_capacity),
14409        ));
14410        let hostcall_tracker = Rc::new(RefCell::new(HostcallTracker::default()));
14411        let hostcalls_total = Arc::new(AtomicU64::new(0));
14412        let hostcalls_timed_out = Arc::new(AtomicU64::new(0));
14413        let last_memory_used_bytes = Arc::new(AtomicU64::new(0));
14414        let peak_memory_used_bytes = Arc::new(AtomicU64::new(0));
14415        let tick_counter = Arc::new(AtomicU64::new(0));
14416        let trace_seq = Arc::new(AtomicU64::new(1));
14417
14418        let instance = Self {
14419            runtime,
14420            context,
14421            scheduler,
14422            hostcall_queue,
14423            trace_seq,
14424            hostcall_tracker,
14425            hostcalls_total,
14426            hostcalls_timed_out,
14427            last_memory_used_bytes,
14428            peak_memory_used_bytes,
14429            tick_counter,
14430            interrupt_budget,
14431            config,
14432            allowed_read_roots: Arc::new(std::sync::Mutex::new(Vec::new())),
14433            repair_events,
14434            module_state,
14435            policy,
14436        };
14437
14438        instance.install_pi_bridge().await?;
14439        Ok(instance)
14440    }
14441
14442    async fn map_quickjs_error(&self, err: &rquickjs::Error) -> Error {
14443        if self.interrupt_budget.did_trip() {
14444            self.interrupt_budget.clear_trip();
14445            return Error::extension("PiJS execution budget exceeded".to_string());
14446        }
14447        if matches!(err, rquickjs::Error::Exception) {
14448            let detail = self
14449                .context
14450                .with(|ctx| {
14451                    let caught = ctx.catch();
14452                    Ok::<String, rquickjs::Error>(format_quickjs_exception(&ctx, caught))
14453                })
14454                .await
14455                .ok();
14456            if let Some(detail) = detail {
14457                let detail = detail.trim();
14458                if !detail.is_empty() && detail != "undefined" {
14459                    return Error::extension(format!("QuickJS exception: {detail}"));
14460                }
14461            }
14462        }
14463        map_js_error(err)
14464    }
14465
14466    fn map_quickjs_job_error<E: std::fmt::Display>(&self, err: E) -> Error {
14467        if self.interrupt_budget.did_trip() {
14468            self.interrupt_budget.clear_trip();
14469            return Error::extension("PiJS execution budget exceeded".to_string());
14470        }
14471        Error::extension(format!("QuickJS job: {err}"))
14472    }
14473
14474    fn should_sample_memory_usage(&self) -> bool {
14475        if self.config.limits.memory_limit_bytes.is_some() {
14476            return true;
14477        }
14478
14479        let tick = self.tick_counter.fetch_add(1, AtomicOrdering::SeqCst) + 1;
14480        tick == 1 || (tick % UNBOUNDED_MEMORY_USAGE_SAMPLE_EVERY_TICKS == 0)
14481    }
14482
14483    fn module_cache_snapshot(&self) -> (u64, u64, u64, u64, u64) {
14484        let state = self.module_state.borrow();
14485        let entries = u64::try_from(state.compiled_sources.len()).unwrap_or(u64::MAX);
14486        (
14487            state.module_cache_counters.hits,
14488            state.module_cache_counters.misses,
14489            state.module_cache_counters.invalidations,
14490            entries,
14491            state.module_cache_counters.disk_hits,
14492        )
14493    }
14494
14495    #[allow(clippy::future_not_send, clippy::too_many_lines)]
14496    pub async fn reset_for_warm_reload(&self) -> Result<PiJsWarmResetReport> {
14497        let rust_pending_hostcalls =
14498            u64::try_from(self.hostcall_tracker.borrow().pending_count()).unwrap_or(u64::MAX);
14499        let rust_pending_hostcall_queue =
14500            u64::try_from(self.hostcall_queue.borrow().len()).unwrap_or(u64::MAX);
14501        let rust_scheduler_pending = self.scheduler.borrow().has_pending();
14502
14503        let mut report = PiJsWarmResetReport {
14504            rust_pending_hostcalls,
14505            rust_pending_hostcall_queue,
14506            rust_scheduler_pending,
14507            ..PiJsWarmResetReport::default()
14508        };
14509
14510        if rust_pending_hostcalls > 0 || rust_pending_hostcall_queue > 0 || rust_scheduler_pending {
14511            report.reason_code = Some("pending_rust_work".to_string());
14512            return Ok(report);
14513        }
14514
14515        let reset_payload_value = match self
14516            .context
14517            .with(|ctx| {
14518                let global = ctx.globals();
14519                let reset_fn: Function<'_> = global.get("__pi_reset_extension_runtime_state")?;
14520                let value: Value<'_> = reset_fn.call(())?;
14521                js_to_json(&value)
14522            })
14523            .await
14524        {
14525            Ok(value) => value,
14526            Err(err) => return Err(self.map_quickjs_error(&err).await),
14527        };
14528
14529        let reset_payload: JsRuntimeResetPayload = serde_json::from_value(reset_payload_value)
14530            .map_err(|err| {
14531                Error::extension(format!("PiJS warm reset payload decode failed: {err}"))
14532            })?;
14533
14534        report.pending_tasks_before = reset_payload.before.pending_tasks;
14535        report.pending_hostcalls_before = reset_payload.before.pending_hostcalls;
14536        report.pending_timers_before = reset_payload.before.pending_timers;
14537
14538        let residual_after = reset_payload.after.extensions
14539            + reset_payload.after.tools
14540            + reset_payload.after.commands
14541            + reset_payload.after.hooks
14542            + reset_payload.after.event_bus_hooks
14543            + reset_payload.after.providers
14544            + reset_payload.after.shortcuts
14545            + reset_payload.after.message_renderers
14546            + reset_payload.after.pending_tasks
14547            + reset_payload.after.pending_hostcalls
14548            + reset_payload.after.pending_timers
14549            + reset_payload.after.pending_event_listener_lists
14550            + reset_payload.after.provider_streams;
14551        report.residual_entries_after = residual_after;
14552
14553        self.hostcall_queue.borrow_mut().clear();
14554        *self.hostcall_tracker.borrow_mut() = HostcallTracker::default();
14555
14556        if let Ok(mut roots) = self.allowed_read_roots.lock() {
14557            roots.clear();
14558        }
14559
14560        let mut dynamic_invalidations = 0_u64;
14561        {
14562            let mut state = self.module_state.borrow_mut();
14563            let dynamic_specs: Vec<String> =
14564                state.dynamic_virtual_modules.keys().cloned().collect();
14565            state.dynamic_virtual_modules.clear();
14566            state.dynamic_virtual_named_exports.clear();
14567            state.extension_roots.clear();
14568            state.canonical_extension_roots.clear();
14569            state.extension_root_tiers.clear();
14570            state.extension_root_scopes.clear();
14571            state.extension_roots_by_id.clear();
14572            state.extension_roots_without_id.clear();
14573
14574            for spec in dynamic_specs {
14575                if state.compiled_sources.remove(&spec).is_some() {
14576                    dynamic_invalidations = dynamic_invalidations.saturating_add(1);
14577                }
14578            }
14579            if dynamic_invalidations > 0 {
14580                state.module_cache_counters.invalidations = state
14581                    .module_cache_counters
14582                    .invalidations
14583                    .saturating_add(dynamic_invalidations);
14584            }
14585        }
14586        report.dynamic_module_invalidations = dynamic_invalidations;
14587
14588        let (cache_hits, cache_misses, cache_invalidations, cache_entries, _disk_hits) =
14589            self.module_cache_snapshot();
14590        report.module_cache_hits = cache_hits;
14591        report.module_cache_misses = cache_misses;
14592        report.module_cache_invalidations = cache_invalidations;
14593        report.module_cache_entries = cache_entries;
14594
14595        if report.pending_tasks_before > 0
14596            || report.pending_hostcalls_before > 0
14597            || report.pending_timers_before > 0
14598        {
14599            report.reason_code = Some("pending_js_work".to_string());
14600            return Ok(report);
14601        }
14602
14603        if !reset_payload.clean || residual_after > 0 {
14604            report.reason_code = Some("reset_residual_state".to_string());
14605            return Ok(report);
14606        }
14607
14608        report.reused = true;
14609        Ok(report)
14610    }
14611
14612    /// Evaluate JavaScript source code.
14613    pub async fn eval(&self, source: &str) -> Result<()> {
14614        self.interrupt_budget.reset();
14615        match self.context.with(|ctx| ctx.eval::<(), _>(source)).await {
14616            Ok(()) => {}
14617            Err(err) => return Err(self.map_quickjs_error(&err).await),
14618        }
14619        // Drain any immediate jobs (Promise.resolve chains, etc.)
14620        self.drain_jobs().await?;
14621        Ok(())
14622    }
14623
14624    /// Invoke a zero-argument global JS function and drain immediate microtasks.
14625    ///
14626    /// This is useful for hot loops that need to trigger pre-installed JS helpers
14627    /// without paying per-call parser/compile overhead from `eval()`.
14628    pub async fn call_global_void(&self, name: &str) -> Result<()> {
14629        self.interrupt_budget.reset();
14630        match self
14631            .context
14632            .with(|ctx| {
14633                let global = ctx.globals();
14634                let function: Function<'_> = global.get(name)?;
14635                function.call::<(), ()>(())?;
14636                Ok::<(), rquickjs::Error>(())
14637            })
14638            .await
14639        {
14640            Ok(()) => {}
14641            Err(err) => return Err(self.map_quickjs_error(&err).await),
14642        }
14643        self.drain_jobs().await?;
14644        Ok(())
14645    }
14646
14647    // ---- Auto-repair event infrastructure (bd-k5q5.8.1) --------------------
14648
14649    /// The configured repair mode for this runtime.
14650    pub const fn repair_mode(&self) -> RepairMode {
14651        self.config.repair_mode
14652    }
14653
14654    /// Whether the auto-repair pipeline should apply repairs.
14655    pub const fn auto_repair_enabled(&self) -> bool {
14656        self.config.repair_mode.should_apply()
14657    }
14658
14659    /// Record an auto-repair event.  The event is appended to the internal
14660    /// log and emitted as a structured tracing span so external log sinks
14661    /// can capture it.
14662    pub fn record_repair(&self, event: ExtensionRepairEvent) {
14663        tracing::info!(
14664            event = "pijs.repair",
14665            extension_id = %event.extension_id,
14666            pattern = %event.pattern,
14667            success = event.success,
14668            repair_action = %event.repair_action,
14669            "auto-repair applied"
14670        );
14671        if let Ok(mut events) = self.repair_events.lock() {
14672            events.push(event);
14673        }
14674    }
14675
14676    /// Drain all accumulated repair events, leaving the internal buffer
14677    /// empty.  Useful for conformance reports that need to distinguish
14678    /// clean passes from repaired passes.
14679    pub fn drain_repair_events(&self) -> Vec<ExtensionRepairEvent> {
14680        self.repair_events
14681            .lock()
14682            .map(|mut v| std::mem::take(&mut *v))
14683            .unwrap_or_default()
14684    }
14685
14686    /// Number of repair events recorded since the runtime was created.
14687    pub fn repair_count(&self) -> u64 {
14688        self.repair_events.lock().map_or(0, |v| v.len() as u64)
14689    }
14690
14691    /// Reset transient module state for warm isolate reuse.
14692    ///
14693    /// Clears extension roots, dynamic virtual modules, named export tracking,
14694    /// repair events, and cache counters while **preserving** the compiled
14695    /// sources cache (both in-memory and disk). This lets the runtime be
14696    /// reloaded with a fresh set of extensions without paying the SWC
14697    /// transpilation cost again.
14698    pub fn reset_transient_state(&self) {
14699        let mut state = self.module_state.borrow_mut();
14700        state.extension_roots.clear();
14701        state.canonical_extension_roots.clear();
14702        state.extension_root_tiers.clear();
14703        state.extension_root_scopes.clear();
14704        state.extension_roots_by_id.clear();
14705        state.extension_roots_without_id.clear();
14706        state.dynamic_virtual_modules.clear();
14707        state.dynamic_virtual_named_exports.clear();
14708        state.module_cache_counters = ModuleCacheCounters::default();
14709        // Keep compiled_sources — the transpiled source cache is still valid.
14710        // Keep disk_cache_dir — reuse the same persistent cache.
14711        // Keep static_virtual_modules — immutable, shared via Arc.
14712        drop(state);
14713
14714        // Clear hostcall state.
14715        self.hostcall_queue.borrow_mut().clear();
14716        *self.hostcall_tracker.borrow_mut() = HostcallTracker::default();
14717        // Drain repair events.
14718        if let Ok(mut events) = self.repair_events.lock() {
14719            events.clear();
14720        }
14721        // Reset counters.
14722        self.hostcalls_total
14723            .store(0, std::sync::atomic::Ordering::SeqCst);
14724        self.hostcalls_timed_out
14725            .store(0, std::sync::atomic::Ordering::SeqCst);
14726        self.tick_counter
14727            .store(0, std::sync::atomic::Ordering::SeqCst);
14728    }
14729
14730    /// Evaluate a JavaScript file.
14731    pub async fn eval_file(&self, path: &std::path::Path) -> Result<()> {
14732        self.interrupt_budget.reset();
14733        match self.context.with(|ctx| ctx.eval_file::<(), _>(path)).await {
14734            Ok(()) => {}
14735            Err(err) => return Err(self.map_quickjs_error(&err).await),
14736        }
14737        self.drain_jobs().await?;
14738        Ok(())
14739    }
14740
14741    /// Run a closure inside the JS context and map QuickJS errors into `pi::Error`.
14742    ///
14743    /// This is intentionally `pub(crate)` so the extensions runtime can call JS helper
14744    /// functions without exposing raw rquickjs types as part of the public API.
14745    pub(crate) async fn with_ctx<F, R>(&self, f: F) -> Result<R>
14746    where
14747        F: for<'js> FnOnce(Ctx<'js>) -> rquickjs::Result<R> + rquickjs::markers::ParallelSend,
14748        R: rquickjs::markers::ParallelSend,
14749    {
14750        self.interrupt_budget.reset();
14751        match self.context.with(f).await {
14752            Ok(value) => Ok(value),
14753            Err(err) => Err(self.map_quickjs_error(&err).await),
14754        }
14755    }
14756
14757    /// Read a global variable from the JS context and convert it to JSON.
14758    ///
14759    /// This is primarily intended for integration tests and diagnostics; it intentionally
14760    /// does not expose raw `rquickjs` types as part of the public API.
14761    pub async fn read_global_json(&self, name: &str) -> Result<serde_json::Value> {
14762        self.interrupt_budget.reset();
14763        let value = match self
14764            .context
14765            .with(|ctx| {
14766                let global = ctx.globals();
14767                let value: Value<'_> = global.get(name)?;
14768                js_to_json(&value)
14769            })
14770            .await
14771        {
14772            Ok(value) => value,
14773            Err(err) => return Err(self.map_quickjs_error(&err).await),
14774        };
14775        Ok(value)
14776    }
14777
14778    /// Drain pending hostcall requests from the queue.
14779    ///
14780    /// Returns the requests that need to be processed by the host.
14781    /// After processing, call `complete_hostcall()` for each.
14782    pub fn drain_hostcall_requests(&self) -> VecDeque<HostcallRequest> {
14783        self.hostcall_queue.borrow_mut().drain_all()
14784    }
14785
14786    /// Drain pending QuickJS jobs (Promise microtasks) until fixpoint.
14787    pub async fn drain_microtasks(&self) -> Result<usize> {
14788        self.drain_jobs().await
14789    }
14790
14791    /// Return the next timer deadline (runtime clock), if any.
14792    pub fn next_timer_deadline_ms(&self) -> Option<u64> {
14793        self.scheduler.borrow().next_timer_deadline()
14794    }
14795
14796    /// Peek at pending hostcall requests without draining.
14797    pub fn pending_hostcall_count(&self) -> usize {
14798        self.hostcall_tracker.borrow().pending_count()
14799    }
14800
14801    /// Snapshot queue depth/backpressure counters for diagnostics.
14802    pub fn hostcall_queue_telemetry(&self) -> HostcallQueueTelemetry {
14803        self.hostcall_queue.borrow().snapshot()
14804    }
14805
14806    /// Queue wait (enqueue -> dispatch start) in milliseconds for a pending hostcall.
14807    pub fn hostcall_queue_wait_ms(&self, call_id: &str) -> Option<u64> {
14808        let now_ms = self.scheduler.borrow().now_ms();
14809        self.hostcall_tracker
14810            .borrow()
14811            .queue_wait_ms(call_id, now_ms)
14812    }
14813
14814    /// Check whether a given hostcall is still pending.
14815    ///
14816    /// This is useful for streaming hostcalls that need to stop polling/reading once the JS side
14817    /// has timed out or otherwise completed the call.
14818    pub fn is_hostcall_pending(&self, call_id: &str) -> bool {
14819        self.hostcall_tracker.borrow().is_pending(call_id)
14820    }
14821
14822    /// Check whether a given hostcall is pending and not cancelled.
14823    pub fn is_hostcall_active(&self, call_id: &str) -> bool {
14824        self.hostcall_tracker.borrow().is_active(call_id)
14825    }
14826
14827    /// Get all tools registered by loaded JS extensions.
14828    pub async fn get_registered_tools(&self) -> Result<Vec<ExtensionToolDef>> {
14829        self.interrupt_budget.reset();
14830        let value = match self
14831            .context
14832            .with(|ctx| {
14833                let global = ctx.globals();
14834                let getter: Function<'_> = global.get("__pi_get_registered_tools")?;
14835                let tools: Value<'_> = getter.call(())?;
14836                js_to_json(&tools)
14837            })
14838            .await
14839        {
14840            Ok(value) => value,
14841            Err(err) => return Err(self.map_quickjs_error(&err).await),
14842        };
14843
14844        serde_json::from_value(value).map_err(|err| Error::Json(Box::new(err)))
14845    }
14846
14847    /// Read a global value by name and convert it to JSON.
14848    ///
14849    /// This is intentionally a narrow helper that avoids exposing raw `rquickjs`
14850    /// types in the public API (useful for integration tests and debugging).
14851    pub async fn get_global_json(&self, name: &str) -> Result<serde_json::Value> {
14852        self.interrupt_budget.reset();
14853        match self
14854            .context
14855            .with(|ctx| {
14856                let global = ctx.globals();
14857                let value: Value<'_> = global.get(name)?;
14858                js_to_json(&value)
14859            })
14860            .await
14861        {
14862            Ok(value) => Ok(value),
14863            Err(err) => Err(self.map_quickjs_error(&err).await),
14864        }
14865    }
14866
14867    /// Enqueue a hostcall completion to be delivered on next tick.
14868    pub fn complete_hostcall(&self, call_id: impl Into<String>, outcome: HostcallOutcome) {
14869        let call_id = call_id.into();
14870        if let HostcallOutcome::StreamChunk { sequence, .. } = &outcome {
14871            self.hostcall_tracker
14872                .borrow_mut()
14873                .record_stream_seq(&call_id, *sequence);
14874        }
14875        self.scheduler
14876            .borrow_mut()
14877            .enqueue_hostcall_complete(call_id, outcome);
14878    }
14879
14880    /// Enqueue multiple hostcall completions in one scheduler borrow.
14881    pub fn complete_hostcalls_batch<I>(&self, completions: I)
14882    where
14883        I: IntoIterator<Item = (String, HostcallOutcome)>,
14884    {
14885        let mut scheduler = self.scheduler.borrow_mut();
14886        let mut tracker = self.hostcall_tracker.borrow_mut();
14887        for (call_id, outcome) in completions {
14888            if let HostcallOutcome::StreamChunk { sequence, .. } = &outcome {
14889                tracker.record_stream_seq(&call_id, *sequence);
14890            }
14891            scheduler.enqueue_hostcall_complete(call_id, outcome);
14892        }
14893    }
14894
14895    /// Cancel a pending hostcall and enqueue a final sentinel stream chunk if applicable.
14896    pub fn cancel_hostcall(&self, call_id: &str) -> bool {
14897        let (timer_id, next_seq) = {
14898            let mut tracker = self.hostcall_tracker.borrow_mut();
14899            let Some(timer_id) = tracker.cancel(call_id) else {
14900                return false;
14901            };
14902            let next_seq = tracker.stream_next_seq(call_id);
14903            (Some(timer_id), next_seq)
14904        };
14905
14906        if let Some(timer_id) = timer_id {
14907            let _ = self.scheduler.borrow_mut().clear_timeout(timer_id);
14908        }
14909
14910        if let Some(sequence) = next_seq {
14911            self.scheduler.borrow_mut().enqueue_stream_chunk(
14912                call_id.to_string(),
14913                sequence,
14914                serde_json::Value::Null,
14915                true,
14916            );
14917        }
14918        true
14919    }
14920
14921    /// Enqueue an inbound event to be delivered on next tick.
14922    pub fn enqueue_event(&self, event_id: impl Into<String>, payload: serde_json::Value) {
14923        self.scheduler
14924            .borrow_mut()
14925            .enqueue_event(event_id.into(), payload);
14926    }
14927
14928    /// Set a timer to fire after the given delay.
14929    ///
14930    /// Returns the timer ID for cancellation.
14931    pub fn set_timeout(&self, delay_ms: u64) -> u64 {
14932        self.scheduler.borrow_mut().set_timeout(delay_ms)
14933    }
14934
14935    /// Cancel a timer by ID.
14936    pub fn clear_timeout(&self, timer_id: u64) -> bool {
14937        self.scheduler.borrow_mut().clear_timeout(timer_id)
14938    }
14939
14940    /// Get the current time from the clock.
14941    pub fn now_ms(&self) -> u64 {
14942        self.scheduler.borrow().now_ms()
14943    }
14944
14945    /// Check if there are pending tasks (macrotasks or timers).
14946    pub fn has_pending(&self) -> bool {
14947        self.scheduler.borrow().has_pending() || self.pending_hostcall_count() > 0
14948    }
14949
14950    /// Execute one tick of the event loop.
14951    ///
14952    /// This will:
14953    /// 1. Move due timers to the macrotask queue
14954    /// 2. Execute one macrotask (if any)
14955    /// 3. Drain all pending QuickJS jobs (microtasks)
14956    ///
14957    /// Returns statistics about what was executed.
14958    pub async fn tick(&self) -> Result<PiJsTickStats> {
14959        // Get the next macrotask from scheduler
14960        let macrotask = self.scheduler.borrow_mut().tick();
14961
14962        let mut stats = PiJsTickStats::default();
14963
14964        if let Some(task) = macrotask {
14965            stats.ran_macrotask = true;
14966            self.interrupt_budget.reset();
14967
14968            // Handle the macrotask inside the JS context
14969            let result = self
14970                .context
14971                .with(|ctx| {
14972                    self.handle_macrotask(&ctx, &task)?;
14973                    Ok::<_, rquickjs::Error>(())
14974                })
14975                .await;
14976            if let Err(err) = result {
14977                return Err(self.map_quickjs_error(&err).await);
14978            }
14979
14980            // Drain microtasks until fixpoint
14981            stats.jobs_drained = self.drain_jobs().await?;
14982        }
14983
14984        stats.pending_hostcalls = self.hostcall_tracker.borrow().pending_count();
14985        stats.hostcalls_total = self
14986            .hostcalls_total
14987            .load(std::sync::atomic::Ordering::SeqCst);
14988        stats.hostcalls_timed_out = self
14989            .hostcalls_timed_out
14990            .load(std::sync::atomic::Ordering::SeqCst);
14991
14992        if self.should_sample_memory_usage() {
14993            let usage = self.runtime.memory_usage().await;
14994            stats.memory_used_bytes = u64::try_from(usage.memory_used_size).unwrap_or(0);
14995            self.last_memory_used_bytes
14996                .store(stats.memory_used_bytes, std::sync::atomic::Ordering::SeqCst);
14997
14998            let mut peak = self
14999                .peak_memory_used_bytes
15000                .load(std::sync::atomic::Ordering::SeqCst);
15001            if stats.memory_used_bytes > peak {
15002                peak = stats.memory_used_bytes;
15003                self.peak_memory_used_bytes
15004                    .store(peak, std::sync::atomic::Ordering::SeqCst);
15005            }
15006            stats.peak_memory_used_bytes = peak;
15007        } else {
15008            stats.memory_used_bytes = self
15009                .last_memory_used_bytes
15010                .load(std::sync::atomic::Ordering::SeqCst);
15011            stats.peak_memory_used_bytes = self
15012                .peak_memory_used_bytes
15013                .load(std::sync::atomic::Ordering::SeqCst);
15014        }
15015        stats.repairs_total = self.repair_count();
15016        let (cache_hits, cache_misses, cache_invalidations, cache_entries, disk_hits) =
15017            self.module_cache_snapshot();
15018        stats.module_cache_hits = cache_hits;
15019        stats.module_cache_misses = cache_misses;
15020        stats.module_cache_invalidations = cache_invalidations;
15021        stats.module_cache_entries = cache_entries;
15022        stats.module_disk_cache_hits = disk_hits;
15023
15024        if let Some(limit) = self.config.limits.memory_limit_bytes {
15025            let limit = u64::try_from(limit).unwrap_or(u64::MAX);
15026            if stats.memory_used_bytes > limit {
15027                return Err(Error::extension(format!(
15028                    "PiJS memory budget exceeded (used {} bytes, limit {} bytes)",
15029                    stats.memory_used_bytes, limit
15030                )));
15031            }
15032        }
15033
15034        Ok(stats)
15035    }
15036
15037    /// Drain all pending QuickJS jobs (microtasks).
15038    async fn drain_jobs(&self) -> Result<usize> {
15039        let mut count = 0;
15040        loop {
15041            if count >= MAX_JOBS_PER_TICK {
15042                return Err(Error::extension(format!(
15043                    "PiJS microtask limit exceeded ({MAX_JOBS_PER_TICK})"
15044                )));
15045            }
15046            let ran = match self.runtime.execute_pending_job().await {
15047                Ok(ran) => ran,
15048                Err(err) => return Err(self.map_quickjs_job_error(err)),
15049            };
15050            if !ran {
15051                break;
15052            }
15053            count += 1;
15054        }
15055        Ok(count)
15056    }
15057
15058    /// Handle a macrotask by resolving/rejecting Promises or dispatching events.
15059    fn handle_macrotask(
15060        &self,
15061        ctx: &Ctx<'_>,
15062        task: &crate::scheduler::Macrotask,
15063    ) -> rquickjs::Result<()> {
15064        use crate::scheduler::MacrotaskKind as SMK;
15065
15066        match &task.kind {
15067            SMK::HostcallComplete { call_id, outcome } => {
15068                let is_nonfinal_stream = matches!(
15069                    outcome,
15070                    HostcallOutcome::StreamChunk {
15071                        is_final: false,
15072                        ..
15073                    }
15074                );
15075
15076                if is_nonfinal_stream {
15077                    // Non-final stream chunk: keep the call pending, just deliver the chunk.
15078                    if !self.hostcall_tracker.borrow().is_active(call_id) {
15079                        tracing::debug!(
15080                            event = "pijs.macrotask.stream_chunk.ignored",
15081                            call_id = %call_id,
15082                            "Ignoring stream chunk (not pending)"
15083                        );
15084                        return Ok(());
15085                    }
15086                } else {
15087                    // Final chunk or non-stream outcome: complete the hostcall.
15088                    let completion = self.hostcall_tracker.borrow_mut().on_complete(call_id);
15089                    let timer_id = match completion {
15090                        HostcallCompletion::Delivered { timer_id } => timer_id,
15091                        HostcallCompletion::Unknown => {
15092                            tracing::debug!(
15093                                event = "pijs.macrotask.hostcall_complete.ignored",
15094                                call_id = %call_id,
15095                                "Ignoring hostcall completion (not pending)"
15096                            );
15097                            return Ok(());
15098                        }
15099                    };
15100
15101                    if let Some(timer_id) = timer_id {
15102                        let _ = self.scheduler.borrow_mut().clear_timeout(timer_id);
15103                    }
15104                }
15105
15106                tracing::debug!(
15107                    event = "pijs.macrotask.hostcall_complete",
15108                    call_id = %call_id,
15109                    seq = task.seq.value(),
15110                    "Delivering hostcall completion"
15111                );
15112                Self::deliver_hostcall_completion(ctx, call_id, outcome)?;
15113            }
15114            SMK::TimerFired { timer_id } => {
15115                if let Some(call_id) = self
15116                    .hostcall_tracker
15117                    .borrow_mut()
15118                    .take_timed_out_call(*timer_id)
15119                {
15120                    self.hostcalls_timed_out
15121                        .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15122                    tracing::warn!(
15123                        event = "pijs.hostcall.timeout",
15124                        call_id = %call_id,
15125                        timer_id = timer_id,
15126                        "Hostcall timed out"
15127                    );
15128
15129                    let outcome = HostcallOutcome::Error {
15130                        code: "timeout".to_string(),
15131                        message: "Hostcall timed out".to_string(),
15132                    };
15133                    Self::deliver_hostcall_completion(ctx, &call_id, &outcome)?;
15134                    return Ok(());
15135                }
15136
15137                tracing::debug!(
15138                    event = "pijs.macrotask.timer_fired",
15139                    timer_id = timer_id,
15140                    seq = task.seq.value(),
15141                    "Timer fired"
15142                );
15143                // Timer callbacks are stored in a JS-side map
15144                Self::deliver_timer_fire(ctx, *timer_id)?;
15145            }
15146            SMK::InboundEvent { event_id, payload } => {
15147                tracing::debug!(
15148                    event = "pijs.macrotask.inbound_event",
15149                    event_id = %event_id,
15150                    seq = task.seq.value(),
15151                    "Delivering inbound event"
15152                );
15153                Self::deliver_inbound_event(ctx, event_id, payload)?;
15154            }
15155        }
15156        Ok(())
15157    }
15158
15159    /// Deliver a hostcall completion to JS.
15160    fn deliver_hostcall_completion(
15161        ctx: &Ctx<'_>,
15162        call_id: &str,
15163        outcome: &HostcallOutcome,
15164    ) -> rquickjs::Result<()> {
15165        let global = ctx.globals();
15166        let complete_fn: Function<'_> = global.get("__pi_complete_hostcall")?;
15167        let js_outcome = match outcome {
15168            HostcallOutcome::Success(value) => {
15169                let obj = Object::new(ctx.clone())?;
15170                obj.set("ok", true)?;
15171                obj.set("value", json_to_js(ctx, value)?)?;
15172                obj
15173            }
15174            HostcallOutcome::Error { code, message } => {
15175                let obj = Object::new(ctx.clone())?;
15176                obj.set("ok", false)?;
15177                obj.set("code", code.clone())?;
15178                obj.set("message", message.clone())?;
15179                obj
15180            }
15181            HostcallOutcome::StreamChunk {
15182                chunk,
15183                sequence,
15184                is_final,
15185            } => {
15186                let obj = Object::new(ctx.clone())?;
15187                obj.set("ok", true)?;
15188                obj.set("stream", true)?;
15189                obj.set("sequence", *sequence)?;
15190                obj.set("isFinal", *is_final)?;
15191                obj.set("chunk", json_to_js(ctx, chunk)?)?;
15192                obj
15193            }
15194        };
15195        complete_fn.call::<_, ()>((call_id, js_outcome))?;
15196        Ok(())
15197    }
15198
15199    /// Deliver a timer fire event to JS.
15200    fn deliver_timer_fire(ctx: &Ctx<'_>, timer_id: u64) -> rquickjs::Result<()> {
15201        let global = ctx.globals();
15202        let fire_fn: Function<'_> = global.get("__pi_fire_timer")?;
15203        fire_fn.call::<_, ()>((timer_id,))?;
15204        Ok(())
15205    }
15206
15207    /// Deliver an inbound event to JS.
15208    fn deliver_inbound_event(
15209        ctx: &Ctx<'_>,
15210        event_id: &str,
15211        payload: &serde_json::Value,
15212    ) -> rquickjs::Result<()> {
15213        let global = ctx.globals();
15214        let dispatch_fn: Function<'_> = global.get("__pi_dispatch_event")?;
15215        let js_payload = json_to_js(ctx, payload)?;
15216        dispatch_fn.call::<_, ()>((event_id, js_payload))?;
15217        Ok(())
15218    }
15219
15220    /// Generate a unique trace ID.
15221    fn next_trace_id(&self) -> u64 {
15222        self.trace_seq.fetch_add(1, AtomicOrdering::SeqCst)
15223    }
15224
15225    /// Install the pi.* bridge with Promise-returning hostcall methods.
15226    ///
15227    /// The bridge uses a two-layer design:
15228    /// 1. Rust native functions (`__pi_*_native`) that return call_id strings
15229    /// 2. JS wrappers (`pi.*`) that create Promises and register them
15230    ///
15231    /// This avoids lifetime issues with returning Promises from Rust closures.
15232    /// Register an additional filesystem root that `readFileSync` is allowed
15233    /// to access.  Called before loading each extension so it can read its own
15234    /// bundled assets (HTML templates, markdown docs, etc.).
15235    pub fn add_allowed_read_root(&self, root: &std::path::Path) {
15236        let canonical_root = crate::extensions::safe_canonicalize(root);
15237        if let Ok(mut roots) = self.allowed_read_roots.lock() {
15238            if !roots.contains(&canonical_root) {
15239                roots.push(canonical_root);
15240            }
15241        }
15242    }
15243
15244    /// Register an extension root directory so the resolver can detect
15245    /// monorepo escape patterns (Pattern 3).  Also registers the root
15246    /// for `readFileSync` access.
15247    pub fn add_extension_root(&self, root: PathBuf) {
15248        self.add_extension_root_with_id(root, None);
15249    }
15250
15251    /// Register an extension root with optional extension ID metadata.
15252    ///
15253    /// Pattern 4 (missing npm dependency proxy stubs) uses this metadata to
15254    /// apply stricter policy for official/first-party extensions and to allow
15255    /// same-scope package imports (`@scope/*`) when scope can be discovered.
15256    pub fn add_extension_root_with_id(&self, root: PathBuf, extension_id: Option<&str>) {
15257        let canonical_root = crate::extensions::safe_canonicalize(&root);
15258        self.add_allowed_read_root(&canonical_root);
15259        let mut state = self.module_state.borrow_mut();
15260        if !state.extension_roots.contains(&root) {
15261            state.canonical_extension_roots.push(canonical_root.clone());
15262            state.extension_roots.push(root.clone());
15263        }
15264
15265        if let Some(extension_id) = extension_id {
15266            let roots = state
15267                .extension_roots_by_id
15268                .entry(extension_id.to_string())
15269                .or_default();
15270            if !roots.contains(&canonical_root) {
15271                roots.push(canonical_root);
15272            }
15273        } else if !state.extension_roots_without_id.contains(&canonical_root) {
15274            state.extension_roots_without_id.push(canonical_root);
15275        }
15276
15277        let tier = extension_id.map_or_else(
15278            || root_path_hint_tier(&root),
15279            |id| classify_proxy_stub_source_tier(id, &root),
15280        );
15281        state.extension_root_tiers.insert(root.clone(), tier);
15282
15283        if let Some(scope) = read_extension_package_scope(&root) {
15284            state.extension_root_scopes.insert(root, scope);
15285        }
15286    }
15287
15288    #[allow(clippy::too_many_lines)]
15289    async fn install_pi_bridge(&self) -> Result<()> {
15290        let hostcall_queue = self.hostcall_queue.clone();
15291        let scheduler = Rc::clone(&self.scheduler);
15292        let hostcall_tracker = Rc::clone(&self.hostcall_tracker);
15293        let hostcalls_total = Arc::clone(&self.hostcalls_total);
15294        let trace_seq = Arc::clone(&self.trace_seq);
15295        let default_hostcall_timeout_ms = self.config.limits.hostcall_timeout_ms;
15296        let process_cwd = self.config.cwd.clone();
15297        let process_args = self.config.args.clone();
15298        let env = self.config.env.clone();
15299        let deny_env = self.config.deny_env;
15300        let repair_mode = self.config.repair_mode;
15301        let repair_events = Arc::clone(&self.repair_events);
15302        let allow_unsafe_sync_exec = self.config.allow_unsafe_sync_exec;
15303        let allowed_read_roots = Arc::clone(&self.allowed_read_roots);
15304        let module_state = Rc::clone(&self.module_state);
15305        let policy = self.policy.clone();
15306
15307        self.context
15308            .with(|ctx| {
15309                let global = ctx.globals();
15310
15311                // Install native functions that return call_ids
15312                // These are wrapped by JS to create Promises
15313
15314                // __pi_tool_native(name, input) -> call_id
15315                global.set(
15316                    "__pi_tool_native",
15317                    Func::from({
15318                        let queue = hostcall_queue.clone();
15319                        let tracker = hostcall_tracker.clone();
15320                        let scheduler = Rc::clone(&scheduler);
15321                        let hostcalls_total = Arc::clone(&hostcalls_total);
15322                        let trace_seq = Arc::clone(&trace_seq);
15323                        move |ctx: Ctx<'_>,
15324                              name: String,
15325                              input: Value<'_>|
15326                              -> rquickjs::Result<String> {
15327                            let payload = js_to_json(&input)?;
15328                            let call_id = format!("call-{}", generate_call_id());
15329                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15330                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15331                            let enqueued_at_ms = scheduler.borrow().now_ms();
15332                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15333                            let timer_id =
15334                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15335                            tracker
15336                                .borrow_mut()
15337                                .register(call_id.clone(), timer_id, enqueued_at_ms);
15338                            let extension_id: Option<String> = ctx
15339                                .globals()
15340                                .get::<_, Option<String>>("__pi_current_extension_id")
15341                                .ok()
15342                                .flatten()
15343                                .map(|value| value.trim().to_string())
15344                                .filter(|value| !value.is_empty());
15345                            let request = HostcallRequest {
15346                                call_id: call_id.clone(),
15347                                kind: HostcallKind::Tool { name },
15348                                payload,
15349                                trace_id,
15350                                extension_id,
15351                            };
15352                            enqueue_hostcall_request_with_backpressure(
15353                                &queue, &tracker, &scheduler, request,
15354                            );
15355                            Ok(call_id)
15356                        }
15357                    }),
15358                )?;
15359
15360                // __pi_exec_native(cmd, args, options) -> call_id
15361                global.set(
15362                    "__pi_exec_native",
15363                    Func::from({
15364                        let queue = hostcall_queue.clone();
15365                        let tracker = hostcall_tracker.clone();
15366                        let scheduler = Rc::clone(&scheduler);
15367                        let hostcalls_total = Arc::clone(&hostcalls_total);
15368                        let trace_seq = Arc::clone(&trace_seq);
15369                        move |ctx: Ctx<'_>,
15370                              cmd: String,
15371                              args: Value<'_>,
15372                              options: Opt<Value<'_>>|
15373                              -> rquickjs::Result<String> {
15374                            let mut options_json = match options.0.as_ref() {
15375                                None => serde_json::json!({}),
15376                                Some(value) if value.is_null() => serde_json::json!({}),
15377                                Some(value) => js_to_json(value)?,
15378                            };
15379                            if let Some(default_timeout_ms) =
15380                                default_hostcall_timeout_ms.filter(|ms| *ms > 0)
15381                            {
15382                                match &mut options_json {
15383                                    serde_json::Value::Object(map) => {
15384                                        let has_timeout = map.contains_key("timeout")
15385                                            || map.contains_key("timeoutMs")
15386                                            || map.contains_key("timeout_ms");
15387                                        if !has_timeout {
15388                                            map.insert(
15389                                                "timeoutMs".to_string(),
15390                                                serde_json::Value::from(default_timeout_ms),
15391                                            );
15392                                        }
15393                                    }
15394                                    _ => {
15395                                        options_json =
15396                                            serde_json::json!({ "timeoutMs": default_timeout_ms });
15397                                    }
15398                                }
15399                            }
15400                            let payload = serde_json::json!({
15401                                "args": js_to_json(&args)?,
15402                                "options": options_json,
15403                            });
15404                            let call_id = format!("call-{}", generate_call_id());
15405                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15406                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15407                            let enqueued_at_ms = scheduler.borrow().now_ms();
15408                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15409                            let timer_id =
15410                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15411                            tracker
15412                                .borrow_mut()
15413                                .register(call_id.clone(), timer_id, enqueued_at_ms);
15414                            let extension_id: Option<String> = ctx
15415                                .globals()
15416                                .get::<_, Option<String>>("__pi_current_extension_id")
15417                                .ok()
15418                                .flatten()
15419                                .map(|value| value.trim().to_string())
15420                                .filter(|value| !value.is_empty());
15421                            let request = HostcallRequest {
15422                                call_id: call_id.clone(),
15423                                kind: HostcallKind::Exec { cmd },
15424                                payload,
15425                                trace_id,
15426                                extension_id,
15427                            };
15428                            enqueue_hostcall_request_with_backpressure(
15429                                &queue, &tracker, &scheduler, request,
15430                            );
15431                            Ok(call_id)
15432                        }
15433                    }),
15434                )?;
15435
15436                // __pi_http_native(request) -> call_id
15437                global.set(
15438                    "__pi_http_native",
15439                    Func::from({
15440                        let queue = hostcall_queue.clone();
15441                        let tracker = hostcall_tracker.clone();
15442                        let scheduler = Rc::clone(&scheduler);
15443                        let hostcalls_total = Arc::clone(&hostcalls_total);
15444                        let trace_seq = Arc::clone(&trace_seq);
15445                        move |ctx: Ctx<'_>, req: Value<'_>| -> rquickjs::Result<String> {
15446                            let payload = js_to_json(&req)?;
15447                            let call_id = format!("call-{}", generate_call_id());
15448                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15449                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15450                            let enqueued_at_ms = scheduler.borrow().now_ms();
15451                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15452                            let timer_id =
15453                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15454                            tracker
15455                                .borrow_mut()
15456                                .register(call_id.clone(), timer_id, enqueued_at_ms);
15457                            let extension_id: Option<String> = ctx
15458                                .globals()
15459                                .get::<_, Option<String>>("__pi_current_extension_id")
15460                                .ok()
15461                                .flatten()
15462                                .map(|value| value.trim().to_string())
15463                                .filter(|value| !value.is_empty());
15464                            let request = HostcallRequest {
15465                                call_id: call_id.clone(),
15466                                kind: HostcallKind::Http,
15467                                payload,
15468                                trace_id,
15469                                extension_id,
15470                            };
15471                            enqueue_hostcall_request_with_backpressure(
15472                                &queue, &tracker, &scheduler, request,
15473                            );
15474                            Ok(call_id)
15475                        }
15476                    }),
15477                )?;
15478
15479                // __pi_session_native(op, args) -> call_id
15480                global.set(
15481                    "__pi_session_native",
15482                    Func::from({
15483                        let queue = hostcall_queue.clone();
15484                        let tracker = hostcall_tracker.clone();
15485                        let scheduler = Rc::clone(&scheduler);
15486                        let hostcalls_total = Arc::clone(&hostcalls_total);
15487                        let trace_seq = Arc::clone(&trace_seq);
15488                        move |ctx: Ctx<'_>,
15489                              op: String,
15490                              args: Value<'_>|
15491                              -> rquickjs::Result<String> {
15492                            let payload = js_to_json(&args)?;
15493                            let call_id = format!("call-{}", generate_call_id());
15494                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15495                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15496                            let enqueued_at_ms = scheduler.borrow().now_ms();
15497                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15498                            let timer_id =
15499                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15500                            tracker
15501                                .borrow_mut()
15502                                .register(call_id.clone(), timer_id, enqueued_at_ms);
15503                            let extension_id: Option<String> = ctx
15504                                .globals()
15505                                .get::<_, Option<String>>("__pi_current_extension_id")
15506                                .ok()
15507                                .flatten()
15508                                .map(|value| value.trim().to_string())
15509                                .filter(|value| !value.is_empty());
15510                            let request = HostcallRequest {
15511                                call_id: call_id.clone(),
15512                                kind: HostcallKind::Session { op },
15513                                payload,
15514                                trace_id,
15515                                extension_id,
15516                            };
15517                            enqueue_hostcall_request_with_backpressure(
15518                                &queue, &tracker, &scheduler, request,
15519                            );
15520                            Ok(call_id)
15521                        }
15522                    }),
15523                )?;
15524
15525                // __pi_ui_native(op, args) -> call_id
15526                global.set(
15527                    "__pi_ui_native",
15528                    Func::from({
15529                        let queue = hostcall_queue.clone();
15530                        let tracker = hostcall_tracker.clone();
15531                        let scheduler = Rc::clone(&scheduler);
15532                        let hostcalls_total = Arc::clone(&hostcalls_total);
15533                        let trace_seq = Arc::clone(&trace_seq);
15534                        move |ctx: Ctx<'_>,
15535                              op: String,
15536                              args: Value<'_>|
15537                              -> rquickjs::Result<String> {
15538                            let payload = js_to_json(&args)?;
15539                            let call_id = format!("call-{}", generate_call_id());
15540                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15541                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15542                            let enqueued_at_ms = scheduler.borrow().now_ms();
15543                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15544                            let timer_id =
15545                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15546                            tracker
15547                                .borrow_mut()
15548                                .register(call_id.clone(), timer_id, enqueued_at_ms);
15549                            let extension_id: Option<String> = ctx
15550                                .globals()
15551                                .get::<_, Option<String>>("__pi_current_extension_id")
15552                                .ok()
15553                                .flatten()
15554                                .map(|value| value.trim().to_string())
15555                                .filter(|value| !value.is_empty());
15556                            let request = HostcallRequest {
15557                                call_id: call_id.clone(),
15558                                kind: HostcallKind::Ui { op },
15559                                payload,
15560                                trace_id,
15561                                extension_id,
15562                            };
15563                            enqueue_hostcall_request_with_backpressure(
15564                                &queue, &tracker, &scheduler, request,
15565                            );
15566                            Ok(call_id)
15567                        }
15568                    }),
15569                )?;
15570
15571                // __pi_events_native(op, args) -> call_id
15572                global.set(
15573                    "__pi_events_native",
15574                    Func::from({
15575                        let queue = hostcall_queue.clone();
15576                        let tracker = hostcall_tracker.clone();
15577                        let scheduler = Rc::clone(&scheduler);
15578                        let hostcalls_total = Arc::clone(&hostcalls_total);
15579                        let trace_seq = Arc::clone(&trace_seq);
15580                        move |ctx: Ctx<'_>,
15581                              op: String,
15582                              args: Value<'_>|
15583                              -> rquickjs::Result<String> {
15584                            let payload = js_to_json(&args)?;
15585                            let call_id = format!("call-{}", generate_call_id());
15586                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15587                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15588                            let enqueued_at_ms = scheduler.borrow().now_ms();
15589                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15590                            let timer_id =
15591                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15592                            tracker
15593                                .borrow_mut()
15594                                .register(call_id.clone(), timer_id, enqueued_at_ms);
15595                            let extension_id: Option<String> = ctx
15596                                .globals()
15597                                .get::<_, Option<String>>("__pi_current_extension_id")
15598                                .ok()
15599                                .flatten()
15600                                .map(|value| value.trim().to_string())
15601                                .filter(|value| !value.is_empty());
15602                            let request = HostcallRequest {
15603                                call_id: call_id.clone(),
15604                                kind: HostcallKind::Events { op },
15605                                payload,
15606                                trace_id,
15607                                extension_id,
15608                            };
15609                            enqueue_hostcall_request_with_backpressure(
15610                                &queue, &tracker, &scheduler, request,
15611                            );
15612                            Ok(call_id)
15613                        }
15614                    }),
15615                )?;
15616
15617                // __pi_log_native(entry) -> call_id
15618                global.set(
15619                    "__pi_log_native",
15620                    Func::from({
15621                        let queue = hostcall_queue.clone();
15622                        let tracker = hostcall_tracker.clone();
15623                        let scheduler = Rc::clone(&scheduler);
15624                        let hostcalls_total = Arc::clone(&hostcalls_total);
15625                        let trace_seq = Arc::clone(&trace_seq);
15626                        move |ctx: Ctx<'_>, entry: Value<'_>| -> rquickjs::Result<String> {
15627                            let payload = js_to_json(&entry)?;
15628                            let call_id = format!("call-{}", generate_call_id());
15629                            hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
15630                            let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
15631                            let enqueued_at_ms = scheduler.borrow().now_ms();
15632                            let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
15633                            let timer_id =
15634                                timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
15635                            tracker
15636                                .borrow_mut()
15637                                .register(call_id.clone(), timer_id, enqueued_at_ms);
15638                            let extension_id: Option<String> = ctx
15639                                .globals()
15640                                .get::<_, Option<String>>("__pi_current_extension_id")
15641                                .ok()
15642                                .flatten()
15643                                .map(|value| value.trim().to_string())
15644                                .filter(|value| !value.is_empty());
15645                            let request = HostcallRequest {
15646                                call_id: call_id.clone(),
15647                                kind: HostcallKind::Log,
15648                                payload,
15649                                trace_id,
15650                                extension_id,
15651                            };
15652                            enqueue_hostcall_request_with_backpressure(
15653                                &queue, &tracker, &scheduler, request,
15654                            );
15655                            Ok(call_id)
15656                        }
15657                    }),
15658                )?;
15659
15660                // __pi_set_timeout_native(delay_ms) -> timer_id
15661                global.set(
15662                    "__pi_set_timeout_native",
15663                    Func::from({
15664                        let scheduler = Rc::clone(&scheduler);
15665                        move |_ctx: Ctx<'_>, delay_ms: u64| -> rquickjs::Result<u64> {
15666                            Ok(scheduler.borrow_mut().set_timeout(delay_ms))
15667                        }
15668                    }),
15669                )?;
15670
15671                // __pi_clear_timeout_native(timer_id) -> bool
15672                global.set(
15673                    "__pi_clear_timeout_native",
15674                    Func::from({
15675                        let scheduler = Rc::clone(&scheduler);
15676                        move |_ctx: Ctx<'_>, timer_id: u64| -> rquickjs::Result<bool> {
15677                            Ok(scheduler.borrow_mut().clear_timeout(timer_id))
15678                        }
15679                    }),
15680                )?;
15681
15682                // __pi_cancel_hostcall_native(call_id) -> bool
15683                global.set(
15684                    "__pi_cancel_hostcall_native",
15685                    Func::from({
15686                        let tracker = hostcall_tracker.clone();
15687                        let scheduler = Rc::clone(&scheduler);
15688                        move |_ctx: Ctx<'_>, call_id: String| -> rquickjs::Result<bool> {
15689                            let (timer_id, next_seq) = {
15690                                let mut tracker = tracker.borrow_mut();
15691                                let Some(timer_id) = tracker.cancel(&call_id) else {
15692                                    return Ok(false);
15693                                };
15694                                let next_seq = tracker.stream_next_seq(&call_id);
15695                                (Some(timer_id), next_seq)
15696                            };
15697
15698                            if let Some(timer_id) = timer_id {
15699                                let _ = scheduler.borrow_mut().clear_timeout(timer_id);
15700                            }
15701
15702                            if let Some(sequence) = next_seq {
15703                                scheduler
15704                                    .borrow_mut()
15705                                    .enqueue_stream_chunk(call_id, sequence, serde_json::Value::Null, true);
15706                            }
15707
15708                            Ok(true)
15709                        }
15710                    }),
15711                )?;
15712
15713                // __pi_now_ms_native() -> u64
15714                global.set(
15715                    "__pi_now_ms_native",
15716                    Func::from({
15717                        let scheduler = Rc::clone(&scheduler);
15718                        move |_ctx: Ctx<'_>| -> rquickjs::Result<u64> {
15719                            Ok(scheduler.borrow().now_ms())
15720                        }
15721                    }),
15722                )?;
15723
15724                // __pi_process_cwd_native() -> String
15725                global.set(
15726                    "__pi_process_cwd_native",
15727                    Func::from({
15728                        let process_cwd = process_cwd.clone();
15729                        move |_ctx: Ctx<'_>| -> rquickjs::Result<String> { Ok(process_cwd.clone()) }
15730                    }),
15731                )?;
15732
15733                // __pi_process_args_native() -> string[]
15734                global.set(
15735                    "__pi_process_args_native",
15736                    Func::from({
15737                        let process_args = process_args.clone();
15738                        move |_ctx: Ctx<'_>| -> rquickjs::Result<Vec<String>> {
15739                            Ok(process_args.clone())
15740                        }
15741                    }),
15742                )?;
15743
15744                // __pi_process_exit_native(code) -> enqueues exit hostcall
15745                global.set(
15746                    "__pi_process_exit_native",
15747                    Func::from({
15748                        let queue = hostcall_queue.clone();
15749                        let tracker = hostcall_tracker.clone();
15750                        let scheduler = Rc::clone(&scheduler);
15751                        move |_ctx: Ctx<'_>, code: i32| -> rquickjs::Result<()> {
15752                            tracing::info!(
15753                                event = "pijs.process.exit",
15754                                code,
15755                                "process.exit requested"
15756                            );
15757                            let call_id = format!("call-{}", generate_call_id());
15758                            let enqueued_at_ms = scheduler.borrow().now_ms();
15759                            tracker
15760                                .borrow_mut()
15761                                .register(call_id.clone(), None, enqueued_at_ms);
15762                            let request = HostcallRequest {
15763                                call_id,
15764                                kind: HostcallKind::Events {
15765                                    op: "exit".to_string(),
15766                                },
15767                                payload: serde_json::json!({ "code": code }),
15768                                trace_id: 0,
15769                                extension_id: None,
15770                            };
15771                            enqueue_hostcall_request_with_backpressure(
15772                                &queue, &tracker, &scheduler, request,
15773                            );
15774                            Ok(())
15775                        }
15776                    }),
15777                )?;
15778
15779                // __pi_process_execpath_native() -> string
15780                global.set(
15781                    "__pi_process_execpath_native",
15782                    Func::from(move |_ctx: Ctx<'_>| -> rquickjs::Result<String> {
15783                        Ok(std::env::current_exe().map_or_else(
15784                            |_| "/usr/bin/pi".to_string(),
15785                            |p| p.to_string_lossy().into_owned(),
15786                        ))
15787                    }),
15788                )?;
15789
15790                // __pi_env_get_native(key) -> string | null
15791                global.set(
15792                    "__pi_env_get_native",
15793                    Func::from({
15794                        let env = env.clone();
15795                        let policy_for_env = policy.clone();
15796                        move |_ctx: Ctx<'_>, key: String| -> rquickjs::Result<Option<String>> {
15797                            // Compat fallback runs BEFORE deny_env so conformance
15798                            // scanning can inject deterministic dummy keys even when
15799                            // the policy denies env access (ext-conformance feature
15800                            // or PI_EXT_COMPAT_SCAN=1 guard this path).
15801                            if let Some(value) = compat_env_fallback_value(&key, &env) {
15802                                tracing::debug!(
15803                                    event = "pijs.env.get.compat",
15804                                    key = %key,
15805                                    "env compat fallback"
15806                                );
15807                                return Ok(Some(value));
15808                            }
15809                            if deny_env {
15810                                tracing::debug!(event = "pijs.env.get.denied", key = %key, "env capability denied");
15811                                return Ok(None);
15812                            }
15813                            // If a policy is present, use its SecretBroker (including
15814                            // disclosure_allowlist). Otherwise fall back to default
15815                            // secret filtering so obvious credentials are still hidden.
15816                            let allowed = policy_for_env.as_ref().map_or_else(
15817                                || is_env_var_allowed(&key),
15818                                |policy| !policy.secret_broker.is_secret(&key),
15819                            );
15820                            tracing::debug!(
15821                                event = "pijs.env.get",
15822                                key = %key,
15823                                allowed,
15824                                "env get"
15825                            );
15826                            if !allowed {
15827                                return Ok(None);
15828                            }
15829                            Ok(env.get(&key).cloned())
15830                        }
15831                    }),
15832                )?;
15833
15834                // __pi_crypto_random_bytes_native(len) -> byte-like JS value
15835                // (string/Array/Uint8Array/ArrayBuffer depending on bridge coercion).
15836                // The JS shim normalizes this into plain number[] bytes.
15837                global.set(
15838                    "__pi_crypto_random_bytes_native",
15839                    Func::from(
15840                        move |_ctx: Ctx<'_>, len: usize| -> rquickjs::Result<Vec<u8>> {
15841                            if len > 10 * 1024 * 1024 {
15842                                return Err(rquickjs::Error::new_from_js(
15843                                    "number",
15844                                    "randomBytes size limit exceeded (max 10MB)",
15845                                ));
15846                            }
15847                            tracing::debug!(
15848                                event = "pijs.crypto.random_bytes",
15849                                len,
15850                                "crypto random bytes"
15851                            );
15852                            random_bytes(len)
15853                                .map_err(|err| map_crypto_entropy_error("randomBytes", err))
15854                        },
15855                    ),
15856                )?;
15857
15858                // __pi_base64_encode_native(binary_string) -> base64 string
15859                global.set(
15860                    "__pi_base64_encode_native",
15861                    Func::from(
15862                        move |_ctx: Ctx<'_>, input: String| -> rquickjs::Result<String> {
15863                            let mut bytes = Vec::with_capacity(input.len());
15864                            for ch in input.chars() {
15865                                let code = ch as u32;
15866                                let byte = u8::try_from(code).map_err(|_| {
15867                                    rquickjs::Error::new_into_js_message(
15868                                        "base64",
15869                                        "encode",
15870                                        "Input contains non-latin1 characters",
15871                                    )
15872                                })?;
15873                                bytes.push(byte);
15874                            }
15875                            Ok(BASE64_STANDARD.encode(bytes))
15876                        },
15877                    ),
15878                )?;
15879
15880                // __pi_base64_encode_bytes_native(Uint8Array) -> base64 string
15881                global.set(
15882                    "__pi_base64_encode_bytes_native",
15883                    Func::from(
15884                        move |_ctx: Ctx<'_>, input: rquickjs::TypedArray<'_, u8>| -> rquickjs::Result<String> {
15885                            let bytes = input.as_bytes().ok_or_else(|| {
15886                                rquickjs::Error::new_into_js_message(
15887                                    "base64",
15888                                    "encode",
15889                                    "Detached TypedArray",
15890                                )
15891                            })?;
15892                            Ok(BASE64_STANDARD.encode(bytes))
15893                        },
15894                    ),
15895                )?;
15896
15897                // __pi_base64_decode_native(base64) -> binary string
15898                global.set(
15899                    "__pi_base64_decode_native",
15900                    Func::from(
15901                        move |_ctx: Ctx<'_>, input: String| -> rquickjs::Result<String> {
15902                            let bytes = BASE64_STANDARD.decode(input).map_err(|err| {
15903                                rquickjs::Error::new_into_js_message(
15904                                    "base64",
15905                                    "decode",
15906                                    format!("Invalid base64: {err}"),
15907                                )
15908                            })?;
15909
15910                            let mut out = String::with_capacity(bytes.len());
15911                            for byte in bytes {
15912                                out.push(byte as char);
15913                            }
15914                            Ok(out)
15915                        },
15916                    ),
15917                )?;
15918
15919                // __pi_console_output_native(level, message) — routes JS console output
15920                // through the Rust tracing infrastructure so extensions get a working
15921                // `console` global.
15922                global.set(
15923                    "__pi_console_output_native",
15924                    Func::from(
15925                        move |_ctx: Ctx<'_>,
15926                              level: String,
15927                              message: String|
15928                              -> rquickjs::Result<()> {
15929                            match level.as_str() {
15930                                "error" => tracing::error!(
15931                                    target: "pijs.console",
15932                                    "{message}"
15933                                ),
15934                                "warn" => tracing::warn!(
15935                                    target: "pijs.console",
15936                                    "{message}"
15937                                ),
15938                                "debug" => tracing::debug!(
15939                                    target: "pijs.console",
15940                                    "{message}"
15941                                ),
15942                                "trace" => tracing::trace!(
15943                                    target: "pijs.console",
15944                                    "{message}"
15945                                ),
15946                                // "log" and "info" both map to info
15947                                _ => tracing::info!(
15948                                    target: "pijs.console",
15949                                    "{message}"
15950                                ),
15951                            }
15952                            Ok(())
15953                        },
15954                    ),
15955                )?;
15956
15957                // __pi_host_check_write_access(path) -> void (throws on denied path)
15958                // Enforces workspace/extension-root confinement for node:fs write APIs.
15959                // This guard only applies while extension code is actively executing.
15960                global.set(
15961                    "__pi_host_check_write_access",
15962                    Func::from({
15963                        let process_cwd = process_cwd.clone();
15964                        let allowed_read_roots = Arc::clone(&allowed_read_roots);
15965                        let module_state = Rc::clone(&module_state);
15966                        move |ctx: Ctx<'_>, path: String| -> rquickjs::Result<()> {
15967                            let extension_id = current_extension_id(&ctx);
15968
15969                            // Keep standalone PiJsRuntime unit harness behavior unchanged.
15970                            if extension_id.is_none() {
15971                                return Ok(());
15972                            }
15973
15974                            let workspace_root =
15975                                crate::extensions::safe_canonicalize(Path::new(&process_cwd));
15976                            let requested = PathBuf::from(&path);
15977                            let requested_abs = if requested.is_absolute() {
15978                                requested
15979                            } else {
15980                                workspace_root.join(requested)
15981                            };
15982                            let checked_path = crate::extensions::safe_canonicalize(&requested_abs);
15983
15984                            let in_ext_root = path_is_in_allowed_extension_root(
15985                                &checked_path,
15986                                extension_id.as_deref(),
15987                                &module_state,
15988                                &allowed_read_roots,
15989                            );
15990
15991                            let allowed = checked_path.starts_with(&workspace_root) || in_ext_root;
15992
15993                            if allowed {
15994                                Ok(())
15995                            } else {
15996                                Err(rquickjs::Error::new_loading_message(
15997                                    &path,
15998                                    "host write denied: path outside extension root".to_string(),
15999                                ))
16000                            }
16001                        }
16002                    }),
16003                )?;
16004
16005                // __pi_host_read_file_sync(path) -> base64 string (throws on error)
16006                // Synchronous real-filesystem read fallback for node:fs readFileSync.
16007                // Reads are confined to the workspace root AND any registered
16008                // extension roots to prevent host filesystem probing outside
16009                // project / extension boundaries.
16010                global.set(
16011                    "__pi_host_read_file_sync",
16012                    Func::from({
16013                        let process_cwd = process_cwd.clone();
16014                        let allowed_read_roots = Arc::clone(&allowed_read_roots);
16015                        let module_state = Rc::clone(&module_state);
16016                        let configured_repair_mode = repair_mode;
16017                        let repair_events = Arc::clone(&repair_events);
16018                        move |ctx: Ctx<'_>, path: String| -> rquickjs::Result<String> {
16019                            const MAX_SYNC_READ_SIZE: u64 = 64 * 1024 * 1024; // 64MB hard limit
16020                            let extension_id = current_extension_id(&ctx);
16021
16022                            let workspace_root =
16023                                crate::extensions::safe_canonicalize(Path::new(&process_cwd));
16024
16025                            let requested = PathBuf::from(&path);
16026                            let requested_abs = if requested.is_absolute() {
16027                                requested
16028                            } else {
16029                                workspace_root.join(requested)
16030                            };
16031
16032                            let apply_missing_asset_fallback = |checked_path: &Path, error_msg: &str| -> rquickjs::Result<String> {
16033                                let in_ext_root = path_is_in_allowed_extension_root(
16034                                    checked_path,
16035                                    extension_id.as_deref(),
16036                                    &module_state,
16037                                    &allowed_read_roots,
16038                                );
16039
16040                                if in_ext_root {
16041                                    let ext = checked_path
16042                                        .extension()
16043                                        .and_then(|e| e.to_str())
16044                                        .unwrap_or("");
16045                                    let fallback = match ext {
16046                                        "html" | "htm" => "<!DOCTYPE html><html><body></body></html>",
16047                                        "css" => "/* auto-repair: empty stylesheet */",
16048                                        "js" | "mjs" => "// auto-repair: empty script",
16049                                        "md" | "txt" | "toml" | "yaml" | "yml" => "",
16050                                        _ => {
16051                                            return Err(rquickjs::Error::new_loading_message(
16052                                                &path,
16053                                                format!("host read open: {error_msg}"),
16054                                            ));
16055                                        }
16056                                    };
16057
16058                                    tracing::info!(
16059                                        event = "pijs.repair.missing_asset",
16060                                        path = %path,
16061                                        ext = %ext,
16062                                        "returning empty fallback for missing asset"
16063                                    );
16064
16065                                    if let Ok(mut events) = repair_events.lock() {
16066                                        events.push(ExtensionRepairEvent {
16067                                            extension_id: extension_id.clone().unwrap_or_default(),
16068                                            pattern: RepairPattern::MissingAsset,
16069                                            original_error: format!("ENOENT: {}", checked_path.display()),
16070                                            repair_action: format!("returned empty {ext} fallback"),
16071                                            success: true,
16072                                            timestamp_ms: 0,
16073                                        });
16074                                    }
16075
16076                                    return Ok(BASE64_STANDARD.encode(fallback.as_bytes()));
16077                                }
16078
16079                                Err(rquickjs::Error::new_loading_message(
16080                                    &path,
16081                                    format!("host read open: {error_msg}"),
16082                                ))
16083                            };
16084
16085                            #[cfg(target_os = "linux")]
16086                            {
16087                                use std::io::Read;
16088                                use std::os::fd::AsRawFd;
16089
16090                                // Open first to get a handle, then verify the handle's path.
16091                                // This prevents TOCTOU attacks where the path is swapped
16092                                // between check and read.
16093                                let file = match std::fs::File::open(&requested_abs) {
16094                                    Ok(file) => file,
16095                                    Err(err)
16096                                        if err.kind() == std::io::ErrorKind::NotFound
16097                                            && configured_repair_mode.should_apply() =>
16098                                    {
16099                                        // Pattern 2 (bd-k5q5.8.3): missing asset fallback.
16100                                        let checked_path = crate::extensions::safe_canonicalize(&requested_abs);
16101
16102                                        let in_ext_root = path_is_in_allowed_extension_root(
16103                                            &checked_path,
16104                                            extension_id.as_deref(),
16105                                            &module_state,
16106                                            &allowed_read_roots,
16107                                        );
16108                                        let allowed = checked_path.starts_with(&workspace_root) || in_ext_root;
16109
16110                                        if !allowed {
16111                                            return Err(rquickjs::Error::new_loading_message(
16112                                                &path,
16113                                                format!("host read open: {err}"),
16114                                            ));
16115                                        }
16116
16117                                        return apply_missing_asset_fallback(&checked_path, &err.to_string());
16118                                    }
16119                                    Err(err) => {
16120                                        return Err(rquickjs::Error::new_loading_message(
16121                                            &path,
16122                                            format!("host read open: {err}"),
16123                                        ));
16124                                    }
16125                                };
16126
16127                                let secure_path_buf = std::fs::read_link(format!(
16128                                    "/proc/self/fd/{}",
16129                                    file.as_raw_fd()
16130                                ))
16131                                .map_err(|err| {
16132                                    rquickjs::Error::new_loading_message(
16133                                        &path,
16134                                        format!("host read verify: {err}"),
16135                                    )
16136                                })?;
16137                                let secure_path =
16138                                    crate::extensions::strip_unc_prefix(secure_path_buf);
16139
16140                                let in_ext_root = path_is_in_allowed_extension_root(
16141                                    &secure_path,
16142                                    extension_id.as_deref(),
16143                                    &module_state,
16144                                    &allowed_read_roots,
16145                                );
16146                                let allowed =
16147                                    secure_path.starts_with(&workspace_root) || in_ext_root;
16148
16149                                if !allowed {
16150                                    return Err(rquickjs::Error::new_loading_message(
16151                                        &path,
16152                                        "host read denied: path outside extension root".to_string(),
16153                                    ));
16154                                }
16155
16156                                let mut reader = file.take(MAX_SYNC_READ_SIZE + 1);
16157                                let mut buffer = Vec::new();
16158                                reader.read_to_end(&mut buffer).map_err(|err| {
16159                                    rquickjs::Error::new_loading_message(
16160                                        &path,
16161                                        format!("host read content: {err}"),
16162                                    )
16163                                })?;
16164
16165                                if buffer.len() as u64 > MAX_SYNC_READ_SIZE {
16166                                    return Err(rquickjs::Error::new_loading_message(
16167                                        &path,
16168                                        format!(
16169                                            "host read failed: file exceeds {MAX_SYNC_READ_SIZE} bytes"
16170                                        ),
16171                                    ));
16172                                }
16173
16174                                Ok(BASE64_STANDARD.encode(buffer))
16175                            }
16176
16177                            #[cfg(not(target_os = "linux"))]
16178                            {
16179                                let checked_path = crate::extensions::safe_canonicalize(&requested_abs);
16180
16181                                // Allow reads from workspace root or any registered
16182                                // extension root directory.
16183                                let in_ext_root = path_is_in_allowed_extension_root(
16184                                    &checked_path,
16185                                    extension_id.as_deref(),
16186                                    &module_state,
16187                                    &allowed_read_roots,
16188                                );
16189                                let allowed =
16190                                    checked_path.starts_with(&workspace_root) || in_ext_root;
16191
16192                                if !allowed {
16193                                    return Err(rquickjs::Error::new_loading_message(
16194                                        &path,
16195                                        "host read denied: path outside extension root".to_string(),
16196                                    ));
16197                                }
16198
16199                                use std::io::Read;
16200                                let file = match std::fs::File::open(&checked_path) {
16201                                    Ok(file) => file,
16202                                    Err(err) => {
16203                                        if err.kind() == std::io::ErrorKind::NotFound && in_ext_root && configured_repair_mode.should_apply() {
16204                                            return apply_missing_asset_fallback(&checked_path, &err.to_string());
16205                                        }
16206                                        return Err(rquickjs::Error::new_loading_message(
16207                                            &path,
16208                                            format!("host read: {err}"),
16209                                        ));
16210                                    }
16211                                };
16212
16213                                let mut reader = file.take(MAX_SYNC_READ_SIZE + 1);
16214                                let mut buffer = Vec::new();
16215                                reader.read_to_end(&mut buffer).map_err(|err| {
16216                                    rquickjs::Error::new_loading_message(
16217                                        &path,
16218                                        format!("host read content: {err}"),
16219                                    )
16220                                })?;
16221
16222                                if buffer.len() as u64 > MAX_SYNC_READ_SIZE {
16223                                    return Err(rquickjs::Error::new_loading_message(
16224                                        &path,
16225                                        format!("host read failed: file exceeds {} bytes", MAX_SYNC_READ_SIZE),
16226                                    ));
16227                                }
16228
16229                                Ok(BASE64_STANDARD.encode(buffer))
16230                            }
16231                        }
16232                    }),
16233                )?;
16234
16235                // __pi_exec_sync_native(cmd, args_json, cwd, timeout_ms, max_buffer) -> JSON string
16236                // Synchronous subprocess execution for node:child_process execSync/spawnSync.
16237                // Runs std::process::Command directly (no hostcall queue).
16238                global.set(
16239                    "__pi_exec_sync_native",
16240                    Func::from({
16241                        let process_cwd = process_cwd.clone();
16242                        let policy = self.policy.clone();
16243                        move |ctx: Ctx<'_>,
16244                              cmd: String,
16245                              args_json: String,
16246                              cwd: Opt<String>,
16247                              timeout_ms: Opt<f64>,
16248                              max_buffer: Opt<f64>|
16249                              -> rquickjs::Result<String> {
16250                            use std::process::{Command, Stdio};
16251                            use std::time::{Duration, Instant};
16252
16253                            tracing::debug!(
16254                                event = "pijs.exec_sync",
16255                                cmd = %cmd,
16256                                "exec_sync"
16257                            );
16258
16259                            let args: Vec<String> = serde_json::from_str(&args_json)
16260                                .map_err(|err| rquickjs::Error::new_into_js_message(
16261                                    "String",
16262                                    "Array",
16263                                    format!("invalid JSON args: {err}"),
16264                                ))?;
16265
16266                            let mut denied_reason = if allow_unsafe_sync_exec {
16267                                None
16268                            } else {
16269                                Some("sync child_process APIs are disabled by default".to_string())
16270                            };
16271
16272                            // 2. Per-extension capability check
16273                            if denied_reason.is_none() {
16274                                if let Some(policy) = &policy {
16275                                    let extension_id: Option<String> = ctx
16276                                        .globals()
16277                                        .get::<_, Option<String>>("__pi_current_extension_id")
16278                                        .ok()
16279                                        .flatten()
16280                                        .map(|value| value.trim().to_string())
16281                                        .filter(|value| !value.is_empty());
16282
16283                                    if check_exec_capability(policy, extension_id.as_deref()) {
16284                                        match evaluate_exec_mediation(&policy.exec_mediation, &cmd, &args) {
16285                                            ExecMediationResult::Deny { reason, .. } => {
16286                                                denied_reason = Some(format!(
16287                                                    "command blocked by exec mediation: {reason}"
16288                                                ));
16289                                            }
16290                                            ExecMediationResult::AllowWithAudit {
16291                                                class,
16292                                                reason,
16293                                            } => {
16294                                                tracing::info!(
16295                                                    event = "pijs.exec_sync.mediation_audit",
16296                                                    cmd = %cmd,
16297                                                    class = class.label(),
16298                                                    reason = %reason,
16299                                                    "sync child_process command allowed with exec mediation audit"
16300                                                );
16301                                            }
16302                                            ExecMediationResult::Allow => {}
16303                                        }
16304                                    } else {
16305                                        denied_reason = Some("extension lacks 'exec' capability".to_string());
16306                                    }
16307                                }
16308                            }
16309
16310                            if let Some(reason) = denied_reason {
16311                                tracing::warn!(
16312                                    event = "pijs.exec_sync.denied",
16313                                    cmd = %cmd,
16314                                    reason = %reason,
16315                                    "sync child_process execution denied by security policy"
16316                                );
16317                                let denied = serde_json::json!({
16318                                    "stdout": "",
16319                                    "stderr": "",
16320                                    "status": null,
16321                                    "error": format!("Execution denied by policy ({reason})"),
16322                                    "killed": false,
16323                                    "pid": 0,
16324                                    "code": "denied",
16325                                });
16326                                return Ok(denied.to_string());
16327                            }
16328
16329                            let working_dir = cwd
16330                                .0
16331                                .filter(|s| !s.is_empty())
16332                                .unwrap_or_else(|| process_cwd.clone());
16333
16334                            let timeout = timeout_ms
16335                                .0
16336                                .filter(|ms| ms.is_finite() && *ms > 0.0)
16337                                .map(|ms| Duration::from_secs_f64(ms / 1000.0));
16338
16339                            // Default to 10MB limit if not specified (generous but safe vs OOM)
16340                            let limit_bytes = max_buffer
16341                                .0
16342                                .filter(|b| b.is_finite() && *b > 0.0)
16343                                .and_then(|b| b.trunc().to_string().parse::<usize>().ok())
16344                                .unwrap_or(10 * 1024 * 1024);
16345
16346                            let result: std::result::Result<serde_json::Value, String> = (|| {
16347                                #[derive(Clone, Copy)]
16348                                enum StreamKind {
16349                                    Stdout,
16350                                    Stderr,
16351                                }
16352                                struct StreamChunk {
16353                                    kind: StreamKind,
16354                                    bytes: Vec<u8>,
16355                                }
16356                                fn pump_stream(
16357                                    mut reader: impl std::io::Read,
16358                                    tx: &std::sync::mpsc::SyncSender<StreamChunk>,
16359                                    kind: StreamKind,
16360                                ) {
16361                                    let mut buf = [0u8; 8192];
16362                                    loop {
16363                                        let read = match reader.read(&mut buf) {
16364                                            Ok(0) => break,
16365                                            Ok(read) => read,
16366                                            Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
16367                                            Err(_) => break,
16368                                        };
16369                                        let chunk = StreamChunk {
16370                                            kind,
16371                                            bytes: buf[..read].to_vec(),
16372                                        };
16373                                        if tx.send(chunk).is_err() {
16374                                            break;
16375                                        }
16376                                    }
16377                                }
16378
16379                                let mut command = Command::new(&cmd);
16380                                command
16381                                    .args(&args)
16382                                    .current_dir(&working_dir)
16383                                    .stdin(Stdio::null())
16384                                    .stdout(Stdio::piped())
16385                                    .stderr(Stdio::piped());
16386                                crate::tools::isolate_command_process_group(&mut command);
16387
16388                                let mut child = command.spawn().map_err(|e| e.to_string())?;
16389                                let pid = child.id();
16390
16391                                let stdout_pipe =
16392                                    child.stdout.take().ok_or("Missing stdout pipe")?;
16393                                let stderr_pipe =
16394                                    child.stderr.take().ok_or("Missing stderr pipe")?;
16395
16396                                let (tx, rx) = std::sync::mpsc::sync_channel::<StreamChunk>(128);
16397                                let tx_stdout = tx.clone();
16398                                let _stdout_handle =
16399                                    std::thread::spawn(move || pump_stream(stdout_pipe, &tx_stdout, StreamKind::Stdout));
16400                                let _stderr_handle =
16401                                    std::thread::spawn(move || pump_stream(stderr_pipe, &tx, StreamKind::Stderr));
16402
16403                                let start = Instant::now();
16404                                let mut killed = false;
16405                                let mut limit_exceeded = false;
16406                                let mut limit_error: Option<String> = None;
16407
16408                                let mut stdout_bytes = Vec::new();
16409                                let mut stderr_bytes = Vec::new();
16410
16411                                macro_rules! ingest_chunk {
16412                                    ($kind:expr, $bytes:expr) => {
16413                                        if !limit_exceeded {
16414                                            match $kind {
16415                                                StreamKind::Stdout => {
16416                                                    if stdout_bytes.len() + $bytes.len() > limit_bytes {
16417                                                        stdout_bytes.extend_from_slice(&$bytes[..limit_bytes.saturating_sub(stdout_bytes.len())]);
16418                                                        limit_exceeded = true;
16419                                                        limit_error = Some("stdout maxBuffer length exceeded".to_string());
16420                                                    } else {
16421                                                        stdout_bytes.extend($bytes);
16422                                                    }
16423                                                }
16424                                                StreamKind::Stderr => {
16425                                                    if stderr_bytes.len() + $bytes.len() > limit_bytes {
16426                                                        stderr_bytes.extend_from_slice(&$bytes[..limit_bytes.saturating_sub(stderr_bytes.len())]);
16427                                                        limit_exceeded = true;
16428                                                        limit_error = Some("stderr maxBuffer length exceeded".to_string());
16429                                                    } else {
16430                                                        stderr_bytes.extend($bytes);
16431                                                    }
16432                                                }
16433                                            }
16434                                        }
16435                                    };
16436                                }
16437
16438                                let status = loop {
16439                                    while let Ok(chunk) = rx.try_recv() {
16440                                        ingest_chunk!(chunk.kind, chunk.bytes);
16441                                    }
16442
16443                                    if let Some(st) = child.try_wait().map_err(|e| e.to_string())? {
16444                                        break st;
16445                                    }
16446                                    if !killed && limit_exceeded {
16447                                        killed = true;
16448                                        crate::tools::kill_process_group_tree(Some(pid));
16449                                        let _ = child.kill();
16450                                        break child.wait().map_err(|e| e.to_string())?;
16451                                    }
16452                                    if let Some(t) = timeout {
16453                                        if !killed && start.elapsed() >= t {
16454                                            killed = true;
16455                                            crate::tools::kill_process_group_tree(Some(pid));
16456                                            let _ = child.kill();
16457                                            break child.wait().map_err(|e| e.to_string())?;
16458                                        }
16459                                    }
16460                                    if let Ok(chunk) = rx.recv_timeout(Duration::from_millis(5)) {
16461                                        ingest_chunk!(chunk.kind, chunk.bytes);
16462                                    }
16463                                };
16464
16465                                let drain_deadline = Instant::now() + Duration::from_secs(2);
16466                                loop {
16467                                    match rx.try_recv() {
16468                                        Ok(chunk) => ingest_chunk!(chunk.kind, chunk.bytes),
16469                                        Err(std::sync::mpsc::TryRecvError::Empty) => {
16470                                            if Instant::now() >= drain_deadline {
16471                                                break;
16472                                            }
16473                                            std::thread::sleep(Duration::from_millis(10));
16474                                        }
16475                                        Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
16476                                    }
16477                                }
16478
16479                                drop(rx);
16480                                let _ = child.wait();
16481
16482                                let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
16483                                let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
16484                                let code = status.code();
16485
16486                                Ok(serde_json::json!({
16487                                    "stdout": stdout,
16488                                    "stderr": stderr,
16489                                    "status": code,
16490                                    "killed": killed,
16491                                    "pid": pid,
16492                                    "error": limit_error
16493                                }))
16494                            })(
16495                            );
16496
16497                            let json = match result {
16498                                Ok(v) => v,
16499                                Err(e) => serde_json::json!({
16500                                    "stdout": "",
16501                                    "stderr": "",
16502                                    "status": null,
16503                                    "error": e,
16504                                    "killed": false,
16505                                    "pid": 0,
16506                                }),
16507                            };
16508                            Ok(json.to_string())
16509                        }
16510                    }),
16511                )?;
16512
16513                // Register crypto hostcalls for node:crypto module
16514                crate::crypto_shim::register_crypto_hostcalls(&global)?;
16515
16516                // Inject WebAssembly polyfill (wasmtime-backed) when wasm-host feature is enabled
16517                #[cfg(feature = "wasm-host")]
16518                {
16519                    let wasm_state = std::rc::Rc::new(std::cell::RefCell::new(
16520                        crate::pi_wasm::WasmBridgeState::new(),
16521                    ));
16522                    crate::pi_wasm::inject_wasm_globals(&ctx, &wasm_state)?;
16523                }
16524
16525                // Install the JS bridge that creates Promises and wraps the native functions
16526                match ctx.eval::<(), _>(PI_BRIDGE_JS) {
16527                    Ok(()) => {}
16528                    Err(rquickjs::Error::Exception) => {
16529                        let detail = format_quickjs_exception(&ctx, ctx.catch());
16530                        return Err(rquickjs::Error::new_into_js_message(
16531                            "PI_BRIDGE_JS",
16532                            "eval",
16533                            detail,
16534                        ));
16535                    }
16536                    Err(err) => return Err(err),
16537                }
16538
16539                Ok(())
16540            })
16541            .await
16542            .map_err(|err| map_js_error(&err))?;
16543
16544        Ok(())
16545    }
16546}
16547
16548/// Generate a unique call_id using a thread-local counter.
16549fn generate_call_id() -> u64 {
16550    use std::sync::atomic::{AtomicU64, Ordering};
16551    static COUNTER: AtomicU64 = AtomicU64::new(1);
16552    COUNTER.fetch_add(1, Ordering::Relaxed)
16553}
16554
16555fn hex_lower(bytes: &[u8]) -> String {
16556    const HEX: [char; 16] = [
16557        '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
16558    ];
16559
16560    let mut output = String::with_capacity(bytes.len() * 2);
16561    for &byte in bytes {
16562        output.push(HEX[usize::from(byte >> 4)]);
16563        output.push(HEX[usize::from(byte & 0x0f)]);
16564    }
16565    output
16566}
16567
16568fn map_crypto_entropy_error(api: &'static str, err: getrandom::Error) -> rquickjs::Error {
16569    tracing::error!(
16570        event = "pijs.crypto.entropy_failure",
16571        api,
16572        error = %err,
16573        "OS randomness unavailable"
16574    );
16575    rquickjs::Error::new_into_js_message("crypto", api, format!("OS randomness unavailable: {err}"))
16576}
16577
16578fn fill_random_bytes_with<F, E>(len: usize, mut fill: F) -> std::result::Result<Vec<u8>, E>
16579where
16580    F: FnMut(&mut [u8]) -> std::result::Result<(), E>,
16581{
16582    let mut out = vec![0u8; len];
16583    if len > 0 {
16584        fill(&mut out)?;
16585    }
16586    Ok(out)
16587}
16588
16589fn random_bytes(len: usize) -> std::result::Result<Vec<u8>, getrandom::Error> {
16590    fill_random_bytes_with(len, getrandom::fill)
16591}
16592
16593/// JavaScript bridge code for managing pending hostcalls and timer callbacks.
16594///
16595/// This code creates the `pi` global object with Promise-returning methods.
16596/// Each method wraps a native Rust function (`__pi_*_native`) that returns a call_id.
16597const PI_BRIDGE_JS: &str = r"
16598// ============================================================================
16599// Console global — must come first so all other bridge code can use it.
16600// ============================================================================
16601if (typeof globalThis.console === 'undefined') {
16602    const __fmt = (...args) => args.map(a => {
16603        if (a === null) return 'null';
16604        if (a === undefined) return 'undefined';
16605        if (typeof a === 'object') {
16606            try { return JSON.stringify(a); } catch (_) { return String(a); }
16607        }
16608        return String(a);
16609    }).join(' ');
16610
16611    globalThis.console = {
16612        log:   (...args) => { __pi_console_output_native('log', __fmt(...args)); },
16613        info:  (...args) => { __pi_console_output_native('info', __fmt(...args)); },
16614        warn:  (...args) => { __pi_console_output_native('warn', __fmt(...args)); },
16615        error: (...args) => { __pi_console_output_native('error', __fmt(...args)); },
16616        debug: (...args) => { __pi_console_output_native('debug', __fmt(...args)); },
16617        trace: (...args) => { __pi_console_output_native('trace', __fmt(...args)); },
16618        dir:   (...args) => { __pi_console_output_native('log', __fmt(...args)); },
16619        time:  ()        => {},
16620        timeEnd: ()      => {},
16621        timeLog: ()      => {},
16622        assert: (cond, ...args) => {
16623            if (!cond) __pi_console_output_native('error', 'Assertion failed: ' + __fmt(...args));
16624        },
16625        count:    () => {},
16626        countReset: () => {},
16627        group:    () => {},
16628        groupEnd: () => {},
16629        table:    (...args) => { __pi_console_output_native('log', __fmt(...args)); },
16630        clear:    () => {},
16631    };
16632}
16633
16634// ============================================================================
16635// Intl polyfill — minimal stubs for extensions that use Intl APIs.
16636// QuickJS does not ship with Intl support; these cover the most common uses.
16637// ============================================================================
16638if (typeof globalThis.Intl === 'undefined') {
16639    const __intlPad = (n, w) => String(n).padStart(w || 2, '0');
16640
16641    class NumberFormat {
16642        constructor(locale, opts) {
16643            this._locale = locale || 'en-US';
16644            this._opts = opts || {};
16645        }
16646        format(n) {
16647            const o = this._opts;
16648            if (o.style === 'currency') {
16649                const c = o.currency || 'USD';
16650                const v = Number(n).toFixed(o.maximumFractionDigits ?? 2);
16651                return c + ' ' + v;
16652            }
16653            if (o.notation === 'compact') {
16654                const abs = Math.abs(n);
16655                if (abs >= 1e9) return (n / 1e9).toFixed(1) + 'B';
16656                if (abs >= 1e6) return (n / 1e6).toFixed(1) + 'M';
16657                if (abs >= 1e3) return (n / 1e3).toFixed(1) + 'K';
16658                return String(n);
16659            }
16660            if (o.style === 'percent') return (Number(n) * 100).toFixed(0) + '%';
16661            return String(n);
16662        }
16663        resolvedOptions() { return { ...this._opts, locale: this._locale }; }
16664    }
16665
16666    const __months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
16667    class DateTimeFormat {
16668        constructor(locale, opts) {
16669            this._locale = locale || 'en-US';
16670            this._opts = opts || {};
16671        }
16672        format(d) {
16673            const dt = d instanceof Date ? d : new Date(d ?? Date.now());
16674            const o = this._opts;
16675            const parts = [];
16676            if (o.month === 'short') parts.push(__months[dt.getMonth()]);
16677            else if (o.month === 'numeric' || o.month === '2-digit') parts.push(__intlPad(dt.getMonth() + 1));
16678            if (o.day === 'numeric' || o.day === '2-digit') parts.push(String(dt.getDate()));
16679            if (o.year === 'numeric') parts.push(String(dt.getFullYear()));
16680            if (parts.length === 0) {
16681                return __intlPad(dt.getMonth()+1) + '/' + __intlPad(dt.getDate()) + '/' + dt.getFullYear();
16682            }
16683            if (o.hour !== undefined) {
16684                parts.push(__intlPad(dt.getHours()) + ':' + __intlPad(dt.getMinutes()));
16685            }
16686            return parts.join(' ');
16687        }
16688        resolvedOptions() { return { ...this._opts, locale: this._locale, timeZone: 'UTC' }; }
16689    }
16690
16691    class Collator {
16692        constructor(locale, opts) {
16693            this._locale = locale || 'en';
16694            this._opts = opts || {};
16695        }
16696        compare(a, b) {
16697            const sa = String(a ?? '');
16698            const sb = String(b ?? '');
16699            if (this._opts.sensitivity === 'base') {
16700                return sa.toLowerCase().localeCompare(sb.toLowerCase());
16701            }
16702            return sa.localeCompare(sb);
16703        }
16704        resolvedOptions() { return { ...this._opts, locale: this._locale }; }
16705    }
16706
16707    class Segmenter {
16708        constructor(locale, opts) {
16709            this._locale = locale || 'en';
16710            this._opts = opts || {};
16711        }
16712        segment(str) {
16713            const s = String(str ?? '');
16714            const segments = [];
16715            // Approximate grapheme segmentation: split by codepoints
16716            for (const ch of s) {
16717                segments.push({ segment: ch, index: segments.length, input: s });
16718            }
16719            segments[Symbol.iterator] = function*() { for (const seg of segments) yield seg; };
16720            return segments;
16721        }
16722    }
16723
16724    class RelativeTimeFormat {
16725        constructor(locale, opts) {
16726            this._locale = locale || 'en';
16727            this._opts = opts || {};
16728        }
16729        format(value, unit) {
16730            const v = Number(value);
16731            const u = String(unit);
16732            const abs = Math.abs(v);
16733            const plural = abs !== 1 ? 's' : '';
16734            if (this._opts.numeric === 'auto') {
16735                if (v === -1 && u === 'day') return 'yesterday';
16736                if (v === 1 && u === 'day') return 'tomorrow';
16737            }
16738            if (v < 0) return abs + ' ' + u + plural + ' ago';
16739            return 'in ' + abs + ' ' + u + plural;
16740        }
16741    }
16742
16743    globalThis.Intl = {
16744        NumberFormat,
16745        DateTimeFormat,
16746        Collator,
16747        Segmenter,
16748        RelativeTimeFormat,
16749    };
16750}
16751
16752// Pending hostcalls: call_id -> { resolve, reject }
16753const __pi_pending_hostcalls = new Map();
16754
16755// Timer callbacks: timer_id -> callback
16756const __pi_timer_callbacks = new Map();
16757
16758// Event listeners: event_id -> [callback, ...]
16759const __pi_event_listeners = new Map();
16760
16761// ============================================================================
16762// Extension Registry (registration + hooks)
16763// ============================================================================
16764
16765var __pi_current_extension_id = null;
16766
16767// extension_id -> { id, name, version, apiVersion, tools: Map, commands: Map, hooks: Map, mcpServers: Map }
16768const __pi_extensions = new Map();
16769
16770// Fast indexes
16771const __pi_tool_index = new Map();      // tool_name -> { extensionId, spec, execute }
16772const __pi_command_index = new Map();   // command_name -> { extensionId, name, description, handler }
16773const __pi_hook_index = new Map();      // event_name -> [{ extensionId, handler }, ...]
16774const __pi_event_bus_index = new Map(); // event_name -> [{ extensionId, handler }, ...] (pi.events.on)
16775const __pi_provider_index = new Map();  // provider_id -> { extensionId, spec }
16776const __pi_shortcut_index = new Map();  // key_id -> { extensionId, key, description, handler }
16777const __pi_message_renderer_index = new Map(); // customType -> { extensionId, customType, renderer }
16778const __pi_mcp_server_index = new Map(); // server_name -> { extensionId, spec }
16779
16780// Async task tracking for Rust-driven calls (tool exec, command exec, event dispatch).
16781// task_id -> { status: 'pending'|'resolved'|'rejected', value?, error? }
16782const __pi_tasks = new Map();
16783
16784function __pi_serialize_error(err) {
16785    if (!err) {
16786        return { message: 'Unknown error' };
16787    }
16788    if (typeof err === 'string') {
16789        return { message: err };
16790    }
16791    const out = { message: String(err.message || err) };
16792    if (err.code) out.code = String(err.code);
16793    if (err.stack) out.stack = String(err.stack);
16794    return out;
16795}
16796
16797function __pi_task_start(task_id, promise) {
16798    const id = String(task_id || '').trim();
16799    if (!id) {
16800        throw new Error('task_id is required');
16801    }
16802    __pi_tasks.set(id, { status: 'pending' });
16803    Promise.resolve(promise).then(
16804        (value) => {
16805            __pi_tasks.set(id, { status: 'resolved', value: value });
16806        },
16807        (err) => {
16808            __pi_tasks.set(id, { status: 'rejected', error: __pi_serialize_error(err) });
16809        }
16810    );
16811    return id;
16812}
16813
16814function __pi_task_poll(task_id) {
16815    const id = String(task_id || '').trim();
16816    return __pi_tasks.get(id) || null;
16817}
16818
16819function __pi_task_take(task_id) {
16820    const id = String(task_id || '').trim();
16821    const state = __pi_tasks.get(id) || null;
16822    if (state && state.status !== 'pending') {
16823        __pi_tasks.delete(id);
16824    }
16825    return state;
16826}
16827
16828function __pi_runtime_registry_snapshot() {
16829    return {
16830        extensions: __pi_extensions.size,
16831        tools: __pi_tool_index.size,
16832        commands: __pi_command_index.size,
16833        hooks: __pi_hook_index.size,
16834        eventBusHooks: __pi_event_bus_index.size,
16835        providers: __pi_provider_index.size,
16836        shortcuts: __pi_shortcut_index.size,
16837        messageRenderers: __pi_message_renderer_index.size,
16838        mcpServers: __pi_mcp_server_index.size,
16839        pendingTasks: __pi_tasks.size,
16840        pendingHostcalls: __pi_pending_hostcalls.size,
16841        pendingTimers: __pi_timer_callbacks.size,
16842        pendingEventListenerLists: __pi_event_listeners.size,
16843        providerStreams:
16844            typeof __pi_provider_streams !== 'undefined' &&
16845            __pi_provider_streams &&
16846            typeof __pi_provider_streams.size === 'number'
16847                ? __pi_provider_streams.size
16848                : 0,
16849    };
16850}
16851
16852function __pi_reset_extension_runtime_state() {
16853    const before = __pi_runtime_registry_snapshot();
16854
16855    if (
16856        typeof __pi_provider_streams !== 'undefined' &&
16857        __pi_provider_streams &&
16858        typeof __pi_provider_streams.values === 'function'
16859    ) {
16860        for (const stream of __pi_provider_streams.values()) {
16861            try {
16862                if (stream && stream.controller && typeof stream.controller.abort === 'function') {
16863                    stream.controller.abort();
16864                }
16865            } catch (_) {}
16866            try {
16867                if (
16868                    stream &&
16869                    stream.iterator &&
16870                    typeof stream.iterator.return === 'function'
16871                ) {
16872                    stream.iterator.return();
16873                }
16874            } catch (_) {}
16875        }
16876        if (typeof __pi_provider_streams.clear === 'function') {
16877            __pi_provider_streams.clear();
16878        }
16879    }
16880    if (typeof __pi_provider_stream_seq === 'number') {
16881        __pi_provider_stream_seq = 0;
16882    }
16883
16884    __pi_current_extension_id = null;
16885    __pi_extensions.clear();
16886    __pi_tool_index.clear();
16887    __pi_command_index.clear();
16888    __pi_hook_index.clear();
16889    __pi_event_bus_index.clear();
16890    __pi_provider_index.clear();
16891    __pi_shortcut_index.clear();
16892    __pi_message_renderer_index.clear();
16893    __pi_mcp_server_index.clear();
16894    __pi_tasks.clear();
16895    __pi_pending_hostcalls.clear();
16896    __pi_timer_callbacks.clear();
16897    __pi_event_listeners.clear();
16898
16899    const after = __pi_runtime_registry_snapshot();
16900    const clean =
16901        after.extensions === 0 &&
16902        after.tools === 0 &&
16903        after.commands === 0 &&
16904        after.hooks === 0 &&
16905        after.eventBusHooks === 0 &&
16906        after.providers === 0 &&
16907        after.shortcuts === 0 &&
16908        after.messageRenderers === 0 &&
16909        after.mcpServers === 0 &&
16910        after.pendingTasks === 0 &&
16911        after.pendingHostcalls === 0 &&
16912        after.pendingTimers === 0 &&
16913        after.pendingEventListenerLists === 0 &&
16914        after.providerStreams === 0;
16915
16916    return { before, after, clean };
16917}
16918
16919function __pi_get_or_create_extension(extension_id, meta) {
16920    const id = String(extension_id || '').trim();
16921    if (!id) {
16922        throw new Error('extension_id is required');
16923    }
16924
16925    if (!__pi_extensions.has(id)) {
16926        __pi_extensions.set(id, {
16927            id: id,
16928            name: (meta && meta.name) ? String(meta.name) : id,
16929            version: (meta && meta.version) ? String(meta.version) : '0.0.0',
16930            apiVersion: (meta && meta.apiVersion) ? String(meta.apiVersion) : '1.0',
16931            tools: new Map(),
16932            commands: new Map(),
16933            hooks: new Map(),
16934            eventBusHooks: new Map(),
16935            providers: new Map(),
16936            mcpServers: new Map(),
16937            shortcuts: new Map(),
16938            flags: new Map(),
16939            flagValues: new Map(),
16940            messageRenderers: new Map(),
16941            activeTools: null,
16942        });
16943    }
16944
16945    return __pi_extensions.get(id);
16946}
16947
16948function __pi_begin_extension(extension_id, meta) {
16949    const ext = __pi_get_or_create_extension(extension_id, meta);
16950    __pi_current_extension_id = ext.id;
16951}
16952
16953function __pi_end_extension() {
16954    __pi_current_extension_id = null;
16955}
16956
16957function __pi_current_extension_or_throw() {
16958    if (!__pi_current_extension_id) {
16959        throw new Error('No active extension. Did you forget to call __pi_begin_extension?');
16960    }
16961    const ext = __pi_extensions.get(__pi_current_extension_id);
16962    if (!ext) {
16963        throw new Error('Internal error: active extension not found');
16964    }
16965    return ext;
16966}
16967
16968async function __pi_with_extension_async(extension_id, fn) {
16969    const prev = __pi_current_extension_id;
16970    __pi_current_extension_id = String(extension_id || '').trim();
16971    try {
16972        return await fn();
16973    } finally {
16974        __pi_current_extension_id = prev;
16975    }
16976}
16977
16978// Pattern 5 (bd-k5q5.8.6): log export shape normalization repairs.
16979// This is a lightweight JS-side event emitter; the Rust repair_events
16980// collector is not called from here to keep the bridge minimal.
16981function __pi_emit_repair_event(pattern, ext_id, entry, error, action) {
16982    if (typeof globalThis.__pi_host_log_event === 'function') {
16983        try {
16984            globalThis.__pi_host_log_event('pijs.repair.' + pattern, JSON.stringify({
16985                extension_id: ext_id, entry, error, action
16986            }));
16987        } catch (_) { /* best-effort */ }
16988    }
16989}
16990
16991async function __pi_load_extension(extension_id, entry_specifier, meta) {
16992    const id = String(extension_id || '').trim();
16993    const entry = String(entry_specifier || '').trim();
16994    if (!id) {
16995        throw new Error('load_extension: extension_id is required');
16996    }
16997    if (!entry) {
16998        throw new Error('load_extension: entry_specifier is required');
16999    }
17000
17001    const prev = __pi_current_extension_id;
17002    __pi_begin_extension(id, meta);
17003    try {
17004        const mod = await import(entry);
17005        let init = mod && mod.default;
17006
17007        // Pattern 5 (bd-k5q5.8.6): export shape normalization.
17008        // Try alternative activation function shapes before failing.
17009        if (typeof init !== 'function') {
17010            // 5a: double-wrapped default (CJS→ESM artifact)
17011            if (init && typeof init === 'object' && typeof init.default === 'function') {
17012                init = init.default;
17013                __pi_emit_repair_event('export_shape', id, entry,
17014                    'double-wrapped default export', 'unwrapped mod.default.default');
17015            }
17016            // 5b: named 'activate' export
17017            else if (typeof mod.activate === 'function') {
17018                init = mod.activate;
17019                __pi_emit_repair_event('export_shape', id, entry,
17020                    'no default export function', 'used named export mod.activate');
17021            }
17022            // 5c: nested CJS default with activate method
17023            else if (init && typeof init === 'object' && typeof init.activate === 'function') {
17024                init = init.activate;
17025                __pi_emit_repair_event('export_shape', id, entry,
17026                    'default is object with activate method', 'used mod.default.activate');
17027            }
17028        }
17029
17030        if (typeof init !== 'function') {
17031            const namedFallbacks = ['init', 'initialize', 'setup', 'register', 'plugin', 'main'];
17032            for (const key of namedFallbacks) {
17033                if (typeof mod?.[key] === 'function') {
17034                    init = mod[key];
17035                    __pi_emit_repair_event('export_shape', id, entry,
17036                        'no default export function', `used named export mod.${key}`);
17037                    break;
17038                }
17039            }
17040        }
17041
17042        if (typeof init !== 'function' && init && typeof init === 'object') {
17043            const nestedFallbacks = ['init', 'initialize', 'setup', 'register', 'plugin', 'main'];
17044            for (const key of nestedFallbacks) {
17045                if (typeof init?.[key] === 'function') {
17046                    init = init[key];
17047                    __pi_emit_repair_event('export_shape', id, entry,
17048                        'default is object with init-like export', `used mod.default.${key}`);
17049                    break;
17050                }
17051            }
17052        }
17053
17054        if (typeof init !== 'function') {
17055            for (const [key, value] of Object.entries(mod || {})) {
17056                if (typeof value === 'function') {
17057                    init = value;
17058                    __pi_emit_repair_event('export_shape', id, entry,
17059                        'no default export function', `used first function export mod.${key}`);
17060                    break;
17061                }
17062            }
17063        }
17064
17065        if (typeof init !== 'function') {
17066            throw new Error('load_extension: entry module must default-export a function');
17067        }
17068        await init(pi);
17069        return true;
17070    } finally {
17071        __pi_current_extension_id = prev;
17072    }
17073}
17074
17075function __pi_register_tool(spec) {
17076    const ext = __pi_current_extension_or_throw();
17077    if (!spec || typeof spec !== 'object') {
17078        throw new Error('registerTool: spec must be an object');
17079    }
17080    const name = String(spec.name || '').trim();
17081    if (!name) {
17082        throw new Error('registerTool: spec.name is required');
17083    }
17084    if (typeof spec.execute !== 'function') {
17085        throw new Error('registerTool: spec.execute must be a function');
17086    }
17087
17088    const toolSpec = {
17089        name: name,
17090        description: spec.description ? String(spec.description) : '',
17091        parameters: spec.parameters || { type: 'object', properties: {} },
17092    };
17093    if (typeof spec.label === 'string') {
17094        toolSpec.label = spec.label;
17095    }
17096
17097    if (__pi_tool_index.has(name)) {
17098        const existing = __pi_tool_index.get(name);
17099        if (existing && existing.extensionId !== ext.id) {
17100            throw new Error(`registerTool: tool name collision: ${name}`);
17101        }
17102    }
17103
17104    const record = { extensionId: ext.id, spec: toolSpec, execute: spec.execute };
17105    ext.tools.set(name, record);
17106    __pi_tool_index.set(name, record);
17107}
17108
17109function __pi_get_registered_tools() {
17110    const names = Array.from(__pi_tool_index.keys()).map((v) => String(v));
17111    names.sort();
17112    const out = [];
17113    for (const name of names) {
17114        const record = __pi_tool_index.get(name);
17115        if (!record || !record.spec) continue;
17116        out.push(record.spec);
17117    }
17118    return out;
17119}
17120
17121function __pi_register_command(name, spec) {
17122    const ext = __pi_current_extension_or_throw();
17123    const cmd = String(name || '').trim().replace(/^\//, '');
17124    if (!cmd) {
17125        throw new Error('registerCommand: name is required');
17126    }
17127    if (!spec || typeof spec !== 'object') {
17128        throw new Error('registerCommand: spec must be an object');
17129    }
17130    // Accept both spec.handler and spec.fn (PiCommand compat)
17131    const handler = typeof spec.handler === 'function' ? spec.handler
17132        : typeof spec.fn === 'function' ? spec.fn
17133        : undefined;
17134    if (!handler) {
17135        throw new Error('registerCommand: spec.handler must be a function');
17136    }
17137
17138    const cmdSpec = {
17139        name: cmd,
17140        description: spec.description ? String(spec.description) : '',
17141    };
17142
17143    if (__pi_command_index.has(cmd)) {
17144        const existing = __pi_command_index.get(cmd);
17145        if (existing && existing.extensionId !== ext.id) {
17146            throw new Error(`registerCommand: command name collision: ${cmd}`);
17147        }
17148    }
17149
17150    const record = {
17151        extensionId: ext.id,
17152        name: cmd,
17153        description: cmdSpec.description,
17154        handler: handler,
17155        spec: cmdSpec,
17156    };
17157    ext.commands.set(cmd, record);
17158    __pi_command_index.set(cmd, record);
17159}
17160
17161function __pi_register_provider(provider_id, spec) {
17162    const ext = __pi_current_extension_or_throw();
17163    const id = String(provider_id || '').trim();
17164    if (!id) {
17165        throw new Error('registerProvider: id is required');
17166    }
17167    if (!spec || typeof spec !== 'object') {
17168        throw new Error('registerProvider: spec must be an object');
17169    }
17170
17171    const models = Array.isArray(spec.models) ? spec.models.map((m) => {
17172        const out = {
17173            id: m && m.id ? String(m.id) : '',
17174            name: m && m.name ? String(m.name) : '',
17175        };
17176        if (m && m.api) out.api = String(m.api);
17177        if (m && m.reasoning !== undefined) out.reasoning = !!m.reasoning;
17178        if (m && Array.isArray(m.input)) out.input = m.input.slice();
17179        if (m && m.cost) out.cost = m.cost;
17180        if (m && m.contextWindow !== undefined) out.contextWindow = m.contextWindow;
17181        if (m && m.maxTokens !== undefined) out.maxTokens = m.maxTokens;
17182        return out;
17183    }) : [];
17184
17185    const hasStreamSimple = typeof spec.streamSimple === 'function';
17186    if (spec.streamSimple !== undefined && spec.streamSimple !== null && !hasStreamSimple) {
17187        throw new Error('registerProvider: spec.streamSimple must be a function');
17188    }
17189
17190    const providerSpec = {
17191        id: id,
17192        baseUrl: spec.baseUrl ? String(spec.baseUrl) : '',
17193        apiKey: spec.apiKey ? String(spec.apiKey) : '',
17194        api: spec.api ? String(spec.api) : '',
17195        models: models,
17196        hasStreamSimple: hasStreamSimple,
17197    };
17198
17199    if (hasStreamSimple && !providerSpec.api) {
17200        throw new Error('registerProvider: api is required when registering streamSimple');
17201    }
17202
17203    if (__pi_provider_index.has(id)) {
17204        const existing = __pi_provider_index.get(id);
17205        if (existing && existing.extensionId !== ext.id) {
17206            throw new Error(`registerProvider: provider id collision: ${id}`);
17207        }
17208    }
17209
17210    const record = {
17211        extensionId: ext.id,
17212        spec: providerSpec,
17213        streamSimple: hasStreamSimple ? spec.streamSimple : null,
17214    };
17215    ext.providers.set(id, record);
17216    __pi_provider_index.set(id, record);
17217}
17218
17219function __pi_register_mcp_server(name, spec) {
17220    const ext = __pi_current_extension_or_throw();
17221    const serverName = String(name || '').trim();
17222    if (!serverName) {
17223        throw new Error('registerMcpServer: name is required');
17224    }
17225    if (!spec || typeof spec !== 'object') {
17226        throw new Error('registerMcpServer: spec must be an object');
17227    }
17228
17229    const command = spec.command !== undefined && spec.command !== null
17230        ? String(spec.command).trim()
17231        : '';
17232    const url = spec.url !== undefined && spec.url !== null
17233        ? String(spec.url).trim()
17234        : '';
17235    if (!command && !url) {
17236        throw new Error('registerMcpServer: spec.command or spec.url is required');
17237    }
17238
17239    let args = undefined;
17240    if (spec.args !== undefined && spec.args !== null) {
17241        if (!Array.isArray(spec.args)) {
17242            throw new Error('registerMcpServer: spec.args must be an array');
17243        }
17244        args = spec.args.map((value) => String(value));
17245    }
17246
17247    let env = undefined;
17248    if (spec.env !== undefined && spec.env !== null) {
17249        if (typeof spec.env !== 'object' || Array.isArray(spec.env)) {
17250            throw new Error('registerMcpServer: spec.env must be an object');
17251        }
17252        env = Object.create(null);
17253        for (const [key, value] of Object.entries(spec.env)) {
17254            env[String(key)] = String(value);
17255        }
17256    }
17257
17258    const mcpSpec = {
17259        name: serverName,
17260        transport: spec.transport ? String(spec.transport) : undefined,
17261        command: command || undefined,
17262        url: url || undefined,
17263        args: args,
17264        env: env,
17265    };
17266    if (spec.description !== undefined) {
17267        mcpSpec.description = String(spec.description);
17268    }
17269    if (spec.cwd !== undefined) {
17270        mcpSpec.cwd = String(spec.cwd);
17271    }
17272
17273    if (__pi_mcp_server_index.has(serverName)) {
17274        const existing = __pi_mcp_server_index.get(serverName);
17275        if (existing && existing.extensionId !== ext.id) {
17276            throw new Error(`registerMcpServer: server name collision: ${serverName}`);
17277        }
17278    }
17279
17280    const record = { extensionId: ext.id, spec: mcpSpec };
17281    ext.mcpServers.set(serverName, record);
17282    __pi_mcp_server_index.set(serverName, record);
17283    return mcpSpec;
17284}
17285
17286function __pi_register_mcp_server_for_extension(extension_id, name, spec) {
17287    const extId = String(extension_id || '').trim();
17288    if (!extId) {
17289        throw new Error('registerMcpServer: extension_id is required');
17290    }
17291    const prev = __pi_current_extension_id;
17292    __pi_current_extension_id = extId;
17293    try {
17294        return __pi_register_mcp_server(name, spec);
17295    } finally {
17296        __pi_current_extension_id = prev;
17297    }
17298}
17299
17300// ============================================================================
17301// Provider Streaming (streamSimple bridge)
17302// ============================================================================
17303
17304let __pi_provider_stream_seq = 0;
17305const __pi_provider_streams = new Map(); // stream_id -> { iterator, controller }
17306
17307function __pi_make_abort_controller() {
17308    const listeners = new Set();
17309    const signal = {
17310        aborted: false,
17311        addEventListener: (type, cb) => {
17312            if (type !== 'abort') return;
17313            if (typeof cb === 'function') listeners.add(cb);
17314        },
17315        removeEventListener: (type, cb) => {
17316            if (type !== 'abort') return;
17317            listeners.delete(cb);
17318        },
17319    };
17320    return {
17321        signal,
17322        abort: () => {
17323            if (signal.aborted) return;
17324            signal.aborted = true;
17325            for (const cb of listeners) {
17326                try {
17327                    cb();
17328                } catch (_) {}
17329            }
17330        },
17331    };
17332}
17333
17334async function __pi_provider_stream_simple_start(provider_id, model, context, options) {
17335    const id = String(provider_id || '').trim();
17336    if (!id) {
17337        throw new Error('providerStreamSimple.start: provider_id is required');
17338    }
17339    const record = __pi_provider_index.get(id);
17340    if (!record) {
17341        throw new Error('providerStreamSimple.start: unknown provider: ' + id);
17342    }
17343    if (!record.streamSimple || typeof record.streamSimple !== 'function') {
17344        throw new Error('providerStreamSimple.start: provider has no streamSimple handler: ' + id);
17345    }
17346
17347    const controller = __pi_make_abort_controller();
17348    const mergedOptions = Object.assign({}, options || {}, { signal: controller.signal });
17349
17350    const stream = record.streamSimple(model, context, mergedOptions);
17351    const iterator = stream && stream[Symbol.asyncIterator] ? stream[Symbol.asyncIterator]() : stream;
17352    if (!iterator || typeof iterator.next !== 'function') {
17353        throw new Error('providerStreamSimple.start: streamSimple must return an async iterator');
17354    }
17355
17356    const stream_id = 'provider-stream-' + String(++__pi_provider_stream_seq);
17357    __pi_provider_streams.set(stream_id, { iterator, controller });
17358    return stream_id;
17359}
17360
17361async function __pi_provider_stream_simple_next(stream_id) {
17362    const id = String(stream_id || '').trim();
17363    const record = __pi_provider_streams.get(id);
17364    if (!record) {
17365        return { done: true, value: null };
17366    }
17367
17368    const result = await record.iterator.next();
17369    if (!result || result.done) {
17370        __pi_provider_streams.delete(id);
17371        return { done: true, value: null };
17372    }
17373
17374    return { done: false, value: result.value };
17375}
17376
17377async function __pi_provider_stream_simple_cancel(stream_id) {
17378    const id = String(stream_id || '').trim();
17379    const record = __pi_provider_streams.get(id);
17380    if (!record) {
17381        return false;
17382    }
17383
17384    try {
17385        record.controller.abort();
17386    } catch (_) {}
17387
17388    try {
17389        if (record.iterator && typeof record.iterator.return === 'function') {
17390            await record.iterator.return();
17391        }
17392    } catch (_) {}
17393
17394    __pi_provider_streams.delete(id);
17395    return true;
17396}
17397
17398const __pi_reserved_keys = new Set(['ctrl+c', 'ctrl+d', 'ctrl+l', 'ctrl+z']);
17399
17400function __pi_key_to_string(key) {
17401    // Convert Key object from @mariozechner/pi-tui to string format
17402    if (typeof key === 'string') {
17403        return key.toLowerCase();
17404    }
17405    if (key && typeof key === 'object') {
17406        const kind = key.kind;
17407        const k = key.key || '';
17408        if (kind === 'ctrlAlt') {
17409            return 'ctrl+alt+' + k.toLowerCase();
17410        }
17411        if (kind === 'ctrlShift') {
17412            return 'ctrl+shift+' + k.toLowerCase();
17413        }
17414        if (kind === 'ctrl') {
17415            return 'ctrl+' + k.toLowerCase();
17416        }
17417        if (kind === 'alt') {
17418            return 'alt+' + k.toLowerCase();
17419        }
17420        if (kind === 'shift') {
17421            return 'shift+' + k.toLowerCase();
17422        }
17423        // Fallback for unknown object format
17424        if (k) {
17425            return k.toLowerCase();
17426        }
17427    }
17428    return '<unknown>';
17429}
17430
17431function __pi_register_shortcut(key, spec) {
17432    const ext = __pi_current_extension_or_throw();
17433    if (!spec || typeof spec !== 'object') {
17434        throw new Error('registerShortcut: spec must be an object');
17435    }
17436    if (typeof spec.handler !== 'function') {
17437        throw new Error('registerShortcut: spec.handler must be a function');
17438    }
17439
17440    const keyId = __pi_key_to_string(key);
17441    if (__pi_reserved_keys.has(keyId)) {
17442        throw new Error('registerShortcut: key ' + keyId + ' is reserved and cannot be overridden');
17443    }
17444
17445    const record = {
17446        key: key,
17447        keyId: keyId,
17448        description: spec.description ? String(spec.description) : '',
17449        handler: spec.handler,
17450        extensionId: ext.id,
17451        spec: { shortcut: keyId, key: key, key_id: keyId, description: spec.description ? String(spec.description) : '' },
17452    };
17453    ext.shortcuts.set(keyId, record);
17454    __pi_shortcut_index.set(keyId, record);
17455}
17456
17457function __pi_register_message_renderer(customType, renderer) {
17458    const ext = __pi_current_extension_or_throw();
17459    const typeId = String(customType || '').trim();
17460    if (!typeId) {
17461        throw new Error('registerMessageRenderer: customType is required');
17462    }
17463    if (typeof renderer !== 'function') {
17464        throw new Error('registerMessageRenderer: renderer must be a function');
17465    }
17466
17467    const record = {
17468        customType: typeId,
17469        renderer: renderer,
17470        extensionId: ext.id,
17471    };
17472    ext.messageRenderers.set(typeId, record);
17473    __pi_message_renderer_index.set(typeId, record);
17474}
17475
17476	function __pi_register_hook(event_name, handler) {
17477	    const ext = __pi_current_extension_or_throw();
17478	    const eventName = String(event_name || '').trim();
17479	    if (!eventName) {
17480	        throw new Error('on: event name is required');
17481	    }
17482	    if (typeof handler !== 'function') {
17483	        throw new Error('on: handler must be a function');
17484	    }
17485
17486	    if (!ext.hooks.has(eventName)) {
17487	        ext.hooks.set(eventName, []);
17488	    }
17489	    ext.hooks.get(eventName).push(handler);
17490
17491	    if (!__pi_hook_index.has(eventName)) {
17492	        __pi_hook_index.set(eventName, []);
17493	    }
17494	    const indexed = { extensionId: ext.id, handler: handler };
17495	    __pi_hook_index.get(eventName).push(indexed);
17496
17497	    let removed = false;
17498	    return function unsubscribe() {
17499	        if (removed) return;
17500	        removed = true;
17501
17502	        const local = ext.hooks.get(eventName);
17503	        if (Array.isArray(local)) {
17504	            const idx = local.indexOf(handler);
17505	            if (idx !== -1) local.splice(idx, 1);
17506	            if (local.length === 0) ext.hooks.delete(eventName);
17507	        }
17508
17509	        const global = __pi_hook_index.get(eventName);
17510	        if (Array.isArray(global)) {
17511	            const idx = global.indexOf(indexed);
17512	            if (idx !== -1) global.splice(idx, 1);
17513	            if (global.length === 0) __pi_hook_index.delete(eventName);
17514	        }
17515	    };
17516	}
17517
17518	function __pi_register_event_bus_hook(event_name, handler) {
17519	    const ext = __pi_current_extension_or_throw();
17520	    const eventName = String(event_name || '').trim();
17521	    if (!eventName) {
17522	        throw new Error('events.on: event name is required');
17523	    }
17524	    if (typeof handler !== 'function') {
17525	        throw new Error('events.on: handler must be a function');
17526	    }
17527
17528	    if (!ext.eventBusHooks.has(eventName)) {
17529	        ext.eventBusHooks.set(eventName, []);
17530	    }
17531	    ext.eventBusHooks.get(eventName).push(handler);
17532
17533	    if (!__pi_event_bus_index.has(eventName)) {
17534	        __pi_event_bus_index.set(eventName, []);
17535	    }
17536	    const indexed = { extensionId: ext.id, handler: handler };
17537	    __pi_event_bus_index.get(eventName).push(indexed);
17538
17539	    let removed = false;
17540	    return function unsubscribe() {
17541	        if (removed) return;
17542	        removed = true;
17543
17544	        const local = ext.eventBusHooks.get(eventName);
17545	        if (Array.isArray(local)) {
17546	            const idx = local.indexOf(handler);
17547	            if (idx !== -1) local.splice(idx, 1);
17548	            if (local.length === 0) ext.eventBusHooks.delete(eventName);
17549	        }
17550
17551	        const global = __pi_event_bus_index.get(eventName);
17552	        if (Array.isArray(global)) {
17553	            const idx = global.indexOf(indexed);
17554	            if (idx !== -1) global.splice(idx, 1);
17555	            if (global.length === 0) __pi_event_bus_index.delete(eventName);
17556	        }
17557	    };
17558	}
17559
17560function __pi_register_flag(flag_name, spec) {
17561    const ext = __pi_current_extension_or_throw();
17562    const name = String(flag_name || '').trim().replace(/^\//, '');
17563    if (!name) {
17564        throw new Error('registerFlag: name is required');
17565    }
17566    if (!spec || typeof spec !== 'object') {
17567        throw new Error('registerFlag: spec must be an object');
17568    }
17569    ext.flags.set(name, spec);
17570}
17571
17572function __pi_set_flag_value(extension_id, flag_name, value) {
17573    const extId = String(extension_id || '').trim();
17574    const name = String(flag_name || '').trim().replace(/^\//, '');
17575    if (!extId || !name) return false;
17576    const ext = __pi_extensions.get(extId);
17577    if (!ext) return false;
17578    ext.flagValues.set(name, value);
17579    return true;
17580}
17581
17582function __pi_get_flag(flag_name) {
17583    const ext = __pi_current_extension_or_throw();
17584    const name = String(flag_name || '').trim().replace(/^\//, '');
17585    if (!name) return undefined;
17586    if (ext.flagValues.has(name)) {
17587        return ext.flagValues.get(name);
17588    }
17589    const spec = ext.flags.get(name);
17590    return spec ? spec.default : undefined;
17591}
17592
17593function __pi_set_active_tools(tools) {
17594    const ext = __pi_current_extension_or_throw();
17595    if (!Array.isArray(tools)) {
17596        throw new Error('setActiveTools: tools must be an array');
17597    }
17598    ext.activeTools = tools.map((t) => String(t));
17599    // Best-effort notify host; ignore completion.
17600    try {
17601        pi.events('setActiveTools', { extensionId: ext.id, tools: ext.activeTools }).catch(() => {});
17602    } catch (_) {}
17603}
17604
17605function __pi_get_active_tools() {
17606    const ext = __pi_current_extension_or_throw();
17607    if (!Array.isArray(ext.activeTools)) return undefined;
17608    return ext.activeTools.slice();
17609}
17610
17611function __pi_get_model() {
17612    return pi.events('getModel', {});
17613}
17614
17615function __pi_set_model(provider, modelId) {
17616    const p = provider != null ? String(provider) : null;
17617    const m = modelId != null ? String(modelId) : null;
17618    return pi.events('setModel', { provider: p, modelId: m });
17619}
17620
17621function __pi_get_thinking_level() {
17622    return pi.events('getThinkingLevel', {});
17623}
17624
17625function __pi_set_thinking_level(level) {
17626    const l = level != null ? String(level).trim() : null;
17627    return pi.events('setThinkingLevel', { thinkingLevel: l });
17628}
17629
17630function __pi_get_session_name() {
17631    return pi.session('get_name', {});
17632}
17633
17634function __pi_set_session_name(name) {
17635    const n = name != null ? String(name) : '';
17636    return pi.session('set_name', { name: n });
17637}
17638
17639function __pi_set_label(entryId, label) {
17640    const eid = String(entryId || '').trim();
17641    if (!eid) {
17642        throw new Error('setLabel: entryId is required');
17643    }
17644    const l = label != null ? String(label).trim() : null;
17645    return pi.session('set_label', { targetId: eid, label: l || undefined });
17646}
17647
17648function __pi_append_entry(custom_type, data) {
17649    const ext = __pi_current_extension_or_throw();
17650    const customType = String(custom_type || '').trim();
17651    if (!customType) {
17652        throw new Error('appendEntry: customType is required');
17653    }
17654    try {
17655        pi.events('appendEntry', {
17656            extensionId: ext.id,
17657            customType: customType,
17658            data: data === undefined ? null : data,
17659        }).catch(() => {});
17660    } catch (_) {}
17661}
17662
17663function __pi_send_message(message, options) {
17664    const ext = __pi_current_extension_or_throw();
17665    if (!message || typeof message !== 'object') {
17666        throw new Error('sendMessage: message must be an object');
17667    }
17668    const opts = options && typeof options === 'object' ? options : {};
17669    try {
17670        pi.events('sendMessage', { extensionId: ext.id, message: message, options: opts }).catch(() => {});
17671    } catch (_) {}
17672}
17673
17674function __pi_send_user_message(text, options) {
17675    const ext = __pi_current_extension_or_throw();
17676    const msg = String(text === undefined || text === null ? '' : text).trim();
17677    if (!msg) return;
17678    const opts = options && typeof options === 'object' ? options : {};
17679    try {
17680        pi.events('sendUserMessage', { extensionId: ext.id, text: msg, options: opts }).catch(() => {});
17681    } catch (_) {}
17682}
17683
17684function __pi_snapshot_extensions() {
17685    const out = [];
17686    for (const [id, ext] of __pi_extensions.entries()) {
17687        const tools = [];
17688        for (const tool of ext.tools.values()) {
17689            tools.push(tool.spec);
17690        }
17691
17692        const commands = [];
17693        for (const cmd of ext.commands.values()) {
17694            commands.push(cmd.spec);
17695        }
17696
17697        const providers = [];
17698        for (const provider of ext.providers.values()) {
17699            providers.push(provider.spec);
17700        }
17701
17702        const mcp_servers = [];
17703        if (ext.mcpServers && typeof ext.mcpServers.values === 'function') {
17704            for (const server of ext.mcpServers.values()) {
17705                if (server && server.spec) {
17706                    mcp_servers.push(server.spec);
17707                }
17708            }
17709        }
17710
17711        const event_hooks = [];
17712        for (const key of ext.hooks.keys()) {
17713            event_hooks.push(String(key));
17714        }
17715
17716        const shortcuts = [];
17717        for (const shortcut of ext.shortcuts.values()) {
17718            shortcuts.push(shortcut.spec);
17719        }
17720
17721        const message_renderers = [];
17722        for (const renderer of ext.messageRenderers.values()) {
17723            message_renderers.push(renderer.customType);
17724        }
17725
17726        const flags = [];
17727        for (const [flagName, flagSpec] of ext.flags.entries()) {
17728            flags.push({
17729                name: flagName,
17730                description: flagSpec.description ? String(flagSpec.description) : '',
17731                type: flagSpec.type ? String(flagSpec.type) : 'string',
17732                default: flagSpec.default !== undefined ? flagSpec.default : null,
17733            });
17734        }
17735
17736        out.push({
17737            id: id,
17738            name: ext.name,
17739            version: ext.version,
17740            api_version: ext.apiVersion,
17741            tools: tools,
17742            slash_commands: commands,
17743            providers: providers,
17744            mcp_servers: mcp_servers,
17745            shortcuts: shortcuts,
17746            message_renderers: message_renderers,
17747            flags: flags,
17748            event_hooks: event_hooks,
17749            active_tools: Array.isArray(ext.activeTools) ? ext.activeTools.slice() : null,
17750        });
17751    }
17752    return out;
17753}
17754
17755function __pi_make_extension_theme() {
17756    return Object.create(__pi_extension_theme_template);
17757}
17758
17759const __pi_extension_theme_template = {
17760    // Minimal theme shim. Legacy emits ANSI; conformance harness should normalize ANSI away.
17761    fg: (_style, text) => String(text === undefined || text === null ? '' : text),
17762    bold: (text) => String(text === undefined || text === null ? '' : text),
17763    strikethrough: (text) => String(text === undefined || text === null ? '' : text),
17764};
17765
17766function __pi_build_extension_ui_template(hasUI) {
17767    const toUiText = (value) => {
17768        if (value && typeof value === 'object') {
17769            if (value.text !== undefined && value.text !== null) return String(value.text);
17770            if (value.message !== undefined && value.message !== null) return String(value.message);
17771            if (value.title !== undefined && value.title !== null) return String(value.title);
17772        }
17773        return String(value === undefined || value === null ? '' : value);
17774    };
17775    return {
17776        select: (title, options) => {
17777            if (!hasUI) return Promise.resolve(undefined);
17778            const list = Array.isArray(options) ? options : [];
17779            const mapped = list.map((v) => String(v));
17780            return pi.ui('select', { title: String(title === undefined || title === null ? '' : title), options: mapped });
17781        },
17782        confirm: (title, message) => {
17783            if (!hasUI) return Promise.resolve(false);
17784            return pi.ui('confirm', {
17785                title: String(title === undefined || title === null ? '' : title),
17786                message: String(message === undefined || message === null ? '' : message),
17787            });
17788        },
17789        input: (title, placeholder, def) => {
17790            if (!hasUI) return Promise.resolve(undefined);
17791            // Legacy extensions typically call input(title, placeholder?, default?)
17792            let payloadDefault = def;
17793            let payloadPlaceholder = placeholder;
17794            if (def === undefined && typeof placeholder === 'string') {
17795                payloadDefault = placeholder;
17796                payloadPlaceholder = undefined;
17797            }
17798            return pi.ui('input', {
17799                title: String(title === undefined || title === null ? '' : title),
17800                placeholder: payloadPlaceholder,
17801                default: payloadDefault,
17802            });
17803        },
17804        editor: (title, def, language) => {
17805            if (!hasUI) return Promise.resolve(undefined);
17806            // Legacy extensions typically call editor(title, defaultText)
17807            return pi.ui('editor', {
17808                title: String(title === undefined || title === null ? '' : title),
17809                language: language,
17810                default: def,
17811            });
17812        },
17813        notify: (message, level) => {
17814            const notifyType = level ? String(level) : undefined;
17815            const payload = {
17816                message: String(message === undefined || message === null ? '' : message),
17817            };
17818            if (notifyType) {
17819                payload.level = notifyType;
17820                payload.notifyType = notifyType; // legacy field
17821            }
17822            void pi.ui('notify', payload).catch(() => {});
17823        },
17824        setStatus: (statusKey, statusText) => {
17825            const key = String(statusKey === undefined || statusKey === null ? '' : statusKey);
17826            const text = String(statusText === undefined || statusText === null ? '' : statusText);
17827            void pi.ui('setStatus', {
17828                statusKey: key,
17829                statusText: text,
17830                text: text, // compat: some UI surfaces only consume `text`
17831            }).catch(() => {});
17832        },
17833        setFooter: (text) => {
17834            const value = toUiText(text);
17835            void pi.ui('setStatus', {
17836                statusKey: 'footer',
17837                statusText: value,
17838                text: value,
17839            }).catch(() => {});
17840        },
17841        setHeader: (text) => {
17842            const value = toUiText(text);
17843            void pi.ui('setTitle', {
17844                title: value,
17845                text: value,
17846            }).catch(() => {});
17847        },
17848        setWorkingMessage: (text) => {
17849            const value = toUiText(text);
17850            void pi.ui('setStatus', {
17851                statusKey: 'working',
17852                statusText: value,
17853                text: value,
17854            }).catch(() => {});
17855        },
17856        setWidget: (widgetKey, lines) => {
17857            if (!hasUI) return;
17858            const payload = { widgetKey: String(widgetKey === undefined || widgetKey === null ? '' : widgetKey) };
17859            if (Array.isArray(lines)) {
17860                payload.lines = lines.map((v) => String(v));
17861                payload.widgetLines = payload.lines; // compat with pi-mono RPC naming
17862                payload.content = payload.lines.join('\n'); // compat: some UI surfaces expect a single string
17863            }
17864            void pi.ui('setWidget', payload).catch(() => {});
17865        },
17866        setTitle: (title) => {
17867            void pi.ui('setTitle', {
17868                title: String(title === undefined || title === null ? '' : title),
17869            }).catch(() => {});
17870        },
17871        setEditorText: (text) => {
17872            void pi.ui('set_editor_text', {
17873                text: String(text === undefined || text === null ? '' : text),
17874            }).catch(() => {});
17875        },
17876        getEditorText: () => {
17877            if (!hasUI) return Promise.resolve('');
17878            return pi.ui('getEditorText', {});
17879        },
17880        custom: async (componentFactory, options) => {
17881            if (!hasUI) return undefined;
17882            const opts = options && typeof options === 'object' ? options : {};
17883            if (typeof componentFactory !== 'function') {
17884                return pi.ui('custom', opts);
17885            }
17886
17887            const widgetKey = '__pi_custom_overlay';
17888            const parseWidth = (value, fallback) => {
17889                if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
17890                    return Math.max(20, Math.floor(value));
17891                }
17892                if (typeof value === 'string') {
17893                    const text = value.trim();
17894                    if (!text) return fallback;
17895                    if (text.endsWith('%')) {
17896                        const pct = Number.parseFloat(text.slice(0, -1));
17897                        if (Number.isFinite(pct) && pct > 0) {
17898                            return Math.max(20, Math.floor((fallback * pct) / 100));
17899                        }
17900                        return fallback;
17901                    }
17902                    const parsed = Number.parseInt(text, 10);
17903                    if (Number.isFinite(parsed) && parsed > 0) {
17904                        return Math.max(20, parsed);
17905                    }
17906                }
17907                return fallback;
17908            };
17909            const fallbackWidth = parseWidth(
17910                opts.width ?? (opts.overlayOptions && opts.overlayOptions.width),
17911                80
17912            );
17913
17914            let done = false;
17915            let doneValue = undefined;
17916            let renderWidth = fallbackWidth;
17917            let needsRender = true;
17918            let renderInFlight = false;
17919            let pollInFlight = false;
17920            let component = null;
17921            let renderTimer = null;
17922            let pollTimer = null;
17923
17924            const theme = (this && this.theme) || __pi_make_extension_theme();
17925            const keybindings = {};
17926            const onDone = (value) => {
17927                done = true;
17928                doneValue = value;
17929            };
17930            const tui = {
17931                requestRender: () => {
17932                    needsRender = true;
17933                },
17934            };
17935
17936            const toKittyRelease = (keyData) => {
17937                if (typeof keyData !== 'string' || keyData.length === 0) return null;
17938                if (keyData.length !== 1) return null;
17939                const ch = keyData;
17940                if (ch >= 'A' && ch <= 'Z') {
17941                    const code = ch.toLowerCase().charCodeAt(0);
17942                    return `\u001b[${code};2:3u`;
17943                }
17944                return `\u001b[${ch.charCodeAt(0)};1:3u`;
17945            };
17946
17947            const disposeComponent = () => {
17948                if (component && typeof component.dispose === 'function') {
17949                    try {
17950                        component.dispose();
17951                    } catch (_) {}
17952                }
17953            };
17954
17955            const pushFrame = async () => {
17956                if (!component || typeof component.render !== 'function') return;
17957                let lines = [];
17958                try {
17959                    const rendered = component.render(renderWidth);
17960                    if (Array.isArray(rendered)) {
17961                        lines = rendered.map((line) =>
17962                            String(line === undefined || line === null ? '' : line)
17963                        );
17964                    } else if (rendered !== undefined && rendered !== null) {
17965                        lines = String(rendered).split('\n');
17966                    }
17967                } catch (_) {
17968                    done = true;
17969                    return;
17970                }
17971                await pi
17972                    .ui('setWidget', {
17973                        widgetKey,
17974                        lines,
17975                        title:
17976                            typeof opts.title === 'string'
17977                                ? opts.title
17978                                : (opts.overlay ? 'Extension Overlay' : undefined),
17979                    })
17980                    .catch(() => {});
17981            };
17982
17983            const handlePollResponse = (response) => {
17984                if (!response || typeof response !== 'object') return;
17985                if (typeof response.width === 'number' && Number.isFinite(response.width)) {
17986                    const nextWidth = Math.max(20, Math.floor(response.width));
17987                    if (nextWidth !== renderWidth) {
17988                        renderWidth = nextWidth;
17989                        needsRender = true;
17990                    }
17991                }
17992                if (response.closed || response.cancelled) {
17993                    done = true;
17994                    return;
17995                }
17996                const keyData = typeof response.key === 'string' ? response.key : null;
17997                if (keyData && component && typeof component.handleInput === 'function') {
17998                    try {
17999                        component.handleInput(keyData);
18000                        const release = toKittyRelease(keyData);
18001                        if (release) {
18002                            component.handleInput(release);
18003                        }
18004                    } catch (_) {
18005                        done = true;
18006                        return;
18007                    }
18008                    needsRender = true;
18009                }
18010            };
18011
18012            const pollInput = () => {
18013                if (done || pollInFlight) return;
18014                pollInFlight = true;
18015                void pi
18016                    .ui('custom', {
18017                        ...opts,
18018                        mode: 'poll',
18019                        widgetKey,
18020                    })
18021                    .then(handlePollResponse)
18022                    .catch(() => {})
18023                    .finally(() => {
18024                        pollInFlight = false;
18025                    });
18026            };
18027
18028            try {
18029                component = componentFactory(tui, theme, keybindings, onDone);
18030            } catch (err) {
18031                disposeComponent();
18032                throw err;
18033            }
18034
18035            renderTimer = setInterval(() => {
18036                if (done || renderInFlight || !needsRender) return;
18037                needsRender = false;
18038                renderInFlight = true;
18039                void pushFrame().finally(() => {
18040                    renderInFlight = false;
18041                });
18042            }, 1000 / 30);
18043
18044            pollTimer = setInterval(() => {
18045                pollInput();
18046            }, 16);
18047
18048            pollInput();
18049            needsRender = false;
18050            await pushFrame();
18051
18052            while (!done) {
18053                await __pi_sleep(16);
18054            }
18055
18056            if (renderTimer) clearInterval(renderTimer);
18057            if (pollTimer) clearInterval(pollTimer);
18058            disposeComponent();
18059
18060            await pi.ui('setWidget', { widgetKey, clear: true, lines: [] }).catch(() => {});
18061            await pi
18062                .ui('custom', {
18063                    ...opts,
18064                    mode: 'close',
18065                    close: true,
18066                    widgetKey,
18067                })
18068                .catch(() => {});
18069
18070            return doneValue;
18071        },
18072        getAllThemes: () => {
18073            if (!hasUI) return Promise.resolve([]);
18074            return pi.ui('getAllThemes', {});
18075        },
18076        getTheme: (name) => {
18077            if (!hasUI) return Promise.resolve(undefined);
18078            return pi.ui('getTheme', {
18079                name: String(name === undefined || name === null ? '' : name),
18080            });
18081        },
18082        setTheme: (themeOrName) => {
18083            if (!hasUI) return Promise.resolve({ success: false, error: 'UI not available' });
18084            if (themeOrName && typeof themeOrName === 'object') {
18085                const name = themeOrName.name;
18086                return pi.ui('setTheme', {
18087                    name: String(name === undefined || name === null ? '' : name),
18088                });
18089            }
18090            return pi.ui('setTheme', {
18091                name: String(themeOrName === undefined || themeOrName === null ? '' : themeOrName),
18092            });
18093        },
18094    };
18095}
18096
18097const __pi_extension_ui_templates = {
18098    with_ui: __pi_build_extension_ui_template(true),
18099    without_ui: __pi_build_extension_ui_template(false),
18100};
18101
18102function __pi_make_extension_ui(hasUI) {
18103    const template = hasUI ? __pi_extension_ui_templates.with_ui : __pi_extension_ui_templates.without_ui;
18104    const ui = Object.create(template);
18105    ui.theme = __pi_make_extension_theme();
18106    return ui;
18107}
18108
18109function __pi_make_extension_ctx(ctx_payload) {
18110    const hasUI = !!(ctx_payload && (ctx_payload.hasUI || ctx_payload.has_ui));
18111    const cwd = ctx_payload && (ctx_payload.cwd || ctx_payload.CWD) ? String(ctx_payload.cwd || ctx_payload.CWD) : '';
18112
18113    const entriesRaw =
18114        (ctx_payload && (ctx_payload.sessionEntries || ctx_payload.session_entries || ctx_payload.entries)) || [];
18115    const branchRaw =
18116        (ctx_payload && (ctx_payload.sessionBranch || ctx_payload.session_branch || ctx_payload.branch)) || entriesRaw;
18117
18118    const entries = Array.isArray(entriesRaw) ? entriesRaw : [];
18119    const branch = Array.isArray(branchRaw) ? branchRaw : entries;
18120
18121    const leafEntry =
18122        (ctx_payload &&
18123            (ctx_payload.sessionLeafEntry ||
18124                ctx_payload.session_leaf_entry ||
18125                ctx_payload.leafEntry ||
18126                ctx_payload.leaf_entry)) ||
18127        null;
18128
18129    const modelRegistryValues =
18130        (ctx_payload && (ctx_payload.modelRegistry || ctx_payload.model_registry || ctx_payload.model_registry_values)) ||
18131        {};
18132
18133    const sessionManager = {
18134        getEntries: () => entries,
18135        getBranch: () => branch,
18136        getLeafEntry: () => leafEntry,
18137    };
18138
18139    return {
18140        hasUI: hasUI,
18141        cwd: cwd,
18142        ui: __pi_make_extension_ui(hasUI),
18143        sessionManager: sessionManager,
18144        modelRegistry: {
18145            getApiKeyForProvider: async (provider) => {
18146                const key = String(provider || '').trim();
18147                if (!key) return undefined;
18148                const value = modelRegistryValues[key];
18149                if (value === undefined || value === null) return undefined;
18150                return String(value);
18151            },
18152        },
18153    };
18154}
18155
18156	async function __pi_dispatch_event_inner(eventName, event_payload, ctx) {
18157	    const handlers = [
18158	        ...(__pi_hook_index.get(eventName) || []),
18159	        ...(__pi_event_bus_index.get(eventName) || []),
18160	    ];
18161	    if (handlers.length === 0) {
18162	        return undefined;
18163	    }
18164
18165	    const needsSignal = eventName === 'session_before_compact' || eventName === 'session_before_tree';
18166	    if (needsSignal) {
18167	        const base = event_payload && typeof event_payload === 'object' ? event_payload : {};
18168	        if (base !== event_payload) {
18169	            event_payload = base;
18170	        }
18171	        if (!('signal' in base)) {
18172	            base.signal = new AbortController().signal;
18173	        }
18174	    }
18175
18176	    if (eventName === 'input') {
18177	        const base = event_payload && typeof event_payload === 'object' ? event_payload : {};
18178	        const originalText = typeof base.text === 'string'
18179	            ? base.text
18180	            : (typeof base.content === 'string' ? base.content : String(base.text ?? base.content ?? ''));
18181	        const originalImages = Array.isArray(base.images)
18182	            ? base.images
18183	            : (Array.isArray(base.attachments) ? base.attachments : undefined);
18184	        const source = base.source !== undefined ? base.source : 'extension';
18185
18186        let currentText = originalText;
18187        let currentImages = originalImages;
18188
18189	        for (const entry of handlers) {
18190	            const handler = entry && entry.handler;
18191	            if (typeof handler !== 'function') continue;
18192	            const event = { type: 'input', text: currentText, images: currentImages, source: source };
18193	            let result = undefined;
18194	            try {
18195	                result = await __pi_with_extension_async(entry.extensionId, () => handler(event, ctx));
18196	            } catch (e) {
18197		                try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
18198		                continue;
18199		            }
18200	            if (result && typeof result === 'object') {
18201	                if (result.action === 'handled') return result;
18202	                if (result.action === 'transform' && typeof result.text === 'string') {
18203	                    currentText = result.text;
18204	                    if (result.images !== undefined) currentImages = result.images;
18205                }
18206            }
18207        }
18208
18209        if (currentText !== originalText || currentImages !== originalImages) {
18210            return { action: 'transform', text: currentText, images: currentImages };
18211        }
18212        return { action: 'continue' };
18213    }
18214
18215	    if (eventName === 'before_agent_start') {
18216	        const base = event_payload && typeof event_payload === 'object' ? event_payload : {};
18217	        const prompt = typeof base.prompt === 'string' ? base.prompt : '';
18218	        const images = Array.isArray(base.images) ? base.images : undefined;
18219	        let currentSystemPrompt = typeof base.systemPrompt === 'string' ? base.systemPrompt : '';
18220	        let modified = false;
18221	        const messages = [];
18222
18223	        for (const entry of handlers) {
18224	            const handler = entry && entry.handler;
18225	            if (typeof handler !== 'function') continue;
18226	            const event = { type: 'before_agent_start', prompt, images, systemPrompt: currentSystemPrompt };
18227	            let result = undefined;
18228	            try {
18229	                result = await __pi_with_extension_async(entry.extensionId, () => handler(event, ctx));
18230	            } catch (e) {
18231		                try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
18232		                continue;
18233		            }
18234	            if (result && typeof result === 'object') {
18235	                if (result.message !== undefined) messages.push(result.message);
18236	                if (result.systemPrompt !== undefined) {
18237	                    currentSystemPrompt = String(result.systemPrompt);
18238	                    modified = true;
18239                }
18240            }
18241        }
18242
18243        if (messages.length > 0 || modified) {
18244            return { messages: messages.length > 0 ? messages : undefined, systemPrompt: modified ? currentSystemPrompt : undefined };
18245        }
18246        return undefined;
18247    }
18248
18249	    if (eventName === 'resources_discover') {
18250	        const skillPaths = [];
18251	        const promptPaths = [];
18252	        const themePaths = [];
18253
18254	        const pushPaths = (target, value) => {
18255	            if (!value) return;
18256	            if (Array.isArray(value)) {
18257	                for (const entry of value) {
18258	                    if (typeof entry === 'string' && entry.trim()) {
18259	                        target.push(entry.trim());
18260	                    }
18261	                }
18262	                return;
18263	            }
18264	            if (typeof value === 'string' && value.trim()) {
18265	                target.push(value.trim());
18266	            }
18267	        };
18268
18269	        for (const entry of handlers) {
18270	            const handler = entry && entry.handler;
18271	            if (typeof handler !== 'function') continue;
18272	            let result = undefined;
18273	            try {
18274	                result = await __pi_with_extension_async(entry.extensionId, () => handler(event_payload, ctx));
18275	            } catch (e) {
18276	                try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
18277	                continue;
18278	            }
18279	            if (!result || typeof result !== 'object') continue;
18280	            pushPaths(skillPaths, result.skillPaths || result.skill_paths);
18281	            pushPaths(promptPaths, result.promptPaths || result.prompt_paths);
18282	            pushPaths(themePaths, result.themePaths || result.theme_paths);
18283	        }
18284
18285	        const response = {};
18286	        if (skillPaths.length > 0) response.skillPaths = skillPaths;
18287	        if (promptPaths.length > 0) response.promptPaths = promptPaths;
18288	        if (themePaths.length > 0) response.themePaths = themePaths;
18289	        if (Object.keys(response).length > 0) {
18290	            return response;
18291	        }
18292	        return undefined;
18293	    }
18294
18295	    let last = undefined;
18296	    for (const entry of handlers) {
18297	        const handler = entry && entry.handler;
18298	        if (typeof handler !== 'function') continue;
18299	        let value = undefined;
18300	        try {
18301	            value = await __pi_with_extension_async(entry.extensionId, () => handler(event_payload, ctx));
18302	        } catch (e) {
18303	            try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
18304	            if (eventName === 'tool_call' || eventName.startsWith('session_before_')) {
18305	                throw e;
18306	            }
18307	            continue;
18308	        }
18309	        if (value === undefined) continue;
18310
18311        // First-result semantics (legacy parity)
18312        if (eventName === 'user_bash') {
18313            return value;
18314        }
18315
18316        last = value;
18317
18318        // Early-stop semantics (legacy parity)
18319        if (eventName === 'tool_call' && value && typeof value === 'object' && value.block) {
18320            return value;
18321        }
18322        if (eventName.startsWith('session_before_') && value && typeof value === 'object' && value.cancel) {
18323            return value;
18324        }
18325    }
18326    return last;
18327}
18328
18329	async function __pi_dispatch_extension_event(event_name, event_payload, ctx_payload) {
18330	    const eventName = String(event_name || '').trim();
18331	    if (!eventName) {
18332	        throw new Error('dispatch_event: event name is required');
18333	    }
18334	    const ctx = __pi_make_extension_ctx(ctx_payload);
18335	    return __pi_dispatch_event_inner(eventName, event_payload, ctx);
18336	}
18337
18338	async function __pi_dispatch_extension_events_batch(events_json, ctx_payload) {
18339	    const ctx = __pi_make_extension_ctx(ctx_payload);
18340	    const results = [];
18341	    for (const entry of events_json) {
18342	        const eventName = String(entry.event_name || '').trim();
18343	        if (!eventName) continue;
18344	        try {
18345	            const value = await __pi_dispatch_event_inner(eventName, entry.event_payload, ctx);
18346	            results.push({ event: eventName, ok: true, value: value });
18347	        } catch (e) {
18348	            results.push({ event: eventName, ok: false, error: String(e) });
18349	        }
18350	    }
18351	    return results;
18352	}
18353
18354function __pi_validate_tool_input(schema, input) {
18355    if (!schema || typeof schema !== 'object') return;
18356    const schemaType = schema.type;
18357    const typeList = Array.isArray(schemaType)
18358        ? schemaType.filter((value) => typeof value === 'string')
18359        : (typeof schemaType === 'string' ? [schemaType] : []);
18360    const typeIsObject = typeList.includes('object');
18361    const allowsNull = typeList.includes('null');
18362    const hasExplicitTypes = schemaType !== undefined && typeList.length > 0;
18363    const hasProperties = schema.properties && typeof schema.properties === 'object';
18364    const schemaIsObject = schemaType !== undefined ? typeIsObject : hasProperties;
18365    if (!schemaIsObject) return;
18366    const required = Array.isArray(schema.required)
18367        ? schema.required.filter((value) => typeof value === 'string')
18368        : [];
18369    if (input === undefined) {
18370        if (required.length === 0) return;
18371        throw new Error(`Tool input missing required fields: ${required.join(', ')}`);
18372    }
18373    if (input === null) {
18374        if (allowsNull) return;
18375        throw new Error('Tool input must be an object');
18376    }
18377    if (typeof input !== 'object' || Array.isArray(input)) {
18378        if (hasExplicitTypes) {
18379            const inputType = Array.isArray(input) ? 'array' : typeof input;
18380            if (typeList.includes(inputType)) return;
18381            if (inputType === 'number' && typeList.includes('integer') && Number.isInteger(input)) {
18382                return;
18383            }
18384        }
18385        throw new Error('Tool input must be an object');
18386    }
18387    if (required.length === 0) return;
18388    const missing = [];
18389    for (const key of required) {
18390        if (!Object.prototype.hasOwnProperty.call(input, key) || input[key] === undefined) {
18391            missing.push(key);
18392        }
18393    }
18394    if (missing.length > 0) {
18395        throw new Error(`Tool input missing required fields: ${missing.join(', ')}`);
18396    }
18397}
18398
18399async function __pi_execute_tool(tool_name, tool_call_id, input, ctx_payload) {
18400    const name = String(tool_name || '').trim();
18401    const record = __pi_tool_index.get(name);
18402    if (!record) {
18403        throw new Error(`Unknown tool: ${name}`);
18404    }
18405
18406    __pi_validate_tool_input(record.spec && record.spec.parameters, input);
18407
18408    const ctx = __pi_make_extension_ctx(ctx_payload);
18409    return __pi_with_extension_async(record.extensionId, () =>
18410        record.execute(tool_call_id, input, undefined, undefined, ctx)
18411    );
18412}
18413
18414async function __pi_execute_command(command_name, args, ctx_payload) {
18415    const name = String(command_name || '').trim().replace(/^\//, '');
18416    const record = __pi_command_index.get(name);
18417    if (!record) {
18418        throw new Error(`Unknown command: ${name}`);
18419    }
18420
18421    const ctx = __pi_make_extension_ctx(ctx_payload);
18422    return __pi_with_extension_async(record.extensionId, () => record.handler(args, ctx));
18423}
18424
18425async function __pi_execute_shortcut(key_id, ctx_payload) {
18426    const id = String(key_id || '').trim().toLowerCase();
18427    const record = __pi_shortcut_index.get(id);
18428    if (!record) {
18429        throw new Error('Unknown shortcut: ' + id);
18430    }
18431
18432    const ctx = __pi_make_extension_ctx(ctx_payload);
18433    return __pi_with_extension_async(record.extensionId, () => record.handler(ctx));
18434}
18435
18436// Hostcall stream class (async iterator for streaming hostcall results)
18437class __pi_HostcallStream {
18438    constructor(callId, options = undefined) {
18439        this.callId = callId;
18440        this.buffer = [];
18441        this.waitResolve = null;
18442        this.done = false;
18443        this.bufferLimit = 16;
18444        this.stallTimeoutMs = 30000;
18445        this.stallTimer = null;
18446
18447        if (options && typeof options === 'object') {
18448            const bufferSize = options.buffer_size ?? options.bufferSize;
18449            if (Number.isFinite(bufferSize) && bufferSize > 0) {
18450                this.bufferLimit = Math.max(1, Math.floor(bufferSize));
18451            }
18452            const stallTimeout = options.stall_timeout_ms ?? options.stallTimeoutMs;
18453            if (Number.isFinite(stallTimeout) && stallTimeout >= 0) {
18454                this.stallTimeoutMs = Math.floor(stallTimeout);
18455            }
18456        }
18457    }
18458    _clearStallTimer() {
18459        if (this.stallTimer !== null) {
18460            clearTimeout(this.stallTimer);
18461            this.stallTimer = null;
18462        }
18463    }
18464    _armStallTimer() {
18465        if (this.stallTimeoutMs <= 0) return;
18466        if (this.stallTimer !== null) {
18467            clearTimeout(this.stallTimer);
18468        }
18469        const timeoutMs = this.stallTimeoutMs;
18470        this.stallTimer = setTimeout(() => {
18471            this.stallTimer = null;
18472            if (this.done) return;
18473            if (this.waitResolve) return;
18474            if (this.buffer.length < this.bufferLimit) return;
18475            const seconds = Math.max(1, Math.round(timeoutMs / 1000));
18476            console.warn(`Stream stalled: JS consumer did not pull for ${seconds}s`);
18477            if (typeof __pi_cancel_hostcall_native === 'function') {
18478                try {
18479                    __pi_cancel_hostcall_native(this.callId);
18480                } catch (e) {
18481                    console.error('Hostcall cancel error:', e);
18482                }
18483            }
18484        }, timeoutMs);
18485    }
18486    pushChunk(chunk, isFinal) {
18487        if (isFinal) {
18488            this.done = true;
18489            this._clearStallTimer();
18490        }
18491        if (this.waitResolve) {
18492            const resolve = this.waitResolve;
18493            this.waitResolve = null;
18494            this._clearStallTimer();
18495            if (isFinal && chunk === null) {
18496                resolve({ value: undefined, done: true });
18497            } else {
18498                resolve({ value: chunk, done: false });
18499            }
18500        } else {
18501            this.buffer.push({ chunk, isFinal });
18502            if (!this.done && this.buffer.length >= this.bufferLimit) {
18503                this._armStallTimer();
18504            }
18505        }
18506    }
18507    pushError(error) {
18508        this.done = true;
18509        this._clearStallTimer();
18510        if (this.waitResolve) {
18511            const rej = this.waitResolve;
18512            this.waitResolve = null;
18513            rej({ __error: error });
18514        } else {
18515            this.buffer.push({ __error: error });
18516        }
18517    }
18518    next() {
18519        if (this.buffer.length > 0) {
18520            const entry = this.buffer.shift();
18521            if (!this.done) {
18522                if (this.buffer.length < this.bufferLimit) {
18523                    this._clearStallTimer();
18524                } else {
18525                    this._armStallTimer();
18526                }
18527            }
18528            if (entry.__error) return Promise.reject(entry.__error);
18529            if (entry.isFinal && entry.chunk === null) return Promise.resolve({ value: undefined, done: true });
18530            return Promise.resolve({ value: entry.chunk, done: false });
18531        }
18532        if (this.done) {
18533            this._clearStallTimer();
18534            return Promise.resolve({ value: undefined, done: true });
18535        }
18536        this._clearStallTimer();
18537        return new Promise((resolve, reject) => {
18538            this.waitResolve = (result) => {
18539                if (result && result.__error) reject(result.__error);
18540                else resolve(result);
18541            };
18542        });
18543    }
18544    return() {
18545        this.done = true;
18546        this.buffer = [];
18547        this.waitResolve = null;
18548        this._clearStallTimer();
18549        return Promise.resolve({ value: undefined, done: true });
18550    }
18551    [Symbol.asyncIterator]() { return this; }
18552}
18553
18554// Complete a hostcall (called from Rust)
18555function __pi_complete_hostcall_impl(call_id, outcome) {
18556    const pending = __pi_pending_hostcalls.get(call_id);
18557    if (!pending) return;
18558
18559    if (outcome.stream) {
18560        const seq = Number(outcome.sequence);
18561        if (!Number.isFinite(seq)) {
18562            const error = new Error('Invalid stream sequence');
18563            error.code = 'STREAM_SEQUENCE';
18564            if (pending.stream) pending.stream.pushError(error);
18565            else if (pending.reject) pending.reject(error);
18566            __pi_pending_hostcalls.delete(call_id);
18567            return;
18568        }
18569        if (pending.lastSeq === undefined) {
18570            if (seq !== 0) {
18571                const error = new Error('Stream sequence must start at 0');
18572                error.code = 'STREAM_SEQUENCE';
18573                if (pending.stream) pending.stream.pushError(error);
18574                else if (pending.reject) pending.reject(error);
18575                __pi_pending_hostcalls.delete(call_id);
18576                return;
18577            }
18578        } else if (seq <= pending.lastSeq) {
18579            const error = new Error('Stream sequence out of order');
18580            error.code = 'STREAM_SEQUENCE';
18581            if (pending.stream) pending.stream.pushError(error);
18582            else if (pending.reject) pending.reject(error);
18583            __pi_pending_hostcalls.delete(call_id);
18584            return;
18585        }
18586        pending.lastSeq = seq;
18587
18588        if (pending.stream) {
18589            pending.stream.pushChunk(outcome.chunk, outcome.isFinal);
18590        } else if (pending.onChunk) {
18591            const chunk = outcome.chunk;
18592            const isFinal = outcome.isFinal;
18593            Promise.resolve().then(() => {
18594                try {
18595                    pending.onChunk(chunk, isFinal);
18596                } catch (e) {
18597                    console.error('Hostcall onChunk error:', e);
18598                }
18599            });
18600        }
18601        if (outcome.isFinal) {
18602            __pi_pending_hostcalls.delete(call_id);
18603            if (pending.resolve) pending.resolve(outcome.chunk);
18604        }
18605        return;
18606    }
18607
18608    if (!outcome.ok && pending.stream) {
18609        const error = new Error(outcome.message);
18610        error.code = outcome.code;
18611        pending.stream.pushError(error);
18612        __pi_pending_hostcalls.delete(call_id);
18613        return;
18614    }
18615
18616    __pi_pending_hostcalls.delete(call_id);
18617    if (outcome.ok) {
18618        pending.resolve(outcome.value);
18619    } else {
18620        const error = new Error(outcome.message);
18621        error.code = outcome.code;
18622        pending.reject(error);
18623    }
18624}
18625
18626function __pi_complete_hostcall(call_id, outcome) {
18627    const pending = __pi_pending_hostcalls.get(call_id);
18628    if (pending && pending.extensionId) {
18629        const prev = __pi_current_extension_id;
18630        __pi_current_extension_id = pending.extensionId;
18631        try {
18632            return __pi_complete_hostcall_impl(call_id, outcome);
18633        } finally {
18634            Promise.resolve().then(() => { __pi_current_extension_id = prev; });
18635        }
18636    }
18637    return __pi_complete_hostcall_impl(call_id, outcome);
18638}
18639
18640// Fire a timer callback (called from Rust)
18641function __pi_fire_timer(timer_id) {
18642    const callback = __pi_timer_callbacks.get(timer_id);
18643    if (callback) {
18644        __pi_timer_callbacks.delete(timer_id);
18645        try {
18646            callback();
18647        } catch (e) {
18648            console.error('Timer callback error:', e);
18649        }
18650    }
18651}
18652
18653// Dispatch an inbound event (called from Rust)
18654function __pi_dispatch_event(event_id, payload) {
18655    const listeners = __pi_event_listeners.get(event_id);
18656    if (listeners) {
18657        for (const listener of listeners) {
18658            try {
18659                listener(payload);
18660            } catch (e) {
18661                console.error('Event listener error:', e);
18662            }
18663        }
18664    }
18665}
18666
18667// Register a timer callback (used by setTimeout)
18668function __pi_register_timer(timer_id, callback) {
18669    __pi_timer_callbacks.set(timer_id, callback);
18670}
18671
18672// Unregister a timer callback (used by clearTimeout)
18673function __pi_unregister_timer(timer_id) {
18674    __pi_timer_callbacks.delete(timer_id);
18675}
18676
18677// Add an event listener
18678function __pi_add_event_listener(event_id, callback) {
18679    if (!__pi_event_listeners.has(event_id)) {
18680        __pi_event_listeners.set(event_id, []);
18681    }
18682    __pi_event_listeners.get(event_id).push(callback);
18683}
18684
18685// Remove an event listener
18686function __pi_remove_event_listener(event_id, callback) {
18687    const listeners = __pi_event_listeners.get(event_id);
18688    if (listeners) {
18689        const index = listeners.indexOf(callback);
18690        if (index !== -1) {
18691            listeners.splice(index, 1);
18692        }
18693    }
18694}
18695
18696// Helper to create a Promise-returning hostcall wrapper
18697function __pi_make_hostcall(nativeFn) {
18698    return function(...args) {
18699        return new Promise((resolve, reject) => {
18700            const call_id = nativeFn(...args);
18701            __pi_pending_hostcalls.set(call_id, {
18702                resolve,
18703                reject,
18704                extensionId: __pi_current_extension_id
18705            });
18706        });
18707    };
18708}
18709
18710function __pi_make_streaming_hostcall(nativeFn, ...args) {
18711    const call_id = nativeFn(...args);
18712    let options = undefined;
18713    if (args.length > 0) {
18714        const last = args[args.length - 1];
18715        if (last && typeof last === 'object' && !Array.isArray(last)) {
18716            options = last;
18717        }
18718    }
18719    const stream = new __pi_HostcallStream(call_id, options);
18720    __pi_pending_hostcalls.set(call_id, {
18721        stream,
18722        resolve: () => {},
18723        reject: () => {},
18724        extensionId: __pi_current_extension_id
18725    });
18726    return stream;
18727}
18728
18729function __pi_env_get(key) {
18730    const value = __pi_env_get_native(key);
18731    if (value === null || value === undefined) {
18732        return undefined;
18733    }
18734    return value;
18735}
18736
18737function __pi_path_join(...parts) {
18738    let out = '';
18739    for (const part of parts) {
18740        if (!part) continue;
18741        if (out === '' || out.endsWith('/')) {
18742            out += part;
18743        } else {
18744            out += '/' + part;
18745        }
18746    }
18747    return __pi_path_normalize(out);
18748}
18749
18750function __pi_path_basename(path) {
18751    if (!path) return '';
18752    let p = path;
18753    while (p.length > 1 && p.endsWith('/')) {
18754        p = p.slice(0, -1);
18755    }
18756    const idx = p.lastIndexOf('/');
18757    return idx === -1 ? p : p.slice(idx + 1);
18758}
18759
18760function __pi_path_normalize(path) {
18761    if (!path) return '';
18762    const isAbs = path.startsWith('/');
18763    const parts = path.split('/').filter(p => p.length > 0);
18764    const stack = [];
18765    for (const part of parts) {
18766        if (part === '.') continue;
18767        if (part === '..') {
18768            if (stack.length > 0 && stack[stack.length - 1] !== '..') {
18769                stack.pop();
18770            } else if (!isAbs) {
18771                stack.push('..');
18772            }
18773            continue;
18774        }
18775        stack.push(part);
18776    }
18777    const joined = stack.join('/');
18778    return isAbs ? '/' + joined : joined || (isAbs ? '/' : '');
18779}
18780
18781function __pi_sleep(ms) {
18782    return new Promise((resolve) => setTimeout(resolve, ms));
18783}
18784
18785// Create the pi global object with Promise-returning methods
18786const __pi_exec_hostcall = __pi_make_hostcall(__pi_exec_native);
18787	const pi = {
18788    // pi.tool(name, input) - invoke a tool
18789    tool: __pi_make_hostcall(__pi_tool_native),
18790
18791    // pi.exec(cmd, args, options) - execute a shell command
18792    exec: (cmd, args, options = {}) => {
18793        if (options && options.stream) {
18794            const onChunk =
18795                options && typeof options === 'object'
18796                    ? (options.onChunk || options.on_chunk)
18797                    : undefined;
18798            if (typeof onChunk === 'function') {
18799                const opts = Object.assign({}, options);
18800                delete opts.onChunk;
18801                delete opts.on_chunk;
18802                const call_id = __pi_exec_native(cmd, args, opts);
18803                return new Promise((resolve, reject) => {
18804                    __pi_pending_hostcalls.set(call_id, { onChunk, resolve, reject, extensionId: __pi_current_extension_id });
18805                });
18806            }
18807            return __pi_make_streaming_hostcall(__pi_exec_native, cmd, args, options);
18808        }
18809        return __pi_exec_hostcall(cmd, args, options);
18810    },
18811
18812    // pi.http(request) - make an HTTP request
18813    http: (request) => {
18814        if (request && request.stream) {
18815            const onChunk =
18816                request && typeof request === 'object'
18817                    ? (request.onChunk || request.on_chunk)
18818                    : undefined;
18819            if (typeof onChunk === 'function') {
18820                const req = Object.assign({}, request);
18821                delete req.onChunk;
18822                delete req.on_chunk;
18823                const call_id = __pi_http_native(req);
18824                return new Promise((resolve, reject) => {
18825                    __pi_pending_hostcalls.set(call_id, { onChunk, resolve, reject, extensionId: __pi_current_extension_id });
18826                });
18827            }
18828            return __pi_make_streaming_hostcall(__pi_http_native, request);
18829        }
18830        return __pi_make_hostcall(__pi_http_native)(request);
18831    },
18832
18833    // pi.session(op, args) - session operations
18834    session: __pi_make_hostcall(__pi_session_native),
18835
18836    // pi.ui(op, args) - UI operations
18837    ui: __pi_make_hostcall(__pi_ui_native),
18838
18839	    // pi.events(op, args) - event operations
18840	    events: __pi_make_hostcall(__pi_events_native),
18841
18842    // pi.log(entry) - structured log emission
18843    log: __pi_make_hostcall(__pi_log_native),
18844
18845    // Extension API (legacy-compatible subset)
18846    registerTool: __pi_register_tool,
18847    registerCommand: __pi_register_command,
18848    registerProvider: __pi_register_provider,
18849    registerMcpServer: __pi_register_mcp_server,
18850    registerShortcut: __pi_register_shortcut,
18851    registerMessageRenderer: __pi_register_message_renderer,
18852    on: __pi_register_hook,
18853    registerFlag: __pi_register_flag,
18854    getFlag: __pi_get_flag,
18855    setActiveTools: __pi_set_active_tools,
18856    getActiveTools: __pi_get_active_tools,
18857    getModel: __pi_get_model,
18858    setModel: __pi_set_model,
18859    getThinkingLevel: __pi_get_thinking_level,
18860    setThinkingLevel: __pi_set_thinking_level,
18861    appendEntry: __pi_append_entry,
18862	    sendMessage: __pi_send_message,
18863	    sendUserMessage: __pi_send_user_message,
18864	    getSessionName: __pi_get_session_name,
18865	    setSessionName: __pi_set_session_name,
18866	    setLabel: __pi_set_label,
18867	};
18868
18869	// Convenience API: pi.events.emit/on (inter-extension bus).
18870	// Keep pi.events callable for legacy hostcall operations.
18871	pi.events.emit = (event, data, options = undefined) => {
18872	    const name = String(event || '').trim();
18873	    if (!name) {
18874	        throw new Error('events.emit: event name is required');
18875	    }
18876	    const payload = { event: name, data: (data === undefined ? null : data) };
18877	    if (options && typeof options === 'object') {
18878	        if (options.ctx !== undefined) payload.ctx = options.ctx;
18879	        if (options.timeout_ms !== undefined) payload.timeout_ms = options.timeout_ms;
18880	        if (options.timeoutMs !== undefined) payload.timeoutMs = options.timeoutMs;
18881	        if (options.timeout !== undefined) payload.timeout = options.timeout;
18882	    }
18883	    return pi.events('emit', payload);
18884	};
18885	pi.events.on = (event, handler) => __pi_register_event_bus_hook(event, handler);
18886
18887	pi.env = {
18888	    get: __pi_env_get,
18889	};
18890
18891pi.process = {
18892    cwd: __pi_process_cwd_native(),
18893    args: __pi_process_args_native(),
18894};
18895
18896const __pi_det_cwd = __pi_env_get('PI_DETERMINISTIC_CWD');
18897if (__pi_det_cwd) {
18898    try { pi.process.cwd = __pi_det_cwd; } catch (_) {}
18899}
18900
18901try { Object.freeze(pi.process.args); } catch (_) {}
18902try { Object.freeze(pi.process); } catch (_) {}
18903
18904pi.path = {
18905    join: __pi_path_join,
18906    basename: __pi_path_basename,
18907    normalize: __pi_path_normalize,
18908};
18909
18910function __pi_crypto_bytes_to_array(raw) {
18911    if (raw == null) return [];
18912    if (Array.isArray(raw)) {
18913        return raw.map((value) => Number(value) & 0xff);
18914    }
18915    if (raw instanceof Uint8Array) {
18916        return Array.from(raw, (value) => Number(value) & 0xff);
18917    }
18918    if (raw instanceof ArrayBuffer) {
18919        return Array.from(new Uint8Array(raw), (value) => Number(value) & 0xff);
18920    }
18921    if (typeof raw === 'string') {
18922        // Depending on bridge coercion, bytes may arrive as:
18923        // 1) hex text (2 chars per byte), or 2) latin1-style binary string.
18924        const isHex = raw.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(raw);
18925        if (isHex) {
18926            const out = [];
18927            for (let i = 0; i + 1 < raw.length; i += 2) {
18928                const byte = Number.parseInt(raw.slice(i, i + 2), 16);
18929                out.push(Number.isFinite(byte) ? (byte & 0xff) : 0);
18930            }
18931            return out;
18932        }
18933        const out = new Array(raw.length);
18934        for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i) & 0xff;
18935        return out;
18936    }
18937    if (typeof raw.length === 'number') {
18938        const len = Number(raw.length) || 0;
18939        const out = new Array(len);
18940        for (let i = 0; i < len; i++) out[i] = Number(raw[i] || 0) & 0xff;
18941        return out;
18942    }
18943    return [];
18944}
18945
18946pi.crypto = {
18947    randomBytes: function(n) {
18948        return __pi_crypto_bytes_to_array(__pi_crypto_random_bytes_native(n));
18949    },
18950};
18951
18952pi.time = {
18953    nowMs: __pi_now_ms_native,
18954    sleep: __pi_sleep,
18955};
18956
18957// Make pi available globally
18958globalThis.pi = pi;
18959
18960const __pi_det_time_raw = __pi_env_get('PI_DETERMINISTIC_TIME_MS');
18961const __pi_det_time_step_raw = __pi_env_get('PI_DETERMINISTIC_TIME_STEP_MS');
18962const __pi_det_random_raw = __pi_env_get('PI_DETERMINISTIC_RANDOM');
18963const __pi_det_random_seed_raw = __pi_env_get('PI_DETERMINISTIC_RANDOM_SEED');
18964
18965if (__pi_det_time_raw !== undefined) {
18966    const __pi_det_base = Number(__pi_det_time_raw);
18967    if (Number.isFinite(__pi_det_base)) {
18968        const __pi_det_step = (() => {
18969            if (__pi_det_time_step_raw === undefined) return 1;
18970            const value = Number(__pi_det_time_step_raw);
18971            return Number.isFinite(value) ? value : 1;
18972        })();
18973        let __pi_det_tick = 0;
18974        const __pi_det_now = () => {
18975            const value = __pi_det_base + (__pi_det_step * __pi_det_tick);
18976            __pi_det_tick += 1;
18977            return value;
18978        };
18979
18980        if (pi && pi.time) {
18981            pi.time.nowMs = () => __pi_det_now();
18982        }
18983
18984        const __pi_OriginalDate = Date;
18985        class PiDeterministicDate extends __pi_OriginalDate {
18986            constructor(...args) {
18987                if (args.length === 0) {
18988                    super(__pi_det_now());
18989                } else {
18990                    super(...args);
18991                }
18992            }
18993            static now() {
18994                return __pi_det_now();
18995            }
18996        }
18997        PiDeterministicDate.UTC = __pi_OriginalDate.UTC;
18998        PiDeterministicDate.parse = __pi_OriginalDate.parse;
18999        globalThis.Date = PiDeterministicDate;
19000    }
19001}
19002
19003if (__pi_det_random_raw !== undefined) {
19004    const __pi_det_random_val = Number(__pi_det_random_raw);
19005    if (Number.isFinite(__pi_det_random_val)) {
19006        Math.random = () => __pi_det_random_val;
19007    }
19008} else if (__pi_det_random_seed_raw !== undefined) {
19009    let __pi_det_state = Number(__pi_det_random_seed_raw);
19010    if (Number.isFinite(__pi_det_state)) {
19011        __pi_det_state = __pi_det_state >>> 0;
19012        Math.random = () => {
19013            __pi_det_state = (__pi_det_state * 1664525 + 1013904223) >>> 0;
19014            return __pi_det_state / 4294967296;
19015        };
19016    }
19017}
19018
19019// ============================================================================
19020// Minimal Web/Node polyfills for legacy extensions (best-effort)
19021// ============================================================================
19022
19023if (typeof globalThis.btoa !== 'function') {
19024    globalThis.btoa = (s) => {
19025        const bin = String(s === undefined || s === null ? '' : s);
19026        return __pi_base64_encode_native(bin);
19027    };
19028}
19029
19030if (typeof globalThis.atob !== 'function') {
19031    globalThis.atob = (s) => {
19032        const b64 = String(s === undefined || s === null ? '' : s);
19033        return __pi_base64_decode_native(b64);
19034    };
19035}
19036
19037if (typeof globalThis.TextEncoder === 'undefined') {
19038    class TextEncoder {
19039        encode(input) {
19040            const s = String(input === undefined || input === null ? '' : input);
19041            const bytes = [];
19042            for (let i = 0; i < s.length; i++) {
19043                let code = s.charCodeAt(i);
19044                if (code < 0x80) {
19045                    bytes.push(code);
19046                    continue;
19047                }
19048                if (code < 0x800) {
19049                    bytes.push(0xc0 | (code >> 6));
19050                    bytes.push(0x80 | (code & 0x3f));
19051                    continue;
19052                }
19053                if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
19054                    const next = s.charCodeAt(i + 1);
19055                    if (next >= 0xdc00 && next <= 0xdfff) {
19056                        const cp = ((code - 0xd800) << 10) + (next - 0xdc00) + 0x10000;
19057                        bytes.push(0xf0 | (cp >> 18));
19058                        bytes.push(0x80 | ((cp >> 12) & 0x3f));
19059                        bytes.push(0x80 | ((cp >> 6) & 0x3f));
19060                        bytes.push(0x80 | (cp & 0x3f));
19061                        i++;
19062                        continue;
19063                    }
19064                }
19065                bytes.push(0xe0 | (code >> 12));
19066                bytes.push(0x80 | ((code >> 6) & 0x3f));
19067                bytes.push(0x80 | (code & 0x3f));
19068            }
19069            return new Uint8Array(bytes);
19070        }
19071    }
19072    globalThis.TextEncoder = TextEncoder;
19073}
19074
19075if (typeof globalThis.TextDecoder === 'undefined') {
19076    class TextDecoder {
19077        constructor(encoding = 'utf-8') {
19078            this.encoding = encoding;
19079        }
19080
19081        decode(input, _opts) {
19082            if (input === undefined || input === null) return '';
19083            if (typeof input === 'string') return input;
19084
19085            let bytes;
19086            if (input instanceof ArrayBuffer) {
19087                bytes = new Uint8Array(input);
19088            } else if (ArrayBuffer.isView && ArrayBuffer.isView(input)) {
19089                bytes = new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
19090            } else if (Array.isArray(input)) {
19091                bytes = new Uint8Array(input);
19092            } else if (typeof input.length === 'number') {
19093                bytes = new Uint8Array(input);
19094            } else {
19095                return '';
19096            }
19097
19098            let outChunks = [];
19099            let chunk = [];
19100            for (let i = 0; i < bytes.length; ) {
19101                if (chunk.length >= 4096) {
19102                    outChunks.push(String.fromCharCode.apply(null, chunk));
19103                    chunk.length = 0;
19104                }
19105                const b0 = bytes[i++];
19106                if (b0 < 0x80) {
19107                    chunk.push(b0);
19108                    continue;
19109                }
19110                if ((b0 & 0xe0) === 0xc0) {
19111                    const b1 = bytes[i++] & 0x3f;
19112                    chunk.push(((b0 & 0x1f) << 6) | b1);
19113                    continue;
19114                }
19115                if ((b0 & 0xf0) === 0xe0) {
19116                    const b1 = bytes[i++] & 0x3f;
19117                    const b2 = bytes[i++] & 0x3f;
19118                    chunk.push(((b0 & 0x0f) << 12) | (b1 << 6) | b2);
19119                    continue;
19120                }
19121                if ((b0 & 0xf8) === 0xf0) {
19122                    const b1 = bytes[i++] & 0x3f;
19123                    const b2 = bytes[i++] & 0x3f;
19124                    const b3 = bytes[i++] & 0x3f;
19125                    let cp = ((b0 & 0x07) << 18) | (b1 << 12) | (b2 << 6) | b3;
19126                    cp -= 0x10000;
19127                    chunk.push(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
19128                    continue;
19129                }
19130            }
19131            if (chunk.length > 0) {
19132                outChunks.push(String.fromCharCode.apply(null, chunk));
19133            }
19134            return outChunks.join('');
19135        }
19136    }
19137
19138    globalThis.TextDecoder = TextDecoder;
19139}
19140
19141// structuredClone — deep clone using JSON round-trip
19142if (typeof globalThis.structuredClone === 'undefined') {
19143    globalThis.structuredClone = (value) => JSON.parse(JSON.stringify(value));
19144}
19145
19146// queueMicrotask — schedule a microtask
19147if (typeof globalThis.queueMicrotask === 'undefined') {
19148    globalThis.queueMicrotask = (fn) => Promise.resolve().then(fn);
19149}
19150
19151// performance.now() — high-resolution timer
19152if (typeof globalThis.performance === 'undefined') {
19153    const start = Date.now();
19154    globalThis.performance = { now: () => Date.now() - start, timeOrigin: start };
19155}
19156
19157if (typeof globalThis.URLSearchParams === 'undefined') {
19158    class URLSearchParams {
19159        constructor(init) {
19160            this._pairs = [];
19161            if (typeof init === 'string') {
19162                const s = init.replace(/^\?/, '');
19163                if (s.length > 0) {
19164                    for (const part of s.split('&')) {
19165                        const idx = part.indexOf('=');
19166                        if (idx === -1) {
19167                            this.append(decodeURIComponent(part), '');
19168                        } else {
19169                            const k = part.slice(0, idx);
19170                            const v = part.slice(idx + 1);
19171                            this.append(decodeURIComponent(k), decodeURIComponent(v));
19172                        }
19173                    }
19174                }
19175            } else if (Array.isArray(init)) {
19176                for (const entry of init) {
19177                    if (!entry) continue;
19178                    this.append(entry[0], entry[1]);
19179                }
19180            } else if (init && typeof init === 'object') {
19181                for (const k of Object.keys(init)) {
19182                    this.append(k, init[k]);
19183                }
19184            }
19185        }
19186
19187        append(key, value) {
19188            this._pairs.push([String(key), String(value)]);
19189        }
19190
19191        toString() {
19192            const out = [];
19193            for (const [k, v] of this._pairs) {
19194                out.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
19195            }
19196            return out.join('&');
19197        }
19198    }
19199
19200    globalThis.URLSearchParams = URLSearchParams;
19201}
19202
19203if (typeof globalThis.URL === 'undefined') {
19204    class URL {
19205        constructor(input, base) {
19206            const s = base ? new URL(base).href.replace(/\/[^/]*$/, '/') + String(input ?? '') : String(input ?? '');
19207            const m = s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]*)([^?#]*)(\?[^#]*)?(#.*)?$/);
19208            if (m) {
19209                this.protocol = m[1] + ':';
19210                const auth = m[2];
19211                const atIdx = auth.lastIndexOf('@');
19212                if (atIdx !== -1) {
19213                    const userinfo = auth.slice(0, atIdx);
19214                    const ci = userinfo.indexOf(':');
19215                    this.username = ci === -1 ? userinfo : userinfo.slice(0, ci);
19216                    this._pw = ci === -1 ? String() : userinfo.slice(ci + 1);
19217                    this.host = auth.slice(atIdx + 1);
19218                } else {
19219                    this.username = '';
19220                    this._pw = String();
19221                    this.host = auth;
19222                }
19223                const hi = this.host.indexOf(':');
19224                this.hostname = hi === -1 ? this.host : this.host.slice(0, hi);
19225                this.port = hi === -1 ? '' : this.host.slice(hi + 1);
19226                this.pathname = m[3] || '/';
19227                this.search = m[4] || '';
19228                this.hash = m[5] || '';
19229            } else {
19230                this.protocol = '';
19231                this.username = '';
19232                this._pw = String();
19233                this.host = '';
19234                this.hostname = '';
19235                this.port = '';
19236                this.pathname = s;
19237                this.search = '';
19238                this.hash = '';
19239            }
19240            this.searchParams = new globalThis.URLSearchParams(this.search.replace(/^\?/, ''));
19241            this.origin = this.protocol ? `${this.protocol}//${this.host}` : '';
19242            this.href = this.toString();
19243        }
19244        get password() {
19245            return this._pw;
19246        }
19247        set password(value) {
19248            this._pw = value == null ? String() : String(value);
19249        }
19250        toString() {
19251            const auth = this.username ? `${this.username}${this.password ? ':' + this.password : ''}@` : '';
19252            return this.protocol ? `${this.protocol}//${auth}${this.host}${this.pathname}${this.search}${this.hash}` : this.pathname;
19253        }
19254        toJSON() { return this.toString(); }
19255    }
19256    globalThis.URL = URL;
19257}
19258
19259if (typeof globalThis.Buffer === 'undefined') {
19260    class Buffer extends Uint8Array {
19261        static _normalizeSearchOffset(length, byteOffset) {
19262            if (byteOffset == null) return 0;
19263            const number = Number(byteOffset);
19264            if (Number.isNaN(number)) return 0;
19265            if (number === Infinity) return length;
19266            if (number === -Infinity) return 0;
19267            const offset = Math.trunc(number);
19268            if (offset < 0) return Math.max(length + offset, 0);
19269            if (offset > length) return length;
19270            return offset;
19271        }
19272        static from(input, encoding) {
19273            if (typeof input === 'string') {
19274                const enc = String(encoding || '').toLowerCase();
19275                if (enc === 'base64') {
19276                    const bin = __pi_base64_decode_native(input);
19277                    const out = new Buffer(bin.length);
19278                    for (let i = 0; i < bin.length; i++) {
19279                        out[i] = bin.charCodeAt(i) & 0xff;
19280                    }
19281                    return out;
19282                }
19283                if (enc === 'hex') {
19284                    const hex = input.replace(/[^0-9a-fA-F]/g, '');
19285                    const out = new Buffer(hex.length >> 1);
19286                    for (let i = 0; i < out.length; i++) {
19287                        out[i] = parseInt(hex.substr(i * 2, 2), 16);
19288                    }
19289                    return out;
19290                }
19291                const encoded = new TextEncoder().encode(input);
19292                const out = new Buffer(encoded.length);
19293                out.set(encoded);
19294                return out;
19295            }
19296            if (input instanceof ArrayBuffer) {
19297                const out = new Buffer(input.byteLength);
19298                out.set(new Uint8Array(input));
19299                return out;
19300            }
19301            if (ArrayBuffer.isView && ArrayBuffer.isView(input)) {
19302                const out = new Buffer(input.byteLength);
19303                out.set(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
19304                return out;
19305            }
19306            if (Array.isArray(input)) {
19307                const out = new Buffer(input.length);
19308                for (let i = 0; i < input.length; i++) out[i] = input[i] & 0xff;
19309                return out;
19310            }
19311            throw new Error('Buffer.from: unsupported input');
19312        }
19313        static alloc(size, fill) {
19314            const buf = new Buffer(size);
19315            if (fill !== undefined) buf.fill(typeof fill === 'number' ? fill : 0);
19316            return buf;
19317        }
19318        static allocUnsafe(size) { return new Buffer(size); }
19319        static isBuffer(obj) { return obj instanceof Buffer; }
19320        static isEncoding(enc) {
19321            return ['utf8','utf-8','ascii','latin1','binary','base64','hex','ucs2','ucs-2','utf16le','utf-16le'].includes(String(enc).toLowerCase());
19322        }
19323        static byteLength(str, encoding) {
19324            if (typeof str !== 'string') return str.length || 0;
19325            const enc = String(encoding || 'utf8').toLowerCase();
19326            if (enc === 'base64') return Math.ceil(str.length * 3 / 4);
19327            if (enc === 'hex') return str.length >> 1;
19328            return new TextEncoder().encode(str).length;
19329        }
19330        static concat(list, totalLength) {
19331            if (!Array.isArray(list) || list.length === 0) return Buffer.alloc(0);
19332            const total = totalLength !== undefined ? totalLength : list.reduce((s, b) => s + b.length, 0);
19333            const out = Buffer.alloc(total);
19334            let offset = 0;
19335            for (const buf of list) {
19336                if (offset >= total) break;
19337                const src = buf instanceof Uint8Array ? buf : Buffer.from(buf);
19338                const copyLen = Math.min(src.length, total - offset);
19339                out.set(src.subarray(0, copyLen), offset);
19340                offset += copyLen;
19341            }
19342            return out;
19343        }
19344        static compare(a, b) {
19345            const len = Math.min(a.length, b.length);
19346            for (let i = 0; i < len; i++) {
19347                if (a[i] < b[i]) return -1;
19348                if (a[i] > b[i]) return 1;
19349            }
19350            if (a.length < b.length) return -1;
19351            if (a.length > b.length) return 1;
19352            return 0;
19353        }
19354        toString(encoding, start, end) {
19355            const s = start || 0;
19356            const e = end !== undefined ? end : this.length;
19357            const view = this.subarray(s, e);
19358            const enc = String(encoding || 'utf8').toLowerCase();
19359            if (enc === 'base64') {
19360                if (typeof globalThis.__pi_base64_encode_bytes_native === 'function') {
19361                    return __pi_base64_encode_bytes_native(view);
19362                }
19363                let binaryChunks = [];
19364                let chunk = [];
19365                for (let i = 0; i < view.length; i++) {
19366                    chunk.push(view[i]);
19367                    if (chunk.length >= 4096) {
19368                        binaryChunks.push(String.fromCharCode.apply(null, chunk));
19369                        chunk.length = 0;
19370                    }
19371                }
19372                if (chunk.length > 0) {
19373                    binaryChunks.push(String.fromCharCode.apply(null, chunk));
19374                }
19375                return __pi_base64_encode_native(binaryChunks.join(''));
19376            }
19377            if (enc === 'hex') {
19378                const hexArr = new Array(view.length);
19379                for (let i = 0; i < view.length; i++) {
19380                    hexArr[i] = (view[i] < 16 ? '0' : '') + view[i].toString(16);
19381                }
19382                return hexArr.join('');
19383            }
19384            return new TextDecoder().decode(view);
19385        }
19386        toJSON() {
19387            return { type: 'Buffer', data: Array.from(this) };
19388        }
19389        equals(other) {
19390            if (this.length !== other.length) return false;
19391            for (let i = 0; i < this.length; i++) {
19392                if (this[i] !== other[i]) return false;
19393            }
19394            return true;
19395        }
19396        compare(other) { return Buffer.compare(this, other); }
19397        copy(target, targetStart, sourceStart, sourceEnd) {
19398            const ts = targetStart || 0;
19399            const ss = sourceStart || 0;
19400            const se = sourceEnd !== undefined ? sourceEnd : this.length;
19401            const src = this.subarray(ss, se);
19402            const copyLen = Math.min(src.length, target.length - ts);
19403            target.set(src.subarray(0, copyLen), ts);
19404            return copyLen;
19405        }
19406        slice(start, end) {
19407            const sliced = super.slice(start, end);
19408            const buf = new Buffer(sliced.length);
19409            buf.set(sliced);
19410            return buf;
19411        }
19412        indexOf(value, byteOffset, encoding) {
19413            let offset = Buffer._normalizeSearchOffset(this.length, byteOffset);
19414            let searchEncoding = encoding;
19415            if (typeof byteOffset === 'string') {
19416                offset = 0;
19417                searchEncoding = byteOffset;
19418            }
19419            if (typeof value === 'number') {
19420                for (let i = offset; i < this.length; i++) {
19421                    if (this[i] === (value & 0xff)) return i;
19422                }
19423                return -1;
19424            }
19425            const needle = typeof value === 'string' ? Buffer.from(value, searchEncoding) : value;
19426            if (needle.length === 0) return offset;
19427            outer: for (let i = offset; i <= this.length - needle.length; i++) {
19428                for (let j = 0; j < needle.length; j++) {
19429                    if (this[i + j] !== needle[j]) continue outer;
19430                }
19431                return i;
19432            }
19433            return -1;
19434        }
19435        includes(value, byteOffset, encoding) {
19436            return this.indexOf(value, byteOffset, encoding) !== -1;
19437        }
19438        write(string, offset, length, encoding) {
19439            const o = offset || 0;
19440            const enc = encoding || 'utf8';
19441            const bytes = Buffer.from(string, enc);
19442            const len = length !== undefined ? Math.min(length, bytes.length) : bytes.length;
19443            const copyLen = Math.min(len, this.length - o);
19444            this.set(bytes.subarray(0, copyLen), o);
19445            return copyLen;
19446        }
19447        fill(value, offset, end, encoding) {
19448            const s = offset || 0;
19449            const e = end !== undefined ? end : this.length;
19450            const v = typeof value === 'number' ? (value & 0xff) : 0;
19451            for (let i = s; i < e; i++) this[i] = v;
19452            return this;
19453        }
19454        readUInt8(offset) { return this[offset || 0]; }
19455        readUInt16BE(offset) { const o = offset || 0; return (this[o] << 8) | this[o + 1]; }
19456        readUInt16LE(offset) { const o = offset || 0; return this[o] | (this[o + 1] << 8); }
19457        readUInt32BE(offset) { const o = offset || 0; return ((this[o] << 24) | (this[o+1] << 16) | (this[o+2] << 8) | this[o+3]) >>> 0; }
19458        readUInt32LE(offset) { const o = offset || 0; return (this[o] | (this[o+1] << 8) | (this[o+2] << 16) | (this[o+3] << 24)) >>> 0; }
19459        readInt8(offset) { const v = this[offset || 0]; return v > 127 ? v - 256 : v; }
19460        writeUInt8(value, offset) { this[offset || 0] = value & 0xff; return (offset || 0) + 1; }
19461        writeUInt16BE(value, offset) { const o = offset || 0; this[o] = (value >> 8) & 0xff; this[o+1] = value & 0xff; return o + 2; }
19462        writeUInt16LE(value, offset) { const o = offset || 0; this[o] = value & 0xff; this[o+1] = (value >> 8) & 0xff; return o + 2; }
19463        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; }
19464        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; }
19465    }
19466    globalThis.Buffer = Buffer;
19467}
19468
19469if (typeof globalThis.crypto === 'undefined') {
19470    globalThis.crypto = {};
19471}
19472
19473if (typeof globalThis.crypto.getRandomValues !== 'function') {
19474    globalThis.crypto.getRandomValues = (arr) => {
19475        const len = Number(arr && arr.length ? arr.length : 0);
19476        const bytes = __pi_crypto_bytes_to_array(__pi_crypto_random_bytes_native(len));
19477        for (let i = 0; i < len; i++) {
19478            arr[i] = bytes[i] || 0;
19479        }
19480        return arr;
19481    };
19482}
19483
19484if (!globalThis.crypto.subtle) {
19485    globalThis.crypto.subtle = {};
19486}
19487
19488if (typeof globalThis.crypto.subtle.digest !== 'function') {
19489    globalThis.crypto.subtle.digest = async (algorithm, data) => {
19490        const name = typeof algorithm === 'string' ? algorithm : (algorithm && algorithm.name ? algorithm.name : '');
19491        const upper = String(name).toUpperCase();
19492        if (upper !== 'SHA-256') {
19493            throw new Error('crypto.subtle.digest: only SHA-256 is supported');
19494        }
19495        const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
19496        const hex = __pi_crypto_hash_native('sha256', bytes, 'hex');
19497        const out = new Uint8Array(hex.length / 2);
19498        for (let i = 0; i < out.length; i++) {
19499            out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
19500        }
19501        return out.buffer;
19502    };
19503}
19504
19505if (typeof globalThis.crypto.randomUUID !== 'function') {
19506    globalThis.crypto.randomUUID = () => {
19507        const bytes = __pi_crypto_bytes_to_array(__pi_crypto_random_bytes_native(16));
19508        while (bytes.length < 16) bytes.push(0);
19509        bytes[6] = (bytes[6] & 0x0f) | 0x40;
19510        bytes[8] = (bytes[8] & 0x3f) | 0x80;
19511        const hex = Array.from(bytes, (b) => (b & 0xff).toString(16).padStart(2, '0')).join('');
19512        return (
19513            hex.slice(0, 8) +
19514            '-' +
19515            hex.slice(8, 12) +
19516            '-' +
19517            hex.slice(12, 16) +
19518            '-' +
19519            hex.slice(16, 20) +
19520            '-' +
19521            hex.slice(20)
19522        );
19523    };
19524}
19525
19526if (typeof globalThis.process === 'undefined') {
19527    const rawPlatform =
19528        __pi_env_get_native('PI_PLATFORM') ||
19529        __pi_env_get_native('OSTYPE') ||
19530        __pi_env_get_native('OS') ||
19531        'linux';
19532    // Normalize to Node.js conventions: strip version suffix from OSTYPE
19533    // (e.g. darwin24.0 -> darwin, linux-gnu -> linux, msys -> win32)
19534    const platform = (() => {
19535        const s = String(rawPlatform).replace(/[0-9].*$/, '').split('-')[0].toLowerCase();
19536        if (s === 'darwin') return 'darwin';
19537        if (s === 'msys' || s === 'cygwin' || s === 'windows_nt') return 'win32';
19538        return s || 'linux';
19539    })();
19540    const detHome = __pi_env_get_native('PI_DETERMINISTIC_HOME');
19541    const detCwd = __pi_env_get_native('PI_DETERMINISTIC_CWD');
19542
19543    const envProxy = new Proxy(
19544        {},
19545        {
19546            get(_target, prop) {
19547                if (typeof prop !== 'string') return undefined;
19548                if (prop === 'HOME' && detHome) return detHome;
19549                const value = __pi_env_get_native(prop);
19550                return value === null || value === undefined ? undefined : value;
19551            },
19552            set(_target, prop, _value) {
19553                // Read-only in PiJS — silently ignore writes
19554                return typeof prop === 'string';
19555            },
19556            deleteProperty(_target, prop) {
19557                // Read-only — silently ignore deletes
19558                return typeof prop === 'string';
19559            },
19560            has(_target, prop) {
19561                if (typeof prop !== 'string') return false;
19562                if (prop === 'HOME' && detHome) return true;
19563                const value = __pi_env_get_native(prop);
19564                return value !== null && value !== undefined;
19565            },
19566            ownKeys() {
19567                // Cannot enumerate real env — return empty
19568                return [];
19569            },
19570            getOwnPropertyDescriptor(_target, prop) {
19571                if (typeof prop !== 'string') return undefined;
19572                const value = __pi_env_get_native(prop);
19573                if (value === null || value === undefined) return undefined;
19574                return { value, writable: false, enumerable: true, configurable: true };
19575            },
19576        },
19577    );
19578
19579    // stdout/stderr that route through console output
19580    function makeWritable(level) {
19581        return {
19582            write(chunk) {
19583                if (typeof __pi_console_output_native === 'function') {
19584                    __pi_console_output_native(level, String(chunk));
19585                }
19586                return true;
19587            },
19588            end() { return this; },
19589            on() { return this; },
19590            once() { return this; },
19591            pipe() { return this; },
19592            isTTY: false,
19593        };
19594    }
19595
19596    // Event listener registry
19597    const __evtMap = Object.create(null);
19598    function __on(event, fn) {
19599        if (!__evtMap[event]) __evtMap[event] = [];
19600        __evtMap[event].push(fn);
19601        return globalThis.process;
19602    }
19603    function __off(event, fn) {
19604        const arr = __evtMap[event];
19605        if (!arr) return globalThis.process;
19606        const idx = arr.indexOf(fn);
19607        if (idx >= 0) arr.splice(idx, 1);
19608        return globalThis.process;
19609    }
19610
19611    const startMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
19612
19613    globalThis.process = {
19614        env: envProxy,
19615        argv: __pi_process_args_native(),
19616        cwd: () => detCwd || __pi_process_cwd_native(),
19617        platform: String(platform).split('-')[0],
19618        arch: __pi_env_get_native('PI_TARGET_ARCH') || 'x64',
19619        version: 'v20.0.0',
19620        versions: { node: '20.0.0', v8: '0.0.0', modules: '0' },
19621        pid: 1,
19622        ppid: 0,
19623        title: 'pi',
19624        execPath: (typeof __pi_process_execpath_native === 'function')
19625            ? __pi_process_execpath_native()
19626            : '/usr/bin/pi',
19627        execArgv: [],
19628        stdout: makeWritable('log'),
19629        stderr: makeWritable('error'),
19630        stdin: { on() { return this; }, once() { return this; }, read() {}, resume() { return this; }, pause() { return this; } },
19631        nextTick: (fn, ...args) => { Promise.resolve().then(() => fn(...args)); },
19632        hrtime: Object.assign((prev) => {
19633            const nowMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
19634            const secs = Math.floor(nowMs / 1000);
19635            const nanos = Math.floor((nowMs % 1000) * 1e6);
19636            if (Array.isArray(prev) && prev.length >= 2) {
19637                let ds = secs - prev[0];
19638                let dn = nanos - prev[1];
19639                if (dn < 0) { ds -= 1; dn += 1e9; }
19640                return [ds, dn];
19641            }
19642            return [secs, nanos];
19643        }, {
19644            bigint: () => {
19645                const nowMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
19646                return BigInt(Math.floor(nowMs * 1e6));
19647            },
19648        }),
19649        kill: (pid, sig) => {
19650            const impl = globalThis.__pi_process_kill_impl;
19651            if (typeof impl === 'function') {
19652                return impl(pid, sig);
19653            }
19654            const err = new Error('process.kill is not available in PiJS');
19655            err.code = 'ENOSYS';
19656            throw err;
19657        },
19658        exit: (code) => {
19659            const exitCode = code === undefined ? 0 : Number(code);
19660            // Fire exit listeners
19661            const listeners = __evtMap['exit'];
19662            if (listeners) {
19663                for (const fn of listeners.slice()) {
19664                    try { fn(exitCode); } catch (_) {}
19665                }
19666            }
19667            // Signal native side
19668            if (typeof __pi_process_exit_native === 'function') {
19669                __pi_process_exit_native(exitCode);
19670            }
19671            const err = new Error('process.exit(' + exitCode + ')');
19672            err.code = 'ERR_PROCESS_EXIT';
19673            err.exitCode = exitCode;
19674            throw err;
19675        },
19676        chdir: (_dir) => {
19677            const err = new Error('process.chdir is not supported in PiJS');
19678            err.code = 'ENOSYS';
19679            throw err;
19680        },
19681        uptime: () => {
19682            const nowMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
19683            return Math.floor((nowMs - startMs) / 1000);
19684        },
19685        memoryUsage: () => ({
19686            rss: 0, heapTotal: 0, heapUsed: 0, external: 0, arrayBuffers: 0,
19687        }),
19688        cpuUsage: (_prev) => ({ user: 0, system: 0 }),
19689        emitWarning: (msg) => {
19690            if (typeof __pi_console_output_native === 'function') {
19691                __pi_console_output_native('warn', 'Warning: ' + msg);
19692            }
19693        },
19694        release: { name: 'node', lts: 'PiJS' },
19695        config: { variables: {} },
19696        features: {},
19697        on: __on,
19698        addListener: __on,
19699        off: __off,
19700        removeListener: __off,
19701        once(event, fn) {
19702            const wrapped = (...args) => {
19703                __off(event, wrapped);
19704                fn(...args);
19705            };
19706            wrapped._original = fn;
19707            __on(event, wrapped);
19708            return globalThis.process;
19709        },
19710        removeAllListeners(event) {
19711            if (event) { delete __evtMap[event]; }
19712            else { for (const k in __evtMap) delete __evtMap[k]; }
19713            return globalThis.process;
19714        },
19715        listeners(event) {
19716            return (__evtMap[event] || []).slice();
19717        },
19718        emit(event, ...args) {
19719            const listeners = __evtMap[event];
19720            if (!listeners || listeners.length === 0) return false;
19721            for (const fn of listeners.slice()) {
19722                try { fn(...args); } catch (_) {}
19723            }
19724            return true;
19725        },
19726    };
19727
19728    try { Object.freeze(envProxy); } catch (_) {}
19729    try { Object.freeze(globalThis.process.argv); } catch (_) {}
19730    // Do NOT freeze globalThis.process — extensions may need to monkey-patch it
19731}
19732
19733// Node.js global alias compatibility.
19734if (typeof globalThis.global === 'undefined') {
19735    globalThis.global = globalThis;
19736}
19737
19738if (typeof globalThis.Bun === 'undefined') {
19739    const __pi_bun_require = (specifier) => {
19740        try {
19741            if (typeof require === 'function') {
19742                return require(specifier);
19743            }
19744        } catch (_) {}
19745        return null;
19746    };
19747
19748    const __pi_bun_fs = () => __pi_bun_require('node:fs');
19749    const __pi_bun_import_fs = () => import('node:fs');
19750    const __pi_bun_child_process = () => __pi_bun_require('node:child_process');
19751
19752    const __pi_bun_to_uint8 = (value) => {
19753        if (value instanceof Uint8Array) {
19754            return value;
19755        }
19756        if (value instanceof ArrayBuffer) {
19757            return new Uint8Array(value);
19758        }
19759        if (ArrayBuffer.isView && ArrayBuffer.isView(value)) {
19760            return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
19761        }
19762        if (typeof value === 'string') {
19763            return new TextEncoder().encode(value);
19764        }
19765        if (value === undefined || value === null) {
19766            return new Uint8Array();
19767        }
19768        return new TextEncoder().encode(String(value));
19769    };
19770
19771    const __pi_bun_make_text_stream = (fetchText) => ({
19772        async text() {
19773            return fetchText();
19774        },
19775        async arrayBuffer() {
19776            const text = await fetchText();
19777            const bytes = new TextEncoder().encode(String(text ?? ''));
19778            return bytes.buffer;
19779        },
19780    });
19781
19782    const __pi_bun_schedule = (fn) => {
19783        if (typeof globalThis.queueMicrotask === 'function') {
19784            globalThis.queueMicrotask(fn);
19785            return;
19786        }
19787        if (typeof globalThis.setTimeout === 'function') {
19788            globalThis.setTimeout(fn, 0);
19789            return;
19790        }
19791        try {
19792            Promise.resolve().then(fn);
19793        } catch (_) {
19794            fn();
19795        }
19796    };
19797
19798    const __pi_bun_make_emitter = (target) => {
19799        const listeners = {};
19800        target.on = function(event, handler) {
19801            if (typeof handler !== 'function') return this;
19802            if (!listeners[event]) listeners[event] = [];
19803            listeners[event].push(handler);
19804            return this;
19805        };
19806        target.once = function(event, handler) {
19807            if (typeof handler !== 'function') return this;
19808            const wrapper = (...args) => {
19809                this.off(event, wrapper);
19810                handler(...args);
19811            };
19812            return this.on(event, wrapper);
19813        };
19814        target.off = function(event, handler) {
19815            const list = listeners[event];
19816            if (!list) return this;
19817            if (!handler) {
19818                delete listeners[event];
19819                return this;
19820            }
19821            const idx = list.indexOf(handler);
19822            if (idx >= 0) list.splice(idx, 1);
19823            return this;
19824        };
19825        target.emit = function(event, ...args) {
19826            const list = listeners[event];
19827            if (!list || list.length === 0) return false;
19828            list.slice().forEach((fn) => {
19829                try { fn(...args); } catch (_) {}
19830            });
19831            return true;
19832        };
19833        target.addEventListener = target.on;
19834        target.removeEventListener = target.off;
19835        return target;
19836    };
19837
19838    const __pi_bun_make_socket = (options = {}, handler = null) => {
19839        const socket = __pi_bun_make_emitter({});
19840        socket.remoteAddress = String(options.hostname || options.host || '127.0.0.1');
19841        socket.remotePort = Number(options.port || 0);
19842        socket.localAddress = '127.0.0.1';
19843        socket.localPort = 0;
19844        socket.readyState = 'opening';
19845        socket.connecting = true;
19846        socket.data = Object.prototype.hasOwnProperty.call(options, 'data') ? options.data : null;
19847        socket.binaryType = options.binaryType || 'buffer';
19848        socket.closed = false;
19849        socket.write = (data) => {
19850            if (typeof data === 'string') return data.length;
19851            if (data && typeof data.byteLength === 'number') return data.byteLength;
19852            if (data && typeof data.length === 'number') return data.length;
19853            return 0;
19854        };
19855        socket.end = (data) => {
19856            if (data !== undefined) socket.write(data);
19857            socket.close();
19858        };
19859        socket.close = () => {
19860            if (socket.closed) return;
19861            socket.closed = true;
19862            socket.readyState = 'closed';
19863            socket.connecting = false;
19864            if (handler && typeof handler.close === 'function') {
19865                try { handler.close(socket); } catch (_) {}
19866            }
19867            const evt = { type: 'close' };
19868            if (typeof socket.onclose === 'function') socket.onclose(evt);
19869            socket.emit('close', evt);
19870        };
19871        socket.ref = () => socket;
19872        socket.unref = () => socket;
19873        return socket;
19874    };
19875
19876    const Bun = {};
19877
19878    Bun.argv = Array.isArray(globalThis.process && globalThis.process.argv)
19879        ? globalThis.process.argv.slice()
19880        : [];
19881
19882    Bun.file = (path) => {
19883        const targetPath = String(path ?? '');
19884        return {
19885            path: targetPath,
19886            name: targetPath,
19887            async exists() {
19888                const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
19889                return Boolean(fs && typeof fs.existsSync === 'function' && fs.existsSync(targetPath));
19890            },
19891            async text() {
19892                const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
19893                if (!fs || typeof fs.readFileSync !== 'function') {
19894                    throw new Error('Bun.file.text: node:fs is unavailable');
19895                }
19896                return String(fs.readFileSync(targetPath, 'utf8'));
19897            },
19898            async arrayBuffer() {
19899                const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
19900                if (!fs || typeof fs.readFileSync !== 'function') {
19901                    throw new Error('Bun.file.arrayBuffer: node:fs is unavailable');
19902                }
19903                const bytes = __pi_bun_to_uint8(fs.readFileSync(targetPath));
19904                return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
19905            },
19906            async json() {
19907                return JSON.parse(await this.text());
19908            },
19909        };
19910    };
19911
19912    Bun.write = async (destination, data) => {
19913        const targetPath =
19914            destination && typeof destination === 'object' && typeof destination.path === 'string'
19915                ? destination.path
19916                : String(destination ?? '');
19917        if (!targetPath) {
19918            throw new Error('Bun.write: destination path is required');
19919        }
19920        const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
19921        if (!fs || typeof fs.writeFileSync !== 'function') {
19922            throw new Error('Bun.write: node:fs is unavailable');
19923        }
19924
19925        let payload = data;
19926        if (payload && typeof payload === 'object' && typeof payload.text === 'function') {
19927            payload = payload.text();
19928        }
19929        if (payload && typeof payload === 'object' && typeof payload.arrayBuffer === 'function') {
19930            payload = payload.arrayBuffer();
19931        }
19932        if (payload && typeof payload.then === 'function') {
19933            payload = await payload;
19934        }
19935
19936        const bytes = __pi_bun_to_uint8(payload);
19937        fs.writeFileSync(targetPath, bytes);
19938        return bytes.byteLength;
19939    };
19940
19941    Bun.connect = (rawOptions = {}) => {
19942        const options = rawOptions && typeof rawOptions === 'object' ? rawOptions : {};
19943        const handler = options.socket && typeof options.socket === 'object' ? options.socket : null;
19944        const socket = __pi_bun_make_socket(options, handler);
19945        __pi_bun_schedule(() => {
19946            if (socket.closed) return;
19947            socket.connecting = false;
19948            socket.readyState = 'open';
19949            const evt = { type: 'open' };
19950            if (typeof socket.onopen === 'function') socket.onopen(evt);
19951            socket.emit('open', evt);
19952            if (handler && typeof handler.open === 'function') {
19953                try { handler.open(socket); } catch (_) {}
19954            }
19955        });
19956        return socket;
19957    };
19958
19959    Bun.listen = (rawOptions = {}) => {
19960        const options = rawOptions && typeof rawOptions === 'object' ? rawOptions : {};
19961        const handler = options.socket && typeof options.socket === 'object' ? options.socket : null;
19962        const server = __pi_bun_make_emitter({});
19963        server.closed = false;
19964        server.listening = false;
19965        server.port = Number(options.port || 0);
19966        server.hostname = String(options.hostname || '0.0.0.0');
19967        if (options.unix !== undefined) server.unix = options.unix;
19968        server.stop = () => {
19969            if (server.closed) return;
19970            server.closed = true;
19971            server.listening = false;
19972            const evt = { type: 'close' };
19973            if (typeof server.onclose === 'function') server.onclose(evt);
19974            server.emit('close', evt);
19975        };
19976        server.reload = () => server;
19977        server.ref = () => server;
19978        server.unref = () => server;
19979        __pi_bun_schedule(() => {
19980            if (server.closed) return;
19981            server.listening = true;
19982            const evt = { type: 'listening' };
19983            if (typeof server.onlistening === 'function') server.onlistening(evt);
19984            server.emit('listening', evt);
19985            if (handler && typeof handler.open === 'function') {
19986                const socket = __pi_bun_make_socket(options, handler);
19987                try { handler.open(socket); } catch (_) {}
19988            }
19989        });
19990        return server;
19991    };
19992
19993    Bun.which = (command) => {
19994        const name = String(command ?? '').trim();
19995        if (!name) return null;
19996        const cwd =
19997            globalThis.process && typeof globalThis.process.cwd === 'function'
19998                ? globalThis.process.cwd()
19999                : '/';
20000        const raw = __pi_exec_sync_native('which', JSON.stringify([name]), cwd, 2000, undefined);
20001        try {
20002            const parsed = JSON.parse(raw || '{}');
20003            if (Number(parsed && parsed.code) !== 0) return null;
20004            const out = String((parsed && parsed.stdout) || '').trim();
20005            return out ? out.split('\n')[0] : null;
20006        } catch (_) {
20007            return null;
20008        }
20009    };
20010
20011    Bun.spawn = (commandOrArgv, rawOptions = {}) => {
20012        const options = rawOptions && typeof rawOptions === 'object' ? rawOptions : {};
20013
20014        let command = '';
20015        let args = [];
20016        if (Array.isArray(commandOrArgv)) {
20017            if (commandOrArgv.length === 0) {
20018                throw new Error('Bun.spawn: command is required');
20019            }
20020            command = String(commandOrArgv[0] ?? '');
20021            args = commandOrArgv.slice(1).map((arg) => String(arg ?? ''));
20022        } else {
20023            command = String(commandOrArgv ?? '');
20024            if (Array.isArray(options.args)) {
20025                args = options.args.map((arg) => String(arg ?? ''));
20026            }
20027        }
20028
20029        if (!command.trim()) {
20030            throw new Error('Bun.spawn: command is required');
20031        }
20032
20033        const spawnOptions = {
20034            shell: false,
20035            stdio: [
20036                options.stdin === 'pipe' ? 'pipe' : 'ignore',
20037                options.stdout === 'ignore' ? 'ignore' : 'pipe',
20038                options.stderr === 'ignore' ? 'ignore' : 'pipe',
20039            ],
20040        };
20041        if (typeof options.cwd === 'string' && options.cwd.trim().length > 0) {
20042            spawnOptions.cwd = options.cwd;
20043        }
20044        if (
20045            typeof options.timeout === 'number' &&
20046            Number.isFinite(options.timeout) &&
20047            options.timeout >= 0
20048        ) {
20049            spawnOptions.timeout = Math.floor(options.timeout);
20050        }
20051
20052        const childProcess = __pi_bun_child_process();
20053        if (childProcess && typeof childProcess.spawn === 'function') {
20054            const child = childProcess.spawn(command, args, spawnOptions);
20055            let stdoutChunks = [];
20056            let stderrChunks = [];
20057
20058            if (child && child.stdout && typeof child.stdout.on === 'function') {
20059                child.stdout.on('data', (chunk) => {
20060                    stdoutChunks.push(String(chunk ?? ''));
20061                });
20062            }
20063            if (child && child.stderr && typeof child.stderr.on === 'function') {
20064                child.stderr.on('data', (chunk) => {
20065                    stderrChunks.push(String(chunk ?? ''));
20066                });
20067            }
20068
20069            const exited = new Promise((resolve, reject) => {
20070                let settled = false;
20071                child.on('error', (err) => {
20072                    if (settled) return;
20073                    settled = true;
20074                    reject(err instanceof Error ? err : new Error(String(err)));
20075                });
20076                child.on('close', (code) => {
20077                    if (settled) return;
20078                    settled = true;
20079                    resolve(typeof code === 'number' ? code : null);
20080                });
20081            });
20082
20083            return {
20084                pid: typeof child.pid === 'number' ? child.pid : 0,
20085                stdin: child.stdin || null,
20086                stdout: __pi_bun_make_text_stream(async () => {
20087                    await exited.catch(() => null);
20088                    return stdoutChunks.join('');
20089                }),
20090                stderr: __pi_bun_make_text_stream(async () => {
20091                    await exited.catch(() => null);
20092                    return stderrChunks.join('');
20093                }),
20094                exited,
20095                kill(signal) {
20096                    try {
20097                        return child.kill(signal);
20098                    } catch (_) {
20099                        return false;
20100                    }
20101                },
20102                ref() { return this; },
20103                unref() { return this; },
20104            };
20105        }
20106
20107        // Fallback path if node:child_process is unavailable in context.
20108        const execOptions = {};
20109        if (spawnOptions.cwd !== undefined) execOptions.cwd = spawnOptions.cwd;
20110        if (spawnOptions.timeout !== undefined) execOptions.timeout = spawnOptions.timeout;
20111        const execPromise = pi.exec(command, args, execOptions);
20112        let killed = false;
20113
20114        const exited = execPromise.then(
20115            (result) => (killed ? null : (Number(result && result.code) || 0)),
20116            () => (killed ? null : 1),
20117        );
20118
20119        return {
20120            pid: 0,
20121            stdin: null,
20122            stdout: __pi_bun_make_text_stream(async () => {
20123                try {
20124                    const result = await execPromise;
20125                    return String((result && result.stdout) || '');
20126                } catch (_) {
20127                    return '';
20128                }
20129            }),
20130            stderr: __pi_bun_make_text_stream(async () => {
20131                try {
20132                    const result = await execPromise;
20133                    return String((result && result.stderr) || '');
20134                } catch (_) {
20135                    return '';
20136                }
20137            }),
20138            exited,
20139            kill() {
20140                killed = true;
20141                return true;
20142            },
20143            ref() { return this; },
20144            unref() { return this; },
20145        };
20146    };
20147
20148    globalThis.Bun = Bun;
20149}
20150
20151if (typeof globalThis.setTimeout !== 'function') {
20152    globalThis.setTimeout = (callback, delay, ...args) => {
20153        const ms = Number(delay || 0);
20154        const timer_id = __pi_set_timeout_native(ms <= 0 ? 0 : Math.floor(ms));
20155        const captured_id = __pi_current_extension_id;
20156        __pi_register_timer(timer_id, () => {
20157            const prev = __pi_current_extension_id;
20158            __pi_current_extension_id = captured_id;
20159            try {
20160                callback(...args);
20161            } catch (e) {
20162                console.error('setTimeout callback error:', e);
20163            } finally {
20164                __pi_current_extension_id = prev;
20165            }
20166        });
20167        return timer_id;
20168    };
20169}
20170
20171if (typeof globalThis.clearTimeout !== 'function') {
20172    globalThis.clearTimeout = (timer_id) => {
20173        __pi_unregister_timer(timer_id);
20174        try {
20175            __pi_clear_timeout_native(timer_id);
20176        } catch (_) {}
20177    };
20178}
20179
20180// setInterval polyfill using setTimeout
20181const __pi_intervals = new Map();
20182let __pi_interval_id = 0;
20183
20184if (typeof globalThis.setInterval !== 'function') {
20185    globalThis.setInterval = (callback, delay, ...args) => {
20186        const ms = Math.max(0, Number(delay || 0));
20187        const id = ++__pi_interval_id;
20188        const captured_id = __pi_current_extension_id;
20189        const run = () => {
20190            if (!__pi_intervals.has(id)) return;
20191            const prev = __pi_current_extension_id;
20192            __pi_current_extension_id = captured_id;
20193            try {
20194                callback(...args);
20195            } catch (e) {
20196                console.error('setInterval callback error:', e);
20197            } finally {
20198                __pi_current_extension_id = prev;
20199            }
20200            if (__pi_intervals.has(id)) {
20201                __pi_intervals.set(id, globalThis.setTimeout(run, ms));
20202            }
20203        };
20204        __pi_intervals.set(id, globalThis.setTimeout(run, ms));
20205        return id;
20206    };
20207}
20208
20209if (typeof globalThis.clearInterval !== 'function') {
20210    globalThis.clearInterval = (id) => {
20211        const timerId = __pi_intervals.get(id);
20212        if (timerId !== undefined) {
20213            globalThis.clearTimeout(timerId);
20214            __pi_intervals.delete(id);
20215        }
20216    };
20217}
20218
20219if (typeof globalThis.fetch !== 'function') {
20220    const __pi_fetch_body_bytes_to_base64 = (value) => {
20221        let bytes = null;
20222        if (value instanceof Uint8Array) {
20223            bytes = value;
20224        } else if (value instanceof ArrayBuffer) {
20225            bytes = new Uint8Array(value);
20226        } else if (ArrayBuffer.isView && ArrayBuffer.isView(value)) {
20227            bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
20228        }
20229        if (!bytes) return null;
20230        if (typeof globalThis.__pi_base64_encode_bytes_native === 'function') {
20231            return __pi_base64_encode_bytes_native(bytes);
20232        }
20233        let binaryChunks = [];
20234        let chunk = [];
20235        for (let i = 0; i < bytes.length; i++) {
20236            chunk.push(bytes[i]);
20237            if (chunk.length >= 4096) {
20238                binaryChunks.push(String.fromCharCode.apply(null, chunk));
20239                chunk.length = 0;
20240            }
20241        }
20242        if (chunk.length > 0) {
20243            binaryChunks.push(String.fromCharCode.apply(null, chunk));
20244        }
20245        return __pi_base64_encode_native(binaryChunks.join(''));
20246    };
20247
20248    class Headers {
20249        constructor(init) {
20250            this._map = {};
20251            if (init && typeof init === 'object') {
20252                if (Array.isArray(init)) {
20253                    for (const pair of init) {
20254                        if (pair && pair.length >= 2) this.set(pair[0], pair[1]);
20255                    }
20256                } else if (typeof init.forEach === 'function') {
20257                    init.forEach((v, k) => this.set(k, v));
20258                } else {
20259                    for (const k of Object.keys(init)) {
20260                        this.set(k, init[k]);
20261                    }
20262                }
20263            }
20264        }
20265
20266        get(name) {
20267            const key = String(name || '').toLowerCase();
20268            return this._map[key] === undefined ? null : this._map[key];
20269        }
20270
20271        set(name, value) {
20272            const key = String(name || '').toLowerCase();
20273            this._map[key] = String(value === undefined || value === null ? '' : value);
20274        }
20275
20276        entries() {
20277            return Object.entries(this._map);
20278        }
20279    }
20280
20281    class Response {
20282        constructor(bodyBytes, init) {
20283            const options = init && typeof init === 'object' ? init : {};
20284            this.status = Number(options.status || 0);
20285            this.ok = this.status >= 200 && this.status < 300;
20286            this.headers = new Headers(options.headers || {});
20287            this._bytes = bodyBytes || new Uint8Array();
20288            this.body = {
20289                getReader: () => {
20290                    let done = false;
20291                    return {
20292                        read: async () => {
20293                            if (done) return { done: true, value: undefined };
20294                            done = true;
20295                            return { done: false, value: this._bytes };
20296                        },
20297                        cancel: async () => {
20298                            done = true;
20299                        },
20300                        releaseLock: () => {},
20301                    };
20302                },
20303            };
20304        }
20305
20306        async text() {
20307            return new TextDecoder().decode(this._bytes);
20308        }
20309
20310        async json() {
20311            return JSON.parse(await this.text());
20312        }
20313
20314        async arrayBuffer() {
20315            const copy = new Uint8Array(this._bytes.length);
20316            copy.set(this._bytes);
20317            return copy.buffer;
20318        }
20319    }
20320
20321    globalThis.Headers = Headers;
20322    globalThis.Response = Response;
20323
20324    if (typeof globalThis.Event === 'undefined') {
20325        class Event {
20326            constructor(type, options) {
20327                const opts = options && typeof options === 'object' ? options : {};
20328                this.type = String(type || '');
20329                this.bubbles = !!opts.bubbles;
20330                this.cancelable = !!opts.cancelable;
20331                this.composed = !!opts.composed;
20332                this.defaultPrevented = false;
20333                this.target = null;
20334                this.currentTarget = null;
20335                this.timeStamp = Date.now();
20336            }
20337            preventDefault() {
20338                if (this.cancelable) this.defaultPrevented = true;
20339            }
20340            stopPropagation() {}
20341            stopImmediatePropagation() {}
20342        }
20343        globalThis.Event = Event;
20344    }
20345
20346    if (typeof globalThis.CustomEvent === 'undefined' && typeof globalThis.Event === 'function') {
20347        class CustomEvent extends globalThis.Event {
20348            constructor(type, options) {
20349                const opts = options && typeof options === 'object' ? options : {};
20350                super(type, opts);
20351                this.detail = opts.detail;
20352            }
20353        }
20354        globalThis.CustomEvent = CustomEvent;
20355    }
20356
20357    if (typeof globalThis.EventTarget === 'undefined') {
20358        class EventTarget {
20359            constructor() {
20360                this.__listeners = Object.create(null);
20361            }
20362            addEventListener(type, listener) {
20363                const key = String(type || '');
20364                if (!key || !listener) return;
20365                if (!this.__listeners[key]) this.__listeners[key] = [];
20366                if (!this.__listeners[key].includes(listener)) this.__listeners[key].push(listener);
20367            }
20368            removeEventListener(type, listener) {
20369                const key = String(type || '');
20370                const list = this.__listeners[key];
20371                if (!list || !listener) return;
20372                this.__listeners[key] = list.filter((fn) => fn !== listener);
20373            }
20374            dispatchEvent(event) {
20375                if (!event || typeof event.type !== 'string') return true;
20376                const key = event.type;
20377                const list = (this.__listeners[key] || []).slice();
20378                try {
20379                    event.target = this;
20380                    event.currentTarget = this;
20381                } catch (_) {}
20382                for (const listener of list) {
20383                    try {
20384                        if (typeof listener === 'function') listener.call(this, event);
20385                        else if (listener && typeof listener.handleEvent === 'function') listener.handleEvent(event);
20386                    } catch (_) {}
20387                }
20388                return !(event && event.defaultPrevented);
20389            }
20390        }
20391        globalThis.EventTarget = EventTarget;
20392    }
20393
20394    if (typeof globalThis.TransformStream === 'undefined') {
20395        class TransformStream {
20396            constructor(_transformer) {
20397                const queue = [];
20398                let closed = false;
20399                this.readable = {
20400                    getReader() {
20401                        return {
20402                            async read() {
20403                                if (queue.length > 0) {
20404                                    return { done: false, value: queue.shift() };
20405                                }
20406                                return { done: closed, value: undefined };
20407                            },
20408                            async cancel() {
20409                                closed = true;
20410                            },
20411                            releaseLock() {},
20412                        };
20413                    },
20414                };
20415                this.writable = {
20416                    getWriter() {
20417                        return {
20418                            async write(chunk) {
20419                                queue.push(chunk);
20420                            },
20421                            async close() {
20422                                closed = true;
20423                            },
20424                            async abort() {
20425                                closed = true;
20426                            },
20427                            releaseLock() {},
20428                        };
20429                    },
20430                };
20431            }
20432        }
20433        globalThis.TransformStream = TransformStream;
20434    }
20435
20436    // AbortController / AbortSignal polyfill — many npm packages check for these
20437    if (typeof globalThis.AbortController === 'undefined') {
20438        class AbortSignal {
20439            constructor() { this.aborted = false; this._listeners = []; }
20440            get reason() { return this.aborted ? (this._reason !== undefined ? this._reason : new Error('This operation was aborted')) : undefined; }
20441            addEventListener(type, fn) { if (type === 'abort') this._listeners.push(fn); }
20442            removeEventListener(type, fn) { if (type === 'abort') this._listeners = this._listeners.filter(f => f !== fn); }
20443            throwIfAborted() { if (this.aborted) throw this.reason; }
20444            static abort(reason) { const s = new AbortSignal(); s.aborted = true; s._reason = reason !== undefined ? reason : new Error('This operation was aborted'); return s; }
20445            static timeout(ms) { const s = new AbortSignal(); setTimeout(() => { s.aborted = true; s._reason = new Error('The operation was aborted due to timeout'); s._listeners.forEach(fn => fn()); }, ms); return s; }
20446        }
20447        class AbortController {
20448            constructor() { this.signal = new AbortSignal(); }
20449            abort(reason) { this.signal.aborted = true; this.signal._reason = reason; this.signal._listeners.forEach(fn => fn()); }
20450        }
20451        globalThis.AbortController = AbortController;
20452        globalThis.AbortSignal = AbortSignal;
20453    }
20454
20455    globalThis.fetch = async (input, init) => {
20456        const url = typeof input === 'string' ? input : String(input && input.url ? input.url : input);
20457        const options = init && typeof init === 'object' ? init : {};
20458        const method = options.method ? String(options.method) : 'GET';
20459
20460        const headers = {};
20461        if (options.headers && typeof options.headers === 'object') {
20462            if (options.headers instanceof Headers) {
20463                for (const [k, v] of options.headers.entries()) headers[k] = v;
20464            } else if (Array.isArray(options.headers)) {
20465                for (const pair of options.headers) {
20466                    if (pair && pair.length >= 2) headers[String(pair[0])] = String(pair[1]);
20467                }
20468            } else {
20469                for (const k of Object.keys(options.headers)) {
20470                    headers[k] = String(options.headers[k]);
20471                }
20472            }
20473        }
20474
20475        let body = undefined;
20476        let body_bytes = undefined;
20477        if (options.body !== undefined && options.body !== null) {
20478            const encoded = __pi_fetch_body_bytes_to_base64(options.body);
20479            if (encoded !== null) {
20480                body_bytes = encoded;
20481            } else {
20482                body = typeof options.body === 'string' ? options.body : String(options.body);
20483            }
20484        }
20485
20486        const request = { url, method, headers };
20487        if (body !== undefined) request.body = body;
20488        if (body_bytes !== undefined) request.body_bytes = body_bytes;
20489
20490        const resp = await pi.http(request);
20491        const status = resp && resp.status !== undefined ? Number(resp.status) : 0;
20492        const respHeaders = resp && resp.headers && typeof resp.headers === 'object' ? resp.headers : {};
20493
20494        let bytes = new Uint8Array();
20495        if (resp && resp.body_bytes) {
20496            const bin = __pi_base64_decode_native(String(resp.body_bytes));
20497            const out = new Uint8Array(bin.length);
20498            for (let i = 0; i < bin.length; i++) {
20499                out[i] = bin.charCodeAt(i) & 0xff;
20500            }
20501            bytes = out;
20502        } else if (resp && resp.body !== undefined && resp.body !== null) {
20503            bytes = new TextEncoder().encode(String(resp.body));
20504        }
20505
20506        return new Response(bytes, { status, headers: respHeaders });
20507    };
20508}
20509";
20510
20511#[cfg(test)]
20512#[allow(clippy::future_not_send)]
20513mod tests {
20514    use super::*;
20515    use crate::scheduler::DeterministicClock;
20516
20517    #[allow(clippy::future_not_send)]
20518    async fn get_global_json<C: SchedulerClock + 'static>(
20519        runtime: &PiJsRuntime<C>,
20520        name: &str,
20521    ) -> serde_json::Value {
20522        runtime
20523            .context
20524            .with(|ctx| {
20525                let global = ctx.globals();
20526                let value: Value<'_> = global.get(name)?;
20527                js_to_json(&value)
20528            })
20529            .await
20530            .expect("js context")
20531    }
20532
20533    #[allow(clippy::future_not_send)]
20534    async fn call_global_fn_json<C: SchedulerClock + 'static>(
20535        runtime: &PiJsRuntime<C>,
20536        name: &str,
20537    ) -> serde_json::Value {
20538        runtime
20539            .context
20540            .with(|ctx| {
20541                let global = ctx.globals();
20542                let function: Function<'_> = global.get(name)?;
20543                let value: Value<'_> = function.call(())?;
20544                js_to_json(&value)
20545            })
20546            .await
20547            .expect("js context")
20548    }
20549
20550    #[allow(clippy::future_not_send)]
20551    async fn runtime_with_sync_exec_enabled(
20552        clock: Arc<DeterministicClock>,
20553    ) -> PiJsRuntime<Arc<DeterministicClock>> {
20554        let config = PiJsRuntimeConfig {
20555            allow_unsafe_sync_exec: true,
20556            ..PiJsRuntimeConfig::default()
20557        };
20558        PiJsRuntime::with_clock_and_config_with_policy(clock, config, None)
20559            .await
20560            .expect("create runtime")
20561    }
20562
20563    #[allow(clippy::future_not_send)]
20564    async fn drain_until_idle(
20565        runtime: &PiJsRuntime<Arc<DeterministicClock>>,
20566        clock: &Arc<DeterministicClock>,
20567    ) {
20568        for _ in 0..10_000 {
20569            if !runtime.has_pending() {
20570                break;
20571            }
20572
20573            let stats = runtime.tick().await.expect("tick");
20574            if stats.ran_macrotask {
20575                continue;
20576            }
20577
20578            let next_deadline = runtime.scheduler.borrow().next_timer_deadline();
20579            let Some(next_deadline) = next_deadline else {
20580                break;
20581            };
20582
20583            let now = runtime.now_ms();
20584            assert!(
20585                next_deadline > now,
20586                "expected future timer deadline (deadline={next_deadline}, now={now})"
20587            );
20588            clock.set(next_deadline);
20589        }
20590    }
20591
20592    #[test]
20593    fn extract_static_require_specifiers_skips_literals_and_comments() {
20594        let source = r#"
20595const fs = require("fs");
20596const text = "require('left-pad')";
20597const tpl = `require("ajv/dist/runtime/validation_error").default`;
20598// require("zlib")
20599/* require("tty") */
20600const path = require('path');
20601"#;
20602
20603        let specifiers = extract_static_require_specifiers(source);
20604        assert_eq!(specifiers, vec!["fs".to_string(), "path".to_string()]);
20605    }
20606
20607    #[test]
20608    fn maybe_cjs_to_esm_ignores_codegen_string_requires() {
20609        let source = r#"
20610const fs = require("fs");
20611const generated = `require("ajv/dist/runtime/validation_error").default`;
20612module.exports = { fs, generated };
20613"#;
20614
20615        let rewritten = maybe_cjs_to_esm(source);
20616        assert!(rewritten.contains(r#"from "fs";"#));
20617        assert!(!rewritten.contains(r#"from "ajv/dist/runtime/validation_error";"#));
20618    }
20619
20620    #[test]
20621    fn maybe_cjs_to_esm_leaves_doom_style_dirname_module_alone() {
20622        let source = r#"
20623import { dirname, join } from "node:path";
20624import { fileURLToPath } from "node:url";
20625
20626const __dirname = dirname(fileURLToPath(import.meta.url));
20627export const bundled = join(__dirname, "doom1.wad");
20628"#;
20629
20630        let rewritten = maybe_cjs_to_esm(source);
20631        assert!(
20632            !rewritten.contains("const __filename ="),
20633            "declared __dirname should not trigger __filename shim:\n{rewritten}"
20634        );
20635        assert!(
20636            !rewritten.contains("const __dirname = (() =>"),
20637            "declared __dirname should not be replaced:\n{rewritten}"
20638        );
20639    }
20640
20641    #[test]
20642    fn source_declares_binding_detects_inline_const_binding() {
20643        let source = r#"import { dirname } from "node:path"; const __dirname = dirname("/tmp/demo"); export const bundled = __dirname;"#;
20644        assert!(source_declares_binding(source, "__dirname"));
20645    }
20646
20647    #[test]
20648    fn source_declares_binding_ignores_nested_bindings() {
20649        let source = r#"
20650const topLevel = true;
20651function outer() {
20652    function module() {}
20653    var exports = {};
20654    const require = () => "nested";
20655    return { module, exports, require };
20656}
20657"#;
20658
20659        assert!(!source_declares_binding(source, "module"));
20660        assert!(!source_declares_binding(source, "exports"));
20661        assert!(!source_declares_binding(source, "require"));
20662    }
20663
20664    #[test]
20665    fn maybe_cjs_to_esm_injects_module_for_nested_false_positive_bundle_bindings() {
20666        let source = concat!(
20667            "\n",
20668            "var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);\n",
20669            "var require_demo = __commonJS((exports, module) => {\n",
20670            "    module.exports = { ok: true };\n",
20671            "});\n",
20672            "function outer() {\n",
20673            "    function module() {}\n",
20674            "    var exports = {};\n",
20675            "}\n",
20676            "export const loaded = require_demo();\n",
20677        );
20678
20679        let rewritten = maybe_cjs_to_esm(source);
20680        assert!(
20681            rewritten.contains("const module = { exports: {} };"),
20682            "nested bundle bindings must not suppress the CJS module shim:\n{rewritten}"
20683        );
20684        assert!(
20685            rewritten.contains("const exports = module.exports;"),
20686            "nested bundle bindings must not suppress the CJS exports shim:\n{rewritten}"
20687        );
20688    }
20689
20690    #[test]
20691    fn maybe_cjs_to_esm_leaves_inline_doom_style_dirname_module_alone() {
20692        let source = r#"import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); export const bundled = join(__dirname, "doom1.wad");"#;
20693
20694        let rewritten = maybe_cjs_to_esm(source);
20695        assert!(
20696            !rewritten.contains("const __filename ="),
20697            "inline declared __dirname should not trigger __filename shim:\n{rewritten}"
20698        );
20699        assert!(
20700            !rewritten.contains("const __dirname = (() =>"),
20701            "inline declared __dirname should not be replaced:\n{rewritten}"
20702        );
20703    }
20704
20705    #[test]
20706    fn maybe_cjs_to_esm_injects_dirname_without_filename_for_free_dirname() {
20707        let source = r"
20708export const currentDir = __dirname;
20709";
20710
20711        let rewritten = maybe_cjs_to_esm(source);
20712        assert!(
20713            rewritten.contains("const __dirname = (() =>"),
20714            "free __dirname should get a dirname shim:\n{rewritten}"
20715        );
20716        assert!(
20717            !rewritten.contains("const __filename ="),
20718            "free __dirname alone should not force a __filename shim:\n{rewritten}"
20719        );
20720    }
20721
20722    #[test]
20723    fn extract_import_names_handles_default_plus_named_imports() {
20724        let source = r#"
20725import Ajv, {
20726  KeywordDefinition,
20727  type AnySchema,
20728  ValidationError as AjvValidationError,
20729} from "ajv";
20730"#;
20731
20732        let names = extract_import_names(source, "ajv");
20733        assert_eq!(
20734            names,
20735            vec![
20736                "KeywordDefinition".to_string(),
20737                "ValidationError".to_string()
20738            ]
20739        );
20740    }
20741
20742    #[test]
20743    fn extract_builtin_import_names_collects_node_aliases() {
20744        let source = r#"
20745import { isIP } from "net";
20746import { isIPv4 as netIsIpv4 } from "node:net";
20747"#;
20748        let names = extract_builtin_import_names(source, "node:net", "node:net");
20749        assert_eq!(
20750            names.into_iter().collect::<Vec<_>>(),
20751            vec!["isIP".to_string(), "isIPv4".to_string()]
20752        );
20753    }
20754
20755    #[test]
20756    fn builtin_overlay_generation_scopes_exports_per_importing_module() {
20757        let temp_dir = tempfile::tempdir().expect("tempdir");
20758        let base_a = temp_dir.path().join("a.mjs");
20759        let base_b = temp_dir.path().join("b.mjs");
20760        std::fs::write(&base_a, r#"import { isIP } from "net";"#).expect("write a");
20761        std::fs::write(&base_b, r#"import { isIPv6 } from "node:net";"#).expect("write b");
20762
20763        let mut state = PiJsModuleState::new();
20764        let overlay_a = maybe_register_builtin_compat_overlay(
20765            &mut state,
20766            base_a.to_string_lossy().as_ref(),
20767            "net",
20768            "node:net",
20769        )
20770        .expect("overlay key for a");
20771        let overlay_b = maybe_register_builtin_compat_overlay(
20772            &mut state,
20773            base_b.to_string_lossy().as_ref(),
20774            "node:net",
20775            "node:net",
20776        )
20777        .expect("overlay key for b");
20778        assert!(overlay_a.starts_with("pijs-compat://builtin/node:net/"));
20779        assert!(overlay_b.starts_with("pijs-compat://builtin/node:net/"));
20780        assert_ne!(overlay_a, overlay_b);
20781
20782        let exported_names_a = state
20783            .dynamic_virtual_named_exports
20784            .get(&overlay_a)
20785            .expect("export names for a");
20786        assert!(exported_names_a.contains("isIP"));
20787        assert!(!exported_names_a.contains("isIPv6"));
20788
20789        let exported_names_b = state
20790            .dynamic_virtual_named_exports
20791            .get(&overlay_b)
20792            .expect("export names for b");
20793        assert!(exported_names_b.contains("isIPv6"));
20794        assert!(!exported_names_b.contains("isIP"));
20795
20796        let overlay_source_a = state
20797            .dynamic_virtual_modules
20798            .get(&overlay_a)
20799            .expect("overlay source for a");
20800        assert!(overlay_source_a.contains(r#"import * as __pijs_builtin_ns from "node:net";"#));
20801        assert!(overlay_source_a.contains("export const isIP ="));
20802        assert!(!overlay_source_a.contains("export const isIPv6 ="));
20803
20804        let overlay_source_b = state
20805            .dynamic_virtual_modules
20806            .get(&overlay_b)
20807            .expect("overlay source for b");
20808        assert!(overlay_source_b.contains("export const isIPv6 ="));
20809        assert!(!overlay_source_b.contains("export const isIP ="));
20810    }
20811
20812    #[test]
20813    fn hostcall_completions_run_before_due_timers() {
20814        let clock = Arc::new(ManualClock::new(1_000));
20815        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
20816
20817        let _timer = loop_state.set_timeout(0);
20818        loop_state.enqueue_hostcall_completion("call-1");
20819
20820        let mut seen = Vec::new();
20821        let result = loop_state.tick(|task| seen.push(task.kind), || false);
20822
20823        assert!(result.ran_macrotask);
20824        assert_eq!(
20825            seen,
20826            vec![MacrotaskKind::HostcallComplete {
20827                call_id: "call-1".to_string()
20828            }]
20829        );
20830    }
20831
20832    #[test]
20833    fn hostcall_request_queue_spills_to_overflow_with_stable_order() {
20834        fn req(id: usize) -> HostcallRequest {
20835            HostcallRequest {
20836                call_id: format!("call-{id}"),
20837                kind: HostcallKind::Log,
20838                payload: serde_json::json!({ "n": id }),
20839                trace_id: u64::try_from(id).unwrap_or(u64::MAX),
20840                extension_id: Some("ext.queue".to_string()),
20841            }
20842        }
20843
20844        let mut queue = HostcallRequestQueue::with_capacities(2, 4);
20845        assert!(matches!(
20846            queue.push_back(req(0)),
20847            HostcallQueueEnqueueResult::FastPath { .. }
20848        ));
20849        assert!(matches!(
20850            queue.push_back(req(1)),
20851            HostcallQueueEnqueueResult::FastPath { .. }
20852        ));
20853        assert!(matches!(
20854            queue.push_back(req(2)),
20855            HostcallQueueEnqueueResult::OverflowPath { .. }
20856        ));
20857
20858        let snapshot = queue.snapshot();
20859        assert_eq!(snapshot.fast_depth, 2);
20860        assert_eq!(snapshot.overflow_depth, 1);
20861        assert_eq!(snapshot.total_depth, 3);
20862        assert_eq!(snapshot.overflow_enqueued_total, 1);
20863
20864        let drained = queue.drain_all();
20865        let drained_ids: Vec<_> = drained.into_iter().map(|item| item.call_id).collect();
20866        assert_eq!(
20867            drained_ids,
20868            vec![
20869                "call-0".to_string(),
20870                "call-1".to_string(),
20871                "call-2".to_string()
20872            ]
20873        );
20874    }
20875
20876    #[test]
20877    fn hostcall_request_queue_rejects_when_overflow_capacity_reached() {
20878        fn req(id: usize) -> HostcallRequest {
20879            HostcallRequest {
20880                call_id: format!("reject-{id}"),
20881                kind: HostcallKind::Log,
20882                payload: serde_json::json!({ "n": id }),
20883                trace_id: u64::try_from(id).unwrap_or(u64::MAX),
20884                extension_id: None,
20885            }
20886        }
20887
20888        let mut queue = HostcallRequestQueue::with_capacities(1, 1);
20889        assert!(matches!(
20890            queue.push_back(req(0)),
20891            HostcallQueueEnqueueResult::FastPath { .. }
20892        ));
20893        assert!(matches!(
20894            queue.push_back(req(1)),
20895            HostcallQueueEnqueueResult::OverflowPath { .. }
20896        ));
20897        let reject = queue.push_back(req(2));
20898        assert!(matches!(
20899            reject,
20900            HostcallQueueEnqueueResult::Rejected { .. }
20901        ));
20902
20903        let snapshot = queue.snapshot();
20904        assert_eq!(snapshot.total_depth, 2);
20905        assert_eq!(snapshot.overflow_depth, 1);
20906        assert_eq!(snapshot.overflow_rejected_total, 1);
20907    }
20908
20909    #[test]
20910    fn timers_order_by_deadline_then_schedule_seq() {
20911        let clock = Arc::new(ManualClock::new(0));
20912        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock.clone()));
20913
20914        let t1 = loop_state.set_timeout(10);
20915        let t2 = loop_state.set_timeout(10);
20916        let t3 = loop_state.set_timeout(5);
20917        clock.set(10);
20918
20919        let mut fired = Vec::new();
20920        for _ in 0..3 {
20921            loop_state.tick(
20922                |task| {
20923                    if let MacrotaskKind::TimerFired { timer_id } = task.kind {
20924                        fired.push(timer_id);
20925                    }
20926                },
20927                || false,
20928            );
20929        }
20930
20931        assert_eq!(fired, vec![t3, t1, t2]);
20932    }
20933
20934    #[test]
20935    fn clear_timeout_prevents_fire() {
20936        let clock = Arc::new(ManualClock::new(0));
20937        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock.clone()));
20938
20939        let timer_id = loop_state.set_timeout(5);
20940        assert!(loop_state.clear_timeout(timer_id));
20941        clock.set(10);
20942
20943        let mut fired = Vec::new();
20944        let result = loop_state.tick(
20945            |task| {
20946                if let MacrotaskKind::TimerFired { timer_id } = task.kind {
20947                    fired.push(timer_id);
20948                }
20949            },
20950            || false,
20951        );
20952
20953        assert!(!result.ran_macrotask);
20954        assert!(fired.is_empty());
20955    }
20956
20957    #[test]
20958    fn clear_timeout_nonexistent_returns_false_and_does_not_pollute_cancelled_set() {
20959        let clock = Arc::new(ManualClock::new(0));
20960        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
20961
20962        assert!(!loop_state.clear_timeout(42));
20963        assert!(
20964            loop_state.cancelled_timers.is_empty(),
20965            "unknown timer ids should not be retained"
20966        );
20967    }
20968
20969    #[test]
20970    fn clear_timeout_double_cancel_returns_false() {
20971        let clock = Arc::new(ManualClock::new(0));
20972        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
20973
20974        let timer_id = loop_state.set_timeout(10);
20975        assert!(loop_state.clear_timeout(timer_id));
20976        assert!(!loop_state.clear_timeout(timer_id));
20977    }
20978
20979    #[test]
20980    fn pi_event_loop_timer_id_saturates_at_u64_max() {
20981        let clock = Arc::new(ManualClock::new(0));
20982        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
20983        loop_state.next_timer_id = u64::MAX;
20984
20985        let first = loop_state.set_timeout(10);
20986        let second = loop_state.set_timeout(20);
20987
20988        assert_eq!(first, u64::MAX);
20989        assert_eq!(second, u64::MAX);
20990    }
20991
20992    #[test]
20993    fn audit_ledger_sequence_saturates_at_u64_max() {
20994        let mut ledger = AuditLedger::new();
20995        ledger.next_sequence = u64::MAX;
20996
20997        let first = ledger.append(
20998            1_700_000_000_000,
20999            "ext-a",
21000            AuditEntryKind::Analysis,
21001            "first".to_string(),
21002            Vec::new(),
21003        );
21004        let second = ledger.append(
21005            1_700_000_000_100,
21006            "ext-a",
21007            AuditEntryKind::ProposalGenerated,
21008            "second".to_string(),
21009            Vec::new(),
21010        );
21011
21012        assert_eq!(first, u64::MAX);
21013        assert_eq!(second, u64::MAX);
21014        assert_eq!(ledger.len(), 2);
21015    }
21016
21017    #[test]
21018    fn microtasks_drain_to_fixpoint_after_macrotask() {
21019        let clock = Arc::new(ManualClock::new(0));
21020        let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
21021
21022        loop_state.enqueue_inbound_event("evt-1");
21023
21024        let mut drain_calls = 0;
21025        let result = loop_state.tick(
21026            |_task| {},
21027            || {
21028                drain_calls += 1;
21029                drain_calls <= 2
21030            },
21031        );
21032
21033        assert!(result.ran_macrotask);
21034        assert_eq!(result.microtasks_drained, 2);
21035        assert_eq!(drain_calls, 3);
21036    }
21037
21038    #[test]
21039    fn compile_module_source_reports_missing_file() {
21040        let temp_dir = tempfile::tempdir().expect("tempdir");
21041        let missing_path = temp_dir.path().join("missing.js");
21042        let err = compile_module_source(
21043            &HashMap::new(),
21044            &HashMap::new(),
21045            missing_path.to_string_lossy().as_ref(),
21046        )
21047        .expect_err("missing module should error");
21048        let message = err.to_string();
21049        assert!(
21050            message.contains("Module is not a file"),
21051            "unexpected error: {message}"
21052        );
21053    }
21054
21055    #[test]
21056    fn compile_module_source_reports_unsupported_extension() {
21057        let temp_dir = tempfile::tempdir().expect("tempdir");
21058        let bad_path = temp_dir.path().join("module.txt");
21059        std::fs::write(&bad_path, "hello").expect("write module.txt");
21060
21061        let err = compile_module_source(
21062            &HashMap::new(),
21063            &HashMap::new(),
21064            bad_path.to_string_lossy().as_ref(),
21065        )
21066        .expect_err("unsupported extension should error");
21067        let message = err.to_string();
21068        assert!(
21069            message.contains("Unsupported module extension"),
21070            "unexpected error: {message}"
21071        );
21072    }
21073
21074    #[test]
21075    fn module_cache_key_changes_when_virtual_module_changes() {
21076        let static_modules = HashMap::new();
21077        let mut dynamic_modules = HashMap::new();
21078        dynamic_modules.insert("pijs://virt".to_string(), "export const x = 1;".to_string());
21079
21080        let key_before = module_cache_key(&static_modules, &dynamic_modules, "pijs://virt")
21081            .expect("virtual key should exist");
21082
21083        dynamic_modules.insert("pijs://virt".to_string(), "export const x = 2;".to_string());
21084        let key_after = module_cache_key(&static_modules, &dynamic_modules, "pijs://virt")
21085            .expect("virtual key should exist");
21086
21087        assert_ne!(key_before, key_after);
21088    }
21089
21090    #[test]
21091    fn module_cache_key_changes_when_file_size_changes() {
21092        let temp_dir = tempfile::tempdir().expect("tempdir");
21093        let module_path = temp_dir.path().join("module.js");
21094        std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
21095        let name = module_path.to_string_lossy().to_string();
21096
21097        let key_before =
21098            module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("file key");
21099
21100        std::fs::write(&module_path, "export const xyz = 123456;\n").expect("rewrite module");
21101        let key_after =
21102            module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("file key");
21103
21104        assert_ne!(key_before, key_after);
21105    }
21106
21107    #[test]
21108    fn load_compiled_module_source_tracks_hit_miss_and_invalidation_counters() {
21109        let temp_dir = tempfile::tempdir().expect("tempdir");
21110        let module_path = temp_dir.path().join("module.js");
21111        std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
21112        let name = module_path.to_string_lossy().to_string();
21113
21114        let mut state = PiJsModuleState::new();
21115
21116        let _first = load_compiled_module_source(&mut state, &name).expect("first compile");
21117        assert_eq!(state.module_cache_counters.hits, 0);
21118        assert_eq!(state.module_cache_counters.misses, 1);
21119        assert_eq!(state.module_cache_counters.invalidations, 0);
21120        assert_eq!(state.compiled_sources.len(), 1);
21121
21122        let _second = load_compiled_module_source(&mut state, &name).expect("cache hit");
21123        assert_eq!(state.module_cache_counters.hits, 1);
21124        assert_eq!(state.module_cache_counters.misses, 1);
21125        assert_eq!(state.module_cache_counters.invalidations, 0);
21126
21127        std::fs::write(&module_path, "export const xyz = 123456;\n").expect("rewrite module");
21128        let _third = load_compiled_module_source(&mut state, &name).expect("recompile");
21129        assert_eq!(state.module_cache_counters.hits, 1);
21130        assert_eq!(state.module_cache_counters.misses, 2);
21131        assert_eq!(state.module_cache_counters.invalidations, 1);
21132    }
21133
21134    #[test]
21135    fn load_compiled_module_source_uses_disk_cache_between_states() {
21136        let temp_dir = tempfile::tempdir().expect("tempdir");
21137        let cache_dir = temp_dir.path().join("cache");
21138        let module_path = temp_dir.path().join("module.js");
21139        std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
21140        let name = module_path.to_string_lossy().to_string();
21141
21142        let mut first_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir.clone()));
21143        let first = load_compiled_module_source(&mut first_state, &name).expect("first compile");
21144        assert_eq!(first_state.module_cache_counters.misses, 1);
21145        assert_eq!(first_state.module_cache_counters.disk_hits, 0);
21146
21147        let key = module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("file key");
21148        let cache_path = disk_cache_path(&cache_dir, &key);
21149        assert!(
21150            cache_path.exists(),
21151            "expected persisted cache at {cache_path:?}"
21152        );
21153
21154        let mut second_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir));
21155        let second =
21156            load_compiled_module_source(&mut second_state, &name).expect("load from disk cache");
21157        assert_eq!(second_state.module_cache_counters.disk_hits, 1);
21158        assert_eq!(second_state.module_cache_counters.misses, 0);
21159        assert_eq!(second_state.module_cache_counters.hits, 0);
21160        assert_eq!(first, second);
21161    }
21162
21163    #[test]
21164    fn load_compiled_module_source_disk_cache_invalidates_when_file_changes() {
21165        let temp_dir = tempfile::tempdir().expect("tempdir");
21166        let cache_dir = temp_dir.path().join("cache");
21167        let module_path = temp_dir.path().join("module.js");
21168        std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
21169        let name = module_path.to_string_lossy().to_string();
21170
21171        let mut prime_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir.clone()));
21172        let first = load_compiled_module_source(&mut prime_state, &name).expect("first compile");
21173        let first_key = module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("key");
21174
21175        std::fs::write(
21176            &module_path,
21177            "export const xyz = 1234567890;\nexport const more = true;\n",
21178        )
21179        .expect("rewrite module");
21180        let second_key = module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("key");
21181        assert_ne!(first_key, second_key);
21182
21183        let mut second_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir));
21184        let second = load_compiled_module_source(&mut second_state, &name).expect("recompile");
21185        assert_eq!(second_state.module_cache_counters.disk_hits, 0);
21186        assert_eq!(second_state.module_cache_counters.misses, 1);
21187        assert_ne!(first, second);
21188    }
21189
21190    #[test]
21191    fn warm_reset_clears_extension_registry_state() {
21192        futures::executor::block_on(async {
21193            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
21194                .await
21195                .expect("create runtime");
21196
21197            runtime
21198                .eval(
21199                    r#"
21200                    __pi_begin_extension("ext.reset", { name: "ext.reset" });
21201                    pi.registerTool({
21202                        name: "warm_reset_tool",
21203                        execute: async (_callId, _input) => ({ ok: true }),
21204                    });
21205                    pi.registerCommand("warm_reset_cmd", {
21206                        handler: async (_args, _ctx) => ({ ok: true }),
21207                    });
21208                    pi.on("startup", async () => {});
21209                    __pi_end_extension();
21210                    "#,
21211                )
21212                .await
21213                .expect("register extension state");
21214
21215            let before = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
21216            assert_eq!(before["extensions"], serde_json::json!(1));
21217            assert_eq!(before["tools"], serde_json::json!(1));
21218            assert_eq!(before["commands"], serde_json::json!(1));
21219
21220            let report = runtime
21221                .reset_for_warm_reload()
21222                .await
21223                .expect("warm reset should run");
21224            assert!(report.reused, "expected warm reuse, got report: {report:?}");
21225            assert!(
21226                report.reason_code.is_none(),
21227                "unexpected warm-reset reason: {:?}",
21228                report.reason_code
21229            );
21230
21231            let after = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
21232            assert_eq!(after["extensions"], serde_json::json!(0));
21233            assert_eq!(after["tools"], serde_json::json!(0));
21234            assert_eq!(after["commands"], serde_json::json!(0));
21235            assert_eq!(after["hooks"], serde_json::json!(0));
21236            assert_eq!(after["pendingTasks"], serde_json::json!(0));
21237            assert_eq!(after["pendingHostcalls"], serde_json::json!(0));
21238        });
21239    }
21240
21241    #[test]
21242    fn warm_reset_reports_pending_rust_work() {
21243        futures::executor::block_on(async {
21244            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
21245                .await
21246                .expect("create runtime");
21247            let _timer = runtime.set_timeout(10);
21248
21249            let report = runtime
21250                .reset_for_warm_reload()
21251                .await
21252                .expect("warm reset should return report");
21253            assert!(!report.reused);
21254            assert_eq!(report.reason_code.as_deref(), Some("pending_rust_work"));
21255        });
21256    }
21257
21258    #[test]
21259    fn warm_reset_reports_pending_js_work() {
21260        futures::executor::block_on(async {
21261            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
21262                .await
21263                .expect("create runtime");
21264
21265            runtime
21266                .eval(
21267                    r#"
21268                    __pi_tasks.set("pending-task", { status: "pending" });
21269                    "#,
21270                )
21271                .await
21272                .expect("inject pending JS task");
21273
21274            let report = runtime
21275                .reset_for_warm_reload()
21276                .await
21277                .expect("warm reset should return report");
21278            assert!(!report.reused);
21279            assert_eq!(report.reason_code.as_deref(), Some("pending_js_work"));
21280
21281            let after = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
21282            assert_eq!(after["pendingTasks"], serde_json::json!(0));
21283        });
21284    }
21285
21286    #[test]
21287    #[allow(clippy::too_many_lines)]
21288    fn reset_transient_state_preserves_compiled_cache_and_clears_transient_state() {
21289        futures::executor::block_on(async {
21290            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
21291                .await
21292                .expect("create runtime");
21293
21294            let cache_key = "pijs://virtual".to_string();
21295            {
21296                let mut state = runtime.module_state.borrow_mut();
21297                let extension_root = PathBuf::from("/tmp/ext-root");
21298                state.extension_roots.push(extension_root.clone());
21299                state
21300                    .extension_root_tiers
21301                    .insert(extension_root.clone(), ProxyStubSourceTier::Community);
21302                state
21303                    .extension_root_scopes
21304                    .insert(extension_root, "@scope".to_string());
21305                state
21306                    .dynamic_virtual_modules
21307                    .insert(cache_key.clone(), "export const v = 1;".to_string());
21308                let mut exports = BTreeSet::new();
21309                exports.insert("v".to_string());
21310                state
21311                    .dynamic_virtual_named_exports
21312                    .insert(cache_key.clone(), exports);
21313                state.compiled_sources.insert(
21314                    cache_key.clone(),
21315                    CompiledModuleCacheEntry {
21316                        cache_key: Some("cache-v1".to_string()),
21317                        source: b"compiled-source".to_vec().into(),
21318                    },
21319                );
21320                state.module_cache_counters = ModuleCacheCounters {
21321                    hits: 3,
21322                    misses: 4,
21323                    invalidations: 5,
21324                    disk_hits: 6,
21325                };
21326            }
21327
21328            runtime
21329                .hostcall_queue
21330                .borrow_mut()
21331                .push_back(HostcallRequest {
21332                    call_id: "call-1".to_string(),
21333                    kind: HostcallKind::Tool {
21334                        name: "read".to_string(),
21335                    },
21336                    payload: serde_json::json!({}),
21337                    trace_id: 1,
21338                    extension_id: Some("ext.reset".to_string()),
21339                });
21340            runtime
21341                .hostcall_tracker
21342                .borrow_mut()
21343                .register("call-1".to_string(), Some(42), 0);
21344            runtime
21345                .hostcalls_total
21346                .store(11, std::sync::atomic::Ordering::SeqCst);
21347            runtime
21348                .hostcalls_timed_out
21349                .store(2, std::sync::atomic::Ordering::SeqCst);
21350            runtime
21351                .tick_counter
21352                .store(7, std::sync::atomic::Ordering::SeqCst);
21353
21354            runtime.reset_transient_state();
21355
21356            {
21357                let state = runtime.module_state.borrow();
21358                assert!(state.extension_roots.is_empty());
21359                assert!(state.canonical_extension_roots.is_empty());
21360                assert!(state.extension_root_tiers.is_empty());
21361                assert!(state.extension_root_scopes.is_empty());
21362                assert!(state.extension_roots_by_id.is_empty());
21363                assert!(state.extension_roots_without_id.is_empty());
21364                assert!(state.dynamic_virtual_modules.is_empty());
21365                assert!(state.dynamic_virtual_named_exports.is_empty());
21366
21367                let cached = state
21368                    .compiled_sources
21369                    .get(&cache_key)
21370                    .expect("compiled source should persist across reset");
21371                assert_eq!(cached.cache_key.as_deref(), Some("cache-v1"));
21372                assert_eq!(cached.source.as_ref(), b"compiled-source");
21373
21374                assert_eq!(state.module_cache_counters.hits, 0);
21375                assert_eq!(state.module_cache_counters.misses, 0);
21376                assert_eq!(state.module_cache_counters.invalidations, 0);
21377                assert_eq!(state.module_cache_counters.disk_hits, 0);
21378            }
21379
21380            assert!(runtime.hostcall_queue.borrow().is_empty());
21381            assert_eq!(runtime.hostcall_tracker.borrow().pending_count(), 0);
21382            assert_eq!(
21383                runtime
21384                    .hostcalls_total
21385                    .load(std::sync::atomic::Ordering::SeqCst),
21386                0
21387            );
21388            assert_eq!(
21389                runtime
21390                    .hostcalls_timed_out
21391                    .load(std::sync::atomic::Ordering::SeqCst),
21392                0
21393            );
21394            assert_eq!(
21395                runtime
21396                    .tick_counter
21397                    .load(std::sync::atomic::Ordering::SeqCst),
21398                0
21399            );
21400        });
21401    }
21402
21403    #[test]
21404    fn warm_isolate_pool_tracks_created_and_reset_counts() {
21405        let cache_dir = tempfile::tempdir().expect("tempdir");
21406        let template = PiJsRuntimeConfig {
21407            cwd: "/tmp/warm-pool".to_string(),
21408            args: vec!["--flag".to_string()],
21409            env: HashMap::from([("PI_POOL".to_string(), "yes".to_string())]),
21410            deny_env: false,
21411            disk_cache_dir: Some(cache_dir.path().join("module-cache")),
21412            ..PiJsRuntimeConfig::default()
21413        };
21414        let expected_disk_cache_dir = template.disk_cache_dir.clone();
21415
21416        let pool = WarmIsolatePool::new(template.clone());
21417        assert_eq!(pool.created_count(), 0);
21418        assert_eq!(pool.reset_count(), 0);
21419
21420        let cfg_a = pool.make_config();
21421        let cfg_b = pool.make_config();
21422        assert_eq!(pool.created_count(), 2);
21423        assert_eq!(cfg_a.cwd, template.cwd);
21424        assert_eq!(cfg_b.cwd, template.cwd);
21425        assert_eq!(cfg_a.args, template.args);
21426        assert_eq!(cfg_a.env.get("PI_POOL"), Some(&"yes".to_string()));
21427        assert_eq!(cfg_a.deny_env, template.deny_env);
21428        assert_eq!(cfg_a.disk_cache_dir, expected_disk_cache_dir);
21429
21430        pool.record_reset();
21431        pool.record_reset();
21432        assert_eq!(pool.reset_count(), 2);
21433    }
21434
21435    #[test]
21436    fn warm_reset_clears_canonical_and_per_extension_roots() {
21437        futures::executor::block_on(async {
21438            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
21439                .await
21440                .expect("create runtime");
21441
21442            let temp_dir = tempfile::tempdir().expect("tempdir");
21443            let root = temp_dir.path().join("ext");
21444            std::fs::create_dir_all(&root).expect("mkdir ext");
21445            runtime.add_extension_root_with_id(root.clone(), Some("ext.reset.roots"));
21446
21447            let report = runtime
21448                .reset_for_warm_reload()
21449                .await
21450                .expect("warm reset should run");
21451            assert!(report.reused, "expected warm reuse, got report: {report:?}");
21452
21453            let state = runtime.module_state.borrow();
21454            assert!(state.extension_roots.is_empty());
21455            assert!(state.canonical_extension_roots.is_empty());
21456            assert!(state.extension_roots_by_id.is_empty());
21457            assert!(state.extension_roots_without_id.is_empty());
21458        });
21459    }
21460
21461    #[test]
21462    fn resolver_error_messages_are_classified_deterministically() {
21463        assert_eq!(
21464            unsupported_module_specifier_message("left-pad"),
21465            "Package module specifiers are not supported in PiJS: left-pad"
21466        );
21467        assert_eq!(
21468            unsupported_module_specifier_message("https://example.com/mod.js"),
21469            "Network module imports are not supported in PiJS: https://example.com/mod.js"
21470        );
21471        assert_eq!(
21472            unsupported_module_specifier_message("pi:internal/foo"),
21473            "Unsupported module specifier: pi:internal/foo"
21474        );
21475    }
21476
21477    #[test]
21478    fn resolve_module_path_uses_documented_candidate_order() {
21479        let temp_dir = tempfile::tempdir().expect("tempdir");
21480        let root = temp_dir.path();
21481        let base = root.join("entry.ts");
21482        std::fs::write(&base, "export {};\n").expect("write base");
21483
21484        let pkg_dir = root.join("pkg");
21485        std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg");
21486        let pkg_index_js = pkg_dir.join("index.js");
21487        let pkg_index_ts = pkg_dir.join("index.ts");
21488        std::fs::write(&pkg_index_js, "export const js = true;\n").expect("write index.js");
21489        std::fs::write(&pkg_index_ts, "export const ts = true;\n").expect("write index.ts");
21490
21491        let module_js = root.join("module.js");
21492        let module_ts = root.join("module.ts");
21493        std::fs::write(&module_js, "export const js = true;\n").expect("write module.js");
21494        std::fs::write(&module_ts, "export const ts = true;\n").expect("write module.ts");
21495
21496        let only_json = root.join("only_json.json");
21497        std::fs::write(&only_json, "{\"ok\":true}\n").expect("write only_json.json");
21498
21499        let mode = RepairMode::default();
21500        let roots = [root.to_path_buf()];
21501        let canonical_roots = roots
21502            .iter()
21503            .map(|p| crate::extensions::safe_canonicalize(p))
21504            .collect::<Vec<_>>();
21505
21506        let resolved_pkg = resolve_module_path(
21507            base.to_string_lossy().as_ref(),
21508            "./pkg",
21509            mode,
21510            &canonical_roots,
21511        )
21512        .expect("resolve ./pkg");
21513        assert_eq!(resolved_pkg, pkg_index_ts);
21514
21515        let resolved_module = resolve_module_path(
21516            base.to_string_lossy().as_ref(),
21517            "./module",
21518            mode,
21519            &canonical_roots,
21520        )
21521        .expect("resolve ./module");
21522        assert_eq!(resolved_module, module_ts);
21523
21524        let resolved_json = resolve_module_path(
21525            base.to_string_lossy().as_ref(),
21526            "./only_json",
21527            mode,
21528            &canonical_roots,
21529        )
21530        .expect("resolve ./only_json");
21531        assert_eq!(resolved_json, only_json);
21532
21533        let file_url = format!("file://{}", module_ts.display());
21534        let resolved_file_url = resolve_module_path(
21535            base.to_string_lossy().as_ref(),
21536            &file_url,
21537            mode,
21538            &canonical_roots,
21539        )
21540        .expect("file://");
21541        assert_eq!(resolved_file_url, module_ts);
21542    }
21543
21544    #[test]
21545    fn resolve_module_path_blocks_file_url_outside_extension_root() {
21546        let temp_dir = tempfile::tempdir().expect("tempdir");
21547        let root = temp_dir.path();
21548        let extension_root = root.join("ext");
21549        std::fs::create_dir_all(&extension_root).expect("mkdir ext");
21550
21551        let base = extension_root.join("index.ts");
21552        std::fs::write(&base, "export {};\n").expect("write base");
21553
21554        let outside = root.join("secret.ts");
21555        std::fs::write(&outside, "export const secret  = 1;\n").expect("write outside");
21556
21557        let mode = RepairMode::default();
21558        let roots = [extension_root];
21559        let canonical_roots = roots
21560            .iter()
21561            .map(|p| crate::extensions::safe_canonicalize(p))
21562            .collect::<Vec<_>>();
21563        let file_url = format!("file://{}", outside.display());
21564        let resolved = resolve_module_path(
21565            base.to_string_lossy().as_ref(),
21566            &file_url,
21567            mode,
21568            &canonical_roots,
21569        );
21570        assert!(
21571            resolved.is_none(),
21572            "file:// import outside extension root should be blocked, got {resolved:?}"
21573        );
21574    }
21575
21576    #[test]
21577    fn resolve_module_path_allows_file_url_inside_extension_root() {
21578        let temp_dir = tempfile::tempdir().expect("tempdir");
21579        let root = temp_dir.path();
21580        let extension_root = root.join("ext");
21581        std::fs::create_dir_all(&extension_root).expect("mkdir ext");
21582
21583        let base = extension_root.join("index.ts");
21584        std::fs::write(&base, "export {};\n").expect("write base");
21585
21586        let inside = extension_root.join("module.ts");
21587        std::fs::write(&inside, "export const ok = 1;\n").expect("write inside");
21588
21589        let mode = RepairMode::default();
21590        let roots = [extension_root];
21591        let canonical_roots = roots
21592            .iter()
21593            .map(|p| crate::extensions::safe_canonicalize(p))
21594            .collect::<Vec<_>>();
21595        let file_url = format!("file://{}", inside.display());
21596        let resolved = resolve_module_path(
21597            base.to_string_lossy().as_ref(),
21598            &file_url,
21599            mode,
21600            &canonical_roots,
21601        );
21602        assert_eq!(resolved, Some(inside));
21603    }
21604
21605    #[test]
21606    fn pijs_dynamic_import_reports_deterministic_package_error() {
21607        futures::executor::block_on(async {
21608            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
21609                .await
21610                .expect("create runtime");
21611
21612            runtime
21613                .eval(
21614                    r"
21615                    globalThis.packageImportError = {};
21616                    import('left-pad')
21617                      .then(() => {
21618                        globalThis.packageImportError.done = true;
21619                        globalThis.packageImportError.message = '';
21620                      })
21621                      .catch((err) => {
21622                        globalThis.packageImportError.done = true;
21623                        globalThis.packageImportError.message = String((err && err.message) || err || '');
21624                      });
21625                    ",
21626                )
21627                .await
21628                .expect("eval package import");
21629
21630            let result = get_global_json(&runtime, "packageImportError").await;
21631            assert_eq!(result["done"], serde_json::json!(true));
21632            let message = result["message"].as_str().unwrap_or_default();
21633            assert!(
21634                message.contains("Package module specifiers are not supported in PiJS: left-pad"),
21635                "unexpected message: {message}"
21636            );
21637        });
21638    }
21639
21640    #[test]
21641    fn proxy_stub_allowlist_blocks_sensitive_packages() {
21642        assert!(is_proxy_blocklisted_package("node:fs"));
21643        assert!(is_proxy_blocklisted_package("fs"));
21644        assert!(is_proxy_blocklisted_package("child_process"));
21645        assert!(!is_proxy_blocklisted_package("@aliou/pi-utils-settings"));
21646    }
21647
21648    #[test]
21649    fn proxy_stub_allowlist_accepts_curated_scope_and_pi_pattern() {
21650        assert!(is_proxy_allowlisted_package("@sourcegraph/scip-python"));
21651        assert!(is_proxy_allowlisted_package("@aliou/pi-utils-settings"));
21652        assert!(is_proxy_allowlisted_package("@example/pi-helpers"));
21653        assert!(!is_proxy_allowlisted_package("left-pad"));
21654    }
21655
21656    #[test]
21657    fn proxy_stub_allows_same_scope_packages_for_extension() {
21658        let temp_dir = tempfile::tempdir().expect("tempdir");
21659        let root = temp_dir.path().join("community").join("scope-ext");
21660        std::fs::create_dir_all(&root).expect("mkdir root");
21661        std::fs::write(
21662            root.join("package.json"),
21663            r#"{ "name": "@qualisero/my-ext", "version": "1.0.0" }"#,
21664        )
21665        .expect("write package.json");
21666        let base = root.join("index.mjs");
21667        std::fs::write(&base, "export {};\n").expect("write base");
21668
21669        let mut tiers = HashMap::new();
21670        tiers.insert(root.clone(), ProxyStubSourceTier::Community);
21671        let mut scopes = HashMap::new();
21672        scopes.insert(root.clone(), "@qualisero".to_string());
21673
21674        assert!(should_auto_stub_package(
21675            "@qualisero/shared-lib",
21676            base.to_string_lossy().as_ref(),
21677            &[root],
21678            &tiers,
21679            &scopes,
21680        ));
21681    }
21682
21683    #[test]
21684    fn proxy_stub_allows_non_blocklisted_package_for_community_tier() {
21685        let temp_dir = tempfile::tempdir().expect("tempdir");
21686        let root = temp_dir.path().join("community").join("generic-ext");
21687        std::fs::create_dir_all(&root).expect("mkdir root");
21688        let base = root.join("index.mjs");
21689        std::fs::write(&base, "export {};\n").expect("write base");
21690
21691        let mut tiers = HashMap::new();
21692        tiers.insert(root.clone(), ProxyStubSourceTier::Community);
21693
21694        assert!(should_auto_stub_package(
21695            "left-pad",
21696            base.to_string_lossy().as_ref(),
21697            &[root],
21698            &tiers,
21699            &HashMap::new(),
21700        ));
21701    }
21702
21703    #[test]
21704    fn proxy_stub_disallowed_for_official_tier() {
21705        let temp_dir = tempfile::tempdir().expect("tempdir");
21706        let root = temp_dir.path().join("official-pi-mono").join("my-ext");
21707        std::fs::create_dir_all(&root).expect("mkdir root");
21708        let base = root.join("index.mjs");
21709        std::fs::write(&base, "export {};\n").expect("write base");
21710
21711        let mut tiers = HashMap::new();
21712        tiers.insert(root.clone(), ProxyStubSourceTier::Official);
21713
21714        assert!(!should_auto_stub_package(
21715            "left-pad",
21716            base.to_string_lossy().as_ref(),
21717            &[root],
21718            &tiers,
21719            &HashMap::new(),
21720        ));
21721    }
21722
21723    #[test]
21724    fn pijs_dynamic_import_autostrict_allows_missing_npm_proxy_stub() {
21725        const TEST_PKG: &str = "@aliou/pi-missing-proxy-test";
21726        futures::executor::block_on(async {
21727            let temp_dir = tempfile::tempdir().expect("tempdir");
21728            let ext_dir = temp_dir.path().join("community").join("proxy-ext");
21729            std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
21730            let entry = ext_dir.join("index.mjs");
21731            std::fs::write(
21732                &entry,
21733                r#"
21734import dep from "@aliou/pi-missing-proxy-test";
21735globalThis.__proxyProbe = {
21736  kind: typeof dep,
21737  chain: typeof dep.foo.bar(),
21738  primitive: String(dep),
21739};
21740export default dep;
21741"#,
21742            )
21743            .expect("write extension module");
21744
21745            let config = PiJsRuntimeConfig {
21746                repair_mode: RepairMode::AutoStrict,
21747                ..PiJsRuntimeConfig::default()
21748            };
21749            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
21750                DeterministicClock::new(0),
21751                config,
21752                None,
21753            )
21754            .await
21755            .expect("create runtime");
21756            runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/proxy-ext"));
21757
21758            let entry_spec = format!("file://{}", entry.display());
21759            let script = format!(
21760                r#"
21761                globalThis.proxyImport = {{}};
21762                import({entry_spec:?})
21763                  .then(() => {{
21764                    globalThis.proxyImport.done = true;
21765                    globalThis.proxyImport.error = "";
21766                  }})
21767                  .catch((err) => {{
21768                    globalThis.proxyImport.done = true;
21769                    globalThis.proxyImport.error = String((err && err.message) || err || "");
21770                  }});
21771                "#
21772            );
21773            runtime.eval(&script).await.expect("eval import");
21774
21775            let result = get_global_json(&runtime, "proxyImport").await;
21776            assert_eq!(result["done"], serde_json::json!(true));
21777            assert_eq!(result["error"], serde_json::json!(""));
21778
21779            let probe = get_global_json(&runtime, "__proxyProbe").await;
21780            assert_eq!(probe["kind"], serde_json::json!("function"));
21781            assert_eq!(probe["chain"], serde_json::json!("function"));
21782            assert_eq!(probe["primitive"], serde_json::json!(""));
21783
21784            let events = runtime.drain_repair_events();
21785            assert!(events.iter().any(|event| {
21786                event.pattern == RepairPattern::MissingNpmDep
21787                    && event.repair_action.contains(TEST_PKG)
21788            }));
21789        });
21790    }
21791
21792    #[test]
21793    fn pijs_dynamic_import_autosafe_rejects_missing_npm_proxy_stub() {
21794        const TEST_PKG: &str = "@aliou/pi-missing-proxy-test-safe";
21795        futures::executor::block_on(async {
21796            let temp_dir = tempfile::tempdir().expect("tempdir");
21797            let ext_dir = temp_dir.path().join("community").join("proxy-ext-safe");
21798            std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
21799            let entry = ext_dir.join("index.mjs");
21800            std::fs::write(
21801                &entry,
21802                r#"import dep from "@aliou/pi-missing-proxy-test-safe"; export default dep;"#,
21803            )
21804            .expect("write extension module");
21805
21806            let config = PiJsRuntimeConfig {
21807                repair_mode: RepairMode::AutoSafe,
21808                ..PiJsRuntimeConfig::default()
21809            };
21810            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
21811                DeterministicClock::new(0),
21812                config,
21813                None,
21814            )
21815            .await
21816            .expect("create runtime");
21817            runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/proxy-ext-safe"));
21818
21819            let entry_spec = format!("file://{}", entry.display());
21820            let script = format!(
21821                r#"
21822                globalThis.proxySafeImport = {{}};
21823                import({entry_spec:?})
21824                  .then(() => {{
21825                    globalThis.proxySafeImport.done = true;
21826                    globalThis.proxySafeImport.error = "";
21827                  }})
21828                  .catch((err) => {{
21829                    globalThis.proxySafeImport.done = true;
21830                    globalThis.proxySafeImport.error = String((err && err.message) || err || "");
21831                  }});
21832                "#
21833            );
21834            runtime.eval(&script).await.expect("eval import");
21835
21836            let result = get_global_json(&runtime, "proxySafeImport").await;
21837            assert_eq!(result["done"], serde_json::json!(true));
21838            let message = result["error"].as_str().unwrap_or_default();
21839            // Check error class without the full package name at the tail:
21840            // on macOS the longer temp paths can cause QuickJS error
21841            // formatting to truncate the final characters of the message.
21842            assert!(
21843                message.contains("Package module specifiers are not supported in PiJS"),
21844                "unexpected message: {message}"
21845            );
21846        });
21847    }
21848
21849    #[test]
21850    fn pijs_dynamic_import_existing_virtual_module_does_not_emit_missing_npm_repair() {
21851        futures::executor::block_on(async {
21852            let temp_dir = tempfile::tempdir().expect("tempdir");
21853            let ext_dir = temp_dir.path().join("community").join("proxy-ext-existing");
21854            std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
21855            let entry = ext_dir.join("index.mjs");
21856            std::fs::write(
21857                &entry,
21858                r#"
21859import { ConfigLoader } from "@aliou/pi-utils-settings";
21860globalThis.__existingVirtualProbe = typeof ConfigLoader;
21861export default ConfigLoader;
21862"#,
21863            )
21864            .expect("write extension module");
21865
21866            let config = PiJsRuntimeConfig {
21867                repair_mode: RepairMode::AutoStrict,
21868                ..PiJsRuntimeConfig::default()
21869            };
21870            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
21871                DeterministicClock::new(0),
21872                config,
21873                None,
21874            )
21875            .await
21876            .expect("create runtime");
21877            runtime
21878                .add_extension_root_with_id(ext_dir.clone(), Some("community/proxy-ext-existing"));
21879
21880            let entry_spec = format!("file://{}", entry.display());
21881            let script = format!(
21882                r#"
21883                globalThis.proxyExistingImport = {{}};
21884                import({entry_spec:?})
21885                  .then(() => {{
21886                    globalThis.proxyExistingImport.done = true;
21887                    globalThis.proxyExistingImport.error = "";
21888                  }})
21889                  .catch((err) => {{
21890                    globalThis.proxyExistingImport.done = true;
21891                    globalThis.proxyExistingImport.error = String((err && err.message) || err || "");
21892                  }});
21893                "#
21894            );
21895            runtime.eval(&script).await.expect("eval import");
21896
21897            let result = get_global_json(&runtime, "proxyExistingImport").await;
21898            assert_eq!(result["done"], serde_json::json!(true));
21899            assert_eq!(result["error"], serde_json::json!(""));
21900
21901            let probe = get_global_json(&runtime, "__existingVirtualProbe").await;
21902            assert_eq!(probe, serde_json::json!("function"));
21903
21904            let events = runtime.drain_repair_events();
21905            assert!(
21906                !events
21907                    .iter()
21908                    .any(|event| event.pattern == RepairPattern::MissingNpmDep),
21909                "existing virtual module should suppress missing_npm_dep repair events"
21910            );
21911        });
21912    }
21913
21914    #[test]
21915    fn pijs_dynamic_import_loads_doom_style_wad_finder_module() {
21916        futures::executor::block_on(async {
21917            let temp_dir = tempfile::tempdir().expect("tempdir");
21918            let ext_dir = temp_dir.path().join("community").join("doom-like");
21919            std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
21920            let entry = ext_dir.join("wad-finder.ts");
21921            std::fs::write(
21922                &entry,
21923                r#"
21924import { dirname, join } from "node:path";
21925import { fileURLToPath } from "node:url";
21926
21927const __dirname = dirname(fileURLToPath(import.meta.url));
21928globalThis.__doomWadFinderProbe = {
21929  bundled: join(__dirname, "doom1.wad"),
21930};
21931
21932export const bundled = globalThis.__doomWadFinderProbe.bundled;
21933"#,
21934            )
21935            .expect("write extension module");
21936
21937            let config = PiJsRuntimeConfig {
21938                repair_mode: RepairMode::AutoStrict,
21939                ..PiJsRuntimeConfig::default()
21940            };
21941            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
21942                DeterministicClock::new(0),
21943                config,
21944                None,
21945            )
21946            .await
21947            .expect("create runtime");
21948            runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/doom-like"));
21949
21950            let entry_spec = format!("file://{}", entry.display());
21951            let script = format!(
21952                r#"
21953                globalThis.doomLikeImport = {{}};
21954                import({entry_spec:?})
21955                  .then(() => {{
21956                    globalThis.doomLikeImport.done = true;
21957                    globalThis.doomLikeImport.error = "";
21958                  }})
21959                  .catch((err) => {{
21960                    globalThis.doomLikeImport.done = true;
21961                    globalThis.doomLikeImport.error = String((err && err.message) || err || "");
21962                  }});
21963                "#
21964            );
21965            runtime.eval(&script).await.expect("eval import");
21966
21967            let result = get_global_json(&runtime, "doomLikeImport").await;
21968            assert_eq!(result["done"], serde_json::json!(true));
21969            assert_eq!(result["error"], serde_json::json!(""));
21970
21971            let probe = get_global_json(&runtime, "__doomWadFinderProbe").await;
21972            let bundled = probe["bundled"].as_str().unwrap_or_default();
21973            assert!(
21974                bundled.ends_with("/doom1.wad"),
21975                "unexpected doom wad probe: {probe}"
21976            );
21977        });
21978    }
21979
21980    #[test]
21981    fn pijs_dynamic_import_loads_real_doom_wad_finder_module() {
21982        futures::executor::block_on(async {
21983            let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
21984            let ext_dir = repo_root.join("tests/ext_conformance/artifacts/doom-overlay");
21985            let entry = ext_dir.join("wad-finder.ts");
21986            assert!(entry.is_file(), "missing doom wad-finder at {entry:?}");
21987
21988            let config = PiJsRuntimeConfig {
21989                repair_mode: RepairMode::AutoStrict,
21990                ..PiJsRuntimeConfig::default()
21991            };
21992            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
21993                DeterministicClock::new(0),
21994                config,
21995                None,
21996            )
21997            .await
21998            .expect("create runtime");
21999            runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/doom-overlay"));
22000
22001            let entry_spec = format!("file://{}", entry.display());
22002            let script = format!(
22003                r#"
22004                globalThis.realDoomWadFinderImport = {{}};
22005                import({entry_spec:?})
22006                  .then((mod) => {{
22007                    globalThis.realDoomWadFinderImport.done = true;
22008                    globalThis.realDoomWadFinderImport.error = "";
22009                    globalThis.realDoomWadFinderImport.exportType = typeof mod.findWadFile;
22010                  }})
22011                  .catch((err) => {{
22012                    globalThis.realDoomWadFinderImport.done = true;
22013                    globalThis.realDoomWadFinderImport.error = String((err && err.message) || err || "");
22014                  }});
22015                "#
22016            );
22017            runtime.eval(&script).await.expect("eval import");
22018
22019            let result = get_global_json(&runtime, "realDoomWadFinderImport").await;
22020            assert_eq!(result["done"], serde_json::json!(true));
22021            assert_eq!(result["error"], serde_json::json!(""));
22022            assert_eq!(result["exportType"], serde_json::json!("function"));
22023        });
22024    }
22025
22026    #[test]
22027    fn pijs_loads_real_doom_extension_entry() {
22028        futures::executor::block_on(async {
22029            let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
22030            let ext_dir = repo_root.join("tests/ext_conformance/artifacts/doom-overlay");
22031            let entry = ext_dir.join("index.ts");
22032            assert!(entry.is_file(), "missing doom entry at {entry:?}");
22033
22034            let config = PiJsRuntimeConfig {
22035                repair_mode: RepairMode::AutoStrict,
22036                ..PiJsRuntimeConfig::default()
22037            };
22038            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
22039                DeterministicClock::new(0),
22040                config,
22041                None,
22042            )
22043            .await
22044            .expect("create runtime");
22045            runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/doom-overlay"));
22046
22047            let entry_spec = format!("file://{}", entry.display());
22048            let script = format!(
22049                r#"
22050                globalThis.realDoomEntryLoad = {{}};
22051                __pi_load_extension("community/doom-overlay", {entry_spec:?}, {{ name: "doom-overlay" }})
22052                  .then(() => {{
22053                    globalThis.realDoomEntryLoad.done = true;
22054                    globalThis.realDoomEntryLoad.error = "";
22055                  }})
22056                  .catch((err) => {{
22057                    globalThis.realDoomEntryLoad.done = true;
22058                    globalThis.realDoomEntryLoad.error = String((err && err.message) || err || "");
22059                  }});
22060                "#
22061            );
22062            runtime.eval(&script).await.expect("eval load_extension");
22063
22064            let result = get_global_json(&runtime, "realDoomEntryLoad").await;
22065            assert_eq!(result["done"], serde_json::json!(true));
22066            assert_eq!(result["error"], serde_json::json!(""));
22067
22068            let snapshot = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
22069            assert_eq!(snapshot["extensions"], serde_json::json!(1));
22070            assert_eq!(snapshot["commands"], serde_json::json!(1));
22071        });
22072    }
22073
22074    #[test]
22075    fn pijs_dynamic_import_reports_deterministic_network_error() {
22076        futures::executor::block_on(async {
22077            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22078                .await
22079                .expect("create runtime");
22080
22081            runtime
22082                .eval(
22083                    r"
22084                    globalThis.networkImportError = {};
22085                    import('https://example.com/mod.js')
22086                      .then(() => {
22087                        globalThis.networkImportError.done = true;
22088                        globalThis.networkImportError.message = '';
22089                      })
22090                      .catch((err) => {
22091                        globalThis.networkImportError.done = true;
22092                        globalThis.networkImportError.message = String((err && err.message) || err || '');
22093                      });
22094                    ",
22095                )
22096                .await
22097                .expect("eval network import");
22098
22099            let result = get_global_json(&runtime, "networkImportError").await;
22100            assert_eq!(result["done"], serde_json::json!(true));
22101            let message = result["message"].as_str().unwrap_or_default();
22102            assert!(
22103                message.contains(
22104                    "Network module imports are not supported in PiJS: https://example.com/mod.js"
22105                ),
22106                "unexpected message: {message}"
22107            );
22108        });
22109    }
22110
22111    // Tests for the Promise bridge (bd-2ke)
22112
22113    #[test]
22114    fn pijs_runtime_creates_hostcall_request() {
22115        futures::executor::block_on(async {
22116            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22117                .await
22118                .expect("create runtime");
22119
22120            // Call pi.tool() which should enqueue a hostcall request
22121            runtime
22122                .eval(r#"pi.tool("read", { path: "test.txt" });"#)
22123                .await
22124                .expect("eval");
22125
22126            // Check that a hostcall request was enqueued
22127            let requests = runtime.drain_hostcall_requests();
22128            assert_eq!(requests.len(), 1);
22129            let req = &requests[0];
22130            assert!(matches!(&req.kind, HostcallKind::Tool { name } if name == "read"));
22131            assert_eq!(req.payload["path"], "test.txt");
22132            assert_eq!(req.extension_id.as_deref(), None);
22133        });
22134    }
22135
22136    #[test]
22137    fn pijs_runtime_hostcall_request_captures_extension_id() {
22138        futures::executor::block_on(async {
22139            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22140                .await
22141                .expect("create runtime");
22142
22143            runtime
22144                .eval(
22145                    r#"
22146                __pi_begin_extension("ext.test", { name: "Test" });
22147                pi.tool("read", { path: "test.txt" });
22148                __pi_end_extension();
22149            "#,
22150                )
22151                .await
22152                .expect("eval");
22153
22154            let requests = runtime.drain_hostcall_requests();
22155            assert_eq!(requests.len(), 1);
22156            assert_eq!(requests[0].extension_id.as_deref(), Some("ext.test"));
22157        });
22158    }
22159
22160    #[test]
22161    fn pijs_runtime_log_hostcall_request_shape() {
22162        futures::executor::block_on(async {
22163            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22164                .await
22165                .expect("create runtime");
22166
22167            runtime
22168                .eval(
22169                    r#"
22170                pi.log({
22171                    level: "info",
22172                    event: "unit.test",
22173                    message: "hello",
22174                    correlation: { scenario_id: "scn-1" }
22175                });
22176            "#,
22177                )
22178                .await
22179                .expect("eval");
22180
22181            let requests = runtime.drain_hostcall_requests();
22182            assert_eq!(requests.len(), 1);
22183            let req = &requests[0];
22184            assert!(matches!(&req.kind, HostcallKind::Log));
22185            assert_eq!(req.payload["level"], "info");
22186            assert_eq!(req.payload["event"], "unit.test");
22187            assert_eq!(req.payload["message"], "hello");
22188        });
22189    }
22190
22191    #[test]
22192    fn pijs_runtime_get_registered_tools_empty() {
22193        futures::executor::block_on(async {
22194            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22195                .await
22196                .expect("create runtime");
22197
22198            let tools = runtime.get_registered_tools().await.expect("get tools");
22199            assert!(tools.is_empty());
22200        });
22201    }
22202
22203    #[test]
22204    fn pijs_runtime_get_registered_tools_single_tool() {
22205        futures::executor::block_on(async {
22206            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22207                .await
22208                .expect("create runtime");
22209
22210            runtime
22211                .eval(
22212                    r"
22213                __pi_begin_extension('ext.test', { name: 'Test' });
22214                pi.registerTool({
22215                    name: 'my_tool',
22216                    label: 'My Tool',
22217                    description: 'Does stuff',
22218                    parameters: { type: 'object', properties: { path: { type: 'string' } } },
22219                    execute: async (_callId, _input) => { return { ok: true }; },
22220                });
22221                __pi_end_extension();
22222            ",
22223                )
22224                .await
22225                .expect("eval");
22226
22227            let tools = runtime.get_registered_tools().await.expect("get tools");
22228            assert_eq!(tools.len(), 1);
22229            assert_eq!(
22230                tools[0],
22231                ExtensionToolDef {
22232                    name: "my_tool".to_string(),
22233                    label: Some("My Tool".to_string()),
22234                    description: "Does stuff".to_string(),
22235                    parameters: serde_json::json!({
22236                        "type": "object",
22237                        "properties": {
22238                            "path": { "type": "string" }
22239                        }
22240                    }),
22241                }
22242            );
22243        });
22244    }
22245
22246    #[test]
22247    fn pijs_runtime_get_registered_tools_sorts_by_name() {
22248        futures::executor::block_on(async {
22249            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22250                .await
22251                .expect("create runtime");
22252
22253            runtime
22254                .eval(
22255                    r"
22256                __pi_begin_extension('ext.test', { name: 'Test' });
22257                pi.registerTool({ name: 'b', execute: async (_callId, _input) => { return {}; } });
22258                pi.registerTool({ name: 'a', execute: async (_callId, _input) => { return {}; } });
22259                __pi_end_extension();
22260            ",
22261                )
22262                .await
22263                .expect("eval");
22264
22265            let tools = runtime.get_registered_tools().await.expect("get tools");
22266            assert_eq!(
22267                tools
22268                    .iter()
22269                    .map(|tool| tool.name.as_str())
22270                    .collect::<Vec<_>>(),
22271                vec!["a", "b"]
22272            );
22273        });
22274    }
22275
22276    #[test]
22277    fn pijs_validate_tool_input_allows_null_when_schema_allows_null() {
22278        futures::executor::block_on(async {
22279            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22280                .await
22281                .expect("create runtime");
22282
22283            runtime
22284                .eval(
22285                    r#"
22286                __pi_validate_tool_input({
22287                    type: ["object", "null"],
22288                    properties: { name: { type: "string" } },
22289                    required: ["name"]
22290                }, null);
22291            "#,
22292                )
22293                .await
22294                .expect("null input should be allowed when schema permits null");
22295        });
22296    }
22297
22298    #[test]
22299    fn pijs_validate_tool_input_rejects_missing_required_object() {
22300        futures::executor::block_on(async {
22301            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22302                .await
22303                .expect("create runtime");
22304
22305            let err = runtime
22306                .eval(
22307                    r#"
22308                __pi_validate_tool_input({
22309                    type: ["object", "null"],
22310                    properties: { name: { type: "string" } },
22311                    required: ["name"]
22312                }, {});
22313            "#,
22314                )
22315                .await
22316                .expect_err("missing required field should throw");
22317
22318            assert!(err.to_string().contains("missing required"));
22319        });
22320    }
22321
22322    #[test]
22323    fn pijs_validate_tool_input_rejects_non_object_when_schema_is_object() {
22324        futures::executor::block_on(async {
22325            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22326                .await
22327                .expect("create runtime");
22328
22329            let err = runtime
22330                .eval(
22331                    r#"
22332                __pi_validate_tool_input({
22333                    type: "object",
22334                    properties: { name: { type: "string" } }
22335                }, "nope");
22336            "#,
22337                )
22338                .await
22339                .expect_err("non-object input should throw");
22340
22341            assert!(err.to_string().contains("Tool input must be an object"));
22342        });
22343    }
22344
22345    #[test]
22346    fn pijs_validate_tool_input_allows_non_object_when_schema_allows_string() {
22347        futures::executor::block_on(async {
22348            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22349                .await
22350                .expect("create runtime");
22351
22352            runtime
22353                .eval(
22354                    r#"
22355                __pi_validate_tool_input({
22356                    type: ["object", "string"],
22357                    properties: { name: { type: "string" } },
22358                    required: ["name"]
22359                }, "ok");
22360            "#,
22361                )
22362                .await
22363                .expect("string input should be allowed when schema permits string");
22364        });
22365    }
22366
22367    #[test]
22368    fn pijs_validate_tool_input_rejects_null_when_schema_disallows_null() {
22369        futures::executor::block_on(async {
22370            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22371                .await
22372                .expect("create runtime");
22373
22374            let err = runtime
22375                .eval(
22376                    r#"
22377                __pi_validate_tool_input({
22378                    type: ["object", "string"],
22379                    properties: { name: { type: "string" } }
22380                }, null);
22381            "#,
22382                )
22383                .await
22384                .expect_err("null input should be rejected when schema disallows null");
22385
22386            assert!(err.to_string().contains("Tool input must be an object"));
22387        });
22388    }
22389
22390    #[test]
22391    fn pijs_validate_tool_input_rejects_number_when_schema_only_string() {
22392        futures::executor::block_on(async {
22393            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22394                .await
22395                .expect("create runtime");
22396
22397            let err = runtime
22398                .eval(
22399                    r#"
22400                __pi_validate_tool_input({
22401                    type: ["object", "string"],
22402                    properties: { name: { type: "string" } }
22403                }, 42);
22404            "#,
22405                )
22406                .await
22407                .expect_err("number input should be rejected when schema allows only string");
22408
22409            assert!(err.to_string().contains("Tool input must be an object"));
22410        });
22411    }
22412
22413    #[test]
22414    fn pijs_validate_tool_input_allows_undefined_when_no_required() {
22415        futures::executor::block_on(async {
22416            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22417                .await
22418                .expect("create runtime");
22419
22420            runtime
22421                .eval(
22422                    r#"
22423                __pi_validate_tool_input({
22424                    type: "object",
22425                    properties: { name: { type: "string" } }
22426                }, undefined);
22427            "#,
22428                )
22429                .await
22430                .expect("undefined input should be allowed when no required fields");
22431        });
22432    }
22433
22434    #[test]
22435    fn hostcall_params_hash_is_stable_for_key_ordering() {
22436        let first = serde_json::json!({ "b": 2, "a": 1 });
22437        let second = serde_json::json!({ "a": 1, "b": 2 });
22438
22439        assert_eq!(
22440            hostcall_params_hash("http", &first),
22441            hostcall_params_hash("http", &second)
22442        );
22443        assert_ne!(
22444            hostcall_params_hash("http", &first),
22445            hostcall_params_hash("tool", &first)
22446        );
22447    }
22448
22449    #[test]
22450    #[allow(clippy::too_many_lines)]
22451    fn hostcall_request_params_for_hash_uses_canonical_shapes() {
22452        let cases = vec![
22453            (
22454                HostcallRequest {
22455                    call_id: "tool-case".to_string(),
22456                    kind: HostcallKind::Tool {
22457                        name: "read".to_string(),
22458                    },
22459                    payload: serde_json::json!({ "path": "README.md" }),
22460                    trace_id: 0,
22461                    extension_id: None,
22462                },
22463                serde_json::json!({ "name": "read", "input": { "path": "README.md" } }),
22464            ),
22465            (
22466                HostcallRequest {
22467                    call_id: "exec-case".to_string(),
22468                    kind: HostcallKind::Exec {
22469                        cmd: "echo".to_string(),
22470                    },
22471                    payload: serde_json::json!({
22472                        "command": "legacy alias should be dropped",
22473                        "args": ["hello"],
22474                        "options": { "timeout": 1000 }
22475                    }),
22476                    trace_id: 0,
22477                    extension_id: None,
22478                },
22479                serde_json::json!({
22480                    "cmd": "echo",
22481                    "args": ["hello"],
22482                    "options": { "timeout": 1000 }
22483                }),
22484            ),
22485            (
22486                HostcallRequest {
22487                    call_id: "session-object".to_string(),
22488                    kind: HostcallKind::Session {
22489                        op: "set_model".to_string(),
22490                    },
22491                    payload: serde_json::json!({
22492                        "provider": "openai",
22493                        "modelId": "gpt-4o"
22494                    }),
22495                    trace_id: 0,
22496                    extension_id: None,
22497                },
22498                serde_json::json!({
22499                    "op": "set_model",
22500                    "provider": "openai",
22501                    "modelId": "gpt-4o"
22502                }),
22503            ),
22504            (
22505                HostcallRequest {
22506                    call_id: "ui-non-object".to_string(),
22507                    kind: HostcallKind::Ui {
22508                        op: "set_status".to_string(),
22509                    },
22510                    payload: serde_json::json!("ready"),
22511                    trace_id: 0,
22512                    extension_id: None,
22513                },
22514                serde_json::json!({ "op": "set_status", "payload": "ready" }),
22515            ),
22516            (
22517                HostcallRequest {
22518                    call_id: "events-non-object".to_string(),
22519                    kind: HostcallKind::Events {
22520                        op: "emit".to_string(),
22521                    },
22522                    payload: serde_json::json!(42),
22523                    trace_id: 0,
22524                    extension_id: None,
22525                },
22526                serde_json::json!({ "op": "emit", "payload": 42 }),
22527            ),
22528            (
22529                HostcallRequest {
22530                    call_id: "session-null".to_string(),
22531                    kind: HostcallKind::Session {
22532                        op: "get_state".to_string(),
22533                    },
22534                    payload: serde_json::Value::Null,
22535                    trace_id: 0,
22536                    extension_id: None,
22537                },
22538                serde_json::json!({ "op": "get_state" }),
22539            ),
22540            (
22541                HostcallRequest {
22542                    call_id: "log-entry".to_string(),
22543                    kind: HostcallKind::Log,
22544                    payload: serde_json::json!({
22545                        "level": "info",
22546                        "event": "unit.test",
22547                        "message": "hello",
22548                        "correlation": { "scenario_id": "scn-1" }
22549                    }),
22550                    trace_id: 0,
22551                    extension_id: None,
22552                },
22553                serde_json::json!({
22554                    "level": "info",
22555                    "event": "unit.test",
22556                    "message": "hello",
22557                    "correlation": { "scenario_id": "scn-1" }
22558                }),
22559            ),
22560        ];
22561
22562        for (request, expected) in cases {
22563            assert_eq!(
22564                request.params_for_hash(),
22565                expected,
22566                "canonical params mismatch for {}",
22567                request.call_id
22568            );
22569        }
22570    }
22571
22572    #[test]
22573    fn hostcall_request_params_hash_matches_wasm_contract_for_canonical_requests() {
22574        let requests = vec![
22575            HostcallRequest {
22576                call_id: "hash-session".to_string(),
22577                kind: HostcallKind::Session {
22578                    op: "set_model".to_string(),
22579                },
22580                payload: serde_json::json!({
22581                    "modelId": "gpt-4o",
22582                    "provider": "openai"
22583                }),
22584                trace_id: 0,
22585                extension_id: Some("ext.test".to_string()),
22586            },
22587            HostcallRequest {
22588                call_id: "hash-ui".to_string(),
22589                kind: HostcallKind::Ui {
22590                    op: "set_status".to_string(),
22591                },
22592                payload: serde_json::json!("thinking"),
22593                trace_id: 0,
22594                extension_id: Some("ext.test".to_string()),
22595            },
22596            HostcallRequest {
22597                call_id: "hash-log".to_string(),
22598                kind: HostcallKind::Log,
22599                payload: serde_json::json!({
22600                    "level": "warn",
22601                    "event": "log.test",
22602                    "message": "warn line",
22603                    "correlation": { "scenario_id": "scn-2" }
22604                }),
22605                trace_id: 0,
22606                extension_id: Some("ext.test".to_string()),
22607            },
22608        ];
22609
22610        for request in requests {
22611            let params = request.params_for_hash();
22612            let js_hash = request.params_hash();
22613
22614            // Validate streaming hash matches the reference implementation.
22615            let wasm_contract_hash =
22616                crate::extensions::hostcall_params_hash(request.method(), &params);
22617
22618            assert_eq!(
22619                js_hash, wasm_contract_hash,
22620                "hash parity mismatch for {}",
22621                request.call_id
22622            );
22623        }
22624    }
22625
22626    #[test]
22627    fn hostcall_request_io_uring_capability_and_hint_mappings_are_deterministic() {
22628        let cases = vec![
22629            (
22630                HostcallRequest {
22631                    call_id: "io-read".to_string(),
22632                    kind: HostcallKind::Tool {
22633                        name: "read".to_string(),
22634                    },
22635                    payload: serde_json::Value::Null,
22636                    trace_id: 0,
22637                    extension_id: None,
22638                },
22639                HostcallCapabilityClass::Filesystem,
22640                HostcallIoHint::IoHeavy,
22641            ),
22642            (
22643                HostcallRequest {
22644                    call_id: "io-bash".to_string(),
22645                    kind: HostcallKind::Tool {
22646                        name: "bash".to_string(),
22647                    },
22648                    payload: serde_json::Value::Null,
22649                    trace_id: 0,
22650                    extension_id: None,
22651                },
22652                HostcallCapabilityClass::Execution,
22653                HostcallIoHint::CpuBound,
22654            ),
22655            (
22656                HostcallRequest {
22657                    call_id: "io-http".to_string(),
22658                    kind: HostcallKind::Http,
22659                    payload: serde_json::Value::Null,
22660                    trace_id: 0,
22661                    extension_id: None,
22662                },
22663                HostcallCapabilityClass::Network,
22664                HostcallIoHint::IoHeavy,
22665            ),
22666            (
22667                HostcallRequest {
22668                    call_id: "io-session".to_string(),
22669                    kind: HostcallKind::Session {
22670                        op: "get_state".to_string(),
22671                    },
22672                    payload: serde_json::Value::Null,
22673                    trace_id: 0,
22674                    extension_id: None,
22675                },
22676                HostcallCapabilityClass::Session,
22677                HostcallIoHint::Unknown,
22678            ),
22679            (
22680                HostcallRequest {
22681                    call_id: "io-log".to_string(),
22682                    kind: HostcallKind::Log,
22683                    payload: serde_json::Value::Null,
22684                    trace_id: 0,
22685                    extension_id: None,
22686                },
22687                HostcallCapabilityClass::Telemetry,
22688                HostcallIoHint::Unknown,
22689            ),
22690        ];
22691
22692        for (request, expected_capability, expected_hint) in cases {
22693            assert_eq!(
22694                request.io_uring_capability_class(),
22695                expected_capability,
22696                "capability mismatch for {}",
22697                request.call_id
22698            );
22699            assert_eq!(
22700                request.io_uring_io_hint(),
22701                expected_hint,
22702                "io hint mismatch for {}",
22703                request.call_id
22704            );
22705        }
22706    }
22707
22708    #[test]
22709    fn hostcall_request_io_uring_lane_input_preserves_queue_and_force_flags() {
22710        let request = HostcallRequest {
22711            call_id: "io-lane-input".to_string(),
22712            kind: HostcallKind::Tool {
22713                name: "write".to_string(),
22714            },
22715            payload: serde_json::json!({ "path": "notes.txt", "content": "ok" }),
22716            trace_id: 0,
22717            extension_id: Some("ext.test".to_string()),
22718        };
22719
22720        let input = request.io_uring_lane_input(17, true);
22721        assert_eq!(input.capability, HostcallCapabilityClass::Filesystem);
22722        assert_eq!(input.io_hint, HostcallIoHint::IoHeavy);
22723        assert_eq!(input.queue_depth, 17);
22724        assert!(input.force_compat_lane);
22725    }
22726
22727    #[test]
22728    fn pijs_runtime_multiple_hostcalls() {
22729        futures::executor::block_on(async {
22730            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22731                .await
22732                .expect("create runtime");
22733
22734            runtime
22735                .eval(
22736                    r#"
22737            pi.tool("read", { path: "a.txt" });
22738            pi.exec("ls", ["-la"]);
22739            pi.http({ url: "https://example.com" });
22740        "#,
22741                )
22742                .await
22743                .expect("eval");
22744
22745            let requests = runtime.drain_hostcall_requests();
22746            let kinds = requests
22747                .iter()
22748                .map(|req| format!("{:?}", req.kind))
22749                .collect::<Vec<_>>();
22750            assert_eq!(requests.len(), 3, "hostcalls: {kinds:?}");
22751
22752            assert!(matches!(&requests[0].kind, HostcallKind::Tool { name } if name == "read"));
22753            assert!(matches!(&requests[1].kind, HostcallKind::Exec { cmd } if cmd == "ls"));
22754            assert!(matches!(&requests[2].kind, HostcallKind::Http));
22755        });
22756    }
22757
22758    #[test]
22759    fn pijs_fetch_binary_body_uses_body_bytes_hostcall() {
22760        futures::executor::block_on(async {
22761            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22762                .await
22763                .expect("create runtime");
22764
22765            runtime
22766                .eval(
22767                    r#"
22768            fetch("https://example.com/upload", {
22769                method: "POST",
22770                headers: { "content-type": "application/octet-stream" },
22771                body: new Uint8Array([0, 1, 2, 255]),
22772            });
22773        "#,
22774                )
22775                .await
22776                .expect("eval");
22777
22778            let requests = runtime.drain_hostcall_requests();
22779            assert_eq!(requests.len(), 1);
22780            assert!(matches!(&requests[0].kind, HostcallKind::Http));
22781
22782            let payload = requests[0]
22783                .payload
22784                .as_object()
22785                .expect("http payload object");
22786            assert_eq!(
22787                payload.get("method").and_then(serde_json::Value::as_str),
22788                Some("POST")
22789            );
22790            assert_eq!(
22791                payload
22792                    .get("body_bytes")
22793                    .and_then(serde_json::Value::as_str),
22794                Some("AAEC/w==")
22795            );
22796            assert!(
22797                payload.get("body").is_none(),
22798                "binary fetch bodies must use body_bytes instead of text coercion: {payload:?}"
22799            );
22800        });
22801    }
22802
22803    #[test]
22804    fn pijs_runtime_hostcall_completion_resolves_promise() {
22805        futures::executor::block_on(async {
22806            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22807                .await
22808                .expect("create runtime");
22809
22810            // Set up a promise handler that stores the result
22811            runtime
22812                .eval(
22813                    r#"
22814            globalThis.result = null;
22815            pi.tool("read", { path: "test.txt" }).then(r => {
22816                globalThis.result = r;
22817            });
22818        "#,
22819                )
22820                .await
22821                .expect("eval");
22822
22823            // Get the hostcall request
22824            let requests = runtime.drain_hostcall_requests();
22825            assert_eq!(requests.len(), 1);
22826            let call_id = requests[0].call_id.clone();
22827
22828            // Complete the hostcall
22829            runtime.complete_hostcall(
22830                call_id,
22831                HostcallOutcome::Success(serde_json::json!({ "content": "hello world" })),
22832            );
22833
22834            // Tick to deliver the completion
22835            let stats = runtime.tick().await.expect("tick");
22836            assert!(stats.ran_macrotask);
22837
22838            // Verify the promise was resolved with the correct value
22839            runtime
22840                .eval(
22841                    r#"
22842            if (globalThis.result === null) {
22843                throw new Error("Promise not resolved");
22844            }
22845            if (globalThis.result.content !== "hello world") {
22846                throw new Error("Wrong result: " + JSON.stringify(globalThis.result));
22847            }
22848        "#,
22849                )
22850                .await
22851                .expect("verify result");
22852        });
22853    }
22854
22855    #[test]
22856    fn pijs_runtime_hostcall_error_rejects_promise() {
22857        futures::executor::block_on(async {
22858            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22859                .await
22860                .expect("create runtime");
22861
22862            // Set up a promise handler that captures rejection
22863            runtime
22864                .eval(
22865                    r#"
22866            globalThis.error = null;
22867            pi.tool("read", { path: "nonexistent.txt" }).catch(e => {
22868                globalThis.error = { code: e.code, message: e.message };
22869            });
22870        "#,
22871                )
22872                .await
22873                .expect("eval");
22874
22875            let requests = runtime.drain_hostcall_requests();
22876            let call_id = requests[0].call_id.clone();
22877
22878            // Complete with an error
22879            runtime.complete_hostcall(
22880                call_id,
22881                HostcallOutcome::Error {
22882                    code: "ENOENT".to_string(),
22883                    message: "File not found".to_string(),
22884                },
22885            );
22886
22887            runtime.tick().await.expect("tick");
22888
22889            // Verify the promise was rejected
22890            runtime
22891                .eval(
22892                    r#"
22893            if (globalThis.error === null) {
22894                throw new Error("Promise not rejected");
22895            }
22896            if (globalThis.error.code !== "ENOENT") {
22897                throw new Error("Wrong error code: " + globalThis.error.code);
22898            }
22899        "#,
22900                )
22901                .await
22902                .expect("verify error");
22903        });
22904    }
22905
22906    #[test]
22907    fn pijs_runtime_tick_stats() {
22908        futures::executor::block_on(async {
22909            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22910                .await
22911                .expect("create runtime");
22912
22913            // No pending tasks
22914            let stats = runtime.tick().await.expect("tick");
22915            assert!(!stats.ran_macrotask);
22916            assert_eq!(stats.pending_hostcalls, 0);
22917
22918            // Create a hostcall
22919            runtime.eval(r#"pi.tool("test", {});"#).await.expect("eval");
22920
22921            let requests = runtime.drain_hostcall_requests();
22922            assert_eq!(requests.len(), 1);
22923
22924            // Complete it
22925            runtime.complete_hostcall(
22926                requests[0].call_id.clone(),
22927                HostcallOutcome::Success(serde_json::json!(null)),
22928            );
22929
22930            let stats = runtime.tick().await.expect("tick");
22931            assert!(stats.ran_macrotask);
22932        });
22933    }
22934
22935    #[test]
22936    #[allow(clippy::too_many_lines)]
22937    fn pijs_custom_ui_width_updates_trigger_reflow() {
22938        futures::executor::block_on(async {
22939            let clock = Arc::new(DeterministicClock::new(0));
22940            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22941                .await
22942                .expect("create runtime");
22943
22944            runtime
22945                .eval(
22946                    r"
22947                    globalThis.renderWidths = [];
22948                    const ui = __pi_make_extension_ui(true);
22949                    void ui.custom((_tui, _theme, _keybindings, onDone) => ({
22950                        render(width) {
22951                            globalThis.renderWidths.push(width);
22952                            if (width === 40) {
22953                                onDone(width);
22954                            }
22955                            return [`width:${width}`];
22956                        }
22957                    }), { width: 80 });
22958                    ",
22959                )
22960                .await
22961                .expect("start custom ui");
22962
22963            let initial_requests = runtime.drain_hostcall_requests();
22964            assert_eq!(
22965                initial_requests.len(),
22966                2,
22967                "custom UI should issue an initial poll and first frame"
22968            );
22969
22970            let mut initial_frame_call = None;
22971            let mut initial_poll_call = None;
22972            let mut unexpected_initial_hostcall = None;
22973            for request in initial_requests {
22974                match &request.kind {
22975                    HostcallKind::Ui { op } if op == "setWidget" => {
22976                        initial_frame_call = Some(request);
22977                    }
22978                    HostcallKind::Ui { op } if op == "custom" => {
22979                        initial_poll_call = Some(request);
22980                    }
22981                    other => {
22982                        unexpected_initial_hostcall = Some(format!("{other:?}"));
22983                    }
22984                }
22985            }
22986            assert_eq!(
22987                unexpected_initial_hostcall, None,
22988                "unexpected initial hostcall"
22989            );
22990
22991            let initial_frame_call = initial_frame_call.expect("initial frame hostcall");
22992            assert_eq!(
22993                initial_frame_call.payload["lines"],
22994                serde_json::json!(["width:80"])
22995            );
22996            runtime.complete_hostcall(
22997                initial_frame_call.call_id,
22998                HostcallOutcome::Success(serde_json::json!(null)),
22999            );
23000
23001            let initial_poll_call = initial_poll_call.expect("initial poll hostcall");
23002            runtime.complete_hostcall(
23003                initial_poll_call.call_id,
23004                HostcallOutcome::Success(serde_json::json!({ "width": 80 })),
23005            );
23006
23007            runtime
23008                .tick()
23009                .await
23010                .expect("deliver initial frame completion");
23011            runtime
23012                .tick()
23013                .await
23014                .expect("deliver initial poll completion");
23015            assert_eq!(
23016                get_global_json(&runtime, "renderWidths").await,
23017                serde_json::json!([80])
23018            );
23019
23020            let mut saw_post_startup_poll = false;
23021            for step in 0..12 {
23022                let next_deadline = runtime
23023                    .scheduler
23024                    .borrow()
23025                    .next_timer_deadline()
23026                    .expect("custom UI should keep timers alive");
23027                clock.set(next_deadline);
23028
23029                let stats = runtime.tick().await.expect("tick timer");
23030                assert!(
23031                    stats.ran_macrotask,
23032                    "expected timer macrotask at step {step}"
23033                );
23034
23035                let requests = runtime.drain_hostcall_requests();
23036                if requests.is_empty() {
23037                    continue;
23038                }
23039                assert_eq!(requests.len(), 1, "expected one hostcall at step {step}");
23040                let request = requests.into_iter().next().expect("hostcall request");
23041
23042                match &request.kind {
23043                    HostcallKind::Ui { op } if op == "custom" => {
23044                        saw_post_startup_poll = true;
23045                        runtime.complete_hostcall(
23046                            request.call_id,
23047                            HostcallOutcome::Success(serde_json::json!({ "width": 40 })),
23048                        );
23049                        runtime.tick().await.expect("deliver poll completion");
23050                    }
23051                    HostcallKind::Ui { op } if op == "setWidget" => {
23052                        assert!(
23053                            saw_post_startup_poll,
23054                            "startup should not enqueue a redundant timer-driven frame"
23055                        );
23056                        assert_eq!(
23057                            request.payload["lines"],
23058                            serde_json::json!(["width:40"]),
23059                            "width change should trigger a reflow frame"
23060                        );
23061                        return;
23062                    }
23063                    other => panic!("unexpected hostcall at step {step}: {other:?}"),
23064                }
23065            }
23066
23067            panic!("did not observe a width-change reflow frame");
23068        });
23069    }
23070
23071    #[test]
23072    fn pijs_hostcall_timeout_rejects_promise() {
23073        futures::executor::block_on(async {
23074            let clock = Arc::new(DeterministicClock::new(0));
23075            let mut config = PiJsRuntimeConfig::default();
23076            config.limits.hostcall_timeout_ms = Some(50);
23077
23078            let runtime =
23079                PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
23080                    .await
23081                    .expect("create runtime");
23082
23083            runtime
23084                .eval(
23085                    r#"
23086                    globalThis.done = false;
23087                    globalThis.code = null;
23088                    pi.tool("read", { path: "test.txt" })
23089                        .then(() => { globalThis.done = true; })
23090                        .catch((e) => { globalThis.code = e.code; globalThis.done = true; });
23091                    "#,
23092                )
23093                .await
23094                .expect("eval");
23095
23096            let requests = runtime.drain_hostcall_requests();
23097            assert_eq!(requests.len(), 1);
23098
23099            clock.set(50);
23100            let stats = runtime.tick().await.expect("tick");
23101            assert!(stats.ran_macrotask);
23102            assert_eq!(stats.hostcalls_timed_out, 1);
23103            assert_eq!(
23104                get_global_json(&runtime, "done").await,
23105                serde_json::json!(true)
23106            );
23107            assert_eq!(
23108                get_global_json(&runtime, "code").await,
23109                serde_json::json!("timeout")
23110            );
23111
23112            // Late completions should be ignored.
23113            runtime.complete_hostcall(
23114                requests[0].call_id.clone(),
23115                HostcallOutcome::Success(serde_json::json!({ "ok": true })),
23116            );
23117            let stats = runtime.tick().await.expect("tick late completion");
23118            assert!(stats.ran_macrotask);
23119            assert_eq!(stats.hostcalls_timed_out, 1);
23120        });
23121    }
23122
23123    #[test]
23124    fn pijs_interrupt_budget_aborts_eval() {
23125        futures::executor::block_on(async {
23126            let mut config = PiJsRuntimeConfig::default();
23127            config.limits.interrupt_budget = Some(0);
23128
23129            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
23130                DeterministicClock::new(0),
23131                config,
23132                None,
23133            )
23134            .await
23135            .expect("create runtime");
23136
23137            let err = runtime
23138                .eval(
23139                    r"
23140                    let sum = 0;
23141                    for (let i = 0; i < 1000000; i++) { sum += i; }
23142                    ",
23143                )
23144                .await
23145                .expect_err("expected budget exceed");
23146
23147            assert!(err.to_string().contains("PiJS execution budget exceeded"));
23148        });
23149    }
23150
23151    #[test]
23152    fn pijs_microtasks_drain_before_next_macrotask() {
23153        futures::executor::block_on(async {
23154            let clock = Arc::new(DeterministicClock::new(0));
23155            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23156                .await
23157                .expect("create runtime");
23158
23159            runtime
23160                .eval(r"globalThis.order = []; globalThis.__pi_done = false;")
23161                .await
23162                .expect("init order");
23163
23164            let timer_id = runtime.set_timeout(10);
23165            runtime
23166                .eval(&format!(
23167                    r#"__pi_register_timer({timer_id}, () => {{
23168                        globalThis.order.push("timer");
23169                        Promise.resolve().then(() => globalThis.order.push("timer-micro"));
23170                    }});"#
23171                ))
23172                .await
23173                .expect("register timer");
23174
23175            runtime
23176                .eval(
23177                    r#"
23178                    pi.tool("read", {}).then(() => {
23179                        globalThis.order.push("hostcall");
23180                        Promise.resolve().then(() => globalThis.order.push("hostcall-micro"));
23181                    });
23182                    "#,
23183                )
23184                .await
23185                .expect("enqueue hostcall");
23186
23187            let requests = runtime.drain_hostcall_requests();
23188            let call_id = requests
23189                .into_iter()
23190                .next()
23191                .expect("hostcall request")
23192                .call_id;
23193
23194            runtime.complete_hostcall(call_id, HostcallOutcome::Success(serde_json::json!(null)));
23195
23196            // Make the timer due as well.
23197            clock.set(10);
23198
23199            // Tick 1: hostcall completion runs first, and its microtasks drain immediately.
23200            runtime.tick().await.expect("tick hostcall");
23201            let after_first = get_global_json(&runtime, "order").await;
23202            assert_eq!(
23203                after_first,
23204                serde_json::json!(["hostcall", "hostcall-micro"])
23205            );
23206
23207            // Tick 2: timer runs, and its microtasks drain before the next macrotask.
23208            runtime.tick().await.expect("tick timer");
23209            let after_second = get_global_json(&runtime, "order").await;
23210            assert_eq!(
23211                after_second,
23212                serde_json::json!(["hostcall", "hostcall-micro", "timer", "timer-micro"])
23213            );
23214        });
23215    }
23216
23217    #[test]
23218    fn pijs_clear_timeout_prevents_timer_callback() {
23219        futures::executor::block_on(async {
23220            let clock = Arc::new(DeterministicClock::new(0));
23221            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23222                .await
23223                .expect("create runtime");
23224
23225            runtime
23226                .eval(r"globalThis.order = []; ")
23227                .await
23228                .expect("init order");
23229
23230            let timer_id = runtime.set_timeout(10);
23231            runtime
23232                .eval(&format!(
23233                    r#"__pi_register_timer({timer_id}, () => globalThis.order.push("timer"));"#
23234                ))
23235                .await
23236                .expect("register timer");
23237
23238            assert!(runtime.clear_timeout(timer_id));
23239            clock.set(10);
23240
23241            let stats = runtime.tick().await.expect("tick");
23242            assert!(!stats.ran_macrotask);
23243
23244            let order = get_global_json(&runtime, "order").await;
23245            assert_eq!(order, serde_json::json!([]));
23246        });
23247    }
23248
23249    #[test]
23250    fn pijs_env_get_honors_allowlist() {
23251        futures::executor::block_on(async {
23252            let clock = Arc::new(DeterministicClock::new(0));
23253            let mut env = HashMap::new();
23254            env.insert("HOME".to_string(), "/virtual/home".to_string());
23255            env.insert("PI_IMAGE_SAVE_MODE".to_string(), "tmp".to_string());
23256            env.insert(
23257                "AWS_SECRET_ACCESS_KEY".to_string(),
23258                "nope-do-not-expose".to_string(),
23259            );
23260            let config = PiJsRuntimeConfig {
23261                cwd: "/virtual/cwd".to_string(),
23262                args: vec!["--flag".to_string()],
23263                env,
23264                limits: PiJsRuntimeLimits::default(),
23265                repair_mode: RepairMode::default(),
23266                allow_unsafe_sync_exec: false,
23267                deny_env: false,
23268                disk_cache_dir: None,
23269            };
23270            let runtime =
23271                PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
23272                    .await
23273                    .expect("create runtime");
23274
23275            runtime
23276                .eval(
23277                    r#"
23278                    globalThis.home = pi.env.get("HOME");
23279                    globalThis.mode = pi.env.get("PI_IMAGE_SAVE_MODE");
23280                    globalThis.missing_is_undefined = (pi.env.get("NOPE") === undefined);
23281                    globalThis.secret_is_undefined = (pi.env.get("AWS_SECRET_ACCESS_KEY") === undefined);
23282                    globalThis.process_secret_is_undefined = (process.env.AWS_SECRET_ACCESS_KEY === undefined);
23283                    globalThis.secret_in_env = ("AWS_SECRET_ACCESS_KEY" in process.env);
23284                    "#,
23285                )
23286                .await
23287                .expect("eval env");
23288
23289            assert_eq!(
23290                get_global_json(&runtime, "home").await,
23291                serde_json::json!("/virtual/home")
23292            );
23293            assert_eq!(
23294                get_global_json(&runtime, "mode").await,
23295                serde_json::json!("tmp")
23296            );
23297            assert_eq!(
23298                get_global_json(&runtime, "missing_is_undefined").await,
23299                serde_json::json!(true)
23300            );
23301            assert_eq!(
23302                get_global_json(&runtime, "secret_is_undefined").await,
23303                serde_json::json!(true)
23304            );
23305            assert_eq!(
23306                get_global_json(&runtime, "process_secret_is_undefined").await,
23307                serde_json::json!(true)
23308            );
23309            assert_eq!(
23310                get_global_json(&runtime, "secret_in_env").await,
23311                serde_json::json!(false)
23312            );
23313        });
23314    }
23315
23316    #[test]
23317    fn pijs_process_path_crypto_time_apis_smoke() {
23318        futures::executor::block_on(async {
23319            let clock = Arc::new(DeterministicClock::new(123));
23320            let config = PiJsRuntimeConfig {
23321                cwd: "/virtual/cwd".to_string(),
23322                args: vec!["a".to_string(), "b".to_string()],
23323                env: HashMap::new(),
23324                limits: PiJsRuntimeLimits::default(),
23325                repair_mode: RepairMode::default(),
23326                allow_unsafe_sync_exec: false,
23327                deny_env: false,
23328                disk_cache_dir: None,
23329            };
23330            let runtime =
23331                PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
23332                    .await
23333                    .expect("create runtime");
23334
23335            runtime
23336                .eval(
23337                    r#"
23338                    globalThis.cwd = pi.process.cwd;
23339                    globalThis.args = pi.process.args;
23340                    globalThis.pi_process_is_frozen = Object.isFrozen(pi.process);
23341                    globalThis.pi_args_is_frozen = Object.isFrozen(pi.process.args);
23342                    try { pi.process.cwd = "/hacked"; } catch (_) {}
23343                    try { pi.process.args.push("c"); } catch (_) {}
23344                    globalThis.cwd_after_mut = pi.process.cwd;
23345                    globalThis.args_after_mut = pi.process.args;
23346
23347                    globalThis.joined = pi.path.join("/a", "b", "..", "c");
23348                    globalThis.base = pi.path.basename("/a/b/c.txt");
23349                    globalThis.norm = pi.path.normalize("/a/./b//../c/");
23350
23351                    globalThis.bytes = pi.crypto.randomBytes(32);
23352
23353                    globalThis.now = pi.time.nowMs();
23354                    globalThis.done = false;
23355                    pi.time.sleep(10).then(() => { globalThis.done = true; });
23356                    "#,
23357                )
23358                .await
23359                .expect("eval apis");
23360
23361            for (key, expected) in [
23362                ("cwd", serde_json::json!("/virtual/cwd")),
23363                ("args", serde_json::json!(["a", "b"])),
23364                ("pi_process_is_frozen", serde_json::json!(true)),
23365                ("pi_args_is_frozen", serde_json::json!(true)),
23366                ("cwd_after_mut", serde_json::json!("/virtual/cwd")),
23367                ("args_after_mut", serde_json::json!(["a", "b"])),
23368                ("joined", serde_json::json!("/a/c")),
23369                ("base", serde_json::json!("c.txt")),
23370                ("norm", serde_json::json!("/a/c")),
23371            ] {
23372                assert_eq!(get_global_json(&runtime, key).await, expected);
23373            }
23374
23375            let bytes = get_global_json(&runtime, "bytes").await;
23376            let bytes_arr = bytes.as_array().expect("bytes array");
23377            assert_eq!(bytes_arr.len(), 32);
23378            assert!(
23379                bytes_arr
23380                    .iter()
23381                    .all(|value| value.as_u64().is_some_and(|n| n <= 255)),
23382                "bytes must be numbers in 0..=255: {bytes}"
23383            );
23384
23385            assert_eq!(
23386                get_global_json(&runtime, "now").await,
23387                serde_json::json!(123)
23388            );
23389            assert_eq!(
23390                get_global_json(&runtime, "done").await,
23391                serde_json::json!(false)
23392            );
23393
23394            clock.set(133);
23395            runtime.tick().await.expect("tick sleep");
23396            assert_eq!(
23397                get_global_json(&runtime, "done").await,
23398                serde_json::json!(true)
23399            );
23400        });
23401    }
23402
23403    #[test]
23404    fn pijs_random_bytes_helper_propagates_fill_errors() {
23405        let err = fill_random_bytes_with(16, |_| Err("entropy unavailable")).unwrap_err();
23406        assert_eq!(err, "entropy unavailable");
23407    }
23408
23409    #[test]
23410    fn pijs_crypto_random_bytes_are_not_uuid_patterned() {
23411        futures::executor::block_on(async {
23412            let clock = Arc::new(DeterministicClock::new(0));
23413            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23414                .await
23415                .expect("create runtime");
23416
23417            runtime
23418                .eval(
23419                    r"
23420                    const bytes = pi.crypto.randomBytes(128);
23421                    const blocks = [];
23422                    for (let i = 0; i < bytes.length; i += 16) {
23423                        blocks.push({
23424                            versionNibble: (bytes[i + 6] >> 4) & 0x0f,
23425                            variantBits: (bytes[i + 8] >> 6) & 0x03,
23426                        });
23427                    }
23428                    globalThis.randomBytesLookLikeUuidBlocks = blocks.every(
23429                        (block) => block.versionNibble === 4 && block.variantBits === 2,
23430                    );
23431                    ",
23432                )
23433                .await
23434                .expect("eval random bytes pattern");
23435
23436            assert_eq!(
23437                get_global_json(&runtime, "randomBytesLookLikeUuidBlocks").await,
23438                serde_json::json!(false)
23439            );
23440        });
23441    }
23442
23443    #[test]
23444    fn pijs_inbound_event_fifo_and_microtask_fixpoint() {
23445        futures::executor::block_on(async {
23446            let clock = Arc::new(DeterministicClock::new(0));
23447            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23448                .await
23449                .expect("create runtime");
23450
23451            runtime
23452                .eval(
23453                    r#"
23454                    globalThis.order = [];
23455                    __pi_add_event_listener("evt", (payload) => {
23456                        globalThis.order.push(payload.n);
23457                        Promise.resolve().then(() => globalThis.order.push(payload.n + 1000));
23458                    });
23459                    "#,
23460                )
23461                .await
23462                .expect("install listener");
23463
23464            runtime.enqueue_event("evt", serde_json::json!({ "n": 1 }));
23465            runtime.enqueue_event("evt", serde_json::json!({ "n": 2 }));
23466
23467            runtime.tick().await.expect("tick 1");
23468            let after_first = get_global_json(&runtime, "order").await;
23469            assert_eq!(after_first, serde_json::json!([1, 1001]));
23470
23471            runtime.tick().await.expect("tick 2");
23472            let after_second = get_global_json(&runtime, "order").await;
23473            assert_eq!(after_second, serde_json::json!([1, 1001, 2, 1002]));
23474        });
23475    }
23476
23477    #[derive(Debug, Clone)]
23478    struct XorShift64 {
23479        state: u64,
23480    }
23481
23482    impl XorShift64 {
23483        const fn new(seed: u64) -> Self {
23484            let seed = seed ^ 0x9E37_79B9_7F4A_7C15;
23485            Self { state: seed }
23486        }
23487
23488        fn next_u64(&mut self) -> u64 {
23489            let mut x = self.state;
23490            x ^= x << 13;
23491            x ^= x >> 7;
23492            x ^= x << 17;
23493            self.state = x;
23494            x
23495        }
23496
23497        fn next_range_u64(&mut self, upper_exclusive: u64) -> u64 {
23498            if upper_exclusive == 0 {
23499                return 0;
23500            }
23501            self.next_u64() % upper_exclusive
23502        }
23503
23504        fn next_usize(&mut self, upper_exclusive: usize) -> usize {
23505            let upper = u64::try_from(upper_exclusive).expect("usize fits u64");
23506            let value = self.next_range_u64(upper);
23507            usize::try_from(value).expect("value < upper_exclusive")
23508        }
23509    }
23510
23511    #[allow(clippy::future_not_send)]
23512    async fn run_seeded_runtime_trace(seed: u64) -> serde_json::Value {
23513        let clock = Arc::new(DeterministicClock::new(0));
23514        let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23515            .await
23516            .expect("create runtime");
23517
23518        runtime
23519            .eval(
23520                r#"
23521                globalThis.order = [];
23522                __pi_add_event_listener("evt", (payload) => {
23523                    globalThis.order.push("event:" + payload.step);
23524                    Promise.resolve().then(() => globalThis.order.push("event-micro:" + payload.step));
23525                });
23526                "#,
23527            )
23528            .await
23529            .expect("init");
23530
23531        let mut rng = XorShift64::new(seed);
23532        let mut timers = Vec::new();
23533
23534        for step in 0..64u64 {
23535            match rng.next_range_u64(6) {
23536                0 => {
23537                    runtime
23538                        .eval(&format!(
23539                            r#"
23540                            pi.tool("test", {{ step: {step} }}).then(() => {{
23541                                globalThis.order.push("hostcall:{step}");
23542                                Promise.resolve().then(() => globalThis.order.push("hostcall-micro:{step}"));
23543                            }});
23544                            "#
23545                        ))
23546                        .await
23547                        .expect("enqueue hostcall");
23548
23549                    for request in runtime.drain_hostcall_requests() {
23550                        runtime.complete_hostcall(
23551                            request.call_id,
23552                            HostcallOutcome::Success(serde_json::json!({ "step": step })),
23553                        );
23554                    }
23555                }
23556                1 => {
23557                    let delay_ms = rng.next_range_u64(25);
23558                    let timer_id = runtime.set_timeout(delay_ms);
23559                    timers.push(timer_id);
23560                    runtime
23561                        .eval(&format!(
23562                            r#"__pi_register_timer({timer_id}, () => {{
23563                                globalThis.order.push("timer:{step}");
23564                                Promise.resolve().then(() => globalThis.order.push("timer-micro:{step}"));
23565                            }});"#
23566                        ))
23567                        .await
23568                        .expect("register timer");
23569                }
23570                2 => {
23571                    runtime.enqueue_event("evt", serde_json::json!({ "step": step }));
23572                }
23573                3 if !timers.is_empty() => {
23574                    let idx = rng.next_usize(timers.len());
23575                    let _ = runtime.clear_timeout(timers[idx]);
23576                }
23577                4 => {
23578                    let delta_ms = rng.next_range_u64(50);
23579                    clock.advance(delta_ms);
23580                }
23581                _ => {}
23582            }
23583
23584            // Drive the loop a bit.
23585            for _ in 0..3 {
23586                if !runtime.has_pending() {
23587                    break;
23588                }
23589                let _ = runtime.tick().await.expect("tick");
23590            }
23591        }
23592
23593        drain_until_idle(&runtime, &clock).await;
23594        get_global_json(&runtime, "order").await
23595    }
23596
23597    #[test]
23598    fn pijs_seeded_trace_is_deterministic() {
23599        futures::executor::block_on(async {
23600            let a = run_seeded_runtime_trace(0x00C0_FFEE).await;
23601            let b = run_seeded_runtime_trace(0x00C0_FFEE).await;
23602            assert_eq!(a, b);
23603        });
23604    }
23605
23606    #[test]
23607    fn pijs_events_on_returns_unsubscribe_and_removes_handler() {
23608        futures::executor::block_on(async {
23609            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23610                .await
23611                .expect("create runtime");
23612
23613            runtime
23614                .eval(
23615                    r#"
23616                    globalThis.seen = [];
23617                    globalThis.done = false;
23618
23619                    __pi_begin_extension("ext.b", { name: "ext.b" });
23620                    const off = pi.events.on("custom_event", (payload, _ctx) => { globalThis.seen.push(payload); });
23621                    if (typeof off !== "function") throw new Error("expected unsubscribe function");
23622                    __pi_end_extension();
23623
23624                    (async () => {
23625                      await __pi_dispatch_extension_event("custom_event", { n: 1 }, {});
23626                      off();
23627                      await __pi_dispatch_extension_event("custom_event", { n: 2 }, {});
23628                      globalThis.done = true;
23629                    })();
23630                "#,
23631                )
23632                .await
23633                .expect("eval");
23634
23635            assert_eq!(
23636                get_global_json(&runtime, "done").await,
23637                serde_json::Value::Bool(true)
23638            );
23639            assert_eq!(
23640                get_global_json(&runtime, "seen").await,
23641                serde_json::json!([{ "n": 1 }])
23642            );
23643        });
23644    }
23645
23646    #[test]
23647    fn pijs_event_dispatch_continues_after_handler_error() {
23648        futures::executor::block_on(async {
23649            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23650                .await
23651                .expect("create runtime");
23652
23653            runtime
23654                .eval(
23655                    r#"
23656                    globalThis.seen = [];
23657                    globalThis.done = false;
23658
23659                    __pi_begin_extension("ext.err", { name: "ext.err" });
23660                    pi.events.on("custom_event", (_payload, _ctx) => { throw new Error("boom"); });
23661                    __pi_end_extension();
23662
23663                    __pi_begin_extension("ext.ok", { name: "ext.ok" });
23664                    pi.events.on("custom_event", (payload, _ctx) => { globalThis.seen.push(payload); });
23665                    __pi_end_extension();
23666
23667                    (async () => {
23668                      await __pi_dispatch_extension_event("custom_event", { hello: "world" }, {});
23669                      globalThis.done = true;
23670                    })();
23671                "#,
23672                )
23673                .await
23674                .expect("eval");
23675
23676            assert_eq!(
23677                get_global_json(&runtime, "done").await,
23678                serde_json::Value::Bool(true)
23679            );
23680            assert_eq!(
23681                get_global_json(&runtime, "seen").await,
23682                serde_json::json!([{ "hello": "world" }])
23683            );
23684        });
23685    }
23686
23687    // ---- Extension crash recovery and isolation tests (bd-m4wc) ----
23688
23689    #[test]
23690    fn pijs_crash_register_throw_host_continues() {
23691        futures::executor::block_on(async {
23692            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23693                .await
23694                .expect("create runtime");
23695
23696            // Extension that throws during registration
23697            runtime
23698                .eval(
23699                    r#"
23700                    globalThis.postCrashResult = null;
23701
23702                    __pi_begin_extension("ext.crash", { name: "ext.crash" });
23703                    // Simulate a throw during registration by registering a handler then
23704                    // throwing - the handler should still be partially registered
23705                    throw new Error("registration boom");
23706                "#,
23707                )
23708                .await
23709                .ok(); // May fail, that's fine
23710
23711            // End the crashed extension context
23712            runtime.eval(r"__pi_end_extension();").await.ok();
23713
23714            // Host can still load another extension after the crash
23715            runtime
23716                .eval(
23717                    r#"
23718                    __pi_begin_extension("ext.ok", { name: "ext.ok" });
23719                    pi.events.on("test_event", (p, _) => { globalThis.postCrashResult = p; });
23720                    __pi_end_extension();
23721                "#,
23722                )
23723                .await
23724                .expect("second extension should load");
23725
23726            // Dispatch event - only the healthy extension should handle it
23727            runtime
23728                .eval(
23729                    r#"
23730                    (async () => {
23731                        await __pi_dispatch_extension_event("test_event", { ok: true }, {});
23732                    })();
23733                "#,
23734                )
23735                .await
23736                .expect("dispatch");
23737
23738            assert_eq!(
23739                get_global_json(&runtime, "postCrashResult").await,
23740                serde_json::json!({ "ok": true })
23741            );
23742        });
23743    }
23744
23745    #[test]
23746    fn pijs_crash_handler_throw_other_handlers_run() {
23747        futures::executor::block_on(async {
23748            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23749                .await
23750                .expect("create runtime");
23751
23752            runtime
23753                .eval(
23754                    r#"
23755                    globalThis.handlerResults = [];
23756                    globalThis.dispatchDone = false;
23757
23758                    // Extension A: will throw
23759                    __pi_begin_extension("ext.a", { name: "ext.a" });
23760                    pi.events.on("multi_test", (_p, _c) => {
23761                        globalThis.handlerResults.push("a-before-throw");
23762                        throw new Error("handler crash");
23763                    });
23764                    __pi_end_extension();
23765
23766                    // Extension B: should still run
23767                    __pi_begin_extension("ext.b", { name: "ext.b" });
23768                    pi.events.on("multi_test", (_p, _c) => {
23769                        globalThis.handlerResults.push("b-ok");
23770                    });
23771                    __pi_end_extension();
23772
23773                    // Extension C: should also still run
23774                    __pi_begin_extension("ext.c", { name: "ext.c" });
23775                    pi.events.on("multi_test", (_p, _c) => {
23776                        globalThis.handlerResults.push("c-ok");
23777                    });
23778                    __pi_end_extension();
23779
23780                    (async () => {
23781                        await __pi_dispatch_extension_event("multi_test", {}, {});
23782                        globalThis.dispatchDone = true;
23783                    })();
23784                "#,
23785                )
23786                .await
23787                .expect("eval");
23788
23789            assert_eq!(
23790                get_global_json(&runtime, "dispatchDone").await,
23791                serde_json::Value::Bool(true)
23792            );
23793
23794            let results = get_global_json(&runtime, "handlerResults").await;
23795            let arr = results.as_array().expect("should be array");
23796            // Handler A ran (at least the part before throw)
23797            assert!(
23798                arr.iter().any(|v| v == "a-before-throw"),
23799                "Handler A should have run before throwing"
23800            );
23801            // Handlers B and C should have run despite A's crash
23802            assert!(
23803                arr.iter().any(|v| v == "b-ok"),
23804                "Handler B should run after A crashes"
23805            );
23806            assert!(
23807                arr.iter().any(|v| v == "c-ok"),
23808                "Handler C should run after A crashes"
23809            );
23810        });
23811    }
23812
23813    #[test]
23814    fn pijs_crash_invalid_hostcall_returns_error_not_panic() {
23815        futures::executor::block_on(async {
23816            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23817                .await
23818                .expect("create runtime");
23819
23820            // Extension makes an invalid hostcall (unknown tool)
23821            runtime
23822                .eval(
23823                    r#"
23824                    globalThis.invalidResult = null;
23825                    globalThis.errCode = null;
23826
23827                    __pi_begin_extension("ext.bad", { name: "ext.bad" });
23828                    pi.tool("completely_nonexistent_tool_xyz", { junk: true })
23829                        .then((r) => { globalThis.invalidResult = r; })
23830                        .catch((e) => { globalThis.errCode = e.code || "unknown"; });
23831                    __pi_end_extension();
23832                "#,
23833                )
23834                .await
23835                .expect("eval");
23836
23837            // The hostcall should be queued but not crash the runtime
23838            let requests = runtime.drain_hostcall_requests();
23839            assert_eq!(requests.len(), 1, "Hostcall should be queued");
23840
23841            // Host can still evaluate JS after the invalid hostcall
23842            runtime
23843                .eval(
23844                    r"
23845                    globalThis.hostStillAlive = true;
23846                ",
23847                )
23848                .await
23849                .expect("host should still work");
23850
23851            assert_eq!(
23852                get_global_json(&runtime, "hostStillAlive").await,
23853                serde_json::Value::Bool(true)
23854            );
23855        });
23856    }
23857
23858    #[test]
23859    fn pijs_crash_after_crash_new_extensions_load() {
23860        futures::executor::block_on(async {
23861            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23862                .await
23863                .expect("create runtime");
23864
23865            // Simulate a crash sequence: extension throws, then new ones load fine
23866            runtime
23867                .eval(
23868                    r#"
23869                    globalThis.loadOrder = [];
23870
23871                    // Extension 1: loads fine
23872                    __pi_begin_extension("ext.1", { name: "ext.1" });
23873                    globalThis.loadOrder.push("1-loaded");
23874                    __pi_end_extension();
23875                "#,
23876                )
23877                .await
23878                .expect("ext 1");
23879
23880            // Extension 2: crashes during eval
23881            runtime
23882                .eval(
23883                    r#"
23884                    __pi_begin_extension("ext.2", { name: "ext.2" });
23885                    globalThis.loadOrder.push("2-before-crash");
23886                    throw new Error("ext 2 crash");
23887                "#,
23888                )
23889                .await
23890                .ok(); // Expected to fail
23891
23892            runtime.eval(r"__pi_end_extension();").await.ok();
23893
23894            // Extension 3: should still load after ext 2's crash
23895            runtime
23896                .eval(
23897                    r#"
23898                    __pi_begin_extension("ext.3", { name: "ext.3" });
23899                    globalThis.loadOrder.push("3-loaded");
23900                    __pi_end_extension();
23901                "#,
23902                )
23903                .await
23904                .expect("ext 3 should load after crash");
23905
23906            // Extension 4: loads fine too
23907            runtime
23908                .eval(
23909                    r#"
23910                    __pi_begin_extension("ext.4", { name: "ext.4" });
23911                    globalThis.loadOrder.push("4-loaded");
23912                    __pi_end_extension();
23913                "#,
23914                )
23915                .await
23916                .expect("ext 4 should load");
23917
23918            let order = get_global_json(&runtime, "loadOrder").await;
23919            let arr = order.as_array().expect("should be array");
23920            assert!(
23921                arr.iter().any(|v| v == "1-loaded"),
23922                "Extension 1 should have loaded"
23923            );
23924            assert!(
23925                arr.iter().any(|v| v == "3-loaded"),
23926                "Extension 3 should load after crash"
23927            );
23928            assert!(
23929                arr.iter().any(|v| v == "4-loaded"),
23930                "Extension 4 should load after crash"
23931            );
23932        });
23933    }
23934
23935    #[test]
23936    fn pijs_crash_no_cross_contamination_between_extensions() {
23937        futures::executor::block_on(async {
23938            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23939                .await
23940                .expect("create runtime");
23941
23942            runtime
23943                .eval(
23944                    r#"
23945                    globalThis.extAData = null;
23946                    globalThis.extBData = null;
23947                    globalThis.eventsDone = false;
23948
23949                    // Extension A: sets its own state
23950                    __pi_begin_extension("ext.isolated.a", { name: "ext.isolated.a" });
23951                    pi.events.on("isolation_test", (_p, _c) => {
23952                        globalThis.extAData = "from-A";
23953                    });
23954                    __pi_end_extension();
23955
23956                    // Extension B: sets its own state independently
23957                    __pi_begin_extension("ext.isolated.b", { name: "ext.isolated.b" });
23958                    pi.events.on("isolation_test", (_p, _c) => {
23959                        globalThis.extBData = "from-B";
23960                    });
23961                    __pi_end_extension();
23962
23963                    (async () => {
23964                        await __pi_dispatch_extension_event("isolation_test", {}, {});
23965                        globalThis.eventsDone = true;
23966                    })();
23967                "#,
23968                )
23969                .await
23970                .expect("eval");
23971
23972            assert_eq!(
23973                get_global_json(&runtime, "eventsDone").await,
23974                serde_json::Value::Bool(true)
23975            );
23976            // Each extension should have set its own global independently
23977            assert_eq!(
23978                get_global_json(&runtime, "extAData").await,
23979                serde_json::json!("from-A")
23980            );
23981            assert_eq!(
23982                get_global_json(&runtime, "extBData").await,
23983                serde_json::json!("from-B")
23984            );
23985        });
23986    }
23987
23988    #[test]
23989    fn pijs_host_read_denies_cross_extension_root_access() {
23990        futures::executor::block_on(async {
23991            let temp_dir = tempfile::tempdir().expect("tempdir");
23992            let workspace = temp_dir.path().join("workspace");
23993            let ext_a = temp_dir.path().join("ext-a");
23994            let ext_b = temp_dir.path().join("ext-b");
23995            std::fs::create_dir_all(&workspace).expect("mkdir workspace");
23996            std::fs::create_dir_all(&ext_a).expect("mkdir ext-a");
23997            std::fs::create_dir_all(&ext_b).expect("mkdir ext-b");
23998            let secret_path = ext_a.join("secret.txt");
23999            std::fs::write(&secret_path, "top-secret").expect("write secret");
24000
24001            let config = PiJsRuntimeConfig {
24002                cwd: workspace.display().to_string(),
24003                ..PiJsRuntimeConfig::default()
24004            };
24005            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
24006                DeterministicClock::new(0),
24007                config,
24008                None,
24009            )
24010            .await
24011            .expect("create runtime");
24012            runtime.add_extension_root_with_id(ext_a, Some("ext.a"));
24013            runtime.add_extension_root_with_id(ext_b, Some("ext.b"));
24014
24015            let script = format!(
24016                r#"
24017                globalThis.crossExtensionRead = {{}};
24018                import('node:module').then(({{ createRequire }}) => {{
24019                    const require = createRequire('/tmp/example.js');
24020                    const fs = require('node:fs');
24021                    return __pi_with_extension_async("ext.b", async () => {{
24022                        try {{
24023                            globalThis.crossExtensionRead.value = fs.readFileSync({secret_path:?}, 'utf8');
24024                            globalThis.crossExtensionRead.ok = true;
24025                        }} catch (err) {{
24026                            globalThis.crossExtensionRead.ok = false;
24027                            globalThis.crossExtensionRead.error = String((err && err.message) || err || '');
24028                        }}
24029                    }});
24030                }}).finally(() => {{
24031                    globalThis.crossExtensionRead.done = true;
24032                }});
24033                "#
24034            );
24035            runtime
24036                .eval(&script)
24037                .await
24038                .expect("eval cross-extension read");
24039
24040            let result = get_global_json(&runtime, "crossExtensionRead").await;
24041            assert_eq!(result["done"], serde_json::json!(true));
24042            assert_eq!(result["ok"], serde_json::json!(false));
24043            let error = result["error"].as_str().unwrap_or_default();
24044            assert!(
24045                error.contains("host read denied"),
24046                "expected host read denial, got: {error}"
24047            );
24048        });
24049    }
24050
24051    #[test]
24052    fn pijs_host_read_allows_idless_extension_root_for_active_extension() {
24053        futures::executor::block_on(async {
24054            let temp_dir = tempfile::tempdir().expect("tempdir");
24055            let workspace = temp_dir.path().join("workspace");
24056            let ext_root = temp_dir.path().join("ext");
24057            std::fs::create_dir_all(&workspace).expect("mkdir workspace");
24058            std::fs::create_dir_all(&ext_root).expect("mkdir ext");
24059            let asset_path = ext_root.join("asset.txt");
24060            std::fs::write(&asset_path, "legacy-root-access").expect("write asset");
24061
24062            let config = PiJsRuntimeConfig {
24063                cwd: workspace.display().to_string(),
24064                ..PiJsRuntimeConfig::default()
24065            };
24066            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
24067                DeterministicClock::new(0),
24068                config,
24069                None,
24070            )
24071            .await
24072            .expect("create runtime");
24073            runtime.add_extension_root(ext_root);
24074
24075            let script = format!(
24076                r#"
24077                globalThis.legacyRootRead = {{}};
24078                import('node:module').then(({{ createRequire }}) => {{
24079                    const require = createRequire('/tmp/example.js');
24080                    const fs = require('node:fs');
24081                    return __pi_with_extension_async("ext.legacy", async () => {{
24082                        try {{
24083                            globalThis.legacyRootRead.value = fs.readFileSync({asset_path:?}, 'utf8');
24084                            globalThis.legacyRootRead.ok = true;
24085                        }} catch (err) {{
24086                            globalThis.legacyRootRead.ok = false;
24087                            globalThis.legacyRootRead.error = String((err && err.message) || err || '');
24088                        }}
24089                    }});
24090                }}).finally(() => {{
24091                    globalThis.legacyRootRead.done = true;
24092                }});
24093                "#
24094            );
24095            runtime
24096                .eval(&script)
24097                .await
24098                .expect("eval id-less extension root read");
24099
24100            let result = get_global_json(&runtime, "legacyRootRead").await;
24101            assert_eq!(result["done"], serde_json::json!(true));
24102            assert_eq!(result["ok"], serde_json::json!(true));
24103            assert_eq!(result["value"], serde_json::json!("legacy-root-access"));
24104        });
24105    }
24106
24107    #[test]
24108    fn pijs_host_write_denies_cross_extension_root_access() {
24109        futures::executor::block_on(async {
24110            let temp_dir = tempfile::tempdir().expect("tempdir");
24111            let workspace = temp_dir.path().join("workspace");
24112            let ext_a = temp_dir.path().join("ext-a");
24113            let ext_b = temp_dir.path().join("ext-b");
24114            std::fs::create_dir_all(&workspace).expect("mkdir workspace");
24115            std::fs::create_dir_all(&ext_a).expect("mkdir ext-a");
24116            std::fs::create_dir_all(&ext_b).expect("mkdir ext-b");
24117            let target_path = ext_a.join("owned.txt");
24118
24119            let config = PiJsRuntimeConfig {
24120                cwd: workspace.display().to_string(),
24121                ..PiJsRuntimeConfig::default()
24122            };
24123            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
24124                DeterministicClock::new(0),
24125                config,
24126                None,
24127            )
24128            .await
24129            .expect("create runtime");
24130            runtime.add_extension_root_with_id(ext_a, Some("ext.a"));
24131            runtime.add_extension_root_with_id(ext_b, Some("ext.b"));
24132
24133            let script = format!(
24134                r#"
24135                globalThis.crossExtensionWrite = {{}};
24136                import('node:module').then(({{ createRequire }}) => {{
24137                    const require = createRequire('/tmp/example.js');
24138                    const fs = require('node:fs');
24139                    return __pi_with_extension_async("ext.b", async () => {{
24140                        try {{
24141                            fs.writeFileSync({target_path:?}, 'owned');
24142                            globalThis.crossExtensionWrite.ok = true;
24143                        }} catch (err) {{
24144                            globalThis.crossExtensionWrite.ok = false;
24145                            globalThis.crossExtensionWrite.error = String((err && err.message) || err || '');
24146                        }}
24147                        globalThis.crossExtensionWrite.exists = fs.existsSync({target_path:?});
24148                    }});
24149                }}).finally(() => {{
24150                    globalThis.crossExtensionWrite.done = true;
24151                }});
24152                "#
24153            );
24154            runtime
24155                .eval(&script)
24156                .await
24157                .expect("eval cross-extension write");
24158
24159            let result = get_global_json(&runtime, "crossExtensionWrite").await;
24160            assert_eq!(result["done"], serde_json::json!(true));
24161            assert_eq!(result["ok"], serde_json::json!(false));
24162            assert_eq!(result["exists"], serde_json::json!(false));
24163            let error = result["error"].as_str().unwrap_or_default();
24164            assert!(
24165                error.contains("host write denied"),
24166                "expected host write denial, got: {error}"
24167            );
24168        });
24169    }
24170
24171    #[test]
24172    fn pijs_crash_interrupt_budget_stops_infinite_loop() {
24173        futures::executor::block_on(async {
24174            let config = PiJsRuntimeConfig {
24175                limits: PiJsRuntimeLimits {
24176                    // Use a small interrupt budget to catch infinite loops quickly
24177                    interrupt_budget: Some(1000),
24178                    ..Default::default()
24179                },
24180                ..Default::default()
24181            };
24182            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
24183                DeterministicClock::new(0),
24184                config,
24185                None,
24186            )
24187            .await
24188            .expect("create runtime");
24189
24190            // Try to run an infinite loop - should be interrupted by budget
24191            let result = runtime
24192                .eval(
24193                    r"
24194                    let i = 0;
24195                    while (true) { i++; }
24196                    globalThis.loopResult = i;
24197                ",
24198                )
24199                .await;
24200
24201            // The eval should fail due to interrupt
24202            assert!(
24203                result.is_err(),
24204                "Infinite loop should be interrupted by budget"
24205            );
24206
24207            // Host should still be alive after interrupt
24208            let alive_result = runtime.eval(r#"globalThis.postInterrupt = "alive";"#).await;
24209            // After an interrupt, the runtime may or may not accept new evals
24210            // The key assertion is that we didn't hang
24211            if alive_result.is_ok() {
24212                assert_eq!(
24213                    get_global_json(&runtime, "postInterrupt").await,
24214                    serde_json::json!("alive")
24215                );
24216            }
24217        });
24218    }
24219
24220    #[test]
24221    fn pijs_events_emit_queues_events_hostcall() {
24222        futures::executor::block_on(async {
24223            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
24224                .await
24225                .expect("create runtime");
24226
24227            runtime
24228                .eval(
24229                    r#"
24230                    __pi_begin_extension("ext.test", { name: "Test" });
24231                    pi.events.emit("custom_event", { a: 1 });
24232                    __pi_end_extension();
24233                "#,
24234                )
24235                .await
24236                .expect("eval");
24237
24238            let requests = runtime.drain_hostcall_requests();
24239            assert_eq!(requests.len(), 1);
24240
24241            let req = &requests[0];
24242            assert_eq!(req.extension_id.as_deref(), Some("ext.test"));
24243            assert!(
24244                matches!(&req.kind, HostcallKind::Events { op } if op == "emit"),
24245                "unexpected hostcall kind: {:?}",
24246                req.kind
24247            );
24248            assert_eq!(
24249                req.payload,
24250                serde_json::json!({ "event": "custom_event", "data": { "a": 1 } })
24251            );
24252        });
24253    }
24254
24255    #[test]
24256    fn pijs_console_global_is_defined_and_callable() {
24257        futures::executor::block_on(async {
24258            let clock = Arc::new(DeterministicClock::new(0));
24259            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24260                .await
24261                .expect("create runtime");
24262
24263            // Verify console global exists and all standard methods are functions
24264            runtime
24265                .eval(
24266                    r"
24267                    globalThis.console_exists = typeof globalThis.console === 'object';
24268                    globalThis.has_log   = typeof console.log   === 'function';
24269                    globalThis.has_warn  = typeof console.warn  === 'function';
24270                    globalThis.has_error = typeof console.error === 'function';
24271                    globalThis.has_info  = typeof console.info  === 'function';
24272                    globalThis.has_debug = typeof console.debug === 'function';
24273                    globalThis.has_trace = typeof console.trace === 'function';
24274                    globalThis.has_dir   = typeof console.dir   === 'function';
24275                    globalThis.has_assert = typeof console.assert === 'function';
24276                    globalThis.has_table = typeof console.table === 'function';
24277
24278                    // Call each method to ensure they don't throw
24279                    console.log('test log', 42, { key: 'value' });
24280                    console.warn('test warn');
24281                    console.error('test error');
24282                    console.info('test info');
24283                    console.debug('test debug');
24284                    console.trace('test trace');
24285                    console.dir({ a: 1 });
24286                    console.assert(true, 'should not appear');
24287                    console.assert(false, 'assertion failed message');
24288                    console.table([1, 2, 3]);
24289                    console.time();
24290                    console.timeEnd();
24291                    console.group();
24292                    console.groupEnd();
24293                    console.clear();
24294
24295                    globalThis.calls_succeeded = true;
24296                    ",
24297                )
24298                .await
24299                .expect("eval console tests");
24300
24301            assert_eq!(
24302                get_global_json(&runtime, "console_exists").await,
24303                serde_json::json!(true)
24304            );
24305            assert_eq!(
24306                get_global_json(&runtime, "has_log").await,
24307                serde_json::json!(true)
24308            );
24309            assert_eq!(
24310                get_global_json(&runtime, "has_warn").await,
24311                serde_json::json!(true)
24312            );
24313            assert_eq!(
24314                get_global_json(&runtime, "has_error").await,
24315                serde_json::json!(true)
24316            );
24317            assert_eq!(
24318                get_global_json(&runtime, "has_info").await,
24319                serde_json::json!(true)
24320            );
24321            assert_eq!(
24322                get_global_json(&runtime, "has_debug").await,
24323                serde_json::json!(true)
24324            );
24325            assert_eq!(
24326                get_global_json(&runtime, "has_trace").await,
24327                serde_json::json!(true)
24328            );
24329            assert_eq!(
24330                get_global_json(&runtime, "has_dir").await,
24331                serde_json::json!(true)
24332            );
24333            assert_eq!(
24334                get_global_json(&runtime, "has_assert").await,
24335                serde_json::json!(true)
24336            );
24337            assert_eq!(
24338                get_global_json(&runtime, "has_table").await,
24339                serde_json::json!(true)
24340            );
24341            assert_eq!(
24342                get_global_json(&runtime, "calls_succeeded").await,
24343                serde_json::json!(true)
24344            );
24345        });
24346    }
24347
24348    #[test]
24349    fn pijs_node_events_module_provides_event_emitter() {
24350        futures::executor::block_on(async {
24351            let clock = Arc::new(DeterministicClock::new(0));
24352            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24353                .await
24354                .expect("create runtime");
24355
24356            // Use dynamic import() since eval() runs as a script, not a module
24357            runtime
24358                .eval(
24359                    r"
24360                    globalThis.results = [];
24361                    globalThis.testDone = false;
24362
24363                    import('node:events').then(({ EventEmitter }) => {
24364                        const emitter = new EventEmitter();
24365
24366                        emitter.on('data', (val) => globalThis.results.push('data:' + val));
24367                        emitter.once('done', () => globalThis.results.push('done'));
24368
24369                        emitter.emit('data', 1);
24370                        emitter.emit('data', 2);
24371                        emitter.emit('done');
24372                        emitter.emit('done'); // should not fire again
24373
24374                        globalThis.listenerCount = emitter.listenerCount('data');
24375                        globalThis.eventNames = emitter.eventNames();
24376                        globalThis.testDone = true;
24377                    });
24378                    ",
24379                )
24380                .await
24381                .expect("eval EventEmitter test");
24382
24383            assert_eq!(
24384                get_global_json(&runtime, "testDone").await,
24385                serde_json::json!(true)
24386            );
24387            assert_eq!(
24388                get_global_json(&runtime, "results").await,
24389                serde_json::json!(["data:1", "data:2", "done"])
24390            );
24391            assert_eq!(
24392                get_global_json(&runtime, "listenerCount").await,
24393                serde_json::json!(1)
24394            );
24395            assert_eq!(
24396                get_global_json(&runtime, "eventNames").await,
24397                serde_json::json!(["data"])
24398            );
24399        });
24400    }
24401
24402    #[test]
24403    fn pijs_bare_module_aliases_resolve_correctly() {
24404        futures::executor::block_on(async {
24405            let clock = Arc::new(DeterministicClock::new(0));
24406            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24407                .await
24408                .expect("create runtime");
24409
24410            // Test that bare "events" alias resolves to "node:events"
24411            runtime
24412                .eval(
24413                    r"
24414                    globalThis.bare_events_ok = false;
24415                    import('events').then((mod) => {
24416                        const e = new mod.default();
24417                        globalThis.bare_events_ok = typeof e.on === 'function';
24418                    });
24419                    ",
24420                )
24421                .await
24422                .expect("eval bare events import");
24423
24424            assert_eq!(
24425                get_global_json(&runtime, "bare_events_ok").await,
24426                serde_json::json!(true)
24427            );
24428        });
24429    }
24430
24431    #[test]
24432    fn pijs_path_extended_functions() {
24433        futures::executor::block_on(async {
24434            let clock = Arc::new(DeterministicClock::new(0));
24435            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24436                .await
24437                .expect("create runtime");
24438
24439            runtime
24440                .eval(
24441                    r"
24442                    globalThis.pathResults = {};
24443                    import('node:path').then((path) => {
24444                        globalThis.pathResults.isAbsRoot = path.isAbsolute('/foo/bar');
24445                        globalThis.pathResults.isAbsRel = path.isAbsolute('foo/bar');
24446                        globalThis.pathResults.extJs = path.extname('/a/b/file.js');
24447                        globalThis.pathResults.extNone = path.extname('/a/b/noext');
24448                        globalThis.pathResults.extDot = path.extname('.hidden');
24449                        globalThis.pathResults.norm = path.normalize('/a/b/../c/./d');
24450                        globalThis.pathResults.parseBase = path.parse('/home/user/file.txt').base;
24451                        globalThis.pathResults.parseExt = path.parse('/home/user/file.txt').ext;
24452                        globalThis.pathResults.parseName = path.parse('/home/user/file.txt').name;
24453                        globalThis.pathResults.parseDir = path.parse('/home/user/file.txt').dir;
24454                        globalThis.pathResults.hasPosix = typeof path.posix === 'object';
24455                        globalThis.pathResults.done = true;
24456                    });
24457                    ",
24458                )
24459                .await
24460                .expect("eval path extended");
24461
24462            let r = get_global_json(&runtime, "pathResults").await;
24463            assert_eq!(r["done"], serde_json::json!(true));
24464            assert_eq!(r["isAbsRoot"], serde_json::json!(true));
24465            assert_eq!(r["isAbsRel"], serde_json::json!(false));
24466            assert_eq!(r["extJs"], serde_json::json!(".js"));
24467            assert_eq!(r["extNone"], serde_json::json!(""));
24468            assert_eq!(r["extDot"], serde_json::json!(""));
24469            assert_eq!(r["norm"], serde_json::json!("/a/c/d"));
24470            assert_eq!(r["parseBase"], serde_json::json!("file.txt"));
24471            assert_eq!(r["parseExt"], serde_json::json!(".txt"));
24472            assert_eq!(r["parseName"], serde_json::json!("file"));
24473            assert_eq!(r["parseDir"], serde_json::json!("/home/user"));
24474            assert_eq!(r["hasPosix"], serde_json::json!(true));
24475        });
24476    }
24477
24478    #[test]
24479    fn pijs_fs_callback_apis() {
24480        futures::executor::block_on(async {
24481            let clock = Arc::new(DeterministicClock::new(0));
24482            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24483                .await
24484                .expect("create runtime");
24485
24486            runtime
24487                .eval(
24488                    r"
24489                    globalThis.fsResults = {};
24490                    import('node:fs').then((fs) => {
24491                        fs.writeFileSync('/fake', '');
24492                        // readFile callback
24493                        fs.readFile('/fake', 'utf8', (err, data) => {
24494                            globalThis.fsResults.readFileCallbackCalled = true;
24495                            globalThis.fsResults.readFileData = data;
24496                        });
24497                        // writeFile callback
24498                        fs.writeFile('/fake', 'data', (err) => {
24499                            globalThis.fsResults.writeFileCallbackCalled = true;
24500                        });
24501                        // accessSync throws
24502                        try {
24503                            fs.accessSync('/nonexistent');
24504                            globalThis.fsResults.accessSyncThrew = false;
24505                        } catch (e) {
24506                            globalThis.fsResults.accessSyncThrew = true;
24507                        }
24508                        // access callback with error
24509                        fs.access('/nonexistent', (err) => {
24510                            globalThis.fsResults.accessCallbackErr = !!err;
24511                        });
24512                        globalThis.fsResults.hasLstatSync = typeof fs.lstatSync === 'function';
24513                        globalThis.fsResults.done = true;
24514                    });
24515                    ",
24516                )
24517                .await
24518                .expect("eval fs callbacks");
24519
24520            let r = get_global_json(&runtime, "fsResults").await;
24521            assert_eq!(r["done"], serde_json::json!(true));
24522            assert_eq!(r["readFileCallbackCalled"], serde_json::json!(true));
24523            assert_eq!(r["readFileData"], serde_json::json!(""));
24524            assert_eq!(r["writeFileCallbackCalled"], serde_json::json!(true));
24525            assert_eq!(r["accessSyncThrew"], serde_json::json!(true));
24526            assert_eq!(r["accessCallbackErr"], serde_json::json!(true));
24527            assert_eq!(r["hasLstatSync"], serde_json::json!(true));
24528        });
24529    }
24530
24531    #[test]
24532    fn pijs_fs_sync_roundtrip_and_dirents() {
24533        futures::executor::block_on(async {
24534            let clock = Arc::new(DeterministicClock::new(0));
24535            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24536                .await
24537                .expect("create runtime");
24538
24539            runtime
24540                .eval(
24541                    r"
24542                    globalThis.fsRoundTrip = {};
24543                    import('node:fs').then((fs) => {
24544                        fs.mkdirSync('/tmp/demo', { recursive: true });
24545                        fs.writeFileSync('/tmp/demo/hello.txt', 'hello world');
24546                        fs.writeFileSync('/tmp/demo/raw.bin', Buffer.from([1, 2, 3, 4]));
24547
24548                        globalThis.fsRoundTrip.exists = fs.existsSync('/tmp/demo/hello.txt');
24549                        globalThis.fsRoundTrip.readText = fs.readFileSync('/tmp/demo/hello.txt', 'utf8');
24550                        const raw = fs.readFileSync('/tmp/demo/raw.bin');
24551                        globalThis.fsRoundTrip.rawLen = raw.length;
24552
24553                        const names = fs.readdirSync('/tmp/demo');
24554                        globalThis.fsRoundTrip.names = names;
24555
24556                        const dirents = fs.readdirSync('/tmp/demo', { withFileTypes: true });
24557                        globalThis.fsRoundTrip.direntHasMethods =
24558                          typeof dirents[0].isFile === 'function' &&
24559                          typeof dirents[0].isDirectory === 'function';
24560
24561                        const dirStat = fs.statSync('/tmp/demo');
24562                        const fileStat = fs.statSync('/tmp/demo/hello.txt');
24563                        globalThis.fsRoundTrip.isDir = dirStat.isDirectory();
24564                        globalThis.fsRoundTrip.isFile = fileStat.isFile();
24565                        globalThis.fsRoundTrip.done = true;
24566                    });
24567                    ",
24568                )
24569                .await
24570                .expect("eval fs sync roundtrip");
24571
24572            let r = get_global_json(&runtime, "fsRoundTrip").await;
24573            assert_eq!(r["done"], serde_json::json!(true));
24574            assert_eq!(r["exists"], serde_json::json!(true));
24575            assert_eq!(r["readText"], serde_json::json!("hello world"));
24576            assert_eq!(r["rawLen"], serde_json::json!(4));
24577            assert_eq!(r["isDir"], serde_json::json!(true));
24578            assert_eq!(r["isFile"], serde_json::json!(true));
24579            assert_eq!(r["direntHasMethods"], serde_json::json!(true));
24580            assert_eq!(r["names"], serde_json::json!(["hello.txt", "raw.bin"]));
24581        });
24582    }
24583
24584    #[test]
24585    fn pijs_create_require_supports_node_builtins() {
24586        futures::executor::block_on(async {
24587            let clock = Arc::new(DeterministicClock::new(0));
24588            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24589                .await
24590                .expect("create runtime");
24591
24592            runtime
24593                .eval(
24594                    r"
24595                    globalThis.requireResults = {};
24596                    import('node:module').then(({ createRequire }) => {
24597                        const require = createRequire('/tmp/example.js');
24598                        const path = require('path');
24599                        const fs = require('node:fs');
24600                        const crypto = require('crypto');
24601                        const http2 = require('http2');
24602
24603                        globalThis.requireResults.pathJoinWorks = path.join('a', 'b') === 'a/b';
24604                        globalThis.requireResults.fsReadFileSync = typeof fs.readFileSync === 'function';
24605                        globalThis.requireResults.cryptoHasRandomUUID = typeof crypto.randomUUID === 'function';
24606                        globalThis.requireResults.http2HasConnect = typeof http2.connect === 'function';
24607                        globalThis.requireResults.http2PathHeader = http2.constants.HTTP2_HEADER_PATH;
24608
24609                        try {
24610                            const missing = require('left-pad');
24611                            globalThis.requireResults.missingModuleThrows = false;
24612                            globalThis.requireResults.missingModuleIsStub =
24613                              typeof missing === 'function' &&
24614                              typeof missing.default === 'function' &&
24615                              typeof missing.anyNestedProperty === 'function';
24616                        } catch (err) {
24617                            globalThis.requireResults.missingModuleThrows = true;
24618                            globalThis.requireResults.missingModuleIsStub = false;
24619                        }
24620                        globalThis.requireResults.done = true;
24621                    });
24622                    ",
24623                )
24624                .await
24625                .expect("eval createRequire test");
24626
24627            let r = get_global_json(&runtime, "requireResults").await;
24628            assert_eq!(r["done"], serde_json::json!(true));
24629            assert_eq!(r["pathJoinWorks"], serde_json::json!(true));
24630            assert_eq!(r["fsReadFileSync"], serde_json::json!(true));
24631            assert_eq!(r["cryptoHasRandomUUID"], serde_json::json!(true));
24632            assert_eq!(r["http2HasConnect"], serde_json::json!(true));
24633            assert_eq!(r["http2PathHeader"], serde_json::json!(":path"));
24634            assert_eq!(r["missingModuleThrows"], serde_json::json!(false));
24635            assert_eq!(r["missingModuleIsStub"], serde_json::json!(true));
24636        });
24637    }
24638
24639    #[test]
24640    fn pijs_fs_promises_delegates_to_node_fs_promises_api() {
24641        futures::executor::block_on(async {
24642            let clock = Arc::new(DeterministicClock::new(0));
24643            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24644                .await
24645                .expect("create runtime");
24646
24647            runtime
24648                .eval(
24649                    r"
24650                    globalThis.fsPromisesResults = {};
24651                    import('node:fs/promises').then(async (fsp) => {
24652                        await fsp.mkdir('/tmp/promise-demo', { recursive: true });
24653                        await fsp.writeFile('/tmp/promise-demo/value.txt', 'value');
24654                        const text = await fsp.readFile('/tmp/promise-demo/value.txt', 'utf8');
24655                        const names = await fsp.readdir('/tmp/promise-demo');
24656
24657                        globalThis.fsPromisesResults.readText = text;
24658                        globalThis.fsPromisesResults.names = names;
24659                        globalThis.fsPromisesResults.done = true;
24660                    });
24661                    ",
24662                )
24663                .await
24664                .expect("eval fs promises test");
24665
24666            let r = get_global_json(&runtime, "fsPromisesResults").await;
24667            assert_eq!(r["done"], serde_json::json!(true));
24668            assert_eq!(r["readText"], serde_json::json!("value"));
24669            assert_eq!(r["names"], serde_json::json!(["value.txt"]));
24670        });
24671    }
24672
24673    #[test]
24674    fn pijs_child_process_spawn_emits_data_and_close() {
24675        futures::executor::block_on(async {
24676            let clock = Arc::new(DeterministicClock::new(0));
24677            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24678                .await
24679                .expect("create runtime");
24680
24681            runtime
24682                .eval(
24683                    r"
24684                    globalThis.childProcessResult = { events: [] };
24685                    import('node:child_process').then(({ spawn }) => {
24686                        const child = spawn('pi', ['--version'], {
24687                            shell: false,
24688                            stdio: ['ignore', 'pipe', 'pipe'],
24689                        });
24690                        let stdout = '';
24691                        let stderr = '';
24692                        child.stdout?.on('data', (chunk) => {
24693                            stdout += chunk.toString();
24694                            globalThis.childProcessResult.events.push('stdout');
24695                        });
24696                        child.stderr?.on('data', (chunk) => {
24697                            stderr += chunk.toString();
24698                            globalThis.childProcessResult.events.push('stderr');
24699                        });
24700                        child.on('error', (err) => {
24701                            globalThis.childProcessResult.error =
24702                                String((err && err.message) || err || '');
24703                            globalThis.childProcessResult.done = true;
24704                        });
24705                        child.on('exit', (code, signal) => {
24706                            globalThis.childProcessResult.events.push('exit');
24707                            globalThis.childProcessResult.exitCode = code;
24708                            globalThis.childProcessResult.exitSignal = signal;
24709                        });
24710                        child.on('close', (code) => {
24711                            globalThis.childProcessResult.events.push('close');
24712                            globalThis.childProcessResult.code = code;
24713                            globalThis.childProcessResult.stdout = stdout;
24714                            globalThis.childProcessResult.stderr = stderr;
24715                            globalThis.childProcessResult.killed = child.killed;
24716                            globalThis.childProcessResult.pid = child.pid;
24717                            globalThis.childProcessResult.done = true;
24718                        });
24719                    });
24720                    ",
24721                )
24722                .await
24723                .expect("eval child_process spawn script");
24724
24725            let mut requests = runtime.drain_hostcall_requests();
24726            assert_eq!(requests.len(), 1);
24727            let request = requests.pop_front().expect("exec hostcall");
24728            assert!(
24729                matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "pi"),
24730                "unexpected hostcall kind: {:?}",
24731                request.kind
24732            );
24733
24734            runtime.complete_hostcall(
24735                request.call_id,
24736                HostcallOutcome::Success(serde_json::json!({
24737                    "stdout": "line-1\n",
24738                    "stderr": "warn-1\n",
24739                    "code": 0,
24740                    "killed": false
24741                })),
24742            );
24743
24744            drain_until_idle(&runtime, &clock).await;
24745            let r = get_global_json(&runtime, "childProcessResult").await;
24746            assert_eq!(r["done"], serde_json::json!(true));
24747            assert_eq!(r["code"], serde_json::json!(0));
24748            assert_eq!(r["exitCode"], serde_json::json!(0));
24749            assert_eq!(r["exitSignal"], serde_json::Value::Null);
24750            assert_eq!(r["stdout"], serde_json::json!("line-1\n"));
24751            assert_eq!(r["stderr"], serde_json::json!("warn-1\n"));
24752            assert_eq!(r["killed"], serde_json::json!(false));
24753            assert_eq!(
24754                r["events"],
24755                serde_json::json!(["stdout", "stderr", "exit", "close"])
24756            );
24757        });
24758    }
24759
24760    #[test]
24761    fn pijs_child_process_spawn_forwards_timeout_option_to_hostcall() {
24762        futures::executor::block_on(async {
24763            let clock = Arc::new(DeterministicClock::new(0));
24764            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24765                .await
24766                .expect("create runtime");
24767
24768            runtime
24769                .eval(
24770                    r"
24771                    globalThis.childTimeoutResult = {};
24772                    import('node:child_process').then(({ spawn }) => {
24773                        const child = spawn('pi', ['--version'], {
24774                            shell: false,
24775                            timeout: 250,
24776                            stdio: ['ignore', 'pipe', 'pipe'],
24777                        });
24778                        child.on('close', (code) => {
24779                            globalThis.childTimeoutResult.code = code;
24780                            globalThis.childTimeoutResult.killed = child.killed;
24781                            globalThis.childTimeoutResult.done = true;
24782                        });
24783                    });
24784                    ",
24785                )
24786                .await
24787                .expect("eval child_process timeout script");
24788
24789            let mut requests = runtime.drain_hostcall_requests();
24790            assert_eq!(requests.len(), 1);
24791            let request = requests.pop_front().expect("exec hostcall");
24792            assert!(
24793                matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "pi"),
24794                "unexpected hostcall kind: {:?}",
24795                request.kind
24796            );
24797            assert_eq!(
24798                request.payload["options"]["timeout"].as_i64(),
24799                Some(250),
24800                "spawn timeout should be forwarded to hostcall options"
24801            );
24802
24803            runtime.complete_hostcall(
24804                request.call_id,
24805                HostcallOutcome::Success(serde_json::json!({
24806                    "stdout": "",
24807                    "stderr": "",
24808                    "code": 0,
24809                    "killed": true
24810                })),
24811            );
24812
24813            drain_until_idle(&runtime, &clock).await;
24814            let r = get_global_json(&runtime, "childTimeoutResult").await;
24815            assert_eq!(r["done"], serde_json::json!(true));
24816            assert_eq!(r["killed"], serde_json::json!(true));
24817            assert_eq!(r["code"], serde_json::Value::Null);
24818        });
24819    }
24820
24821    #[test]
24822    fn pijs_child_process_exec_returns_child_and_forwards_timeout() {
24823        futures::executor::block_on(async {
24824            let clock = Arc::new(DeterministicClock::new(0));
24825            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24826                .await
24827                .expect("create runtime");
24828
24829            runtime
24830                .eval(
24831                    r"
24832                    globalThis.execShimResult = {};
24833                    import('node:child_process').then(({ exec }) => {
24834                        const child = exec('echo hello-exec', { timeout: 321 }, (err, stdout, stderr) => {
24835                            globalThis.execShimResult.cbDone = true;
24836                            globalThis.execShimResult.cbErr = err ? String((err && err.message) || err) : null;
24837                            globalThis.execShimResult.stdout = stdout;
24838                            globalThis.execShimResult.stderr = stderr;
24839                        });
24840                        globalThis.execShimResult.hasPid = typeof child.pid === 'number';
24841                        globalThis.execShimResult.hasKill = typeof child.kill === 'function';
24842                        child.on('close', () => {
24843                            globalThis.execShimResult.closed = true;
24844                        });
24845                    });
24846                    ",
24847                )
24848                .await
24849                .expect("eval child_process exec script");
24850
24851            let mut requests = runtime.drain_hostcall_requests();
24852            assert_eq!(requests.len(), 1);
24853            let request = requests.pop_front().expect("exec hostcall");
24854            assert!(
24855                matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "sh"),
24856                "unexpected hostcall kind: {:?}",
24857                request.kind
24858            );
24859            assert_eq!(
24860                request.payload["args"],
24861                serde_json::json!(["-c", "echo hello-exec"])
24862            );
24863            assert_eq!(request.payload["options"]["timeout"].as_i64(), Some(321));
24864
24865            runtime.complete_hostcall(
24866                request.call_id,
24867                HostcallOutcome::Success(serde_json::json!({
24868                    "stdout": "hello-exec\n",
24869                    "stderr": "",
24870                    "code": 0,
24871                    "killed": false
24872                })),
24873            );
24874
24875            drain_until_idle(&runtime, &clock).await;
24876            let r = get_global_json(&runtime, "execShimResult").await;
24877            assert_eq!(r["hasPid"], serde_json::json!(true));
24878            assert_eq!(r["hasKill"], serde_json::json!(true));
24879            assert_eq!(r["closed"], serde_json::json!(true));
24880            assert_eq!(r["cbDone"], serde_json::json!(true));
24881            assert_eq!(r["cbErr"], serde_json::Value::Null);
24882            assert_eq!(r["stdout"], serde_json::json!("hello-exec\n"));
24883            assert_eq!(r["stderr"], serde_json::json!(""));
24884        });
24885    }
24886
24887    #[test]
24888    fn pijs_child_process_exec_file_returns_child_and_forwards_timeout() {
24889        futures::executor::block_on(async {
24890            let clock = Arc::new(DeterministicClock::new(0));
24891            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24892                .await
24893                .expect("create runtime");
24894
24895            runtime
24896                .eval(
24897                    r"
24898                    globalThis.execFileShimResult = {};
24899                    import('node:child_process').then(({ execFile }) => {
24900                        const child = execFile('echo', ['hello-file'], { timeout: 222 }, (err, stdout, stderr) => {
24901                            globalThis.execFileShimResult.cbDone = true;
24902                            globalThis.execFileShimResult.cbErr = err ? String((err && err.message) || err) : null;
24903                            globalThis.execFileShimResult.stdout = stdout;
24904                            globalThis.execFileShimResult.stderr = stderr;
24905                        });
24906                        globalThis.execFileShimResult.hasPid = typeof child.pid === 'number';
24907                        globalThis.execFileShimResult.hasKill = typeof child.kill === 'function';
24908                    });
24909                    ",
24910                )
24911                .await
24912                .expect("eval child_process execFile script");
24913
24914            let mut requests = runtime.drain_hostcall_requests();
24915            assert_eq!(requests.len(), 1);
24916            let request = requests.pop_front().expect("execFile hostcall");
24917            assert!(
24918                matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "echo"),
24919                "unexpected hostcall kind: {:?}",
24920                request.kind
24921            );
24922            assert_eq!(request.payload["args"], serde_json::json!(["hello-file"]));
24923            assert_eq!(request.payload["options"]["timeout"].as_i64(), Some(222));
24924
24925            runtime.complete_hostcall(
24926                request.call_id,
24927                HostcallOutcome::Success(serde_json::json!({
24928                    "stdout": "hello-file\n",
24929                    "stderr": "",
24930                    "code": 0,
24931                    "killed": false
24932                })),
24933            );
24934
24935            drain_until_idle(&runtime, &clock).await;
24936            let r = get_global_json(&runtime, "execFileShimResult").await;
24937            assert_eq!(r["hasPid"], serde_json::json!(true));
24938            assert_eq!(r["hasKill"], serde_json::json!(true));
24939            assert_eq!(r["cbDone"], serde_json::json!(true));
24940            assert_eq!(r["cbErr"], serde_json::Value::Null);
24941            assert_eq!(r["stdout"], serde_json::json!("hello-file\n"));
24942            assert_eq!(r["stderr"], serde_json::json!(""));
24943        });
24944    }
24945
24946    #[test]
24947    fn pijs_child_process_process_kill_targets_spawned_pid() {
24948        futures::executor::block_on(async {
24949            let clock = Arc::new(DeterministicClock::new(0));
24950            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
24951                .await
24952                .expect("create runtime");
24953
24954            runtime
24955                .eval(
24956                    r"
24957                    globalThis.childKillResult = {};
24958                    import('node:child_process').then(({ spawn }) => {
24959                        const child = spawn('pi', ['--version'], {
24960                            shell: false,
24961                            detached: true,
24962                            stdio: ['ignore', 'pipe', 'pipe'],
24963                        });
24964                        globalThis.childKillResult.pid = child.pid;
24965                        child.on('close', (code) => {
24966                            globalThis.childKillResult.code = code;
24967                            globalThis.childKillResult.killed = child.killed;
24968                            globalThis.childKillResult.done = true;
24969                        });
24970                        try {
24971                            globalThis.childKillResult.killOk = process.kill(-child.pid, 'SIGKILL') === true;
24972                        } catch (err) {
24973                            globalThis.childKillResult.killErrorCode = String((err && err.code) || '');
24974                            globalThis.childKillResult.killErrorMessage = String((err && err.message) || err || '');
24975                        }
24976                    });
24977                    ",
24978                )
24979                .await
24980                .expect("eval child_process kill script");
24981
24982            let mut requests = runtime.drain_hostcall_requests();
24983            assert_eq!(requests.len(), 1);
24984            let request = requests.pop_front().expect("exec hostcall");
24985            runtime.complete_hostcall(
24986                request.call_id,
24987                HostcallOutcome::Success(serde_json::json!({
24988                    "stdout": "",
24989                    "stderr": "",
24990                    "code": 0,
24991                    "killed": false
24992                })),
24993            );
24994
24995            drain_until_idle(&runtime, &clock).await;
24996            let r = get_global_json(&runtime, "childKillResult").await;
24997            assert_eq!(r["killOk"], serde_json::json!(true));
24998            assert_eq!(r["killed"], serde_json::json!(true));
24999            assert_eq!(r["code"], serde_json::Value::Null);
25000            assert_eq!(r["done"], serde_json::json!(true));
25001        });
25002    }
25003
25004    #[test]
25005    fn pijs_child_process_denied_exec_emits_error_and_close() {
25006        futures::executor::block_on(async {
25007            let clock = Arc::new(DeterministicClock::new(0));
25008            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25009                .await
25010                .expect("create runtime");
25011
25012            runtime
25013                .eval(
25014                    r"
25015                    globalThis.childDeniedResult = {};
25016                    import('node:child_process').then(({ spawn }) => {
25017                        const child = spawn('pi', ['--version'], {
25018                            shell: false,
25019                            stdio: ['ignore', 'pipe', 'pipe'],
25020                        });
25021                        child.on('error', (err) => {
25022                            globalThis.childDeniedResult.errorCode = String((err && err.code) || '');
25023                            globalThis.childDeniedResult.errorMessage = String((err && err.message) || err || '');
25024                        });
25025                        child.on('close', (code) => {
25026                            globalThis.childDeniedResult.code = code;
25027                            globalThis.childDeniedResult.killed = child.killed;
25028                            globalThis.childDeniedResult.done = true;
25029                        });
25030                    });
25031                    ",
25032                )
25033                .await
25034                .expect("eval child_process denied script");
25035
25036            let mut requests = runtime.drain_hostcall_requests();
25037            assert_eq!(requests.len(), 1);
25038            let request = requests.pop_front().expect("exec hostcall");
25039            runtime.complete_hostcall(
25040                request.call_id,
25041                HostcallOutcome::Error {
25042                    code: "denied".to_string(),
25043                    message: "Capability 'exec' denied by policy".to_string(),
25044                },
25045            );
25046
25047            drain_until_idle(&runtime, &clock).await;
25048            let r = get_global_json(&runtime, "childDeniedResult").await;
25049            assert_eq!(r["done"], serde_json::json!(true));
25050            assert_eq!(r["errorCode"], serde_json::json!("denied"));
25051            assert_eq!(
25052                r["errorMessage"],
25053                serde_json::json!("Capability 'exec' denied by policy")
25054            );
25055            assert_eq!(r["code"], serde_json::json!(1));
25056            assert_eq!(r["killed"], serde_json::json!(false));
25057        });
25058    }
25059
25060    #[test]
25061    fn pijs_child_process_rejects_unsupported_shell_option() {
25062        futures::executor::block_on(async {
25063            let clock = Arc::new(DeterministicClock::new(0));
25064            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25065                .await
25066                .expect("create runtime");
25067
25068            runtime
25069                .eval(
25070                    r"
25071                    globalThis.childOptionResult = {};
25072                    import('node:child_process').then(({ spawn }) => {
25073                        try {
25074                            spawn('pi', ['--version'], { shell: true });
25075                            globalThis.childOptionResult.threw = false;
25076                        } catch (err) {
25077                            globalThis.childOptionResult.threw = true;
25078                            globalThis.childOptionResult.message = String((err && err.message) || err || '');
25079                        }
25080                        globalThis.childOptionResult.done = true;
25081                    });
25082                    ",
25083                )
25084                .await
25085                .expect("eval child_process unsupported shell script");
25086
25087            drain_until_idle(&runtime, &clock).await;
25088            let r = get_global_json(&runtime, "childOptionResult").await;
25089            assert_eq!(r["done"], serde_json::json!(true));
25090            assert_eq!(r["threw"], serde_json::json!(true));
25091            assert_eq!(
25092                r["message"],
25093                serde_json::json!(
25094                    "node:child_process.spawn: only shell=false is supported in PiJS"
25095                )
25096            );
25097            assert_eq!(runtime.drain_hostcall_requests().len(), 0);
25098        });
25099    }
25100
25101    // -----------------------------------------------------------------------
25102    // bd-2b9y: Node core shim unit tests
25103    // -----------------------------------------------------------------------
25104
25105    #[test]
25106    fn pijs_node_os_module_exports() {
25107        futures::executor::block_on(async {
25108            let clock = Arc::new(DeterministicClock::new(0));
25109            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25110                .await
25111                .expect("create runtime");
25112
25113            runtime
25114                .eval(
25115                    r"
25116                    globalThis.osResults = {};
25117                    import('node:os').then((os) => {
25118                        globalThis.osResults.homedir = os.homedir();
25119                        globalThis.osResults.tmpdir = os.tmpdir();
25120                        globalThis.osResults.hostname = os.hostname();
25121                        globalThis.osResults.platform = os.platform();
25122                        globalThis.osResults.arch = os.arch();
25123                        globalThis.osResults.type = os.type();
25124                        globalThis.osResults.release = os.release();
25125                        globalThis.osResults.done = true;
25126                    });
25127                    ",
25128                )
25129                .await
25130                .expect("eval node:os");
25131
25132            let r = get_global_json(&runtime, "osResults").await;
25133            assert_eq!(r["done"], serde_json::json!(true));
25134            // homedir returns HOME env or fallback
25135            assert!(r["homedir"].is_string());
25136            // tmpdir matches std::env::temp_dir()
25137            let expected_tmpdir = std::env::temp_dir().display().to_string();
25138            assert_eq!(r["tmpdir"].as_str().unwrap(), expected_tmpdir);
25139            // hostname is a non-empty string (real system hostname)
25140            assert!(
25141                r["hostname"].as_str().is_some_and(|s| !s.is_empty()),
25142                "hostname should be non-empty string"
25143            );
25144            // platform/arch/type match current system
25145            let expected_platform = match std::env::consts::OS {
25146                "macos" => "darwin",
25147                "windows" => "win32",
25148                other => other,
25149            };
25150            assert_eq!(r["platform"].as_str().unwrap(), expected_platform);
25151            let expected_arch = match std::env::consts::ARCH {
25152                "x86_64" => "x64",
25153                "aarch64" => "arm64",
25154                other => other,
25155            };
25156            assert_eq!(r["arch"].as_str().unwrap(), expected_arch);
25157            let expected_type = match std::env::consts::OS {
25158                "linux" => "Linux",
25159                "macos" => "Darwin",
25160                "windows" => "Windows_NT",
25161                other => other,
25162            };
25163            assert_eq!(r["type"].as_str().unwrap(), expected_type);
25164            assert_eq!(r["release"], serde_json::json!("6.0.0"));
25165        });
25166    }
25167
25168    #[test]
25169    fn build_node_os_module_produces_valid_js() {
25170        let source = super::build_node_os_module();
25171        // Verify basic structure - has expected exports
25172        assert!(
25173            source.contains("export function platform()"),
25174            "missing platform"
25175        );
25176        assert!(source.contains("export function cpus()"), "missing cpus");
25177        assert!(source.contains("_numCpus"), "missing _numCpus");
25178        // Print first few lines for debugging
25179        for (i, line) in source.lines().enumerate().take(20) {
25180            eprintln!("  {i}: {line}");
25181        }
25182        let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
25183        assert!(
25184            source.contains(&format!("const _numCpus = {num_cpus}")),
25185            "expected _numCpus = {num_cpus} in module"
25186        );
25187    }
25188
25189    #[test]
25190    fn pijs_node_os_native_values_cpus_and_userinfo() {
25191        futures::executor::block_on(async {
25192            let clock = Arc::new(DeterministicClock::new(0));
25193            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25194                .await
25195                .expect("create runtime");
25196
25197            runtime
25198                .eval(
25199                    r"
25200                    globalThis.nativeOsResults = {};
25201                    import('node:os').then((os) => {
25202                        globalThis.nativeOsResults.cpuCount = os.cpus().length;
25203                        globalThis.nativeOsResults.totalmem = os.totalmem();
25204                        globalThis.nativeOsResults.freemem = os.freemem();
25205                        globalThis.nativeOsResults.eol = os.EOL;
25206                        globalThis.nativeOsResults.endianness = os.endianness();
25207                        globalThis.nativeOsResults.devNull = os.devNull;
25208                        const ui = os.userInfo();
25209                        globalThis.nativeOsResults.uid = ui.uid;
25210                        globalThis.nativeOsResults.username = ui.username;
25211                        globalThis.nativeOsResults.hasShell = typeof ui.shell === 'string';
25212                        globalThis.nativeOsResults.hasHomedir = typeof ui.homedir === 'string';
25213                        globalThis.nativeOsResults.done = true;
25214                    });
25215                    ",
25216                )
25217                .await
25218                .expect("eval node:os native");
25219
25220            let r = get_global_json(&runtime, "nativeOsResults").await;
25221            assert_eq!(r["done"], serde_json::json!(true));
25222            // cpus() returns array with count matching available parallelism
25223            let expected_cpus =
25224                std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
25225            assert_eq!(r["cpuCount"], serde_json::json!(expected_cpus));
25226            // totalmem/freemem are positive numbers
25227            assert!(r["totalmem"].as_f64().unwrap() > 0.0);
25228            assert!(r["freemem"].as_f64().unwrap() > 0.0);
25229            // EOL is correct for platform
25230            let expected_eol = if cfg!(windows) { "\r\n" } else { "\n" };
25231            assert_eq!(r["eol"], serde_json::json!(expected_eol));
25232            assert_eq!(r["endianness"], serde_json::json!("LE"));
25233            let expected_dev_null = if cfg!(windows) {
25234                "\\\\.\\NUL"
25235            } else {
25236                "/dev/null"
25237            };
25238            assert_eq!(r["devNull"], serde_json::json!(expected_dev_null));
25239            // userInfo has real uid and non-empty username
25240            assert!(r["uid"].is_number());
25241            assert!(r["username"].as_str().is_some_and(|s| !s.is_empty()));
25242            assert_eq!(r["hasShell"], serde_json::json!(true));
25243            assert_eq!(r["hasHomedir"], serde_json::json!(true));
25244        });
25245    }
25246
25247    #[test]
25248    fn pijs_node_os_bare_import_alias() {
25249        futures::executor::block_on(async {
25250            let clock = Arc::new(DeterministicClock::new(0));
25251            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25252                .await
25253                .expect("create runtime");
25254
25255            runtime
25256                .eval(
25257                    r"
25258                    globalThis.bare_os_ok = false;
25259                    import('os').then((os) => {
25260                        globalThis.bare_os_ok = typeof os.homedir === 'function'
25261                            && typeof os.platform === 'function';
25262                    });
25263                    ",
25264                )
25265                .await
25266                .expect("eval bare os import");
25267
25268            assert_eq!(
25269                get_global_json(&runtime, "bare_os_ok").await,
25270                serde_json::json!(true)
25271            );
25272        });
25273    }
25274
25275    #[test]
25276    fn pijs_node_url_module_exports() {
25277        futures::executor::block_on(async {
25278            let clock = Arc::new(DeterministicClock::new(0));
25279            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25280                .await
25281                .expect("create runtime");
25282
25283            runtime
25284                .eval(
25285                    r"
25286                    globalThis.urlResults = {};
25287                    import('node:url').then((url) => {
25288                        globalThis.urlResults.fileToPath = url.fileURLToPath('file:///home/user/test.txt');
25289                        globalThis.urlResults.pathToFile = url.pathToFileURL('/home/user/test.txt').href;
25290
25291                        const u = new url.URL('https://example.com/path?key=val#frag');
25292                        globalThis.urlResults.href = u.href;
25293                        globalThis.urlResults.protocol = u.protocol;
25294                        globalThis.urlResults.hostname = u.hostname;
25295                        globalThis.urlResults.pathname = u.pathname;
25296                        globalThis.urlResults.toString = u.toString();
25297
25298                        globalThis.urlResults.done = true;
25299                    });
25300                    ",
25301                )
25302                .await
25303                .expect("eval node:url");
25304
25305            let r = get_global_json(&runtime, "urlResults").await;
25306            assert_eq!(r["done"], serde_json::json!(true));
25307            assert_eq!(r["fileToPath"], serde_json::json!("/home/user/test.txt"));
25308            assert_eq!(
25309                r["pathToFile"],
25310                serde_json::json!("file:///home/user/test.txt")
25311            );
25312            // URL parsing
25313            assert!(r["href"].as_str().unwrap().starts_with("https://"));
25314            assert_eq!(r["protocol"], serde_json::json!("https:"));
25315            assert_eq!(r["hostname"], serde_json::json!("example.com"));
25316            // Shim URL.pathname includes query+fragment (lightweight parser)
25317            assert!(r["pathname"].as_str().unwrap().starts_with("/path"));
25318        });
25319    }
25320
25321    #[test]
25322    fn pijs_node_crypto_create_hash_and_uuid() {
25323        futures::executor::block_on(async {
25324            let clock = Arc::new(DeterministicClock::new(0));
25325            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25326                .await
25327                .expect("create runtime");
25328
25329            runtime
25330                .eval(
25331                    r"
25332                    globalThis.cryptoResults = {};
25333                    import('node:crypto').then((crypto) => {
25334                        // createHash
25335                        const hash = crypto.createHash('sha256');
25336                        hash.update('hello');
25337                        globalThis.cryptoResults.hexDigest = hash.digest('hex');
25338
25339                        // createHash chained
25340                        globalThis.cryptoResults.chainedHex = crypto
25341                            .createHash('sha256')
25342                            .update('world')
25343                            .digest('hex');
25344
25345                        // randomUUID
25346                        const uuid = crypto.randomUUID();
25347                        globalThis.cryptoResults.uuidLength = uuid.length;
25348                        // UUID v4 format: 8-4-4-4-12
25349                        globalThis.cryptoResults.uuidHasDashes = uuid.split('-').length === 5;
25350
25351                        globalThis.cryptoResults.done = true;
25352                    });
25353                    ",
25354                )
25355                .await
25356                .expect("eval node:crypto");
25357
25358            let r = get_global_json(&runtime, "cryptoResults").await;
25359            assert_eq!(r["done"], serde_json::json!(true));
25360            // createHash returns a hex string
25361            assert!(r["hexDigest"].is_string());
25362            let hex = r["hexDigest"].as_str().unwrap();
25363            // djb2-simulated hash, not real SHA-256 — verify it's a non-empty hex string
25364            assert!(!hex.is_empty());
25365            assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
25366            // chained usage also works
25367            assert!(r["chainedHex"].is_string());
25368            let chained = r["chainedHex"].as_str().unwrap();
25369            assert!(!chained.is_empty());
25370            assert!(chained.chars().all(|c| c.is_ascii_hexdigit()));
25371            // Two different inputs produce different hashes
25372            assert_ne!(r["hexDigest"], r["chainedHex"]);
25373            // randomUUID format
25374            assert_eq!(r["uuidLength"], serde_json::json!(36));
25375            assert_eq!(r["uuidHasDashes"], serde_json::json!(true));
25376        });
25377    }
25378
25379    #[test]
25380    fn pijs_web_crypto_get_random_values_smoke() {
25381        futures::executor::block_on(async {
25382            let clock = Arc::new(DeterministicClock::new(0));
25383            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25384                .await
25385                .expect("create runtime");
25386
25387            runtime
25388                .eval(
25389                    r"
25390                    const bytes = new Uint8Array(32);
25391                    crypto.getRandomValues(bytes);
25392                    globalThis.cryptoRng = {
25393                        len: bytes.length,
25394                        inRange: Array.from(bytes).every((n) => Number.isInteger(n) && n >= 0 && n <= 255),
25395                    };
25396                    ",
25397                )
25398                .await
25399                .expect("eval web crypto getRandomValues");
25400
25401            let r = get_global_json(&runtime, "cryptoRng").await;
25402            assert_eq!(r["len"], serde_json::json!(32));
25403            assert_eq!(r["inRange"], serde_json::json!(true));
25404        });
25405    }
25406
25407    #[test]
25408    fn pijs_buffer_global_operations() {
25409        futures::executor::block_on(async {
25410            let clock = Arc::new(DeterministicClock::new(0));
25411            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25412                .await
25413                .expect("create runtime");
25414
25415            runtime
25416                .eval(
25417                    r"
25418                    globalThis.bufResults = {};
25419                    // Test the global Buffer polyfill (set up during runtime init)
25420                    const B = globalThis.Buffer;
25421                    globalThis.bufResults.hasBuffer = typeof B === 'function';
25422                    globalThis.bufResults.hasFrom = typeof B.from === 'function';
25423
25424                    // Buffer.from with array input
25425                    const arr = B.from([65, 66, 67]);
25426                    globalThis.bufResults.fromArrayLength = arr.length;
25427
25428                    // Uint8Array allocation
25429                    const zeroed = new Uint8Array(16);
25430                    globalThis.bufResults.allocLength = zeroed.length;
25431
25432                    globalThis.bufResults.done = true;
25433                    ",
25434                )
25435                .await
25436                .expect("eval Buffer");
25437
25438            let r = get_global_json(&runtime, "bufResults").await;
25439            assert_eq!(r["done"], serde_json::json!(true));
25440            assert_eq!(r["hasBuffer"], serde_json::json!(true));
25441            assert_eq!(r["hasFrom"], serde_json::json!(true));
25442            assert_eq!(r["fromArrayLength"], serde_json::json!(3));
25443            assert_eq!(r["allocLength"], serde_json::json!(16));
25444        });
25445    }
25446
25447    #[test]
25448    fn pijs_node_fs_promises_async_roundtrip() {
25449        futures::executor::block_on(async {
25450            let clock = Arc::new(DeterministicClock::new(0));
25451            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25452                .await
25453                .expect("create runtime");
25454
25455            runtime
25456                .eval(
25457                    r"
25458                    globalThis.fspResults = {};
25459                    import('node:fs/promises').then(async (fsp) => {
25460                        // Write then read back
25461                        await fsp.writeFile('/test/hello.txt', 'async content');
25462                        const data = await fsp.readFile('/test/hello.txt', 'utf8');
25463                        globalThis.fspResults.readBack = data;
25464
25465                        // stat
25466                        const st = await fsp.stat('/test/hello.txt');
25467                        globalThis.fspResults.statIsFile = st.isFile();
25468                        globalThis.fspResults.statSize = st.size;
25469
25470                        // mkdir + readdir
25471                        await fsp.mkdir('/test/subdir');
25472                        await fsp.writeFile('/test/subdir/a.txt', 'aaa');
25473                        const entries = await fsp.readdir('/test/subdir');
25474                        globalThis.fspResults.dirEntries = entries;
25475
25476                        // unlink
25477                        await fsp.unlink('/test/subdir/a.txt');
25478                        const exists = await fsp.access('/test/subdir/a.txt').then(() => true).catch(() => false);
25479                        globalThis.fspResults.deletedFileExists = exists;
25480
25481                        globalThis.fspResults.done = true;
25482                    });
25483                    ",
25484                )
25485                .await
25486                .expect("eval fs/promises");
25487
25488            drain_until_idle(&runtime, &clock).await;
25489
25490            let r = get_global_json(&runtime, "fspResults").await;
25491            assert_eq!(r["done"], serde_json::json!(true));
25492            assert_eq!(r["readBack"], serde_json::json!("async content"));
25493            assert_eq!(r["statIsFile"], serde_json::json!(true));
25494            assert!(r["statSize"].as_u64().unwrap() > 0);
25495            assert_eq!(r["dirEntries"], serde_json::json!(["a.txt"]));
25496            assert_eq!(r["deletedFileExists"], serde_json::json!(false));
25497        });
25498    }
25499
25500    #[test]
25501    fn pijs_node_process_module_exports() {
25502        futures::executor::block_on(async {
25503            let clock = Arc::new(DeterministicClock::new(0));
25504            let config = PiJsRuntimeConfig {
25505                cwd: "/test/project".to_string(),
25506                args: vec!["arg1".to_string(), "arg2".to_string()],
25507                env: HashMap::new(),
25508                limits: PiJsRuntimeLimits::default(),
25509                repair_mode: RepairMode::default(),
25510                allow_unsafe_sync_exec: false,
25511                deny_env: false,
25512                disk_cache_dir: None,
25513            };
25514            let runtime =
25515                PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
25516                    .await
25517                    .expect("create runtime");
25518
25519            runtime
25520                .eval(
25521                    r"
25522                    globalThis.procResults = {};
25523                    import('node:process').then((proc) => {
25524                        globalThis.procResults.platform = proc.platform;
25525                        globalThis.procResults.arch = proc.arch;
25526                        globalThis.procResults.version = proc.version;
25527                        globalThis.procResults.pid = proc.pid;
25528                        globalThis.procResults.cwdType = typeof proc.cwd;
25529                        globalThis.procResults.cwdValue = typeof proc.cwd === 'function'
25530                            ? proc.cwd() : proc.cwd;
25531                        globalThis.procResults.hasEnv = typeof proc.env === 'object';
25532                        globalThis.procResults.hasStdout = typeof proc.stdout === 'object';
25533                        globalThis.procResults.hasStderr = typeof proc.stderr === 'object';
25534                        globalThis.procResults.hasNextTick = typeof proc.nextTick === 'function';
25535
25536                        // nextTick should schedule microtask
25537                        globalThis.procResults.nextTickRan = false;
25538                        proc.nextTick(() => { globalThis.procResults.nextTickRan = true; });
25539
25540                        // hrtime should return array
25541                        const hr = proc.hrtime();
25542                        globalThis.procResults.hrtimeIsArray = Array.isArray(hr);
25543                        globalThis.procResults.hrtimeLength = hr.length;
25544
25545                        globalThis.procResults.done = true;
25546                    });
25547                    ",
25548                )
25549                .await
25550                .expect("eval node:process");
25551
25552            drain_until_idle(&runtime, &clock).await;
25553
25554            let r = get_global_json(&runtime, "procResults").await;
25555            assert_eq!(r["done"], serde_json::json!(true));
25556            // platform/arch are determined at runtime from env/cfg
25557            assert!(r["platform"].is_string(), "platform should be a string");
25558            let expected_arch = if cfg!(target_arch = "aarch64") {
25559                "arm64"
25560            } else {
25561                "x64"
25562            };
25563            assert_eq!(r["arch"], serde_json::json!(expected_arch));
25564            assert!(r["version"].is_string());
25565            assert_eq!(r["pid"], serde_json::json!(1));
25566            assert!(r["hasEnv"] == serde_json::json!(true));
25567            assert!(r["hasStdout"] == serde_json::json!(true));
25568            assert!(r["hasStderr"] == serde_json::json!(true));
25569            assert!(r["hasNextTick"] == serde_json::json!(true));
25570            // nextTick is scheduled as microtask — should have run
25571            assert_eq!(r["nextTickRan"], serde_json::json!(true));
25572            assert_eq!(r["hrtimeIsArray"], serde_json::json!(true));
25573            assert_eq!(r["hrtimeLength"], serde_json::json!(2));
25574        });
25575    }
25576
25577    #[test]
25578    fn pijs_pi_path_join_behavior() {
25579        futures::executor::block_on(async {
25580            let clock = Arc::new(DeterministicClock::new(0));
25581            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25582                .await
25583                .expect("create runtime");
25584
25585            runtime
25586                .eval(
25587                    r"
25588                    globalThis.joinResults = {};
25589                    globalThis.joinResults.concatAbs = pi.path.join('/a', '/b');
25590                    globalThis.joinResults.normal = pi.path.join('a', 'b');
25591                    globalThis.joinResults.root = pi.path.join('/', 'a');
25592                    globalThis.joinResults.dots = pi.path.join('/a', '..', 'b');
25593                    globalThis.joinResults.done = true;
25594                    ",
25595                )
25596                .await
25597                .expect("eval pi.path.join");
25598
25599            let r = get_global_json(&runtime, "joinResults").await;
25600            assert_eq!(r["done"], serde_json::json!(true));
25601            // Should be /a/b, NOT /b (bug fix)
25602            assert_eq!(r["concatAbs"], serde_json::json!("/a/b"));
25603            assert_eq!(r["normal"], serde_json::json!("a/b"));
25604            assert_eq!(r["root"], serde_json::json!("/a"));
25605            assert_eq!(r["dots"], serde_json::json!("/b"));
25606        });
25607    }
25608
25609    #[test]
25610    fn pijs_node_path_relative_resolve_format() {
25611        futures::executor::block_on(async {
25612            let clock = Arc::new(DeterministicClock::new(0));
25613            let config = PiJsRuntimeConfig {
25614                cwd: "/home/user/project".to_string(),
25615                args: Vec::new(),
25616                env: HashMap::new(),
25617                limits: PiJsRuntimeLimits::default(),
25618                repair_mode: RepairMode::default(),
25619                allow_unsafe_sync_exec: false,
25620                deny_env: false,
25621                disk_cache_dir: None,
25622            };
25623            let runtime =
25624                PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
25625                    .await
25626                    .expect("create runtime");
25627
25628            runtime
25629                .eval(
25630                    r"
25631                    globalThis.pathResults2 = {};
25632                    import('node:path').then((path) => {
25633                        // relative
25634                        globalThis.pathResults2.relSameDir = path.relative('/a/b/c', '/a/b/c/d');
25635                        globalThis.pathResults2.relUp = path.relative('/a/b/c', '/a/b');
25636                        globalThis.pathResults2.relSame = path.relative('/a/b', '/a/b');
25637
25638                        // resolve uses cwd as base
25639                        globalThis.pathResults2.resolveAbs = path.resolve('/absolute/path');
25640                        globalThis.pathResults2.resolveRel = path.resolve('relative');
25641
25642                        // format
25643                        globalThis.pathResults2.formatFull = path.format({
25644                            dir: '/home/user',
25645                            base: 'file.txt'
25646                        });
25647
25648                        // sep and delimiter constants
25649                        globalThis.pathResults2.sep = path.sep;
25650                        globalThis.pathResults2.delimiter = path.delimiter;
25651
25652                        // dirname edge cases
25653                        globalThis.pathResults2.dirnameRoot = path.dirname('/');
25654                        globalThis.pathResults2.dirnameNested = path.dirname('/a/b/c');
25655
25656                        // join edge cases
25657                        globalThis.pathResults2.joinEmpty = path.join();
25658                        globalThis.pathResults2.joinDots = path.join('a', '..', 'b');
25659
25660                        globalThis.pathResults2.done = true;
25661                    });
25662                    ",
25663                )
25664                .await
25665                .expect("eval path extended 2");
25666
25667            let r = get_global_json(&runtime, "pathResults2").await;
25668            assert_eq!(r["done"], serde_json::json!(true));
25669            assert_eq!(r["relSameDir"], serde_json::json!("d"));
25670            assert_eq!(r["relUp"], serde_json::json!(".."));
25671            assert_eq!(r["relSame"], serde_json::json!("."));
25672            assert_eq!(r["resolveAbs"], serde_json::json!("/absolute/path"));
25673            // resolve('relative') should resolve against cwd
25674            assert!(r["resolveRel"].as_str().unwrap().ends_with("/relative"));
25675            assert_eq!(r["formatFull"], serde_json::json!("/home/user/file.txt"));
25676            assert_eq!(r["sep"], serde_json::json!("/"));
25677            assert_eq!(r["delimiter"], serde_json::json!(":"));
25678            assert_eq!(r["dirnameRoot"], serde_json::json!("/"));
25679            assert_eq!(r["dirnameNested"], serde_json::json!("/a/b"));
25680            // join doesn't normalize; normalize is separate
25681            let join_dots = r["joinDots"].as_str().unwrap();
25682            assert!(join_dots == "b" || join_dots == "a/../b");
25683        });
25684    }
25685
25686    #[test]
25687    fn pijs_node_util_module_exports() {
25688        futures::executor::block_on(async {
25689            let clock = Arc::new(DeterministicClock::new(0));
25690            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25691                .await
25692                .expect("create runtime");
25693
25694            runtime
25695                .eval(
25696                    r"
25697                    globalThis.utilResults = {};
25698                    import('node:util').then((util) => {
25699                        globalThis.utilResults.hasInspect = typeof util.inspect === 'function';
25700                        globalThis.utilResults.hasPromisify = typeof util.promisify === 'function';
25701                        globalThis.utilResults.inspectResult = util.inspect({ a: 1, b: [2, 3] });
25702                        globalThis.utilResults.done = true;
25703                    });
25704                    ",
25705                )
25706                .await
25707                .expect("eval node:util");
25708
25709            let r = get_global_json(&runtime, "utilResults").await;
25710            assert_eq!(r["done"], serde_json::json!(true));
25711            assert_eq!(r["hasInspect"], serde_json::json!(true));
25712            assert_eq!(r["hasPromisify"], serde_json::json!(true));
25713            // inspect should return some string representation
25714            assert!(r["inspectResult"].is_string());
25715        });
25716    }
25717
25718    #[test]
25719    fn pijs_node_assert_module_pass_and_fail() {
25720        futures::executor::block_on(async {
25721            let clock = Arc::new(DeterministicClock::new(0));
25722            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25723                .await
25724                .expect("create runtime");
25725
25726            runtime
25727                .eval(
25728                    r"
25729                    globalThis.assertResults = {};
25730                    import('node:assert').then((mod) => {
25731                        const assert = mod.default;
25732
25733                        // Passing assertions should not throw
25734                        assert.ok(true);
25735                        assert.strictEqual(1, 1);
25736                        assert.deepStrictEqual({ a: 1 }, { a: 1 });
25737                        assert.notStrictEqual(1, 2);
25738
25739                        // Failing assertion should throw
25740                        try {
25741                            assert.strictEqual(1, 2);
25742                            globalThis.assertResults.failDidNotThrow = true;
25743                        } catch (e) {
25744                            globalThis.assertResults.failThrew = true;
25745                            globalThis.assertResults.failMessage = e.message || String(e);
25746                        }
25747
25748                        globalThis.assertResults.done = true;
25749                    });
25750                    ",
25751                )
25752                .await
25753                .expect("eval node:assert");
25754
25755            let r = get_global_json(&runtime, "assertResults").await;
25756            assert_eq!(r["done"], serde_json::json!(true));
25757            assert_eq!(r["failThrew"], serde_json::json!(true));
25758            assert!(r["failMessage"].is_string());
25759        });
25760    }
25761
25762    #[test]
25763    fn pijs_node_fs_sync_edge_cases() {
25764        futures::executor::block_on(async {
25765            let clock = Arc::new(DeterministicClock::new(0));
25766            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25767                .await
25768                .expect("create runtime");
25769
25770            runtime
25771                .eval(
25772                    r"
25773                    globalThis.fsEdge = {};
25774                    import('node:fs').then((fs) => {
25775                        // Write, overwrite, read back
25776                        fs.writeFileSync('/edge/file.txt', 'first');
25777                        fs.writeFileSync('/edge/file.txt', 'second');
25778                        globalThis.fsEdge.overwrite = fs.readFileSync('/edge/file.txt', 'utf8');
25779
25780                        // existsSync for existing vs non-existing
25781                        globalThis.fsEdge.existsTrue = fs.existsSync('/edge/file.txt');
25782                        globalThis.fsEdge.existsFalse = fs.existsSync('/nonexistent/file.txt');
25783
25784                        // mkdirSync + readdirSync with withFileTypes
25785                        fs.mkdirSync('/edge/dir');
25786                        fs.writeFileSync('/edge/dir/a.txt', 'aaa');
25787                        fs.mkdirSync('/edge/dir/sub');
25788                        const dirents = fs.readdirSync('/edge/dir', { withFileTypes: true });
25789                        globalThis.fsEdge.direntCount = dirents.length;
25790                        const fileDirent = dirents.find(d => d.name === 'a.txt');
25791                        const dirDirent = dirents.find(d => d.name === 'sub');
25792                        globalThis.fsEdge.fileIsFile = fileDirent ? fileDirent.isFile() : null;
25793                        globalThis.fsEdge.dirIsDir = dirDirent ? dirDirent.isDirectory() : null;
25794
25795                        // rmSync recursive
25796                        fs.writeFileSync('/edge/dir/sub/deep.txt', 'deep');
25797                        fs.rmSync('/edge/dir', { recursive: true });
25798                        globalThis.fsEdge.rmRecursiveGone = !fs.existsSync('/edge/dir');
25799
25800                        // accessSync on non-existing file should throw
25801                        try {
25802                            fs.accessSync('/nope');
25803                            globalThis.fsEdge.accessThrew = false;
25804                        } catch (e) {
25805                            globalThis.fsEdge.accessThrew = true;
25806                        }
25807
25808                        // statSync on directory
25809                        fs.mkdirSync('/edge/statdir');
25810                        const dStat = fs.statSync('/edge/statdir');
25811                        globalThis.fsEdge.dirStatIsDir = dStat.isDirectory();
25812                        globalThis.fsEdge.dirStatIsFile = dStat.isFile();
25813
25814                        globalThis.fsEdge.done = true;
25815                    });
25816                    ",
25817                )
25818                .await
25819                .expect("eval fs edge cases");
25820
25821            let r = get_global_json(&runtime, "fsEdge").await;
25822            assert_eq!(r["done"], serde_json::json!(true));
25823            assert_eq!(r["overwrite"], serde_json::json!("second"));
25824            assert_eq!(r["existsTrue"], serde_json::json!(true));
25825            assert_eq!(r["existsFalse"], serde_json::json!(false));
25826            assert_eq!(r["direntCount"], serde_json::json!(2));
25827            assert_eq!(r["fileIsFile"], serde_json::json!(true));
25828            assert_eq!(r["dirIsDir"], serde_json::json!(true));
25829            assert_eq!(r["rmRecursiveGone"], serde_json::json!(true));
25830            assert_eq!(r["accessThrew"], serde_json::json!(true));
25831            assert_eq!(r["dirStatIsDir"], serde_json::json!(true));
25832            assert_eq!(r["dirStatIsFile"], serde_json::json!(false));
25833        });
25834    }
25835
25836    #[test]
25837    fn pijs_node_net_and_http_stubs_throw() {
25838        futures::executor::block_on(async {
25839            let clock = Arc::new(DeterministicClock::new(0));
25840            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25841                .await
25842                .expect("create runtime");
25843
25844            runtime
25845                .eval(
25846                    r"
25847                    globalThis.stubResults = {};
25848                    (async () => {
25849                        // node:net createServer should throw
25850                        const net = await import('node:net');
25851                        try {
25852                            net.createServer();
25853                            globalThis.stubResults.netThrew = false;
25854                        } catch (e) {
25855                            globalThis.stubResults.netThrew = true;
25856                        }
25857
25858                        // node:http createServer should throw
25859                        const http = await import('node:http');
25860                        try {
25861                            http.createServer();
25862                            globalThis.stubResults.httpThrew = false;
25863                        } catch (e) {
25864                            globalThis.stubResults.httpThrew = true;
25865                        }
25866
25867                        // node:https createServer should throw
25868                        const https = await import('node:https');
25869                        try {
25870                            https.createServer();
25871                            globalThis.stubResults.httpsThrew = false;
25872                        } catch (e) {
25873                            globalThis.stubResults.httpsThrew = true;
25874                        }
25875
25876                        globalThis.stubResults.done = true;
25877                    })();
25878                    ",
25879                )
25880                .await
25881                .expect("eval stub throws");
25882
25883            drain_until_idle(&runtime, &clock).await;
25884
25885            let r = get_global_json(&runtime, "stubResults").await;
25886            assert_eq!(r["done"], serde_json::json!(true));
25887            assert_eq!(r["netThrew"], serde_json::json!(true));
25888            assert_eq!(r["httpThrew"], serde_json::json!(true));
25889            assert_eq!(r["httpsThrew"], serde_json::json!(true));
25890        });
25891    }
25892
25893    #[test]
25894    fn pijs_glob_sync_matches_vfs() {
25895        futures::executor::block_on(async {
25896            let clock = Arc::new(DeterministicClock::new(0));
25897            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25898                .await
25899                .expect("create runtime");
25900
25901            runtime
25902                .eval(
25903                    r"
25904                    globalThis.globResult = {};
25905                    (async () => {
25906                        const fs = await import('node:fs');
25907                        fs.mkdirSync('/glob');
25908                        fs.writeFileSync('/glob/a.txt', 'a');
25909                        fs.writeFileSync('/glob/b.md', 'b');
25910                        fs.mkdirSync('/glob/sub');
25911                        fs.writeFileSync('/glob/sub/c.txt', 'c');
25912
25913                        const glob = await import('glob');
25914                        globalThis.globResult.txt = glob.globSync('/glob/**/*.txt');
25915                        globalThis.globResult.md = glob.globSync('/glob/*.md');
25916                        globalThis.globResult.rel = glob.globSync('glob/*.md', { cwd: '/' });
25917                        globalThis.globResult.done = true;
25918                    })();
25919                    ",
25920                )
25921                .await
25922                .expect("eval glob");
25923
25924            drain_until_idle(&runtime, &clock).await;
25925
25926            let r = get_global_json(&runtime, "globResult").await;
25927            assert_eq!(r["done"], serde_json::json!(true));
25928            assert_eq!(
25929                r["txt"],
25930                serde_json::json!(["/glob/a.txt", "/glob/sub/c.txt"])
25931            );
25932            assert_eq!(r["md"], serde_json::json!(["/glob/b.md"]));
25933            assert_eq!(r["rel"], serde_json::json!(["glob/b.md"]));
25934        });
25935    }
25936
25937    #[test]
25938    fn pijs_calculate_cost_updates_usage() {
25939        futures::executor::block_on(async {
25940            let clock = Arc::new(DeterministicClock::new(0));
25941            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25942                .await
25943                .expect("create runtime");
25944
25945            runtime
25946                .eval(
25947                    r"
25948                    globalThis.costResult = {};
25949                    (async () => {
25950                        const ai = await import('@mariozechner/pi-ai');
25951                        const model = { cost: { input: 2, output: 4, cacheRead: 1, cacheWrite: 3 } };
25952                        const usage = {
25953                            input: 1000,
25954                            output: 2000,
25955                            cacheRead: 500,
25956                            cacheWrite: 250,
25957                            cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
25958                        };
25959                        const cost = ai.calculateCost(model, usage);
25960                        globalThis.costResult = { cost, usage, done: true };
25961                    })();
25962                    ",
25963                )
25964                .await
25965                .expect("eval cost");
25966
25967            drain_until_idle(&runtime, &clock).await;
25968
25969            let r = get_global_json(&runtime, "costResult").await;
25970            assert_eq!(r["done"], serde_json::json!(true));
25971            let total_tokens = r["usage"]["totalTokens"].as_u64().unwrap_or_default();
25972            assert_eq!(total_tokens, 3750);
25973
25974            let total_cost = r["cost"]["total"].as_f64().unwrap_or_default();
25975            assert!((total_cost - 0.01125).abs() < 1e-9);
25976            let input_cost = r["cost"]["input"].as_f64().unwrap_or_default();
25977            assert!((input_cost - 0.002).abs() < 1e-9);
25978            let output_cost = r["cost"]["output"].as_f64().unwrap_or_default();
25979            assert!((output_cost - 0.008).abs() < 1e-9);
25980        });
25981    }
25982
25983    #[test]
25984    fn pijs_node_readline_stub_exports() {
25985        futures::executor::block_on(async {
25986            let clock = Arc::new(DeterministicClock::new(0));
25987            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
25988                .await
25989                .expect("create runtime");
25990
25991            runtime
25992                .eval(
25993                    r"
25994                    globalThis.rlResult = {};
25995                    import('node:readline').then((rl) => {
25996                        globalThis.rlResult.hasCreateInterface = typeof rl.createInterface === 'function';
25997                        globalThis.rlResult.done = true;
25998                    });
25999                    ",
26000                )
26001                .await
26002                .expect("eval readline");
26003
26004            let r = get_global_json(&runtime, "rlResult").await;
26005            assert_eq!(r["done"], serde_json::json!(true));
26006            assert_eq!(r["hasCreateInterface"], serde_json::json!(true));
26007        });
26008    }
26009
26010    #[test]
26011    fn pijs_node_test_stub_describe_it_flags() {
26012        futures::executor::block_on(async {
26013            let clock = Arc::new(DeterministicClock::new(0));
26014            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
26015                .await
26016                .expect("create runtime");
26017
26018            runtime
26019                .eval(
26020                    r"
26021                    globalThis.nodeTest = {};
26022                    import('node:test').then((mod) => {
26023                        const { test, describe, it } = mod;
26024                        globalThis.nodeTest.hasTestSkip = typeof test.skip === 'function';
26025                        globalThis.nodeTest.hasDescribeSkip = typeof describe.skip === 'function';
26026                        globalThis.nodeTest.hasItSkip = typeof it.skip === 'function';
26027                        globalThis.nodeTest.hasDescribeOnly = typeof describe.only === 'function';
26028                        globalThis.nodeTest.hasItOnly = typeof it.only === 'function';
26029                        globalThis.nodeTest.hasDescribeTodo = typeof describe.todo === 'function';
26030                        globalThis.nodeTest.hasItTodo = typeof it.todo === 'function';
26031                        globalThis.nodeTest.done = true;
26032                    });
26033                    ",
26034                )
26035                .await
26036                .expect("eval node:test");
26037
26038            let r = get_global_json(&runtime, "nodeTest").await;
26039            assert_eq!(r["done"], serde_json::json!(true));
26040            assert_eq!(r["hasTestSkip"], serde_json::json!(true));
26041            assert_eq!(r["hasDescribeSkip"], serde_json::json!(true));
26042            assert_eq!(r["hasItSkip"], serde_json::json!(true));
26043            assert_eq!(r["hasDescribeOnly"], serde_json::json!(true));
26044            assert_eq!(r["hasItOnly"], serde_json::json!(true));
26045            assert_eq!(r["hasDescribeTodo"], serde_json::json!(true));
26046            assert_eq!(r["hasItTodo"], serde_json::json!(true));
26047        });
26048    }
26049
26050    #[test]
26051    fn pijs_node_test_runs_basic_cases() {
26052        futures::executor::block_on(async {
26053            let clock = Arc::new(DeterministicClock::new(0));
26054            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
26055                .await
26056                .expect("create runtime");
26057
26058            runtime
26059                .eval(
26060                    r#"
26061                    globalThis.nodeTestRun = { done: false };
26062                    (async () => {
26063                        const { test, describe, it, beforeEach, afterEach, run } = await import("node:test");
26064                        const order = [];
26065                        beforeEach(() => order.push("beforeEach"));
26066                        afterEach(() => order.push("afterEach"));
26067                        test("passes", () => { order.push("pass"); });
26068                        test.skip("skipped", () => { order.push("skip"); });
26069                        describe("suite", () => {
26070                            it("nested", () => { order.push("nested"); });
26071                        });
26072                        const result = await run();
26073                        globalThis.nodeTestRun.result = result;
26074                        globalThis.nodeTestRun.order = order;
26075                        globalThis.nodeTestRun.done = true;
26076                    })().catch((e) => {
26077                        globalThis.nodeTestRun.error = String(e && e.message ? e.message : e);
26078                        globalThis.nodeTestRun.done = false;
26079                    });
26080                    "#,
26081                )
26082                .await
26083                .expect("eval node:test run");
26084
26085            drain_until_idle(&runtime, &clock).await;
26086
26087            let r = get_global_json(&runtime, "nodeTestRun").await;
26088            assert_eq!(r["done"], serde_json::json!(true));
26089            assert_eq!(r["result"]["ok"], serde_json::json!(true));
26090            assert_eq!(r["result"]["summary"]["total"], serde_json::json!(3));
26091            assert_eq!(r["result"]["summary"]["passed"], serde_json::json!(2));
26092            assert_eq!(r["result"]["summary"]["failed"], serde_json::json!(0));
26093            assert_eq!(r["result"]["summary"]["skipped"], serde_json::json!(1));
26094            assert_eq!(
26095                r["order"],
26096                serde_json::json!([
26097                    "beforeEach",
26098                    "pass",
26099                    "afterEach",
26100                    "beforeEach",
26101                    "nested",
26102                    "afterEach"
26103                ])
26104            );
26105        });
26106    }
26107
26108    #[test]
26109    fn pijs_node_stream_promises_pipeline_pass_through() {
26110        futures::executor::block_on(async {
26111            let clock = Arc::new(DeterministicClock::new(0));
26112            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
26113                .await
26114                .expect("create runtime");
26115
26116            runtime
26117                .eval(
26118                    r#"
26119                    globalThis.streamInterop = { done: false };
26120                    (async () => {
26121                        const { Readable, PassThrough, Writable } = await import("node:stream");
26122                        const { pipeline } = await import("node:stream/promises");
26123
26124                        const collected = [];
26125                        const source = Readable.from(["alpha", "-", "omega"]);
26126                        const through = new PassThrough();
26127                        const sink = new Writable({
26128                          write(chunk, _encoding, callback) {
26129                            collected.push(String(chunk));
26130                            callback(null);
26131                          }
26132                        });
26133
26134                        await pipeline(source, through, sink);
26135                        globalThis.streamInterop.value = collected.join("");
26136                        globalThis.streamInterop.done = true;
26137                    })().catch((e) => {
26138                        globalThis.streamInterop.error = String(e && e.message ? e.message : e);
26139                        globalThis.streamInterop.done = false;
26140                    });
26141                    "#,
26142                )
26143                .await
26144                .expect("eval node:stream pipeline");
26145
26146            drain_until_idle(&runtime, &clock).await;
26147
26148            let result = get_global_json(&runtime, "streamInterop").await;
26149            assert_eq!(result["done"], serde_json::json!(true));
26150            assert_eq!(result["value"], serde_json::json!("alpha-omega"));
26151        });
26152    }
26153
26154    #[test]
26155    fn pijs_fs_create_stream_pipeline_copies_content() {
26156        futures::executor::block_on(async {
26157            let clock = Arc::new(DeterministicClock::new(0));
26158            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
26159                .await
26160                .expect("create runtime");
26161
26162            runtime
26163                .eval(
26164                    r#"
26165                    globalThis.fsStreamCopy = { done: false };
26166                    (async () => {
26167                        const fs = await import("node:fs");
26168                        const { pipeline } = await import("node:stream/promises");
26169
26170                        fs.writeFileSync("/tmp/source.txt", "stream-data-123");
26171                        const src = fs.createReadStream("/tmp/source.txt");
26172                        const dst = fs.createWriteStream("/tmp/dest.txt");
26173                        await pipeline(src, dst);
26174
26175                        globalThis.fsStreamCopy.value = fs.readFileSync("/tmp/dest.txt", "utf8");
26176                        globalThis.fsStreamCopy.done = true;
26177                    })().catch((e) => {
26178                        globalThis.fsStreamCopy.error = String(e && e.message ? e.message : e);
26179                        globalThis.fsStreamCopy.done = false;
26180                    });
26181                    "#,
26182                )
26183                .await
26184                .expect("eval fs stream copy");
26185
26186            drain_until_idle(&runtime, &clock).await;
26187
26188            let result = get_global_json(&runtime, "fsStreamCopy").await;
26189            assert_eq!(result["done"], serde_json::json!(true));
26190            assert_eq!(result["value"], serde_json::json!("stream-data-123"));
26191        });
26192    }
26193
26194    #[test]
26195    fn pijs_node_stream_web_stream_bridge_roundtrip() {
26196        futures::executor::block_on(async {
26197            let clock = Arc::new(DeterministicClock::new(0));
26198            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
26199                .await
26200                .expect("create runtime");
26201
26202            runtime
26203                .eval(
26204                    r#"
26205                    globalThis.webBridge = { done: false, skipped: false };
26206                    (async () => {
26207                        if (typeof ReadableStream !== "function" || typeof WritableStream !== "function") {
26208                            globalThis.webBridge.skipped = true;
26209                            globalThis.webBridge.done = true;
26210                            return;
26211                        }
26212
26213                        const { Readable, Writable } = await import("node:stream");
26214                        const { pipeline } = await import("node:stream/promises");
26215
26216                        const webReadable = new ReadableStream({
26217                          start(controller) {
26218                            controller.enqueue("ab");
26219                            controller.enqueue("cd");
26220                            controller.close();
26221                          }
26222                        });
26223                        const nodeReadable = Readable.fromWeb(webReadable);
26224
26225                        const fromWebChunks = [];
26226                        const webWritable = new WritableStream({
26227                          write(chunk) {
26228                            fromWebChunks.push(String(chunk));
26229                          }
26230                        });
26231                        const nodeWritable = Writable.fromWeb(webWritable);
26232                        await pipeline(nodeReadable, nodeWritable);
26233
26234                        const nodeReadableRoundtrip = Readable.from(["x", "y"]);
26235                        const webReadableRoundtrip = Readable.toWeb(nodeReadableRoundtrip);
26236                        const reader = webReadableRoundtrip.getReader();
26237                        const toWebChunks = [];
26238                        while (true) {
26239                          const { done, value } = await reader.read();
26240                          if (done) break;
26241                          toWebChunks.push(String(value));
26242                        }
26243
26244                        globalThis.webBridge.fromWeb = fromWebChunks.join("");
26245                        globalThis.webBridge.toWeb = toWebChunks.join("");
26246                        globalThis.webBridge.done = true;
26247                    })().catch((e) => {
26248                        globalThis.webBridge.error = String(e && e.message ? e.message : e);
26249                        globalThis.webBridge.done = false;
26250                    });
26251                    "#,
26252                )
26253                .await
26254                .expect("eval web stream bridge");
26255
26256            drain_until_idle(&runtime, &clock).await;
26257
26258            let result = get_global_json(&runtime, "webBridge").await;
26259            assert_eq!(result["done"], serde_json::json!(true));
26260            if result["skipped"] == serde_json::json!(true) {
26261                return;
26262            }
26263            assert_eq!(result["fromWeb"], serde_json::json!("abcd"));
26264            assert_eq!(result["toWeb"], serde_json::json!("xy"));
26265        });
26266    }
26267
26268    // ── Streaming hostcall tests ────────────────────────────────────────
26269
26270    #[test]
26271    fn pijs_stream_chunks_delivered_via_async_iterator() {
26272        futures::executor::block_on(async {
26273            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
26274                .await
26275                .expect("create runtime");
26276
26277            // Start a streaming exec call
26278            runtime
26279                .eval(
26280                    r#"
26281            globalThis.chunks = [];
26282            globalThis.done = false;
26283            (async () => {
26284                const stream = pi.exec("cat", ["big.txt"], { stream: true });
26285                for await (const chunk of stream) {
26286                    globalThis.chunks.push(chunk);
26287                }
26288                globalThis.done = true;
26289            })();
26290            "#,
26291                )
26292                .await
26293                .expect("eval");
26294
26295            let requests = runtime.drain_hostcall_requests();
26296            assert_eq!(requests.len(), 1);
26297            let call_id = requests[0].call_id.clone();
26298
26299            // Send three non-final chunks then a final one
26300            for seq in 0..3 {
26301                runtime.complete_hostcall(
26302                    call_id.clone(),
26303                    HostcallOutcome::StreamChunk {
26304                        sequence: seq,
26305                        chunk: serde_json::json!({ "line": seq }),
26306                        is_final: false,
26307                    },
26308                );
26309                let stats = runtime.tick().await.expect("tick chunk");
26310                assert!(stats.ran_macrotask);
26311            }
26312
26313            // Hostcall should still be pending (tracker not yet completed)
26314            assert!(
26315                runtime.hostcall_tracker.borrow().is_pending(&call_id),
26316                "hostcall should still be pending after non-final chunks"
26317            );
26318
26319            // Send final chunk
26320            runtime.complete_hostcall(
26321                call_id.clone(),
26322                HostcallOutcome::StreamChunk {
26323                    sequence: 3,
26324                    chunk: serde_json::json!({ "line": 3 }),
26325                    is_final: true,
26326                },
26327            );
26328            let stats = runtime.tick().await.expect("tick final");
26329            assert!(stats.ran_macrotask);
26330
26331            // Hostcall is now completed
26332            assert!(
26333                !runtime.hostcall_tracker.borrow().is_pending(&call_id),
26334                "hostcall should be completed after final chunk"
26335            );
26336
26337            // Run microtasks to let the async iterator resolve
26338            runtime.tick().await.expect("tick settle");
26339
26340            let chunks = get_global_json(&runtime, "chunks").await;
26341            let arr = chunks.as_array().expect("chunks is array");
26342            assert_eq!(arr.len(), 4, "expected 4 chunks, got {arr:?}");
26343            for (i, c) in arr.iter().enumerate() {
26344                assert_eq!(c["line"], serde_json::json!(i), "chunk {i}");
26345            }
26346
26347            let done = get_global_json(&runtime, "done").await;
26348            assert_eq!(
26349                done,
26350                serde_json::json!(true),
26351                "async loop should have completed"
26352            );
26353        });
26354    }
26355
26356    #[test]
26357    fn pijs_stream_error_rejects_async_iterator() {
26358        futures::executor::block_on(async {
26359            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
26360                .await
26361                .expect("create runtime");
26362
26363            runtime
26364                .eval(
26365                    r#"
26366            globalThis.chunks = [];
26367            globalThis.errMsg = null;
26368            (async () => {
26369                try {
26370                    const stream = pi.exec("fail", [], { stream: true });
26371                    for await (const chunk of stream) {
26372                        globalThis.chunks.push(chunk);
26373                    }
26374                } catch (e) {
26375                    globalThis.errMsg = e.message;
26376                }
26377            })();
26378            "#,
26379                )
26380                .await
26381                .expect("eval");
26382
26383            let requests = runtime.drain_hostcall_requests();
26384            let call_id = requests[0].call_id.clone();
26385
26386            // Send one good chunk
26387            runtime.complete_hostcall(
26388                call_id.clone(),
26389                HostcallOutcome::StreamChunk {
26390                    sequence: 0,
26391                    chunk: serde_json::json!("first"),
26392                    is_final: false,
26393                },
26394            );
26395            runtime.tick().await.expect("tick chunk 0");
26396
26397            // Now error the hostcall
26398            runtime.complete_hostcall(
26399                call_id,
26400                HostcallOutcome::Error {
26401                    code: "STREAM_ERR".into(),
26402                    message: "broken pipe".into(),
26403                },
26404            );
26405            runtime.tick().await.expect("tick error");
26406            runtime.tick().await.expect("tick settle");
26407
26408            let chunks = get_global_json(&runtime, "chunks").await;
26409            assert_eq!(
26410                chunks.as_array().expect("array").len(),
26411                1,
26412                "should have received 1 chunk before error"
26413            );
26414
26415            let err = get_global_json(&runtime, "errMsg").await;
26416            assert_eq!(err, serde_json::json!("broken pipe"));
26417        });
26418    }
26419
26420    #[test]
26421    fn pijs_stream_http_returns_async_iterator() {
26422        futures::executor::block_on(async {
26423            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
26424                .await
26425                .expect("create runtime");
26426
26427            runtime
26428                .eval(
26429                    r#"
26430            globalThis.chunks = [];
26431            globalThis.done = false;
26432            (async () => {
26433                const stream = pi.http({ url: "http://example.com", stream: true });
26434                for await (const chunk of stream) {
26435                    globalThis.chunks.push(chunk);
26436                }
26437                globalThis.done = true;
26438            })();
26439            "#,
26440                )
26441                .await
26442                .expect("eval");
26443
26444            let requests = runtime.drain_hostcall_requests();
26445            assert_eq!(requests.len(), 1);
26446            let call_id = requests[0].call_id.clone();
26447
26448            // Two chunks: non-final then final
26449            runtime.complete_hostcall(
26450                call_id.clone(),
26451                HostcallOutcome::StreamChunk {
26452                    sequence: 0,
26453                    chunk: serde_json::json!("chunk-a"),
26454                    is_final: false,
26455                },
26456            );
26457            runtime.tick().await.expect("tick a");
26458
26459            runtime.complete_hostcall(
26460                call_id,
26461                HostcallOutcome::StreamChunk {
26462                    sequence: 1,
26463                    chunk: serde_json::json!("chunk-b"),
26464                    is_final: true,
26465                },
26466            );
26467            runtime.tick().await.expect("tick b");
26468            runtime.tick().await.expect("tick settle");
26469
26470            let chunks = get_global_json(&runtime, "chunks").await;
26471            let arr = chunks.as_array().expect("array");
26472            assert_eq!(arr.len(), 2);
26473            assert_eq!(arr[0], serde_json::json!("chunk-a"));
26474            assert_eq!(arr[1], serde_json::json!("chunk-b"));
26475
26476            assert_eq!(
26477                get_global_json(&runtime, "done").await,
26478                serde_json::json!(true)
26479            );
26480        });
26481    }
26482
26483    #[test]
26484    #[allow(clippy::too_many_lines)]
26485    fn pijs_stream_concurrent_exec_calls_have_independent_lifecycle() {
26486        futures::executor::block_on(async {
26487            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
26488                .await
26489                .expect("create runtime");
26490
26491            runtime
26492                .eval(
26493                    r#"
26494            globalThis.streamA = [];
26495            globalThis.streamB = [];
26496            globalThis.doneA = false;
26497            globalThis.doneB = false;
26498            (async () => {
26499                const stream = pi.exec("cmd-a", [], { stream: true });
26500                for await (const chunk of stream) {
26501                    globalThis.streamA.push(chunk);
26502                }
26503                globalThis.doneA = true;
26504            })();
26505            (async () => {
26506                const stream = pi.exec("cmd-b", [], { stream: true });
26507                for await (const chunk of stream) {
26508                    globalThis.streamB.push(chunk);
26509                }
26510                globalThis.doneB = true;
26511            })();
26512            "#,
26513                )
26514                .await
26515                .expect("eval");
26516
26517            let requests = runtime.drain_hostcall_requests();
26518            assert_eq!(requests.len(), 2, "expected two streaming exec requests");
26519
26520            let mut call_a: Option<String> = None;
26521            let mut call_b: Option<String> = None;
26522            for request in &requests {
26523                match &request.kind {
26524                    HostcallKind::Exec { cmd } if cmd == "cmd-a" => {
26525                        call_a = Some(request.call_id.clone());
26526                    }
26527                    HostcallKind::Exec { cmd } if cmd == "cmd-b" => {
26528                        call_b = Some(request.call_id.clone());
26529                    }
26530                    _ => {}
26531                }
26532            }
26533
26534            let call_a = call_a.expect("call_id for cmd-a");
26535            let call_b = call_b.expect("call_id for cmd-b");
26536            assert_ne!(call_a, call_b, "concurrent calls must have distinct ids");
26537            assert_eq!(runtime.pending_hostcall_count(), 2);
26538
26539            runtime.complete_hostcall(
26540                call_a.clone(),
26541                HostcallOutcome::StreamChunk {
26542                    sequence: 0,
26543                    chunk: serde_json::json!("a0"),
26544                    is_final: false,
26545                },
26546            );
26547            runtime.tick().await.expect("tick a0");
26548
26549            runtime.complete_hostcall(
26550                call_b.clone(),
26551                HostcallOutcome::StreamChunk {
26552                    sequence: 0,
26553                    chunk: serde_json::json!("b0"),
26554                    is_final: false,
26555                },
26556            );
26557            runtime.tick().await.expect("tick b0");
26558            assert_eq!(runtime.pending_hostcall_count(), 2);
26559
26560            runtime.complete_hostcall(
26561                call_b.clone(),
26562                HostcallOutcome::StreamChunk {
26563                    sequence: 1,
26564                    chunk: serde_json::json!("b1"),
26565                    is_final: true,
26566                },
26567            );
26568            runtime.tick().await.expect("tick b1");
26569            assert_eq!(runtime.pending_hostcall_count(), 1);
26570            assert!(runtime.is_hostcall_pending(&call_a));
26571            assert!(!runtime.is_hostcall_pending(&call_b));
26572
26573            runtime.complete_hostcall(
26574                call_a.clone(),
26575                HostcallOutcome::StreamChunk {
26576                    sequence: 1,
26577                    chunk: serde_json::json!("a1"),
26578                    is_final: true,
26579                },
26580            );
26581            runtime.tick().await.expect("tick a1");
26582            assert_eq!(runtime.pending_hostcall_count(), 0);
26583            assert!(!runtime.is_hostcall_pending(&call_a));
26584
26585            runtime.tick().await.expect("tick settle 1");
26586            runtime.tick().await.expect("tick settle 2");
26587
26588            let stream_a = get_global_json(&runtime, "streamA").await;
26589            let stream_b = get_global_json(&runtime, "streamB").await;
26590            assert_eq!(
26591                stream_a.as_array().expect("streamA array"),
26592                &vec![serde_json::json!("a0"), serde_json::json!("a1")]
26593            );
26594            assert_eq!(
26595                stream_b.as_array().expect("streamB array"),
26596                &vec![serde_json::json!("b0"), serde_json::json!("b1")]
26597            );
26598            assert_eq!(
26599                get_global_json(&runtime, "doneA").await,
26600                serde_json::json!(true)
26601            );
26602            assert_eq!(
26603                get_global_json(&runtime, "doneB").await,
26604                serde_json::json!(true)
26605            );
26606        });
26607    }
26608
26609    #[test]
26610    fn pijs_stream_chunk_ignored_after_hostcall_completed() {
26611        futures::executor::block_on(async {
26612            let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
26613                .await
26614                .expect("create runtime");
26615
26616            runtime
26617                .eval(
26618                    r#"
26619            globalThis.result = null;
26620            pi.tool("read", { path: "test.txt" }).then(r => {
26621                globalThis.result = r;
26622            });
26623            "#,
26624                )
26625                .await
26626                .expect("eval");
26627
26628            let requests = runtime.drain_hostcall_requests();
26629            let call_id = requests[0].call_id.clone();
26630
26631            // Complete normally first
26632            runtime.complete_hostcall(
26633                call_id.clone(),
26634                HostcallOutcome::Success(serde_json::json!({ "content": "done" })),
26635            );
26636            runtime.tick().await.expect("tick success");
26637
26638            // Now try to deliver a stream chunk to the same call_id — should be ignored
26639            runtime.complete_hostcall(
26640                call_id,
26641                HostcallOutcome::StreamChunk {
26642                    sequence: 0,
26643                    chunk: serde_json::json!("stale"),
26644                    is_final: false,
26645                },
26646            );
26647            // This should not panic
26648            let stats = runtime.tick().await.expect("tick stale chunk");
26649            assert!(stats.ran_macrotask, "macrotask should run (and be ignored)");
26650
26651            let result = get_global_json(&runtime, "result").await;
26652            assert_eq!(result["content"], serde_json::json!("done"));
26653        });
26654    }
26655
26656    // ── node:child_process sync tests ──────────────────────────────────
26657
26658    #[test]
26659    fn pijs_exec_sync_denied_by_default_security_policy() {
26660        futures::executor::block_on(async {
26661            let clock = Arc::new(DeterministicClock::new(0));
26662            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
26663                .await
26664                .expect("create runtime");
26665
26666            runtime
26667                .eval(
26668                    r"
26669                    globalThis.syncDenied = {};
26670                    import('node:child_process').then(({ execSync }) => {
26671                        try {
26672                            execSync('echo should-not-run');
26673                            globalThis.syncDenied.threw = false;
26674                        } catch (e) {
26675                            globalThis.syncDenied.threw = true;
26676                            globalThis.syncDenied.msg = String((e && e.message) || e || '');
26677                        }
26678                        globalThis.syncDenied.done = true;
26679                    });
26680                    ",
26681                )
26682                .await
26683                .expect("eval execSync deny");
26684
26685            let r = get_global_json(&runtime, "syncDenied").await;
26686            assert_eq!(r["done"], serde_json::json!(true));
26687            assert_eq!(r["threw"], serde_json::json!(true));
26688            assert!(
26689                r["msg"]
26690                    .as_str()
26691                    .unwrap_or("")
26692                    .contains("disabled by default"),
26693                "unexpected denial message: {}",
26694                r["msg"]
26695            );
26696        });
26697    }
26698
26699    #[test]
26700    fn pijs_exec_sync_enforces_exec_mediation_for_critical_commands() {
26701        futures::executor::block_on(async {
26702            let clock = Arc::new(DeterministicClock::new(0));
26703            let config = PiJsRuntimeConfig {
26704                allow_unsafe_sync_exec: true,
26705                ..PiJsRuntimeConfig::default()
26706            };
26707            let policy = crate::extensions::PolicyProfile::Permissive.to_policy();
26708            let runtime = PiJsRuntime::with_clock_and_config_with_policy(
26709                Arc::clone(&clock),
26710                config,
26711                Some(policy),
26712            )
26713            .await
26714            .expect("create runtime");
26715
26716            runtime
26717                .eval(
26718                    r"
26719                    globalThis.syncMediation = {};
26720                    import('node:child_process').then(({ execSync }) => {
26721                        try {
26722                            execSync('dd if=/dev/zero of=/dev/null count=1');
26723                            globalThis.syncMediation.threw = false;
26724                        } catch (e) {
26725                            globalThis.syncMediation.threw = true;
26726                            globalThis.syncMediation.msg = String((e && e.message) || e || '');
26727                        }
26728                        globalThis.syncMediation.done = true;
26729                    });
26730                    ",
26731                )
26732                .await
26733                .expect("eval execSync mediation");
26734
26735            let r = get_global_json(&runtime, "syncMediation").await;
26736            assert_eq!(r["done"], serde_json::json!(true));
26737            assert_eq!(r["threw"], serde_json::json!(true));
26738            assert!(
26739                r["msg"].as_str().unwrap_or("").contains("exec mediation"),
26740                "unexpected mediation denial message: {}",
26741                r["msg"]
26742            );
26743        });
26744    }
26745
26746    #[test]
26747    fn pijs_exec_sync_runs_command_and_returns_stdout() {
26748        futures::executor::block_on(async {
26749            let clock = Arc::new(DeterministicClock::new(0));
26750            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26751
26752            runtime
26753                .eval(
26754                    r"
26755                    globalThis.syncResult = {};
26756                    import('node:child_process').then(({ execSync }) => {
26757                        try {
26758                            const output = execSync('echo hello-sync');
26759                            globalThis.syncResult.stdout = output.trim();
26760                            globalThis.syncResult.done = true;
26761                        } catch (e) {
26762                            globalThis.syncResult.error = String(e);
26763                            globalThis.syncResult.stack = e.stack || '';
26764                            globalThis.syncResult.done = false;
26765                        }
26766                    }).catch(e => {
26767                        globalThis.syncResult.promiseError = String(e);
26768                    });
26769                    ",
26770                )
26771                .await
26772                .expect("eval execSync test");
26773
26774            let r = get_global_json(&runtime, "syncResult").await;
26775            assert!(
26776                r["done"] == serde_json::json!(true),
26777                "execSync test failed: error={}, stack={}, promiseError={}",
26778                r["error"],
26779                r["stack"],
26780                r["promiseError"]
26781            );
26782            assert_eq!(r["stdout"], serde_json::json!("hello-sync"));
26783        });
26784    }
26785
26786    #[test]
26787    fn pijs_exec_sync_throws_when_stdout_exceeds_max_buffer() {
26788        futures::executor::block_on(async {
26789            let clock = Arc::new(DeterministicClock::new(0));
26790            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26791
26792            runtime
26793                .eval(
26794                    r#"
26795                    globalThis.syncMaxBuffer = {};
26796                    import('node:child_process').then(({ execSync }) => {
26797                        try {
26798                            execSync(
26799                                "python3 -c 'import sys; sys.stdout.write(\"x\" * 70000)'",
26800                                { maxBuffer: 1024 }
26801                            );
26802                            globalThis.syncMaxBuffer.threw = false;
26803                        } catch (e) {
26804                            globalThis.syncMaxBuffer.threw = true;
26805                            globalThis.syncMaxBuffer.msg = String((e && e.message) || e || '');
26806                            globalThis.syncMaxBuffer.stdoutLen =
26807                                typeof e.stdout === 'string' ? e.stdout.length : -1;
26808                        }
26809                        globalThis.syncMaxBuffer.done = true;
26810                    });
26811                    "#,
26812                )
26813                .await
26814                .expect("eval execSync maxBuffer");
26815
26816            let r = get_global_json(&runtime, "syncMaxBuffer").await;
26817            assert_eq!(r["done"], serde_json::json!(true));
26818            assert_eq!(r["threw"], serde_json::json!(true));
26819            assert!(
26820                r["msg"]
26821                    .as_str()
26822                    .unwrap_or("")
26823                    .contains("maxBuffer length exceeded"),
26824                "unexpected maxBuffer message: {}",
26825                r["msg"]
26826            );
26827            assert_eq!(r["stdoutLen"].as_f64(), Some(1024.0));
26828        });
26829    }
26830
26831    #[test]
26832    fn pijs_exec_sync_throws_on_nonzero_exit() {
26833        futures::executor::block_on(async {
26834            let clock = Arc::new(DeterministicClock::new(0));
26835            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26836
26837            runtime
26838                .eval(
26839                    r"
26840                    globalThis.syncErr = {};
26841                    import('node:child_process').then(({ execSync }) => {
26842                        try {
26843                            execSync('exit 42');
26844                            globalThis.syncErr.threw = false;
26845                        } catch (e) {
26846                            globalThis.syncErr.threw = true;
26847                            globalThis.syncErr.status = e.status;
26848                            globalThis.syncErr.hasStderr = typeof e.stderr === 'string';
26849                        }
26850                        globalThis.syncErr.done = true;
26851                    });
26852                    ",
26853                )
26854                .await
26855                .expect("eval execSync nonzero");
26856
26857            let r = get_global_json(&runtime, "syncErr").await;
26858            assert_eq!(r["done"], serde_json::json!(true));
26859            assert_eq!(r["threw"], serde_json::json!(true));
26860            // Status is a JS number (always f64 in QuickJS), so compare as f64
26861            assert_eq!(r["status"].as_f64(), Some(42.0));
26862            assert_eq!(r["hasStderr"], serde_json::json!(true));
26863        });
26864    }
26865
26866    #[test]
26867    fn pijs_exec_sync_empty_command_throws() {
26868        futures::executor::block_on(async {
26869            let clock = Arc::new(DeterministicClock::new(0));
26870            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26871
26872            runtime
26873                .eval(
26874                    r"
26875                    globalThis.emptyResult = {};
26876                    import('node:child_process').then(({ execSync }) => {
26877                        try {
26878                            execSync('');
26879                            globalThis.emptyResult.threw = false;
26880                        } catch (e) {
26881                            globalThis.emptyResult.threw = true;
26882                            globalThis.emptyResult.msg = e.message;
26883                        }
26884                        globalThis.emptyResult.done = true;
26885                    });
26886                    ",
26887                )
26888                .await
26889                .expect("eval execSync empty");
26890
26891            let r = get_global_json(&runtime, "emptyResult").await;
26892            assert_eq!(r["done"], serde_json::json!(true));
26893            assert_eq!(r["threw"], serde_json::json!(true));
26894            assert!(
26895                r["msg"]
26896                    .as_str()
26897                    .unwrap_or("")
26898                    .contains("command is required")
26899            );
26900        });
26901    }
26902
26903    #[test]
26904    fn pijs_spawn_sync_returns_result_object() {
26905        futures::executor::block_on(async {
26906            let clock = Arc::new(DeterministicClock::new(0));
26907            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26908
26909            runtime
26910                .eval(
26911                    r"
26912                    globalThis.spawnSyncResult = {};
26913                    import('node:child_process').then(({ spawnSync }) => {
26914                        const r = spawnSync('echo', ['spawn-test']);
26915                        globalThis.spawnSyncResult.stdout = r.stdout.trim();
26916                        globalThis.spawnSyncResult.status = r.status;
26917                        globalThis.spawnSyncResult.hasOutput = Array.isArray(r.output);
26918                        globalThis.spawnSyncResult.noError = r.error === undefined;
26919                        globalThis.spawnSyncResult.done = true;
26920                    });
26921                    ",
26922                )
26923                .await
26924                .expect("eval spawnSync test");
26925
26926            let r = get_global_json(&runtime, "spawnSyncResult").await;
26927            assert_eq!(r["done"], serde_json::json!(true));
26928            assert_eq!(r["stdout"], serde_json::json!("spawn-test"));
26929            assert_eq!(r["status"].as_f64(), Some(0.0));
26930            assert_eq!(r["hasOutput"], serde_json::json!(true));
26931            assert_eq!(r["noError"], serde_json::json!(true));
26932        });
26933    }
26934
26935    #[test]
26936    fn pijs_spawn_sync_captures_nonzero_exit() {
26937        futures::executor::block_on(async {
26938            let clock = Arc::new(DeterministicClock::new(0));
26939            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26940
26941            runtime
26942                .eval(
26943                    r"
26944                    globalThis.spawnSyncFail = {};
26945                    import('node:child_process').then(({ spawnSync }) => {
26946                        const r = spawnSync('sh', ['-c', 'exit 7']);
26947                        globalThis.spawnSyncFail.status = r.status;
26948                        globalThis.spawnSyncFail.signal = r.signal;
26949                        globalThis.spawnSyncFail.done = true;
26950                    });
26951                    ",
26952                )
26953                .await
26954                .expect("eval spawnSync fail");
26955
26956            let r = get_global_json(&runtime, "spawnSyncFail").await;
26957            assert_eq!(r["done"], serde_json::json!(true));
26958            assert_eq!(r["status"].as_f64(), Some(7.0));
26959            assert_eq!(r["signal"], serde_json::json!(null));
26960        });
26961    }
26962
26963    #[test]
26964    fn pijs_spawn_sync_bad_command_returns_error() {
26965        futures::executor::block_on(async {
26966            let clock = Arc::new(DeterministicClock::new(0));
26967            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26968
26969            runtime
26970                .eval(
26971                    r"
26972                    globalThis.badCmd = {};
26973                    import('node:child_process').then(({ spawnSync }) => {
26974                        const r = spawnSync('__nonexistent_binary_xyzzy__');
26975                        globalThis.badCmd.hasError = r.error !== undefined;
26976                        globalThis.badCmd.statusNull = r.status === null;
26977                        globalThis.badCmd.done = true;
26978                    });
26979                    ",
26980                )
26981                .await
26982                .expect("eval spawnSync bad cmd");
26983
26984            let r = get_global_json(&runtime, "badCmd").await;
26985            assert_eq!(r["done"], serde_json::json!(true));
26986            assert_eq!(r["hasError"], serde_json::json!(true));
26987            assert_eq!(r["statusNull"], serde_json::json!(true));
26988        });
26989    }
26990
26991    #[test]
26992    fn pijs_exec_file_sync_runs_binary_directly() {
26993        futures::executor::block_on(async {
26994            let clock = Arc::new(DeterministicClock::new(0));
26995            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
26996
26997            runtime
26998                .eval(
26999                    r"
27000                    globalThis.execFileResult = {};
27001                    import('node:child_process').then(({ execFileSync }) => {
27002                        const output = execFileSync('echo', ['file-sync-test']);
27003                        globalThis.execFileResult.stdout = output.trim();
27004                        globalThis.execFileResult.done = true;
27005                    });
27006                    ",
27007                )
27008                .await
27009                .expect("eval execFileSync test");
27010
27011            let r = get_global_json(&runtime, "execFileResult").await;
27012            assert_eq!(r["done"], serde_json::json!(true));
27013            assert_eq!(r["stdout"], serde_json::json!("file-sync-test"));
27014        });
27015    }
27016
27017    #[test]
27018    fn pijs_exec_file_sync_throws_when_stdout_exceeds_max_buffer() {
27019        futures::executor::block_on(async {
27020            let clock = Arc::new(DeterministicClock::new(0));
27021            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
27022
27023            runtime
27024                .eval(
27025                    r#"
27026                    globalThis.execFileMaxBuffer = {};
27027                    import('node:child_process').then(({ execFileSync }) => {
27028                        try {
27029                            execFileSync(
27030                                'python3',
27031                                ['-c', 'import sys; sys.stdout.write("x" * 70000)'],
27032                                { maxBuffer: 1024 }
27033                            );
27034                            globalThis.execFileMaxBuffer.threw = false;
27035                        } catch (e) {
27036                            globalThis.execFileMaxBuffer.threw = true;
27037                            globalThis.execFileMaxBuffer.msg = String((e && e.message) || e || '');
27038                            globalThis.execFileMaxBuffer.stdoutLen =
27039                                typeof e.stdout === 'string' ? e.stdout.length : -1;
27040                        }
27041                        globalThis.execFileMaxBuffer.done = true;
27042                    });
27043                    "#,
27044                )
27045                .await
27046                .expect("eval execFileSync maxBuffer");
27047
27048            let r = get_global_json(&runtime, "execFileMaxBuffer").await;
27049            assert_eq!(r["done"], serde_json::json!(true));
27050            assert_eq!(r["threw"], serde_json::json!(true));
27051            assert!(
27052                r["msg"]
27053                    .as_str()
27054                    .unwrap_or("")
27055                    .contains("maxBuffer length exceeded"),
27056                "unexpected execFileSync maxBuffer message: {}",
27057                r["msg"]
27058            );
27059            assert_eq!(r["stdoutLen"].as_f64(), Some(1024.0));
27060        });
27061    }
27062
27063    #[test]
27064    fn pijs_exec_sync_captures_stderr() {
27065        futures::executor::block_on(async {
27066            let clock = Arc::new(DeterministicClock::new(0));
27067            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
27068
27069            runtime
27070                .eval(
27071                    r"
27072                    globalThis.stderrResult = {};
27073                    import('node:child_process').then(({ execSync }) => {
27074                        try {
27075                            execSync('echo err-msg >&2 && exit 1');
27076                            globalThis.stderrResult.threw = false;
27077                        } catch (e) {
27078                            globalThis.stderrResult.threw = true;
27079                            globalThis.stderrResult.stderr = e.stderr.trim();
27080                        }
27081                        globalThis.stderrResult.done = true;
27082                    });
27083                    ",
27084                )
27085                .await
27086                .expect("eval execSync stderr");
27087
27088            let r = get_global_json(&runtime, "stderrResult").await;
27089            assert_eq!(r["done"], serde_json::json!(true));
27090            assert_eq!(r["threw"], serde_json::json!(true));
27091            assert_eq!(r["stderr"], serde_json::json!("err-msg"));
27092        });
27093    }
27094
27095    #[test]
27096    #[cfg(unix)]
27097    fn pijs_exec_sync_with_cwd_option() {
27098        futures::executor::block_on(async {
27099            let clock = Arc::new(DeterministicClock::new(0));
27100            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
27101
27102            runtime
27103                .eval(
27104                    r"
27105                    globalThis.cwdResult = {};
27106                    import('node:child_process').then(({ execSync }) => {
27107                        const output = execSync('pwd', { cwd: '/tmp' });
27108                        globalThis.cwdResult.dir = output.trim();
27109                        globalThis.cwdResult.done = true;
27110                    });
27111                    ",
27112                )
27113                .await
27114                .expect("eval execSync cwd");
27115
27116            let r = get_global_json(&runtime, "cwdResult").await;
27117            assert_eq!(r["done"], serde_json::json!(true));
27118            // /tmp may resolve to /private/tmp on macOS
27119            let dir = r["dir"].as_str().unwrap_or("");
27120            assert!(
27121                dir == "/tmp" || dir.ends_with("/tmp"),
27122                "expected /tmp, got: {dir}"
27123            );
27124        });
27125    }
27126
27127    #[test]
27128    fn pijs_spawn_sync_empty_command_throws() {
27129        futures::executor::block_on(async {
27130            let clock = Arc::new(DeterministicClock::new(0));
27131            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
27132
27133            runtime
27134                .eval(
27135                    r"
27136                    globalThis.emptySpawn = {};
27137                    import('node:child_process').then(({ spawnSync }) => {
27138                        try {
27139                            spawnSync('');
27140                            globalThis.emptySpawn.threw = false;
27141                        } catch (e) {
27142                            globalThis.emptySpawn.threw = true;
27143                            globalThis.emptySpawn.msg = e.message;
27144                        }
27145                        globalThis.emptySpawn.done = true;
27146                    });
27147                    ",
27148                )
27149                .await
27150                .expect("eval spawnSync empty");
27151
27152            let r = get_global_json(&runtime, "emptySpawn").await;
27153            assert_eq!(r["done"], serde_json::json!(true));
27154            assert_eq!(r["threw"], serde_json::json!(true));
27155            assert!(
27156                r["msg"]
27157                    .as_str()
27158                    .unwrap_or("")
27159                    .contains("command is required")
27160            );
27161        });
27162    }
27163
27164    #[test]
27165    #[cfg(unix)]
27166    fn pijs_spawn_sync_options_as_second_arg() {
27167        futures::executor::block_on(async {
27168            let clock = Arc::new(DeterministicClock::new(0));
27169            let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
27170
27171            // spawnSync(cmd, options) with no args array — options is 2nd param
27172            runtime
27173                .eval(
27174                    r"
27175                    globalThis.optsResult = {};
27176                    import('node:child_process').then(({ spawnSync }) => {
27177                        const r = spawnSync('pwd', { cwd: '/tmp' });
27178                        globalThis.optsResult.stdout = r.stdout.trim();
27179                        globalThis.optsResult.done = true;
27180                    });
27181                    ",
27182                )
27183                .await
27184                .expect("eval spawnSync opts as 2nd arg");
27185
27186            let r = get_global_json(&runtime, "optsResult").await;
27187            assert_eq!(r["done"], serde_json::json!(true));
27188            let stdout = r["stdout"].as_str().unwrap_or("");
27189            assert!(
27190                stdout == "/tmp" || stdout.ends_with("/tmp"),
27191                "expected /tmp, got: {stdout}"
27192            );
27193        });
27194    }
27195
27196    // ── node:os expanded API tests ─────────────────────────────────────
27197
27198    #[test]
27199    fn pijs_os_expanded_apis() {
27200        futures::executor::block_on(async {
27201            let clock = Arc::new(DeterministicClock::new(0));
27202            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
27203                .await
27204                .expect("create runtime");
27205
27206            runtime
27207                .eval(
27208                    r"
27209                    globalThis.osEx = {};
27210                    import('node:os').then((os) => {
27211                        const cpuArr = os.cpus();
27212                        globalThis.osEx.cpusIsArray = Array.isArray(cpuArr);
27213                        globalThis.osEx.cpusLen = cpuArr.length;
27214                        globalThis.osEx.cpuHasModel = typeof cpuArr[0].model === 'string';
27215                        globalThis.osEx.cpuHasSpeed = typeof cpuArr[0].speed === 'number';
27216                        globalThis.osEx.cpuHasTimes = typeof cpuArr[0].times === 'object';
27217
27218                        globalThis.osEx.totalmem = os.totalmem();
27219                        globalThis.osEx.totalMemPositive = os.totalmem() > 0;
27220                        globalThis.osEx.freeMemPositive = os.freemem() > 0;
27221                        globalThis.osEx.freeMemLessTotal = os.freemem() <= os.totalmem();
27222
27223                        globalThis.osEx.uptimePositive = os.uptime() > 0;
27224
27225                        const la = os.loadavg();
27226                        globalThis.osEx.loadavgIsArray = Array.isArray(la);
27227                        globalThis.osEx.loadavgLen = la.length;
27228
27229                        globalThis.osEx.networkInterfacesIsObj = typeof os.networkInterfaces() === 'object';
27230
27231                        const ui = os.userInfo();
27232                        globalThis.osEx.userInfoHasUid = typeof ui.uid === 'number';
27233                        globalThis.osEx.userInfoHasUsername = typeof ui.username === 'string';
27234                        globalThis.osEx.userInfoHasHomedir = typeof ui.homedir === 'string';
27235                        globalThis.osEx.userInfoHasShell = typeof ui.shell === 'string';
27236
27237                        globalThis.osEx.endianness = os.endianness();
27238                        globalThis.osEx.eol = os.EOL;
27239                        globalThis.osEx.devNull = os.devNull;
27240                        globalThis.osEx.hasConstants = typeof os.constants === 'object';
27241
27242                        globalThis.osEx.done = true;
27243                    });
27244                    ",
27245                )
27246                .await
27247                .expect("eval node:os expanded");
27248
27249            let r = get_global_json(&runtime, "osEx").await;
27250            assert_eq!(r["done"], serde_json::json!(true));
27251            // cpus()
27252            assert_eq!(r["cpusIsArray"], serde_json::json!(true));
27253            assert!(r["cpusLen"].as_f64().unwrap_or(0.0) >= 1.0);
27254            assert_eq!(r["cpuHasModel"], serde_json::json!(true));
27255            assert_eq!(r["cpuHasSpeed"], serde_json::json!(true));
27256            assert_eq!(r["cpuHasTimes"], serde_json::json!(true));
27257            // totalmem/freemem
27258            assert_eq!(r["totalMemPositive"], serde_json::json!(true));
27259            assert_eq!(r["freeMemPositive"], serde_json::json!(true));
27260            assert_eq!(r["freeMemLessTotal"], serde_json::json!(true));
27261            // uptime
27262            assert_eq!(r["uptimePositive"], serde_json::json!(true));
27263            // loadavg
27264            assert_eq!(r["loadavgIsArray"], serde_json::json!(true));
27265            assert_eq!(r["loadavgLen"].as_f64(), Some(3.0));
27266            // networkInterfaces
27267            assert_eq!(r["networkInterfacesIsObj"], serde_json::json!(true));
27268            // userInfo
27269            assert_eq!(r["userInfoHasUid"], serde_json::json!(true));
27270            assert_eq!(r["userInfoHasUsername"], serde_json::json!(true));
27271            assert_eq!(r["userInfoHasHomedir"], serde_json::json!(true));
27272            assert_eq!(r["userInfoHasShell"], serde_json::json!(true));
27273            // endianness / EOL / devNull / constants
27274            assert_eq!(r["endianness"], serde_json::json!("LE"));
27275            let expected_eol = if cfg!(windows) { "\r\n" } else { "\n" };
27276            assert_eq!(r["eol"], serde_json::json!(expected_eol));
27277            let expected_dev_null = if cfg!(windows) {
27278                "\\\\.\\NUL"
27279            } else {
27280                "/dev/null"
27281            };
27282            assert_eq!(r["devNull"], serde_json::json!(expected_dev_null));
27283            assert_eq!(r["hasConstants"], serde_json::json!(true));
27284        });
27285    }
27286
27287    // ── Buffer expanded API tests ──────────────────────────────────────
27288
27289    #[test]
27290    fn pijs_buffer_expanded_apis() {
27291        futures::executor::block_on(async {
27292            let clock = Arc::new(DeterministicClock::new(0));
27293            let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
27294                .await
27295                .expect("create runtime");
27296
27297            runtime
27298                .eval(
27299                    r"
27300                    globalThis.bufResult = {};
27301                    (() => {
27302                        const B = globalThis.Buffer;
27303
27304                        // alloc
27305                        const a = B.alloc(4, 0xAB);
27306                        globalThis.bufResult.allocFill = Array.from(a);
27307
27308                        // from string + hex encoding
27309                        const hex = B.from('48656c6c6f', 'hex');
27310                        globalThis.bufResult.hexDecode = hex.toString('utf8');
27311
27312                        // concat
27313                        const c = B.concat([B.from('Hello'), B.from(' World')]);
27314                        globalThis.bufResult.concat = c.toString();
27315
27316                        // byteLength
27317                        globalThis.bufResult.byteLength = B.byteLength('Hello');
27318
27319                        // compare
27320                        globalThis.bufResult.compareEqual = B.compare(B.from('abc'), B.from('abc'));
27321                        globalThis.bufResult.compareLess = B.compare(B.from('abc'), B.from('abd'));
27322                        globalThis.bufResult.compareGreater = B.compare(B.from('abd'), B.from('abc'));
27323
27324                        // isEncoding
27325                        globalThis.bufResult.isEncodingUtf8 = B.isEncoding('utf8');
27326                        globalThis.bufResult.isEncodingFake = B.isEncoding('fake');
27327
27328                        // isBuffer
27329                        globalThis.bufResult.isBufferTrue = B.isBuffer(B.from('x'));
27330                        globalThis.bufResult.isBufferFalse = B.isBuffer('x');
27331
27332                        // instance methods
27333                        const b = B.from('Hello World');
27334                        globalThis.bufResult.indexOf = b.indexOf('World');
27335                        globalThis.bufResult.includes = b.includes('World');
27336                        globalThis.bufResult.notIncludes = b.includes('xyz');
27337                        const neg = B.from('abc');
27338                        globalThis.bufResult.negativeMiss = neg.indexOf('a', -1);
27339                        globalThis.bufResult.negativeHit = neg.indexOf('c', -1);
27340                        globalThis.bufResult.negativeIncludes = neg.includes('a', -1);
27341                        globalThis.bufResult.indexOfHexNeedle = B.from('hello').indexOf('6c6c', 'hex');
27342
27343                        const sliced = b.slice(0, 5);
27344                        globalThis.bufResult.slice = sliced.toString();
27345
27346                        globalThis.bufResult.toJSON = b.toJSON().type;
27347
27348                        const eq1 = B.from('abc');
27349                        const eq2 = B.from('abc');
27350                        const eq3 = B.from('xyz');
27351                        globalThis.bufResult.equalsTrue = eq1.equals(eq2);
27352                        globalThis.bufResult.equalsFalse = eq1.equals(eq3);
27353
27354                        // copy
27355                        const src = B.from('Hello');
27356                        const dst = B.alloc(5);
27357                        src.copy(dst);
27358                        globalThis.bufResult.copy = dst.toString();
27359
27360                        // write
27361                        const wb = B.alloc(10);
27362                        wb.write('Hi');
27363                        globalThis.bufResult.write = wb.toString('utf8', 0, 2);
27364
27365                        // readUInt / writeUInt
27366                        const nb = B.alloc(4);
27367                        nb.writeUInt16BE(0x1234, 0);
27368                        globalThis.bufResult.readUInt16BE = nb.readUInt16BE(0);
27369                        nb.writeUInt32LE(0xDEADBEEF, 0);
27370                        globalThis.bufResult.readUInt32LE = nb.readUInt32LE(0);
27371
27372                        // hex encoding
27373                        const hb = B.from([0xDE, 0xAD]);
27374                        globalThis.bufResult.toHex = hb.toString('hex');
27375
27376                        // base64 round-trip
27377                        const b64 = B.from('Hello').toString('base64');
27378                        const roundTrip = B.from(b64, 'base64').toString();
27379                        globalThis.bufResult.base64Round = roundTrip;
27380
27381                        globalThis.bufResult.done = true;
27382                    })();
27383                    ",
27384                )
27385                .await
27386                .expect("eval Buffer expanded");
27387
27388            let r = get_global_json(&runtime, "bufResult").await;
27389            assert_eq!(r["done"], serde_json::json!(true));
27390            // alloc with fill
27391            assert_eq!(r["allocFill"], serde_json::json!([0xAB, 0xAB, 0xAB, 0xAB]));
27392            // hex decode
27393            assert_eq!(r["hexDecode"], serde_json::json!("Hello"));
27394            // concat
27395            assert_eq!(r["concat"], serde_json::json!("Hello World"));
27396            // byteLength
27397            assert_eq!(r["byteLength"].as_f64(), Some(5.0));
27398            // compare
27399            assert_eq!(r["compareEqual"].as_f64(), Some(0.0));
27400            assert!(r["compareLess"].as_f64().unwrap_or(0.0) < 0.0);
27401            assert!(r["compareGreater"].as_f64().unwrap_or(0.0) > 0.0);
27402            // isEncoding
27403            assert_eq!(r["isEncodingUtf8"], serde_json::json!(true));
27404            assert_eq!(r["isEncodingFake"], serde_json::json!(false));
27405            // isBuffer
27406            assert_eq!(r["isBufferTrue"], serde_json::json!(true));
27407            assert_eq!(r["isBufferFalse"], serde_json::json!(false));
27408            // indexOf / includes
27409            assert_eq!(r["indexOf"].as_f64(), Some(6.0));
27410            assert_eq!(r["includes"], serde_json::json!(true));
27411            assert_eq!(r["notIncludes"], serde_json::json!(false));
27412            assert_eq!(r["negativeMiss"].as_f64(), Some(-1.0));
27413            assert_eq!(r["negativeHit"].as_f64(), Some(2.0));
27414            assert_eq!(r["negativeIncludes"], serde_json::json!(false));
27415            assert_eq!(r["indexOfHexNeedle"].as_f64(), Some(2.0));
27416            // slice
27417            assert_eq!(r["slice"], serde_json::json!("Hello"));
27418            // toJSON
27419            assert_eq!(r["toJSON"], serde_json::json!("Buffer"));
27420            // equals
27421            assert_eq!(r["equalsTrue"], serde_json::json!(true));
27422            assert_eq!(r["equalsFalse"], serde_json::json!(false));
27423            // copy
27424            assert_eq!(r["copy"], serde_json::json!("Hello"));
27425            // write
27426            assert_eq!(r["write"], serde_json::json!("Hi"));
27427            // readUInt16BE
27428            assert_eq!(r["readUInt16BE"].as_f64(), Some(f64::from(0x1234)));
27429            // readUInt32LE
27430            assert_eq!(r["readUInt32LE"].as_f64(), Some(f64::from(0xDEAD_BEEF_u32)));
27431            // hex
27432            assert_eq!(r["toHex"], serde_json::json!("dead"));
27433            // base64 round-trip
27434            assert_eq!(r["base64Round"], serde_json::json!("Hello"));
27435        });
27436    }
27437}