Skip to main content

kaish_kernel/
kernel.rs

1//! The Kernel (核) — the heart of kaish.
2//!
3//! The Kernel owns and coordinates all core components:
4//! - Interpreter state (scope, $?)
5//! - Tool registry (builtins, user tools)
6//! - VFS router (mount points)
7//! - Job manager (background jobs)
8//!
9//! # Architecture
10//!
11//! ```text
12//! ┌────────────────────────────────────────────────────────────┐
13//! │                         Kernel (核)                         │
14//! │  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐  │
15//! │  │   Scope      │  │ ToolRegistry │  │  VfsRouter       │  │
16//! │  │  (variables) │  │  (builtins,  │  │  (mount points)  │  │
17//! │  │              │  │   user tools)│  │                  │  │
18//! │  └──────────────┘  └──────────────┘  └──────────────────┘  │
19//! │  ┌──────────────────────────────┐  ┌──────────────────┐    │
20//! │  │  JobManager (background)     │  │  ExecResult ($?) │    │
21//! │  └──────────────────────────────┘  └──────────────────┘    │
22//! └────────────────────────────────────────────────────────────┘
23//! ```
24
25use std::collections::HashMap;
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
29use std::time::Duration;
30
31use anyhow::{Context, Result};
32use tokio::sync::RwLock;
33
34/// Monotonic counter assigned to each Kernel at construction time, exposed
35/// via `$$` / `${$}`. Starts at 1; each new Kernel gets the next value.
36/// `Kernel::fork()` inherits the parent's value (matching bash's "subshell
37/// keeps parent's $$" semantics) because forks clone the parent's Scope
38/// rather than calling `set_pid` again.
39///
40/// Deliberately *not* the OS PID — kaish runs as a long-lived MCP server
41/// or embedded inside other binaries (kaijutsu), where the host PID is
42/// meaningless to the script. See
43/// `~/.claude/projects/-home-atobey-src-kaish/memory/lang_dollar_dollar_identifier.md`
44/// for the design rationale.
45static KERNEL_COUNTER: AtomicU64 = AtomicU64::new(1);
46
47/// Maximum depth of dynamic statement-engine re-entry — command substitution
48/// (`$(…)`), shell-function calls, and `.kai` script sourcing — before a
49/// **loud** error is returned instead of letting the native call stack
50/// overflow (a `SIGSEGV`/abort with no diagnostic). Mirrors the intent of the
51/// alias re-entry cap (10) and the lexer's `MAX_PAREN_DEPTH` (256): a runaway
52/// or mutually recursive script hits a catchable ceiling, not a signal.
53///
54/// Each level stacks the dispatch chain between re-entries. After the GH #48
55/// allocation pass this measures ~50 KB (release) / ~57 KB (debug at the default
56/// `opt-level = 1` dev profile — see the root `Cargo.toml`) / ~193 KB (a fully
57/// unoptimized debug build) of native stack per level, down from ~80 / ~380 KB
58/// before; the `recursion_stack_cost_tests` probe reports the live figure.
59///
60/// This cap and [`RECOMMENDED_STACK_SIZE`] are a **matched pair**: the cap must
61/// trip *before* `cap × (worst-case per-level stack)` can exceed the floor, so a
62/// runaway is caught, not a `SIGSEGV`. The worst case is the ~193 KB unoptimized
63/// figure — the `opt-level = 1` dev profile above is local to this workspace and
64/// does **not** propagate to embedders, whose own debug builds of the kernel pay
65/// the full unoptimized cost. `48 × 193 KB ≈ 9.3 MB` under the 12 MiB floor
66/// keeps the same ~1.3× margin the pre-#48 pair had (`32 × 380 KB ≈ 12 MB` under
67/// 16 MiB); #48's smaller frames are what let the cap rise 32→48 and the floor
68/// drop 16→12 MiB together. **The guard only fires *before* the stack overflows
69/// on a thread that meets that floor** — this is why the REPL sizes its threads
70/// to it and embedders must too (see `docs/EMBEDDING.md`). Forks (background
71/// jobs, scatter workers, pipeline stages) run on fresh stacks and get a fresh
72/// counter, bounding each chain independently. GH #46 / #47 / #48.
73pub const MAX_RECURSION_DEPTH: usize = 48;
74
75/// Recommended native stack size (12 MiB) for any thread that drives kaish
76/// execution — the REPL sizes its `block_on` thread and tokio worker threads
77/// to this, and embedders that call `Kernel::execute` (directly or via a tokio
78/// runtime) should do the same (`runtime::Builder::thread_stack_size`, and a
79/// `std::thread` stack for a non-worker driver).
80///
81/// The kernel recurses on the native stack (command substitution, shell
82/// functions, `.kai` scripts). [`MAX_RECURSION_DEPTH`] converts a runaway into
83/// a loud error, but only *if the stack is at least this large* — on the
84/// default ~2 MB tokio worker stack the recursion overflows (SIGSEGV) before
85/// reaching the cap. This floor is the companion to that cap (see its docs for
86/// the `cap × per-level < floor` relationship): 12 MiB holds the depth-48 cap
87/// with margin even for an unoptimized embedder build (~193 KB/level), and #48
88/// shrank the per-level cost enough to drop it from 16 MiB. kaish can't set this
89/// itself (it doesn't own the runtime), so it exposes the floor for owners to
90/// apply. See GH #47 / #48.
91pub const RECOMMENDED_STACK_SIZE: usize = 12 * 1024 * 1024;
92
93use async_trait::async_trait;
94
95use crate::ast::{
96    spread_non_list_message, Arg, BinaryOp, Command, Expr, FileTestOp, ListElem, RecordKey, Stmt,
97    StringPart, TestExpr, ToolDef, Value,
98};
99pub use kaish_types::{CommandKind, ExecuteOptions};
100use crate::backend::{BackendError, KernelBackend};
101use kaish_glob::glob_match;
102use crate::dispatch::{CommandDispatcher, PipelinePosition};
103use crate::interpreter::{apply_output_format, eval_expr, expand_tilde, json_to_value_no_envelope, value_to_bool, value_to_string, value_to_text_sink, ControlFlow, ExecResult, PathError, Scope};
104use crate::parser::parse;
105use crate::scheduler::{is_bool_type, schema_param_lookup, select_leaf, stderr_stream, JobManager, PipelineRunner, StderrReceiver};
106#[cfg(feature = "subprocess")]
107use crate::scheduler::{drain_to_stream_teed, BoundedStream, DEFAULT_STREAM_MAX_SIZE};
108use crate::tools::{register_builtins, ExecContext, GlobalFlags, ToolArgs, ToolRegistry};
109#[cfg(feature = "subprocess")]
110use crate::tools::{resolve_in_path, virtual_cwd_error};
111use crate::validator::{Severity, Validator};
112#[cfg(feature = "localfs")]
113use crate::vfs::LocalFs;
114use crate::vfs::{BuiltinFs, DevFs, JobFs, MemoryFs, VfsRouter};
115use kaish_vfs::ByteBudget;
116#[cfg(all(feature = "localfs", feature = "overlay"))]
117use kaish_vfs::OverlayFs;
118
119/// VFS mount mode determines how the local filesystem is exposed.
120///
121/// Different modes trade off convenience vs. security:
122/// - `Passthrough` gives native path access (best for human REPL use)
123/// - `Sandboxed` restricts access to a subtree (safer for agents)
124/// - `NoLocal` provides complete isolation (tests, pure memory mode)
125#[derive(Debug, Clone)]
126pub enum VfsMountMode {
127    /// LocalFs at "/" — native paths work directly.
128    ///
129    /// Full filesystem access. Use for human-operated REPL sessions where
130    /// native paths like `/home/user/project` should just work.
131    ///
132    /// Mounts:
133    /// - `/` → LocalFs("/")
134    /// - `/v` → MemoryFs (blob storage)
135    #[cfg(feature = "localfs")]
136    Passthrough,
137
138    /// Transparent sandbox — paths look native but access is restricted.
139    ///
140    /// The local filesystem is mounted at its real path (e.g., `/home/user`),
141    /// so `/home/user/src/project` just works. But paths outside the sandbox
142    /// root are not accessible.
143    ///
144    /// **Note:** This only restricts VFS (builtin) operations. External commands
145    /// bypass the sandbox entirely — see [`KernelConfig::allow_external_commands`].
146    ///
147    /// Mounts:
148    /// - `/` → MemoryFs (catches paths outside sandbox)
149    /// - `{root}` → LocalFs(root)  (e.g., `/home/user` → LocalFs)
150    /// - `/tmp` → LocalFs("/tmp")
151    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
152    /// - `/v` → MemoryFs (blob storage)
153    #[cfg(feature = "localfs")]
154    Sandboxed {
155        /// Root path for local filesystem. Defaults to `$HOME`.
156        /// Can be restricted further, e.g., `~/src`.
157        root: Option<PathBuf>,
158    },
159
160    /// No local filesystem. Memory only.
161    ///
162    /// Complete isolation — no access to the host filesystem.
163    /// Useful for tests or pure sandboxed execution.
164    ///
165    /// Output spill is forced to [`SpillMode::Memory`](crate::output_limit::SpillMode::Memory)
166    /// for this mode at kernel construction: with no host filesystem mounted,
167    /// large output must not write a host spill file (`paths::spill_dir()`
168    /// bypasses the VFS). This overrides any explicit `SpillMode::Disk`.
169    ///
170    /// Mounts:
171    /// - `/` → MemoryFs
172    /// - `/tmp` → MemoryFs
173    /// - `/v` → MemoryFs
174    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
175    NoLocal,
176}
177
178#[allow(clippy::derivable_impls)] // native has multiple variants; not derivable cross-feature
179impl Default for VfsMountMode {
180    fn default() -> Self {
181        #[cfg(feature = "localfs")]
182        { VfsMountMode::Sandboxed { root: None } }
183        #[cfg(not(feature = "localfs"))]
184        { VfsMountMode::NoLocal }
185    }
186}
187
188/// Configuration for kernel initialization.
189#[derive(Clone)]
190pub struct KernelConfig {
191    /// Name of this kernel (for identification).
192    pub name: String,
193
194    /// VFS mount mode — controls how local filesystem is exposed.
195    pub vfs_mode: VfsMountMode,
196
197    /// Initial working directory (VFS path).
198    pub cwd: PathBuf,
199
200    /// Whether to skip pre-execution validation.
201    ///
202    /// When false (default), scripts are validated before execution to catch
203    /// errors early. Set to true to skip validation for performance or to
204    /// allow dynamic/external commands.
205    pub skip_validation: bool,
206
207    /// When true, standalone external commands inherit stdio for real-time output.
208    ///
209    /// Set by script runner and REPL for human-visible output.
210    /// Not set by MCP server (output must be captured for structured responses).
211    pub interactive: bool,
212
213    /// Ignore file configuration for file-walking tools.
214    pub ignore_config: crate::ignore_config::IgnoreConfig,
215
216    /// Output size limit configuration for agent safety.
217    pub output_limit: crate::output_limit::OutputLimitConfig,
218
219    /// Whether external command execution (PATH lookup, `exec`, `spawn`) is allowed.
220    ///
221    /// When `true` (default), commands not found as builtins are resolved via PATH
222    /// and executed as child processes. When `false`, only kaish builtins and
223    /// backend-registered tools are available.
224    ///
225    /// **Security:** External commands bypass the VFS sandbox entirely — they see
226    /// the real filesystem, network, and environment. Set to `false` when running
227    /// untrusted input.
228    pub allow_external_commands: bool,
229
230
231    /// Enable trash-on-delete for rm (set -o trash).
232    ///
233    /// When enabled, small files are moved to freedesktop.org Trash instead of
234    /// being permanently deleted. Can also be enabled at runtime with `set -o trash`
235    /// or via `KAISH_TRASH=1`.
236    pub trash_enabled: bool,
237
238    /// Variables to populate the root scope with at construction, all marked
239    /// for export to child processes.
240    ///
241    /// The kernel itself is hermetic — it never reads `std::env::vars()` —
242    /// so frontends that want OS-env passthrough (REPL, MCP) populate this
243    /// from `std::env::vars()`. Embedders that want isolation pass nothing
244    /// (or only the keys they curate).
245    pub initial_vars: HashMap<String, Value>,
246
247    /// Default per-request timeout. When `Some`, every `execute_with_options`
248    /// call without an explicit `ExecuteOptions::timeout` uses this duration.
249    /// When elapsed, the kernel cancels the request, kills any external
250    /// children with the configured grace, and returns exit code 124.
251    ///
252    /// `None` means no default timeout — only explicit per-call timeouts apply.
253    pub request_timeout: Option<Duration>,
254
255    /// Grace period between SIGTERM and SIGKILL when killing an external
256    /// child on cancellation or timeout.
257    ///
258    /// Defaults to 2 seconds. Set to `Duration::ZERO` to escalate immediately
259    /// to SIGKILL. Long-shutdown processes (databases, etc.) may need more.
260    pub kill_grace: Duration,
261
262    /// Cap on memory-resident bytes across all kernel-owned `MemoryFs` mounts.
263    ///
264    /// One shared `ByteBudget` (labeled `"vfs-memory"`) is created at kernel
265    /// construction and handed to every `MemoryFs` the kernel builds in
266    /// `setup_vfs` (Passthrough `/v`; Sandboxed `/` and `/v`; NoLocal `/`,
267    /// `/tmp`, `/v`). Writes that would exceed the cap fail loudly with
268    /// `StorageFull` — an in-band error a model reads and adapts to; fail
269    /// loud over quietly eating RAM.
270    ///
271    /// **Why the agent preset is bounded by default:** an agent embedder
272    /// typically creates a fresh kernel per `execute()` call, so the 64 MiB cap
273    /// is per-call, not per-session. Embedders that know their workload needs
274    /// more opt out with `without_vfs_budget()` or raise the cap with
275    /// `with_vfs_budget(bytes)` — protection on by default, opt out knowingly.
276    /// All other profiles default to `None` (unbounded).
277    ///
278    /// Follows the same pattern as `OutputLimitConfig`: agent preset bounded, rest unbounded.
279    pub vfs_budget_bytes: Option<u64>,
280
281    /// Enable copy-on-write overlay mode (opt-in).
282    ///
283    /// When `true`, the primary local filesystem mount is wrapped in an
284    /// `OverlayFs` so writes are virtual — the lower layer is never touched.
285    /// Use `kaish-vfs status/diff/commit/reset` to inspect and manage the
286    /// overlay transaction.
287    ///
288    /// **Passthrough:** `/` becomes `OverlayFs over LocalFs::read_only("/")`.
289    /// **Sandboxed{root}:** the `{root}` mount becomes
290    /// `OverlayFs over LocalFs::read_only(root)`; the `/tmp` and XDG runtime
291    /// mounts stay as real `LocalFs` (real writes escape the transaction —
292    /// see `docs/kaish-overlayfs.md` for the escape-hatch inventory).
293    /// **NoLocal:** incompatible — construction fails loudly (everything is
294    /// already virtual; an overlay adds no value and no lower layer to wrap).
295    /// **with_backend:** incompatible — the embedder controls the VFS; the
296    /// kernel cannot wrap it without bypassing the embedder's semantics.
297    ///
298    /// **Not default-on for the agent preset:** each `execute()` call gets a fresh kernel,
299    /// making the overlay a per-call transaction — `kaish-vfs commit` must run
300    /// in the same call as the writes, or the transaction is discarded on drop.
301    /// Frontends (REPL, MCP) expose `--overlay` as an explicit opt-in flag.
302    pub overlay: bool,
303
304    /// The [`JobManager`] this kernel adopts. `None` — the default — builds a
305    /// fresh one, so every kernel owns its own job table.
306    ///
307    /// Supply one to share a single job table across kernels. An embedder that
308    /// builds a kernel per request (kaijutsu builds one per tool call) has no
309    /// other way to keep a `cmd &` job reachable: ids, status, and output
310    /// streams all live on the manager, so a per-kernel manager takes them
311    /// down with the kernel that made it. One manager held by the embedder and
312    /// handed to every kernel keeps `&` usable across calls, and keeps job ids
313    /// unique because they are minted from the manager's own counter.
314    ///
315    /// **A shared manager carries shared settings.** `kill_grace` and
316    /// `persist_output_files` are stamped onto the manager at kernel
317    /// construction, so the last kernel built wins for both: a hermetic kernel
318    /// (`NoLocal`, or any `with_backend` kernel) turns `persist_output_files`
319    /// off for every kernel on that manager, and each kernel's
320    /// [`Self::kill_grace`] overwrites the previous one's. Share a manager
321    /// between kernels configured alike, or accept the last writer.
322    ///
323    /// Set through [`Self::with_job_manager`].
324    pub job_manager: Option<Arc<JobManager>>,
325
326    /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on every external command this kernel
327    /// spawns, so the OS kills the child the instant this process dies —
328    /// **for any reason, including `kill -9`, a segfault, or an OOM kill.**
329    ///
330    /// Off by default; on for [`Self::agent`] and [`Self::agent_with_root`],
331    /// the same "protection on by default for the agent preset, opt in
332    /// elsewhere" split [`Self::vfs_budget_bytes`] uses.
333    ///
334    /// **Why not unconditional.** kaish already puts every child in its own
335    /// process group and kills through a pidfd on cancel, and drops it with
336    /// `kill_on_drop`. All three need this process to still be running code,
337    /// so none of them survive a hard kill — that is the gap this closes. But
338    /// closing it costs something a human at a REPL may not want: an armed
339    /// child cannot outlive its shell, at all, and the child has no way to
340    /// opt out from inside (unlike SIGHUP, which `nohup`/`disown` exist to
341    /// escape). A REPL user who backgrounds a long download and exits expects
342    /// it to keep going. An agent embedder expects the opposite — an
343    /// invisible orphaned `cargo build` is the failure — so the presets
344    /// differ rather than one behavior being forced on both.
345    ///
346    /// **Linux only.** macOS has no `PR_SET_PDEATHSIG` and no equivalent that
347    /// works without a live parent (`kqueue`'s `NOTE_EXIT` needs a watcher
348    /// process). This flag is accepted and has no effect there, rather than
349    /// being faked with something weaker.
350    ///
351    /// Set through [`Self::with_kill_children_on_parent_death`].
352    pub kill_children_on_parent_death: bool,
353}
354
355/// Get the default sandbox root ($HOME).
356#[cfg(feature = "localfs")]
357fn default_sandbox_root() -> PathBuf {
358    std::env::var("HOME")
359        .map(PathBuf::from)
360        .unwrap_or_else(|_| PathBuf::from("/"))
361}
362
363impl Default for KernelConfig {
364    fn default() -> Self {
365        #[cfg(feature = "localfs")]
366        {
367            let home = default_sandbox_root();
368            Self {
369                name: "default".to_string(),
370                vfs_mode: VfsMountMode::Sandboxed { root: None },
371                cwd: home,
372                skip_validation: false,
373                interactive: false,
374                ignore_config: crate::ignore_config::IgnoreConfig::none(),
375                output_limit: crate::output_limit::OutputLimitConfig::none(),
376                allow_external_commands: cfg!(feature = "subprocess"),
377                trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
378                initial_vars: HashMap::new(),
379                request_timeout: None,
380                kill_grace: Duration::from_secs(2),
381                vfs_budget_bytes: None,
382                overlay: false,
383                job_manager: None,
384                kill_children_on_parent_death: false,
385            }
386        }
387        #[cfg(not(feature = "localfs"))]
388        {
389            Self {
390                name: "default".to_string(),
391                vfs_mode: VfsMountMode::NoLocal,
392                cwd: PathBuf::from("/"),
393                skip_validation: false,
394                interactive: false,
395                ignore_config: crate::ignore_config::IgnoreConfig::none(),
396                output_limit: crate::output_limit::OutputLimitConfig::none(),
397                allow_external_commands: false,
398                trash_enabled: false,
399                initial_vars: HashMap::new(),
400                request_timeout: None,
401                kill_grace: Duration::from_secs(2),
402                vfs_budget_bytes: None,
403                overlay: false,
404                job_manager: None,
405                kill_children_on_parent_death: false,
406            }
407        }
408    }
409}
410
411impl KernelConfig {
412    /// Create a transient kernel config (sandboxed, for temporary use).
413    #[cfg(feature = "localfs")]
414    pub fn transient() -> Self {
415        let home = default_sandbox_root();
416        Self {
417            name: "transient".to_string(),
418            vfs_mode: VfsMountMode::Sandboxed { root: None },
419            cwd: home,
420            skip_validation: false,
421            interactive: false,
422            ignore_config: crate::ignore_config::IgnoreConfig::none(),
423            output_limit: crate::output_limit::OutputLimitConfig::none(),
424            allow_external_commands: cfg!(feature = "subprocess"),
425            trash_enabled: false,
426            initial_vars: HashMap::new(),
427            request_timeout: None,
428            kill_grace: Duration::from_secs(2),
429            vfs_budget_bytes: None,
430            overlay: false,
431            job_manager: None,
432            kill_children_on_parent_death: false,
433        }
434    }
435
436    /// Create a transient kernel config (isolated, no-default-features).
437    #[cfg(not(feature = "localfs"))]
438    pub fn transient() -> Self {
439        Self::isolated()
440    }
441
442    /// Create a kernel config with the given name (sandboxed by default).
443    #[cfg(feature = "localfs")]
444    pub fn named(name: &str) -> Self {
445        let home = default_sandbox_root();
446        Self {
447            name: name.to_string(),
448            vfs_mode: VfsMountMode::Sandboxed { root: None },
449            cwd: home,
450            skip_validation: false,
451            interactive: false,
452            ignore_config: crate::ignore_config::IgnoreConfig::none(),
453            output_limit: crate::output_limit::OutputLimitConfig::none(),
454            allow_external_commands: cfg!(feature = "subprocess"),
455            trash_enabled: false,
456            initial_vars: HashMap::new(),
457            request_timeout: None,
458            kill_grace: Duration::from_secs(2),
459            vfs_budget_bytes: None,
460            overlay: false,
461            job_manager: None,
462            kill_children_on_parent_death: false,
463        }
464    }
465
466    /// Create a kernel config with the given name (isolated, no-default-features).
467    #[cfg(not(feature = "localfs"))]
468    pub fn named(name: &str) -> Self {
469        Self {
470            name: name.to_string(),
471            ..Self::isolated()
472        }
473    }
474
475    /// Create a REPL config with passthrough filesystem access.
476    ///
477    /// Native paths like `/home/user/project` work directly.
478    /// The cwd is set to the actual current working directory.
479    #[cfg(feature = "localfs")]
480    pub fn repl() -> Self {
481        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
482        Self {
483            name: "repl".to_string(),
484            vfs_mode: VfsMountMode::Passthrough,
485            cwd,
486            skip_validation: false,
487            interactive: false,
488            // Ignore-aware by default (GH #134): .gitignore + default ignores
489            // at Advisory scope — `--no-ignore` / `kaish-ignore clear` recover.
490            ignore_config: crate::ignore_config::IgnoreConfig::interactive(),
491            output_limit: crate::output_limit::OutputLimitConfig::none(),
492            allow_external_commands: cfg!(feature = "subprocess"),
493            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
494            initial_vars: HashMap::new(),
495            request_timeout: None,
496            kill_grace: Duration::from_secs(2),
497            vfs_budget_bytes: None,
498            overlay: false,
499            job_manager: None,
500            kill_children_on_parent_death: false,
501        }
502    }
503
504    /// Create a sandboxed-agent config with sandboxed filesystem access.
505    ///
506    /// The preset for embedding kaish as an untrusted agent's shell (e.g. an MCP
507    /// server like kaibo/kaijutsu): sandboxed VFS, non-interactive, bounded
508    /// memory and output. Local filesystem is accessible at its real path (e.g.,
509    /// `/home/user`), but sandboxed to `$HOME`. Paths outside the sandbox are not
510    /// accessible through builtins. External commands still access the real
511    /// filesystem — use `.with_allow_external_commands(false)` to block them.
512    ///
513    /// VFS memory is bounded at 64 MiB per `execute()` call by default (an agent
514    /// embedder typically creates a fresh kernel per call). Raise or remove with
515    /// `with_vfs_budget` / `without_vfs_budget`.
516    #[cfg(feature = "localfs")]
517    pub fn agent() -> Self {
518        let home = default_sandbox_root();
519        Self {
520            name: "agent".to_string(),
521            vfs_mode: VfsMountMode::Sandboxed { root: None },
522            cwd: home,
523            skip_validation: false,
524            interactive: false,
525            ignore_config: crate::ignore_config::IgnoreConfig::agent(),
526            output_limit: crate::output_limit::OutputLimitConfig::agent(),
527            allow_external_commands: cfg!(feature = "subprocess"),
528            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
529            initial_vars: HashMap::new(),
530            request_timeout: None,
531            kill_grace: Duration::from_secs(2),
532            vfs_budget_bytes: Some(64 * 1024 * 1024),
533            overlay: false,
534            job_manager: None,
535            // An agent embedder must never leave an invisible `cargo build` running
536            // after its process is hard-killed; see the field doc for why this is
537            // not the default everywhere.
538            kill_children_on_parent_death: true,
539        }
540    }
541
542    /// Create a sandboxed-agent config with a custom sandbox root.
543    ///
544    /// Use this to restrict access to a subdirectory like `~/src`.
545    ///
546    /// VFS memory is bounded at 64 MiB per `execute()` call by default.
547    /// Raise or remove with `with_vfs_budget` / `without_vfs_budget`.
548    #[cfg(feature = "localfs")]
549    pub fn agent_with_root(root: PathBuf) -> Self {
550        Self {
551            name: "agent".to_string(),
552            vfs_mode: VfsMountMode::Sandboxed { root: Some(root.clone()) },
553            cwd: root,
554            skip_validation: false,
555            interactive: false,
556            ignore_config: crate::ignore_config::IgnoreConfig::agent(),
557            output_limit: crate::output_limit::OutputLimitConfig::agent(),
558            allow_external_commands: cfg!(feature = "subprocess"),
559            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
560            initial_vars: HashMap::new(),
561            request_timeout: None,
562            kill_grace: Duration::from_secs(2),
563            vfs_budget_bytes: Some(64 * 1024 * 1024),
564            overlay: false,
565            job_manager: None,
566            // Same reasoning as `agent()`.
567            kill_children_on_parent_death: true,
568        }
569    }
570
571    /// Create a config with no local filesystem (memory only).
572    ///
573    /// Complete isolation: no local filesystem and external commands are disabled.
574    /// Useful for tests or pure sandboxed execution.
575    pub fn isolated() -> Self {
576        Self {
577            name: "isolated".to_string(),
578            vfs_mode: VfsMountMode::NoLocal,
579            cwd: PathBuf::from("/"),
580            skip_validation: false,
581            interactive: false,
582            ignore_config: crate::ignore_config::IgnoreConfig::none(),
583            output_limit: crate::output_limit::OutputLimitConfig::none(),
584            allow_external_commands: false,
585            trash_enabled: false,
586            initial_vars: HashMap::new(),
587            request_timeout: None,
588            kill_grace: Duration::from_secs(2),
589            vfs_budget_bytes: None,
590            overlay: false,
591            job_manager: None,
592            kill_children_on_parent_death: false,
593        }
594    }
595
596    /// Set the VFS mount mode.
597    pub fn with_vfs_mode(mut self, mode: VfsMountMode) -> Self {
598        self.vfs_mode = mode;
599        self
600    }
601
602    /// Set the initial working directory.
603    pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
604        self.cwd = cwd;
605        self
606    }
607
608    /// Skip pre-execution validation.
609    pub fn with_skip_validation(mut self, skip: bool) -> Self {
610        self.skip_validation = skip;
611        self
612    }
613
614    /// Enable interactive mode (external commands inherit stdio).
615    pub fn with_interactive(mut self, interactive: bool) -> Self {
616        self.interactive = interactive;
617        self
618    }
619
620    /// Set the ignore file configuration.
621    pub fn with_ignore_config(mut self, config: crate::ignore_config::IgnoreConfig) -> Self {
622        self.ignore_config = config;
623        self
624    }
625
626    /// Set the output limit configuration.
627    pub fn with_output_limit(mut self, config: crate::output_limit::OutputLimitConfig) -> Self {
628        self.output_limit = config;
629        self
630    }
631
632    /// Set whether external command execution is allowed.
633    ///
634    /// When `false`, commands not found as builtins produce "command not found"
635    /// instead of searching PATH. The `exec` and `spawn` builtins also return
636    /// errors. Use this to prevent VFS sandbox bypass via external binaries.
637    pub fn with_allow_external_commands(mut self, allow: bool) -> Self {
638        self.allow_external_commands = allow;
639        self
640    }
641
642    /// Enable or disable trash-on-delete at startup.
643    pub fn with_trash(mut self, enabled: bool) -> Self {
644        self.trash_enabled = enabled;
645        self
646    }
647
648    /// Add a single initial variable; marked exported when the kernel boots.
649    ///
650    /// Repeated calls add (last write wins on key collision).
651    pub fn with_var(mut self, name: impl Into<String>, value: Value) -> Self {
652        self.initial_vars.insert(name.into(), value);
653        self
654    }
655
656    /// Replace the entire initial-vars map. All entries are marked exported.
657    pub fn with_initial_vars(mut self, vars: HashMap<String, Value>) -> Self {
658        self.initial_vars = vars;
659        self
660    }
661
662    /// Extend the initial-vars map with the given entries (last write wins).
663    pub fn with_vars(mut self, vars: HashMap<String, Value>) -> Self {
664        self.initial_vars.extend(vars);
665        self
666    }
667
668    /// Set the default per-request timeout (kernel-wide).
669    ///
670    /// Each `execute_with_options` call without an explicit timeout uses
671    /// this. On elapsed, the kernel cancels and returns exit code 124.
672    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
673        self.request_timeout = Some(timeout);
674        self
675    }
676
677    /// Set the SIGTERM-to-SIGKILL grace period for child kills.
678    pub fn with_kill_grace(mut self, grace: Duration) -> Self {
679        self.kill_grace = grace;
680        self
681    }
682
683    /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on external commands so a hard-killed
684    /// kaish process cannot orphan them (Linux only — read
685    /// [`Self::kill_children_on_parent_death`] for the tradeoff and the macOS
686    /// gap).
687    pub fn with_kill_children_on_parent_death(mut self, on: bool) -> Self {
688        self.kill_children_on_parent_death = on;
689        self
690    }
691
692    /// Adopt an embedder-owned [`JobManager`] instead of building a fresh one,
693    /// so background jobs outlive the kernel that started them. Read
694    /// [`Self::job_manager`] before sharing one manager between kernels that
695    /// are configured differently.
696    pub fn with_job_manager(mut self, jobs: Arc<JobManager>) -> Self {
697        self.job_manager = Some(jobs);
698        self
699    }
700
701    /// Cap VFS memory-resident bytes at `bytes` across all kernel-owned
702    /// `MemoryFs` mounts. A shared `ByteBudget` labeled `"vfs-memory"` is
703    /// created at kernel construction and passed to every `MemoryFs` the
704    /// kernel builds (see `setup_vfs` and `with_backend`).
705    ///
706    /// Writes that would exceed the cap fail loudly with `StorageFull` — an
707    /// in-band error a model reads and adapts to; fail loud over quietly eating
708    /// RAM. Use `without_vfs_budget` to remove the cap entirely.
709    pub fn with_vfs_budget(mut self, bytes: u64) -> Self {
710        self.vfs_budget_bytes = Some(bytes);
711        self
712    }
713
714    /// Remove the VFS memory budget — all `MemoryFs` mounts are unbounded.
715    ///
716    /// Use when the caller knows the workload and the default 64 MiB cap
717    /// (set by `KernelConfig::agent`) is too conservative.
718    pub fn without_vfs_budget(mut self) -> Self {
719        self.vfs_budget_bytes = None;
720        self
721    }
722
723    /// Enable or disable copy-on-write overlay mode.
724    ///
725    /// When `true`, the primary local filesystem mount is wrapped in an
726    /// `OverlayFs` so writes are virtual — the lower layer is never touched.
727    /// Incompatible with `VfsMountMode::NoLocal` (fails loudly at construction)
728    /// and `with_backend` kernels (same — the embedder controls the VFS).
729    pub fn with_overlay(mut self, overlay: bool) -> Self {
730        self.overlay = overlay;
731        self
732    }
733
734}
735
736
737/// Handle to an active overlay session, kept on the kernel and shared to
738/// `ExecContext` so the `kaish-vfs` builtin can reach the `OverlayFs`.
739///
740/// The `mount_path` is the VFS prefix the overlay was mounted under (e.g.
741/// `/home/user`); `commit_root` is the real filesystem path the overlay's
742/// lower is backed by (used as the target for `kaish-vfs commit`).
743#[cfg(all(feature = "localfs", feature = "overlay"))]
744#[derive(Clone)]
745pub struct OverlayHandle {
746    /// The mounted `OverlayFs`, Arc-shared so the builtin can call inspection
747    /// methods without holding a VfsRouter lock.
748    pub fs: Arc<OverlayFs>,
749    /// VFS path this overlay is mounted at (e.g. `/home/user`).
750    pub mount_path: PathBuf,
751    /// Real filesystem root to commit into. Same as the lower's root.
752    pub commit_root: PathBuf,
753}
754
755/// The Kernel (核) — executes kaish code.
756///
757/// This is the primary interface for running kaish commands. It owns all
758/// the runtime state: variables, tools, VFS, jobs, and persistence.
759pub struct Kernel {
760    /// Kernel name.
761    name: String,
762    /// Variable scope.
763    scope: RwLock<Scope>,
764    /// Tool registry.
765    tools: Arc<ToolRegistry>,
766    /// User-defined tools (from `tool name { body }` statements).
767    user_tools: RwLock<HashMap<String, ToolDef>>,
768    /// Virtual filesystem router.
769    vfs: Arc<VfsRouter>,
770    /// Background job manager.
771    jobs: Arc<JobManager>,
772    /// Pipeline runner.
773    runner: PipelineRunner,
774    /// Execution context (cwd, stdin, etc.).
775    exec_ctx: RwLock<ExecContext>,
776    /// Frontend-seeded variables (HOME/PATH/etc, from `KernelConfig::initial_vars`),
777    /// retained past construction so `reset()` can re-seed them into the fresh
778    /// scope instead of silently dropping them.
779    initial_vars: HashMap<String, Value>,
780    /// Whether to skip pre-execution validation.
781    skip_validation: bool,
782    /// When true, standalone external commands inherit stdio for real-time output.
783    interactive: bool,
784    /// Whether external command execution is allowed.
785    allow_external_commands: bool,
786    /// Shared memory budget for all kernel-owned `MemoryFs` mounts.
787    ///
788    /// `None` when `KernelConfig::vfs_budget_bytes` was `None` (unbounded).
789    /// `Some` is Arc-cloned into forks so all concurrent execution draws from
790    /// the same pool — a background job's writes reduce the same cap as
791    /// foreground writes, which is the correct behaviour.
792    vfs_budget: Option<Arc<kaish_vfs::ByteBudget>>,
793    /// Active overlay session handle, if this kernel was constructed with
794    /// `overlay: true`. Arc-shared so `ExecContext` (and thus the
795    /// `kaish-vfs` builtin) can inspect and mutate the overlay without
796    /// holding a kernel write lock. Propagated to forks via `fork_inner`
797    /// and `child_for_pipeline` so `kaish-vfs` works inside background
798    /// jobs, scatter workers, and pipeline stages.
799    #[cfg(all(feature = "localfs", feature = "overlay"))]
800    overlay_handle: Option<Arc<OverlayHandle>>,
801    /// Default per-request timeout (None = no default).
802    request_timeout: Option<Duration>,
803    /// SIGTERM-to-SIGKILL grace period for child kills.
804    kill_grace: Duration,
805    /// Receiver for the kernel stderr stream.
806    ///
807    /// Pipeline stages write to the corresponding `StderrStream` (set on ExecContext).
808    /// The kernel drains this after each statement in `execute_streaming`.
809    stderr_receiver: tokio::sync::Mutex<StderrReceiver>,
810    /// Cancellation token for interrupting execution (Ctrl-C).
811    ///
812    /// Protected by `std::sync::Mutex` (not tokio) because the SIGINT handler
813    /// needs sync access. Each `execute()` call gets a fresh child token;
814    /// `cancel()` cancels the current token and replaces it.
815    cancel_token: std::sync::Mutex<tokio_util::sync::CancellationToken>,
816    /// Per-call polled interrupt check (`ExecuteOptions::interrupt`),
817    /// installed for the duration of an `execute_with_options` call and
818    /// cleared on exit. Consulted by `is_cancelled()` so every existing
819    /// cancellation checkpoint gains interrupt awareness without new wiring.
820    /// std Mutex for the same sync-access reason as `cancel_token`.
821    interrupt: std::sync::Mutex<Option<std::sync::Arc<dyn Fn() -> bool + Send + Sync>>>,
822    /// Terminal state for job control (interactive mode only, Unix only).
823    #[cfg(all(unix, feature = "subprocess"))]
824    terminal_state: Option<Arc<crate::terminal::TerminalState>>,
825    /// Weak self-reference for handing out `Arc<dyn CommandDispatcher>`.
826    ///
827    /// Set by `into_arc()`. Allows builtins to re-dispatch inner commands
828    /// through the full Kernel resolution chain.
829    self_weak: std::sync::OnceLock<std::sync::Weak<Self>>,
830    /// Background job this kernel (a fork) is executing on behalf of, if any.
831    /// Set on the fork created by `execute_background` and inherited by all its
832    /// sub-forks (pipeline stages, scatter workers), so an external command
833    /// spawned anywhere under a background job can record its process group on
834    /// that job for `kill -<sig> %N`. `None` for foreground execution.
835    bg_job_id: Option<crate::scheduler::JobId>,
836    /// Serializes concurrent `execute()` / `execute_streaming()` callers on
837    /// this Kernel instance. Tokio's Mutex is fair (FIFO) and acts as the
838    /// queue. Background jobs, scatter workers, and concurrent pipeline
839    /// stages do NOT take this lock — they run against a *forked* Kernel
840    /// (see [`Kernel::fork`]) so they never contend with the foreground.
841    execute_lock: tokio::sync::Mutex<()>,
842    /// Current dynamic statement-engine re-entry depth — incremented on entry
843    /// to command substitution, a shell-function call, or a `.kai` source, and
844    /// decremented (via an RAII guard, so cancellation stays balanced) on exit.
845    /// Checked against [`MAX_RECURSION_DEPTH`] to turn a stack overflow into a
846    /// loud error (GH #46). Per-Kernel: a fork starts fresh at 0 because it
847    /// runs on its own stack. Atomic only for `Send`/`Sync`; within one Kernel
848    /// the recursion chain is single-threaded (top-level `execute` is
849    /// serialized by `execute_lock`; concurrency happens on forks).
850    recursion_depth: AtomicUsize,
851}
852
853/// RAII balance for [`Kernel::recursion_depth`]: increments on construction
854/// (in `enter_recursion`) and decrements on drop, so a cancelled or
855/// error-unwound re-entry can never leave the counter inflated (which would
856/// spuriously trip later, unrelated recursions).
857struct RecursionGuard<'a> {
858    counter: &'a AtomicUsize,
859}
860
861impl Drop for RecursionGuard<'_> {
862    fn drop(&mut self) {
863        self.counter.fetch_sub(1, Ordering::Relaxed);
864    }
865}
866
867/// Internal result of [`Kernel::setup_vfs`].
868struct VfsSetupResult {
869    vfs: VfsRouter,
870    budget: Option<Arc<ByteBudget>>,
871    #[cfg(all(feature = "localfs", feature = "overlay"))]
872    overlay_handle: Option<Arc<OverlayHandle>>,
873}
874
875impl Kernel {
876    /// Create a new kernel with the given configuration.
877    pub fn new(config: KernelConfig) -> Result<Self> {
878        let mut setup = Self::setup_vfs(&config)?;
879        // An embedder-supplied manager keeps `cmd &` jobs alive across kernels
880        // (see `KernelConfig::job_manager`); with none, this kernel owns its
881        // own job table exactly as before.
882        let jobs = config.job_manager.clone().unwrap_or_else(|| Arc::new(JobManager::new()));
883        // Mirror the cascade's SIGTERM->SIGKILL grace onto the manager so the
884        // kill builtin bounds its wait-for-death on the same number (GH #244).
885        jobs.set_kill_grace(config.kill_grace);
886
887        // Mount JobFs for job observability at /v/jobs
888        setup.vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
889
890        #[cfg(all(feature = "localfs", feature = "overlay"))]
891        let overlay_handle = setup.overlay_handle.take();
892
893        // Mode-based construction: the kernel owns its host mounts, so whether
894        // host side channels are allowed is decided by the VFS mode inside
895        // `assemble` (NoLocal forbids them).
896        let kernel = Self::assemble(config, setup.vfs, jobs, false, setup.budget, |_| {}, |vfs_ref, tools| {
897            ExecContext::with_vfs_and_tools(vfs_ref.clone(), tools.clone())
898        })?;
899
900        #[cfg(all(feature = "localfs", feature = "overlay"))]
901        {
902            let mut kernel = kernel;
903            kernel.overlay_handle = overlay_handle;
904            // Also set it on the ExecContext so builtins can access it.
905            if let Some(ref handle) = kernel.overlay_handle {
906                kernel.exec_ctx.get_mut().overlay_handle = Some(Arc::clone(handle));
907            }
908            return Ok(kernel);
909        }
910
911        #[allow(unreachable_code)]
912        Ok(kernel)
913    }
914
915    /// Set up VFS based on mount mode.
916    ///
917    /// Returns the router, the budget handle (if bounded), and an optional
918    /// overlay handle when `config.overlay` is true. The budget is Arc-shared:
919    /// every `MemoryFs` the kernel creates here holds a clone of the same
920    /// `Arc<ByteBudget>`, so the total charged against it is the sum of all
921    /// in-memory content across all kernel-owned memory mounts.
922    ///
923    /// # Errors
924    /// Returns `Err` if `config.overlay` is true and the mode is `NoLocal`
925    /// (overlay is meaningless when everything is already virtual — there is
926    /// no real lower layer to wrap). The caller (`Kernel::new`) propagates
927    /// this as an `anyhow::Error`.
928    fn setup_vfs(config: &KernelConfig) -> Result<VfsSetupResult> {
929        let mut vfs = VfsRouter::new();
930
931        // One budget for all memory mounts this kernel owns — labeled so the
932        // error message tells the user exactly which knob to raise.
933        let budget: Option<Arc<ByteBudget>> = config
934            .vfs_budget_bytes
935            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
936
937        /// Helper: construct a `MemoryFs` wired to `budget` if present.
938        fn mem(budget: &Option<Arc<ByteBudget>>) -> MemoryFs {
939            match budget {
940                Some(b) => MemoryFs::with_budget(Arc::clone(b)),
941                None => MemoryFs::new(),
942            }
943        }
944
945        // Overlay handle — populated below if config.overlay is true.
946        #[cfg(all(feature = "localfs", feature = "overlay"))]
947        let mut overlay_handle: Option<Arc<OverlayHandle>> = None;
948
949        match &config.vfs_mode {
950            #[cfg(feature = "localfs")]
951            VfsMountMode::Passthrough => {
952                #[cfg(feature = "overlay")]
953                if config.overlay {
954                    // Wrap "/" in an OverlayFs so writes are virtual.
955                    let lower = Arc::new(LocalFs::read_only(PathBuf::from("/")));
956                    let overlay_fs = Arc::new(match &budget {
957                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
958                        None => OverlayFs::over(lower),
959                    });
960                    let handle = Arc::new(OverlayHandle {
961                        fs: Arc::clone(&overlay_fs),
962                        mount_path: PathBuf::from("/"),
963                        commit_root: PathBuf::from("/"),
964                    });
965                    vfs.mount_arc("/", overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
966                    overlay_handle = Some(handle);
967                } else {
968                    // LocalFs at "/" — native paths work directly
969                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
970                }
971                #[cfg(not(feature = "overlay"))]
972                {
973                    if config.overlay {
974                        return Err(anyhow::anyhow!(
975                            "overlay=true requires the `overlay` feature, but this build \
976                             was compiled without it. Recompile with --features overlay \
977                             (or the default feature set) to enable overlay mode."
978                        ));
979                    }
980                    // LocalFs at "/" — native paths work directly
981                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
982                }
983                // Memory for blobs
984                vfs.mount("/v", mem(&budget));
985            }
986            #[cfg(feature = "localfs")]
987            VfsMountMode::Sandboxed { root } => {
988                // Memory at root for safety (catches paths outside sandbox).
989                // Note: /tmp and the XDG runtime dir are LocalFs — writes
990                // there escape the VFS budget and are NOT virtual. This is
991                // intentional: /tmp interop with other processes matters more
992                // than accounting for scratch files there.
993                vfs.mount("/", mem(&budget));
994                vfs.mount("/v", mem(&budget));
995
996                // Synthetic /dev: the host's real /dev isn't reachable here, so
997                // /dev/null and /dev/zero are software-backed (see DevFs).
998                vfs.mount("/dev", DevFs::new());
999
1000                // Real /tmp for interop with other processes
1001                vfs.mount("/tmp", LocalFs::new(PathBuf::from("/tmp")));
1002
1003                // Mount XDG runtime dir for spill files and socket access
1004                let runtime = crate::paths::xdg_runtime_dir();
1005                if runtime.exists() {
1006                    let runtime_str = runtime.to_string_lossy().to_string();
1007                    vfs.mount(&runtime_str, LocalFs::new(runtime));
1008                }
1009
1010                // Resolve the sandbox root (defaults to $HOME)
1011                let local_root = root.clone().unwrap_or_else(|| {
1012                    std::env::var("HOME")
1013                        .map(PathBuf::from)
1014                        .unwrap_or_else(|_| PathBuf::from("/"))
1015                });
1016
1017                let mount_point = local_root.to_string_lossy().to_string();
1018
1019                #[cfg(feature = "overlay")]
1020                if config.overlay {
1021                    // Wrap the sandbox root in an OverlayFs.
1022                    let lower = Arc::new(LocalFs::read_only(local_root.clone()));
1023                    let overlay_fs = Arc::new(match &budget {
1024                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
1025                        None => OverlayFs::over(lower),
1026                    });
1027                    let handle = Arc::new(OverlayHandle {
1028                        fs: Arc::clone(&overlay_fs),
1029                        mount_path: PathBuf::from(&mount_point),
1030                        commit_root: local_root,
1031                    });
1032                    vfs.mount_arc(&mount_point, overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
1033                    overlay_handle = Some(handle);
1034                } else {
1035                    // Mount at the real path for transparent access
1036                    // e.g., /home/atobey → LocalFs("/home/atobey")
1037                    // so /home/atobey/src/kaish just works
1038                    vfs.mount(&mount_point, LocalFs::new(local_root));
1039                }
1040                #[cfg(not(feature = "overlay"))]
1041                {
1042                    if config.overlay {
1043                        return Err(anyhow::anyhow!(
1044                            "overlay=true requires the `overlay` feature, but this build \
1045                             was compiled without it. Recompile with --features overlay \
1046                             (or the default feature set) to enable overlay mode."
1047                        ));
1048                    }
1049                    // Mount at the real path for transparent access
1050                    vfs.mount(&mount_point, LocalFs::new(local_root));
1051                }
1052            }
1053            VfsMountMode::NoLocal => {
1054                if config.overlay {
1055                    return Err(anyhow::anyhow!(
1056                        "overlay=true is incompatible with VfsMountMode::NoLocal: \
1057                         everything is already virtual, there is no real lower layer \
1058                         to wrap. Use with_overlay(false) or switch to a Passthrough \
1059                         or Sandboxed VFS mode."
1060                    ));
1061                }
1062                // Pure memory mode — no local filesystem
1063                vfs.mount("/", mem(&budget));
1064                vfs.mount("/tmp", mem(&budget));
1065                vfs.mount("/v", mem(&budget));
1066                // Synthetic /dev so /dev/null and /dev/zero work hermetically.
1067                vfs.mount("/dev", DevFs::new());
1068            }
1069        }
1070
1071        Ok(VfsSetupResult {
1072            vfs,
1073            budget,
1074            #[cfg(all(feature = "localfs", feature = "overlay"))]
1075            overlay_handle,
1076        })
1077    }
1078
1079    /// Create a transient kernel (no persistence).
1080    pub fn transient() -> Result<Self> {
1081        Self::new(KernelConfig::transient())
1082    }
1083
1084    /// Create a kernel with a custom backend and `/v/*` virtual path support.
1085    ///
1086    /// This is the constructor for embedding kaish in other systems that provide
1087    /// their own storage backend (e.g., CRDT-backed storage in kaijutsu).
1088    ///
1089    /// A `VirtualOverlayBackend` routes paths automatically:
1090    /// - `/v/*` → Internal VFS (JobFs at `/v/jobs`, MemoryFs at `/v/blobs`)
1091    /// - `/dev` → DevFs (synthetic `/dev/null`, `/dev/zero`, `/dev/random`,
1092    ///   `/dev/urandom`) — kernel-owned so it works even when your backend is
1093    ///   read-only
1094    /// - Everything else → Your custom backend
1095    ///
1096    /// The optional `configure_vfs` closure lets you add additional virtual mounts
1097    /// (e.g., `/v/docs` for CRDT blocks) after the built-in mounts are set up.
1098    ///
1099    /// **Note:** The config's `vfs_mode` is ignored — all non-`/v/*` path routing
1100    /// is handled by your custom backend. The config is only used for `name`, `cwd`,
1101    /// `skip_validation`, and `interactive`.
1102    ///
1103    /// # Example
1104    ///
1105    /// ```ignore
1106    /// // Simple: default /v/* mounts only
1107    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |_| {})?;
1108    ///
1109    /// // With custom mounts
1110    /// let kernel = Kernel::with_backend(backend, config, |vfs| {
1111    ///     vfs.mount_arc("/v/docs", docs_fs);
1112    ///     vfs.mount_arc("/v/g", git_fs);
1113    /// }, |_| {})?;
1114    ///
1115    /// // With custom tools
1116    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |tools| {
1117    ///     tools.register(MyCustomTool::new());
1118    /// })?;
1119    /// ```
1120    pub fn with_backend(
1121        backend: Arc<dyn KernelBackend>,
1122        config: KernelConfig,
1123        configure_vfs: impl FnOnce(&mut VfsRouter),
1124        configure_tools: impl FnOnce(&mut ToolRegistry),
1125    ) -> Result<Self> {
1126        use crate::backend::VirtualOverlayBackend;
1127
1128        // overlay=true is incompatible with with_backend: the embedder controls
1129        // the VFS and the kernel cannot wrap it without bypassing the embedder's
1130        // semantics. Fail loudly rather than silently ignoring the flag.
1131        if config.overlay {
1132            return Err(anyhow::anyhow!(
1133                "overlay=true is incompatible with Kernel::with_backend: the embedder \
1134                 controls the VFS; the kernel cannot wrap it with an OverlayFs without \
1135                 bypassing the embedder's storage semantics. Use KernelConfig::with_overlay(false)."
1136            ));
1137        }
1138
1139        let mut vfs = VfsRouter::new();
1140        // See `Kernel::new` — the embedder's manager wins here too.
1141        let jobs = config.job_manager.clone().unwrap_or_else(|| Arc::new(JobManager::new()));
1142        // Mirror the cascade's SIGTERM->SIGKILL grace onto the manager so the
1143        // kill builtin bounds its wait-for-death on the same number (GH #244).
1144        jobs.set_kill_grace(config.kill_grace);
1145
1146        // Create the budget from config so `with_vfs_budget` / `without_vfs_budget`
1147        // work for `with_backend` callers too. The /v/blobs MemoryFs is the only
1148        // kernel-owned memory mount here — embedders own the rest of the VFS.
1149        let vfs_budget: Option<Arc<ByteBudget>> = config
1150            .vfs_budget_bytes
1151            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
1152
1153        vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
1154        let blobs_fs = match &vfs_budget {
1155            Some(b) => MemoryFs::with_budget(Arc::clone(b)),
1156            None => MemoryFs::new(),
1157        };
1158        vfs.mount("/v/blobs", blobs_fs);
1159
1160        // /dev/null and friends are software-backed (see DevFs) and must not
1161        // depend on the embedder's backend — a read-only embedder backend
1162        // (e.g. kaijutsu's read-only host root) would otherwise reject writes
1163        // to /dev/null as a filesystem error instead of discarding them.
1164        vfs.mount("/dev", DevFs::new());
1165
1166        // Let caller add custom mounts (e.g., /v/docs, /v/g)
1167        configure_vfs(&mut vfs);
1168
1169        // A custom-backend kernel owns no host mounts — the embedder supplies
1170        // the entire VFS — so any kernel write to a host filesystem via
1171        // `std::fs` (output spill, job output files) bypasses that VFS and its
1172        // read-only guarantees. Forbid host side channels unconditionally.
1173        Self::assemble(config, vfs, jobs, true, vfs_budget, configure_tools, |vfs_arc: &Arc<VfsRouter>, _: &Arc<ToolRegistry>| {
1174            let overlay: Arc<dyn KernelBackend> =
1175                Arc::new(VirtualOverlayBackend::new(backend, vfs_arc.clone()));
1176            ExecContext::with_backend(overlay)
1177        })
1178    }
1179
1180    /// Shared assembly: wires up tools, runner, scope, and ExecContext.
1181    ///
1182    /// The `make_ctx` closure receives the VFS and tools so backends that need
1183    /// them (like `LocalBackend::with_tools`) can capture them. Custom backends
1184    /// that already have their own storage can ignore these parameters.
1185    fn assemble(
1186        config: KernelConfig,
1187        mut vfs: VfsRouter,
1188        jobs: Arc<JobManager>,
1189        no_host_filesystem: bool,
1190        vfs_budget: Option<Arc<ByteBudget>>,
1191        configure_tools: impl FnOnce(&mut ToolRegistry),
1192        make_ctx: impl FnOnce(&Arc<VfsRouter>, &Arc<ToolRegistry>) -> ExecContext,
1193    ) -> Result<Self> {
1194        // A kernel with no host filesystem of its own must never write to one
1195        // through a side channel. Two paths bypass the VFS by going straight to
1196        // `std::fs`: output spill (`paths::spill_dir()` → host temp/cache) and
1197        // background-job output files (`Job::write_output_file` → host temp).
1198        // Both would punch through the isolation, so force them off:
1199        // in-memory truncation for spill, no host file for job output.
1200        //
1201        // This is true for a `NoLocal` kernel (mounts nothing) and for any
1202        // `with_backend` kernel (`no_host_filesystem` — the embedder owns the
1203        // VFS, so the kernel controls no host mounts and any host write is a
1204        // bypass). Overrides an explicit `SpillMode::Disk`, which is nonsensical
1205        // when there is no kernel-owned host filesystem to spill to.
1206        let no_host_side_channel =
1207            no_host_filesystem || matches!(config.vfs_mode, VfsMountMode::NoLocal);
1208
1209        let KernelConfig { name, cwd, skip_validation, interactive, ignore_config, mut output_limit, allow_external_commands, trash_enabled, initial_vars, request_timeout, kill_grace, kill_children_on_parent_death, .. } = config;
1210
1211        if no_host_side_channel {
1212            output_limit.set_spill_mode(crate::output_limit::SpillMode::Memory);
1213            jobs.set_persist_output_files(false);
1214        }
1215
1216        let mut tools = ToolRegistry::new();
1217        register_builtins(&mut tools);
1218        configure_tools(&mut tools);
1219        let tools = Arc::new(tools);
1220
1221        // Mount BuiltinFs so `ls /v/bin` lists builtins
1222        vfs.mount("/v/bin", BuiltinFs::new(tools.clone()));
1223
1224        let vfs = Arc::new(vfs);
1225
1226        let runner = PipelineRunner::new(tools.clone());
1227
1228        let (stderr_writer, stderr_receiver) = stderr_stream();
1229
1230        let mut exec_ctx = make_ctx(&vfs, &tools);
1231        exec_ctx.set_cwd(cwd);
1232        exec_ctx.kill_children_on_parent_death = kill_children_on_parent_death;
1233        exec_ctx.set_job_manager(jobs.clone());
1234        exec_ctx.set_tool_schemas(tools.schemas());
1235        exec_ctx.set_tools(tools.clone());
1236        #[cfg(feature = "os-integration")]
1237        exec_ctx.set_trash_backend(Arc::new(crate::trash_system::SystemTrash));
1238        exec_ctx.stderr = Some(stderr_writer);
1239        exec_ctx.ignore_config = ignore_config;
1240        exec_ctx.output_limit = output_limit;
1241        exec_ctx.allow_external_commands = allow_external_commands;
1242        exec_ctx.vfs_budget = vfs_budget.clone();
1243
1244        Ok(Self {
1245            name,
1246            scope: RwLock::new({
1247                let mut scope = Scope::new();
1248                scope.set_pid(KERNEL_COUNTER.fetch_add(1, Ordering::Relaxed));
1249                // HOME is NOT read from the host env here — the kernel is
1250                // hermetic. Frontends (REPL, MCP) seed it via `initial_vars`
1251                // below (from `std::env::vars()`); a hermetic embedder leaves
1252                // `initial_vars` empty and gets no HOME (tilde stays literal).
1253                // Apply caller-supplied initial variables, all marked exported.
1254                // Frontends (REPL, MCP) populate this from std::env::vars()
1255                // for shell-like UX; embedders that want hermetic behavior
1256                // simply leave it empty.
1257                for (name, value) in initial_vars.clone() {
1258                    scope.set_exported(name, value);
1259                }
1260                scope.set_trash_enabled(trash_enabled);
1261                scope
1262            }),
1263            initial_vars,
1264            tools,
1265            user_tools: RwLock::new(HashMap::new()),
1266            vfs,
1267            jobs,
1268            runner,
1269            exec_ctx: RwLock::new(exec_ctx),
1270            skip_validation,
1271            interactive,
1272            allow_external_commands,
1273            vfs_budget,
1274            request_timeout,
1275            kill_grace,
1276            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1277            cancel_token: std::sync::Mutex::new(tokio_util::sync::CancellationToken::new()),
1278            interrupt: std::sync::Mutex::new(None),
1279            #[cfg(all(unix, feature = "subprocess"))]
1280            terminal_state: None,
1281            self_weak: std::sync::OnceLock::new(),
1282            execute_lock: tokio::sync::Mutex::new(()),
1283            recursion_depth: AtomicUsize::new(0),
1284            bg_job_id: None,
1285            // Overlay handle is set by Kernel::new after assemble returns;
1286            // assemble itself doesn't know the handle (it's constructed in setup_vfs).
1287            // with_backend always has None (overlay=true is rejected above).
1288            #[cfg(all(feature = "localfs", feature = "overlay"))]
1289            overlay_handle: None,
1290        })
1291    }
1292
1293    /// Plan every statement of `source` without executing anything —
1294    /// [`plan_program`](crate::ast::plan::plan_program) as a method, so an
1295    /// embedder holding a kernel can pair the plans with `get_var` lookups
1296    /// against this kernel's live state.
1297    ///
1298    /// # Errors
1299    ///
1300    /// Returns the parse errors when `source` does not parse.
1301    pub fn plan_program(
1302        &self,
1303        source: &str,
1304    ) -> Result<Vec<crate::ast::plan::PlannedStatement>, Vec<crate::parser::ParseError>> {
1305        crate::ast::plan::plan_program(source)
1306    }
1307
1308    /// Get the kernel name.
1309    pub fn name(&self) -> &str {
1310        &self.name
1311    }
1312
1313    /// Wrap this Kernel in an Arc and initialize its self-reference.
1314    ///
1315    /// This enables the Kernel to hand out `Arc<dyn CommandDispatcher>` references
1316    /// to child contexts, allowing builtins like `timeout` to dispatch inner
1317    /// commands through the full resolution chain (user tools → builtins →
1318    /// .kai scripts → external commands).
1319    pub fn into_arc(self) -> Arc<Self> {
1320        let arc = Arc::new(self);
1321        let _ = arc.self_weak.set(Arc::downgrade(&arc));
1322        arc
1323    }
1324
1325    /// Fork a subsidiary kernel for concurrent execution.
1326    ///
1327    /// The fork is a fully-functional `Kernel` that:
1328    /// - **Snapshots** per-session state from the parent: scope (COW — cheap),
1329    ///   user-defined tools, cwd, aliases, ignore config, etc. Mutations on
1330    ///   the fork do NOT propagate back to the parent — matching bash
1331    ///   subshell / background-job semantics.
1332    /// - **Shares** read-mostly resources with the parent via `Arc`: the tool
1333    ///   registry, the VFS router, and the job manager. A job registered by
1334    ///   the fork is visible to the parent's `jobs` builtin, and the fork
1335    ///   sees the same VFS mounts.
1336    /// - **Owns** its own `stderr_receiver`, `cancel_token`, and
1337    ///   `execute_lock`. It is never the TTY owner, so `interactive` is
1338    ///   `false` and `terminal_state` is `None`.
1339    ///
1340    /// The returned Arc has its `self_weak` populated (via `into_arc`), so
1341    /// nested dispatch through `ctx.dispatcher` (e.g. the `timeout` builtin)
1342    /// routes through the fork itself, not the parent — which is essential
1343    /// for concurrency safety.
1344    ///
1345    /// Use this for **detached** background concurrency where the fork should
1346    /// survive parent cancellation: the `&` background-job operator and any
1347    /// other "fire and forget" worker. The fork gets a fresh, independent
1348    /// cancellation token.
1349    ///
1350    /// For foreground concurrency (scatter workers, concurrent pipeline
1351    /// stages, `$(...)` cmdsubs) where parent timeout/cancel must cascade
1352    /// into the fork's external children, use [`Self::fork_attached`].
1353    pub async fn fork(&self) -> Arc<Self> {
1354        self.fork_inner(tokio_util::sync::CancellationToken::new(), self.bg_job_id)
1355            .await
1356    }
1357
1358    /// Fork attached to the parent's cancellation.
1359    ///
1360    /// Same as [`Self::fork`] but the fork's `cancel_token` is a child of
1361    /// the parent's. When the parent cancels (request timeout, embedder
1362    /// `Kernel::cancel`, etc.), the fork's token also cancels, which in
1363    /// turn kills any external children spawned in the fork via the
1364    /// `wait_or_kill` / SIGTERM-grace-SIGKILL path.
1365    pub async fn fork_attached(&self) -> Arc<Self> {
1366        let child_token = {
1367            #[allow(clippy::expect_used)]
1368            let parent = self.cancel_token.lock().expect("cancel_token poisoned");
1369            parent.child_token()
1370        };
1371        self.fork_inner(child_token, self.bg_job_id).await
1372    }
1373
1374    /// Fork for a background job, stamping the job id so external commands
1375    /// spawned anywhere beneath it record their process groups on that job
1376    /// (for `kill -<sig> %N`). The caller owns `cancel` so it can also drive
1377    /// `JobManager::cancel`.
1378    pub async fn fork_for_background(
1379        &self,
1380        cancel: tokio_util::sync::CancellationToken,
1381        job_id: crate::scheduler::JobId,
1382    ) -> Arc<Self> {
1383        self.fork_inner(cancel, Some(job_id)).await
1384    }
1385
1386    /// Shared fork implementation. Caller decides the cancellation token and
1387    /// which background job (if any) this fork runs on behalf of.
1388    async fn fork_inner(
1389        &self,
1390        cancel: tokio_util::sync::CancellationToken,
1391        bg_job_id: Option<crate::scheduler::JobId>,
1392    ) -> Arc<Self> {
1393        let scope_snapshot = self.scope.read().await.clone();
1394        let user_tools_snapshot = self.user_tools.read().await.clone();
1395
1396        // Snapshot exec_ctx by cloning the cloneable fields, then override
1397        // the ones that should not carry over (stderr channel, dispatcher,
1398        // interactive flag, terminal state, cancel — set from `cancel` arg).
1399        let mut fork_ctx = {
1400            let parent_ctx = self.exec_ctx.read().await;
1401            parent_ctx.child_for_pipeline()
1402        };
1403        let (stderr_writer, stderr_receiver) = stderr_stream();
1404        fork_ctx.stderr = Some(stderr_writer);
1405        // Clear dispatcher; dispatch_command will repopulate it to point at
1406        // the fork on the first dispatch call.
1407        fork_ctx.dispatcher = None;
1408        fork_ctx.interactive = false;
1409        fork_ctx.cancel = cancel.clone();
1410        #[cfg(all(unix, feature = "subprocess"))]
1411        {
1412            fork_ctx.terminal_state = None;
1413        }
1414
1415        let fork = Self {
1416            name: format!("{}:fork", self.name),
1417            scope: RwLock::new(scope_snapshot),
1418            initial_vars: self.initial_vars.clone(),
1419            tools: Arc::clone(&self.tools),
1420            user_tools: RwLock::new(user_tools_snapshot),
1421            vfs: Arc::clone(&self.vfs),
1422            jobs: Arc::clone(&self.jobs),
1423            runner: self.runner.clone(),
1424            exec_ctx: RwLock::new(fork_ctx),
1425            skip_validation: self.skip_validation,
1426            // Forks are never the TTY owner — they run in the background.
1427            interactive: false,
1428            allow_external_commands: self.allow_external_commands,
1429            // Arc-clone the budget so the fork draws from the same pool as the
1430            // parent — background jobs and scatter workers count against the same
1431            // cap as foreground writes.
1432            vfs_budget: self.vfs_budget.clone(),
1433            request_timeout: self.request_timeout,
1434            kill_grace: self.kill_grace,
1435            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1436            cancel_token: std::sync::Mutex::new(cancel),
1437            interrupt: std::sync::Mutex::new(None),
1438            #[cfg(all(unix, feature = "subprocess"))]
1439            terminal_state: None,
1440            self_weak: std::sync::OnceLock::new(),
1441            execute_lock: tokio::sync::Mutex::new(()),
1442            // A fork runs on a fresh stack (spawned task) — its recursion
1443            // budget is independent of the parent's current depth (GH #46).
1444            recursion_depth: AtomicUsize::new(0),
1445            // A fork surfaces its own holds; the parent's slot stays put.
1446            bg_job_id,
1447            // Arc-clone the overlay handle so forks (background jobs, scatter
1448            // workers, pipeline stages) can reach the same overlay transaction
1449            // via `kaish-vfs status/diff/commit/reset`.
1450            #[cfg(all(feature = "localfs", feature = "overlay"))]
1451            overlay_handle: self.overlay_handle.clone(),
1452        };
1453
1454        fork.into_arc()
1455    }
1456
1457    /// Get an `Arc<dyn CommandDispatcher>` to this Kernel, if wrapped via `into_arc()`.
1458    ///
1459    /// Returns `None` if the Kernel was not wrapped, or if all strong references
1460    /// have been dropped (the `Weak` can no longer upgrade).
1461    pub fn dispatcher(&self) -> Option<Arc<dyn CommandDispatcher>> {
1462        self.self_weak
1463            .get()
1464            .and_then(|weak| weak.upgrade())
1465            .map(|arc| arc as Arc<dyn CommandDispatcher>)
1466    }
1467
1468    /// Initialize terminal state for interactive job control.
1469    ///
1470    /// Call this after kernel creation when running as an interactive REPL
1471    /// and stdin is a TTY. Sets up process groups and signal handling.
1472    #[cfg(all(unix, feature = "subprocess"))]
1473    pub fn init_terminal(&mut self) {
1474        if !self.interactive {
1475            return;
1476        }
1477        match crate::terminal::TerminalState::init() {
1478            Ok(state) => {
1479                let state = Arc::new(state);
1480                self.terminal_state = Some(state.clone());
1481                // Set on exec_ctx so builtins (fg, bg, kill) can access it
1482                self.exec_ctx.get_mut().terminal_state = Some(state);
1483                tracing::debug!("terminal job control initialized");
1484            }
1485            Err(e) => {
1486                tracing::warn!("failed to initialize terminal job control: {}", e);
1487            }
1488        }
1489    }
1490
1491    /// Replace or remove the trash backend used by `rm` and `kaish-trash`.
1492    ///
1493    /// The kernel installs the OS trash (`SystemTrash`) automatically when
1494    /// built with the `os-integration` feature. Embedders and tests can swap
1495    /// in a custom [`crate::trash::TrashBackend`], or pass `None` to remove
1496    /// it — with trash enabled but no backend present, `rm` fails loud
1497    /// rather than falling through to permanent delete.
1498    pub fn set_trash_backend(&mut self, backend: Option<Arc<dyn crate::trash::TrashBackend>>) {
1499        self.exec_ctx.get_mut().trash_backend = backend;
1500    }
1501
1502    /// Cancel the current execution.
1503    ///
1504    /// This cancels the current cancellation token, causing any execution
1505    /// loop to exit at the next checkpoint with exit code 130 (SIGINT).
1506    /// A fresh token is installed for the next `execute()` call.
1507    pub fn cancel(&self) {
1508        #[allow(clippy::expect_used)]
1509        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1510        token.cancel();
1511    }
1512
1513    /// Check if the current execution has been cancelled.
1514    ///
1515    /// Also the polling point for `ExecuteOptions::interrupt`: when the
1516    /// embedder's check reports true, the internal token fires here, so every
1517    /// call site of this method is an interrupt checkpoint for free.
1518    pub fn is_cancelled(&self) -> bool {
1519        let interrupted = {
1520            #[allow(clippy::expect_used)]
1521            let check = self.interrupt.lock().expect("interrupt poisoned");
1522            check.as_ref().is_some_and(|f| f())
1523        };
1524        if interrupted {
1525            self.cancel();
1526        }
1527        #[allow(clippy::expect_used)]
1528        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1529        token.is_cancelled()
1530    }
1531
1532    /// Reset the cancellation token (called at the start of each execute).
1533    fn reset_cancel(&self) -> tokio_util::sync::CancellationToken {
1534        #[allow(clippy::expect_used)]
1535        let mut token = self.cancel_token.lock().expect("cancel_token poisoned");
1536        if token.is_cancelled() {
1537            *token = tokio_util::sync::CancellationToken::new();
1538        }
1539        token.clone()
1540    }
1541
1542    /// Acquire the per-Kernel execute lock, warning on contention.
1543    ///
1544    /// Tokio's Mutex is fair (FIFO) so callers queue in arrival order. When
1545    /// the lock is already held, emit a warning so the silent serialization
1546    /// is observable in logs — if you need real parallelism, fork the kernel.
1547    async fn acquire_execute_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
1548        match self.execute_lock.try_lock() {
1549            Ok(guard) => guard,
1550            Err(_) => {
1551                tracing::warn!(
1552                    target: "kaish::kernel::concurrency",
1553                    kernel = %self.name,
1554                    "execute() contended — serializing concurrent caller; \
1555                     use Kernel::fork() for parallelism instead of sharing"
1556                );
1557                self.execute_lock.lock().await
1558            }
1559        }
1560    }
1561
1562    /// Execute kaish source code with default options.
1563    ///
1564    /// Equivalent to `execute_with_options(input, ExecuteOptions::default())`.
1565    /// Returns the result of the last statement executed.
1566    pub async fn execute(&self, input: &str) -> Result<ExecResult> {
1567        self.run_inner(input, ExecuteOptions::default(), None, None).await
1568    }
1569
1570    /// Argv-native peer of [`Self::execute`] — run one command whose arguments
1571    /// are **already tokenized**.
1572    ///
1573    /// `execute(&str)` is string-native: it lexes and parses its input. A caller
1574    /// that already holds OS/structured argv (a busybox-style multicall binary, a
1575    /// structured embedder like kaijutsu) would otherwise have to re-quote argv
1576    /// into a string just to have the lexer split it apart again — a round-trip
1577    /// that is lossy for typed values, since `to_argv()` stringifies
1578    /// [`Value::Bytes`]/[`Value::Json`]. `execute_argv` skips it.
1579    ///
1580    /// **Tokens are literal.** No glob expansion, no `$VAR` interpolation, no
1581    /// command substitution, no word splitting — the "single-quoted word"
1582    /// semantics taken to its end. `execute_argv("echo", &[Value::String("*.txt"
1583    /// .into())])` emits `*.txt`; it does not glob. (One shared-binder expansion
1584    /// does still apply, for consistency with the string door: a leading `~` is
1585    /// expanded against the session `HOME` — kaish expands `~` uniformly, so the
1586    /// two doors agree. Pass a pre-resolved path if you need it byte-literal.) A
1587    /// non-string `Value`
1588    /// (`Bytes`/`Json`/`Int`) lands directly in `ToolArgs.positional`, so typed
1589    /// data survives without a `to_argv()` round-trip. (Caveat: the two-layer
1590    /// clap arg model means a builtin that re-parses its own `to_argv()` still
1591    /// sees a stringified value; the typed-passthrough win fully lands only for
1592    /// builtins that read `args.positional` directly — the documented pattern.)
1593    ///
1594    /// This is a *peer*, not a subset: a command string can carry pipelines,
1595    /// `&&`/`||`, control flow and `$()` that have no argv encoding, so the two
1596    /// doors converge **late** (at the shared dispatch chain) rather than one
1597    /// wrapping the other. From argv classification onward `execute_argv` reuses
1598    /// the exact path a `Stmt::Command` takes — command resolution (aliases, user
1599    /// tools, `.kai` scripts, externals, backend tools), arg binding, and the
1600    /// `--json` transform — so an `ls --json` still applies output formatting. The kernel's
1601    /// pre-execution *syntax* validator does not run: argv has no shell syntax to
1602    /// validate (a tool's own `validate()`/clap parse still runs at dispatch).
1603    ///
1604    /// Concurrent callers serialize on the same execute lock as [`Self::execute`],
1605    /// and the kernel's configured `request_timeout` applies (a hung builtin or
1606    /// external is interrupted at the deadline with exit code 124, the same as the
1607    /// string door). There is no per-call options surface yet — a future
1608    /// `execute_argv_with_options` would carry per-call timeout/cancel/vars/cwd.
1609    #[tracing::instrument(level = "info", skip(self, argv), fields(cmd = name, argc = argv.len()))]
1610    pub async fn execute_argv(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1611        let _guard = self.acquire_execute_lock().await;
1612        self.execute_argv_locked(name, argv).await
1613    }
1614
1615    /// [`Self::execute_argv`]'s body, with the execute lock assumed **held**.
1616    async fn execute_argv_locked(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1617        // Fresh cancel surface for this call: `execute_pipeline` reads
1618        // `self.cancel_token`, so a stale cancelled token from a prior call must be
1619        // replaced first. The returned clone is the token the watchdog cancels on
1620        // an elapsed deadline (it shares state with what `execute_pipeline` reads),
1621        // cascading SIGTERM/SIGKILL to any external child.
1622        let cancel = self.reset_cancel();
1623
1624        // Honor the kernel-configured request timeout for parity with `execute`.
1625        let timeout = self.request_timeout;
1626        if timeout == Some(Duration::ZERO) {
1627            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1628        }
1629
1630        let command = crate::ast::Command {
1631            name: name.to_string(),
1632            args: argv_to_args(argv),
1633            redirects: Vec::new(),
1634        };
1635
1636        let pipeline = crate::ast::Pipeline {
1637            commands: vec![command],
1638            background: false,
1639        };
1640        let work = async {
1641            let result = self.execute_pipeline(&pipeline).await?;
1642            // A gate raised while evaluating inside the dispatched tool — a
1643            // user tool body's `$(…)` — surfaces as this call's own held
1644            // result, and must not strand in the slot for the next serialized
1645            // call to mis-take.
1646            Ok(result)
1647        };
1648        let result = self.run_under_watchdog(timeout, &cancel, work).await?;
1649        self.update_last_result(&result).await;
1650        Ok(result)
1651    }
1652
1653    /// Run `work` under the movable-deadline watchdog for `timeout`, shared by the
1654    /// string door ([`Self::execute_with_options`]) and the argv door
1655    /// ([`Self::execute_argv`]).
1656    ///
1657    /// Mirrors the watchdog into `exec_ctx` (so a builtin can suspend the script
1658    /// clock via `ctx.patient`), and when a timeout is set, spawns the watchdog
1659    /// racing `cancel` — on an elapsed deadline `cancel` fires (cascading
1660    /// SIGTERM/SIGKILL to external children via `wait_or_kill`) and the result's
1661    /// code becomes 124. With `timeout == None`, runs `work` directly. Clears the
1662    /// watchdog handle from `exec_ctx` on the way out (a patient hold against a
1663    /// stale handle would silently suspend nothing). Callers must short-circuit a
1664    /// `Some(Duration::ZERO)` timeout (return 124 without spawning) before calling.
1665    async fn run_under_watchdog<F>(
1666        &self,
1667        timeout: Option<Duration>,
1668        cancel: &tokio_util::sync::CancellationToken,
1669        work: F,
1670    ) -> Result<ExecResult>
1671    where
1672        F: std::future::Future<Output = Result<ExecResult>>,
1673    {
1674        // Assigned unconditionally (clearing any stale handle); None without a timeout.
1675        let watchdog = timeout.map(|d| Arc::new(crate::watchdog::Watchdog::new(d)));
1676        {
1677            let mut ec = self.exec_ctx.write().await;
1678            ec.watchdog = watchdog.clone();
1679        }
1680
1681        let result = if let Some(d) = timeout {
1682            #[allow(clippy::expect_used)]
1683            let watchdog = watchdog.clone().expect("watchdog constructed when timeout is set");
1684            let elapsed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1685            let timer = tokio::spawn(watchdog.run(elapsed.clone(), cancel.clone()));
1686            let r = work.await;
1687            timer.abort();
1688            match r {
1689                Ok(mut res) => {
1690                    if elapsed.load(std::sync::atomic::Ordering::SeqCst) {
1691                        res.code = 124;
1692                        if res.err.is_empty() {
1693                            res.err = format!("timeout: timed out after {:?}", d);
1694                        }
1695                    }
1696                    Ok(res)
1697                }
1698                Err(e) => Err(e),
1699            }
1700        } else {
1701            work.await
1702        };
1703
1704        // The timer task is gone (fired or aborted); drop the stale handle.
1705        {
1706            let mut ec = self.exec_ctx.write().await;
1707            ec.watchdog = None;
1708        }
1709        result
1710    }
1711
1712    /// Execute with per-call options. The primary entry point for embedders
1713    /// that don't need per-statement output streaming.
1714    ///
1715    /// `opts` carries timeout, transient vars overlay, optional cwd override,
1716    /// and optional embedder-owned cancellation token. See [`ExecuteOptions`]
1717    /// for semantics. For streaming, use [`Self::execute_with_options_streaming`].
1718    ///
1719    /// **Cancellation:** if `opts.cancel_token` is `Some`, it is *raced*
1720    /// against the kernel's internal token. Either firing cancels and kills
1721    /// external children. The embedder's token is read-only — kernel
1722    /// timeouts do NOT propagate into it. Distinguish via the returned
1723    /// `code`: 124 = timeout, 130 = cancellation.
1724    ///
1725    /// **Timeout:** `opts.timeout` overrides `KernelConfig::request_timeout`.
1726    /// `Some(Duration::ZERO)` returns 124 immediately without spawning.
1727    ///
1728    /// Concurrent callers on the same Kernel serialize on the kernel-wide
1729    /// execute lock. For true parallelism, call [`Kernel::fork`] (detached)
1730    /// or [`Kernel::fork_attached`] (cancellation cascades from this kernel).
1731    pub async fn execute_with_options(
1732        &self,
1733        input: &str,
1734        opts: ExecuteOptions,
1735    ) -> Result<ExecResult> {
1736        self.run_inner(input, opts, None, None).await
1737    }
1738
1739    /// Same as [`Self::execute_with_options`] but with a per-statement output
1740    /// callback. The callback fires after each top-level statement so the
1741    /// embedder (REPL, MCP streaming) can flush output incrementally.
1742    pub async fn execute_with_options_streaming(
1743        &self,
1744        input: &str,
1745        opts: ExecuteOptions,
1746        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1747    ) -> Result<ExecResult> {
1748        self.run_inner(input, opts, None, Some(on_output)).await
1749    }
1750
1751    /// Execute with a **lazy** standard input fed as a [`PipeReader`](crate::PipeReader).
1752    ///
1753    /// Unlike [`ExecuteOptions::with_stdin`] (a pre-read buffer), this never
1754    /// forces the input to be drained before execution: the reader seeds the
1755    /// first top-level command's `pipe_stdin`, and a command that does not read
1756    /// stdin (`echo`) returns without touching it. This is the seam a
1757    /// non-interactive frontend uses to forward an *open* process stdin without
1758    /// hanging on a pipe that never sends EOF (`sleep 10 | kaish -c 'echo hi'`).
1759    ///
1760    /// Embedders that already hold a complete buffer (text or binary) should
1761    /// prefer the simpler [`ExecuteOptions::with_stdin`] path instead.
1762    pub async fn execute_with_pipe_stdin(
1763        &self,
1764        input: &str,
1765        opts: ExecuteOptions,
1766        pipe_stdin: crate::scheduler::PipeReader,
1767    ) -> Result<ExecResult> {
1768        self.run_inner(input, opts, Some(pipe_stdin), None).await
1769    }
1770
1771    /// Streaming counterpart to [`Self::execute_with_pipe_stdin`] — the REPL
1772    /// `-c`/script frontend uses this to print output incrementally while
1773    /// feeding a lazy process-stdin pipe.
1774    pub async fn execute_with_pipe_stdin_streaming(
1775        &self,
1776        input: &str,
1777        opts: ExecuteOptions,
1778        pipe_stdin: crate::scheduler::PipeReader,
1779        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1780    ) -> Result<ExecResult> {
1781        self.run_inner(input, opts, Some(pipe_stdin), Some(on_output)).await
1782    }
1783
1784    /// Execute kaish source code with a transient overlay of exported variables.
1785    ///
1786    /// Deprecated thin wrapper over [`Self::execute_with_options`]. New code
1787    /// should use that method directly:
1788    /// `execute_with_options(input, ExecuteOptions::new().with_vars(vars))`.
1789    #[deprecated(note = "use Kernel::execute_with_options with ExecuteOptions::with_vars")]
1790    pub async fn execute_with_vars(
1791        &self,
1792        input: &str,
1793        vars: HashMap<String, Value>,
1794    ) -> Result<ExecResult> {
1795        self.run_inner(input, ExecuteOptions::new().with_vars(vars), None, None).await
1796    }
1797
1798    /// Execute kaish source code with a per-statement callback.
1799    ///
1800    /// Deprecated thin wrapper. New code should use
1801    /// [`Self::execute_with_options_streaming`].
1802    #[deprecated(note = "use Kernel::execute_with_options_streaming")]
1803    pub async fn execute_streaming(
1804        &self,
1805        input: &str,
1806        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1807    ) -> Result<ExecResult> {
1808        self.run_inner(input, ExecuteOptions::default(), None, Some(on_output)).await
1809    }
1810
1811    /// Link embedder trace context, then run [`Self::execute_with_options_inner`].
1812    ///
1813    /// The `#[instrument]` execution span resolves its parent from the *current*
1814    /// OpenTelemetry context (see `tracing-opentelemetry`'s `parent_context`),
1815    /// captured when the span is first entered — not when the future is
1816    /// constructed. So a thread-local `attach()` scoped to construction is too
1817    /// early to be seen (the integration test confirms this). `with_context`
1818    /// re-attaches the embedder's context on *every* poll of the inner future,
1819    /// so the context is current at first-enter and survives runtime thread
1820    /// hops. With no embedder trace context, the future runs unwrapped.
1821    async fn run_inner(
1822        &self,
1823        input: &str,
1824        opts: ExecuteOptions,
1825        pipe_stdin: Option<crate::scheduler::PipeReader>,
1826        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1827    ) -> Result<ExecResult> {
1828        use opentelemetry::context::FutureExt;
1829
1830        // Capture the embedder's baggage before `opts` is consumed so it can be
1831        // echoed back onto the result on egress (see `merge_egress_baggage`).
1832        let embedder_baggage = opts.baggage.clone();
1833
1834        let result = match crate::telemetry::extract_parent(&opts) {
1835            Some(parent) => self
1836                .execute_with_options_inner(input, opts, pipe_stdin, on_output)
1837                .with_context(parent)
1838                .await,
1839            None => self.execute_with_options_inner(input, opts, pipe_stdin, on_output).await,
1840        };
1841
1842        result.map(|mut r| {
1843            crate::telemetry::merge_egress_baggage(&mut r, embedder_baggage);
1844            r
1845        })
1846    }
1847
1848    /// Shared body for `execute`, `execute_with_options(_streaming)`, and
1849    /// the deprecated wrappers. Owns the per-call cancel token, vars overlay,
1850    /// cwd override, and timeout race.
1851    #[tracing::instrument(level = "info", skip(self, opts, pipe_stdin, on_output), fields(input_len = input.len()))]
1852    async fn execute_with_options_inner(
1853        &self,
1854        input: &str,
1855        opts: ExecuteOptions,
1856        pipe_stdin: Option<crate::scheduler::PipeReader>,
1857        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1858    ) -> Result<ExecResult> {
1859        let _guard = self.acquire_execute_lock().await;
1860
1861        // Always reset to a fresh internal token; this is the kernel's own
1862        // cancel surface for embedders calling `Kernel::cancel()`. The
1863        // embedder-supplied `opts.cancel_token` is a *read-only input* — it
1864        // is NOT written into `self.cancel_token`, because doing so would
1865        // (a) leak the embedder's token past this call's lifetime,
1866        // (b) re-route a later `Kernel::cancel()` into the embedder's token,
1867        // (c) extend the token's lifetime via the kernel's strong clone.
1868        let internal = self.reset_cancel();
1869
1870        // Install the per-call polled interrupt for `is_cancelled()` to
1871        // consult. The guard clears it on every exit path — a stale check
1872        // must not outlive its call and fire into a later one.
1873        struct ClearInterrupt<'a>(&'a Kernel);
1874        impl Drop for ClearInterrupt<'_> {
1875            fn drop(&mut self) {
1876                if let Ok(mut slot) = self.0.interrupt.lock() {
1877                    *slot = None;
1878                }
1879            }
1880        }
1881        {
1882            #[allow(clippy::expect_used)]
1883            let mut slot = self.interrupt.lock().expect("interrupt poisoned");
1884            *slot = opts.interrupt.clone();
1885        }
1886        let _interrupt_guard = ClearInterrupt(self);
1887
1888        // Race the embedder token against the kernel's internal token via a
1889        // tracked watcher task. We hold the JoinHandle so we can abort the
1890        // task at function exit — otherwise it would wait forever for either
1891        // token to fire and leak per call.
1892        let (effective_cancel, watcher_handle): (
1893            tokio_util::sync::CancellationToken,
1894            Option<tokio::task::JoinHandle<()>>,
1895        ) = if let Some(ext) = opts.cancel_token {
1896            let combined = tokio_util::sync::CancellationToken::new();
1897            let combined_writer = combined.clone();
1898            let i = internal.clone();
1899            let handle = tokio::spawn(async move {
1900                tokio::select! {
1901                    _ = i.cancelled() => combined_writer.cancel(),
1902                    _ = ext.cancelled() => combined_writer.cancel(),
1903                }
1904            });
1905            (combined, Some(handle))
1906        } else {
1907            (internal, None)
1908        };
1909
1910        // Effective timeout: per-call wins over kernel-config default.
1911        let timeout = opts.timeout.or(self.request_timeout);
1912
1913        // ZERO timeout: return 124 immediately without spawning anything.
1914        if timeout == Some(Duration::ZERO) {
1915            if let Some(h) = watcher_handle {
1916                h.abort();
1917            }
1918            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1919        }
1920
1921        // Apply per-call vars overlay (push frame + set_exported), wrapped in
1922        // an RAII guard so a panic inside `execute_streaming_inner` still
1923        // pops the frame and unexports the temporarily-exported names.
1924        struct VarsFrameGuard<'a> {
1925            kernel: &'a Kernel,
1926            newly_exported: Vec<String>,
1927        }
1928        impl Drop for VarsFrameGuard<'_> {
1929            fn drop(&mut self) {
1930                // Best-effort cleanup using try_write. The execute_lock held
1931                // throughout execute_with_options means there is no concurrent
1932                // foreground caller; forks have their own scope and won't
1933                // block this. blocking_write would deadlock the runtime when
1934                // called from a tokio worker thread, so we explicitly do NOT
1935                // fall back to it — if try_write fails (which we've never
1936                // seen in practice), log loudly and accept the leak rather
1937                // than deadlock the entire kernel.
1938                let Ok(mut scope) = self.kernel.scope.try_write() else {
1939                    tracing::error!(
1940                        "vars frame guard: scope lock unexpectedly busy; \
1941                         skipping pop_frame to avoid runtime deadlock — \
1942                         transient vars may leak"
1943                    );
1944                    return;
1945                };
1946                scope.pop_frame();
1947                for name in self.newly_exported.drain(..) {
1948                    scope.unexport(&name);
1949                }
1950            }
1951        }
1952
1953        // Per-call cwd override: save current cwd, set the new one, restore
1954        // on Drop so the kernel's persistent cwd doesn't leak between calls.
1955        // Same RAII pattern as VarsFrameGuard, same blocking_write trade-off.
1956        struct CwdGuard<'a> {
1957            kernel: &'a Kernel,
1958            saved: PathBuf,
1959        }
1960        impl Drop for CwdGuard<'_> {
1961            fn drop(&mut self) {
1962                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1963                    tracing::error!(
1964                        "cwd guard: exec_ctx lock unexpectedly busy; \
1965                         skipping cwd restore — kernel cwd may be wrong for next call"
1966                    );
1967                    return;
1968                };
1969                ec.cwd = std::mem::take(&mut self.saved);
1970            }
1971        }
1972        let _cwd_guard: Option<CwdGuard<'_>> = if let Some(new_cwd) = opts.cwd {
1973            let mut ec = self.exec_ctx.write().await;
1974            let saved = std::mem::replace(&mut ec.cwd, new_cwd);
1975            drop(ec);
1976            Some(CwdGuard { kernel: self, saved })
1977        } else {
1978            None
1979        };
1980
1981        // Per-call stdin: seed the persistent exec_ctx so the first top-level
1982        // command that reads stdin consumes it (it's `take()`n at dispatch).
1983        // Restore the prior value on Drop — normally `None`, so this also drops
1984        // any residual seed an stdin-less program never consumed, keeping it
1985        // from bleeding into the next call. Same RAII pattern as CwdGuard.
1986        struct StdinGuard<'a> {
1987            kernel: &'a Kernel,
1988            saved: Option<Vec<u8>>,
1989        }
1990        impl Drop for StdinGuard<'_> {
1991            fn drop(&mut self) {
1992                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1993                    tracing::error!(
1994                        "stdin guard: exec_ctx lock unexpectedly busy; \
1995                         skipping stdin restore — stale stdin may leak to next call"
1996                    );
1997                    return;
1998                };
1999                ec.stdin = self.saved.take();
2000            }
2001        }
2002        let _stdin_guard: Option<StdinGuard<'_>> = if let Some(stdin) = opts.stdin {
2003            let mut ec = self.exec_ctx.write().await;
2004            let saved = ec.stdin.replace(stdin);
2005            drop(ec);
2006            Some(StdinGuard { kernel: self, saved })
2007        } else {
2008            None
2009        };
2010
2011        // Per-call *lazy* stdin: a frontend-supplied `PipeReader` seeds the
2012        // persistent exec_ctx so the first stdin-reading command drains it (it's
2013        // `take()`n at pipeline build). The RAII guard restores the prior value
2014        // on Drop (normally `None`), so an unread reader doesn't bleed into the
2015        // next call. Mirrors `StdinGuard`; the reader is non-Clone, so it moves.
2016        struct PipeStdinGuard<'a> {
2017            kernel: &'a Kernel,
2018            saved: Option<crate::scheduler::PipeReader>,
2019        }
2020        impl Drop for PipeStdinGuard<'_> {
2021            fn drop(&mut self) {
2022                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2023                    tracing::error!(
2024                        "pipe stdin guard: exec_ctx lock unexpectedly busy; \
2025                         skipping restore — stale pipe stdin may leak to next call"
2026                    );
2027                    return;
2028                };
2029                ec.pipe_stdin = self.saved.take();
2030            }
2031        }
2032        let _pipe_stdin_guard: Option<PipeStdinGuard<'_>> = if let Some(reader) = pipe_stdin {
2033            let mut ec = self.exec_ctx.write().await;
2034            let saved = ec.pipe_stdin.replace(reader);
2035            drop(ec);
2036            Some(PipeStdinGuard { kernel: self, saved })
2037        } else {
2038            None
2039        };
2040
2041        let _vars_guard: Option<VarsFrameGuard<'_>> = if !opts.vars.is_empty() {
2042            let mut scope = self.scope.write().await;
2043            scope.push_frame();
2044            let mut newly = Vec::with_capacity(opts.vars.len());
2045            for (name, value) in opts.vars {
2046                if !scope.is_exported(&name) {
2047                    newly.push(name.clone());
2048                }
2049                scope.set_exported(name, value);
2050            }
2051            drop(scope);
2052            Some(VarsFrameGuard { kernel: self, newly_exported: newly })
2053        } else {
2054            None
2055        };
2056
2057        // Sync the effective cancel into self.exec_ctx so try_execute_external
2058        // (which reads via self.cancel_token) sees cancellation. We also need
2059        // builtins to see it via ctx.cancel — handled in execute_command.
2060        // For simplicity here we mirror effective_cancel into self.cancel_token
2061        // for the duration of this call, then restore the internal token at
2062        // the end (so a later Kernel::cancel still hits our internal surface).
2063        {
2064            #[allow(clippy::expect_used)]
2065            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2066            *cur = effective_cancel.clone();
2067        }
2068
2069        // Run the script under the movable-deadline watchdog (shared with the
2070        // argv door). The watchdog task cancels `effective_cancel` on an elapsed
2071        // deadline; the cascade fires SIGTERM/SIGKILL on any external children via
2072        // the wait_or_kill discipline in try_execute_external. `Some(ZERO)` was
2073        // already handled by the early return above.
2074        let mut noop_cb: Box<dyn FnMut(&ExecResult) + Send> = Box::new(|_| {});
2075        let cb_ref: &mut (dyn FnMut(&ExecResult) + Send) = match on_output {
2076            Some(cb) => cb,
2077            None => &mut *noop_cb,
2078        };
2079
2080        let result = self
2081            .run_under_watchdog(timeout, &effective_cancel, self.execute_streaming_inner(input, cb_ref))
2082            .await;
2083
2084        // Restore self.cancel_token to a fresh, uncancelled token so the
2085        // embedder's view of `Kernel::cancel()` stays predictable on the
2086        // next call (it cancels the kernel's own token, not whatever was
2087        // left over from this call's combined token).
2088        {
2089            #[allow(clippy::expect_used)]
2090            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2091            *cur = tokio_util::sync::CancellationToken::new();
2092        }
2093
2094        // Tear down the embedder-token race watcher (if any). Leaving it
2095        // alive would idle forever waiting for tokens that may never fire.
2096        if let Some(h) = watcher_handle {
2097            h.abort();
2098        }
2099
2100        // VarsFrameGuard drops here on the success path and on early-return
2101        // paths above (error path included). Panic safety preserved.
2102        result
2103    }
2104
2105    /// The actual body of `execute_streaming`, run while holding the execute lock.
2106    ///
2107    /// Split out so internal kernel paths that are already under the lock can
2108    /// call this without deadlocking on re-entry. External callers must go
2109    /// through [`Self::execute_streaming`] so they acquire the lock.
2110    async fn execute_streaming_inner(
2111        &self,
2112        input: &str,
2113        on_output: &mut (dyn FnMut(&ExecResult) + Send),
2114    ) -> Result<ExecResult> {
2115        let program = parse(input).map_err(|errors| {
2116            let msg = errors
2117                .iter()
2118                .map(|e| e.format(input))
2119                .collect::<Vec<_>>()
2120                .join("\n");
2121            anyhow::anyhow!("parse error:\n{}", msg)
2122        })?;
2123
2124        // AST display mode: show AST instead of executing
2125        {
2126            let scope = self.scope.read().await;
2127            if scope.show_ast() {
2128                let output = format!("{:#?}\n", program);
2129                return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
2130            }
2131        }
2132
2133        // Pre-execution validation. Most warnings stay trace-only (every
2134        // external command fires an `UndefinedCommand` warning), but a warning
2135        // whose code opts into agent surfacing is collected here and prepended
2136        // to the result's stderr at each return point below.
2137        let mut surfaced_warnings = String::new();
2138        if !self.skip_validation {
2139            let user_tools = self.user_tools.read().await;
2140            let validator = Validator::new(&self.tools, &user_tools);
2141            let issues = validator.validate(&program);
2142
2143            // Collect errors (warnings are logged but don't prevent execution)
2144            let errors: Vec<_> = issues
2145                .iter()
2146                .filter(|i| i.severity == Severity::Error)
2147                .collect();
2148
2149            if !errors.is_empty() {
2150                let error_msg = errors
2151                    .iter()
2152                    .map(|e| e.format(input))
2153                    .collect::<Vec<_>>()
2154                    .join("\n");
2155                return Err(anyhow::anyhow!("validation failed:\n{}", error_msg));
2156            }
2157
2158            // Log warnings via tracing (trace level to avoid noise); surface the
2159            // opted-in ones to the agent so the guidance is actually seen.
2160            for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
2161                tracing::trace!("validation: {}", warning.format(input));
2162                if warning.code.surfaces_to_agent() {
2163                    surfaced_warnings.push_str(&warning.format(input));
2164                    surfaced_warnings.push('\n');
2165                }
2166            }
2167        }
2168
2169        // Surface opted-in validation warnings to the streaming frontend once,
2170        // before any command output. The streaming consumer (`-c`, REPL) prints
2171        // per `on_output` and ignores the returned aggregate err; non-streaming
2172        // callers (`kernel.execute`) use a noop callback and read the aggregate
2173        // `result.err` (prepended at each return below). The two paths are
2174        // disjoint, so this prints the advisory exactly once on each.
2175        if !surfaced_warnings.is_empty() {
2176            let mut advisory = ExecResult::success("");
2177            advisory.err = surfaced_warnings.clone();
2178            on_output(&advisory);
2179        }
2180
2181        let mut result = ExecResult::success("");
2182
2183        // Reset cancellation token for this execution.
2184        let cancel = self.reset_cancel();
2185
2186        for stmt in program.statements.into_iter() {
2187            if matches!(stmt, Stmt::Empty) {
2188                continue;
2189            }
2190
2191            // Cancellation checkpoint
2192            if cancel.is_cancelled() {
2193                result.code = 130;
2194                return Ok(result);
2195            }
2196
2197            // The statement tap and gate (spec §C.6) — one of exactly two
2198            // sites. It runs before `execute_stmt_flow`, so a held statement
2199            // has run *nothing*: no substitution, no redirect opened, no
2200            let flow_result = self.execute_stmt_flow(&stmt).await;
2201            let flow = flow_result?;
2202
2203            // Drain any stderr written by pipeline stages during this statement.
2204            // This captures stderr from intermediate pipeline stages that would
2205            // otherwise be lost (only the last stage's result is returned).
2206            let drained_stderr = {
2207                let mut receiver = self.stderr_receiver.lock().await;
2208                receiver.drain_lossy()
2209            };
2210
2211            match flow {
2212                ControlFlow::Normal(mut r) => {
2213                    if !drained_stderr.is_empty() {
2214                        if !r.err.is_empty() && !r.err.ends_with('\n') {
2215                            r.err.push('\n');
2216                        }
2217                        // Prepend pipeline stderr before the last stage's stderr
2218                        let combined = format!("{}{}", drained_stderr, r.err);
2219                        r.err = combined;
2220                    }
2221                    on_output(&r);
2222                    // Carry the last statement's structured output for MCP TOON encoding.
2223                    // Must be done here (not in accumulate_result) because accumulate_result
2224                    // is also used in loops where per-iteration output would be wrong.
2225                    let last_output = r.output().cloned();
2226                    accumulate_result(&mut result, &r);
2227                    result.set_output(last_output);
2228                }
2229                ControlFlow::Exit { code } => {
2230                    if !drained_stderr.is_empty() {
2231                        result.err.push_str(&drained_stderr);
2232                    }
2233                    result.code = code;
2234                    if !surfaced_warnings.is_empty() {
2235                        result.err = format!("{surfaced_warnings}{}", result.err);
2236                    }
2237                    return Ok(result);
2238                }
2239                ControlFlow::Return { mut value } => {
2240                    if !drained_stderr.is_empty() {
2241                        value.err = format!("{}{}", drained_stderr, value.err);
2242                    }
2243                    on_output(&value);
2244                    // A top-level `return` stops the script, like `exit` —
2245                    // it must not discard prior statements' accumulated
2246                    // output nor let execution continue past it.
2247                    accumulate_result(&mut result, &value);
2248                    if !surfaced_warnings.is_empty() {
2249                        result.err = format!("{surfaced_warnings}{}", result.err);
2250                    }
2251                    return Ok(result);
2252                }
2253                ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2254                    if !drained_stderr.is_empty() {
2255                        r.err = format!("{}{}", drained_stderr, r.err);
2256                    }
2257                    on_output(&r);
2258                    accumulate_result(&mut result, &r);
2259                }
2260            }
2261        }
2262
2263        if !surfaced_warnings.is_empty() {
2264            result.err = format!("{surfaced_warnings}{}", result.err);
2265        }
2266        Ok(result)
2267    }
2268
2269    /// Execute a single statement, returning control flow information.
2270    fn execute_stmt_flow<'a>(
2271        &'a self,
2272        stmt: &'a Stmt,
2273    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2274        // No per-statement span here: `execute_stmt_flow` is the largest future
2275        // on the recursion ring, and wrapping it in `Instrumented<Span>` carries
2276        // the span's state through every `.await` at every level, costing native
2277        // stack per level (GH #48). Coarser spans on the outer execute entries
2278        // remain. See item 3 of the #48 burndown.
2279        Box::pin(async move {
2280        match stmt {
2281            Stmt::Assignment(assign) => {
2282                // Use async evaluator to support command substitution
2283                let value = self.eval_expr_async(&assign.value).await
2284                    .context("failed to evaluate assignment")?;
2285                let mut scope = self.scope.write().await;
2286                if assign.path.segments.len() == 1 {
2287                    // Plain `NAME=value` — no subscript, so `local` applies.
2288                    if assign.local {
2289                        // local: set in innermost (current function) frame
2290                        scope.set(assign.name(), value.clone());
2291                    } else {
2292                        // non-local: update existing or create in root frame
2293                        scope.set_global(assign.name(), value.clone());
2294                    }
2295                } else {
2296                    // Subscripted lvalue (`xs[0]=v`, `user[email]=v`, …): always
2297                    // mutates the existing root wherever it lives, so `local`
2298                    // has nothing to declare. See docs/LANGUAGE.md,
2299                    // "Assignment — bracket-path lvalues".
2300                    scope.walk_write(&assign.path, value.clone()).map_err(|e| match e {
2301                        PathError::UndefinedRoot(name) => anyhow::anyhow!(
2302                            "{name}: undefined — create it first, e.g. `{name}={{}}` or `{name}=[]`"
2303                        ),
2304                        PathError::Absence(msg) | PathError::Shape(msg) => anyhow::anyhow!(msg),
2305                    })?;
2306                }
2307                drop(scope);
2308
2309                // Assignments don't produce output (like sh)
2310                Ok(ControlFlow::ok(ExecResult::success("")))
2311            }
2312            Stmt::Command(cmd) => {
2313                // Route single commands through execute_pipeline for a unified path.
2314                // This ensures all commands go through the dispatcher chain.
2315                let pipeline = crate::ast::Pipeline {
2316                    commands: vec![cmd.clone()],
2317                    background: false,
2318                };
2319                let result = Box::pin(self.execute_pipeline(&pipeline)).await?;
2320                self.update_last_result(&result).await;
2321
2322                // Check for error exit mode (set -e)
2323                if !result.ok() {
2324                    let scope = self.scope.read().await;
2325                    if scope.error_exit_enabled() {
2326                        return Ok(ControlFlow::exit_code(result.code));
2327                    }
2328                }
2329
2330                Ok(ControlFlow::ok(result))
2331            }
2332            Stmt::Pipeline(pipeline) => {
2333                let result = Box::pin(self.execute_pipeline(pipeline)).await?;
2334                self.update_last_result(&result).await;
2335
2336                // Check for error exit mode (set -e)
2337                if !result.ok() {
2338                    let scope = self.scope.read().await;
2339                    if scope.error_exit_enabled() {
2340                        return Ok(ControlFlow::exit_code(result.code));
2341                    }
2342                }
2343
2344                Ok(ControlFlow::ok(result))
2345            }
2346            Stmt::If(if_stmt) => {
2347                // Use async evaluator to support command substitution in conditions
2348                let cond_value = self.eval_expr_async(&if_stmt.condition).await?;
2349
2350                let branch = if is_truthy(&cond_value) {
2351                    &if_stmt.then_branch
2352                } else {
2353                    if_stmt.else_branch.as_deref().unwrap_or(&[])
2354                };
2355
2356                let mut result = ExecResult::success("");
2357                for stmt in branch {
2358                    let flow = self.execute_stmt_flow(stmt).await?;
2359                    match flow {
2360                        ControlFlow::Normal(r) => {
2361                            accumulate_result(&mut result, &r);
2362                            self.drain_stderr_into(&mut result).await;
2363                        }
2364                        other => {
2365                            self.drain_stderr_into(&mut result).await;
2366                            return Ok(other);
2367                        }
2368                    }
2369                }
2370                Ok(ControlFlow::ok(result))
2371            }
2372            Stmt::For(for_loop) => {
2373                // Evaluate all items and collect values for iteration
2374                // Use async evaluator to support command substitution like $(seq 1 5)
2375                let mut items: Vec<Value> = Vec::new();
2376                for item_expr in &for_loop.items {
2377                    // Glob expansion in for-loop items: `for f in *.txt`
2378                    if let Expr::GlobPattern(pattern) = item_expr {
2379                        let glob_enabled = {
2380                            let scope = self.scope.read().await;
2381                            scope.glob_enabled()
2382                        };
2383                        if glob_enabled {
2384                            let (paths, cwd) = {
2385                                let ctx = self.exec_ctx.read().await;
2386                                let paths = ctx.expand_glob(pattern).await
2387                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2388                                let cwd = ctx.resolve_path(".");
2389                                (paths, cwd)
2390                            };
2391                            if paths.is_empty() {
2392                                return Err(anyhow::anyhow!("no matches: {}", pattern));
2393                            }
2394                            for path in paths {
2395                                let display = if !pattern.starts_with('/') {
2396                                    path.strip_prefix(&cwd)
2397                                        .unwrap_or(&path)
2398                                        .to_string_lossy().into_owned()
2399                                } else {
2400                                    path.to_string_lossy().into_owned()
2401                                };
2402                                items.push(Value::String(display));
2403                            }
2404                            continue;
2405                        }
2406                    }
2407                    // Track whether this item came from $(cmd); that's the
2408                    // only position where multi-line stdout auto-splits per
2409                    // line. Arrays still spread element-by-element; bare
2410                    // $VAR is rejected upstream by validator E012. See
2411                    // docs/LANGUAGE.md.
2412                    let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2413                    let item = self.eval_expr_async(item_expr).await?;
2414                    match item {
2415                        // JSON arrays iterate over elements (preferred path
2416                        // when builtins emit .data — seq, jq, cut, find, …)
2417                        Value::Json(serde_json::Value::Array(arr)) => {
2418                            for elem in arr {
2419                                // Envelope-free: an element that happens to be
2420                                // envelope-shaped (e.g. from `fromjson`) is
2421                                // external data, not an internal bytes round-trip,
2422                                // so it must NOT be re-decoded to Value::Bytes.
2423                                items.push(json_to_value_no_envelope(elem));
2424                            }
2425                        }
2426                        // Strings from $(cmd): empty → 0 iterations,
2427                        // multi-line → split per line (trimming trailing
2428                        // newlines and per-line trailing \r), single-line
2429                        // → one iteration. Whitespace within a line is
2430                        // NOT split — the "$VAR with spaces just works"
2431                        // promise is preserved because this only fires
2432                        // in CommandSubst position.
2433                        Value::String(s) if from_command_subst => {
2434                            let trimmed = s.trim_end_matches(['\n', '\r']);
2435                            if trimmed.is_empty() {
2436                                continue;
2437                            }
2438                            if trimmed.contains('\n') {
2439                                for line in trimmed.split('\n') {
2440                                    let line = line.trim_end_matches('\r');
2441                                    items.push(Value::String(line.to_string()));
2442                                }
2443                            } else {
2444                                items.push(Value::String(trimmed.to_string()));
2445                            }
2446                        }
2447                        // Binary isn't iterable — fail loud rather than loop
2448                        // once over an opaque byte blob.
2449                        Value::Bytes(_) => {
2450                            anyhow::bail!(
2451                                "for: cannot iterate over binary data — decode it \
2452                                 (base64/xxd) first"
2453                            );
2454                        }
2455                        // Strings not from $(cmd) stay as one value.
2456                        other => items.push(other),
2457                    }
2458                }
2459
2460                let mut result = ExecResult::success("");
2461                {
2462                    let mut scope = self.scope.write().await;
2463                    scope.push_frame();
2464                }
2465
2466                'outer: for item in items {
2467                    // Cancellation checkpoint per iteration
2468                    if self.is_cancelled() {
2469                        let mut scope = self.scope.write().await;
2470                        scope.pop_frame();
2471                        result.code = 130;
2472                        return Ok(ControlFlow::ok(result));
2473                    }
2474                    {
2475                        let mut scope = self.scope.write().await;
2476                        scope.set(&for_loop.variable, item);
2477                    }
2478                    for stmt in &for_loop.body {
2479                        let mut flow = match self.execute_stmt_flow(stmt).await {
2480                            Ok(f) => f,
2481                            Err(e) => {
2482                                let mut scope = self.scope.write().await;
2483                                scope.pop_frame();
2484                                return Err(e);
2485                            }
2486                        };
2487                        self.drain_stderr_into(&mut result).await;
2488                        match &mut flow {
2489                            ControlFlow::Normal(r) => {
2490                                accumulate_result(&mut result, r);
2491                                if !r.ok() {
2492                                    let scope = self.scope.read().await;
2493                                    if scope.error_exit_enabled() {
2494                                        drop(scope);
2495                                        let mut scope = self.scope.write().await;
2496                                        scope.pop_frame();
2497                                        return Ok(ControlFlow::exit_code(r.code));
2498                                    }
2499                                }
2500                            }
2501                            ControlFlow::Break { .. } => {
2502                                if flow.decrement_level() {
2503                                    accumulate_flow_output(&mut result, &flow);
2504                                    break 'outer;
2505                                }
2506                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2507                                let mut scope = self.scope.write().await;
2508                                scope.pop_frame();
2509                                return Ok(flow);
2510                            }
2511                            ControlFlow::Continue { .. } => {
2512                                if flow.decrement_level() {
2513                                    accumulate_flow_output(&mut result, &flow);
2514                                    continue 'outer;
2515                                }
2516                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2517                                let mut scope = self.scope.write().await;
2518                                scope.pop_frame();
2519                                return Ok(flow);
2520                            }
2521                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2522                                let mut scope = self.scope.write().await;
2523                                scope.pop_frame();
2524                                return Ok(flow);
2525                            }
2526                        }
2527                    }
2528                }
2529
2530                {
2531                    let mut scope = self.scope.write().await;
2532                    scope.pop_frame();
2533                }
2534                Ok(ControlFlow::ok(result))
2535            }
2536            Stmt::While(while_loop) => {
2537                let mut result = ExecResult::success("");
2538
2539                'outer: loop {
2540                    // Evaluate condition - use async to support command substitution
2541                    // Cancellation checkpoint per iteration
2542                    if self.is_cancelled() {
2543                        result.code = 130;
2544                        return Ok(ControlFlow::ok(result));
2545                    }
2546
2547                    let cond_value = self.eval_expr_async(&while_loop.condition).await?;
2548
2549                    if !is_truthy(&cond_value) {
2550                        break;
2551                    }
2552
2553                    // Execute body
2554                    for stmt in &while_loop.body {
2555                        let mut flow = self.execute_stmt_flow(stmt).await?;
2556                        self.drain_stderr_into(&mut result).await;
2557                        match &mut flow {
2558                            ControlFlow::Normal(r) => {
2559                                accumulate_result(&mut result, r);
2560                                if !r.ok() {
2561                                    let scope = self.scope.read().await;
2562                                    if scope.error_exit_enabled() {
2563                                        return Ok(ControlFlow::exit_code(r.code));
2564                                    }
2565                                }
2566                            }
2567                            ControlFlow::Break { .. } => {
2568                                if flow.decrement_level() {
2569                                    accumulate_flow_output(&mut result, &flow);
2570                                    break 'outer;
2571                                }
2572                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2573                                return Ok(flow);
2574                            }
2575                            ControlFlow::Continue { .. } => {
2576                                if flow.decrement_level() {
2577                                    accumulate_flow_output(&mut result, &flow);
2578                                    continue 'outer;
2579                                }
2580                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2581                                return Ok(flow);
2582                            }
2583                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2584                                return Ok(flow);
2585                            }
2586                        }
2587                    }
2588                }
2589
2590                Ok(ControlFlow::ok(result))
2591            }
2592            Stmt::Case(case_stmt) => {
2593                // Evaluate the expression to match against. Text sink: a
2594                // `case $bin in ...)` pattern match on binary goes loud
2595                // rather than glob-matching against the `[binary: N bytes]`
2596                // placeholder (Decision E — same class as `==`/`in`).
2597                let match_value = {
2598                    let value = self.eval_expr_async(&case_stmt.expr).await?;
2599                    value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?
2600                };
2601
2602                // Try each branch until we find a match
2603                for branch in &case_stmt.branches {
2604                    let matched = branch.patterns.iter().any(|pattern| {
2605                        glob_match(pattern, &match_value)
2606                    });
2607
2608                    if matched {
2609                        // Execute the branch body
2610                        let mut result = ExecResult::success("");
2611                        for stmt in &branch.body {
2612                            let flow = self.execute_stmt_flow(stmt).await?;
2613                            match flow {
2614                                ControlFlow::Normal(r) => {
2615                                    accumulate_result(&mut result, &r);
2616                                    self.drain_stderr_into(&mut result).await;
2617                                }
2618                                other => {
2619                                    self.drain_stderr_into(&mut result).await;
2620                                    return Ok(other);
2621                                }
2622                            }
2623                        }
2624                        return Ok(ControlFlow::ok(result));
2625                    }
2626                }
2627
2628                // No match - return success with empty output (like sh)
2629                Ok(ControlFlow::ok(ExecResult::success("")))
2630            }
2631            Stmt::Break(levels) => {
2632                Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2633            }
2634            Stmt::Continue(levels) => {
2635                Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2636            }
2637            Stmt::Return(expr) => {
2638                // return [N] - N becomes the exit code, NOT stdout
2639                // Shell semantics: return sets exit code, doesn't produce output
2640                let result = if let Some(e) = expr {
2641                    let val = self.eval_expr_async(e).await?;
2642                    let code = crate::interpreter::value_to_exit_code(&val)
2643                        .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2644                    ExecResult::from_parts(code, String::new(), String::new(), None)
2645                } else {
2646                    ExecResult::success("")
2647                };
2648                Ok(ControlFlow::return_value(result))
2649            }
2650            Stmt::Exit(expr) => {
2651                let code = if let Some(e) = expr {
2652                    let val = self.eval_expr_async(e).await?;
2653                    crate::interpreter::value_to_exit_code(&val)
2654                        .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2655                } else {
2656                    0
2657                };
2658                Ok(ControlFlow::exit_code(code))
2659            }
2660            Stmt::ToolDef(tool_def) => {
2661                let mut user_tools = self.user_tools.write().await;
2662                user_tools.insert(tool_def.name.clone(), tool_def.clone());
2663                Ok(ControlFlow::ok(ExecResult::success("")))
2664            }
2665            Stmt::AndChain { left, right } => {
2666                // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2667                // Suppress errexit for the left side — && handles failure itself.
2668                {
2669                    let mut scope = self.scope.write().await;
2670                    scope.suppress_errexit();
2671                }
2672                let left_flow = match self.execute_stmt_flow(left).await {
2673                    Ok(f) => f,
2674                    Err(e) => {
2675                        let mut scope = self.scope.write().await;
2676                        scope.unsuppress_errexit();
2677                        return Err(e);
2678                    }
2679                };
2680                {
2681                    let mut scope = self.scope.write().await;
2682                    scope.unsuppress_errexit();
2683                }
2684                match left_flow {
2685                    ControlFlow::Normal(mut left_result) => {
2686                        self.drain_stderr_into(&mut left_result).await;
2687                        self.update_last_result(&left_result).await;
2688                        // Pending is not failure (spec §I.5) — see the
2689                        // `OrChain` twin. The stash check matters here for a
2690                        // hold swallowed into an apparent success below.
2691                        if left_result.ok() {
2692                            let right_flow = self.execute_stmt_flow(right).await?;
2693                            match right_flow {
2694                                ControlFlow::Normal(mut right_result) => {
2695                                    self.drain_stderr_into(&mut right_result).await;
2696                                    self.update_last_result(&right_result).await;
2697                                    let mut combined = left_result;
2698                                    accumulate_result(&mut combined, &right_result);
2699                                    Ok(ControlFlow::ok(combined))
2700                                }
2701                                other => Ok(other),
2702                            }
2703                        } else {
2704                            Ok(ControlFlow::ok(left_result))
2705                        }
2706                    }
2707                    _ => Ok(left_flow),
2708                }
2709            }
2710            Stmt::OrChain { left, right } => {
2711                // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
2712                // Suppress errexit for the left side — || handles failure itself.
2713                {
2714                    let mut scope = self.scope.write().await;
2715                    scope.suppress_errexit();
2716                }
2717                let left_flow = match self.execute_stmt_flow(left).await {
2718                    Ok(f) => f,
2719                    Err(e) => {
2720                        let mut scope = self.scope.write().await;
2721                        scope.unsuppress_errexit();
2722                        return Err(e);
2723                    }
2724                };
2725                {
2726                    let mut scope = self.scope.write().await;
2727                    scope.unsuppress_errexit();
2728                }
2729                match left_flow {
2730                    ControlFlow::Normal(mut left_result) => {
2731                        self.drain_stderr_into(&mut left_result).await;
2732                        self.update_last_result(&left_result).await;
2733                        // Pending is not failure (spec §I.5): a fallback
2734                        // written for failure must not run on a decision
2735                        // nobody has made yet — and running it would also
2736                        // overwrite the request in the accumulated result.
2737                        // The stash check covers a hold whose typed error a
2738                        // layer below already stringified out of the result.
2739                        // On a stash-based hold the returned `left_result` is
2740                        // that stringified failure, not the held result — the
2741                        // statement boundary discards it and surfaces the
2742                        // slot's result instead. Do not "fix" this by taking
2743                        // the slot here: only statement boundaries take it.
2744                        if !left_result.ok() {
2745                            let right_flow = self.execute_stmt_flow(right).await?;
2746                            match right_flow {
2747                                ControlFlow::Normal(mut right_result) => {
2748                                    self.drain_stderr_into(&mut right_result).await;
2749                                    self.update_last_result(&right_result).await;
2750                                    let mut combined = left_result;
2751                                    accumulate_result(&mut combined, &right_result);
2752                                    Ok(ControlFlow::ok(combined))
2753                                }
2754                                other => Ok(other),
2755                            }
2756                        } else {
2757                            Ok(ControlFlow::ok(left_result))
2758                        }
2759                    }
2760                    _ => Ok(left_flow), // Propagate non-normal flow
2761                }
2762            }
2763            Stmt::Test(test_expr) => {
2764                let is_true = self.eval_test_async(test_expr).await?;
2765                let result = if is_true {
2766                    ExecResult::success("")
2767                } else {
2768                    ExecResult::failure(1, "")
2769                };
2770                // A bare test writes `$?` and honors `set -e` like any command
2771                // (bash: `[[ 1 = 2 ]]; echo $?` → 1). `&&`/`||` operands stay
2772                // safe: the chain arms suppress errexit around their left side,
2773                // and `if`/`while` conditions evaluate as expressions, never
2774                // through this statement arm.
2775                self.update_last_result(&result).await;
2776                if !result.ok() {
2777                    let scope = self.scope.read().await;
2778                    if scope.error_exit_enabled() {
2779                        return Ok(ControlFlow::exit_code(result.code));
2780                    }
2781                }
2782                Ok(ControlFlow::ok(result))
2783            }
2784            Stmt::EnvScoped { assignments, body } => {
2785                // Inline env prefix (`NAME=value ... command`): apply the
2786                // assignments as EXPORTED vars in a fresh frame so the command
2787                // — and its subprocess environment — sees them, then unwind so
2788                // they do NOT persist (bash-style command-scoped env). Values
2789                // evaluate left-to-right with earlier ones already in scope, so
2790                // `A=1 B=$A cmd` works.
2791                {
2792                    let mut scope = self.scope.write().await;
2793                    scope.push_frame();
2794                }
2795                let mut prior_export: Vec<(String, bool)> =
2796                    Vec::with_capacity(assignments.len());
2797                let mut setup_err: Option<anyhow::Error> = None;
2798                for assign in assignments {
2799                    match self.eval_expr_async(&assign.value).await {
2800                        Ok(value) => {
2801                            let mut scope = self.scope.write().await;
2802                            prior_export
2803                                .push((assign.name().to_string(), scope.is_exported(assign.name())));
2804                            scope.set_exported(assign.name(), value);
2805                        }
2806                        Err(e) => {
2807                            setup_err = Some(e);
2808                            break;
2809                        }
2810                    }
2811                }
2812
2813                let flow = if setup_err.is_none() {
2814                    self.execute_stmt_flow(body).await
2815                } else {
2816                    Ok(ControlFlow::ok(ExecResult::success("")))
2817                };
2818
2819                // Unwind the env frame and restore export marks unconditionally
2820                // (names that were not exported before must not stay exported).
2821                {
2822                    let mut scope = self.scope.write().await;
2823                    scope.pop_frame();
2824                    for (name, was_exported) in &prior_export {
2825                        if !*was_exported {
2826                            scope.unexport(name);
2827                        }
2828                    }
2829                }
2830
2831                match setup_err {
2832                    Some(e) => Err(e),
2833                    None => flow,
2834                }
2835            }
2836            Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
2837        }
2838        })
2839    }
2840
2841    /// Build a boxed per-command `ExecContext` snapshot from the persistent
2842    /// kernel state (`ec`/`scope`, both already locked by the caller).
2843    ///
2844    /// Sync on purpose: the ~30 field clones live in this transient frame rather
2845    /// than a coroutine slot, and the result is `Box`ed so only an 8-byte pointer
2846    /// — not the 960-byte struct — rides the dispatch await at every recursion
2847    /// level (GH #48, item 2). `pipeline_position` and `cancel` are the only
2848    /// per-site differences (the pipeline runner uses the kernel's own cancel
2849    /// token and forces `Only`; the per-command dispatch inherits `ec`'s), so
2850    /// they're parameters; every other field is snapshotted identically.
2851    fn snapshot_exec_ctx(
2852        &self,
2853        ec: &ExecContext,
2854        scope: &Scope,
2855        pipeline_position: PipelinePosition,
2856        cancel: tokio_util::sync::CancellationToken,
2857    ) -> Box<ExecContext> {
2858        Box::new(ExecContext {
2859            backend: ec.backend.clone(),
2860            scope: scope.clone(),
2861            cwd: ec.cwd.clone(),
2862            prev_cwd: ec.prev_cwd.clone(),
2863            stdin: ec.stdin.clone(),
2864            stdin_data: ec.stdin_data.clone(),
2865            stdin_data_rx: None,
2866            pipe_stdin: None,
2867            pipe_stdout: None,
2868            stderr: ec.stderr.clone(),
2869            tool_schemas: ec.tool_schemas.clone(),
2870            tools: ec.tools.clone(),
2871            job_manager: ec.job_manager.clone(),
2872            pipeline_position,
2873            interactive: self.interactive,
2874            // The kernel-wide setting; a snapshot inherits it like `interactive`.
2875            kill_children_on_parent_death: ec.kill_children_on_parent_death,
2876            aliases: ec.aliases.clone(),
2877            ignore_config: ec.ignore_config.clone(),
2878            output_limit: ec.output_limit.clone(),
2879            allow_external_commands: self.allow_external_commands,
2880            trash_backend: ec.trash_backend.clone(),
2881            #[cfg(all(unix, feature = "subprocess"))]
2882            terminal_state: ec.terminal_state.clone(),
2883            dispatcher: self.dispatcher(),
2884            cancel,
2885            output_format: None,
2886            vfs_budget: self.vfs_budget.clone(),
2887            watchdog: ec.watchdog.clone(),
2888            #[cfg(all(feature = "localfs", feature = "overlay"))]
2889            overlay_handle: self.overlay_handle.clone(),
2890            // Correlate this command's requests with the background job it
2891            // runs for, if any — the ONE place `job_id` is stamped.
2892            // A replay correlation belongs to exactly one dispatch. Moved
2893            // (not cloned) out of the parent context at the dispatch seam —
2894            // see the stdin hand-off below, which takes it under the same
2895            // write lock — so the gate this snapshot reaches is the only one
2896            // that can adopt it.
2897            // A forked or backgrounded execution keeps its parenthood: a
2898            // gate reached from inside a gated statement is nested under it
2899            // (spec §A.7).
2900        })
2901    }
2902
2903    /// Execute a pipeline.
2904    async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2905        if pipeline.commands.is_empty() {
2906            return Ok(ExecResult::success(""));
2907        }
2908
2909        // Handle background execution (`&` operator)
2910        if pipeline.background {
2911            return self.execute_background(pipeline).await;
2912        }
2913
2914        // All commands go through the runner with the Kernel as dispatcher.
2915        // This is the single execution path — no fast path for single commands.
2916        //
2917        // IMPORTANT: We snapshot exec_ctx into a local context and release the
2918        // lock before running. This prevents deadlocks when dispatch_command
2919        // is called from within the pipeline and recursively triggers another
2920        // pipeline (e.g., via user-defined tools).
2921        let (mut ctx, has_pipe_stdin) = {
2922            let ec = self.exec_ctx.read().await;
2923            let scope = self.scope.read().await;
2924            // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
2925            // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
2926            // the consume-once block below, so note its presence here.
2927            let has_pipe_stdin = ec.pipe_stdin.is_some();
2928            // The pipeline runner drives stage 0 with the first stage's stdin
2929            // seeded from any frontend-supplied input (`ExecuteOptions::stdin`,
2930            // e.g. `printf … | kaish -c sort`) unless a redirect already set it,
2931            // and uses the kernel's own cancel token so a `cancel()` reaches the
2932            // stages. See `snapshot_exec_ctx` for why the snapshot is boxed.
2933            let cancel = {
2934                #[allow(clippy::expect_used)]
2935                let token = self.cancel_token.lock().expect("cancel_token poisoned");
2936                token.clone()
2937            };
2938            (self.snapshot_exec_ctx(&ec, &scope, PipelinePosition::Only, cancel), has_pipe_stdin)
2939        }; // locks released
2940
2941        // Consume-once: move/clear the seeded stdin sources from the persistent
2942        // exec_ctx now that this pipeline's ctx owns them, so a later statement
2943        // in the same call (`cat ; cat`) does not re-receive them — matching
2944        // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
2945        // (the ctx above was built with `pipe_stdin: None`).
2946        if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
2947            let mut ec = self.exec_ctx.write().await;
2948            ctx.pipe_stdin = ec.pipe_stdin.take();
2949            ec.stdin = None;
2950            ec.stdin_data = None;
2951        }
2952
2953        let mut result = self.runner.run(&pipeline.commands, &mut ctx, self).await;
2954
2955        // Post-hoc spill check + exit-3 remap (catches builtins and fast
2956        // external commands; also catches a ring overflow that already
2957        // flipped `did_spill` even when the limit itself is disabled, GH
2958        // #191). This is the shared contract every execution surface must
2959        // apply — see `apply_spill_contract`'s doc comment (GH #212).
2960        crate::output_limit::apply_spill_contract(&mut result, &ctx.output_limit).await;
2961
2962        // Sync changes back from context
2963        {
2964            let mut ec = self.exec_ctx.write().await;
2965            ec.cwd = ctx.cwd.clone();
2966            ec.prev_cwd = ctx.prev_cwd.clone();
2967            ec.aliases = ctx.aliases.clone();
2968            ec.ignore_config = ctx.ignore_config.clone();
2969            ec.output_limit = ctx.output_limit.clone();
2970        }
2971        {
2972            let mut scope = self.scope.write().await;
2973            *scope = ctx.scope.clone();
2974        }
2975
2976        Ok(result)
2977    }
2978
2979    /// Execute a pipeline in the background.
2980    ///
2981    /// The command is spawned as a tokio task and registered with the
2982    /// JobManager. The job is observable via `/v/jobs/{id}/status`,
2983    /// `/v/jobs/{id}/command`, and — while it is
2984    /// still running — `/v/jobs/{id}/stdout` and `/stderr`.
2985    ///
2986    /// GH #240 removed those two nodes because they filled once, at
2987    /// completion, while the docs promised a live stream. They are back on
2988    /// the terms the docs always claimed: `try_execute_external` tees each
2989    /// 8 KiB chunk into the job's stream as the child emits it. See
2990    /// `Job::stdout_stream` for exactly which bytes reach them.
2991    ///
2992    /// Returns immediately with a job ID like "[1]".
2993    #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.commands.len()))]
2994    async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2995        use tokio::sync::oneshot;
2996
2997        // Format the command for display in /v/jobs/{id}/command
2998        let command_str = self.format_pipeline(pipeline);
2999
3000        // Create channel for result notification
3001        let (tx, rx) = oneshot::channel();
3002
3003        // Register with JobManager to get job ID and create VFS entries
3004        let job_id = self.jobs.register(command_str.clone(), rx).await;
3005
3006        // Fork the kernel for this background job. The fork snapshots the
3007        // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
3008        // while sharing the job manager, VFS, and tool registry. The fork's
3009        // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
3010        // is available here — something BackendDispatcher couldn't provide.
3011        //
3012        // The fork gets its own cancellation token (recorded on the job so
3013        // `kill %N` can stop the job — including a pure-builtin job with no OS
3014        // process group) and is stamped with the job id so any external
3015        // command it spawns records its process group for `kill -<sig> %N`.
3016        let cancel = tokio_util::sync::CancellationToken::new();
3017        self.jobs.set_cancel_token(job_id, cancel.clone()).await;
3018        let jobs = self.jobs.clone();
3019        let fork = self.fork_for_background(cancel, job_id).await;
3020        let runner = self.runner.clone();
3021        let commands = pipeline.commands.clone();
3022
3023        // Snapshot the fork's exec_ctx for the spawned task. We have to do
3024        // this before tokio::spawn because the fork's exec_ctx is behind a
3025        // tokio RwLock and we want the spawned task to own its ctx.
3026        let mut bg_ctx = {
3027            let ec = fork.exec_ctx.read().await;
3028            ec.child_for_pipeline()
3029        };
3030        bg_ctx.scope = fork.scope.read().await.clone();
3031        // The fork's dispatcher points at the fork itself; set it here so
3032        // builtins inside the background task (e.g. timeout) re-dispatch
3033        // through the fork, not the parent.
3034        bg_ctx.dispatcher = fork.dispatcher();
3035
3036        // Spawn the background task. Propagate the embedder's trace context
3037        // across the spawn boundary so the job's spans stay in the same trace.
3038        tokio::spawn(crate::telemetry::bind_current_context(async move {
3039            // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
3040            // gives us that (Kernel implements CommandDispatcher).
3041            let mut result = runner.run(&commands, &mut bg_ctx, fork.as_ref()).await;
3042
3043            // Apply the same spill/exit-3 contract the foreground path gets
3044            // (`execute_pipeline`'s `apply_spill_contract` call) — without
3045            // this, a background job whose output overflows the capture ring
3046            // or trips the output limit reports the child's ORIGINAL exit
3047            // code to JobManager, so `[N] done:0`/`Job::status()` silently
3048            // read success even though the output was capped (GH #212).
3049            crate::output_limit::apply_spill_contract(&mut result, &bg_ctx.output_limit).await;
3050
3051            // Close out `/v/jobs/{id}/stdout`/`stderr`: a stream the external
3052            // drain tasks already fed live is left alone (re-writing the
3053            // aggregate would duplicate every byte), an untouched one takes
3054            // the captured result, and both close. Before `tx.send`, so a
3055            // reader that observes a terminal `status` also observes a
3056            // finished stream — never a `done:0` job whose output is still
3057            // arriving.
3058            jobs.finalize_streams(job_id, &result).await;
3059
3060            // Send result to JobManager (ignore error if receiver dropped)
3061            let _ = tx.send(result);
3062        }));
3063
3064        Ok(ExecResult::success(format!("[{}]", job_id)))
3065    }
3066
3067    /// Format a pipeline as a command string for display.
3068    fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
3069        pipeline.commands
3070            .iter()
3071            .map(|cmd| {
3072                let mut parts = vec![cmd.name.clone()];
3073                for arg in &cmd.args {
3074                    match arg {
3075                        Arg::Positional(expr) => {
3076                            parts.push(self.format_expr(expr));
3077                        }
3078                        Arg::Named { key, value } => {
3079                            parts.push(format!("--{}={}", key, self.format_expr(value)));
3080                        }
3081                        Arg::WordAssign { key, value } => {
3082                            parts.push(format!("{}={}", key, self.format_expr(value)));
3083                        }
3084                        Arg::ShortFlag(name) => {
3085                            parts.push(format!("-{}", name));
3086                        }
3087                        Arg::LongFlag(name) => {
3088                            parts.push(format!("--{}", name));
3089                        }
3090                        Arg::DoubleDash => {
3091                            parts.push("--".to_string());
3092                        }
3093                    }
3094                }
3095                parts.join(" ")
3096            })
3097            .collect::<Vec<_>>()
3098            .join(" | ")
3099    }
3100
3101    /// Format an expression as a string for display.
3102    fn format_expr(&self, expr: &Expr) -> String {
3103        match expr {
3104            Expr::Literal(Value::String(s)) => {
3105                if s.contains(' ') || s.contains('"') {
3106                    format!("'{}'", s.replace('\'', "\\'"))
3107                } else {
3108                    s.clone()
3109                }
3110            }
3111            Expr::Literal(Value::Int(i)) => i.to_string(),
3112            Expr::Literal(Value::Float(f)) => f.to_string(),
3113            Expr::Literal(Value::Bool(b)) => b.to_string(),
3114            Expr::Literal(Value::Null) => "null".to_string(),
3115            Expr::VarRef(path) => {
3116                let mut name = String::new();
3117                for (i, seg) in path.segments.iter().enumerate() {
3118                    match seg {
3119                        crate::ast::VarSegment::Field(f) => {
3120                            if i > 0 {
3121                                name.push('.');
3122                            }
3123                            name.push_str(f);
3124                        }
3125                        crate::ast::VarSegment::Index(idx) => name.push_str(&format!("[{idx}]")),
3126                        crate::ast::VarSegment::Key(k) => name.push_str(&format!("[{k}]")),
3127                        crate::ast::VarSegment::Dynamic(v) => name.push_str(&format!("[${v}]")),
3128                        crate::ast::VarSegment::Slice(a, b) => name.push_str(&format!(
3129                            "[{}:{}]",
3130                            a.map(|n| n.to_string()).unwrap_or_default(),
3131                            b.map(|n| n.to_string()).unwrap_or_default()
3132                        )),
3133                    }
3134                }
3135                format!("${{{}}}", name)
3136            }
3137            Expr::Interpolated(_) => "\"...\"".to_string(),
3138            Expr::HereDocBody { .. } => "<<heredoc".to_string(),
3139            _ => "...".to_string(),
3140        }
3141    }
3142
3143    /// Execute a single command.
3144    async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
3145        self.execute_command_depth(name, args, 0).await
3146    }
3147
3148    async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
3149        // Dispatch breadcrumb instead of an `#[instrument]` span: this is the
3150        // most-recursed function on the ring, so wrapping its future in
3151        // `Instrumented<Span>` (plus the `err` recorder) cost native stack at
3152        // every level (GH #48, item 3). A `trace!` event records the command name
3153        // without living in the future.
3154        tracing::trace!(command = %name, alias_depth, "dispatch");
3155        // Special built-ins. `SpecialForm::from_name` is the single source of
3156        // truth (shared with `classify_command` via `is_runtime_special_form`),
3157        // and this match on the enum is *exhaustive* — adding a special-form is a
3158        // compile error until both the name mapping and the behavior here are
3159        // updated. A name that is not a special-form falls through to alias /
3160        // `/v/bin/` / user-tool / builtin / `PATH` resolution unchanged.
3161        if let Some(form) = crate::validator::SpecialForm::from_name(name) {
3162            return match form {
3163                crate::validator::SpecialForm::True => Ok(ExecResult::success("")),
3164                crate::validator::SpecialForm::False => Ok(ExecResult::failure(1, "")),
3165                crate::validator::SpecialForm::Source => Box::pin(self.execute_source(args)).await,
3166            };
3167        }
3168
3169        // Alias expansion (with recursion limit)
3170        if alias_depth < 10 {
3171            let alias_value = {
3172                let ctx = self.exec_ctx.read().await;
3173                ctx.aliases.get(name).cloned()
3174            };
3175            if let Some(alias_val) = alias_value {
3176                // Split alias value into command + args
3177                let parts: Vec<&str> = alias_val.split_whitespace().collect();
3178                if let Some((alias_cmd, alias_args)) = parts.split_first() {
3179                    let mut new_args: Vec<Arg> = alias_args
3180                        .iter()
3181                        .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
3182                        .collect();
3183                    new_args.extend_from_slice(args);
3184                    return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
3185                }
3186            }
3187        }
3188
3189        // Handle /v/bin/ prefix — dispatch to builtins via virtual path
3190        if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
3191            return match self.tools.get(builtin_name) {
3192                Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
3193                None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
3194            };
3195        }
3196
3197        // Check user-defined tools first
3198        {
3199            let user_tools = self.user_tools.read().await;
3200            if let Some(tool_def) = user_tools.get(name) {
3201                let tool_def = tool_def.clone();
3202                drop(user_tools);
3203                return Box::pin(self.execute_user_tool(tool_def, args)).await;
3204            }
3205        }
3206
3207        // Look up builtin tool
3208        let tool = match self.tools.get(name) {
3209            Some(t) => t,
3210            None => {
3211                // Try executing as .kai script from PATH
3212                if let Some(result) = Box::pin(self.try_execute_script(name, args)).await? {
3213                    return Ok(result);
3214                }
3215                // Try executing as external command from PATH — boxed because its
3216                // future is the heaviest branch here (holds a `tokio::process::Command`,
3217                // argv, the child's stdio streams, and kill/reap drop guards); leaving
3218                // it inline fattens every `execute_command_depth` frame on the recursion
3219                // ring even when the command is a builtin.
3220                if let Some(result) = Box::pin(self.try_execute_external(name, args)).await? {
3221                    return Ok(result);
3222                }
3223
3224                // Try backend-registered tools (embedder engines, etc.)
3225                // Look up tool schema for positional→named mapping.
3226                // Clone backend and drop read lock before awaiting (may involve network I/O).
3227                // Backend tools expect named JSON params, so enable positional mapping.
3228                let backend = self.exec_ctx.read().await.backend.clone();
3229                let tool_schema = backend
3230                    .get_tool(name)
3231                    .await
3232                    .unwrap_or_else(|e| {
3233                        // Schema lookup failing just means positionals won't
3234                        // get name-mapped below — `call_tool` is still
3235                        // attempted. Trace it so the degradation is visible
3236                        // rather than silently swallowed.
3237                        tracing::debug!("backend get_tool error for {name}: {e}");
3238                        None
3239                    })
3240                    .map(|t| {
3241                    let mut s = t.schema;
3242                    // Flat backend/MCP tools expect named JSON params, so map
3243                    // bare positionals onto named params. Subcommand-aware tools
3244                    // route positionals through the subcommand path and declare
3245                    // map_positionals per leaf (kj keeps it false so it re-parses
3246                    // the argv with its own clap) — don't blanket-override them.
3247                    if s.subcommands.is_empty() {
3248                        s.map_positionals = true;
3249                    }
3250                    s
3251                });
3252                let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
3253                let mut ctx = self.exec_ctx.write().await;
3254                {
3255                    let scope = self.scope.read().await;
3256                    ctx.scope = scope.clone();
3257                }
3258                let backend = ctx.backend.clone();
3259                match backend.call_tool(name, tool_args, &mut *ctx).await {
3260                    Ok(tool_result) => {
3261                        let mut scope = self.scope.write().await;
3262                        *scope = ctx.scope.clone();
3263                        // Preserve every field (data/content_type/baggage,
3264                        // not just stdout text) — this is the embedder seam:
3265                        // `x=$(embedder_tool)` and structured iteration over
3266                        // its result depend on `.data` surviving the crossing
3267                        // back into the kernel.
3268                        return Ok(ExecResult::from(tool_result));
3269                    }
3270                    Err(BackendError::ToolNotFound(_)) => {
3271                        // The backend confirms no such tool exists — fall
3272                        // through to "command not found" below.
3273                    }
3274                    Err(e) => {
3275                        // The tool was found (dispatch reached real
3276                        // execution) but running it failed — a genuine
3277                        // execution error, not "command not found". Surface
3278                        // it loudly instead of masking it as exit-127.
3279                        return Ok(ExecResult::failure(1, format!("{}: {}", name, e)));
3280                    }
3281                }
3282
3283                return Ok(ExecResult::failure(127, format!("command not found: {}", name)));
3284            }
3285        };
3286
3287        // Build arguments (async to support command substitution, schema-aware
3288        // for flag values), then decide `--help` and `owns_output` — all three
3289        // read the tool's schema and nothing after this block does, so the whole
3290        // schema borrow is scoped here and cannot ride the `tool.execute` await
3291        // below (GH #48, item 7).
3292        let (tool_args, wants_help, owns_output) = {
3293            // Prefer the kernel's schema catalog over `tool.schema()`: for a
3294            // clap-derived builtin, `schema()` rebuilds the entire clap
3295            // `Command` and reflects it into a fresh `ToolSchema` — ~34
3296            // allocations per command, 18% of all allocations in the GH #48
3297            // many-small-commands profile — to produce exactly what the catalog
3298            // already holds. The catalog is seeded from this same registry in
3299            // `Kernel::assemble` and is name-sorted, so this is a binary search
3300            // with no allocation at all. `owned` covers a tool the catalog
3301            // doesn't list (registered after assembly, or whose schema name
3302            // differs from its dispatch name): the fallback calls the same
3303            // `schema()` and is equivalent, just not free.
3304            let catalog = { self.exec_ctx.read().await.tool_schemas.clone() };
3305            let owned;
3306            let schema: &crate::tools::ToolSchema =
3307                match catalog.binary_search_by(|s| s.name.as_str().cmp(name)) {
3308                    Ok(i) => &catalog[i],
3309                    Err(_) => {
3310                        owned = tool.schema();
3311                        &owned
3312                    }
3313                };
3314
3315            let tool_args = self.build_args_async(args, Some(schema)).await?;
3316
3317            // --help / -h: show the generic whole-tool help, unless either the tool's
3318            // root schema claims that flag OR the tool owns its output. Owned-output
3319            // tools re-parse their own argv and route their own `--help` — including
3320            // leaf/subcommand help — through their internal (clap) parser, so the root
3321            // schema can't express "this leaf claims help" and intercepting here would
3322            // render top-level help and return before `execute()` ever sees the
3323            // request (#51). Pass it through and let the tool render its own help.
3324            let schema_claims = |flag: &str| -> bool {
3325                let bare = flag.trim_start_matches('-');
3326                schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
3327            };
3328            let wants_help = !schema.owns_output
3329                && ((tool_args.flags.contains("help") && !schema_claims("help"))
3330                    || (tool_args.flags.contains("h") && !schema_claims("-h")));
3331
3332            (tool_args, wants_help, schema.owns_output)
3333        };
3334
3335        if wants_help {
3336            let help_topic = crate::help::HelpTopic::Tool(name.to_string());
3337            let ctx = self.exec_ctx.read().await;
3338            let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
3339            return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
3340        }
3341
3342        // Snapshot exec_ctx into a local context and release the write lock
3343        // before calling tool.execute. Holding the write across tool execution
3344        // would deadlock any builtin that re-dispatches through ctx.dispatcher
3345        // (timeout, scatter) — the inner dispatch_command needs its own
3346        // exec_ctx.write() and would block forever.
3347        let mut ctx = {
3348            let ec = self.exec_ctx.write().await;
3349            let scope = self.scope.read().await;
3350            // Inherit `ec.pipeline_position` and `ec.cancel` (the latter set by
3351            // dispatch_command from the runner's ctx.cancel, so a builtin-swapped
3352            // child token — e.g. timeout's — reaches the spawned external via
3353            // wait_or_kill; it falls back to the kernel's own token on a
3354            // non-dispatch path). See `snapshot_exec_ctx` for the boxing rationale.
3355            self.snapshot_exec_ctx(&ec, &scope, ec.pipeline_position, ec.cancel.clone())
3356        }; // both locks released — tool.execute can re-dispatch safely
3357
3358        // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3359        // semantics): take() so a later dispatch doesn't see stale stdin.
3360        // Done after the snapshot above so we hold the write briefly.
3361        {
3362            let mut ec = self.exec_ctx.write().await;
3363            ctx.stdin = ec.stdin.take();
3364            ctx.stdin_data = ec.stdin_data.take();
3365            ctx.stdin_data_rx = ec.stdin_data_rx.take();
3366            ctx.pipe_stdin = ec.pipe_stdin.take();
3367            ctx.pipe_stdout = ec.pipe_stdout.take();
3368            // Same take-don't-clone discipline as stdin, and for the same
3369            // reason: these belong to exactly one dispatch, and a copy left
3370            // behind would let the next command adopt it.
3371        }
3372
3373        // Honor --json before the builtin runs so its setting survives a clap
3374        // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3375        // --json on the floor when `try_parse_from` returns Err early).
3376        // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3377        GlobalFlags::apply_from_args(&tool_args, &mut *ctx);
3378
3379        let result = tool.execute(tool_args, &mut *ctx).await;
3380
3381        // Sync mutations back. Tools may have changed scope (set/cd),
3382        // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3383        // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3384        // hands them back to the pipeline runner — the runner uses
3385        // stage_ctx.pipe_stdout to write the result to the next stage when
3386        // the tool itself didn't take and write to it.
3387        {
3388            let mut scope = self.scope.write().await;
3389            *scope = ctx.scope.clone();
3390        }
3391        {
3392            let mut ec = self.exec_ctx.write().await;
3393            ec.cwd = ctx.cwd;
3394            ec.prev_cwd = ctx.prev_cwd;
3395            ec.aliases = ctx.aliases;
3396            // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3397            // mutate the runtime output limit; without this sync the change is
3398            // dropped here and never reaches dispatch_command's read-back, so
3399            // it would not survive past the current statement.
3400            ec.output_limit = ctx.output_limit.clone();
3401            // Same for `kaish-ignore` (add/clear/defaults/scope): this field
3402            // was missing from this sync, so every runtime ignore mutation
3403            // silently died at the end of its own statement — including the
3404            // documented `kaish-ignore add .gitignore` rc-file recipe.
3405            ec.ignore_config = ctx.ignore_config.clone();
3406            ec.pipe_stdin = ctx.pipe_stdin.take();
3407            ec.pipe_stdout = ctx.pipe_stdout.take();
3408        }
3409
3410        // Builtins parse --json via the GlobalFlags flatten in their clap
3411        // struct and write ctx.output_format. The kernel applies it — unless the
3412        // tool owns its own output (renders --json itself), in which case we
3413        // leave its bytes untouched.
3414        let result = finalize_output(result, ctx.output_format, owns_output);
3415
3416        Ok(result)
3417    }
3418
3419    /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3420    /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3421    /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3422    /// unexpanded rather than leaking the host home directory.
3423    async fn scope_home(&self) -> Option<String> {
3424        match self.scope.read().await.get("HOME") {
3425            Some(Value::String(s)) => Some(s.clone()),
3426            _ => None,
3427        }
3428    }
3429
3430    /// Build tool arguments from AST args.
3431    ///
3432    /// Uses async evaluation to support command substitution in arguments.
3433    /// Delegates to the shared `bind_tool_args` core (GH #188): this method
3434    /// now only supplies the evaluator — `self` implements `ArgValueSource`
3435    /// against the kernel's own session state (full recursion through the
3436    /// async pipeline, real glob expansion, tilde expansion). Before this,
3437    /// `bind_tool_args`'s flag/positional-binding logic was duplicated by a
3438    /// reduced sync twin (`scheduler::pipeline::build_tool_args`, used by
3439    /// scatter/gather's own option parsing and the `#[cfg(test)]`
3440    /// `BackendDispatcher`) that could — and did — drift from this method,
3441    /// the same drift-class GH #133 fixed for the external-command spawn
3442    /// sites. Now both paths call the one `bind_tool_args` core, differing
3443    /// only in which `ArgValueSource` they hand it.
3444    async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3445        bind_tool_args(args, schema, self).await
3446    }
3447
3448    /// Build arguments as flat string list for external commands.
3449    ///
3450    /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3451    /// this preserves the original flag format as strings for external commands:
3452    /// - `-l` stays as `-l`
3453    /// - `--verbose` stays as `--verbose`
3454    /// - `key=value` stays as `key=value`
3455    ///
3456    /// This is what external commands expect in their argv.
3457    #[cfg(feature = "subprocess")]
3458    async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3459        let mut argv = Vec::new();
3460        let home = self.scope_home().await;
3461        for arg in args {
3462            match arg {
3463                Arg::Positional(expr) => {
3464                    // Glob expansion for external commands
3465                    if let Expr::GlobPattern(pattern) = expr {
3466                        let glob_enabled = {
3467                            let scope = self.scope.read().await;
3468                            scope.glob_enabled()
3469                        };
3470                        if glob_enabled {
3471                            let (paths, cwd) = {
3472                                let ctx = self.exec_ctx.read().await;
3473                                let paths = ctx.expand_glob(pattern).await
3474                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3475                                let cwd = ctx.resolve_path(".");
3476                                (paths, cwd)
3477                            };
3478                            if paths.is_empty() {
3479                                return Err(anyhow::anyhow!("no matches: {}", pattern));
3480                            }
3481                            for path in paths {
3482                                let display = if !pattern.starts_with('/') {
3483                                    path.strip_prefix(&cwd)
3484                                        .unwrap_or(&path)
3485                                        .to_string_lossy().into_owned()
3486                                } else {
3487                                    path.to_string_lossy().into_owned()
3488                                };
3489                                argv.push(display);
3490                            }
3491                            continue;
3492                        }
3493                    }
3494                    let value = self.eval_expr_async(expr).await?;
3495                    // Decision D: a bare collection can't cross the external
3496                    // process boundary as an argv element — refuse rather than
3497                    // silently JSON-serializing it. A quoted `"$x"` already
3498                    // reduced to a `Value::String` above (via `Expr::Interpolated`),
3499                    // so only a live, un-interpolated `$x` trips this.
3500                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &value) {
3501                        return Err(anyhow::anyhow!(msg));
3502                    }
3503                    let value = apply_tilde_expansion(value, home.as_deref());
3504                    // External-command argv is a text sink: a bare `$BIN` binary
3505                    // word goes loud, never the `[binary: N bytes]` placeholder.
3506                    argv.push(value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?);
3507                }
3508                Arg::Named { key, value } => {
3509                    let val = self.eval_expr_async(value).await?;
3510                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3511                        return Err(anyhow::anyhow!(msg));
3512                    }
3513                    let val = apply_tilde_expansion(val, home.as_deref());
3514                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3515                    argv.push(format!("--{key}={val_str}"));
3516                }
3517                Arg::WordAssign { key, value } => {
3518                    let val = self.eval_expr_async(value).await?;
3519                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3520                        return Err(anyhow::anyhow!(msg));
3521                    }
3522                    let val = apply_tilde_expansion(val, home.as_deref());
3523                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3524                    argv.push(format!("{key}={val_str}"));
3525                }
3526                Arg::ShortFlag(name) => {
3527                    // Preserve original format: -l, -la (combined flags)
3528                    argv.push(format!("-{}", name));
3529                }
3530                Arg::LongFlag(name) => {
3531                    // Preserve original format: --verbose
3532                    argv.push(format!("--{}", name));
3533                }
3534                Arg::DoubleDash => {
3535                    // Preserve the -- marker
3536                    argv.push("--".to_string());
3537                }
3538            }
3539        }
3540        Ok(argv)
3541    }
3542
3543    /// Async expression evaluator that supports command substitution.
3544    ///
3545    /// This is used for contexts where expressions may contain `$(...)` command
3546    /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
3547    fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
3548        Box::pin(async move {
3549        match expr {
3550            Expr::Literal(value) => Ok(value.clone()),
3551            Expr::VarRef(path) => {
3552                let scope = self.scope.read().await;
3553                match scope.resolve_path(path) {
3554                    Ok(v) => Ok(v),
3555                    Err(PathError::UndefinedRoot(_)) => {
3556                        Err(anyhow::anyhow!("undefined variable"))
3557                    }
3558                    Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
3559                        Err(anyhow::anyhow!(msg))
3560                    }
3561                }
3562            }
3563            Expr::Interpolated(parts) => {
3564                let mut result = String::new();
3565                for part in parts {
3566                    result.push_str(&self.eval_string_part_async(part).await?);
3567                }
3568                Ok(Value::String(result))
3569            }
3570            Expr::HereDocBody { parts, strip_tabs } => {
3571                // Assemble part-by-part so `<<-` tab stripping applies to the
3572                // literal source, not to tabs from a `$var` value (bash strips
3573                // source-line tabs before parameter expansion).
3574                let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
3575                for sp in parts {
3576                    match &sp.part {
3577                        StringPart::Literal(s) => asm.push_literal(s),
3578                        other => {
3579                            asm.push_interpolated(&self.eval_string_part_async(other).await?)
3580                        }
3581                    }
3582                }
3583                Ok(Value::String(asm.into_string()))
3584            }
3585            Expr::BinaryOp { left, op, right } => match op {
3586                BinaryOp::And => {
3587                    let left_val = self.eval_expr_async(left).await?;
3588                    if !is_truthy(&left_val) {
3589                        return Ok(left_val);
3590                    }
3591                    self.eval_expr_async(right).await
3592                }
3593                BinaryOp::Or => {
3594                    let left_val = self.eval_expr_async(left).await?;
3595                    if is_truthy(&left_val) {
3596                        return Ok(left_val);
3597                    }
3598                    self.eval_expr_async(right).await
3599                }
3600            },
3601            Expr::CommandSubst(stmts) => {
3602                // Snapshot scope, cwd, and session config before running —
3603                // only output escapes, not side effects like `cd`, variable
3604                // assignments, or config mutations (`kaish-ignore`,
3605                // `kaish-output-limit`, `alias`/`unalias`) — matching how
3606                // every other execution context (background forks, scatter
3607                // workers) already isolates mutations (GH #139).
3608                // Boxed: this ~470 B scope snapshot is held across the nested
3609                // `$(…)` recursion await below, so inlining it grows every
3610                // command-substitution level's future (GH #48, item 4).
3611                let saved_scope = Box::new(self.scope.read().await.clone());
3612                let saved_ec = {
3613                    let ec = self.exec_ctx.read().await;
3614                    (
3615                        ec.cwd.clone(),
3616                        ec.prev_cwd.clone(),
3617                        ec.aliases.clone(),
3618                        ec.ignore_config.clone(),
3619                        ec.output_limit.clone(),
3620                    )
3621                };
3622
3623                // Capture result without `?` — restore state unconditionally
3624                let run_result = self.execute_block_capturing(stmts).await;
3625
3626                // Restore scope and cwd regardless of success/failure
3627                {
3628                    let mut scope = self.scope.write().await;
3629                    *scope = *saved_scope;
3630                    if let Ok(ref r) = run_result {
3631                        scope.set_last_result(r.clone());
3632                    }
3633                }
3634                {
3635                    let mut ec = self.exec_ctx.write().await;
3636                    let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
3637                    ec.cwd = cwd;
3638                    ec.prev_cwd = prev_cwd;
3639                    ec.aliases = aliases;
3640                    ec.ignore_config = ignore_config;
3641                    ec.output_limit = output_limit;
3642                }
3643
3644                // Now propagate the error
3645                let result = run_result?;
3646
3647                // A held body stops the enclosing statement before its
3648                // missing output is used (spec §I.5) — the request rides up
3649                // as a typed error the statement loop converts back into a
3650                // held result, and is stashed for the boundary in case an
3651                // intermediate catch stringifies the error.
3652
3653                // A binary result is preserved as bytes — never lossy-decoded to
3654                // a string. No trailing-newline trim (every byte is significant).
3655                if let Some(bytes) = result.out_bytes() {
3656                    Ok(Value::Bytes(bytes.to_vec()))
3657                // Prefer structured data (enables `for i in $(cmd)` iteration)
3658                } else if let Some(data) = &result.data {
3659                    Ok(data.clone())
3660                } else if let Some(output) = result.output() {
3661                    // Flat non-text node lists (glob, ls, tree) → iterable array
3662                    if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
3663                        let items: Vec<serde_json::Value> = output.root.iter()
3664                            .map(|n| serde_json::Value::String(n.display_name().to_string()))
3665                            .collect();
3666                        Ok(Value::Json(serde_json::Value::Array(items)))
3667                    } else {
3668                        // Strip trailing newlines only (POSIX command-subst),
3669                        // not all trailing whitespace — spaces/tabs are
3670                        // significant. Use the exact same trim as the quoted
3671                        // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
3672                        // `trim_end_matches('\n')`) so bare and quoted command
3673                        // substitution agree.
3674                        Ok(Value::String(
3675                            result.text_out().trim_end_matches('\n').to_string(),
3676                        ))
3677                    }
3678                } else {
3679                    // Otherwise return stdout as single string (NO implicit splitting)
3680                    Ok(Value::String(
3681                        result.text_out().trim_end_matches('\n').to_string(),
3682                    ))
3683                }
3684            }
3685            Expr::Test(test_expr) => {
3686                Ok(Value::Bool(self.eval_test_async(test_expr).await?))
3687            }
3688            Expr::Positional(n) => {
3689                let scope = self.scope.read().await;
3690                match scope.get_positional(*n) {
3691                    Some(s) => Ok(Value::String(s.to_string())),
3692                    None => Ok(Value::String(String::new())),
3693                }
3694            }
3695            Expr::AllArgs => {
3696                let scope = self.scope.read().await;
3697                Ok(Value::String(scope.all_args().join(" ")))
3698            }
3699            Expr::ArgCount => {
3700                let scope = self.scope.read().await;
3701                Ok(Value::Int(scope.arg_count() as i64))
3702            }
3703            Expr::VarLength(path) => {
3704                let scope = self.scope.read().await;
3705                crate::interpreter::resolve_length(&scope, path)
3706                    .map(Value::Int)
3707                    .map_err(|msg| anyhow::anyhow!(msg))
3708            }
3709            Expr::VarWithDefault { path, default } => {
3710                // Resolve inside a scoped guard so the lock is released before the
3711                // recursive default evaluation.
3712                let resolved = {
3713                    let scope = self.scope.read().await;
3714                    crate::interpreter::resolve_default(&scope, path)
3715                        .map_err(|msg| anyhow::anyhow!(msg))?
3716                };
3717                match resolved {
3718                    Some(value) => Ok(value),
3719                    None => self.eval_string_parts_async(default).await.map(Value::String),
3720                }
3721            }
3722            Expr::Arithmetic(expr_str) => {
3723                let scope = self.scope.read().await;
3724                crate::arithmetic::eval_arithmetic(expr_str, &scope)
3725                    .map(Value::Int)
3726                    .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
3727            }
3728            Expr::Command(cmd) => {
3729                // Execute command and return boolean based on exit code
3730                let result = self.execute_command(&cmd.name, &cmd.args).await?;
3731                Ok(Value::Bool(result.code == 0))
3732            }
3733            Expr::LastExitCode => {
3734                let scope = self.scope.read().await;
3735                Ok(Value::Int(scope.last_result().code))
3736            }
3737            Expr::CurrentPid => {
3738                let scope = self.scope.read().await;
3739                Ok(Value::Int(scope.pid() as i64))
3740            }
3741            Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
3742            Expr::ListLiteral(elems) => {
3743                // Spread must itself be a list — a scalar/record spread is a
3744                // loud error, never silently coerced or dropped (mirrors the
3745                // sync `Evaluator::eval_list_literal`; wording shared via
3746                // `spread_non_list_message` so the two paths can't diverge).
3747                let mut out = Vec::with_capacity(elems.len());
3748                for elem in elems {
3749                    match elem {
3750                        ListElem::Item(e) => {
3751                            let value = self.eval_expr_async(e).await?;
3752                            out.push(crate::interpreter::value_to_json(&value));
3753                        }
3754                        ListElem::Spread(e) => {
3755                            let value = self.eval_expr_async(e).await?;
3756                            match value {
3757                                Value::Json(serde_json::Value::Array(items)) => out.extend(items),
3758                                other => return Err(anyhow::anyhow!(spread_non_list_message(&other))),
3759                            }
3760                        }
3761                    }
3762                }
3763                Ok(Value::Json(serde_json::Value::Array(out)))
3764            }
3765            Expr::RecordLiteral(entries) => {
3766                // Insertion order preserved (workspace serde_json has
3767                // `preserve_order`); a duplicate key keeps the last value
3768                // written, matching plain map-insert semantics.
3769                let mut map = serde_json::Map::new();
3770                for entry in entries {
3771                    let key = match &entry.key {
3772                        RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
3773                        // `{"$k": v}` resolves like any double-quoted string
3774                        // (used to silently create a literal "$k" key).
3775                        RecordKey::Interpolated(parts) => {
3776                            self.eval_string_parts_async(parts).await?
3777                        }
3778                    };
3779                    let value = self.eval_expr_async(&entry.value).await?;
3780                    map.insert(key, crate::interpreter::value_to_json(&value));
3781                }
3782                Ok(Value::Json(serde_json::Value::Object(map)))
3783            }
3784        }
3785        })
3786    }
3787
3788    /// Async helper to evaluate multiple StringParts into a single string.
3789    fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
3790        Box::pin(async move {
3791            let mut result = String::new();
3792            for part in parts {
3793                result.push_str(&self.eval_string_part_async(part).await?);
3794            }
3795            Ok(result)
3796        })
3797    }
3798
3799    /// Async helper to evaluate a StringPart.
3800    /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
3801    /// through the VFS backend instead of using raw `std::path`.
3802    fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
3803        Box::pin(async move {
3804            match test_expr {
3805                TestExpr::FileTest { op, path } => {
3806                    let path_value = self.eval_expr_async(path).await?;
3807                    // Expand `~` against the session HOME before stat'ing, the
3808                    // same way argv positionals do — otherwise `[[ -f ~/x ]]`
3809                    // stats the literal `~/x` and is always false.
3810                    let home = self.scope_home().await;
3811                    let path_value = apply_tilde_expansion(path_value, home.as_deref());
3812                    // A binary `[[ -f $bin ]]` operand goes loud rather than
3813                    // silently stat'ing a file literally named
3814                    // `[binary: N bytes]` (the same path-positional guard
3815                    // builtins like `stat`/`cp` use).
3816                    let path_str = crate::interpreter::value_to_text_sink_named(&path_value, "a path")
3817                        .map_err(|e| anyhow::anyhow!("{e}"))?;
3818                    // Resolve against the *session* cwd, not the process cwd, so a
3819                    // relative `[[ -f rel ]]` honors `cd` and agrees with the
3820                    // VFS-aware `test` builtin (GH #101). Backend stats a raw
3821                    // relative path against the process cwd otherwise.
3822                    let (resolved, backend) = {
3823                        let ctx = self.exec_ctx.read().await;
3824                        (ctx.resolve_path(&path_str), ctx.backend.clone())
3825                    };
3826                    let entry = backend.stat(&resolved).await.ok();
3827                    Ok(match op {
3828                        FileTestOp::Exists => entry.is_some(),
3829                        FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
3830                        FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
3831                        FileTestOp::Readable => entry.is_some(),
3832                        FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
3833                            e.permissions.is_none_or(|p| p & 0o222 != 0)
3834                        }),
3835                        FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
3836                            e.permissions.is_some_and(|p| p & 0o111 != 0)
3837                        }),
3838                    })
3839                }
3840                TestExpr::StringTest { op, value } => match op {
3841                    crate::ast::StringTestOp::IsEmpty | crate::ast::StringTestOp::IsNonEmpty => {
3842                        let val = self.eval_expr_async(value).await?;
3843                        // Decision E: a collection operand is a loud Shape error
3844                        // here too — must not diverge from the sync path in
3845                        // interpreter/eval.rs (shared `scalar_test_operand_error`).
3846                        let symbol = match op {
3847                            crate::ast::StringTestOp::IsEmpty => "-z",
3848                            crate::ast::StringTestOp::IsNonEmpty => "-n",
3849                            crate::ast::StringTestOp::IsList
3850                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
3851                        };
3852                        if let Some(msg) = crate::interpreter::scalar_test_operand_error(symbol, &val) {
3853                            anyhow::bail!(msg);
3854                        }
3855                        let s = value_to_string(&val);
3856                        Ok(match op {
3857                            crate::ast::StringTestOp::IsEmpty => s.is_empty(),
3858                            crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
3859                            crate::ast::StringTestOp::IsList
3860                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
3861                        })
3862                    }
3863                    // Shape guard: propagates eval errors like -z/-n (a bare
3864                    // `$unset` is an undefined-variable error, not a silent
3865                    // false). A defined-but-wrong-shaped value is false. Must
3866                    // not diverge from the sync path in interpreter/eval.rs.
3867                    crate::ast::StringTestOp::IsList | crate::ast::StringTestOp::IsRecord => {
3868                        let val = self.eval_expr_async(value).await?;
3869                        Ok(op.matches_shape(&val))
3870                    }
3871                },
3872                TestExpr::Comparison { left, op, right } => {
3873                    // Evaluate operands async (handles $(cmd)), then compare sync
3874                    let left_val = self.eval_expr_async(left).await?;
3875                    let right_val = self.eval_expr_async(right).await?;
3876                    let resolved = TestExpr::Comparison {
3877                        left: Box::new(Expr::Literal(left_val)),
3878                        op: *op,
3879                        right: Box::new(Expr::Literal(right_val)),
3880                    };
3881                    let expr = Expr::Test(Box::new(resolved));
3882                    let mut scope = self.scope.write().await;
3883                    let value = eval_expr(&expr, &mut scope)
3884                        .map_err(|e| anyhow::anyhow!("{}", e))?;
3885                    Ok(value_to_bool(&value))
3886                }
3887                TestExpr::And { left, right } => {
3888                    if !self.eval_test_async(left).await? {
3889                        Ok(false)
3890                    } else {
3891                        self.eval_test_async(right).await
3892                    }
3893                }
3894                TestExpr::Or { left, right } => {
3895                    if self.eval_test_async(left).await? {
3896                        Ok(true)
3897                    } else {
3898                        self.eval_test_async(right).await
3899                    }
3900                }
3901                TestExpr::Not { expr } => {
3902                    Ok(!self.eval_test_async(expr).await?)
3903                }
3904                TestExpr::In { left, right } => {
3905                    let left_val = self.eval_expr_async(left).await?;
3906                    let right_val = self.eval_expr_async(right).await?;
3907                    let resolved = TestExpr::In {
3908                        left: Box::new(Expr::Literal(left_val)),
3909                        right: Box::new(Expr::Literal(right_val)),
3910                    };
3911                    let expr = Expr::Test(Box::new(resolved));
3912                    let mut scope = self.scope.write().await;
3913                    let value = eval_expr(&expr, &mut scope)
3914                        .map_err(|e| anyhow::anyhow!("{}", e))?;
3915                    Ok(value_to_bool(&value))
3916                }
3917                TestExpr::NotIn { left, right } => {
3918                    let left_val = self.eval_expr_async(left).await?;
3919                    let right_val = self.eval_expr_async(right).await?;
3920                    let resolved = TestExpr::NotIn {
3921                        left: Box::new(Expr::Literal(left_val)),
3922                        right: Box::new(Expr::Literal(right_val)),
3923                    };
3924                    let expr = Expr::Test(Box::new(resolved));
3925                    let mut scope = self.scope.write().await;
3926                    let value = eval_expr(&expr, &mut scope)
3927                        .map_err(|e| anyhow::anyhow!("{}", e))?;
3928                    Ok(value_to_bool(&value))
3929                }
3930            }
3931        })
3932    }
3933
3934    fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
3935        Box::pin(async move {
3936            match part {
3937                StringPart::Literal(s) => Ok(s.clone()),
3938                StringPart::Var(path) => {
3939                    let scope = self.scope.read().await;
3940                    match scope.resolve_path(path) {
3941                        // Text sink: binary goes loud, never the placeholder —
3942                        // a `b=$(cat blob)` capture holds real bytes; splicing
3943                        // `[binary: N bytes]` into "$b" would be silent loss.
3944                        Ok(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
3945                        // Unset vars expand to empty; loud path errors surface.
3946                        Err(PathError::UndefinedRoot(_)) => Ok(String::new()),
3947                        Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
3948                            Err(anyhow::anyhow!(msg))
3949                        }
3950                    }
3951                }
3952                StringPart::VarWithDefault { path, default } => {
3953                    let resolved = {
3954                        let scope = self.scope.read().await;
3955                        crate::interpreter::resolve_default(&scope, path)
3956                            .map_err(|msg| anyhow::anyhow!(msg))?
3957                    };
3958                    match resolved {
3959                        Some(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
3960                        None => self.eval_string_parts_async(default).await,
3961                    }
3962                }
3963            StringPart::VarLength(path) => {
3964                let scope = self.scope.read().await;
3965                crate::interpreter::resolve_length(&scope, path)
3966                    .map(|n| n.to_string())
3967                    .map_err(|msg| anyhow::anyhow!(msg))
3968            }
3969            StringPart::Positional(n) => {
3970                let scope = self.scope.read().await;
3971                match scope.get_positional(*n) {
3972                    Some(s) => Ok(s.to_string()),
3973                    None => Ok(String::new()),
3974                }
3975            }
3976            StringPart::AllArgs => {
3977                let scope = self.scope.read().await;
3978                Ok(scope.all_args().join(" "))
3979            }
3980            StringPart::ArgCount => {
3981                let scope = self.scope.read().await;
3982                Ok(scope.arg_count().to_string())
3983            }
3984            StringPart::Arithmetic(expr) => {
3985                // Loud on purpose (GH #183): this used to be `Err(_) =>
3986                // Ok(String::new())`, silently splicing in "" for e.g.
3987                // `"$((1/0))"` — `echo "value: $((1/0))"` printed "value: "
3988                // at exit 0 instead of failing. Matches the bare (non-string)
3989                // `Expr::Arithmetic` arm above, which already propagates.
3990                let scope = self.scope.read().await;
3991                crate::arithmetic::eval_arithmetic(expr, &scope)
3992                    .map(|value| value.to_string())
3993                    .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
3994            }
3995            StringPart::CommandSubst(stmts) => {
3996                // Snapshot scope, cwd, and session config — command
3997                // substitution in strings must not leak side effects (e.g.,
3998                // `"dir: $(cd /; pwd)"` must not change cwd, and
3999                // `"$(kaish-ignore clear)"` must not change the session's
4000                // ignore config) — matching how every other execution
4001                // context (background forks, scatter workers) already
4002                // isolates mutations (GH #139).
4003                // Boxed: this ~470 B scope snapshot is held across the nested
4004                // `$(…)` recursion await below, so inlining it grows every
4005                // command-substitution level's future (GH #48, item 4).
4006                let saved_scope = Box::new(self.scope.read().await.clone());
4007                let saved_ec = {
4008                    let ec = self.exec_ctx.read().await;
4009                    (
4010                        ec.cwd.clone(),
4011                        ec.prev_cwd.clone(),
4012                        ec.aliases.clone(),
4013                        ec.ignore_config.clone(),
4014                        ec.output_limit.clone(),
4015                    )
4016                };
4017
4018                // Capture result without `?` — restore state unconditionally
4019                let run_result = self.execute_block_capturing(stmts).await;
4020
4021                // Restore scope and cwd regardless of success/failure
4022                {
4023                    let mut scope = self.scope.write().await;
4024                    *scope = *saved_scope;
4025                    if let Ok(ref r) = run_result {
4026                        scope.set_last_result(r.clone());
4027                    }
4028                }
4029                {
4030                    let mut ec = self.exec_ctx.write().await;
4031                    let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4032                    ec.cwd = cwd;
4033                    ec.prev_cwd = prev_cwd;
4034                    ec.aliases = aliases;
4035                    ec.ignore_config = ignore_config;
4036                    ec.output_limit = output_limit;
4037                }
4038
4039                // Now propagate the error
4040                let result = run_result?;
4041
4042                // A held body stops the enclosing statement before its
4043                // missing output is spliced in (spec §I.5) — same conversion
4044                // and stash as the bare `$(…)` arm.
4045
4046                // Embedding binary into a string is a text context: fail loud
4047                // rather than splice in U+FFFD garbage.
4048                match result.try_text_out() {
4049                    // Text wins when present — unchanged behavior.
4050                    Ok(s) if !s.is_empty() => Ok(s.trim_end_matches('\n').to_string()),
4051                    // `.out` is empty: a builtin/tool that set only structured
4052                    // `.data` must not silently evaporate to "" (SILENT DATA
4053                    // LOSS). Render it the same way a bare `"$x"`
4054                    // collection-valued variable renders — compact JSON for
4055                    // lists/records, plain form for scalars — by reusing
4056                    // `value_to_string` (the exact `StringPart::Var` helper
4057                    // above) so `"$(cmd)"` and `x=$(cmd); "$x"` display
4058                    // identically. No trailing-newline trim here: that's a
4059                    // text-path artifact, not applicable to a freshly
4060                    // rendered JSON/scalar string.
4061                    Ok(_) => match &result.data {
4062                        Some(data) => Ok(value_to_string(data)),
4063                        None => Ok(String::new()),
4064                    },
4065                    Err(e) => anyhow::bail!(
4066                        "command substitution in a string produced binary data ({e}) — \
4067                         pipe through base64/xxd"
4068                    ),
4069                }
4070            }
4071            StringPart::LastExitCode => {
4072                let scope = self.scope.read().await;
4073                Ok(scope.last_result().code.to_string())
4074            }
4075            StringPart::CurrentPid => {
4076                let scope = self.scope.read().await;
4077                Ok(scope.pid().to_string())
4078            }
4079        }
4080        })
4081    }
4082
4083    /// Update the last result in scope.
4084    async fn update_last_result(&self, result: &ExecResult) {
4085        let mut scope = self.scope.write().await;
4086        scope.set_last_result(result.clone());
4087    }
4088
4089    /// Drain accumulated pipeline stderr into a result.
4090    ///
4091    /// Called after each sub-statement inside control structures (`if`, `for`,
4092    /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
4093    /// than batching until the entire structure finishes.
4094    async fn drain_stderr_into(&self, result: &mut ExecResult) {
4095        let drained = {
4096            let mut receiver = self.stderr_receiver.lock().await;
4097            receiver.drain_lossy()
4098        };
4099        if !drained.is_empty() {
4100            if !result.err.is_empty() && !result.err.ends_with('\n') {
4101                result.err.push('\n');
4102            }
4103            result.err.push_str(&drained);
4104        }
4105    }
4106
4107    /// Execute a user-defined function with local variable scoping.
4108    ///
4109    /// Functions push a new scope frame for local variables. Variables declared
4110    /// with `local` are scoped to the function; other assignments modify outer
4111    /// scopes (or create in root if new).
4112    async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4113        let _depth = self.enter_recursion("a shell function")?;
4114
4115        // 1. Build function args from AST args (async to support command substitution)
4116        let tool_args = self.build_args_async(args, None).await?;
4117
4118        // 2. Push a new scope frame for local variables
4119        {
4120            let mut scope = self.scope.write().await;
4121            scope.push_frame();
4122        }
4123
4124        // 3. Save current positional parameters and set new ones for this function
4125        let saved_positional = {
4126            let mut scope = self.scope.write().await;
4127            let saved = scope.save_positional();
4128
4129            // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4130            let positional_args: Vec<String> = tool_args.positional
4131                .iter()
4132                .map(value_to_string)
4133                .collect();
4134            scope.set_positional(&def.name, positional_args);
4135
4136            saved
4137        };
4138
4139        // 3. Execute body statements with control flow handling
4140        // Accumulate output across statements (like sh)
4141        // Accumulate stdout as raw bytes so a binary-producing statement in a
4142        // function body survives instead of being lossy-decoded here.
4143        let mut accumulated_out: Vec<u8> = Vec::new();
4144        let mut accumulated_err = String::new();
4145        let mut last_code = 0i64;
4146        let mut last_data: Option<Value> = None;
4147
4148        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4149            match r.out_bytes() {
4150                Some(b) => buf.extend_from_slice(b),
4151                None => buf.extend_from_slice(r.text_out().as_bytes()),
4152            }
4153        }
4154
4155        // Track execution error for propagation after cleanup
4156        let mut exec_error: Option<anyhow::Error> = None;
4157        let mut exit_code: Option<i64> = None;
4158
4159        for stmt in &def.body {
4160            match self.execute_stmt_flow(stmt).await {
4161                Ok(flow) => {
4162                    // Drain pipeline stderr after each sub-statement.
4163                    let drained = {
4164                        let mut receiver = self.stderr_receiver.lock().await;
4165                        receiver.drain_lossy()
4166                    };
4167                    if !drained.is_empty() {
4168                        accumulated_err.push_str(&drained);
4169                    }
4170
4171                    match flow {
4172                        ControlFlow::Normal(r) => {
4173                            push_out(&mut accumulated_out, &r);
4174                            accumulated_err.push_str(&r.err);
4175                            last_code = r.code;
4176                            last_data = r.data;
4177                        }
4178                        ControlFlow::Return { value } => {
4179                            push_out(&mut accumulated_out, &value);
4180                            accumulated_err.push_str(&value.err);
4181                            last_code = value.code;
4182                            last_data = value.data;
4183                            break;
4184                        }
4185                        ControlFlow::Exit { code } => {
4186                            exit_code = Some(code);
4187                            break;
4188                        }
4189                        ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4190                            push_out(&mut accumulated_out, &r);
4191                            accumulated_err.push_str(&r.err);
4192                            last_code = r.code;
4193                            last_data = r.data;
4194                        }
4195                    }
4196                }
4197                Err(e) => {
4198                    exec_error = Some(e);
4199                    break;
4200                }
4201            }
4202        }
4203
4204        // 4. Pop scope frame and restore original positional parameters (unconditionally)
4205        {
4206            let mut scope = self.scope.write().await;
4207            scope.pop_frame();
4208            scope.set_positional(saved_positional.0, saved_positional.1);
4209        }
4210
4211        // 5. Propagate error or exit after cleanup
4212        if let Some(e) = exec_error {
4213            return Err(e);
4214        }
4215        let code = exit_code.unwrap_or(last_code);
4216        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4217        result.err = accumulated_err;
4218        result.data = last_data;
4219        Ok(result)
4220    }
4221
4222    fn enter_recursion(&self, what: &str) -> Result<RecursionGuard<'_>> {
4223        let depth = self.recursion_depth.fetch_add(1, Ordering::Relaxed) + 1;
4224        let guard = RecursionGuard { counter: &self.recursion_depth };
4225        if depth > MAX_RECURSION_DEPTH {
4226            return Err(anyhow::anyhow!(
4227                "maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded in {what} — \
4228                 a runaway or mutually recursive script (deeply nested $(…), or \
4229                 functions/scripts that call each other without a base case) was \
4230                 stopped before it could overflow the stack"
4231            ));
4232        }
4233        Ok(guard)
4234    }
4235
4236    async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4237        let _depth = self.enter_recursion("command substitution")?;
4238        // Accumulate stdout as raw bytes so a binary-producing statement
4239        // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4240        // caller can preserve it. The final result is text iff valid UTF-8.
4241        let mut accumulated_out: Vec<u8> = Vec::new();
4242        let mut accumulated_err = String::new();
4243        let mut last_code = 0i64;
4244        let mut last_data: Option<Value> = None;
4245
4246        // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4247        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4248            match r.out_bytes() {
4249                Some(b) => buf.extend_from_slice(b),
4250                None => buf.extend_from_slice(r.text_out().as_bytes()),
4251            }
4252        }
4253
4254        for stmt in stmts {
4255            let flow = self.execute_stmt_flow(stmt).await?;
4256
4257            // Drain pipeline stderr after each sub-statement (incremental, like
4258            // the control-structure and function-body executors).
4259            let drained = {
4260                let mut receiver = self.stderr_receiver.lock().await;
4261                receiver.drain_lossy()
4262            };
4263            if !drained.is_empty() {
4264                accumulated_err.push_str(&drained);
4265            }
4266
4267            match flow {
4268                ControlFlow::Normal(r)
4269                | ControlFlow::Break { result: r, .. }
4270                | ControlFlow::Continue { result: r, .. } => {
4271                    push_out(&mut accumulated_out, &r);
4272                    accumulated_err.push_str(&r.err);
4273                    last_code = r.code;
4274                    last_data = r.data;
4275                }
4276                ControlFlow::Return { value } => {
4277                    push_out(&mut accumulated_out, &value);
4278                    accumulated_err.push_str(&value.err);
4279                    last_code = value.code;
4280                    last_data = value.data;
4281                    break;
4282                }
4283                ControlFlow::Exit { code } => {
4284                    last_code = code;
4285                    break;
4286                }
4287            }
4288        }
4289
4290        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4291        result.err = accumulated_err;
4292        result.data = last_data;
4293        Ok(result)
4294    }
4295
4296    /// Execute the `source` / `.` command to include and run a script.
4297    ///
4298    /// Unlike regular tool execution, `source` executes in the CURRENT scope,
4299    /// allowing the sourced script to set variables and modify shell state.
4300    async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
4301        // `source`/`.` is the fourth dynamic re-entry point: it runs the
4302        // sourced file's statements inline via `execute_stmt_flow`, so a file
4303        // that sources itself recurses unbounded just like a runaway function
4304        // (GH #46). It's intercepted as a special form *before* the other
4305        // guarded paths, so it needs its own guard.
4306        let _depth = self.enter_recursion("source")?;
4307
4308        // Get the file path from the first positional argument
4309        let tool_args = self.build_args_async(args, None).await?;
4310        let path = match tool_args.positional.first() {
4311            Some(Value::String(s)) => s.clone(),
4312            Some(v) => value_to_string(v),
4313            None => {
4314                return Ok(ExecResult::failure(1, "source: missing filename"));
4315            }
4316        };
4317
4318        // Resolve path relative to cwd
4319        let full_path = {
4320            let ctx = self.exec_ctx.read().await;
4321            if path.starts_with('/') {
4322                std::path::PathBuf::from(&path)
4323            } else {
4324                ctx.cwd.join(&path)
4325            }
4326        };
4327
4328        // Read file content via backend
4329        let content = {
4330            let ctx = self.exec_ctx.read().await;
4331            match ctx.backend.read(&full_path, None).await {
4332                Ok(bytes) => {
4333                    String::from_utf8(bytes).map_err(|e| {
4334                        anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
4335                    })?
4336                }
4337                Err(e) => {
4338                    return Ok(ExecResult::failure(
4339                        1,
4340                        format!("source: {}: {}", path, e),
4341                    ));
4342                }
4343            }
4344        };
4345
4346        // Parse the content
4347        let program = match crate::parser::parse(&content) {
4348            Ok(p) => p,
4349            Err(errors) => {
4350                let msg = errors
4351                    .iter()
4352                    .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
4353                    .collect::<Vec<_>>()
4354                    .join("\n");
4355                return Ok(ExecResult::failure(1, format!("source: {}", msg)));
4356            }
4357        };
4358
4359        // Execute each statement in the CURRENT scope (not isolated), accumulating
4360        // stdout/stderr across statements like `execute_user_tool` — a sourced
4361        // script's earlier statements must not be silently dropped in favor of
4362        // just the last one.
4363        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4364            match r.out_bytes() {
4365                Some(b) => buf.extend_from_slice(b),
4366                None => buf.extend_from_slice(r.text_out().as_bytes()),
4367            }
4368        }
4369
4370        let mut accumulated_out: Vec<u8> = Vec::new();
4371        let mut accumulated_err = String::new();
4372        let mut last_code = 0i64;
4373        let mut last_data: Option<Value> = None;
4374
4375        for stmt in program.statements {
4376            if matches!(stmt, crate::ast::Stmt::Empty) {
4377                continue;
4378            }
4379
4380            match self.execute_stmt_flow(&stmt).await {
4381                Ok(flow) => {
4382                    let drained = {
4383                        let mut receiver = self.stderr_receiver.lock().await;
4384                        receiver.drain_lossy()
4385                    };
4386                    if !drained.is_empty() {
4387                        accumulated_err.push_str(&drained);
4388                    }
4389                    match flow {
4390                        ControlFlow::Normal(r) => {
4391                            push_out(&mut accumulated_out, &r);
4392                            accumulated_err.push_str(&r.err);
4393                            last_code = r.code;
4394                            last_data = r.data.clone();
4395                            self.update_last_result(&r).await;
4396                        }
4397                        ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
4398                            return Err(anyhow::anyhow!(
4399                                "source: {}: unexpected break/continue outside loop",
4400                                path
4401                            ));
4402                        }
4403                        ControlFlow::Return { value } => {
4404                            push_out(&mut accumulated_out, &value);
4405                            accumulated_err.push_str(&value.err);
4406                            let mut result = ExecResult::success_text_or_bytes(accumulated_out)
4407                                .with_code(value.code);
4408                            result.err = accumulated_err;
4409                            result.data = value.data;
4410                            return Ok(result);
4411                        }
4412                        ControlFlow::Exit { code } => {
4413                            let mut result =
4414                                ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4415                            result.err = accumulated_err;
4416                            result.data = last_data;
4417                            return Ok(result);
4418                        }
4419                    }
4420                }
4421                Err(e) => {
4422                    return Err(e.context(format!("source: {}", path)));
4423                }
4424            }
4425        }
4426
4427        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4428        result.err = accumulated_err;
4429        result.data = last_data;
4430        Ok(result)
4431    }
4432
4433    /// Try to execute a script from PATH directories.
4434    ///
4435    /// Searches PATH for `{name}.kai` files and executes them in isolated scope
4436    /// (like user-defined tools). Returns None if no script is found.
4437    async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4438        // Held across the PATH probe *and* body execution: a `.kai` sourcing a
4439        // `.kai` re-enters here, and that nesting is what must be bounded (#46).
4440        // A non-script command pays only a transient, balanced increment during
4441        // the probe before falling through to the external path.
4442        let _depth = self.enter_recursion("a .kai script")?;
4443
4444        // Get PATH from scope (default to "/bin")
4445        let path_value = {
4446            let scope = self.scope.read().await;
4447            scope
4448                .get("PATH")
4449                .map(value_to_string)
4450                .unwrap_or_else(|| "/bin".to_string())
4451        };
4452
4453        // Search PATH directories for script
4454        for dir in path_value.split(':') {
4455            if dir.is_empty() {
4456                continue;
4457            }
4458
4459            // Build script path: {dir}/{name}.kai
4460            let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
4461
4462            // Check if script exists
4463            let exists = {
4464                let ctx = self.exec_ctx.read().await;
4465                ctx.backend.exists(&script_path).await
4466            };
4467
4468            if !exists {
4469                continue;
4470            }
4471
4472            // Read script content
4473            let content = {
4474                let ctx = self.exec_ctx.read().await;
4475                match ctx.backend.read(&script_path, None).await {
4476                    Ok(bytes) => match String::from_utf8(bytes) {
4477                        Ok(s) => s,
4478                        Err(e) => {
4479                            return Ok(Some(ExecResult::failure(
4480                                1,
4481                                format!("{}: invalid UTF-8: {}", script_path.display(), e),
4482                            )));
4483                        }
4484                    },
4485                    Err(e) => {
4486                        return Ok(Some(ExecResult::failure(
4487                            1,
4488                            format!("{}: {}", script_path.display(), e),
4489                        )));
4490                    }
4491                }
4492            };
4493
4494            // Parse the script
4495            let program = match crate::parser::parse(&content) {
4496                Ok(p) => p,
4497                Err(errors) => {
4498                    let msg = errors
4499                        .iter()
4500                        .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
4501                        .collect::<Vec<_>>()
4502                        .join("\n");
4503                    return Ok(Some(ExecResult::failure(1, msg)));
4504                }
4505            };
4506
4507            // Build tool_args from args (async for command substitution support)
4508            let tool_args = self.build_args_async(args, None).await?;
4509
4510            // Create isolated scope (like user tools). The trash rail is NOT
4511            // session state a script may shed: a `.kai` script starting from
4512            // a blank scope would otherwise overwrite and delete without the
4513            // recovery net `set -o trash` promised. Carry it.
4514            let mut isolated_scope = Scope::new();
4515            {
4516                let scope = self.scope.read().await;
4517                isolated_scope.set_pid(scope.pid());
4518                isolated_scope.set_trash_enabled(scope.trash_enabled());
4519                isolated_scope.set_trash_max_size(scope.trash_max_size());
4520            }
4521
4522            // Set up positional parameters ($0 = script name, $1, $2, ... = args)
4523            let positional_args: Vec<String> = tool_args.positional
4524                .iter()
4525                .map(value_to_string)
4526                .collect();
4527            isolated_scope.set_positional(name, positional_args);
4528
4529            // Save current scope and swap with isolated scope
4530            let original_scope = {
4531                let mut scope = self.scope.write().await;
4532                std::mem::replace(&mut *scope, isolated_scope)
4533            };
4534
4535            // Execute script statements — accumulate stdout/stderr across
4536            // statements like `execute_user_tool`, rather than keeping only the
4537            // last one's result.
4538            fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4539                match r.out_bytes() {
4540                    Some(b) => buf.extend_from_slice(b),
4541                    None => buf.extend_from_slice(r.text_out().as_bytes()),
4542                }
4543            }
4544
4545            let mut accumulated_out: Vec<u8> = Vec::new();
4546            let mut accumulated_err = String::new();
4547            let mut last_code = 0i64;
4548            let mut last_data: Option<Value> = None;
4549            let mut exec_error: Option<anyhow::Error> = None;
4550            let mut exit_code: Option<i64> = None;
4551
4552            for stmt in program.statements {
4553                if matches!(stmt, crate::ast::Stmt::Empty) {
4554                    continue;
4555                }
4556
4557                match self.execute_stmt_flow(&stmt).await {
4558                    Ok(flow) => {
4559                        let drained = {
4560                            let mut receiver = self.stderr_receiver.lock().await;
4561                            receiver.drain_lossy()
4562                        };
4563                        if !drained.is_empty() {
4564                            accumulated_err.push_str(&drained);
4565                        }
4566                        match flow {
4567                            ControlFlow::Normal(r) => {
4568                                push_out(&mut accumulated_out, &r);
4569                                accumulated_err.push_str(&r.err);
4570                                last_code = r.code;
4571                                last_data = r.data;
4572                            }
4573                            ControlFlow::Return { value } => {
4574                                push_out(&mut accumulated_out, &value);
4575                                accumulated_err.push_str(&value.err);
4576                                last_code = value.code;
4577                                last_data = value.data;
4578                                break;
4579                            }
4580                            ControlFlow::Exit { code } => {
4581                                exit_code = Some(code);
4582                                break;
4583                            }
4584                            ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4585                                push_out(&mut accumulated_out, &r);
4586                                accumulated_err.push_str(&r.err);
4587                                last_code = r.code;
4588                                last_data = r.data;
4589                            }
4590                        }
4591                    }
4592                    Err(e) => {
4593                        exec_error = Some(e);
4594                        break;
4595                    }
4596                }
4597            }
4598
4599            // Restore original scope unconditionally
4600            {
4601                let mut scope = self.scope.write().await;
4602                *scope = original_scope;
4603            }
4604
4605            // Propagate error or exit after cleanup
4606            if let Some(e) = exec_error {
4607                return Err(e.context(format!("script: {}", script_path.display())));
4608            }
4609            let code = exit_code.unwrap_or(last_code);
4610            let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4611            result.err = accumulated_err;
4612            result.data = last_data;
4613            return Ok(Some(result));
4614        }
4615
4616        // No script found
4617        Ok(None)
4618    }
4619
4620    /// Try to execute an external command from PATH.
4621    ///
4622    /// This is the fallback when no builtin or user-defined tool matches.
4623    /// External commands receive a clean argv (flags preserved in their original format).
4624    ///
4625    /// # Requirements
4626    /// - Command must be found in PATH
4627    /// - Current working directory must be on a real filesystem (not virtual like /v)
4628    ///
4629    /// # Returns
4630    /// - `Ok(Some(result))` if command was found and executed
4631    /// - `Ok(None)` if command was not found in PATH
4632    /// - `Err` on execution errors
4633    #[cfg(not(feature = "subprocess"))]
4634    async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<Option<ExecResult>> {
4635        Ok(None)
4636    }
4637
4638    /// Try to execute an external command from PATH.
4639    #[cfg(feature = "subprocess")]
4640    #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
4641    async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4642        // Read the cancel token from `self.exec_ctx`, which `dispatch_command`
4643        // populates from the inbound ctx.cancel on every dispatch. This is
4644        // what makes the `timeout` builtin's swapped child token reach the
4645        // wait_or_kill discipline below — reading `self.cancel_token` would
4646        // give the kernel-wide token and miss the timeout's child cascade.
4647        let cancel = {
4648            let ec = self.exec_ctx.read().await;
4649            ec.cancel.clone()
4650        };
4651        let kill_grace = self.kill_grace;
4652        if !self.allow_external_commands {
4653            return Ok(None);
4654        }
4655
4656        // Get the shell's cwd and its real filesystem location, if any. A
4657        // `None` real path means the cwd is virtual (a CoW overlay, an
4658        // in-memory VFS mount, `/dev`, …) — there's nowhere for a child OS
4659        // process to run. Don't bail out here: a bare command name that isn't
4660        // in PATH at all is a genuine "not found" regardless of cwd, and the
4661        // virtual-cwd error would blame the wrong thing for that case. Once
4662        // the command actually resolves, `real_cwd` is checked again below
4663        // and the honest reason is given then (issue #181).
4664        let (cwd, real_cwd) = {
4665            let ctx = self.exec_ctx.read().await;
4666            (ctx.cwd.clone(), ctx.backend.resolve_real_path(&ctx.cwd))
4667        };
4668
4669        let executable = if name.contains('/') {
4670            // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
4671            let resolved = if std::path::Path::new(name).is_absolute() {
4672                std::path::PathBuf::from(name)
4673            } else {
4674                match &real_cwd {
4675                    Some(real_cwd) => real_cwd.join(name),
4676                    // A relative path can't be resolved without a real cwd to
4677                    // join against, so we can't even tell whether it would
4678                    // exist — name the actual blocker instead of a
4679                    // misleading "No such file or directory".
4680                    None => return Ok(Some(virtual_cwd_error(name, &cwd))),
4681                }
4682            };
4683            if !resolved.exists() {
4684                return Ok(Some(ExecResult::failure(
4685                    127,
4686                    format!("{}: No such file or directory", name),
4687                )));
4688            }
4689            if !resolved.is_file() {
4690                return Ok(Some(ExecResult::failure(
4691                    126,
4692                    format!("{}: Is a directory", name),
4693                )));
4694            }
4695            #[cfg(unix)]
4696            {
4697                use std::os::unix::fs::PermissionsExt;
4698                let mode = std::fs::metadata(&resolved)
4699                    .map(|m| m.permissions().mode())
4700                    .unwrap_or(0);
4701                if mode & 0o111 == 0 {
4702                    return Ok(Some(ExecResult::failure(
4703                        126,
4704                        format!("{}: Permission denied", name),
4705                    )));
4706                }
4707            }
4708            resolved.to_string_lossy().into_owned()
4709        } else {
4710            // Get PATH from scope only. The kernel never reads OS env: a
4711            // frontend that wants host PATH seeds it via initial_vars (the REPL
4712            // does, with os_env_vars()). No PATH in scope → nothing resolves.
4713            let path_var = {
4714                let scope = self.scope.read().await;
4715                scope.get("PATH").map(value_to_string).unwrap_or_default()
4716            };
4717
4718            // Resolve command in PATH
4719            match resolve_in_path(name, &path_var) {
4720                Some(path) => path,
4721                None => return Ok(None), // Not found - let caller handle error
4722            }
4723        };
4724
4725        // The executable resolved — found in PATH, or a path that exists and
4726        // is executable — but there's still nowhere to run it without a real
4727        // cwd to spawn the child process in.
4728        let real_cwd = match real_cwd {
4729            Some(p) => p,
4730            None => return Ok(Some(virtual_cwd_error(name, &cwd))),
4731        };
4732
4733        tracing::debug!(executable = %executable, "resolved external command");
4734
4735        // Build flat argv (preserves flag format)
4736        let argv = self.build_args_flat(args).await?;
4737
4738        // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
4739        // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
4740        // byte vector. Take both out under the lock but do NOT drain here — a
4741        // pipe read can block on its producer (a still-running upstream stage),
4742        // so draining before spawn would serialize the pipeline (deadlocking
4743        // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
4744        // `set_stdin` clears `pipe_stdin`, so a redirect-set buffer and a pipe
4745        // are mutually exclusive in practice; prefer the pipe.
4746        let (pipe_stdin, stdin_bytes) = {
4747            let mut ctx = self.exec_ctx.write().await;
4748            (ctx.pipe_stdin.take(), ctx.take_stdin())
4749        };
4750        let has_stdin = pipe_stdin.is_some() || stdin_bytes.is_some();
4751
4752        // Build and spawn the command
4753        use tokio::process::Command;
4754
4755        let mut cmd = Command::new(&executable);
4756        cmd.args(&argv);
4757        cmd.current_dir(&real_cwd);
4758
4759        // Hermetic env: child sees only kaish's exported vars, not the kaish
4760        // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
4761        // populate it via KernelConfig::initial_vars at construction.
4762        cmd.env_clear();
4763        {
4764            let scope = self.scope.read().await;
4765            let exported = scope.exported_vars();
4766            // A structured value can't cross the process boundary; refuse rather
4767            // than silently JSON-serialize it into the child's environment.
4768            if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
4769                return Err(anyhow::anyhow!(msg));
4770            }
4771            for (var_name, value) in exported {
4772                // Binary can't cross the process boundary as an env var value
4773                // either — loud, not the `[binary: N bytes]` placeholder
4774                // silently exported in its place (kept in sync with
4775                // dispatch.rs::try_external and env.rs::execute_with_env).
4776                let value_str = crate::interpreter::value_to_text_sink_named(
4777                    &value,
4778                    "an exported environment variable value",
4779                )
4780                .map_err(|e| anyhow::anyhow!("{e}"))?;
4781                cmd.env(var_name, value_str);
4782            }
4783        }
4784
4785        // Handle stdin
4786        cmd.stdin(if has_stdin {
4787            std::process::Stdio::piped()
4788        } else if self.interactive {
4789            std::process::Stdio::inherit()
4790        } else {
4791            std::process::Stdio::null()
4792        });
4793
4794        // In interactive mode, standalone or last-in-pipeline commands inherit
4795        // the terminal's stdout/stderr so output streams in real-time.
4796        // First/middle commands must capture stdout for the pipe — same as bash.
4797        let pipeline_position = {
4798            let ctx = self.exec_ctx.read().await;
4799            ctx.pipeline_position
4800        };
4801        let inherit_output = self.interactive
4802            && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
4803
4804        if inherit_output {
4805            cmd.stdout(std::process::Stdio::inherit());
4806            cmd.stderr(std::process::Stdio::inherit());
4807        } else {
4808            cmd.stdout(std::process::Stdio::piped());
4809            cmd.stderr(std::process::Stdio::piped());
4810        }
4811
4812        // On Unix, always put the child in its own process group so cancellation
4813        // can `killpg` the whole tree (the child plus any grandchildren).
4814        // Restoring default tty-related signal handlers stays gated on
4815        // job-control mode — those only matter when the child has a controlling
4816        // terminal.
4817        #[cfg(unix)]
4818        {
4819            let restore_jc_signals = self.terminal_state.is_some() && inherit_output;
4820            // Read before the fork: the child compares `getppid()` against it to
4821            // catch a parent that died inside the fork/prctl window.
4822            let kill_on_parent_death = {
4823                let ec = self.exec_ctx.read().await;
4824                ec.kill_children_on_parent_death
4825            };
4826            let parent_pid = std::process::id();
4827            // SAFETY: setpgid, prctl, getppid, and sigaction(SIG_DFL) are all
4828            // async-signal-safe per POSIX; safe to call between fork and exec.
4829            #[allow(unsafe_code)]
4830            unsafe {
4831                cmd.pre_exec(move || {
4832                    // Own process group — for kill scope.
4833                    nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
4834                        .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
4835                    if kill_on_parent_death {
4836                        crate::dispatch::arm_parent_death_signal(parent_pid)?;
4837                    }
4838                    if restore_jc_signals {
4839                        use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
4840                        let mut sa: nix::libc::sigaction = std::mem::zeroed();
4841                        sa.sa_sigaction = SIG_DFL;
4842                        if sigaction(SIGTSTP, &sa, std::ptr::null_mut()) != 0 {
4843                            return Err(std::io::Error::last_os_error());
4844                        }
4845                        if sigaction(SIGTTOU, &sa, std::ptr::null_mut()) != 0 {
4846                            return Err(std::io::Error::last_os_error());
4847                        }
4848                        if sigaction(SIGTTIN, &sa, std::ptr::null_mut()) != 0 {
4849                            return Err(std::io::Error::last_os_error());
4850                        }
4851                        if sigaction(SIGINT, &sa, std::ptr::null_mut()) != 0 {
4852                            return Err(std::io::Error::last_os_error());
4853                        }
4854                    }
4855                    Ok(())
4856                });
4857            }
4858        }
4859
4860        // Backstop for kill on drop in case our explicit kill path is bypassed
4861        // (panic, early return, etc) on the **capture** wait path. We do NOT
4862        // set this on the JC inherit path: that uses sync `waitpid` outside
4863        // tokio's view of the child, so on drop tokio would try to kill an
4864        // already-reaped (possibly-reused) PID. The JC path has its own
4865        // cancel handling via the side-task watcher.
4866        let in_jc_inherit_path = inherit_output && self.terminal_state.is_some();
4867        if !in_jc_inherit_path {
4868            cmd.kill_on_drop(true);
4869        }
4870
4871        // Spawn the process. Capture a `KillTarget` immediately so cancel/
4872        // timeout paths can deliver signals via pidfd (Linux ≥ 5.3) — bound
4873        // to this process's generation, immune to PID reuse if the OS reaps
4874        // the child before our kill syscalls fire.
4875        let mut child = match cmd.spawn() {
4876            Ok(child) => child,
4877            Err(e) => {
4878                return Ok(Some(ExecResult::failure(
4879                    127,
4880                    format!("{}: {}", name, e),
4881                )));
4882            }
4883        };
4884        let kill_target = crate::pidfd::KillTarget::from_child(&child);
4885
4886        // If this external runs on behalf of a background job, record its
4887        // process group on the job so `kill -<sig> %N` can signal the real
4888        // process directly (STOP/CONT/USR1/…, not just terminate). The child
4889        // did `setpgid(0, 0)` in pre_exec, so its PGID equals its PID.
4890        if let Some(job_id) = self.bg_job_id
4891            && let Some(pid) = child.id()
4892        {
4893            self.jobs.add_pgid(job_id, pid).await;
4894        }
4895
4896        // Same seam, for output: a background job's streams outlive this one
4897        // command, so the drain tasks below tee into them and the job closes
4898        // them itself. This is what makes `/v/jobs/{id}/stdout` grow while a
4899        // `cargo build &` is still building (GH #240 removed the node rather
4900        // than wire this tee; the tee is the half that was missing).
4901        let job_streams = match self.bg_job_id {
4902            Some(job_id) => self.jobs.streams(job_id).await,
4903            None => None,
4904        };
4905
4906        // Feed stdin. A streaming `pipe_stdin` is copied to the child by a
4907        // detached task (bounded memory, no pre-drain) so an upstream stage and
4908        // this child run concurrently — and a child that never reads stdin (or
4909        // is killed) just breaks the copy, which stops. A buffered byte vector
4910        // is written verbatim (no text detour), so binary stdin survives.
4911        let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = pipe_stdin {
4912            child.stdin.take().map(|mut child_stdin| {
4913                tokio::spawn(async move {
4914                    use tokio::io::{AsyncReadExt, AsyncWriteExt};
4915                    let mut buf = [0u8; 8192];
4916                    loop {
4917                        match pipe_in.read(&mut buf).await {
4918                            Ok(0) => break, // EOF
4919                            Ok(n) => {
4920                                if child_stdin.write_all(&buf[..n]).await.is_err() {
4921                                    break; // child closed stdin
4922                                }
4923                            }
4924                            Err(_) => break,
4925                        }
4926                    }
4927                    // Dropping child_stdin signals EOF to the child.
4928                })
4929            })
4930        } else if let Some(data) = stdin_bytes {
4931            // Write the buffered bytes from a detached task too — NOT inline.
4932            // An inline write blocks once the stdin pipe fills, and the output
4933            // drain hasn't spawned yet, so a child that emits a lot before
4934            // consuming all its input (every pipe buffer full) deadlocks. A
4935            // write error here is normal, not a failure: a child that closes
4936            // stdin early (e.g. `head`) breaks the pipe. Dropping child_stdin
4937            // signals EOF.
4938            child.stdin.take().map(|mut child_stdin| {
4939                tokio::spawn(async move {
4940                    use tokio::io::AsyncWriteExt;
4941                    let _ = child_stdin.write_all(&data).await;
4942                })
4943            })
4944        } else {
4945            None
4946        };
4947
4948        // Abort the stdin-copy task on EVERY exit path (the capture path, both
4949        // interactive `inherit_output` returns, and any early error return).
4950        // Once the child is reaped the copy has nothing left to deliver; if it
4951        // were left parked on `pipe_in.read()` it would leak and hold the
4952        // upstream pipe reader open. A drop guard is the single place that
4953        // covers all returns — explicit per-return aborts were error-prone (an
4954        // earlier version missed the two inherit_output returns).
4955        struct AbortStdinCopyOnDrop(Option<tokio::task::JoinHandle<()>>);
4956        impl Drop for AbortStdinCopyOnDrop {
4957            fn drop(&mut self) {
4958                if let Some(t) = self.0.take() {
4959                    t.abort();
4960                }
4961            }
4962        }
4963        let _stdin_copy_guard = AbortStdinCopyOnDrop(stdin_task);
4964
4965        if inherit_output {
4966            // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
4967            #[cfg(unix)]
4968            if let Some(ref term) = self.terminal_state {
4969                let child_id = child.id().unwrap_or(0);
4970                let pid = nix::unistd::Pid::from_raw(child_id as i32);
4971                let pgid = pid; // child is its own pgid leader
4972
4973                // Give the terminal to the child's process group
4974                if let Err(e) = term.give_terminal_to(pgid) {
4975                    tracing::warn!("failed to give terminal to child: {}", e);
4976                }
4977
4978                let term_clone = term.clone();
4979                let cmd_name = name.to_string();
4980                let cmd_display = format!("{} {}", name, argv.join(" "));
4981                let jobs = self.jobs.clone();
4982
4983                // Side task that watches for cancellation while the blocking
4984                // waitpid runs. On cancel, it SIGTERMs the process group, waits
4985                // the grace period, then SIGKILLs. The blocking waitpid returns
4986                // when the child dies. AbortOnDrop guard cancels the watcher
4987                // on the success path so it doesn't keep running after wait
4988                // returns naturally.
4989                //
4990                // `wait_complete` shrinks the PID-reuse race: the watcher
4991                // checks it before each kill syscall and bails out if
4992                // wait_for_foreground has already reaped the child. This
4993                // doesn't fully eliminate the race (atomic load + kill is
4994                // not atomic with the OS reap+reuse), but narrows the window
4995                // to nanoseconds — enough to be ignorable in practice.
4996                let wait_complete = std::sync::Arc::new(
4997                    std::sync::atomic::AtomicBool::new(false)
4998                );
4999                let cancel_watcher = {
5000                    let cancel = cancel.clone();
5001                    let wc = wait_complete.clone();
5002                    // Ownership transfer: the JC path's sync wait inside
5003                    // block_in_place owns the child's reaping, so the
5004                    // cancel_watcher drives the kill side via KillTarget
5005                    // (pidfd-bound on Linux). When kill_target is None
5006                    // (older kernel + open failure, or non-Linux), falls
5007                    // through to the older PID-based path the closure
5008                    // captures from `pid`.
5009                    let target = kill_target.as_ref().map(|t| {
5010                        // Re-borrow the components we need into Owned-ish form
5011                        // so the spawned task is 'static. We can't move
5012                        // KillTarget directly because try_execute_external
5013                        // still uses it after the spawn — but on the JC path
5014                        // there is no further use after the watcher spawn,
5015                        // so a clone-of-pid + owned None pidfd is safe.
5016                        // Simpler: signal via the existing target by cloning
5017                        // a fresh pidfd; the original keeps its handle.
5018                        // Pidfd is just an OwnedFd — not Clone — so do it
5019                        // by re-opening from the pid. Fall back if reopen
5020                        // fails (race already reaped → best-effort kill).
5021                        crate::pidfd::KillTarget::from_pid(t.pid())
5022                    });
5023                    tokio::spawn(async move {
5024                        cancel.cancelled().await;
5025                        if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5026                        use nix::sys::signal::Signal;
5027                        if let Some(t) = &target {
5028                            t.signal(Signal::SIGTERM);
5029                            t.signal_pg(Signal::SIGTERM);
5030                        } else {
5031                            let _ = nix::sys::signal::kill(pid, Signal::SIGTERM);
5032                            let _ = nix::sys::signal::killpg(pid, Signal::SIGTERM);
5033                        }
5034                        if kill_grace > Duration::ZERO {
5035                            tokio::time::sleep(kill_grace).await;
5036                            if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5037                        }
5038                        if let Some(t) = &target {
5039                            t.signal(Signal::SIGKILL);
5040                            t.signal_pg(Signal::SIGKILL);
5041                        } else {
5042                            let _ = nix::sys::signal::kill(pid, Signal::SIGKILL);
5043                            let _ = nix::sys::signal::killpg(pid, Signal::SIGKILL);
5044                        }
5045                    })
5046                };
5047                struct AbortOnDrop(tokio::task::JoinHandle<()>);
5048                impl Drop for AbortOnDrop {
5049                    fn drop(&mut self) {
5050                        self.0.abort();
5051                    }
5052                }
5053                let _watcher_guard = AbortOnDrop(cancel_watcher);
5054
5055                let wait_complete_setter = wait_complete.clone();
5056                let code = tokio::task::block_in_place(move || {
5057                    let result = term_clone.wait_for_foreground(pid);
5058                    // Mark wait done before the watcher might fire.
5059                    wait_complete_setter.store(true, std::sync::atomic::Ordering::SeqCst);
5060
5061                    // Always reclaim the terminal
5062                    if let Err(e) = term_clone.reclaim_terminal() {
5063                        tracing::warn!("failed to reclaim terminal: {}", e);
5064                    }
5065
5066                    match result {
5067                        crate::terminal::WaitResult::Exited(code) => code as i64,
5068                        crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
5069                        crate::terminal::WaitResult::Stopped(_sig) => {
5070                            // Register as a stopped job
5071                            let rt = tokio::runtime::Handle::current();
5072                            let job_id = rt.block_on(jobs.register_stopped(
5073                                cmd_display,
5074                                child_id,
5075                                child_id, // pgid = pid for group leader
5076                            ));
5077                            eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
5078                            148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
5079                        }
5080                    }
5081                });
5082
5083                return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
5084            }
5085
5086            // Non-job-control path with inherited stdio.
5087            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5088                Ok(s) => s,
5089                Err(e) => {
5090                    return Ok(Some(ExecResult::failure(
5091                        1,
5092                        format!("{}: failed to wait: {}", name, e),
5093                    )));
5094                }
5095            };
5096
5097            let code = exit_code_from_status(&status);
5098
5099            // stdout/stderr already went to the terminal
5100            Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
5101        } else {
5102            // Capture output via bounded streams
5103            let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5104            let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5105
5106            let stdout_pipe = child.stdout.take();
5107            let stderr_pipe = child.stderr.take();
5108
5109            let stdout_clone = stdout_stream.clone();
5110            let stderr_clone = stderr_stream.clone();
5111
5112            // Only the stage whose stdout *is* the job's stdout tees: in
5113            // `a | b`, `a`'s bytes are `b`'s stdin, and teeing them would put
5114            // the pipeline's intermediate data into the node alongside its
5115            // real output. stderr has no such routing — every stage's stderr
5116            // is the job's stderr — so it tees from any position.
5117            let stdout_tee = job_streams.as_ref().and_then(|s| {
5118                matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last)
5119                    .then(|| s.stdout.clone())
5120            });
5121            let stderr_tee = job_streams.as_ref().map(|s| s.stderr.clone());
5122
5123            let stdout_task = stdout_pipe.map(|pipe| {
5124                tokio::spawn(async move {
5125                    drain_to_stream_teed(pipe, stdout_clone, stdout_tee).await;
5126                })
5127            });
5128
5129            let stderr_task = stderr_pipe.map(|pipe| {
5130                tokio::spawn(async move {
5131                    drain_to_stream_teed(pipe, stderr_clone, stderr_tee).await;
5132                })
5133            });
5134
5135            let cancelled_before_wait = cancel.is_cancelled();
5136            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5137                Ok(s) => s,
5138                Err(e) => {
5139                    // stdin-copy task is aborted by `_stdin_copy_guard` on return.
5140                    if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5141                    if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5142                    return Ok(Some(ExecResult::failure(
5143                        1,
5144                        format!("{}: failed to wait: {}", name, e),
5145                    )));
5146                }
5147            };
5148
5149            // On cancel, abort the drain tasks (the child's pipes are gone;
5150            // late output is lost but predictable death beats partial capture).
5151            // On normal exit, await drains so we don't lose buffered output.
5152            if cancelled_before_wait || cancel.is_cancelled() {
5153                if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5154                if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5155            } else {
5156                if let Some(task) = stdout_task {
5157                    // Ignore join error — the drain task logs its own errors
5158                    let _ = task.await;
5159                }
5160                if let Some(task) = stderr_task {
5161                    let _ = task.await;
5162                }
5163            }
5164
5165            let code = exit_code_from_status(&status);
5166
5167            // Read stdout as RAW bytes: text if valid UTF-8, else a Bytes
5168            // result, so `curl url`, `curl url > file.bin`, etc. keep binary
5169            // intact. stderr stays text. See docs/binary-data.md.
5170            let stdout = stdout_stream.read().await;
5171            let mut stderr = stderr_stream.read_string().await;
5172            let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
5173
5174            // Both streams are fixed-size rings regardless of `ctx.output_limit`
5175            // (that machinery only runs post-hoc, in `execute_pipeline`, and only
5176            // when enabled). With the limit disabled — the repl/embedded/test
5177            // default — an overflow here used to be silent: `write` evicted the
5178            // oldest bytes and bumped `bytes_evicted`, but nothing ever read that
5179            // counter, so a >10MB stdout reported clean success with its head
5180            // quietly gone (GH #191). Surface it loudly instead.
5181            if stderr_stream.has_overflowed().await {
5182                let stats = stderr_stream.stats().await;
5183                stderr = format!("{}{stderr}", stats.overflow_marker("stderr"));
5184            }
5185            if stdout_stream.has_overflowed().await {
5186                // The marker goes in stderr, never prepended into `result`'s
5187                // stdout payload: stdout may be binary
5188                // (`success_text_or_bytes` yields a `Bytes` result for
5189                // non-UTF-8 data — e.g. `curl` fetching a >10MB binary), and
5190                // string-formatting a marker into it would lossily reinterpret
5191                // bytes as text, introducing a SECOND, different kind of
5192                // corruption on top of the eviction itself.
5193                //
5194                // Only stdout overflow flips `did_spill` — exit-code integrity
5195                // tracks stdout, matching the enabled-limit path's contract
5196                // (stderr overflow alone doesn't remap the exit code).
5197                let stats = stdout_stream.stats().await;
5198                stderr = format!("{}{stderr}", stats.overflow_marker("stdout"));
5199                result.did_spill = true;
5200            }
5201            result.err = stderr;
5202            Ok(Some(result))
5203        }
5204    }
5205
5206    // --- Variable Access ---
5207
5208    /// Get a variable value.
5209    pub async fn get_var(&self, name: &str) -> Option<Value> {
5210        let scope = self.scope.read().await;
5211        scope.get(name).cloned()
5212    }
5213
5214    /// Check if error-exit mode is enabled (for testing).
5215    #[cfg(test)]
5216    pub async fn error_exit_enabled(&self) -> bool {
5217        let scope = self.scope.read().await;
5218        scope.error_exit_enabled()
5219    }
5220
5221    /// Set a variable value.
5222    pub async fn set_var(&self, name: &str, value: Value) {
5223        let mut scope = self.scope.write().await;
5224        scope.set(name.to_string(), value);
5225    }
5226
5227    /// Set positional parameters ($0 script name and $1-$9 args).
5228    pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
5229        let mut scope = self.scope.write().await;
5230        scope.set_positional(script_name, args);
5231    }
5232
5233    /// List all variables.
5234    pub async fn list_vars(&self) -> Vec<(String, Value)> {
5235        let scope = self.scope.read().await;
5236        scope.all()
5237    }
5238
5239    /// List exported variables (name, value), sorted by name. These are the
5240    /// vars a child process would see (see `dispatch`'s hermetic env build).
5241    pub async fn exported_vars(&self) -> Vec<(String, Value)> {
5242        let scope = self.scope.read().await;
5243        scope.exported_vars()
5244    }
5245
5246    // --- CWD ---
5247
5248    /// Get current working directory.
5249    pub async fn cwd(&self) -> PathBuf {
5250        self.exec_ctx.read().await.cwd.clone()
5251    }
5252
5253    /// Set current working directory.
5254    pub async fn set_cwd(&self, path: PathBuf) {
5255        let mut ctx = self.exec_ctx.write().await;
5256        ctx.set_cwd(path);
5257    }
5258
5259    /// Set the working directory only if `path` resolves to a directory in the
5260    /// kernel's backend — the same namespace `cd` validates against. Unlike a
5261    /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
5262    /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
5263    /// disappeared. Returns whether the cwd was changed.
5264    pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5265        // Clone the backend Arc out before the stat so we never hold the
5266        // exec_ctx lock across the await.
5267        let backend = self.exec_ctx.read().await.backend.clone();
5268        let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5269        if is_dir {
5270            self.exec_ctx.write().await.set_cwd(path);
5271        }
5272        is_dir
5273    }
5274
5275    // --- Last Result ---
5276
5277    /// Get the last result ($?).
5278    pub async fn last_result(&self) -> ExecResult {
5279        let scope = self.scope.read().await;
5280        scope.last_result().clone()
5281    }
5282
5283    // --- Tools ---
5284
5285    /// Check if a user-defined function exists.
5286    pub async fn has_function(&self, name: &str) -> bool {
5287        self.user_tools.read().await.contains_key(name)
5288    }
5289
5290    /// Get available tool schemas.
5291    pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5292        self.tools.schemas()
5293    }
5294
5295    /// Classify how the kernel will resolve a command name.
5296    ///
5297    /// This is the supported, single source of truth for command resolution that
5298    /// embedders should call instead of re-deriving the rules. Walk a parsed
5299    /// script (`kaish_kernel::parser::parse` → `Stmt::Command` nodes) and call
5300    /// this per command name to bucket each into builtin / user-function /
5301    /// special-form / dynamic / external — for example a consent gate that blocks
5302    /// a script until external commands are approved.
5303    ///
5304    /// The classification mirrors the interpreter's real resolution order
5305    /// (`execute_command_depth`): special-forms (`true`/`false`/`source`/`.`)
5306    /// short-circuit first, then **aliases are expanded** (bounded recursion,
5307    /// re-checking special-forms each step, exactly as execution does), then user
5308    /// functions (which shadow builtins), then builtins, then a `PATH` lookup. A
5309    /// name that is a variable or command-substitution expansion (`$cmd`,
5310    /// `$(pick)`, `${x}`) classifies as [`CommandKind::Dynamic`] because it can't
5311    /// be resolved statically.
5312    ///
5313    /// Aliases are resolved against the kernel's current alias table, so an
5314    /// `alias cat=/bin/something` makes `cat` classify as `External` — the same
5315    /// thing it would actually run. The safe direction of any residual imprecision
5316    /// is `External`/`Dynamic`, never a false "internal": the `/v/bin/` prefix and
5317    /// `.kai`/backend-tool resolution are reported `External` even though some of
5318    /// those resolve in-process, so a consent gate over-gates rather than letting
5319    /// a `PATH` escape slip through.
5320    pub async fn classify_command(&self, name: &str) -> CommandKind {
5321        // Resolve the command head the way `execute_command_depth` does: a
5322        // special-form short-circuits before any alias lookup, otherwise expand
5323        // aliases (bounded, recursive) and re-check from the top. A dynamic name
5324        // can't be resolved at all.
5325        let mut name = name.to_string();
5326        let mut alias_depth = 0u8;
5327        loop {
5328            if !crate::validator::is_static_command_name(&name) {
5329                return CommandKind::Dynamic;
5330            }
5331            if crate::validator::is_runtime_special_form(&name) {
5332                return CommandKind::Special;
5333            }
5334            if alias_depth >= 10 {
5335                break;
5336            }
5337            let alias_value = {
5338                let ctx = self.exec_ctx.read().await;
5339                ctx.aliases.get(&name).cloned()
5340            };
5341            // Expand to the alias's head command. An empty alias value (no head)
5342            // is ignored by execution, so resolution continues with this name.
5343            match alias_value
5344                .as_deref()
5345                .and_then(|v| v.split_whitespace().next())
5346            {
5347                Some(head) => {
5348                    name = head.to_string();
5349                    alias_depth += 1;
5350                }
5351                None => break,
5352            }
5353        }
5354
5355        let is_user_tool = self.user_tools.read().await.contains_key(&name);
5356        let is_builtin = self.tools.contains(&name);
5357        crate::validator::classify_command_name(&name, is_builtin, is_user_tool)
5358    }
5359
5360    // --- Jobs ---
5361
5362    /// Get job manager.
5363    pub fn jobs(&self) -> Arc<JobManager> {
5364        self.jobs.clone()
5365    }
5366
5367    // --- VFS ---
5368
5369    /// Get VFS router.
5370    pub fn vfs(&self) -> Arc<VfsRouter> {
5371        self.vfs.clone()
5372    }
5373
5374    // --- State ---
5375
5376    /// Reset kernel to initial state.
5377    ///
5378    /// Clears in-memory variables and resets cwd to root. History is not
5379    /// cleared (it persists across resets). The kernel's `$$` identity, the
5380    /// trash-on-delete configuration, and any frontend-seeded `initial_vars`
5381    /// (HOME/PATH/etc, from `KernelConfig`) are re-applied to the fresh
5382    /// scope rather than silently reverting to defaults — an embedder that
5383    /// opted into trash must not find it quietly disabled after a `reset()`
5384    /// between requests.
5385    ///
5386    /// **Background jobs are untouched** (GH #245) — `reset()` is a scope/cwd
5387    /// reset, not a session boundary for `&`. A job started before `reset()`
5388    /// keeps running, stays in `jobs`, and the job ID counter keeps counting
5389    /// up. An embedder treating `reset()` as "new session" (a fresh MCP
5390    /// conversation reusing one kernel, say) inherits every job the previous
5391    /// conversation backgrounded — call [`Self::cancel_all_jobs`] first if
5392    /// that inheritance is not wanted.
5393    pub async fn reset(&self) -> Result<()> {
5394        {
5395            let mut scope = self.scope.write().await;
5396            let pid = scope.pid();
5397            let trash_enabled = scope.trash_enabled();
5398            let mut fresh = Scope::new();
5399            fresh.set_pid(pid);
5400            for (name, value) in self.initial_vars.clone() {
5401                fresh.set_exported(name, value);
5402            }
5403            // The pin travels with the policy it pins — a `reset()` between
5404            // requests that dropped it would hand the next request an
5405            // unpinned session (spec §F.3 item 3).
5406            fresh.set_trash_enabled(trash_enabled);
5407            *scope = fresh;
5408        }
5409        {
5410            let mut ctx = self.exec_ctx.write().await;
5411            ctx.cwd = PathBuf::from("/");
5412        }
5413        Ok(())
5414    }
5415
5416    /// Trip the cancellation token of every tracked background job (`&`) —
5417    /// whether or not `shutdown` follows.
5418    ///
5419    /// This is the same lever `kill %N` uses: a *running* job's in-process
5420    /// future exits at its next checkpoint, and any external children it
5421    /// spawned get the SIGTERM→SIGKILL cascade; it then stays tracked with
5422    /// status `Killed` once it unwinds. For an already-finished job the
5423    /// token trip is a no-op — its future has already resolved and the job
5424    /// keeps reporting its terminal status. This only
5425    /// *starts* cancellation, it does not wait (pair with
5426    /// [`JobManager::wait`]/`wait_all` if the caller needs to block on the
5427    /// unwind, bounded as [`Self::shutdown`] does).
5428    ///
5429    /// A job registered by an embedder via [`JobManager::register`] with no
5430    /// cancel token attached has no lever to cancel — silently skipped here,
5431    /// same as `kill %N`'s own "no cancellation token" case.
5432    ///
5433    /// Returns how many jobs a token was actually tripped for.
5434    pub async fn cancel_all_jobs(&self) -> usize {
5435        let ids = self.jobs.list_ids().await;
5436        let mut cancelled = 0;
5437        for id in ids {
5438            if self.jobs.mark_killed_and_cancel(id, false).await {
5439                cancelled += 1;
5440            }
5441        }
5442        cancelled
5443    }
5444
5445    /// Shut down the kernel.
5446    ///
5447    /// Cancels every tracked background job ([`Self::cancel_all_jobs`]), then
5448    /// waits up to `kill_grace + 3s` **per job** — the same bound `kill %N`
5449    /// gives a single target (GH #244) — for it to actually unwind. The
5450    /// waits are sequential, so the worst case is additive: N jobs that all
5451    /// ignore cancellation block shutdown for N × (kill_grace + 3s). Jobs
5452    /// that unwind promptly (the normal case) cost only their own unwind
5453    /// time. Before this fix `shutdown` called `wait_all()` with no timeout
5454    /// at all: `sleep 3600 &` then `shutdown()` blocked for an hour (GH #245).
5455    ///
5456    /// A job that has not unwound by its deadline is abandoned: logged via
5457    /// `tracing::warn!` and left running detached until the tokio runtime
5458    /// itself goes away. There is no further lever once `shutdown()` has
5459    /// returned — this method does not hang, but it also does not guarantee
5460    /// every job actually stopped.
5461    ///
5462    /// Takes `&self`, not owned `self` — an embedder holding `Arc<Kernel>`
5463    /// (e.g. `kaish-client`'s `EmbeddedClient`) can call this without
5464    /// `Arc::try_unwrap`, since the work here only touches the shared
5465    /// `Arc<JobManager>`, never kernel state that would need exclusive
5466    /// ownership.
5467    pub async fn shutdown(&self) -> Result<()> {
5468        let ids = self.jobs.list_ids().await;
5469        self.cancel_all_jobs().await;
5470
5471        let bound = self.jobs.kill_grace() + Duration::from_secs(3);
5472        for id in ids {
5473            if tokio::time::timeout(bound, self.jobs.wait(id)).await.is_err() {
5474                tracing::warn!(
5475                    job_id = %id,
5476                    bound_secs = bound.as_secs_f64(),
5477                    "kernel shutdown: job did not exit within the grace period after \
5478                     cancellation — abandoning it"
5479                );
5480            }
5481        }
5482        Ok(())
5483    }
5484
5485    /// Dispatch a single command using the full resolution chain.
5486    ///
5487    /// This is the core of `CommandDispatcher` — it syncs state between the
5488    /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
5489    /// then delegates to `execute_command` for the actual dispatch.
5490    ///
5491    /// State flow:
5492    /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
5493    /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
5494    /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
5495    async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5496        // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
5497        // its inner command via ctx.dispatcher) routes through THIS kernel,
5498        // not a stale parent. Critical for forks: the fork's builtins must
5499        // use the fork's dispatcher, not the parent's.
5500        if let Some(d) = self.dispatcher() {
5501            ctx.dispatcher = Some(d);
5502        }
5503
5504        // 1. Sync ctx → self internals
5505        {
5506            let mut scope = self.scope.write().await;
5507            *scope = ctx.scope.clone();
5508        }
5509        {
5510            let mut ec = self.exec_ctx.write().await;
5511            ec.cwd = ctx.cwd.clone();
5512            ec.prev_cwd = ctx.prev_cwd.clone();
5513            ec.stdin = ctx.stdin.take();
5514            ec.stdin_data = ctx.stdin_data.take();
5515            // The structured-data sideband receiver (set by the concurrent
5516            // pipeline runner on the stage ctx) must reach the tool's snapshot
5517            // too — same reason as the pipe endpoints below. Without this a
5518            // pipeline consumer never sees the producer's `.data`.
5519            ec.stdin_data_rx = ctx.stdin_data_rx.take();
5520            // Streaming pipe endpoints and kernel stderr must flow to the
5521            // tool via self.exec_ctx — execute_command reads that, not the
5522            // passed-in ctx. Without moving these, concurrent pipeline
5523            // stages dispatched via a fork get pipe_stdin = None and
5524            // silently read nothing.
5525            ec.pipe_stdin = ctx.pipe_stdin.take();
5526            ec.pipe_stdout = ctx.pipe_stdout.take();
5527            if let Some(stderr) = ctx.stderr.clone() {
5528                ec.stderr = Some(stderr);
5529            }
5530            ec.aliases = ctx.aliases.clone();
5531            ec.ignore_config = ctx.ignore_config.clone();
5532            ec.output_limit = ctx.output_limit.clone();
5533            ec.pipeline_position = ctx.pipeline_position;
5534            // Sync the cancel token from ctx → ec. Builtins like `timeout`
5535            // swap ctx.cancel to a derived child token before re-dispatching;
5536            // execute_command's snapshot reads ec.cancel (kept aligned by
5537            // this sync), so try_execute_external sees the right token.
5538            ec.cancel = ctx.cancel.clone();
5539            // Same alignment for the watchdog: a fork dispatching through its
5540            // own kernel must hand the shared script clock to the snapshot so
5541            // patient holds in forked stages suspend the right timer.
5542            ec.watchdog = ctx.watchdog.clone();
5543        }
5544
5545        // 2. Execute via the full dispatch chain
5546        let result = self.execute_command(&cmd.name, &cmd.args).await?;
5547
5548        // 3. Sync self → ctx
5549        {
5550            let scope = self.scope.read().await;
5551            ctx.scope = scope.clone();
5552        }
5553        {
5554            let mut ec = self.exec_ctx.write().await;
5555            ctx.cwd = ec.cwd.clone();
5556            ctx.prev_cwd = ec.prev_cwd.clone();
5557            ctx.aliases = ec.aliases.clone();
5558            ctx.ignore_config = ec.ignore_config.clone();
5559            ctx.output_limit = ec.output_limit.clone();
5560            // Return any pipe endpoints that the tool didn't consume.
5561            // `take()` here keeps the fork's exec_ctx in a clean state for
5562            // the next dispatch — these are per-command and shouldn't leak
5563            // between calls.
5564            ctx.pipe_stdin = ec.pipe_stdin.take();
5565            ctx.pipe_stdout = ec.pipe_stdout.take();
5566            // Same take-don't-clone discipline as stdin, and for the same
5567            // reason: these belong to exactly one dispatch, and a copy left
5568            // behind would let the next command adopt it.
5569        }
5570
5571        Ok(result)
5572    }
5573}
5574
5575/// Evaluates a single AST expression on behalf of [`bind_tool_args`], the one
5576/// shared arg-binding core behind both `Kernel::build_args_async`
5577/// (production: full recursion through the async pipeline, command
5578/// substitution, real glob expansion) and the reduced sync evaluator behind
5579/// scatter/gather's own option parsing and the `#[cfg(test)]`
5580/// `BackendDispatcher` (`scheduler::pipeline::build_tool_args`'s
5581/// `SyncEvalSource`). GH #188 closes the drift class between those two
5582/// callers: the flag/positional-binding logic (this file's `bind_tool_args`)
5583/// is now the ONLY implementation; only expression evaluation, which is
5584/// capability-bound (recursing into command substitution needs a live async
5585/// pipeline the reduced context doesn't have), still has two providers.
5586#[async_trait]
5587pub(crate) trait ArgValueSource: Send + Sync {
5588    /// Evaluate `expr` to a `Value`. `Ok(None)` means "not representable by
5589    /// this evaluator" — the reduced sync evaluator's bash-compatible
5590    /// "coalesce" convention for an unset bare variable, or an expression
5591    /// form it doesn't support (a binary op) — and the caller drops the
5592    /// argument the same way an unset bare variable always has. The real
5593    /// (Kernel) evaluator never returns `Ok(None)`: it can always fully
5594    /// evaluate.
5595    async fn eval(&self, expr: &Expr) -> Result<Option<Value>>;
5596
5597    /// Expand a bare glob-pattern positional to display strings, or `None`
5598    /// if this evaluator doesn't expand globs here (disabled, or the reduced
5599    /// sync context, which never has — matching its documented "no
5600    /// filesystem walk before worker forks" limit). `bind_tool_args` falls
5601    /// back to `eval` (which hands back the pattern text as a literal
5602    /// string) when this returns `None`. An enabled expansion that matches
5603    /// nothing is a genuine error, not `Ok(None)`.
5604    async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>>;
5605
5606    /// Session `HOME`, for tilde expansion. `None` disables tilde expansion
5607    /// — the reduced sync evaluator's existing behavior (it never expanded
5608    /// `~`).
5609    async fn home(&self) -> Option<String>;
5610}
5611
5612#[async_trait]
5613impl ArgValueSource for Kernel {
5614    async fn eval(&self, expr: &Expr) -> Result<Option<Value>> {
5615        Ok(Some(self.eval_expr_async(expr).await?))
5616    }
5617
5618    async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>> {
5619        let glob_enabled = self.scope.read().await.glob_enabled();
5620        if !glob_enabled {
5621            return Ok(None);
5622        }
5623        let (paths, cwd) = {
5624            let ctx = self.exec_ctx.read().await;
5625            let paths = ctx
5626                .expand_glob(pattern)
5627                .await
5628                .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
5629            let cwd = ctx.resolve_path(".");
5630            (paths, cwd)
5631        };
5632        if paths.is_empty() {
5633            anyhow::bail!("no matches: {}", pattern);
5634        }
5635        let display = paths
5636            .into_iter()
5637            .map(|path| {
5638                if !pattern.starts_with('/') {
5639                    path.strip_prefix(&cwd)
5640                        .unwrap_or(&path)
5641                        .to_string_lossy()
5642                        .into_owned()
5643                } else {
5644                    path.to_string_lossy().into_owned()
5645                }
5646            })
5647            .collect();
5648        Ok(Some(display))
5649    }
5650
5651    async fn home(&self) -> Option<String> {
5652        self.scope_home().await
5653    }
5654}
5655
5656/// Pull `consumes` positional args after a non-bool flag and stash them on
5657/// `tool_args.named` under the canonical param name. Shared core behind
5658/// [`bind_tool_args`]'s `ShortFlag`/`LongFlag` value-flag arms — see that
5659/// function's doc comment for the unification story (GH #188).
5660///
5661/// - `consumes == 1` (non-repeatable) keeps the historical contract: a
5662///   single scalar value (last write wins on the rare duplicate).
5663/// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
5664///   inside `named[canonical] = Value::Json(Array(...))`, preserving
5665///   invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
5666///   a repeated single-value flag must keep every value, not silently drop
5667///   all but the last (a "no silent corruption" violation).
5668/// - `consumes > 1` accumulates each occurrence as an inner
5669///   `serde_json::Value::Array` inside `named[canonical] =
5670///   Value::Json(Array(...))`, preserving invocation order. This is the
5671///   shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
5672///
5673/// Errors loudly if the flag is missing required positionals — matches
5674/// kaish's "no silent fallback" posture and mirrors real jq, which errors on
5675/// `--arg NAME` with no value. A reduced evaluator's `Ok(None)` (a value it
5676/// can't represent — Kernel's evaluator never returns this) falls back to a
5677/// bare flag on the FIRST occurrence, matching the pre-#188 sync twin's
5678/// unset-bare-var "coalesce" convention; mid-accumulation it's a genuine
5679/// error rather than a silently-partial array.
5680#[allow(clippy::too_many_arguments)]
5681async fn consume_flag_positionals(
5682    source: &dyn ArgValueSource,
5683    home: Option<&str>,
5684    args: &[Arg],
5685    flag_name: &str,
5686    canonical: &str,
5687    consumes: usize,
5688    repeatable: bool,
5689    positional_indices: &[usize],
5690    consumed: &mut std::collections::HashSet<usize>,
5691    current_idx: usize,
5692    tool_args: &mut ToolArgs,
5693) -> Result<()> {
5694    let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
5695    for _ in 0..consumes.max(1) {
5696        // A `key=value` (WordAssign) token is consumable only by a
5697        // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
5698        // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
5699        // filter` would reassemble `x=1` into the first slot and steal the
5700        // filter into the second. Multi-value flags take plain positionals.
5701        let allow_word_assign = consumes <= 1;
5702        let next_pos = positional_indices
5703            .iter()
5704            .find(|idx| {
5705                **idx > current_idx
5706                    && !consumed.contains(idx)
5707                    && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
5708            })
5709            .copied();
5710        match next_pos {
5711            Some(pos_idx) => match &args[pos_idx] {
5712                Arg::Positional(expr) => match source.eval(expr).await? {
5713                    Some(value) => {
5714                        let value = apply_tilde_expansion(value, home);
5715                        collected.push(value);
5716                        consumed.insert(pos_idx);
5717                    }
5718                    None if collected.is_empty() => {
5719                        tool_args.flags.insert(flag_name.to_string());
5720                        return Ok(());
5721                    }
5722                    None => anyhow::bail!(
5723                        "--{flag_name}: could not evaluate argument {} in this context",
5724                        collected.len() + 1
5725                    ),
5726                },
5727                // `-v a=1`: reassemble the `key=value` token as the flag's
5728                // scalar value (see `positional_indices` construction).
5729                Arg::WordAssign { key, value } => match source.eval(value).await? {
5730                    Some(val) => {
5731                        let val = apply_tilde_expansion(val, home);
5732                        // Loud on binary (GH #116): `-v a=$BIN` must not silently
5733                        // reassemble the `[binary: N bytes]` placeholder into the
5734                        // flag's value — same text-sink boundary as the primary
5735                        // sinks fixed in #93 item 1.
5736                        let val_str = crate::interpreter::value_to_text_sink_named(
5737                            &val,
5738                            "a key=value argument",
5739                        )
5740                        .map_err(|e| anyhow::anyhow!("{e}"))?;
5741                        collected.push(Value::String(format!("{key}={val_str}")));
5742                        consumed.insert(pos_idx);
5743                    }
5744                    None if collected.is_empty() => {
5745                        tool_args.flags.insert(flag_name.to_string());
5746                        return Ok(());
5747                    }
5748                    None => anyhow::bail!(
5749                        "--{flag_name}: could not evaluate argument {} in this context",
5750                        collected.len() + 1
5751                    ),
5752                },
5753                _ => {}
5754            },
5755            None => {
5756                if consumes <= 1 && collected.is_empty() {
5757                    // Back-compat: a flag with no follow-up positional
5758                    // becomes a bare flag. `--path` with nothing after
5759                    // lands in `flags`, same as before this refactor.
5760                    tool_args.flags.insert(flag_name.to_string());
5761                    return Ok(());
5762                }
5763                anyhow::bail!(
5764                    "--{flag_name} requires {consumes} argument{}, got {}",
5765                    if consumes == 1 { "" } else { "s" },
5766                    collected.len()
5767                );
5768            }
5769        }
5770    }
5771
5772    if consumes <= 1 {
5773        if let Some(v) = collected.pop() {
5774            if repeatable {
5775                push_repeatable_value(tool_args, flag_name, canonical, v)?;
5776            } else {
5777                tool_args.named.insert(canonical.to_string(), v);
5778            }
5779        }
5780        return Ok(());
5781    }
5782
5783    // Multi-consume: accumulate under named[canonical] as array-of-arrays.
5784    let occ: Vec<serde_json::Value> = collected
5785        .into_iter()
5786        .map(|v| crate::interpreter::value_to_json(&v))
5787        .collect();
5788    let entry = tool_args
5789        .named
5790        .entry(canonical.to_string())
5791        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
5792    if let Value::Json(serde_json::Value::Array(outer)) = entry {
5793        outer.push(serde_json::Value::Array(occ));
5794    } else {
5795        anyhow::bail!(
5796            "--{flag_name}: named[{canonical}] already holds a non-array value"
5797        );
5798    }
5799    Ok(())
5800}
5801
5802/// Build `ToolArgs` from AST `Arg`s — the single arg-binding implementation
5803/// (GH #188) shared by `Kernel::build_args_async` (production) and the
5804/// reduced sync path (`scheduler::pipeline::build_tool_args`, used by
5805/// scatter/gather's own option parsing and the `#[cfg(test)]`
5806/// `BackendDispatcher`). The two differ only in the [`ArgValueSource`] they
5807/// pass: Kernel's evaluates full expressions (including `$(...)` command
5808/// substitution) and expands real globs/tilde; the reduced one can't recurse
5809/// into the async pipeline this early (scatter/gather's own flags bind
5810/// before any worker forks) so it evaluates a smaller expression subset and
5811/// never expands globs/tilde — see `SyncEvalSource` in `scheduler::pipeline`.
5812///
5813/// If a schema is provided, uses it to determine argument types:
5814/// - For `--flag` where schema says type is non-bool: consume next
5815///   positional(s) as value(s) (`consumes`/`repeatable`-aware).
5816/// - For `--flag` where schema says type is bool (or unknown): treat as a
5817///   boolean flag.
5818///
5819/// This enables natural shell syntax like `mcp_tool --query "test" --limit 10`.
5820pub(crate) async fn bind_tool_args(
5821    args: &[Arg],
5822    schema: Option<&crate::tools::ToolSchema>,
5823    source: &dyn ArgValueSource,
5824) -> Result<ToolArgs> {
5825    let mut tool_args = ToolArgs::new();
5826    let home = source.home().await;
5827
5828    // A glob-passthrough tool (`glob`) consumes patterns as data: skip
5829    // argv glob expansion so the pattern reaches the tool as written —
5830    // otherwise `glob **/*.rs` binds the first *matching path* as its
5831    // pattern. The eval fallback turns `Expr::GlobPattern` into its
5832    // literal string.
5833    let glob_passthrough = schema.is_some_and(|s| s.glob_passthrough);
5834
5835    // Raw-argv fast path (POSIX `test`): bind every argument to `positional`
5836    // in source order with types preserved — operators (`-f`, `=`, `!`) as
5837    // strings, operands keeping their `Value` — leaving `flags`/`named`
5838    // empty. A position-sensitive command needs the *true* argv: an operand
5839    // that looks like a flag (`test $x = -n`, `test 0 -gt -5`) must not be
5840    // hoisted into the unordered flag set the normal binder splits into.
5841    // Globs still expand and `~` still resolves, matching normal positional
5842    // binding — so `test -f *.rs` errors on too many args, not a literal
5843    // pattern stat.
5844    if schema.is_some_and(|s| s.raw_argv) {
5845        for arg in args {
5846            match arg {
5847                Arg::Positional(expr) => {
5848                    let glob = if let Expr::GlobPattern(p) = expr {
5849                        (!glob_passthrough).then(|| p.clone())
5850                    } else {
5851                        None
5852                    };
5853                    if let Some(pattern) = glob {
5854                        match source.expand_glob(&pattern).await? {
5855                            Some(paths) => {
5856                                for path in paths {
5857                                    tool_args.positional.push(Value::String(path));
5858                                }
5859                            }
5860                            None => {
5861                                let value = source.eval(expr).await?.ok_or_else(|| {
5862                                    anyhow::anyhow!(
5863                                        "raw-argv positional could not be evaluated in this context"
5864                                    )
5865                                })?;
5866                                let value = apply_tilde_expansion(value, home.as_deref());
5867                                tool_args.positional.push(value);
5868                            }
5869                        }
5870                    } else {
5871                        let value = source.eval(expr).await?.ok_or_else(|| {
5872                            anyhow::anyhow!(
5873                                "raw-argv positional could not be evaluated in this context"
5874                            )
5875                        })?;
5876                        let value = apply_tilde_expansion(value, home.as_deref());
5877                        tool_args.positional.push(value);
5878                    }
5879                }
5880                Arg::ShortFlag(name) => {
5881                    tool_args.positional.push(Value::String(format!("-{name}")));
5882                }
5883                Arg::LongFlag(name) => {
5884                    tool_args.positional.push(Value::String(format!("--{name}")));
5885                }
5886                Arg::Named { key, value } => {
5887                    let val = source.eval(value).await?.ok_or_else(|| {
5888                        anyhow::anyhow!("raw-argv --key=value could not be evaluated in this context")
5889                    })?;
5890                    let val = apply_tilde_expansion(val, home.as_deref());
5891                    // Loud on binary (GH #116): `test --k=$BIN` must not
5892                    // silently reassemble the placeholder into the raw-argv
5893                    // positional stream `test` binds against.
5894                    let val_str = crate::interpreter::value_to_text_sink_named(
5895                        &val,
5896                        "a --key=value argument",
5897                    )
5898                    .map_err(|e| anyhow::anyhow!("{e}"))?;
5899                    tool_args
5900                        .positional
5901                        .push(Value::String(format!("--{key}={val_str}")));
5902                }
5903                Arg::WordAssign { key, value } => {
5904                    let val = source.eval(value).await?.ok_or_else(|| {
5905                        anyhow::anyhow!("raw-argv key=value could not be evaluated in this context")
5906                    })?;
5907                    let val = apply_tilde_expansion(val, home.as_deref());
5908                    // Loud on binary (GH #116): same reasoning as the Named
5909                    // arm above, for the bare `key=value` raw-argv form.
5910                    let val_str = crate::interpreter::value_to_text_sink_named(
5911                        &val,
5912                        "a key=value argument",
5913                    )
5914                    .map_err(|e| anyhow::anyhow!("{e}"))?;
5915                    tool_args
5916                        .positional
5917                        .push(Value::String(format!("{key}={val_str}")));
5918                }
5919                Arg::DoubleDash => {
5920                    tool_args.positional.push(Value::String("--".to_string()));
5921                }
5922            }
5923        }
5924        return Ok(tool_args);
5925    }
5926
5927    // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
5928    // schemas; pick the leaf the leading positionals route to and bind
5929    // flags against *its* params. Flat tools return the root. select_leaf
5930    // errors (fail loud) if a computed positional sits where a subcommand
5931    // selector is required.
5932    let leaf = match schema {
5933        Some(s) => Some(select_leaf(s, args)?),
5934        None => None,
5935    };
5936    // Bind against the leaf's params, but MERGE the root schema's params on
5937    // top as "global" flags: a value-flag declared at the tool's top level
5938    // (e.g. kj's `--confirm <token>`) must bind at every leaf, including when
5939    // it trails the subcommand path (`kj context retag a b --confirm <n>`).
5940    // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
5941    // merge is a harmless no-op.
5942    let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
5943    if let Some(l) = leaf {
5944        param_lookup.extend(schema_param_lookup(l));
5945    }
5946    // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
5947    // not the leaf — it's a property of the command, not the subcommand.
5948    let accepts_word_assign = schema
5949        .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
5950        .unwrap_or(false);
5951
5952    // Track which positional indices have been consumed as flag values
5953    let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
5954    let mut past_double_dash = false;
5955
5956    // Indices a value-flag may consume as its value. Positionals always
5957    // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
5958    // itself treat `key=value` as an assignment (everything but
5959    // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
5960    // `-v`, rather than skipping it and grabbing the next positional (the
5961    // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
5962    // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
5963    let positional_indices: Vec<usize> = args
5964        .iter()
5965        .enumerate()
5966        .filter_map(|(i, a)| {
5967            let consumable = matches!(a, Arg::Positional(_))
5968                || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
5969            consumable.then_some(i)
5970        })
5971        .collect();
5972
5973    let mut i = 0;
5974    while i < args.len() {
5975        match &args[i] {
5976            Arg::DoubleDash => {
5977                past_double_dash = true;
5978            }
5979            Arg::Positional(expr) => {
5980                if !consumed.contains(&i) {
5981                    // Glob expansion: bare glob patterns expand to matching files
5982                    if let Expr::GlobPattern(pattern) = expr {
5983                        if !glob_passthrough {
5984                            if let Some(paths) = source.expand_glob(pattern).await? {
5985                                for path in paths {
5986                                    tool_args.positional.push(Value::String(path));
5987                                }
5988                                i += 1;
5989                                continue;
5990                            }
5991                        }
5992                    }
5993                    if let Some(value) = source.eval(expr).await? {
5994                        let value = apply_tilde_expansion(value, home.as_deref());
5995                        tool_args.positional.push(value);
5996                    }
5997                }
5998            }
5999            Arg::Named { key, value } => {
6000                if let Some(val) = source.eval(value).await? {
6001                    let val = apply_tilde_expansion(val, home.as_deref());
6002                    // A repeatable flag in `--flag=value` form must accumulate too,
6003                    // not overwrite — otherwise `--expression=A --expression=B`
6004                    // would silently keep only B, and mixing with the `-e` space
6005                    // form would clobber the array. Route it through the same
6006                    // accumulator the space form uses.
6007                    let is_declared_value_flag = param_lookup
6008                        .get(key.as_str())
6009                        .is_some_and(|(_, typ, ..)| !is_bool_type(typ));
6010                    if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
6011                        push_repeatable_value(&mut tool_args, key, canonical, val)?;
6012                    } else if matches!(val, Value::Bool(_)) && !is_declared_value_flag {
6013                        // Flagify at bind time (GH #189): `--flag=true`/
6014                        // `--flag=false` binds the same way the bare
6015                        // `--flag`/its absence already do (true → flag
6016                        // presence, false → dropped) instead of landing in
6017                        // `named` as a literal `Value::Bool` that a clap
6018                        // `bool` field's `SetTrue` action rejects
6019                        // (`seq --json=true` used to exit 2 with a clap
6020                        // parse error). Covers both a schema-declared bool
6021                        // param AND an undeclared flag — `--json` itself is
6022                        // deliberately excluded from every builtin's schema
6023                        // (`clap_schema::is_skipped`), so this is what makes
6024                        // `--json=true` work universally instead of only for
6025                        // the builtins that happen to call
6026                        // `ToolArgs::flagify_bool_named` themselves. A
6027                        // declared VALUE-taking flag's own `=true` literal
6028                        // (`spawn --command=true`) is excluded by
6029                        // `is_declared_value_flag` and still falls to
6030                        // `named` below.
6031                        if let Value::Bool(true) = val {
6032                            tool_args.flags.insert(key.clone());
6033                        }
6034                        // Value::Bool(false): absent == false, nothing to insert.
6035                    } else {
6036                        tool_args.named.insert(key.clone(), val);
6037                    }
6038                }
6039            }
6040            Arg::WordAssign { key, value } => {
6041                // Already pulled in as a preceding value-flag's argument
6042                // (`awk -v a=1`); don't also emit it as a positional.
6043                if consumed.contains(&i) {
6044                    i += 1;
6045                    continue;
6046                }
6047                if let Some(val) = source.eval(value).await? {
6048                    let val = apply_tilde_expansion(val, home.as_deref());
6049                    // Past `--`, EVERY token is raw data — including for
6050                    // export/alias, whose `key=value` is normally a shell
6051                    // assignment (GH #189). `export -- A=1` must bind `A=1`
6052                    // as a literal positional, not silently re-enter the
6053                    // named-assignment path `past_double_dash` exists to
6054                    // suppress for flags right above this arm.
6055                    if accepts_word_assign && !past_double_dash {
6056                        tool_args.named.insert(key.clone(), val);
6057                    } else {
6058                        // Stringify "key=value" and pass as a positional.
6059                        // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
6060                        // Loud on binary (GH #116): `cat foo=$BIN`/`dd if=$BIN`
6061                        // must not silently become a path/operand literally named
6062                        // `foo=[binary: N bytes]`.
6063                        let val_str = crate::interpreter::value_to_text_sink_named(
6064                            &val,
6065                            "a key=value argument",
6066                        )
6067                        .map_err(|e| anyhow::anyhow!("{e}"))?;
6068                        tool_args.positional.push(Value::String(format!("{key}={val_str}")));
6069                    }
6070                }
6071            }
6072            Arg::ShortFlag(name) => {
6073                if past_double_dash {
6074                    tool_args.positional.push(Value::String(format!("-{name}")));
6075                } else if name.len() == 1 {
6076                    let flag_name = name.as_str();
6077                    let lookup = param_lookup.get(flag_name);
6078
6079                    // Same ambiguity guard as the `LongFlag` arm below (GH
6080                    // #189 item 4): an undeclared short flag immediately
6081                    // followed by an unconsumed positional under a
6082                    // map_positionals (backend/MCP) schema is exactly as
6083                    // ambiguous as the long-flag case — kaish can't tell a
6084                    // space-form value (`-t explorer`) from a bool flag
6085                    // sitting before a real positional (`-f file.txt`).
6086                    // Unlike `--flag`, there is no `-f=value` escape hatch to
6087                    // suggest: a glued `-f=val` is two tokens with a dangling
6088                    // `=` that the parser's no-token-pasting guard already
6089                    // rejects — the only fix is declaring the flag.
6090                    let ambiguous_value = (lookup.is_none()
6091                        && leaf.is_some_and(|s| s.map_positionals)
6092                        && !consumed.contains(&(i + 1)))
6093                        .then(|| match args.get(i + 1) {
6094                            Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
6095                                Some(s.clone())
6096                            }
6097                            Some(Arg::Positional(_)) => Some("VALUE".to_string()),
6098                            _ => None,
6099                        })
6100                        .flatten();
6101                    if let Some(val) = ambiguous_value {
6102                        let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
6103                        anyhow::bail!(
6104                            "{tool}: -{name} is not a declared flag, so the \
6105                             space-separated value ({val:?}) would be silently \
6106                             dropped. Have {tool} declare -{name} in its schema \
6107                             (short flags have no -{name}=value form to fall \
6108                             back on)."
6109                        );
6110                    }
6111
6112                    let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
6113
6114                    if is_bool {
6115                        tool_args.flags.insert(flag_name.to_string());
6116                    } else {
6117                        // Non-bool: consume `consumes` positionals as value(s)
6118                        let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
6119                        let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
6120                        let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
6121                        consume_flag_positionals(
6122                            source,
6123                            home.as_deref(),
6124                            args,
6125                            name,
6126                            canonical,
6127                            consumes,
6128                            repeatable,
6129                            &positional_indices,
6130                            &mut consumed,
6131                            i,
6132                            &mut tool_args,
6133                        )
6134                        .await?;
6135                    }
6136                } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
6137                    // Multi-char short flag matches a schema param (POSIX style: -name value)
6138                    if is_bool_type(typ) {
6139                        tool_args.flags.insert(canonical.to_string());
6140                    } else {
6141                        consume_flag_positionals(
6142                            source,
6143                            home.as_deref(),
6144                            args,
6145                            name,
6146                            canonical,
6147                            consumes,
6148                            repeatable,
6149                            &positional_indices,
6150                            &mut consumed,
6151                            i,
6152                            &mut tool_args,
6153                        )
6154                        .await?;
6155                    }
6156                } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
6157                    .get(&name[..1])
6158                    .filter(|(_, typ, ..)| !is_bool_type(typ))
6159                {
6160                    // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
6161                    // `grep -A1`, `sed -e1d`. The first char is a declared
6162                    // value-taking short flag, so the rest of the token is its
6163                    // value — the coreutils idiom. The lexer's flag char class is
6164                    // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
6165                    // (safe to slice) and the tail is a plain literal.
6166                    bind_glued_short_value(
6167                        &mut tool_args,
6168                        &name[..1],
6169                        canonical,
6170                        consumes,
6171                        repeatable,
6172                        name[1..].to_string(),
6173                    )?;
6174                } else {
6175                    // Multi-char combined short flags. Bool flags stack
6176                    // (`-la`), but the FIRST value-taking flag reached
6177                    // consumes the rest of the token as its glued value
6178                    // (`-ivC3` → C=3) or, if it is the last char, the next
6179                    // positional (`grep -ivC 3` → C=3). Before this, a
6180                    // trailing value-flag was silently treated as a bool,
6181                    // stranding its argument as a stray positional (arity
6182                    // error). Undeclared/bool chars stay bare flags, so a
6183                    // schemaless tool keeps the old all-boolean behavior.
6184                    // The first char being value-taking is handled by the
6185                    // glued arm above, so it never reaches here. The flag
6186                    // char class is ASCII, so byte indexing is char indexing
6187                    // (no `Vec<char>` allocation needed).
6188                    let bytes = name.as_bytes();
6189                    let mut p = 0;
6190                    while p < bytes.len() {
6191                        let key = &name[p..p + 1];
6192                        match param_lookup.get(key) {
6193                            Some(&(canonical, typ, consumes, repeatable))
6194                                if !is_bool_type(typ) =>
6195                            {
6196                                let glued = name[p + 1..].to_string();
6197                                if glued.is_empty() {
6198                                    // Value flag is the last char: take the
6199                                    // next positional. `consume_flag_positionals`
6200                                    // respects `consumes`.
6201                                    consume_flag_positionals(
6202                                        source,
6203                                        home.as_deref(),
6204                                        args,
6205                                        key,
6206                                        canonical,
6207                                        consumes,
6208                                        repeatable,
6209                                        &positional_indices,
6210                                        &mut consumed,
6211                                        i,
6212                                        &mut tool_args,
6213                                    )
6214                                    .await?;
6215                                } else {
6216                                    bind_glued_short_value(
6217                                        &mut tool_args,
6218                                        key,
6219                                        canonical,
6220                                        consumes,
6221                                        repeatable,
6222                                        glued,
6223                                    )?;
6224                                }
6225                                break;
6226                            }
6227                            _ => {
6228                                tool_args.flags.insert(key.to_string());
6229                                p += 1;
6230                            }
6231                        }
6232                    }
6233                }
6234            }
6235            Arg::LongFlag(name) => {
6236                if past_double_dash {
6237                    tool_args.positional.push(Value::String(format!("--{name}")));
6238                } else {
6239                    let lookup = param_lookup.get(name.as_str());
6240                    // An *undeclared* long flag under a `map_positionals`
6241                    // (backend/MCP) schema that is immediately followed by an
6242                    // unconsumed positional is ambiguous: kaish can't tell the
6243                    // space-form value (`--type explorer`) from a bool flag
6244                    // before a real positional (`--force file.txt`). Defaulting
6245                    // to bool here silently divorces the value and misroutes it
6246                    // — a privilege-escalation-by-typo against deny-by-default
6247                    // embedders. Fail loud instead of guessing.
6248                    let ambiguous_value = (lookup.is_none()
6249                        && leaf.is_some_and(|s| s.map_positionals)
6250                        && !consumed.contains(&(i + 1)))
6251                        .then(|| match args.get(i + 1) {
6252                            // Echo a concrete value for a copy-pasteable fix
6253                            // when it's a plain literal; fall back to VALUE.
6254                            Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
6255                                Some(s.clone())
6256                            }
6257                            Some(Arg::Positional(_)) => Some("VALUE".to_string()),
6258                            _ => None,
6259                        })
6260                        .flatten();
6261                    if let Some(val) = ambiguous_value {
6262                        let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
6263                        anyhow::bail!(
6264                            "{tool}: --{name} is not a declared flag, so the \
6265                             space-separated value would be silently dropped. \
6266                             Use --{name}={val}, or have {tool} declare --{name} \
6267                             in its schema."
6268                        );
6269                    }
6270                    let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
6271
6272                    if is_bool {
6273                        tool_args.flags.insert(name.clone());
6274                    } else {
6275                        let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
6276                        let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
6277                        let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
6278                        consume_flag_positionals(
6279                            source,
6280                            home.as_deref(),
6281                            args,
6282                            name,
6283                            canonical,
6284                            consumes,
6285                            repeatable,
6286                            &positional_indices,
6287                            &mut consumed,
6288                            i,
6289                            &mut tool_args,
6290                        )
6291                        .await?;
6292                    }
6293                }
6294            }
6295        }
6296        i += 1;
6297    }
6298
6299    // Map remaining positionals to unfilled non-bool schema params (in order).
6300    // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
6301    // Positionals that appeared after `--` are never mapped (they're raw data).
6302    // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
6303    // Keyed off the routed leaf so a subcommand tool maps against the active
6304    // leaf's params (kj leaves keep map_positionals=false → block skipped).
6305    if let Some(schema) = leaf.filter(|s| s.map_positionals) {
6306        let pre_dash_count = if past_double_dash {
6307            let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
6308            positional_indices.iter()
6309                .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
6310                .count()
6311        } else {
6312            tool_args.positional.len()
6313        };
6314
6315        let mut remaining = Vec::new();
6316        let mut positional_iter = tool_args.positional.drain(..).enumerate();
6317
6318        for param in &schema.params {
6319            if tool_args.named.contains_key(&param.name) || tool_args.flags.contains(&param.name) {
6320                continue;
6321            }
6322            if is_bool_type(&param.param_type) {
6323                continue;
6324            }
6325            loop {
6326                match positional_iter.next() {
6327                    Some((idx, val)) if idx < pre_dash_count => {
6328                        tool_args.named.insert(param.name.clone(), val);
6329                        break;
6330                    }
6331                    Some((_, val)) => {
6332                        remaining.push(val);
6333                    }
6334                    None => break,
6335                }
6336            }
6337        }
6338
6339        remaining.extend(positional_iter.map(|(_, v)| v));
6340        tool_args.positional = remaining;
6341    }
6342
6343    Ok(tool_args)
6344}
6345
6346#[async_trait]
6347impl CommandDispatcher for Kernel {
6348    /// Dispatch a command through the Kernel's full resolution chain.
6349    ///
6350    /// This is the single path for all command execution when called from
6351    /// the pipeline runner. It provides the full dispatch chain:
6352    /// user tools → builtins → .kai scripts → external commands → backend tools.
6353    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
6354        self.dispatch_command(cmd, ctx).await
6355    }
6356
6357    /// Evaluate an expression through the kernel's async chain, including
6358    /// command substitution. Delegates to `eval_expr_async`, which snapshots
6359    /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
6360    /// only command output escapes. The `ctx` is unused here because the
6361    /// kernel evaluates against its own session state (a fork carries the
6362    /// pipeline stage's snapshot); var refs resolve against that scope.
6363    async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
6364        self.eval_expr_async(expr).await
6365    }
6366
6367    /// Produce a forked dispatcher with independent mutable state (detached).
6368    ///
6369    /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
6370    /// recursing into the trait method we're defining) and coerces the
6371    /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
6372    async fn fork(&self) -> Arc<dyn CommandDispatcher> {
6373        let fork: Arc<Kernel> = Kernel::fork(self).await;
6374        fork
6375    }
6376
6377    /// Produce a forked dispatcher with cancellation cascading from this kernel.
6378    async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
6379        let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
6380        fork
6381    }
6382}
6383
6384/// Apply the requested output format to a builtin's result, unless the tool
6385/// owns its own output — and even then, only on success.
6386///
6387/// `format` is `ctx.output_format` (set from `--json`). `owns_output` means
6388/// "this tool renders its own bespoke SUCCESS envelope" (scatter/gather's
6389/// JSONL/array rendering), not "never touch this tool's bytes" — scatter and
6390/// gather never render a structured error themselves, so a failure
6391/// (`ExecResult::failure(code, msg)`, plain text, no `.data`/`.output`) was
6392/// never "already rendered" by the tool. Skipping `apply_output_format` on
6393/// that path just leaked the raw diagnostic under `--json` instead of the
6394/// uniform `{"error","code"}` envelope every other builtin's failure gets
6395/// (kaibo review finding on merged PR #215, confirmed pre-existing for the
6396/// whole owns_output error-path class). Gating the skip on `result.ok()`
6397/// keeps the intentional success-path opt-out while closing that gap.
6398fn finalize_output(
6399    result: ExecResult,
6400    format: Option<crate::interpreter::OutputFormat>,
6401    owns_output: bool,
6402) -> ExecResult {
6403    match format {
6404        Some(_) if owns_output && result.ok() => result,
6405        Some(format) => apply_output_format(result, format),
6406        None => result,
6407    }
6408}
6409
6410/// Accumulate output from one result into another.
6411///
6412/// Appends stdout and stderr verbatim and updates the exit code to match the
6413/// new result. Used to preserve output from multiple statements, loop
6414/// iterations, and command chains. No separator is inserted between outputs —
6415/// each command's output concatenates raw, matching bash (`printf a; printf b`
6416/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
6417/// when a command emits its own, as `echo` does).
6418fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
6419    // Materialize lazy OutputData into .out before accumulating.
6420    // Without this, the first command's output stays in .output while
6421    // the second's text gets appended to .out, losing the first.
6422    accumulated.materialize();
6423    match new.out_bytes() {
6424        // A binary result must not be lossy-decoded by text_out(): concatenate
6425        // raw bytes so the combined output stays binary (this is the path every
6426        // top-level statement's result flows through). See docs/binary-data.md.
6427        Some(new_bytes) => {
6428            let mut combined: Vec<u8> = match accumulated.out_bytes() {
6429                Some(b) => b.to_vec(),
6430                None => accumulated.text_out().into_owned().into_bytes(),
6431            };
6432            combined.extend_from_slice(new_bytes);
6433            accumulated.set_out_bytes(combined);
6434        }
6435        None => accumulated.push_out(&new.text_out()),
6436    }
6437    accumulated.err.push_str(&new.err);
6438    accumulated.code = new.code;
6439    accumulated.data = new.data.clone();
6440    accumulated.did_spill = new.did_spill;
6441    accumulated.original_code = new.original_code;
6442    accumulated.content_type = new.content_type.clone();
6443    accumulated.baggage.clone_from(&new.baggage);
6444}
6445
6446/// Fold a loop's accumulated output into a break/continue signal that is
6447/// propagating to an *outer* loop. Output printed before `break N`/`continue N`
6448/// (with `N > 1`) would otherwise be discarded when the signal replaces the
6449/// loop's result on its way up. The loop's output comes first (it ran before
6450/// the signal was raised), then the signal's already-carried output.
6451fn fold_loop_output_into_flow(loop_output: ExecResult, flow: &mut ControlFlow) {
6452    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
6453        let mut merged = loop_output;
6454        accumulate_result(&mut merged, result);
6455        *result = merged;
6456    }
6457}
6458
6459/// Accumulate the output a break/continue signal carried (from inner loops it
6460/// propagated through) into the loop that finally handles it, so it survives
6461/// into that loop's result.
6462fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
6463    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
6464        accumulate_result(accumulated, result);
6465    }
6466}
6467
6468/// Check if a value is truthy.
6469fn is_truthy(value: &Value) -> bool {
6470    match value {
6471        Value::Null => false,
6472        Value::Bool(b) => *b,
6473        Value::Int(i) => *i != 0,
6474        Value::Float(f) => *f != 0.0,
6475        Value::String(s) => !s.is_empty(),
6476        Value::Json(json) => match json {
6477            serde_json::Value::Null => false,
6478            serde_json::Value::Array(arr) => !arr.is_empty(),
6479            serde_json::Value::Object(obj) => !obj.is_empty(),
6480            serde_json::Value::Bool(b) => *b,
6481            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
6482            serde_json::Value::String(s) => !s.is_empty(),
6483        },
6484        Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
6485    }
6486}
6487
6488/// Apply tilde expansion to a value.
6489///
6490/// Only string values starting with `~` are expanded. `home` is the session
6491/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
6492/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
6493fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
6494    match value {
6495        Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
6496        _ => value,
6497    }
6498}
6499
6500/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
6501/// how the lexer tokenizes the equivalent minimally-quoted command string —
6502/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
6503/// (`build_args_async`) verbatim instead of carrying a parallel one that could
6504/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
6505/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
6506///
6507/// Classification matches the lexer's word classes:
6508/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
6509///   the binder's `past_double_dash` arms, exactly as for the string door).
6510/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
6511/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
6512///   (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
6513///   they fall through to a positional, not a flag).
6514/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
6515///   binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
6516///   `key=value` positional, per the command's word-assign allowlist).
6517/// - everything else → a literal [`Arg::Positional`].
6518///
6519/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
6520/// positional — it can never be a flag — and rides through as-is. That is the
6521/// typed passthrough the string-native door cannot offer.
6522pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
6523    argv.iter().map(classify_argv_token).collect()
6524}
6525
6526fn classify_argv_token(token: &Value) -> Arg {
6527    let Value::String(s) = token else {
6528        return Arg::Positional(Expr::Literal(token.clone()));
6529    };
6530
6531    if s == "--" {
6532        return Arg::DoubleDash;
6533    }
6534
6535    // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
6536    // long-flag words — the lexer now tokenizes each as one `DoubleDashBare`
6537    // literal word (GH #137), matching this classifier's own literal
6538    // fallback — so they fall through to a literal positional rather than a
6539    // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
6540    if let Some(rest) = s.strip_prefix("--") {
6541        if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
6542            return match rest.split_once('=') {
6543                Some((key, val)) => Arg::Named {
6544                    key: key.to_string(),
6545                    value: Expr::Literal(Value::String(val.to_string())),
6546                },
6547                None => Arg::LongFlag(rest.to_string()),
6548            };
6549        }
6550    } else if let Some(rest) = s.strip_prefix('-') {
6551        // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
6552        // token carrying any other char — notably `=` (`-k=v` is a parse error in
6553        // the string door) — or a leading digit (`-1` lexes as a number) is not a
6554        // short-flag word, so it falls through to a literal positional instead of
6555        // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
6556        if is_short_flag_body(rest) {
6557            return Arg::ShortFlag(rest.to_string());
6558        }
6559    }
6560
6561    if let Some((key, val)) = s.split_once('=') {
6562        if is_shell_identifier(key) {
6563            return Arg::WordAssign {
6564                key: key.to_string(),
6565                value: Expr::Literal(Value::String(val.to_string())),
6566            };
6567        }
6568    }
6569
6570    Arg::Positional(Expr::Literal(Value::String(s.clone())))
6571}
6572
6573/// A short-flag word: a leading ASCII letter, then only ASCII
6574/// letters/digits/`-` (the lexer's base `-[a-zA-Z][a-zA-Z0-9-]*` regex) or `:`
6575/// (which `merge_flag_metachar_adjacent` glues onto a `ShortFlag` for the
6576/// `awk -F:` idiom). `-la`, `-A1`, `-a:` qualify; `-1` (a number), `-k=v`
6577/// (`=` is the assignment operator — a parse error in the string door), and
6578/// any non-ASCII tail (never produced by the lexer, and not safe for the
6579/// combined-short-flag binder's byte-index slicing) do not, so they fall
6580/// through to a literal positional instead of a malformed `ShortFlag`.
6581fn is_short_flag_body(s: &str) -> bool {
6582    s.starts_with(|c: char| c.is_ascii_alphabetic())
6583        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
6584}
6585
6586/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
6587fn is_shell_identifier(s: &str) -> bool {
6588    let mut chars = s.chars();
6589    match chars.next() {
6590        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
6591        _ => return false,
6592    }
6593    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
6594}
6595
6596/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
6597/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
6598/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
6599/// must keep every value, not silently drop all but the last. Used by every flag
6600/// surface that can carry the same flag twice — the space form
6601/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
6602/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
6603/// ordered array.
6604pub(crate) fn push_repeatable_value(
6605    tool_args: &mut ToolArgs,
6606    flag_name: &str,
6607    canonical: &str,
6608    v: Value,
6609) -> anyhow::Result<()> {
6610    let occ = crate::interpreter::value_to_json(&v);
6611    let entry = tool_args
6612        .named
6613        .entry(canonical.to_string())
6614        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
6615    if let Value::Json(serde_json::Value::Array(items)) = entry {
6616        items.push(occ);
6617        Ok(())
6618    } else {
6619        anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
6620    }
6621}
6622
6623/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
6624/// is one token, so it carries a single value: a repeatable flag accumulates
6625/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
6626/// first-char glued arm and the combined-bundle arm so the two can't drift on
6627/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
6628/// is a loud error, not a silent single-value bind.
6629pub(crate) fn bind_glued_short_value(
6630    tool_args: &mut ToolArgs,
6631    flag_name: &str,
6632    canonical: &str,
6633    consumes: usize,
6634    repeatable: bool,
6635    value: String,
6636) -> anyhow::Result<()> {
6637    if consumes > 1 {
6638        anyhow::bail!(
6639            "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
6640        );
6641    }
6642    if repeatable {
6643        push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
6644    } else {
6645        tool_args
6646            .named
6647            .insert(canonical.to_string(), Value::String(value));
6648        Ok(())
6649    }
6650}
6651
6652/// Map a child's exit status to a shell-style exit code.
6653///
6654/// `ExitStatus::code()` is `None` when the process died from a signal rather
6655/// than exiting normally; in that case this maps to POSIX's `128 + signal`
6656/// convention (SIGKILL → 137, SIGTERM → 143, …) instead of losing the signal
6657/// number. Shared by both external-command spawn sites — production
6658/// (`try_execute_external`, below) and the test-only twin
6659/// (`dispatch.rs::BackendDispatcher::try_external`) — so they can't drift on
6660/// this mapping again (GH #133 item 1).
6661#[cfg(feature = "subprocess")]
6662pub(crate) fn exit_code_from_status(status: &std::process::ExitStatus) -> i64 {
6663    status.code().unwrap_or_else(|| {
6664        #[cfg(unix)]
6665        {
6666            use std::os::unix::process::ExitStatusExt;
6667            128 + status.signal().unwrap_or(0)
6668        }
6669        #[cfg(not(unix))]
6670        {
6671            -1
6672        }
6673    }) as i64
6674}
6675
6676/// Wait for a child to exit, killing it if `cancel` fires first.
6677///
6678/// `target` carries a Linux pidfd (when available) for race-free direct-child
6679/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
6680/// parameter is ignored and we use tokio's cross-platform `start_kill`.
6681#[cfg(all(unix, feature = "subprocess"))]
6682pub(crate) async fn wait_or_kill(
6683    child: &mut tokio::process::Child,
6684    target: Option<&crate::pidfd::KillTarget>,
6685    cancel: &tokio_util::sync::CancellationToken,
6686    grace: Duration,
6687) -> std::io::Result<std::process::ExitStatus> {
6688    tokio::select! {
6689        biased;
6690        status = child.wait() => status,
6691        _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
6692    }
6693}
6694
6695#[cfg(all(not(unix), feature = "subprocess"))]
6696pub(crate) async fn wait_or_kill(
6697    child: &mut tokio::process::Child,
6698    _target: Option<&()>,
6699    cancel: &tokio_util::sync::CancellationToken,
6700    _grace: Duration,
6701) -> std::io::Result<std::process::ExitStatus> {
6702    tokio::select! {
6703        biased;
6704        status = child.wait() => status,
6705        _ = cancel.cancelled() => {
6706            let _ = child.start_kill();
6707            child.wait().await
6708        }
6709    }
6710}
6711
6712/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
6713///
6714/// Direct-child kill goes through `target.signal()`, which on Linux uses a
6715/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
6716/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
6717#[cfg(all(unix, feature = "subprocess"))]
6718pub(crate) async fn kill_with_grace(
6719    child: &mut tokio::process::Child,
6720    target: Option<&crate::pidfd::KillTarget>,
6721    grace: Duration,
6722) -> std::io::Result<std::process::ExitStatus> {
6723    use nix::sys::signal::Signal;
6724
6725    if let Some(t) = target {
6726        t.signal(Signal::SIGTERM);
6727        t.signal_pg(Signal::SIGTERM);
6728        if grace > Duration::ZERO
6729            && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
6730        {
6731            return status;
6732        }
6733        t.signal(Signal::SIGKILL);
6734        t.signal_pg(Signal::SIGKILL);
6735    }
6736    child.wait().await
6737}
6738
6739#[cfg(test)]
6740#[allow(clippy::unwrap_used, clippy::expect_used)]
6741mod argv_classify_tests {
6742    use super::*;
6743
6744    /// A normalized, comparable view of one `Arg` representing its *logical
6745    /// argument* (what the command observably receives), not its exact AST shape:
6746    ///
6747    /// - Value-bearing arms compare by *stringified* value, so the parser's
6748    ///   number coercion (`-1`→`Int(-1)`) vs the classifier's literal
6749    ///   (`String("-1")`) count as the same argument.
6750    /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
6751    ///   For every command except the `export`/`alias` allowlist, a bareword
6752    ///   `key=value` is stringified straight back to a `"key=value"` positional
6753    ///   (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
6754    ///   converge observably even when they disagree on the AST tag — e.g. the
6755    ///   lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
6756    ///   `WordAssign`, where the classifier (bash-correctly) makes a positional.
6757    ///   The genuine `WordAssign` *detection* on a real identifier LHS is pinned
6758    ///   separately by `classifies_each_word_class`.
6759    ///
6760    /// Returns `None` for shapes we deliberately don't compare:
6761    /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
6762    /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
6763    ///   lex to `Int`, dropping the literal text, where the classifier keeps the
6764    ///   string. That divergence is *intentional* — `execute_argv` preserves a
6765    ///   literal numeric string (pass `Value::Int` for a number), the string door
6766    ///   can only guess — so the property skips it rather than demanding the
6767    ///   classifier replicate a lossy coercion. Numeric edges are pinned exactly
6768    ///   by `classifies_each_word_class`.
6769    fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
6770        // Only a *string*-valued literal is comparable; a coerced number is not.
6771        let lit = |e: &Expr| match e {
6772            Expr::Literal(Value::String(s)) => Some(s.clone()),
6773            _ => None,
6774        };
6775        Some(match arg {
6776            Arg::DoubleDash => ("dash", String::new(), String::new()),
6777            Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
6778            Arg::LongFlag(s) => ("long", s.clone(), String::new()),
6779            Arg::Positional(e) => ("pos", String::new(), lit(e)?),
6780            Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
6781            Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
6782        })
6783    }
6784
6785    /// Classify a single string token the way `execute_argv` would.
6786    fn classify(token: &str) -> Arg {
6787        classify_argv_token(&Value::String(token.to_string()))
6788    }
6789
6790    #[test]
6791    fn classifies_each_word_class() {
6792        assert_eq!(classify("--"), Arg::DoubleDash);
6793        assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
6794        assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
6795        assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
6796        assert_eq!(
6797            classify("--key=value"),
6798            Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
6799        );
6800        assert_eq!(
6801            classify("NAME=val"),
6802            Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
6803        );
6804        // Digits after the first flag char are ordinary (kept verbatim).
6805        assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
6806        assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
6807        // Leading-digit dash is a number to the lexer, not a flag → positional.
6808        assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
6809        // Numeric strings keep their literal text — `execute_argv` does NOT
6810        // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
6811        // who wants a number passes `Value::Int`; a string stays the string.
6812        assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
6813        assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
6814        // A lone dash (stdin convention) is a positional, not a flag.
6815        assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
6816        // Non-identifier LHS is not an assignment.
6817        assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
6818        assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
6819    }
6820
6821    #[test]
6822    fn typed_values_pass_through_as_literal_positionals() {
6823        // The whole point of the `&[Value]` signature: a non-string value is a
6824        // literal positional carrying the *exact* value, never stringified and
6825        // never flag-interpreted.
6826        let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
6827        assert_eq!(
6828            classify_argv_token(&bytes),
6829            Arg::Positional(Expr::Literal(bytes.clone()))
6830        );
6831        let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
6832        assert_eq!(
6833            classify_argv_token(&json),
6834            Arg::Positional(Expr::Literal(json.clone()))
6835        );
6836        // An integer token that *looks* like a flag is still a positional value
6837        // (only strings are inspected for a leading dash).
6838        assert_eq!(
6839            classify_argv_token(&Value::Int(-9)),
6840            Arg::Positional(Expr::Literal(Value::Int(-9)))
6841        );
6842    }
6843
6844    #[test]
6845    fn double_dash_only_matches_exactly() {
6846        // `--` is the marker; `--x` is a long flag. `---` is not a flag word
6847        // (the lexer lexes it as one `DoubleDashBare` literal word, GH #137);
6848        // as a single argv token here it's likewise literal.
6849        assert_eq!(classify("--"), Arg::DoubleDash);
6850        assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
6851        assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
6852    }
6853
6854    #[test]
6855    fn malformed_flag_words_fall_back_to_literal_positionals() {
6856        // A token that isn't a well-formed flag word must NOT be silently misbound
6857        // into the arg binder (house rule: loud/visible over silent-wrong). Each
6858        // of these is a parse error or different tokenization in the string door,
6859        // so the argv door keeps them as literal positionals.
6860        let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
6861        // `=` is not in the short-flag char class (`-k=v` parse-errors in the
6862        // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
6863        assert_eq!(classify("-k=v"), pos("-k=v"));
6864        assert_eq!(classify("-="), pos("-="));
6865        // Empty long-flag key.
6866        assert_eq!(classify("--=v"), pos("--=v"));
6867        // `--` followed by a non-letter is not a long flag.
6868        assert_eq!(classify("--1"), pos("--1"));
6869        // A bare dash and a number-dash are positionals (covered above too).
6870        assert_eq!(classify("-"), pos("-"));
6871        assert_eq!(classify("-9"), pos("-9"));
6872        // A non-ASCII tail is not part of the lexer's short-flag char class
6873        // (`-[a-zA-Z][a-zA-Z0-9-]*`, plus the `:` the metachar-merge pass
6874        // absorbs) — classifying it as `ShortFlag` would hand the combined
6875        // short-flag binder a byte string it (correctly, for real ASCII flag
6876        // words) slices by *byte* index, panicking on a multi-byte char
6877        // boundary. Fall back to a literal positional instead.
6878        assert_eq!(classify("-lé"), pos("-lé"));
6879        assert_eq!(classify("-é"), pos("-é"));
6880    }
6881
6882    #[tokio::test]
6883    async fn non_ascii_short_flag_bundle_does_not_panic() {
6884        // Regression: `execute_argv`'s combined-short-flag loop assumed the
6885        // flag body was ASCII (safe to byte-slice) because the lexer's
6886        // grammar guarantees that on the *string* door. The argv door's
6887        // classifier let a non-ASCII tail through as `ShortFlag`, so
6888        // `execute_argv("ls", &["-lé"])` sliced mid-codepoint and panicked.
6889        let kernel = Kernel::transient().expect("failed to create kernel");
6890        let result = kernel
6891            .execute_argv("ls", &[Value::String("-lé".into())])
6892            .await
6893            .expect("execute_argv must not panic on a non-ASCII short-flag token");
6894        // Not a well-formed flag word, so it's a literal positional — `ls`
6895        // then reports it as a missing path rather than mangling flags.
6896        assert_ne!(result.code, 0);
6897    }
6898
6899    proptest::proptest! {
6900        /// The core correctness claim: the classifier mirrors the lexer/parser
6901        /// on metacharacter-free tokens. For any such single token, the `Arg`
6902        /// the classifier produces matches the one the real parser produces for
6903        /// the equivalent one-word command — so `execute_argv` reusing the
6904        /// string door's binder is sound. (First proptest in the workspace.)
6905        #[test]
6906        fn classifier_matches_parser_on_clean_tokens(
6907            // No digits: this property tests the *classification* boundary
6908            // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
6909            // positional), not numeric coercion. The lexer coerces digit runs to
6910            // `Int`/`Float` and drops the literal text (even inside a colon-merged
6911            // word: `00:` → `0:`); the classifier intentionally preserves the raw
6912            // string. Those numeric edges are pinned exactly by the unit tests.
6913            token in "[a-zA-Z_=./@:+-]{1,8}"
6914        ) {
6915            let parsed = match parse(&format!("cmd {token}")) {
6916                Ok(p) => p,
6917                Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
6918            };
6919            let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
6920                return Ok(());
6921            };
6922            // Only compare when the token lexed as exactly one argument.
6923            let [arg] = cmd.args.as_slice() else { return Ok(()); };
6924
6925            let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
6926                return Ok(()); // a non-literal parsed Expr we don't model — skip
6927            };
6928            proptest::prop_assert_eq!(
6929                ours, theirs,
6930                "classifier diverged from parser on token {:?}", token
6931            );
6932        }
6933    }
6934}
6935
6936#[cfg(all(test, feature = "subprocess"))]
6937#[allow(clippy::expect_used)]
6938mod tests {
6939    use super::*;
6940
6941    #[tokio::test]
6942    async fn test_kernel_transient() {
6943        let kernel = Kernel::transient().expect("failed to create kernel");
6944        assert_eq!(kernel.name(), "transient");
6945    }
6946
6947    #[tokio::test]
6948    async fn test_kernel_execute_echo() {
6949        let kernel = Kernel::transient().expect("failed to create kernel");
6950        let result = kernel.execute("echo hello").await.expect("execution failed");
6951        assert!(result.ok());
6952        assert_eq!(result.text_out().trim(), "hello");
6953    }
6954
6955    #[tokio::test]
6956    async fn test_multiple_statements_accumulate_output() {
6957        let kernel = Kernel::transient().expect("failed to create kernel");
6958        let result = kernel
6959            .execute("echo one\necho two\necho three")
6960            .await
6961            .expect("execution failed");
6962        assert!(result.ok());
6963        // Should have all three outputs separated by newlines
6964        assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
6965        assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
6966        assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
6967    }
6968
6969    #[tokio::test]
6970    async fn test_and_chain_accumulates_output() {
6971        let kernel = Kernel::transient().expect("failed to create kernel");
6972        let result = kernel
6973            .execute("echo first && echo second")
6974            .await
6975            .expect("execution failed");
6976        assert!(result.ok());
6977        assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
6978        assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
6979    }
6980
6981    #[tokio::test]
6982    async fn test_for_loop_accumulates_output() {
6983        let kernel = Kernel::transient().expect("failed to create kernel");
6984        let result = kernel
6985            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
6986            .await
6987            .expect("execution failed");
6988        assert!(result.ok());
6989        assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
6990        assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
6991        assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
6992    }
6993
6994    #[tokio::test]
6995    async fn test_while_loop_accumulates_output() {
6996        let kernel = Kernel::transient().expect("failed to create kernel");
6997        let result = kernel
6998            .execute(r#"
6999                N=3
7000                while [[ ${N} -gt 0 ]]; do
7001                    echo "N=${N}"
7002                    N=$((N - 1))
7003                done
7004            "#)
7005            .await
7006            .expect("execution failed");
7007        assert!(result.ok());
7008        assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
7009        assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
7010        assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
7011    }
7012
7013    #[tokio::test]
7014    async fn test_kernel_set_var() {
7015        let kernel = Kernel::transient().expect("failed to create kernel");
7016
7017        kernel.execute("X=42").await.expect("set failed");
7018
7019        let value = kernel.get_var("X").await;
7020        assert_eq!(value, Some(Value::Int(42)));
7021    }
7022
7023    #[tokio::test]
7024    async fn test_kernel_var_expansion() {
7025        let kernel = Kernel::transient().expect("failed to create kernel");
7026
7027        kernel.execute("NAME=\"world\"").await.expect("set failed");
7028        let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
7029
7030        assert!(result.ok());
7031        assert_eq!(result.text_out().trim(), "hello world");
7032    }
7033
7034    #[tokio::test]
7035    async fn test_kernel_last_result() {
7036        let kernel = Kernel::transient().expect("failed to create kernel");
7037
7038        kernel.execute("echo test").await.expect("echo failed");
7039
7040        let last = kernel.last_result().await;
7041        assert!(last.ok());
7042        assert_eq!(last.text_out().trim(), "test");
7043    }
7044
7045    #[tokio::test]
7046    async fn test_kernel_tool_not_found() {
7047        let kernel = Kernel::transient().expect("failed to create kernel");
7048
7049        let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
7050        assert!(!result.ok());
7051        assert_eq!(result.code, 127);
7052        assert!(result.err.contains("command not found"));
7053    }
7054
7055    #[tokio::test]
7056    async fn backend_tool_data_content_type_and_baggage_survive_into_exec_result() {
7057        // The embedder seam: a backend-registered tool (kaijutsu, an MCP
7058        // engine, …) returns a `ToolResult` with structured `data` — this
7059        // must reach the caller's `ExecResult` intact so `x=$(embedder_tool)`
7060        // and `for r in $(embedder_tool)` see the typed value, not just
7061        // stdout text.
7062        use crate::backend::testing::MockBackend;
7063        use crate::backend::ToolResult;
7064        let (mock, _calls) = MockBackend::new();
7065        let backend = mock.with_tool_result(|_name| {
7066            let mut baggage = std::collections::BTreeMap::new();
7067            baggage.insert("trace_id".to_string(), "abc123".to_string());
7068            // ToolResult is #[non_exhaustive] (GH #93 item 3/hygiene pass) —
7069            // construct via with_data + the with_* setters, not a struct literal.
7070            Ok(ToolResult::with_data("", serde_json::json!({"key": "value"}))
7071                .with_content_type("application/json")
7072                .with_baggage(baggage))
7073        });
7074        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
7075        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
7076            .expect("with_backend kernel");
7077
7078        let result = kernel
7079            .execute("embedder_tool")
7080            .await
7081            .expect("execution failed");
7082        assert!(result.ok(), "backend tool call should succeed: {result:?}");
7083        assert_eq!(
7084            result.data,
7085            Some(Value::Json(serde_json::json!({"key": "value"}))),
7086            "backend tool's structured data must survive into ExecResult, not be dropped"
7087        );
7088        assert_eq!(
7089            result.content_type.as_deref(),
7090            Some("application/json"),
7091            "backend tool's content_type must survive into ExecResult"
7092        );
7093        assert_eq!(
7094            result.baggage.get("trace_id").map(String::as_str),
7095            Some("abc123"),
7096            "backend tool's baggage must survive into ExecResult"
7097        );
7098    }
7099
7100    #[tokio::test]
7101    async fn backend_tool_execution_error_is_not_reported_as_command_not_found() {
7102        // A backend tool that IS found but fails during execution (`Io`,
7103        // `PermissionDenied`, …) must surface its real error, not get
7104        // misreported as exit-127 "command not found" — that masks a genuine
7105        // failure as a lookup miss.
7106        use crate::backend::testing::MockBackend;
7107        let (mock, _calls) = MockBackend::new();
7108        let backend = mock.with_tool_result(|_name| Err(BackendError::Io("disk exploded".to_string())));
7109        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
7110        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
7111            .expect("with_backend kernel");
7112
7113        let result = kernel
7114            .execute("embedder_tool")
7115            .await
7116            .expect("execution failed");
7117        assert_ne!(result.code, 127, "a real execution error must not look like command-not-found: {result:?}");
7118        assert!(!result.ok());
7119        assert!(
7120            result.err.contains("disk exploded"),
7121            "the real backend error must be visible, not masked: {result:?}"
7122        );
7123    }
7124
7125    #[tokio::test]
7126    async fn test_external_command_true() {
7127        // Use REPL config for passthrough filesystem access
7128        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
7129
7130        // /bin/true should be available on any Unix system
7131        let result = kernel.execute("true").await.expect("execution failed");
7132        // This should use the builtin true, which returns 0
7133        assert!(result.ok(), "true should succeed: {:?}", result);
7134    }
7135
7136    #[tokio::test]
7137    async fn test_external_command_basic() {
7138        // Use REPL config for passthrough filesystem access
7139        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
7140
7141        // Test with /bin/echo which is external
7142        // Note: kaish has a builtin echo, so this will use the builtin
7143        // Let's test with a command that's not a builtin
7144        // Actually, let's just test that PATH resolution works by checking the PATH var
7145        let path_var = std::env::var("PATH").unwrap_or_default();
7146        eprintln!("System PATH: {}", path_var);
7147
7148        // Set PATH in kernel to ensure it's available
7149        kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
7150
7151        // Now try an external command like /usr/bin/env
7152        // But env is also a builtin... let's try uname
7153        let result = kernel.execute("uname").await.expect("execution failed");
7154        eprintln!("uname result: {:?}", result);
7155        // uname should succeed if external commands work
7156        assert!(result.ok() || result.code == 127, "uname: {:?}", result);
7157    }
7158
7159    #[tokio::test]
7160    async fn test_kernel_reset() {
7161        let kernel = Kernel::transient().expect("failed to create kernel");
7162
7163        kernel.execute("X=1").await.expect("set failed");
7164        assert!(kernel.get_var("X").await.is_some());
7165
7166        kernel.reset().await.expect("reset failed");
7167        assert!(kernel.get_var("X").await.is_none());
7168    }
7169
7170    #[tokio::test]
7171    async fn test_kernel_reset_preserves_pid_and_initial_vars() {
7172        let kernel = Kernel::new(KernelConfig::transient().with_var("HOME", Value::String("/home/probe".into())))
7173            .expect("failed to create kernel");
7174
7175        let pid_before = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
7176        assert_eq!(kernel.get_var("HOME").await, Some(Value::String("/home/probe".into())));
7177
7178        kernel.reset().await.expect("reset failed");
7179
7180        let pid_after = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
7181        assert_eq!(pid_before, pid_after, "$$ must stay stable across reset(), not silently renumber");
7182        assert_eq!(
7183            kernel.get_var("HOME").await,
7184            Some(Value::String("/home/probe".into())),
7185            "frontend-seeded initial vars (HOME/PATH) must survive reset(), not silently vanish"
7186        );
7187    }
7188
7189    #[tokio::test]
7190    async fn test_kernel_cwd() {
7191        let kernel = Kernel::transient().expect("failed to create kernel");
7192
7193        // Transient kernel uses sandboxed mode with cwd=$HOME
7194        let cwd = kernel.cwd().await;
7195        let home = std::env::var("HOME")
7196            .map(PathBuf::from)
7197            .unwrap_or_else(|_| PathBuf::from("/"));
7198        assert_eq!(cwd, home);
7199
7200        kernel.set_cwd(PathBuf::from("/tmp")).await;
7201        assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
7202    }
7203
7204    #[tokio::test]
7205    async fn test_kernel_list_vars() {
7206        let kernel = Kernel::transient().expect("failed to create kernel");
7207
7208        kernel.execute("A=1").await.ok();
7209        kernel.execute("B=2").await.ok();
7210
7211        let vars = kernel.list_vars().await;
7212        assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
7213        assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
7214    }
7215
7216    #[tokio::test]
7217    async fn test_is_truthy() {
7218        assert!(!is_truthy(&Value::Null));
7219        assert!(!is_truthy(&Value::Bool(false)));
7220        assert!(is_truthy(&Value::Bool(true)));
7221        assert!(!is_truthy(&Value::Int(0)));
7222        assert!(is_truthy(&Value::Int(1)));
7223        assert!(!is_truthy(&Value::String("".into())));
7224        assert!(is_truthy(&Value::String("x".into())));
7225    }
7226
7227    #[tokio::test]
7228    async fn test_jq_in_pipeline() {
7229        let kernel = Kernel::transient().expect("failed to create kernel");
7230        // kaish uses double quotes only; escape inner quotes
7231        let result = kernel
7232            .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
7233            .await
7234            .expect("execution failed");
7235        assert!(result.ok(), "jq pipeline failed: {}", result.err);
7236        assert_eq!(result.text_out().trim(), "Alice");
7237    }
7238
7239    #[tokio::test]
7240    async fn test_user_defined_tool() {
7241        let kernel = Kernel::transient().expect("failed to create kernel");
7242
7243        // Define a function
7244        kernel
7245            .execute(r#"greet() { echo "Hello, $1!" }"#)
7246            .await
7247            .expect("function definition failed");
7248
7249        // Call the function
7250        let result = kernel
7251            .execute(r#"greet "World""#)
7252            .await
7253            .expect("function call failed");
7254
7255        assert!(result.ok(), "greet failed: {}", result.err);
7256        assert_eq!(result.text_out().trim(), "Hello, World!");
7257    }
7258
7259    #[tokio::test]
7260    async fn test_user_tool_positional_args() {
7261        let kernel = Kernel::transient().expect("failed to create kernel");
7262
7263        // Define a function with positional param
7264        kernel
7265            .execute(r#"greet() { echo "Hi $1" }"#)
7266            .await
7267            .expect("function definition failed");
7268
7269        // Call with positional argument
7270        let result = kernel
7271            .execute(r#"greet "Amy""#)
7272            .await
7273            .expect("function call failed");
7274
7275        assert!(result.ok(), "greet failed: {}", result.err);
7276        assert_eq!(result.text_out().trim(), "Hi Amy");
7277    }
7278
7279    #[tokio::test]
7280    async fn test_function_shared_scope() {
7281        let kernel = Kernel::transient().expect("failed to create kernel");
7282
7283        // Set a variable in parent scope
7284        kernel
7285            .execute(r#"SECRET="hidden""#)
7286            .await
7287            .expect("set failed");
7288
7289        // Define a function that accesses and modifies parent variable
7290        kernel
7291            .execute(r#"access_parent() {
7292                echo "${SECRET}"
7293                SECRET="modified"
7294            }"#)
7295            .await
7296            .expect("function definition failed");
7297
7298        // Call the function - it SHOULD see SECRET (shared scope like sh)
7299        let result = kernel.execute("access_parent").await.expect("function call failed");
7300
7301        // Function should have access to parent scope
7302        assert!(
7303            result.text_out().contains("hidden"),
7304            "Function should access parent scope, got: {}",
7305            result.text_out()
7306        );
7307
7308        // Function should have modified the parent variable
7309        let secret = kernel.get_var("SECRET").await;
7310        assert_eq!(
7311            secret,
7312            Some(Value::String("modified".into())),
7313            "Function should modify parent scope"
7314        );
7315    }
7316
7317    #[tokio::test]
7318    #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
7319    async fn test_exec_builtin() {
7320        let kernel = Kernel::transient().expect("failed to create kernel");
7321        // argv is now a space-separated string or JSON array string
7322        let result = kernel
7323            .execute(r#"exec command="/bin/echo" argv="hello world""#)
7324            .await
7325            .expect("exec failed");
7326
7327        assert!(result.ok(), "exec failed: {}", result.err);
7328        assert_eq!(result.text_out().trim(), "hello world");
7329    }
7330
7331    #[tokio::test]
7332    async fn test_while_false_never_runs() {
7333        let kernel = Kernel::transient().expect("failed to create kernel");
7334
7335        // A while loop with false condition should never run
7336        let result = kernel
7337            .execute(r#"
7338                while false; do
7339                    echo "should not run"
7340                done
7341            "#)
7342            .await
7343            .expect("while false failed");
7344
7345        assert!(result.ok());
7346        assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
7347    }
7348
7349    #[tokio::test]
7350    async fn test_while_string_comparison() {
7351        let kernel = Kernel::transient().expect("failed to create kernel");
7352
7353        // Set a flag
7354        kernel.execute(r#"FLAG="go""#).await.expect("set failed");
7355
7356        // Use string comparison as condition (shell-compatible [[ ]] syntax)
7357        // Note: Put echo last so we can check the output
7358        let result = kernel
7359            .execute(r#"
7360                while [[ ${FLAG} == "go" ]]; do
7361                    FLAG="stop"
7362                    echo "running"
7363                done
7364            "#)
7365            .await
7366            .expect("while with string cmp failed");
7367
7368        assert!(result.ok());
7369        assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
7370
7371        // Verify flag was changed
7372        let flag = kernel.get_var("FLAG").await;
7373        assert_eq!(flag, Some(Value::String("stop".into())));
7374    }
7375
7376    #[tokio::test]
7377    async fn test_while_numeric_comparison() {
7378        let kernel = Kernel::transient().expect("failed to create kernel");
7379
7380        // Test > comparison (shell-compatible [[ ]] with -gt)
7381        kernel.execute("N=5").await.expect("set failed");
7382
7383        // Note: Put echo last so we can check the output
7384        let result = kernel
7385            .execute(r#"
7386                while [[ ${N} -gt 3 ]]; do
7387                    N=3
7388                    echo "N was greater"
7389                done
7390            "#)
7391            .await
7392            .expect("while with > failed");
7393
7394        assert!(result.ok());
7395        assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
7396    }
7397
7398    #[tokio::test]
7399    async fn test_break_in_while_loop() {
7400        let kernel = Kernel::transient().expect("failed to create kernel");
7401
7402        let result = kernel
7403            .execute(r#"
7404                I=0
7405                while true; do
7406                    I=1
7407                    echo "before break"
7408                    break
7409                    echo "after break"
7410                done
7411            "#)
7412            .await
7413            .expect("while with break failed");
7414
7415        assert!(result.ok());
7416        assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
7417        assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
7418
7419        // Verify we exited the loop
7420        let i = kernel.get_var("I").await;
7421        assert_eq!(i, Some(Value::Int(1)));
7422    }
7423
7424    #[tokio::test]
7425    async fn test_continue_in_while_loop() {
7426        let kernel = Kernel::transient().expect("failed to create kernel");
7427
7428        // Test continue in a while loop where variables persist
7429        // We use string state transition: "start" -> "middle" -> "end"
7430        // continue on "middle" should skip to next iteration
7431        // Shell-compatible: use [[ ]] for comparisons
7432        let result = kernel
7433            .execute(r#"
7434                STATE="start"
7435                AFTER_CONTINUE="no"
7436                while [[ ${STATE} != "done" ]]; do
7437                    if [[ ${STATE} == "start" ]]; then
7438                        STATE="middle"
7439                        continue
7440                        AFTER_CONTINUE="yes"
7441                    fi
7442                    if [[ ${STATE} == "middle" ]]; then
7443                        STATE="done"
7444                    fi
7445                done
7446            "#)
7447            .await
7448            .expect("while with continue failed");
7449
7450        assert!(result.ok());
7451
7452        // STATE should be "done" (we completed the loop)
7453        let state = kernel.get_var("STATE").await;
7454        assert_eq!(state, Some(Value::String("done".into())));
7455
7456        // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
7457        let after = kernel.get_var("AFTER_CONTINUE").await;
7458        assert_eq!(after, Some(Value::String("no".into())));
7459    }
7460
7461    #[tokio::test]
7462    async fn test_break_with_level() {
7463        let kernel = Kernel::transient().expect("failed to create kernel");
7464
7465        // Nested loop with break 2 to exit both loops
7466        // We verify by checking OUTER value:
7467        // - If break 2 works, OUTER stays at 1 (set before for loop)
7468        // - If break 2 fails, OUTER becomes 2 (set after for loop)
7469        let result = kernel
7470            .execute(r#"
7471                OUTER=0
7472                while true; do
7473                    OUTER=1
7474                    for X in "1 2"; do
7475                        break 2
7476                    done
7477                    OUTER=2
7478                done
7479            "#)
7480            .await
7481            .expect("nested break failed");
7482
7483        assert!(result.ok());
7484
7485        // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
7486        let outer = kernel.get_var("OUTER").await;
7487        assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
7488    }
7489
7490    #[tokio::test]
7491    async fn test_return_from_tool() {
7492        let kernel = Kernel::transient().expect("failed to create kernel");
7493
7494        // Define a function that returns early
7495        kernel
7496            .execute(r#"early_return() {
7497                if [[ $1 == 1 ]]; then
7498                    return 42
7499                fi
7500                echo "not returned"
7501            }"#)
7502            .await
7503            .expect("function definition failed");
7504
7505        // Call with arg=1 should return with exit code 42
7506        // (POSIX shell behavior: return N sets exit code, doesn't output N)
7507        let result = kernel
7508            .execute("early_return 1")
7509            .await
7510            .expect("function call failed");
7511
7512        // Exit code should be 42 (non-zero, so not ok())
7513        assert_eq!(result.code, 42);
7514        // Output should be empty (we returned before echo)
7515        assert!(result.text_out().is_empty());
7516    }
7517
7518    #[tokio::test]
7519    async fn test_return_without_value() {
7520        let kernel = Kernel::transient().expect("failed to create kernel");
7521
7522        // Define a function that returns without a value
7523        kernel
7524            .execute(r#"early_exit() {
7525                if [[ $1 == "stop" ]]; then
7526                    return
7527                fi
7528                echo "continued"
7529            }"#)
7530            .await
7531            .expect("function definition failed");
7532
7533        // Call with arg="stop" should return early
7534        let result = kernel
7535            .execute(r#"early_exit "stop""#)
7536            .await
7537            .expect("function call failed");
7538
7539        assert!(result.ok());
7540        assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
7541    }
7542
7543    #[tokio::test]
7544    async fn test_exit_stops_execution() {
7545        let kernel = Kernel::transient().expect("failed to create kernel");
7546
7547        // exit should stop further execution
7548        kernel
7549            .execute(r#"
7550                BEFORE="yes"
7551                exit 0
7552                AFTER="yes"
7553            "#)
7554            .await
7555            .expect("execution failed");
7556
7557        // BEFORE should be set, AFTER should not
7558        let before = kernel.get_var("BEFORE").await;
7559        assert_eq!(before, Some(Value::String("yes".into())));
7560
7561        let after = kernel.get_var("AFTER").await;
7562        assert!(after.is_none(), "AFTER should not be set after exit");
7563    }
7564
7565    #[tokio::test]
7566    async fn test_exit_with_code() {
7567        let kernel = Kernel::transient().expect("failed to create kernel");
7568
7569        // exit with code should propagate the exit code
7570        let result = kernel
7571            .execute("exit 42")
7572            .await
7573            .expect("exit failed");
7574
7575        assert_eq!(result.code, 42);
7576        assert!(result.text_out().is_empty(), "exit should not produce stdout");
7577    }
7578
7579    #[tokio::test]
7580    async fn test_set_e_stops_on_failure() {
7581        let kernel = Kernel::transient().expect("failed to create kernel");
7582
7583        // Enable error-exit mode
7584        kernel.execute("set -e").await.expect("set -e failed");
7585
7586        // Run a sequence where the middle command fails
7587        kernel
7588            .execute(r#"
7589                STEP1="done"
7590                false
7591                STEP2="done"
7592            "#)
7593            .await
7594            .expect("execution failed");
7595
7596        // STEP1 should be set, but STEP2 should NOT be set (exit on false)
7597        let step1 = kernel.get_var("STEP1").await;
7598        assert_eq!(step1, Some(Value::String("done".into())));
7599
7600        let step2 = kernel.get_var("STEP2").await;
7601        assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
7602    }
7603
7604    #[tokio::test]
7605    async fn test_set_plus_e_disables_error_exit() {
7606        let kernel = Kernel::transient().expect("failed to create kernel");
7607
7608        // Enable then disable error-exit mode
7609        kernel.execute("set -e").await.expect("set -e failed");
7610        kernel.execute("set +e").await.expect("set +e failed");
7611
7612        // Now failure should NOT stop execution
7613        kernel
7614            .execute(r#"
7615                STEP1="done"
7616                false
7617                STEP2="done"
7618            "#)
7619            .await
7620            .expect("execution failed");
7621
7622        // Both should be set since +e disables error exit
7623        let step1 = kernel.get_var("STEP1").await;
7624        assert_eq!(step1, Some(Value::String("done".into())));
7625
7626        let step2 = kernel.get_var("STEP2").await;
7627        assert_eq!(step2, Some(Value::String("done".into())));
7628    }
7629
7630    #[tokio::test]
7631    async fn test_set_ignores_unknown_options() {
7632        let kernel = Kernel::transient().expect("failed to create kernel");
7633
7634        // Bash idiom: set -euo pipefail (we support -e, ignore the rest)
7635        let result = kernel
7636            .execute("set -e -u -o pipefail")
7637            .await
7638            .expect("set with unknown options failed");
7639
7640        assert!(result.ok(), "set should succeed with unknown options");
7641
7642        // -e should still be enabled
7643        kernel
7644            .execute(r#"
7645                BEFORE="yes"
7646                false
7647                AFTER="yes"
7648            "#)
7649            .await
7650            .ok();
7651
7652        let after = kernel.get_var("AFTER").await;
7653        assert!(after.is_none(), "-e should be enabled despite unknown options");
7654    }
7655
7656    #[tokio::test]
7657    async fn test_set_no_args_shows_settings() {
7658        let kernel = Kernel::transient().expect("failed to create kernel");
7659
7660        // Enable -e
7661        kernel.execute("set -e").await.expect("set -e failed");
7662
7663        // Call set with no args to see settings
7664        let result = kernel.execute("set").await.expect("set failed");
7665
7666        assert!(result.ok());
7667        assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
7668    }
7669
7670    #[tokio::test]
7671    async fn test_set_e_in_pipeline() {
7672        let kernel = Kernel::transient().expect("failed to create kernel");
7673
7674        kernel.execute("set -e").await.expect("set -e failed");
7675
7676        // Pipeline failure should trigger exit
7677        kernel
7678            .execute(r#"
7679                BEFORE="yes"
7680                false | cat
7681                AFTER="yes"
7682            "#)
7683            .await
7684            .ok();
7685
7686        let before = kernel.get_var("BEFORE").await;
7687        assert_eq!(before, Some(Value::String("yes".into())));
7688
7689        // AFTER should not be set if pipeline failure triggers exit
7690        // Note: The exit code of a pipeline is the exit code of the last command
7691        // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
7692        // To test pipeline failure, we need the last command to fail.
7693    }
7694
7695    #[tokio::test]
7696    async fn test_set_e_with_and_chain() {
7697        let kernel = Kernel::transient().expect("failed to create kernel");
7698
7699        kernel.execute("set -e").await.expect("set -e failed");
7700
7701        // Commands in && chain should not trigger -e on the first failure
7702        // because && explicitly handles the error
7703        kernel
7704            .execute(r#"
7705                RESULT="initial"
7706                false && RESULT="chained"
7707                RESULT="continued"
7708            "#)
7709            .await
7710            .ok();
7711
7712        // In bash, commands in && don't trigger -e. The chain handles the failure.
7713        // Our implementation may differ - let's verify current behavior.
7714        let result = kernel.get_var("RESULT").await;
7715        // If we follow bash semantics, RESULT should be "continued"
7716        // If we trigger -e on the false, RESULT stays "initial"
7717        assert!(result.is_some(), "RESULT should be set");
7718    }
7719
7720    #[tokio::test]
7721    async fn test_set_e_exits_in_for_loop() {
7722        let kernel = Kernel::transient().expect("failed to create kernel");
7723
7724        kernel.execute("set -e").await.expect("set -e failed");
7725
7726        kernel
7727            .execute(r#"
7728                REACHED="no"
7729                for x in 1 2 3; do
7730                    false
7731                    REACHED="yes"
7732                done
7733            "#)
7734            .await
7735            .ok();
7736
7737        // With set -e, false should trigger exit; REACHED should remain "no"
7738        let reached = kernel.get_var("REACHED").await;
7739        assert_eq!(reached, Some(Value::String("no".into())),
7740            "set -e should exit on failure in for loop body");
7741    }
7742
7743    #[tokio::test]
7744    async fn test_for_loop_continues_without_set_e() {
7745        let kernel = Kernel::transient().expect("failed to create kernel");
7746
7747        // Without set -e, for loop should continue normally
7748        kernel
7749            .execute(r#"
7750                COUNT=0
7751                for x in 1 2 3; do
7752                    false
7753                    COUNT=$((COUNT + 1))
7754                done
7755            "#)
7756            .await
7757            .ok();
7758
7759        let count = kernel.get_var("COUNT").await;
7760        // Arithmetic produces Int values; accept either Int or String representation
7761        let count_val = match &count {
7762            Some(Value::Int(n)) => *n,
7763            Some(Value::String(s)) => s.parse().unwrap_or(-1),
7764            _ => -1,
7765        };
7766        assert_eq!(count_val, 3,
7767            "without set -e, loop should complete all iterations (got {:?})", count);
7768    }
7769
7770    // ═══════════════════════════════════════════════════════════════════════════
7771    // Source Tests
7772    // ═══════════════════════════════════════════════════════════════════════════
7773
7774    #[tokio::test]
7775    async fn test_source_sets_variables() {
7776        let kernel = Kernel::transient().expect("failed to create kernel");
7777
7778        // Write a script to the VFS
7779        kernel
7780            .execute(r#"write "/test.kai" 'FOO="bar"'"#)
7781            .await
7782            .expect("write failed");
7783
7784        // Source the script
7785        let result = kernel
7786            .execute(r#"source "/test.kai""#)
7787            .await
7788            .expect("source failed");
7789
7790        assert!(result.ok(), "source should succeed");
7791
7792        // Variable should be set in current scope
7793        let foo = kernel.get_var("FOO").await;
7794        assert_eq!(foo, Some(Value::String("bar".into())));
7795    }
7796
7797    #[tokio::test]
7798    async fn test_source_with_dot_alias() {
7799        let kernel = Kernel::transient().expect("failed to create kernel");
7800
7801        // Write a script to the VFS
7802        kernel
7803            .execute(r#"write "/vars.kai" 'X=42'"#)
7804            .await
7805            .expect("write failed");
7806
7807        // Source using . alias
7808        let result = kernel
7809            .execute(r#". "/vars.kai""#)
7810            .await
7811            .expect(". failed");
7812
7813        assert!(result.ok(), ". should succeed");
7814
7815        // Variable should be set in current scope
7816        let x = kernel.get_var("X").await;
7817        assert_eq!(x, Some(Value::Int(42)));
7818    }
7819
7820    #[tokio::test]
7821    async fn test_source_not_found() {
7822        let kernel = Kernel::transient().expect("failed to create kernel");
7823
7824        // Try to source a non-existent file
7825        let result = kernel
7826            .execute(r#"source "/nonexistent.kai""#)
7827            .await
7828            .expect("source should not fail with error");
7829
7830        assert!(!result.ok(), "source of non-existent file should fail");
7831        assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
7832    }
7833
7834    #[tokio::test]
7835    async fn test_source_missing_filename() {
7836        let kernel = Kernel::transient().expect("failed to create kernel");
7837
7838        // Call source with no arguments
7839        let result = kernel
7840            .execute("source")
7841            .await
7842            .expect("source should not fail with error");
7843
7844        assert!(!result.ok(), "source without filename should fail");
7845        assert!(result.err.contains("missing filename"), "error should mention missing filename");
7846    }
7847
7848    #[tokio::test]
7849    async fn test_source_executes_multiple_statements() {
7850        let kernel = Kernel::transient().expect("failed to create kernel");
7851
7852        // Write a script with multiple statements
7853        kernel
7854            .execute(r#"write "/multi.kai" 'A=1
7855B=2
7856C=3'"#)
7857            .await
7858            .expect("write failed");
7859
7860        // Source it
7861        kernel
7862            .execute(r#"source "/multi.kai""#)
7863            .await
7864            .expect("source failed");
7865
7866        // All variables should be set
7867        assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
7868        assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
7869        assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
7870    }
7871
7872    #[tokio::test]
7873    async fn test_source_can_define_functions() {
7874        let kernel = Kernel::transient().expect("failed to create kernel");
7875
7876        // Write a script that defines a function
7877        kernel
7878            .execute(r#"write "/functions.kai" 'greet() {
7879    echo "Hello, $1!"
7880}'"#)
7881            .await
7882            .expect("write failed");
7883
7884        // Source it
7885        kernel
7886            .execute(r#"source "/functions.kai""#)
7887            .await
7888            .expect("source failed");
7889
7890        // Use the defined function
7891        let result = kernel
7892            .execute(r#"greet "World""#)
7893            .await
7894            .expect("greet failed");
7895
7896        assert!(result.ok());
7897        assert!(result.text_out().contains("Hello, World!"));
7898    }
7899
7900    #[tokio::test]
7901    async fn test_source_inherits_error_exit() {
7902        let kernel = Kernel::transient().expect("failed to create kernel");
7903
7904        // Enable error exit
7905        kernel.execute("set -e").await.expect("set -e failed");
7906
7907        // Write a script that has a failure
7908        kernel
7909            .execute(r#"write "/fail.kai" 'BEFORE="yes"
7910false
7911AFTER="yes"'"#)
7912            .await
7913            .expect("write failed");
7914
7915        // Source it (should exit on false due to set -e)
7916        kernel
7917            .execute(r#"source "/fail.kai""#)
7918            .await
7919            .ok();
7920
7921        // BEFORE should be set, AFTER should NOT be set due to error exit
7922        let before = kernel.get_var("BEFORE").await;
7923        assert_eq!(before, Some(Value::String("yes".into())));
7924
7925        // Note: This test depends on whether error exit is checked within source
7926        // Currently our implementation checks per-statement in the main kernel
7927    }
7928
7929    // ═══════════════════════════════════════════════════════════════════════════
7930    // set -e with && / || chains
7931    // ═══════════════════════════════════════════════════════════════════════════
7932
7933    #[tokio::test]
7934    async fn test_set_e_and_chain_left_fails() {
7935        // set -e; false && echo hi; REACHED=1 → REACHED should be set
7936        let kernel = Kernel::transient().expect("failed to create kernel");
7937        kernel.execute("set -e").await.expect("set -e failed");
7938
7939        kernel
7940            .execute("false && echo hi; REACHED=1")
7941            .await
7942            .expect("execution failed");
7943
7944        let reached = kernel.get_var("REACHED").await;
7945        assert_eq!(
7946            reached,
7947            Some(Value::Int(1)),
7948            "set -e should not trigger on left side of &&"
7949        );
7950    }
7951
7952    #[tokio::test]
7953    async fn test_set_e_and_chain_right_fails() {
7954        // set -e; true && false; REACHED=1 → REACHED should NOT be set
7955        let kernel = Kernel::transient().expect("failed to create kernel");
7956        kernel.execute("set -e").await.expect("set -e failed");
7957
7958        kernel
7959            .execute("true && false; REACHED=1")
7960            .await
7961            .expect("execution failed");
7962
7963        let reached = kernel.get_var("REACHED").await;
7964        assert!(
7965            reached.is_none(),
7966            "set -e should trigger when right side of && fails"
7967        );
7968    }
7969
7970    #[tokio::test]
7971    async fn test_set_e_or_chain_recovers() {
7972        // set -e; false || echo recovered; REACHED=1 → REACHED should be set
7973        let kernel = Kernel::transient().expect("failed to create kernel");
7974        kernel.execute("set -e").await.expect("set -e failed");
7975
7976        kernel
7977            .execute("false || echo recovered; REACHED=1")
7978            .await
7979            .expect("execution failed");
7980
7981        let reached = kernel.get_var("REACHED").await;
7982        assert_eq!(
7983            reached,
7984            Some(Value::Int(1)),
7985            "set -e should not trigger when || recovers the failure"
7986        );
7987    }
7988
7989    #[tokio::test]
7990    async fn test_set_e_or_chain_both_fail() {
7991        // set -e; false || false; REACHED=1 → REACHED should NOT be set
7992        let kernel = Kernel::transient().expect("failed to create kernel");
7993        kernel.execute("set -e").await.expect("set -e failed");
7994
7995        kernel
7996            .execute("false || false; REACHED=1")
7997            .await
7998            .expect("execution failed");
7999
8000        let reached = kernel.get_var("REACHED").await;
8001        assert!(
8002            reached.is_none(),
8003            "set -e should trigger when || chain ultimately fails"
8004        );
8005    }
8006
8007    // ═══════════════════════════════════════════════════════════════════════════
8008    // Cancellation Tests
8009    // ═══════════════════════════════════════════════════════════════════════════
8010
8011    /// Helper: schedule a cancel after a delay from a background thread.
8012    /// Uses std::thread because cancel() is sync and Kernel is not Send.
8013    fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
8014        let k = Arc::clone(kernel);
8015        std::thread::spawn(move || {
8016            std::thread::sleep(delay);
8017            k.cancel();
8018        });
8019    }
8020
8021    #[tokio::test]
8022    async fn test_cancel_interrupts_for_loop() {
8023        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
8024
8025        // Schedule cancel after a short delay from a background OS thread
8026        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
8027
8028        // #149: a bare `X=$i` body has no await point, so the for-loop's
8029        // cancellation checkpoint (checked once per iteration, see the
8030        // `Stmt::For` arm above) never gets a chance to run mid-body — under
8031        // host load, 100_000 trivial iterations could complete and return
8032        // before the background thread's 10ms sleep ever elapsed, racing a
8033        // natural exit-0 completion against the scheduled cancel. Rather than
8034        // widen the margin (there's no bound on how slow "under load" can be),
8035        // make completion deterministically impossible inside the test
8036        // window: `sleep` is a real interruptible await point (it races
8037        // `tokio::time::sleep` against the same cancellation token — see
8038        // `tools/builtin/sleep.rs`), so a per-iteration sleep both gives
8039        // cancellation somewhere to land almost immediately AND, at enough
8040        // iterations, makes natural completion take far longer than the
8041        // bounded wait below. The outer timeout is the "must not hang CI if
8042        // cancellation is broken" backstop: it fails loudly well before the
8043        // loop could ever finish on its own.
8044        const ITERATIONS: u32 = 2000;
8045        const PER_ITERATION_SLEEP_SECS: f64 = 0.05;
8046        let bound = std::time::Duration::from_secs(10);
8047        let script = format!("for i in $(seq 1 {ITERATIONS}); do X=$i; sleep {PER_ITERATION_SLEEP_SECS}; done");
8048
8049        let result = tokio::time::timeout(bound, kernel.execute(&script))
8050            .await
8051            .unwrap_or_else(|_| {
8052                panic!(
8053                    "for-loop did not return within {bound:?} — cancellation support looks \
8054                     broken (an uncancelled loop needs ~{:.0}s to finish on its own, far \
8055                     longer than this bound)",
8056                    ITERATIONS as f64 * PER_ITERATION_SLEEP_SECS
8057                )
8058            })
8059            .expect("execute failed");
8060
8061        assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
8062
8063        // The loop variable should be set to something well short of the full
8064        // iteration count — i.e. cancellation landed long before the loop
8065        // could complete on its own.
8066        let x = kernel.get_var("X").await;
8067        if let Some(Value::Int(n)) = x {
8068            assert!(
8069                n < i64::from(ITERATIONS),
8070                "loop should have been interrupted before finishing, got X={n}"
8071            );
8072        }
8073    }
8074
8075    #[tokio::test]
8076    async fn test_cancel_interrupts_while_loop() {
8077        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
8078        kernel.execute("COUNT=0").await.expect("init failed");
8079
8080        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
8081
8082        let result = kernel
8083            .execute("while true; do COUNT=$((COUNT + 1)); done")
8084            .await
8085            .expect("execute failed");
8086
8087        assert_eq!(result.code, 130);
8088
8089        let count = kernel.get_var("COUNT").await;
8090        if let Some(Value::Int(n)) = count {
8091            assert!(n > 0, "loop should have run at least once");
8092        }
8093    }
8094
8095    #[tokio::test]
8096    async fn test_reset_after_cancel() {
8097        // After cancellation, the next execute() should work normally
8098        let kernel = Kernel::transient().expect("failed to create kernel");
8099        kernel.cancel(); // cancel with nothing running
8100
8101        let result = kernel.execute("echo hello").await.expect("execute failed");
8102        assert!(result.ok(), "execute after cancel should succeed");
8103        assert_eq!(result.text_out().trim(), "hello");
8104    }
8105
8106    #[tokio::test]
8107    async fn test_cancel_interrupts_statement_sequence() {
8108        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
8109
8110        // Schedule cancel after the first statement runs but before sleep finishes
8111        schedule_cancel(&kernel, std::time::Duration::from_millis(50));
8112
8113        let result = kernel
8114            .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
8115            .await
8116            .expect("execute failed");
8117
8118        assert_eq!(result.code, 130);
8119
8120        // STEP should be 1 (set before sleep), not 2 or 3
8121        let step = kernel.get_var("STEP").await;
8122        assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
8123    }
8124
8125    // ═══════════════════════════════════════════════════════════════════════════
8126    // Case Statement Tests
8127    // ═══════════════════════════════════════════════════════════════════════════
8128
8129    #[tokio::test]
8130    async fn test_case_simple_match() {
8131        let kernel = Kernel::transient().expect("failed to create kernel");
8132
8133        let result = kernel
8134            .execute(r#"
8135                case "hello" in
8136                    hello) echo "matched hello" ;;
8137                    world) echo "matched world" ;;
8138                esac
8139            "#)
8140            .await
8141            .expect("case failed");
8142
8143        assert!(result.ok());
8144        assert_eq!(result.text_out().trim(), "matched hello");
8145    }
8146
8147    #[tokio::test]
8148    async fn test_case_wildcard_match() {
8149        let kernel = Kernel::transient().expect("failed to create kernel");
8150
8151        let result = kernel
8152            .execute(r#"
8153                case "main.rs" in
8154                    *.py) echo "Python" ;;
8155                    *.rs) echo "Rust" ;;
8156                    *) echo "Unknown" ;;
8157                esac
8158            "#)
8159            .await
8160            .expect("case failed");
8161
8162        assert!(result.ok());
8163        assert_eq!(result.text_out().trim(), "Rust");
8164    }
8165
8166    #[tokio::test]
8167    async fn test_case_default_match() {
8168        let kernel = Kernel::transient().expect("failed to create kernel");
8169
8170        let result = kernel
8171            .execute(r#"
8172                case "unknown.xyz" in
8173                    *.py) echo "Python" ;;
8174                    *.rs) echo "Rust" ;;
8175                    *) echo "Default" ;;
8176                esac
8177            "#)
8178            .await
8179            .expect("case failed");
8180
8181        assert!(result.ok());
8182        assert_eq!(result.text_out().trim(), "Default");
8183    }
8184
8185    #[tokio::test]
8186    async fn test_case_no_match() {
8187        let kernel = Kernel::transient().expect("failed to create kernel");
8188
8189        // Case with no default branch and no match
8190        let result = kernel
8191            .execute(r#"
8192                case "nope" in
8193                    "yes") echo "yes" ;;
8194                    "no") echo "no" ;;
8195                esac
8196            "#)
8197            .await
8198            .expect("case failed");
8199
8200        assert!(result.ok());
8201        assert!(result.text_out().is_empty(), "no match should produce empty output");
8202    }
8203
8204    #[tokio::test]
8205    async fn test_case_with_variable() {
8206        let kernel = Kernel::transient().expect("failed to create kernel");
8207
8208        kernel.execute(r#"LANG="rust""#).await.expect("set failed");
8209
8210        let result = kernel
8211            .execute(r#"
8212                case ${LANG} in
8213                    python) echo "snake" ;;
8214                    rust) echo "crab" ;;
8215                    go) echo "gopher" ;;
8216                esac
8217            "#)
8218            .await
8219            .expect("case failed");
8220
8221        assert!(result.ok());
8222        assert_eq!(result.text_out().trim(), "crab");
8223    }
8224
8225    #[tokio::test]
8226    async fn test_case_multiple_patterns() {
8227        let kernel = Kernel::transient().expect("failed to create kernel");
8228
8229        let result = kernel
8230            .execute(r#"
8231                case "yes" in
8232                    "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
8233                    "n"|"no"|"N"|"NO") echo "negative" ;;
8234                esac
8235            "#)
8236            .await
8237            .expect("case failed");
8238
8239        assert!(result.ok());
8240        assert_eq!(result.text_out().trim(), "affirmative");
8241    }
8242
8243    #[tokio::test]
8244    async fn test_case_glob_question_mark() {
8245        let kernel = Kernel::transient().expect("failed to create kernel");
8246
8247        let result = kernel
8248            .execute(r#"
8249                case "test1" in
8250                    test?) echo "matched test?" ;;
8251                    *) echo "default" ;;
8252                esac
8253            "#)
8254            .await
8255            .expect("case failed");
8256
8257        assert!(result.ok());
8258        assert_eq!(result.text_out().trim(), "matched test?");
8259    }
8260
8261    #[tokio::test]
8262    async fn test_case_char_class() {
8263        let kernel = Kernel::transient().expect("failed to create kernel");
8264
8265        let result = kernel
8266            .execute(r#"
8267                case "Yes" in
8268                    [Yy]*) echo "yes-like" ;;
8269                    [Nn]*) echo "no-like" ;;
8270                esac
8271            "#)
8272            .await
8273            .expect("case failed");
8274
8275        assert!(result.ok());
8276        assert_eq!(result.text_out().trim(), "yes-like");
8277    }
8278
8279    // ═══════════════════════════════════════════════════════════════════════════
8280    // Cat Stdin Tests
8281    // ═══════════════════════════════════════════════════════════════════════════
8282
8283    #[tokio::test]
8284    async fn test_cat_from_pipeline() {
8285        let kernel = Kernel::transient().expect("failed to create kernel");
8286
8287        let result = kernel
8288            .execute(r#"echo "piped text" | cat"#)
8289            .await
8290            .expect("cat pipeline failed");
8291
8292        assert!(result.ok(), "cat failed: {}", result.err);
8293        assert_eq!(result.text_out().trim(), "piped text");
8294    }
8295
8296    #[tokio::test]
8297    async fn test_cat_from_pipeline_multiline() {
8298        let kernel = Kernel::transient().expect("failed to create kernel");
8299
8300        let result = kernel
8301            .execute(r#"echo "line1\nline2" | cat -n"#)
8302            .await
8303            .expect("cat pipeline failed");
8304
8305        assert!(result.ok(), "cat failed: {}", result.err);
8306        assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
8307    }
8308
8309    // ═══════════════════════════════════════════════════════════════════════════
8310    // Heredoc Tests
8311    // ═══════════════════════════════════════════════════════════════════════════
8312
8313    #[tokio::test]
8314    async fn test_heredoc_basic() {
8315        let kernel = Kernel::transient().expect("failed to create kernel");
8316
8317        let result = kernel
8318            .execute("cat <<EOF\nhello\nEOF")
8319            .await
8320            .expect("heredoc failed");
8321
8322        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
8323        assert_eq!(result.text_out().trim(), "hello");
8324    }
8325
8326    #[tokio::test]
8327    async fn test_arithmetic_in_string() {
8328        let kernel = Kernel::transient().expect("failed to create kernel");
8329
8330        let result = kernel
8331            .execute(r#"echo "result: $((1 + 2))""#)
8332            .await
8333            .expect("arithmetic in string failed");
8334
8335        assert!(result.ok(), "echo failed: {}", result.err);
8336        assert_eq!(result.text_out().trim(), "result: 3");
8337    }
8338
8339    #[tokio::test]
8340    async fn test_heredoc_multiline() {
8341        let kernel = Kernel::transient().expect("failed to create kernel");
8342
8343        let result = kernel
8344            .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
8345            .await
8346            .expect("heredoc failed");
8347
8348        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
8349        assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
8350        assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
8351        assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
8352    }
8353
8354    #[tokio::test]
8355    async fn test_heredoc_variable_expansion() {
8356        // Bug N: unquoted heredoc should expand variables
8357        let kernel = Kernel::transient().expect("failed to create kernel");
8358
8359        kernel.execute("GREETING=hello").await.expect("set var");
8360
8361        let result = kernel
8362            .execute("cat <<EOF\n$GREETING world\nEOF")
8363            .await
8364            .expect("heredoc expansion failed");
8365
8366        assert!(result.ok(), "heredoc expansion failed: {}", result.err);
8367        assert_eq!(result.text_out().trim(), "hello world");
8368    }
8369
8370    #[tokio::test]
8371    async fn test_heredoc_quoted_no_expansion() {
8372        // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
8373        let kernel = Kernel::transient().expect("failed to create kernel");
8374
8375        kernel.execute("GREETING=hello").await.expect("set var");
8376
8377        let result = kernel
8378            .execute("cat <<'EOF'\n$GREETING world\nEOF")
8379            .await
8380            .expect("quoted heredoc failed");
8381
8382        assert!(result.ok(), "quoted heredoc failed: {}", result.err);
8383        assert_eq!(result.text_out().trim(), "$GREETING world");
8384    }
8385
8386    #[tokio::test]
8387    async fn test_heredoc_default_value_expansion() {
8388        // Bug N: ${VAR:-default} should expand in unquoted heredocs
8389        let kernel = Kernel::transient().expect("failed to create kernel");
8390
8391        let result = kernel
8392            .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
8393            .await
8394            .expect("heredoc default expansion failed");
8395
8396        assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
8397        assert_eq!(result.text_out().trim(), "fallback");
8398    }
8399
8400    // ═══════════════════════════════════════════════════════════════════════════
8401    // Read Builtin Tests
8402    // ═══════════════════════════════════════════════════════════════════════════
8403
8404    #[tokio::test]
8405    async fn test_read_from_pipeline() {
8406        let kernel = Kernel::transient().expect("failed to create kernel");
8407
8408        // Pipe input to read
8409        let result = kernel
8410            .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
8411            .await
8412            .expect("read pipeline failed");
8413
8414        assert!(result.ok(), "read failed: {}", result.err);
8415        assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
8416    }
8417
8418    #[tokio::test]
8419    async fn test_read_multiple_vars_from_pipeline() {
8420        let kernel = Kernel::transient().expect("failed to create kernel");
8421
8422        let result = kernel
8423            .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
8424            .await
8425            .expect("read pipeline failed");
8426
8427        assert!(result.ok(), "read failed: {}", result.err);
8428        assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
8429    }
8430
8431    // ═══════════════════════════════════════════════════════════════════════════
8432    // Shell-Style Function Tests
8433    // ═══════════════════════════════════════════════════════════════════════════
8434
8435    #[tokio::test]
8436    async fn test_posix_function_with_positional_params() {
8437        let kernel = Kernel::transient().expect("failed to create kernel");
8438
8439        // Define POSIX-style function
8440        kernel
8441            .execute(r#"greet() { echo "Hello, $1!" }"#)
8442            .await
8443            .expect("function definition failed");
8444
8445        // Call the function
8446        let result = kernel
8447            .execute(r#"greet "Amy""#)
8448            .await
8449            .expect("function call failed");
8450
8451        assert!(result.ok(), "greet failed: {}", result.err);
8452        assert_eq!(result.text_out().trim(), "Hello, Amy!");
8453    }
8454
8455    #[tokio::test]
8456    async fn test_posix_function_multiple_args() {
8457        let kernel = Kernel::transient().expect("failed to create kernel");
8458
8459        // Define function using $1 and $2
8460        kernel
8461            .execute(r#"add_greeting() { echo "$1 $2!" }"#)
8462            .await
8463            .expect("function definition failed");
8464
8465        // Call the function
8466        let result = kernel
8467            .execute(r#"add_greeting "Hello" "World""#)
8468            .await
8469            .expect("function call failed");
8470
8471        assert!(result.ok(), "function failed: {}", result.err);
8472        assert_eq!(result.text_out().trim(), "Hello World!");
8473    }
8474
8475    #[tokio::test]
8476    async fn test_bash_function_with_positional_params() {
8477        let kernel = Kernel::transient().expect("failed to create kernel");
8478
8479        // Define bash-style function (function keyword, no parens)
8480        kernel
8481            .execute(r#"function greet { echo "Hi $1" }"#)
8482            .await
8483            .expect("function definition failed");
8484
8485        // Call the function
8486        let result = kernel
8487            .execute(r#"greet "Bob""#)
8488            .await
8489            .expect("function call failed");
8490
8491        assert!(result.ok(), "greet failed: {}", result.err);
8492        assert_eq!(result.text_out().trim(), "Hi Bob");
8493    }
8494
8495    #[tokio::test]
8496    async fn test_shell_function_with_all_args() {
8497        let kernel = Kernel::transient().expect("failed to create kernel");
8498
8499        // Define function using $@ (all args)
8500        kernel
8501            .execute(r#"echo_all() { echo "args: $@" }"#)
8502            .await
8503            .expect("function definition failed");
8504
8505        // Call with multiple args
8506        let result = kernel
8507            .execute(r#"echo_all "a" "b" "c""#)
8508            .await
8509            .expect("function call failed");
8510
8511        assert!(result.ok(), "function failed: {}", result.err);
8512        assert_eq!(result.text_out().trim(), "args: a b c");
8513    }
8514
8515    #[tokio::test]
8516    async fn test_shell_function_with_arg_count() {
8517        let kernel = Kernel::transient().expect("failed to create kernel");
8518
8519        // Define function using $# (arg count)
8520        kernel
8521            .execute(r#"count_args() { echo "count: $#" }"#)
8522            .await
8523            .expect("function definition failed");
8524
8525        // Call with three args
8526        let result = kernel
8527            .execute(r#"count_args "x" "y" "z""#)
8528            .await
8529            .expect("function call failed");
8530
8531        assert!(result.ok(), "function failed: {}", result.err);
8532        assert_eq!(result.text_out().trim(), "count: 3");
8533    }
8534
8535    #[tokio::test]
8536    async fn test_shell_function_shared_scope() {
8537        let kernel = Kernel::transient().expect("failed to create kernel");
8538
8539        // Set a variable in parent scope
8540        kernel
8541            .execute(r#"PARENT_VAR="visible""#)
8542            .await
8543            .expect("set failed");
8544
8545        // Define shell function that reads and writes parent variable
8546        kernel
8547            .execute(r#"modify_parent() {
8548                echo "saw: ${PARENT_VAR}"
8549                PARENT_VAR="changed by function"
8550            }"#)
8551            .await
8552            .expect("function definition failed");
8553
8554        // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
8555        let result = kernel.execute("modify_parent").await.expect("function failed");
8556
8557        assert!(
8558            result.text_out().contains("visible"),
8559            "Shell function should access parent scope, got: {}",
8560            result.text_out()
8561        );
8562
8563        // Parent variable should be modified
8564        let var = kernel.get_var("PARENT_VAR").await;
8565        assert_eq!(
8566            var,
8567            Some(Value::String("changed by function".into())),
8568            "Shell function should modify parent scope"
8569        );
8570    }
8571
8572    // ═══════════════════════════════════════════════════════════════════════════
8573    // Script Execution via PATH Tests
8574    // ═══════════════════════════════════════════════════════════════════════════
8575
8576    #[tokio::test]
8577    async fn test_script_execution_from_path() {
8578        let kernel = Kernel::transient().expect("failed to create kernel");
8579
8580        // Create /bin directory and script
8581        kernel.execute(r#"mkdir "/bin""#).await.ok();
8582        kernel
8583            .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
8584            .await
8585            .expect("write script failed");
8586
8587        // Set PATH to /bin
8588        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8589
8590        // Call script by name (without .kai extension)
8591        let result = kernel
8592            .execute("hello")
8593            .await
8594            .expect("script execution failed");
8595
8596        assert!(result.ok(), "script failed: {}", result.err);
8597        assert_eq!(result.text_out().trim(), "Hello from script!");
8598    }
8599
8600    #[tokio::test]
8601    async fn test_script_with_args() {
8602        let kernel = Kernel::transient().expect("failed to create kernel");
8603
8604        // Create script that uses positional params
8605        kernel.execute(r#"mkdir "/bin""#).await.ok();
8606        kernel
8607            .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
8608            .await
8609            .expect("write script failed");
8610
8611        // Set PATH
8612        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8613
8614        // Call script with arg
8615        let result = kernel
8616            .execute(r#"greet "World""#)
8617            .await
8618            .expect("script execution failed");
8619
8620        assert!(result.ok(), "script failed: {}", result.err);
8621        assert_eq!(result.text_out().trim(), "Hello, World!");
8622    }
8623
8624    #[tokio::test]
8625    async fn test_script_not_found() {
8626        let kernel = Kernel::transient().expect("failed to create kernel");
8627
8628        // Set empty PATH
8629        kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
8630
8631        // Call non-existent script
8632        let result = kernel
8633            .execute("noscript")
8634            .await
8635            .expect("execution failed");
8636
8637        assert!(!result.ok(), "should fail with command not found");
8638        assert_eq!(result.code, 127);
8639        assert!(result.err.contains("command not found"));
8640    }
8641
8642    #[tokio::test]
8643    async fn test_script_path_search_order() {
8644        let kernel = Kernel::transient().expect("failed to create kernel");
8645
8646        // Create two directories with same-named script
8647        // Note: using "myscript" not "test" to avoid conflict with test builtin
8648        kernel.execute(r#"mkdir "/first""#).await.ok();
8649        kernel.execute(r#"mkdir "/second""#).await.ok();
8650        kernel
8651            .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
8652            .await
8653            .expect("write failed");
8654        kernel
8655            .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
8656            .await
8657            .expect("write failed");
8658
8659        // Set PATH with first before second
8660        kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
8661
8662        // Should find first one
8663        let result = kernel
8664            .execute("myscript")
8665            .await
8666            .expect("script execution failed");
8667
8668        assert!(result.ok(), "script failed: {}", result.err);
8669        assert_eq!(result.text_out().trim(), "from first");
8670    }
8671
8672    // ═══════════════════════════════════════════════════════════════════════════
8673    // Special Variable Tests ($?, $$, unset vars)
8674    // ═══════════════════════════════════════════════════════════════════════════
8675
8676    #[tokio::test]
8677    async fn test_last_exit_code_success() {
8678        let kernel = Kernel::transient().expect("failed to create kernel");
8679
8680        // true exits with 0
8681        let result = kernel.execute("true; echo $?").await.expect("execution failed");
8682        assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
8683    }
8684
8685    #[tokio::test]
8686    async fn test_last_exit_code_failure() {
8687        let kernel = Kernel::transient().expect("failed to create kernel");
8688
8689        // false exits with 1
8690        let result = kernel.execute("false; echo $?").await.expect("execution failed");
8691        assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
8692    }
8693
8694    #[tokio::test]
8695    async fn test_current_pid() {
8696        let kernel = Kernel::transient().expect("failed to create kernel");
8697
8698        let result = kernel.execute("echo $$").await.expect("execution failed");
8699        // PID should be a positive number
8700        let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
8701        assert!(pid > 0, "PID should be positive");
8702    }
8703
8704    #[tokio::test]
8705    async fn test_unset_variable_expands_to_empty() {
8706        let kernel = Kernel::transient().expect("failed to create kernel");
8707
8708        // Unset variable in interpolation should be empty
8709        let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
8710        assert_eq!(result.text_out().trim(), "prefix::suffix");
8711    }
8712
8713    #[tokio::test]
8714    async fn test_eq_ne_operators() {
8715        let kernel = Kernel::transient().expect("failed to create kernel");
8716
8717        // Test -eq operator
8718        let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
8719        assert_eq!(result.text_out().trim(), "eq works");
8720
8721        // Test -ne operator
8722        let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
8723        assert_eq!(result.text_out().trim(), "ne works");
8724
8725        // Test -eq with different values
8726        let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
8727        assert_eq!(result.text_out().trim(), "correct");
8728    }
8729
8730    #[tokio::test]
8731    async fn test_escaped_dollar_in_string() {
8732        let kernel = Kernel::transient().expect("failed to create kernel");
8733
8734        // \$ should produce literal $
8735        let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
8736        assert_eq!(result.text_out().trim(), "$100");
8737    }
8738
8739    #[tokio::test]
8740    async fn test_special_vars_in_interpolation() {
8741        let kernel = Kernel::transient().expect("failed to create kernel");
8742
8743        // Test $? in string interpolation
8744        let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
8745        assert_eq!(result.text_out().trim(), "exit: 0");
8746
8747        // Test $$ in string interpolation
8748        let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
8749        assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
8750        let text = result.text_out();
8751        let pid_part = text.trim().strip_prefix("pid: ").unwrap();
8752        let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
8753    }
8754
8755    // ═══════════════════════════════════════════════════════════════════════════
8756    // Command Substitution Tests
8757    // ═══════════════════════════════════════════════════════════════════════════
8758
8759    #[tokio::test]
8760    async fn test_command_subst_assignment() {
8761        let kernel = Kernel::transient().expect("failed to create kernel");
8762
8763        // Command substitution in assignment
8764        let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
8765        assert_eq!(result.text_out().trim(), "hello");
8766    }
8767
8768    #[tokio::test]
8769    async fn test_command_subst_with_args() {
8770        let kernel = Kernel::transient().expect("failed to create kernel");
8771
8772        // Command substitution with string argument
8773        let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
8774        assert_eq!(result.text_out().trim(), "a b c");
8775    }
8776
8777    #[tokio::test]
8778    async fn test_command_subst_nested_vars() {
8779        let kernel = Kernel::transient().expect("failed to create kernel");
8780
8781        // Variables inside command substitution
8782        let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
8783        assert_eq!(result.text_out().trim(), "hello world");
8784    }
8785
8786    #[tokio::test]
8787    async fn test_background_job_basic() {
8788        use std::time::Duration;
8789
8790        let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
8791
8792        // Run a simple background command, redirecting its output to a
8793        // memory-backed file. `/v/jobs/{id}/stdout` would work too (and is
8794        // live); the redirect is what this test asserts on.
8795        let result = kernel.execute("echo hello > /tmp/basic_out.txt &").await.expect("execution failed");
8796        assert!(result.ok(), "background command should succeed: {}", result.err);
8797        assert!(result.text_out().contains("[1]"), "should return job ID: {}", result.text_out());
8798
8799        // Give the job time to complete
8800        tokio::time::sleep(Duration::from_millis(100)).await;
8801
8802        // Check job status
8803        let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
8804        assert!(status.ok(), "status should succeed: {}", status.err);
8805        assert!(
8806            status.text_out().contains("done:") || status.text_out().contains("running"),
8807            "should have valid status: {}",
8808            status.text_out()
8809        );
8810
8811        // Check the redirected output
8812        let stdout = kernel.execute("cat /tmp/basic_out.txt").await.expect("output check failed");
8813        assert!(stdout.ok());
8814        assert!(stdout.text_out().contains("hello"));
8815    }
8816
8817    #[tokio::test]
8818    async fn test_heredoc_piped_to_command() {
8819        // Bug 4: heredoc content should pipe through to next command
8820        let kernel = Kernel::transient().expect("kernel");
8821        let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
8822        assert!(result.ok(), "heredoc | cat failed: {}", result.err);
8823        assert_eq!(result.text_out().trim(), "hello world");
8824    }
8825
8826    /// A transient kernel paired with a real, auto-cleaning tempdir. The
8827    /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
8828    /// tests need actual files on disk. Hold the returned `TempDir` for the
8829    /// test's lifetime: it removes the directory tree on drop — including on
8830    /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
8831    /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
8832    /// as a string for interpolation into scripts.
8833    fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
8834        let kernel = Kernel::transient().expect("kernel");
8835        let tmp = tempfile::tempdir().expect("tempdir");
8836        let dir = tmp.path().display().to_string();
8837        (kernel, tmp, dir)
8838    }
8839
8840    #[tokio::test]
8841    async fn test_for_loop_glob_iterates() {
8842        // Bug 1: for F in $(glob ...) should iterate per file, not once
8843        let (kernel, _tmp, dir) = transient_with_tempdir();
8844        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8845        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8846        let result = kernel.execute(&format!(r#"
8847            N=0
8848            for F in $(glob "{dir}/*.txt"); do
8849                N=$((N + 1))
8850            done
8851            echo $N
8852        "#)).await.unwrap();
8853        assert!(result.ok(), "for glob failed: {}", result.err);
8854        assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
8855    }
8856
8857    #[tokio::test]
8858    async fn test_bare_glob_expansion_echo() {
8859        let (kernel, _tmp, dir) = transient_with_tempdir();
8860        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8861        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8862        kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
8863        kernel.execute(&format!("cd {dir}")).await.unwrap();
8864        let result = kernel.execute("echo *.txt").await.unwrap();
8865        assert!(result.ok(), "echo *.txt failed: {}", result.err);
8866        let out = result.text_out();
8867        let out = out.trim();
8868        // Should contain both .txt files (order may vary)
8869        assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
8870        assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
8871        assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
8872    }
8873
8874    #[tokio::test]
8875    async fn test_bare_glob_no_matches_errors() {
8876        let (kernel, _tmp, dir) = transient_with_tempdir();
8877        kernel.execute(&format!("cd {dir}")).await.unwrap();
8878        let result = kernel.execute("echo *.nonexistent").await;
8879        match &result {
8880            Ok(exec) => {
8881                // No-match glob should produce a non-zero exit code
8882                assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
8883                assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
8884            }
8885            Err(e) => {
8886                assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
8887            }
8888        }
8889    }
8890
8891    #[tokio::test]
8892    async fn test_bare_glob_disabled_with_set() {
8893        let (kernel, _tmp, dir) = transient_with_tempdir();
8894        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8895        kernel.execute(&format!("cd {dir}")).await.unwrap();
8896        // Disable glob expansion
8897        kernel.execute("set +o glob").await.unwrap();
8898        let result = kernel.execute("echo *.txt").await.unwrap();
8899        // With glob disabled, *.txt should be passed as literal string
8900        assert!(result.ok(), "echo should succeed: {}", result.err);
8901        assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
8902    }
8903
8904    #[tokio::test]
8905    async fn test_bare_glob_quoted_not_expanded() {
8906        let (kernel, _tmp, dir) = transient_with_tempdir();
8907        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8908        kernel.execute(&format!("cd {dir}")).await.unwrap();
8909        // Quoted globs should NOT expand
8910        let result = kernel.execute("echo \"*.txt\"").await.unwrap();
8911        assert!(result.ok(), "echo should succeed: {}", result.err);
8912        assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
8913    }
8914
8915    #[tokio::test]
8916    async fn test_bare_glob_for_loop() {
8917        let (kernel, _tmp, dir) = transient_with_tempdir();
8918        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8919        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8920        kernel.execute(&format!("cd {dir}")).await.unwrap();
8921        let result = kernel.execute(r#"
8922            N=0
8923            for f in *.txt; do
8924                N=$((N + 1))
8925            done
8926            echo $N
8927        "#).await.unwrap();
8928        assert!(result.ok(), "for loop failed: {}", result.err);
8929        assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
8930    }
8931
8932    #[tokio::test]
8933    async fn test_glob_in_assignment_is_literal() {
8934        let kernel = Kernel::transient().expect("kernel");
8935        let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
8936        assert!(result.ok());
8937        assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
8938    }
8939
8940    #[tokio::test]
8941    async fn test_glob_in_test_expr_is_literal() {
8942        let kernel = Kernel::transient().expect("kernel");
8943        let result = kernel.execute(r#"
8944            if [[ *.txt == "*.txt" ]]; then
8945                echo "match"
8946            else
8947                echo "no"
8948            fi
8949        "#).await.unwrap();
8950        assert!(result.ok());
8951        assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
8952    }
8953
8954    #[tokio::test]
8955    async fn test_command_subst_echo_not_iterable() {
8956        // Regression guard: $(echo "a b c") must remain a single string
8957        let kernel = Kernel::transient().expect("kernel");
8958        let result = kernel.execute(r#"
8959            N=0
8960            for X in $(echo "a b c"); do N=$((N + 1)); done
8961            echo $N
8962        "#).await.unwrap();
8963        assert!(result.ok());
8964        assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
8965    }
8966
8967    // -- accumulate_result / newline tests --
8968
8969    #[test]
8970    fn test_accumulate_preserves_own_newlines() {
8971        // Outputs concatenate verbatim — a command's own trailing newline is
8972        // kept, none is invented.
8973        let mut acc = ExecResult::success("line1\n");
8974        let new = ExecResult::success("line2\n");
8975        accumulate_result(&mut acc, &new);
8976        assert_eq!(&*acc.text_out(), "line1\nline2\n");
8977        assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
8978    }
8979
8980    #[test]
8981    fn test_accumulate_inserts_no_separator() {
8982        // No artificial separator: `printf a; printf b` style concatenates to
8983        // `ab`, matching bash (regression for the 2026-06-09 finding).
8984        let mut acc = ExecResult::success("line1");
8985        let new = ExecResult::success("line2");
8986        accumulate_result(&mut acc, &new);
8987        assert_eq!(&*acc.text_out(), "line1line2");
8988    }
8989
8990    #[test]
8991    fn test_accumulate_empty_into_nonempty() {
8992        let mut acc = ExecResult::success("");
8993        let new = ExecResult::success("hello\n");
8994        accumulate_result(&mut acc, &new);
8995        assert_eq!(&*acc.text_out(), "hello\n");
8996    }
8997
8998    #[test]
8999    fn test_accumulate_nonempty_into_empty() {
9000        let mut acc = ExecResult::success("hello\n");
9001        let new = ExecResult::success("");
9002        accumulate_result(&mut acc, &new);
9003        assert_eq!(&*acc.text_out(), "hello\n");
9004    }
9005
9006    #[test]
9007    fn test_accumulate_stderr_no_double_newlines() {
9008        let mut acc = ExecResult::failure(1, "err1\n");
9009        let new = ExecResult::failure(1, "err2\n");
9010        accumulate_result(&mut acc, &new);
9011        assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
9012    }
9013
9014    #[tokio::test]
9015    async fn test_multiple_echo_no_blank_lines() {
9016        let kernel = Kernel::transient().expect("kernel");
9017        let result = kernel
9018            .execute("echo one\necho two\necho three")
9019            .await
9020            .expect("execution failed");
9021        assert!(result.ok());
9022        assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
9023    }
9024
9025    #[tokio::test]
9026    async fn test_for_loop_no_blank_lines() {
9027        let kernel = Kernel::transient().expect("kernel");
9028        let result = kernel
9029            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
9030            .await
9031            .expect("execution failed");
9032        assert!(result.ok());
9033        assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
9034    }
9035
9036    #[tokio::test]
9037    async fn test_for_command_subst_no_blank_lines() {
9038        let kernel = Kernel::transient().expect("kernel");
9039        let result = kernel
9040            .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
9041            .await
9042            .expect("execution failed");
9043        assert!(result.ok());
9044        assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
9045    }
9046
9047    // ------------------------------------------------------------------
9048    // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
9049    // ------------------------------------------------------------------
9050
9051    /// Helper: a throwaway schema with one `--pair` param declared as
9052    /// consuming two positionals per occurrence. Modelled after what
9053    /// jq_native will declare for `--arg` / `--argjson`.
9054    fn multi_consume_schema() -> crate::tools::ToolSchema {
9055        use crate::tools::{ParamSchema, ToolSchema};
9056        ToolSchema::new("test", "multi-consume smoke")
9057            .param(
9058                ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
9059                    .consumes(2),
9060            )
9061    }
9062
9063    fn pos(s: &str) -> Arg {
9064        Arg::Positional(Expr::Literal(Value::String(s.to_string())))
9065    }
9066
9067    #[tokio::test]
9068    async fn build_args_multi_consume_single_occurrence() {
9069        let kernel = Kernel::transient().expect("kernel");
9070        let schema = multi_consume_schema();
9071        // Simulates:  test --pair NAME VALUE filter
9072        let args = vec![
9073            Arg::LongFlag("pair".into()),
9074            pos("NAME"),
9075            pos("VALUE"),
9076            pos("filter"),
9077        ];
9078        let built = kernel
9079            .build_args_async(&args, Some(&schema))
9080            .await
9081            .expect("build_args should succeed");
9082
9083        // `--pair` + its two positionals are consumed into named["pair"],
9084        // which becomes an outer array of one inner 2-element array.
9085        let pair = built.named.get("pair").expect("named[pair] missing");
9086        match pair {
9087            Value::Json(serde_json::Value::Array(occurrences)) => {
9088                assert_eq!(occurrences.len(), 1, "expected one occurrence");
9089                match &occurrences[0] {
9090                    serde_json::Value::Array(values) => {
9091                        assert_eq!(values.len(), 2, "pair must have 2 values");
9092                        assert_eq!(values[0], serde_json::Value::String("NAME".into()));
9093                        assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
9094                    }
9095                    other => panic!("expected inner array, got {other:?}"),
9096                }
9097            }
9098            other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
9099        }
9100
9101        // The un-consumed positional ("filter") remains in `positional`.
9102        assert_eq!(built.positional.len(), 1);
9103        assert_eq!(built.positional[0], Value::String("filter".into()));
9104    }
9105    #[tokio::test]
9106    async fn build_args_multi_consume_two_occurrences_accumulate() {
9107        let kernel = Kernel::transient().expect("kernel");
9108        let schema = multi_consume_schema();
9109        // Simulates:  test --pair A 1 --pair B 2 filter
9110        let args = vec![
9111            Arg::LongFlag("pair".into()),
9112            pos("A"),
9113            pos("1"),
9114            Arg::LongFlag("pair".into()),
9115            pos("B"),
9116            pos("2"),
9117            pos("filter"),
9118        ];
9119        let built = kernel
9120            .build_args_async(&args, Some(&schema))
9121            .await
9122            .expect("build_args should succeed");
9123
9124        let pair = built.named.get("pair").expect("named[pair] missing");
9125        match pair {
9126            Value::Json(serde_json::Value::Array(occurrences)) => {
9127                assert_eq!(occurrences.len(), 2, "expected two occurrences");
9128                // Preserved in invocation order.
9129                match &occurrences[0] {
9130                    serde_json::Value::Array(values) => {
9131                        assert_eq!(values[0], serde_json::Value::String("A".into()));
9132                        assert_eq!(values[1], serde_json::Value::String("1".into()));
9133                    }
9134                    other => panic!("expected inner array, got {other:?}"),
9135                }
9136                match &occurrences[1] {
9137                    serde_json::Value::Array(values) => {
9138                        assert_eq!(values[0], serde_json::Value::String("B".into()));
9139                        assert_eq!(values[1], serde_json::Value::String("2".into()));
9140                    }
9141                    other => panic!("expected inner array, got {other:?}"),
9142                }
9143            }
9144            other => panic!("expected Json(Array(...)), got {other:?}"),
9145        }
9146    }
9147
9148    // ── undeclared space-form flag under map_positionals (kj --type val) ──
9149    //
9150    // A backend/MCP tool whose schema does NOT declare a flag must not let
9151    // `--flag value` (space form) silently divorce the value: that was a
9152    // privilege-escalation-by-typo against kaijutsu.
9153    // kaish fails loud rather than guessing.
9154
9155    use crate::tools::{ParamSchema, ToolSchema};
9156
9157    /// Backend-style schema (map_positionals) declaring only a `name`
9158    /// positional — `--type` is intentionally undeclared.
9159    fn kj_like_schema() -> ToolSchema {
9160        ToolSchema::new("kj", "incomplete backend schema")
9161            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
9162            .with_positional_mapping()
9163    }
9164
9165    #[tokio::test]
9166    async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
9167        let kernel = Kernel::transient().expect("kernel");
9168        let schema = kj_like_schema();
9169        // kj context create exp --type explorer
9170        let args = vec![
9171            pos("context"),
9172            pos("create"),
9173            pos("exp"),
9174            Arg::LongFlag("type".into()),
9175            pos("explorer"),
9176        ];
9177        let err = kernel
9178            .build_args_async(&args, Some(&schema))
9179            .await
9180            .expect_err("undeclared --type with a space value must fail loud");
9181        let msg = err.to_string();
9182        assert!(msg.contains("--type"), "message should name the flag: {msg}");
9183        assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
9184        assert!(msg.contains("kj"), "message should name the tool: {msg}");
9185    }
9186
9187    #[tokio::test]
9188    async fn build_args_declared_space_flag_still_binds() {
9189        let kernel = Kernel::transient().expect("kernel");
9190        // Same tool, but now the schema DECLARES --type as a string param.
9191        let schema = ToolSchema::new("kj", "complete schema")
9192            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
9193            .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
9194            .with_positional_mapping();
9195        let args = vec![
9196            pos("exp"),
9197            Arg::LongFlag("type".into()),
9198            pos("explorer"),
9199        ];
9200        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9201        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9202    }
9203
9204    #[tokio::test]
9205    async fn build_args_equals_form_binds_for_undeclared_flag() {
9206        let kernel = Kernel::transient().expect("kernel");
9207        let schema = kj_like_schema();
9208        // The unambiguous `=` form must keep working even when undeclared.
9209        let args = vec![
9210            pos("exp"),
9211            Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
9212        ];
9213        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9214        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9215    }
9216
9217    #[tokio::test]
9218    async fn build_args_undeclared_bool_flag_at_end_is_ok() {
9219        let kernel = Kernel::transient().expect("kernel");
9220        let schema = kj_like_schema();
9221        // No positional follows --force → unambiguously a bare flag.
9222        let args = vec![pos("exp"), Arg::LongFlag("force".into())];
9223        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9224        assert!(built.flags.contains("force"));
9225    }
9226
9227    #[tokio::test]
9228    async fn build_args_undeclared_flag_before_another_flag_is_ok() {
9229        let kernel = Kernel::transient().expect("kernel");
9230        let schema = kj_like_schema();
9231        // --verbose is followed by a flag, not a positional → not ambiguous.
9232        let args = vec![
9233            Arg::LongFlag("verbose".into()),
9234            Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
9235        ];
9236        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9237        assert!(built.flags.contains("verbose"));
9238    }
9239
9240    #[tokio::test]
9241    async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
9242        let kernel = Kernel::transient().expect("kernel");
9243        // Builtins set map_positionals=false; the ambiguity guard must not
9244        // fire there (clap validates their flags separately).
9245        let schema = ToolSchema::new("frobnicate", "builtin-style")
9246            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
9247        let args = vec![Arg::LongFlag("frob".into()), pos("value")];
9248        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9249        assert!(built.flags.contains("frob"));
9250    }
9251
9252    // ── GH #189 item 4: the short-flag half of the same ambiguity guard ──
9253    //
9254    // The long-flag guard above was closed by GH #188; an undeclared SHORT
9255    // flag under a map_positionals schema was still silently defaulting to
9256    // bare bool, divorcing a space-form value (`kj -t explorer`) exactly the
9257    // same way the long-flag case used to.
9258
9259    #[tokio::test]
9260    async fn build_args_undeclared_short_space_flag_errors_under_map_positionals() {
9261        let kernel = Kernel::transient().expect("kernel");
9262        let schema = kj_like_schema();
9263        // kj exp -t explorer
9264        let args = vec![pos("exp"), Arg::ShortFlag("t".into()), pos("explorer")];
9265        let err = kernel
9266            .build_args_async(&args, Some(&schema))
9267            .await
9268            .expect_err("undeclared -t with a space value must fail loud");
9269        let msg = err.to_string();
9270        assert!(msg.contains("-t"), "message should name the flag: {msg}");
9271        assert!(msg.contains("kj"), "message should name the tool: {msg}");
9272    }
9273
9274    #[tokio::test]
9275    async fn build_args_undeclared_short_space_flag_ok_for_builtin_schema() {
9276        let kernel = Kernel::transient().expect("kernel");
9277        // Builtins set map_positionals=false; the ambiguity guard must not
9278        // fire there.
9279        let schema = ToolSchema::new("frobnicate", "builtin-style")
9280            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
9281        let args = vec![Arg::ShortFlag("t".into()), pos("value")];
9282        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9283        assert!(built.flags.contains("t"));
9284    }
9285
9286    // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
9287    //
9288    // A tool exposing a subcommand tree binds flags against the *routed leaf's*
9289    // params, not the root's. The subcommand-path positionals stay positional
9290    // (kj re-parses them with its own clap), and a value flag declared only on
9291    // a deep leaf still binds in space form.
9292
9293    /// kj → context (alias ctx) → create{--type value, --force bool}.
9294    /// map_positionals defaults false on every node (builtin/kj style).
9295    fn kj_tree_schema() -> ToolSchema {
9296        ToolSchema::new("kj", "subcommand tool").subcommand(
9297            ToolSchema::new("context", "context ops")
9298                .with_command_aliases(["ctx"])
9299                .subcommand(
9300                    ToolSchema::new("create", "create context")
9301                        .param(ParamSchema::new("type", "string").with_aliases(["t"]))
9302                        .param(ParamSchema::new("force", "bool")),
9303                ),
9304        )
9305    }
9306
9307    #[tokio::test]
9308    async fn build_args_binds_deep_leaf_value_flag_space_form() {
9309        let kernel = Kernel::transient().expect("kernel");
9310        let schema = kj_tree_schema();
9311        // kj context create --type explorer
9312        let args = vec![
9313            pos("context"),
9314            pos("create"),
9315            Arg::LongFlag("type".into()),
9316            pos("explorer"),
9317        ];
9318        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9319        // --type (declared only on the create leaf) binds in space form.
9320        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9321        // The subcommand path survives as positionals for kj to re-parse.
9322        let positionals: Vec<&str> = built
9323            .positional
9324            .iter()
9325            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
9326            .collect();
9327        assert_eq!(positionals, vec!["context", "create"]);
9328    }
9329
9330    #[tokio::test]
9331    async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
9332        let kernel = Kernel::transient().expect("kernel");
9333        let schema = kj_tree_schema();
9334        // kj context create --force somearg  → --force is a leaf bool flag,
9335        // it must NOT consume `somearg`.
9336        let args = vec![
9337            pos("context"),
9338            pos("create"),
9339            Arg::LongFlag("force".into()),
9340            pos("somearg"),
9341        ];
9342        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9343        assert!(built.flags.contains("force"), "force should be a bare flag");
9344        let positionals: Vec<&str> = built
9345            .positional
9346            .iter()
9347            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
9348            .collect();
9349        assert_eq!(positionals, vec!["context", "create", "somearg"]);
9350    }
9351
9352    #[tokio::test]
9353    async fn build_args_alias_routed_leaf_binds_value_flag() {
9354        let kernel = Kernel::transient().expect("kernel");
9355        let schema = kj_tree_schema();
9356        // kj ctx create -t explorer  → command alias + short flag alias.
9357        let args = vec![
9358            pos("ctx"),
9359            pos("create"),
9360            Arg::ShortFlag("t".into()),
9361            pos("explorer"),
9362        ];
9363        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9364        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9365    }
9366
9367    #[tokio::test]
9368    async fn build_args_computed_subcommand_selector_fails_loud() {
9369        let kernel = Kernel::transient().expect("kernel");
9370        let schema = kj_tree_schema();
9371        // kj $(echo context) — routing can't see the value; fail loud.
9372        let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
9373            crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
9374        )]))];
9375        let err = kernel
9376            .build_args_async(&args, Some(&schema))
9377            .await
9378            .expect_err("computed subcommand selector must error");
9379        assert!(
9380            err.to_string().contains("subcommand name is required"),
9381            "got: {err}"
9382        );
9383    }
9384
9385    // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
9386
9387    #[test]
9388    fn finalize_output_renders_when_kernel_owns_it() {
9389        use crate::interpreter::{OutputData, OutputFormat};
9390        let r = ExecResult::with_output(OutputData::text("RAW"));
9391        let out = finalize_output(r, Some(OutputFormat::Json), false);
9392        // Kernel renders the typed OutputData → JSON; text is no longer bare.
9393        assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
9394    }
9395
9396    #[test]
9397    fn finalize_output_skips_when_tool_owns_output_and_succeeds() {
9398        use crate::interpreter::{OutputData, OutputFormat};
9399        let r = ExecResult::with_output(OutputData::text("RAW"));
9400        let out = finalize_output(r, Some(OutputFormat::Json), true);
9401        // owns_output + success: the tool already rendered; kernel leaves bytes
9402        // untouched.
9403        assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
9404    }
9405
9406    #[test]
9407    fn finalize_output_renders_owns_output_failure() {
9408        // scatter/gather (the only owns_output tools) never render their own
9409        // JSONL/array on a FAILURE path — their error returns are plain-text
9410        // `ExecResult::failure(code, msg)`, identical in shape to any other
9411        // builtin's. owns_output means "the tool already rendered its own
9412        // SUCCESS output", not "never touch this tool's bytes" — a failure
9413        // must still get the uniform --json error envelope like every other
9414        // builtin (kaibo review finding on merged PR #215; confirmed
9415        // pre-existing for scatter/gather's whole error-path class, including
9416        // the clap-parse-failure path).
9417        use crate::interpreter::OutputFormat;
9418        let r = ExecResult::failure(2, "scatter: unexpected argument '--nope'");
9419        let out = finalize_output(r, Some(OutputFormat::Json), true);
9420        let parsed: serde_json::Value =
9421            serde_json::from_str(&out.text_out()).expect("--json must always parse as JSON");
9422        assert_eq!(parsed["error"], "scatter: unexpected argument '--nope'");
9423        assert_eq!(parsed["code"], 2);
9424    }
9425
9426    #[test]
9427    fn finalize_output_no_format_is_noop() {
9428        use crate::interpreter::OutputData;
9429        let r = ExecResult::with_output(OutputData::text("RAW"));
9430        let out = finalize_output(r, None, false);
9431        assert_eq!(out.text_out(), "RAW");
9432    }
9433
9434    // ── initial_vars + execute_with_vars + hermetic env ───────────────────
9435
9436    #[tokio::test]
9437    async fn test_initial_vars_set_and_exported() {
9438        let config = KernelConfig::transient()
9439            .with_var("INIT_FOO", Value::String("bar".into()));
9440        let kernel = Kernel::new(config).expect("failed to create kernel");
9441
9442        assert_eq!(
9443            kernel.get_var("INIT_FOO").await,
9444            Some(Value::String("bar".into()))
9445        );
9446        assert!(
9447            kernel.scope.read().await.is_exported("INIT_FOO"),
9448            "initial_vars entries must be marked exported"
9449        );
9450    }
9451
9452    #[tokio::test]
9453    async fn test_execute_with_vars_overlay_visible() {
9454        let kernel = Kernel::transient().expect("failed to create kernel");
9455        let mut overlay = HashMap::new();
9456        overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
9457
9458        let result = kernel
9459            .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
9460            .await
9461            .expect("execute failed");
9462
9463        assert!(result.ok());
9464        assert_eq!(result.text_out().trim(), "yes");
9465    }
9466
9467    #[tokio::test]
9468    async fn test_execute_with_vars_overlay_cleanup() {
9469        let kernel = Kernel::transient().expect("failed to create kernel");
9470        let mut overlay = HashMap::new();
9471        overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
9472
9473        kernel
9474            .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
9475            .await
9476            .expect("execute failed");
9477
9478        assert_eq!(kernel.get_var("EPHEMERAL").await, None);
9479        assert!(
9480            !kernel.scope.read().await.is_exported("EPHEMERAL"),
9481            "overlay-only export must be cleared on return"
9482        );
9483    }
9484
9485    #[tokio::test]
9486    async fn test_execute_with_vars_does_not_clobber_existing_export() {
9487        let kernel = Kernel::transient().expect("failed to create kernel");
9488        kernel
9489            .execute("export OUTER=outer")
9490            .await
9491            .expect("export failed");
9492
9493        let mut overlay = HashMap::new();
9494        overlay.insert("OUTER".to_string(), Value::String("inner".into()));
9495        let result = kernel
9496            .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
9497            .await
9498            .expect("execute failed");
9499        assert_eq!(result.text_out().trim(), "inner");
9500
9501        assert_eq!(
9502            kernel.get_var("OUTER").await,
9503            Some(Value::String("outer".into())),
9504            "outer value must reappear after pop"
9505        );
9506        assert!(
9507            kernel.scope.read().await.is_exported("OUTER"),
9508            "outer export must survive overlay"
9509        );
9510    }
9511
9512    #[tokio::test]
9513    async fn test_execute_with_vars_inner_assignment_is_local() {
9514        let kernel = Kernel::transient().expect("failed to create kernel");
9515        let mut overlay = HashMap::new();
9516        overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
9517
9518        // Variable assignment inside a single statement uses set() (innermost
9519        // frame), not set_global() — this matches bash function-local semantics.
9520        // We explicitly use `local FOO=...` style by relying on the pushed
9521        // frame; the assignment in the script body modifies the same frame.
9522        let result = kernel
9523            .execute_with_options(
9524                r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
9525                ExecuteOptions::new().with_vars(overlay),
9526            )
9527            .await
9528            .expect("execute failed");
9529        assert!(result.ok());
9530
9531        // After the call the frame is popped, so LOCAL_FOO is gone regardless
9532        // of how the script reassigned it.
9533        assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
9534    }
9535
9536    #[tokio::test]
9537    async fn test_external_command_sees_exported_var() {
9538        let kernel = Kernel::transient().expect("failed to create kernel");
9539        // PATH must be in scope to resolve the external `printenv` — the kernel
9540        // never falls back to OS PATH. Seeding it via a scope assignment mirrors
9541        // what a frontend does through initial_vars.
9542        let path = std::env::var("PATH").unwrap_or_default();
9543        let result = kernel
9544            .execute(&format!(
9545                "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
9546            ))
9547            .await
9548            .expect("execute failed");
9549
9550        assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
9551        assert_eq!(result.text_out().trim(), "bar");
9552    }
9553
9554    #[tokio::test]
9555    async fn test_external_command_does_not_see_unexported_var() {
9556        let kernel = Kernel::transient().expect("failed to create kernel");
9557
9558        // Set without exporting; printenv must not see it (exit code != 0,
9559        // empty stdout per printenv semantics).
9560        let result = kernel
9561            .execute("EXT_BAR=hidden; printenv EXT_BAR")
9562            .await
9563            .expect("execute failed");
9564
9565        assert!(!result.ok(), "printenv should fail when var is unexported");
9566        assert!(
9567            result.text_out().trim().is_empty(),
9568            "no stdout when var is missing, got: {}",
9569            result.text_out()
9570        );
9571    }
9572
9573    #[tokio::test]
9574    async fn test_external_command_does_not_see_os_env() {
9575        // The kernel is hermetic: it never reads std::env::vars() and only
9576        // exports what it has been told to export. Cargo always sets PATH for
9577        // tests, so PATH is reliably present in the OS env — but a transient
9578        // kernel doesn't seed it into initial_vars, so `printenv PATH` from
9579        // inside the kernel must fail.
9580        assert!(
9581            std::env::var_os("PATH").is_some(),
9582            "test precondition: cargo should set PATH"
9583        );
9584
9585        let kernel = Kernel::transient().expect("failed to create kernel");
9586        let result = kernel
9587            .execute("printenv PATH")
9588            .await
9589            .expect("execute failed");
9590
9591        assert!(
9592            !result.ok(),
9593            "printenv PATH must fail in hermetic kernel, got stdout={:?}",
9594            result.text_out()
9595        );
9596        assert!(
9597            result.text_out().trim().is_empty(),
9598            "no PATH in subprocess env, got stdout={:?}",
9599            result.text_out()
9600        );
9601    }
9602
9603    #[tokio::test]
9604    async fn test_execute_with_vars_overlay_reaches_subprocess() {
9605        let kernel = Kernel::transient().expect("failed to create kernel");
9606        let mut overlay = HashMap::new();
9607        overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
9608        // PATH in the overlay so the external `printenv` resolves (no OS fallback).
9609        overlay.insert(
9610            "PATH".to_string(),
9611            Value::String(std::env::var("PATH").unwrap_or_default()),
9612        );
9613
9614        let result = kernel
9615            .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
9616            .await
9617            .expect("execute failed");
9618
9619        assert!(
9620            result.ok(),
9621            "printenv should succeed: code={} stdout={:?} stderr={:?}",
9622            result.code,
9623            result.text_out(),
9624            result.err
9625        );
9626        assert_eq!(result.text_out().trim(), "subproc");
9627    }
9628
9629    #[tokio::test]
9630    async fn test_classify_command_builtin() {
9631        let kernel = Kernel::transient().expect("failed to create kernel");
9632        assert_eq!(kernel.classify_command("cat").await, CommandKind::Builtin);
9633        assert_eq!(kernel.classify_command("grep").await, CommandKind::Builtin);
9634    }
9635
9636    #[tokio::test]
9637    async fn test_classify_command_special_forms() {
9638        let kernel = Kernel::transient().expect("failed to create kernel");
9639        for name in ["true", "false", "source", "."] {
9640            assert_eq!(
9641                kernel.classify_command(name).await,
9642                CommandKind::Special,
9643                "{name} should be a special-form",
9644            );
9645        }
9646    }
9647
9648    #[tokio::test]
9649    async fn test_classify_command_dynamic() {
9650        let kernel = Kernel::transient().expect("failed to create kernel");
9651        assert_eq!(kernel.classify_command("$cmd").await, CommandKind::Dynamic);
9652        assert_eq!(
9653            kernel.classify_command("$(pick)").await,
9654            CommandKind::Dynamic
9655        );
9656    }
9657
9658    #[tokio::test]
9659    async fn test_classify_command_external() {
9660        let kernel = Kernel::transient().expect("failed to create kernel");
9661        // Not a builtin, user function, or special-form → escapes to PATH.
9662        assert_eq!(
9663            kernel.classify_command("definitely_not_a_kaish_builtin").await,
9664            CommandKind::External
9665        );
9666        // `readonly` is *not* a kaish special-form despite the validator's
9667        // warning heuristic — at runtime it resolves to an external command, so
9668        // a consent gate must see it as External (regression guard against the
9669        // validator/runtime divergence).
9670        assert_eq!(
9671            kernel.classify_command("readonly").await,
9672            CommandKind::External
9673        );
9674        assert!(kernel.classify_command("readonly").await.escapes_kernel());
9675    }
9676
9677    #[tokio::test]
9678    async fn test_classify_command_user_tool_shadows_builtin() {
9679        let kernel = Kernel::transient().expect("failed to create kernel");
9680        kernel
9681            .execute(r#"greet() { echo "hi" }"#)
9682            .await
9683            .expect("function definition failed");
9684        assert_eq!(
9685            kernel.classify_command("greet").await,
9686            CommandKind::UserTool
9687        );
9688
9689        // A user function named after a builtin classifies as UserTool, matching
9690        // the interpreter's user-tools-first resolution.
9691        kernel
9692            .execute(r#"cat() { echo "shadowed" }"#)
9693            .await
9694            .expect("function definition failed");
9695        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9696    }
9697
9698    #[tokio::test]
9699    async fn test_classify_command_alias_to_external_is_external() {
9700        let kernel = Kernel::transient().expect("failed to create kernel");
9701        // An alias whose head is an external binary must NOT report as the
9702        // builtin it shadows — execution expands the alias, so a consent gate
9703        // would otherwise be told an external command is internal.
9704        kernel
9705            .execute("alias cat='/usr/bin/whatever'")
9706            .await
9707            .expect("alias failed");
9708        assert_eq!(kernel.classify_command("cat").await, CommandKind::External);
9709        assert!(kernel.classify_command("cat").await.escapes_kernel());
9710    }
9711
9712    #[tokio::test]
9713    async fn test_classify_command_alias_to_builtin() {
9714        let kernel = Kernel::transient().expect("failed to create kernel");
9715        kernel.execute("alias g=grep").await.expect("alias failed");
9716        assert_eq!(kernel.classify_command("g").await, CommandKind::Builtin);
9717    }
9718
9719    #[tokio::test]
9720    async fn test_classify_command_alias_to_special_form() {
9721        let kernel = Kernel::transient().expect("failed to create kernel");
9722        kernel.execute("alias t=true").await.expect("alias failed");
9723        assert_eq!(kernel.classify_command("t").await, CommandKind::Special);
9724    }
9725
9726    #[tokio::test]
9727    async fn test_classify_command_braced_var_is_dynamic() {
9728        let kernel = Kernel::transient().expect("failed to create kernel");
9729        // The string API can be handed a `${VAR}` head; it must not be mistaken
9730        // for an external named literally "${VAR}".
9731        assert_eq!(
9732            kernel.classify_command("${CMD}").await,
9733            CommandKind::Dynamic
9734        );
9735    }
9736
9737    /// Drift guard: `classify_command` must agree with what the executor
9738    /// (`execute_command_depth`) actually resolves. The classifier duplicates the
9739    /// interpreter's resolution rules (special-form set, user-tools-before-builtins
9740    /// precedence, alias expansion); without this test those copies could diverge
9741    /// silently — the exact failure class `classify_command` exists to prevent,
9742    /// just moved inside the kernel. Each case asserts the classification AND
9743    /// observes the real resolution, so a future change to one side without the
9744    /// other fails here.
9745    #[tokio::test]
9746    async fn classify_command_matches_executor() {
9747        let kernel = Kernel::transient().expect("failed to create kernel");
9748
9749        // (1) Special-forms. `SpecialForm::from_name` is the single source of
9750        // truth: classify reports Special via it, and the executor matches the
9751        // enum exhaustively, so const↔behavior parity is compile-enforced (a new
9752        // form won't build until both sides handle it). This test pins the other
9753        // half — that each form classifies Special AND actually short-circuits at
9754        // runtime rather than escaping to `PATH`. Every form is executed (not just
9755        // `true`/`false`): an external miss in this PATH-less kernel would be exit
9756        // 127, so a non-127 result that matches the form's own behavior proves the
9757        // short-circuit fired.
9758        for name in ["true", "false", "source", "."] {
9759            assert_eq!(
9760                kernel.classify_command(name).await,
9761                CommandKind::Special,
9762                "{name} should classify Special",
9763            );
9764        }
9765        assert_eq!(kernel.execute("true").await.expect("run true").code, 0);
9766        assert_eq!(kernel.execute("false").await.expect("run false").code, 1);
9767        // `source`/`.` short-circuit to execute_source, which (no filename) fails
9768        // with its own message — exit 1, never the 127 of an unresolved external.
9769        for name in ["source", "."] {
9770            let r = kernel.execute(name).await.expect("run source form");
9771            assert_ne!(r.code, 127, "{name} fell through to PATH instead of source");
9772            assert!(
9773                r.err.contains("source: missing filename"),
9774                "{name} did not route to execute_source: {:?}",
9775                r.err,
9776            );
9777        }
9778
9779        // (2) Builtin: classify Builtin AND the executor runs the builtin.
9780        assert_eq!(kernel.classify_command("echo").await, CommandKind::Builtin);
9781        let r = kernel.execute("echo hi").await.expect("run echo");
9782        assert!(r.ok() && r.text_out().trim() == "hi", "echo builtin didn't run");
9783
9784        // (3) User function shadows a builtin: classify UserTool AND the executor
9785        // runs the function body, not the `cat` builtin.
9786        kernel
9787            .execute(r#"cat() { echo SHADOWED }"#)
9788            .await
9789            .expect("define cat()");
9790        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9791        let r = kernel.execute("cat").await.expect("run shadowed cat");
9792        assert_eq!(
9793            r.text_out().trim(),
9794            "SHADOWED",
9795            "executor ran the builtin instead of the shadowing function",
9796        );
9797
9798        // (4) Alias whose head is external: classify External AND the executor
9799        // resolves through the alias to a missing external (not a builtin).
9800        kernel
9801            .execute("alias x='/nonexistent/binary'")
9802            .await
9803            .expect("define alias x");
9804        assert_eq!(kernel.classify_command("x").await, CommandKind::External);
9805        let r = kernel.execute("x").await.expect("run alias x");
9806        assert!(
9807            !r.ok(),
9808            "alias to a missing external should fail, not resolve internally",
9809        );
9810    }
9811}