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::error::{classify_execute_error, KernelError};
104use 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};
105use crate::parser::parse;
106use crate::scheduler::{is_bool_type, schema_param_lookup, select_leaf, stderr_stream, JobManager, PipelineRunner, StderrReceiver};
107#[cfg(feature = "subprocess")]
108use crate::scheduler::{drain_to_stream_teed, BoundedStream, DEFAULT_STREAM_MAX_SIZE};
109use crate::tools::{
110    external_commands_unavailable_error, global_flag_value_is_truthy, register_builtins,
111    ExecContext, ExternalCommandOutcome, ExternalCommandsUnavailable, GlobalFlags, ToolArgs,
112    ToolRegistry,
113};
114#[cfg(feature = "subprocess")]
115use crate::tools::{resolve_in_path, virtual_cwd_error};
116use crate::validator::{Severity, Validator};
117#[cfg(feature = "localfs")]
118use crate::vfs::LocalFs;
119use crate::vfs::{BuiltinFs, DevFs, JobFs, MemoryFs, VfsRouter};
120use kaish_vfs::ByteBudget;
121#[cfg(all(feature = "localfs", feature = "overlay"))]
122use kaish_vfs::OverlayFs;
123
124/// VFS mount mode determines how the local filesystem is exposed.
125///
126/// Different modes trade off convenience vs. security:
127/// - `Passthrough` gives native path access (best for human REPL use)
128/// - `Sandboxed` restricts access to a subtree (safer for agents)
129/// - `NoLocal` provides complete isolation (tests, pure memory mode)
130#[derive(Debug, Clone)]
131#[non_exhaustive]
132pub enum VfsMountMode {
133    /// LocalFs at "/" — native paths work directly.
134    ///
135    /// Full filesystem access. Use for human-operated REPL sessions where
136    /// native paths like `/home/user/project` should just work.
137    ///
138    /// Mounts:
139    /// - `/` → LocalFs("/")
140    /// - `/v` → MemoryFs (blob storage)
141    #[cfg(feature = "localfs")]
142    Passthrough,
143
144    /// Transparent sandbox — paths look native but access is restricted.
145    ///
146    /// The local filesystem is mounted at its real path (e.g., `/home/user`),
147    /// so `/home/user/src/project` just works. But paths outside the sandbox
148    /// root are not accessible.
149    ///
150    /// **Note:** This only restricts VFS (builtin) operations. External commands
151    /// bypass the sandbox entirely — see [`KernelConfig::allow_external_commands`].
152    ///
153    /// Mounts:
154    /// - `/` → MemoryFs (catches paths outside sandbox)
155    /// - `{root}` → LocalFs(root)  (e.g., `/home/user` → LocalFs)
156    /// - `/tmp` → LocalFs("/tmp")
157    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
158    /// - `/v` → MemoryFs (blob storage)
159    #[cfg(feature = "localfs")]
160    Sandboxed {
161        /// Root path for local filesystem. Defaults to `$HOME`.
162        /// Can be restricted further, e.g., `~/src`.
163        root: Option<PathBuf>,
164    },
165
166    /// No local filesystem. Memory only.
167    ///
168    /// Complete isolation — no access to the host filesystem.
169    /// Useful for tests or pure sandboxed execution.
170    ///
171    /// Output spill is forced to [`SpillMode::Memory`](crate::output_limit::SpillMode::Memory)
172    /// for this mode at kernel construction: with no host filesystem mounted,
173    /// large output must not write a host spill file (`paths::spill_dir()`
174    /// bypasses the VFS). This overrides any explicit `SpillMode::Disk`.
175    ///
176    /// Mounts:
177    /// - `/` → MemoryFs
178    /// - `/tmp` → MemoryFs
179    /// - `/v` → MemoryFs
180    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
181    NoLocal,
182}
183
184#[allow(clippy::derivable_impls)] // native has multiple variants; not derivable cross-feature
185impl Default for VfsMountMode {
186    fn default() -> Self {
187        #[cfg(feature = "localfs")]
188        { VfsMountMode::Sandboxed { root: None } }
189        #[cfg(not(feature = "localfs"))]
190        { VfsMountMode::NoLocal }
191    }
192}
193
194/// Configuration for kernel initialization.
195#[derive(Clone)]
196pub struct KernelConfig {
197    /// Name of this kernel (for identification).
198    pub name: String,
199
200    /// VFS mount mode — controls how local filesystem is exposed.
201    pub vfs_mode: VfsMountMode,
202
203    /// Initial working directory (VFS path).
204    pub cwd: PathBuf,
205
206    /// Whether to skip pre-execution validation.
207    ///
208    /// When false (default), scripts are validated before execution to catch
209    /// errors early. Set to true to skip validation for performance or to
210    /// allow dynamic/external commands.
211    pub skip_validation: bool,
212
213    /// When true, standalone external commands inherit stdio for real-time output.
214    ///
215    /// Set by script runner and REPL for human-visible output.
216    /// Not set by MCP server (output must be captured for structured responses).
217    pub interactive: bool,
218
219    /// Ignore file configuration for file-walking tools.
220    pub ignore_config: crate::ignore_config::IgnoreConfig,
221
222    /// Output size limit configuration for agent safety.
223    pub output_limit: crate::output_limit::OutputLimitConfig,
224
225    /// Whether external command execution (PATH lookup, `exec`, `spawn`) is allowed.
226    ///
227    /// When `true` (default), commands not found as builtins are resolved via PATH
228    /// and executed as child processes. When `false`, only kaish builtins and
229    /// backend-registered tools are available.
230    ///
231    /// **Security:** External commands bypass the VFS sandbox entirely — they see
232    /// the real filesystem, network, and environment. Set to `false` when running
233    /// untrusted input.
234    pub allow_external_commands: bool,
235
236
237    /// Enable trash-on-delete for rm (set -o trash).
238    ///
239    /// When enabled, small files are moved to freedesktop.org Trash instead of
240    /// being permanently deleted. Can also be enabled at runtime with `set -o trash`
241    /// or via `KAISH_TRASH=1`.
242    pub trash_enabled: bool,
243
244    /// Enable errexit (`set -e`) at kernel construction (set -o errexit default).
245    ///
246    /// When enabled, a script aborts at the first statement that exits
247    /// nonzero instead of continuing to the next one. **Off by default** —
248    /// standard shell behavior, so an embedder upgrading kaish sees no
249    /// change. There is one piece of state behind this
250    /// (`Scope::error_exit_enabled`), seeded from this field and mutated at
251    /// runtime by `set -e`/`set +e`: this only picks the *starting* value,
252    /// a script's own `set -e`/`set +e` still applies afterward regardless
253    /// of what this was, and `set -o` always reports the true, single
254    /// answer no matter which one set it. `ExecuteOptions::errexit`
255    /// overrides this for one call.
256    pub errexit_enabled: bool,
257
258    /// Variables to populate the root scope with at construction, all marked
259    /// for export to child processes.
260    ///
261    /// The kernel itself is hermetic — it never reads `std::env::vars()` —
262    /// so frontends that want OS-env passthrough (REPL, MCP) populate this
263    /// from `std::env::vars()`. Embedders that want isolation pass nothing
264    /// (or only the keys they curate).
265    pub initial_vars: HashMap<String, Value>,
266
267    /// Default per-request timeout. When `Some`, every `execute_with_options`
268    /// call without an explicit `ExecuteOptions::timeout` uses this duration.
269    /// When elapsed, the kernel cancels the request, kills any external
270    /// children with the configured grace, and returns exit code 124.
271    ///
272    /// `None` means no default timeout — only explicit per-call timeouts apply.
273    pub request_timeout: Option<Duration>,
274
275    /// Grace period between SIGTERM and SIGKILL when killing an external
276    /// child on cancellation or timeout.
277    ///
278    /// Defaults to 2 seconds. Set to `Duration::ZERO` to escalate immediately
279    /// to SIGKILL. Long-shutdown processes (databases, etc.) may need more.
280    pub kill_grace: Duration,
281
282    /// Cap on memory-resident bytes across all kernel-owned `MemoryFs` mounts.
283    ///
284    /// One shared `ByteBudget` (labeled `"vfs-memory"`) is created at kernel
285    /// construction and handed to every `MemoryFs` the kernel builds in
286    /// `setup_vfs` (Passthrough `/v`; Sandboxed `/` and `/v`; NoLocal `/`,
287    /// `/tmp`, `/v`). Writes that would exceed the cap fail loudly with
288    /// `StorageFull` — an in-band error a model reads and adapts to; fail
289    /// loud over quietly eating RAM.
290    ///
291    /// **Why the agent preset is bounded by default:** an agent embedder
292    /// typically creates a fresh kernel per `execute()` call, so the 64 MiB cap
293    /// is per-call, not per-session. Embedders that know their workload needs
294    /// more opt out with `without_vfs_budget()` or raise the cap with
295    /// `with_vfs_budget(bytes)` — protection on by default, opt out knowingly.
296    /// All other profiles default to `None` (unbounded).
297    ///
298    /// Follows the same pattern as `OutputLimitConfig`: agent preset bounded, rest unbounded.
299    pub vfs_budget_bytes: Option<u64>,
300
301    /// Enable copy-on-write overlay mode (opt-in).
302    ///
303    /// When `true`, the primary local filesystem mount is wrapped in an
304    /// `OverlayFs` so writes are virtual — the lower layer is never touched.
305    /// Use `kaish-vfs status/diff/commit/reset` to inspect and manage the
306    /// overlay transaction.
307    ///
308    /// **Passthrough:** `/` becomes `OverlayFs over LocalFs::read_only("/")`.
309    /// **Sandboxed{root}:** the `{root}` mount becomes
310    /// `OverlayFs over LocalFs::read_only(root)`; the `/tmp` and XDG runtime
311    /// mounts stay as real `LocalFs` (real writes escape the transaction —
312    /// see `docs/kaish-overlayfs.md` for the escape-hatch inventory).
313    /// **NoLocal:** incompatible — construction fails loudly (everything is
314    /// already virtual; an overlay adds no value and no lower layer to wrap).
315    /// **with_backend:** incompatible — the embedder controls the VFS; the
316    /// kernel cannot wrap it without bypassing the embedder's semantics.
317    ///
318    /// **Not default-on for the agent preset:** each `execute()` call gets a fresh kernel,
319    /// making the overlay a per-call transaction — `kaish-vfs commit` must run
320    /// in the same call as the writes, or the transaction is discarded on drop.
321    /// Frontends (REPL, MCP) expose `--overlay` as an explicit opt-in flag.
322    pub overlay: bool,
323
324    /// The [`JobManager`] this kernel adopts. `None` — the default — builds a
325    /// fresh one, so every kernel owns its own job table.
326    ///
327    /// Supply one to share a single job table across kernels. An embedder that
328    /// builds a kernel per request (kaijutsu builds one per tool call) has no
329    /// other way to keep a `cmd &` job reachable: ids, status, and output
330    /// streams all live on the manager, so a per-kernel manager takes them
331    /// down with the kernel that made it. One manager held by the embedder and
332    /// handed to every kernel keeps `&` usable across calls, and keeps job ids
333    /// unique because they are minted from the manager's own counter.
334    ///
335    /// **A shared manager carries shared settings.** `kill_grace` and
336    /// `persist_output_files` are stamped onto the manager at kernel
337    /// construction, so the last kernel built wins for both: a hermetic kernel
338    /// (`NoLocal`, or any `with_backend` kernel) turns `persist_output_files`
339    /// off for every kernel on that manager, and each kernel's
340    /// [`Self::kill_grace`] overwrites the previous one's. Share a manager
341    /// between kernels configured alike, or accept the last writer.
342    ///
343    /// Set through [`Self::with_job_manager`].
344    pub job_manager: Option<Arc<JobManager>>,
345
346    /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on every external command this kernel
347    /// spawns, so the OS kills the child the instant this process dies —
348    /// **for any reason, including `kill -9`, a segfault, or an OOM kill.**
349    ///
350    /// Off by default; on for [`Self::agent`] and [`Self::agent_with_root`],
351    /// the same "protection on by default for the agent preset, opt in
352    /// elsewhere" split [`Self::vfs_budget_bytes`] uses.
353    ///
354    /// **Why not unconditional.** kaish already puts every child in its own
355    /// process group and kills through a pidfd on cancel, and drops it with
356    /// `kill_on_drop`. All three need this process to still be running code,
357    /// so none of them survive a hard kill — that is the gap this closes. But
358    /// closing it costs something a human at a REPL may not want: an armed
359    /// child cannot outlive its shell, at all, and the child has no way to
360    /// opt out from inside (unlike SIGHUP, which `nohup`/`disown` exist to
361    /// escape). A REPL user who backgrounds a long download and exits expects
362    /// it to keep going. An agent embedder expects the opposite — an
363    /// invisible orphaned `cargo build` is the failure — so the presets
364    /// differ rather than one behavior being forced on both.
365    ///
366    /// **Linux only.** macOS has no `PR_SET_PDEATHSIG` and no equivalent that
367    /// works without a live parent (`kqueue`'s `NOTE_EXIT` needs a watcher
368    /// process). This flag is accepted and has no effect there, rather than
369    /// being faked with something weaker.
370    ///
371    /// Set through [`Self::with_kill_children_on_parent_death`].
372    pub kill_children_on_parent_death: bool,
373}
374
375/// Get the default sandbox root ($HOME).
376#[cfg(feature = "localfs")]
377fn default_sandbox_root() -> PathBuf {
378    std::env::var("HOME")
379        .map(PathBuf::from)
380        .unwrap_or_else(|_| PathBuf::from("/"))
381}
382
383impl Default for KernelConfig {
384    fn default() -> Self {
385        #[cfg(feature = "localfs")]
386        {
387            let home = default_sandbox_root();
388            Self {
389                name: "default".to_string(),
390                vfs_mode: VfsMountMode::Sandboxed { root: None },
391                cwd: home,
392                skip_validation: false,
393                interactive: false,
394                ignore_config: crate::ignore_config::IgnoreConfig::none(),
395                output_limit: crate::output_limit::OutputLimitConfig::none(),
396                allow_external_commands: cfg!(feature = "subprocess"),
397                trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
398                errexit_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        #[cfg(not(feature = "localfs"))]
409        {
410            Self {
411                name: "default".to_string(),
412                vfs_mode: VfsMountMode::NoLocal,
413                cwd: PathBuf::from("/"),
414                skip_validation: false,
415                interactive: false,
416                ignore_config: crate::ignore_config::IgnoreConfig::none(),
417                output_limit: crate::output_limit::OutputLimitConfig::none(),
418                allow_external_commands: false,
419                trash_enabled: false,
420                errexit_enabled: false,
421                initial_vars: HashMap::new(),
422                request_timeout: None,
423                kill_grace: Duration::from_secs(2),
424                vfs_budget_bytes: None,
425                overlay: false,
426                job_manager: None,
427                kill_children_on_parent_death: false,
428            }
429        }
430    }
431}
432
433impl KernelConfig {
434    /// Create a transient kernel config (sandboxed, for temporary use).
435    #[cfg(feature = "localfs")]
436    pub fn transient() -> Self {
437        let home = default_sandbox_root();
438        Self {
439            name: "transient".to_string(),
440            vfs_mode: VfsMountMode::Sandboxed { root: None },
441            cwd: home,
442            skip_validation: false,
443            interactive: false,
444            ignore_config: crate::ignore_config::IgnoreConfig::none(),
445            output_limit: crate::output_limit::OutputLimitConfig::none(),
446            allow_external_commands: cfg!(feature = "subprocess"),
447            trash_enabled: false,
448            errexit_enabled: false,
449            initial_vars: HashMap::new(),
450            request_timeout: None,
451            kill_grace: Duration::from_secs(2),
452            vfs_budget_bytes: None,
453            overlay: false,
454            job_manager: None,
455            kill_children_on_parent_death: false,
456        }
457    }
458
459    /// Create a transient kernel config (isolated, no-default-features).
460    #[cfg(not(feature = "localfs"))]
461    pub fn transient() -> Self {
462        Self::isolated()
463    }
464
465    /// Create a kernel config with the given name (sandboxed by default).
466    #[cfg(feature = "localfs")]
467    pub fn named(name: &str) -> Self {
468        let home = default_sandbox_root();
469        Self {
470            name: name.to_string(),
471            vfs_mode: VfsMountMode::Sandboxed { root: None },
472            cwd: home,
473            skip_validation: false,
474            interactive: false,
475            ignore_config: crate::ignore_config::IgnoreConfig::none(),
476            output_limit: crate::output_limit::OutputLimitConfig::none(),
477            allow_external_commands: cfg!(feature = "subprocess"),
478            trash_enabled: false,
479            errexit_enabled: false,
480            initial_vars: HashMap::new(),
481            request_timeout: None,
482            kill_grace: Duration::from_secs(2),
483            vfs_budget_bytes: None,
484            overlay: false,
485            job_manager: None,
486            kill_children_on_parent_death: false,
487        }
488    }
489
490    /// Create a kernel config with the given name (isolated, no-default-features).
491    #[cfg(not(feature = "localfs"))]
492    pub fn named(name: &str) -> Self {
493        Self {
494            name: name.to_string(),
495            ..Self::isolated()
496        }
497    }
498
499    /// Create a REPL config with passthrough filesystem access.
500    ///
501    /// Native paths like `/home/user/project` work directly.
502    /// The cwd is set to the actual current working directory.
503    #[cfg(feature = "localfs")]
504    pub fn repl() -> Self {
505        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
506        Self {
507            name: "repl".to_string(),
508            vfs_mode: VfsMountMode::Passthrough,
509            cwd,
510            skip_validation: false,
511            interactive: false,
512            // Ignore-aware by default (GH #134): .gitignore + default ignores
513            // at Advisory scope — `--no-ignore` / `kaish-ignore clear` recover.
514            ignore_config: crate::ignore_config::IgnoreConfig::interactive(),
515            output_limit: crate::output_limit::OutputLimitConfig::none(),
516            allow_external_commands: cfg!(feature = "subprocess"),
517            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
518            errexit_enabled: false,
519            initial_vars: HashMap::new(),
520            request_timeout: None,
521            kill_grace: Duration::from_secs(2),
522            vfs_budget_bytes: None,
523            overlay: false,
524            job_manager: None,
525            kill_children_on_parent_death: false,
526        }
527    }
528
529    /// Create a sandboxed-agent config with sandboxed filesystem access.
530    ///
531    /// The preset for embedding kaish as an untrusted agent's shell (e.g. an MCP
532    /// server like kaibo/kaijutsu): sandboxed VFS, non-interactive, bounded
533    /// memory and output. Local filesystem is accessible at its real path (e.g.,
534    /// `/home/user`), but sandboxed to `$HOME`. Paths outside the sandbox are not
535    /// accessible through builtins. External commands still access the real
536    /// filesystem — use `.with_allow_external_commands(false)` to block them.
537    ///
538    /// VFS memory is bounded at 64 MiB per `execute()` call by default (an agent
539    /// embedder typically creates a fresh kernel per call). Raise or remove with
540    /// `with_vfs_budget` / `without_vfs_budget`.
541    #[cfg(feature = "localfs")]
542    pub fn agent() -> Self {
543        let home = default_sandbox_root();
544        Self {
545            name: "agent".to_string(),
546            vfs_mode: VfsMountMode::Sandboxed { root: None },
547            cwd: home,
548            skip_validation: false,
549            interactive: false,
550            ignore_config: crate::ignore_config::IgnoreConfig::agent(),
551            output_limit: crate::output_limit::OutputLimitConfig::agent(),
552            allow_external_commands: cfg!(feature = "subprocess"),
553            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
554            errexit_enabled: false,
555            initial_vars: HashMap::new(),
556            request_timeout: None,
557            kill_grace: Duration::from_secs(2),
558            vfs_budget_bytes: Some(64 * 1024 * 1024),
559            overlay: false,
560            job_manager: None,
561            // An agent embedder must never leave an invisible `cargo build` running
562            // after its process is hard-killed; see the field doc for why this is
563            // not the default everywhere.
564            kill_children_on_parent_death: true,
565        }
566    }
567
568    /// Create a sandboxed-agent config with a custom sandbox root.
569    ///
570    /// Use this to restrict access to a subdirectory like `~/src`.
571    ///
572    /// VFS memory is bounded at 64 MiB per `execute()` call by default.
573    /// Raise or remove with `with_vfs_budget` / `without_vfs_budget`.
574    #[cfg(feature = "localfs")]
575    pub fn agent_with_root(root: PathBuf) -> Self {
576        Self {
577            name: "agent".to_string(),
578            vfs_mode: VfsMountMode::Sandboxed { root: Some(root.clone()) },
579            cwd: root,
580            skip_validation: false,
581            interactive: false,
582            ignore_config: crate::ignore_config::IgnoreConfig::agent(),
583            output_limit: crate::output_limit::OutputLimitConfig::agent(),
584            allow_external_commands: cfg!(feature = "subprocess"),
585            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
586            errexit_enabled: false,
587            initial_vars: HashMap::new(),
588            request_timeout: None,
589            kill_grace: Duration::from_secs(2),
590            vfs_budget_bytes: Some(64 * 1024 * 1024),
591            overlay: false,
592            job_manager: None,
593            // Same reasoning as `agent()`.
594            kill_children_on_parent_death: true,
595        }
596    }
597
598    /// Create a config with no local filesystem (memory only).
599    ///
600    /// Complete isolation: no local filesystem and external commands are disabled.
601    /// Useful for tests or pure sandboxed execution.
602    pub fn isolated() -> Self {
603        Self {
604            name: "isolated".to_string(),
605            vfs_mode: VfsMountMode::NoLocal,
606            cwd: PathBuf::from("/"),
607            skip_validation: false,
608            interactive: false,
609            ignore_config: crate::ignore_config::IgnoreConfig::none(),
610            output_limit: crate::output_limit::OutputLimitConfig::none(),
611            allow_external_commands: false,
612            trash_enabled: false,
613            errexit_enabled: false,
614            initial_vars: HashMap::new(),
615            request_timeout: None,
616            kill_grace: Duration::from_secs(2),
617            vfs_budget_bytes: None,
618            overlay: false,
619            job_manager: None,
620            kill_children_on_parent_death: false,
621        }
622    }
623
624    /// Set the VFS mount mode.
625    pub fn with_vfs_mode(mut self, mode: VfsMountMode) -> Self {
626        self.vfs_mode = mode;
627        self
628    }
629
630    /// Set the initial working directory.
631    pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
632        self.cwd = cwd;
633        self
634    }
635
636    /// Skip pre-execution validation.
637    pub fn with_skip_validation(mut self, skip: bool) -> Self {
638        self.skip_validation = skip;
639        self
640    }
641
642    /// Enable interactive mode (external commands inherit stdio).
643    pub fn with_interactive(mut self, interactive: bool) -> Self {
644        self.interactive = interactive;
645        self
646    }
647
648    /// Set the ignore file configuration.
649    pub fn with_ignore_config(mut self, config: crate::ignore_config::IgnoreConfig) -> Self {
650        self.ignore_config = config;
651        self
652    }
653
654    /// Set the output limit configuration.
655    pub fn with_output_limit(mut self, config: crate::output_limit::OutputLimitConfig) -> Self {
656        self.output_limit = config;
657        self
658    }
659
660    /// Set whether external command execution is allowed.
661    ///
662    /// When `false`, commands not found as builtins report that external
663    /// commands are disabled on this shell — distinct from "command not
664    /// found", which stays reserved for a name that genuinely isn't
665    /// resolvable — instead of searching PATH. Backend-registered tools
666    /// (MCP, an embedder's own registry) are unaffected and still resolve.
667    /// The `exec` and `spawn` builtins also refuse, with the same wording.
668    /// Use this to prevent VFS sandbox bypass via external binaries.
669    pub fn with_allow_external_commands(mut self, allow: bool) -> Self {
670        self.allow_external_commands = allow;
671        self
672    }
673
674    /// Enable or disable trash-on-delete at startup.
675    pub fn with_trash(mut self, enabled: bool) -> Self {
676        self.trash_enabled = enabled;
677        self
678    }
679
680    /// Enable or disable errexit (`set -e`) at startup. See `errexit_enabled`
681    /// for precedence against `ExecuteOptions::errexit` and runtime `set -e`.
682    pub fn with_errexit(mut self, enabled: bool) -> Self {
683        self.errexit_enabled = enabled;
684        self
685    }
686
687    /// Add a single initial variable; marked exported when the kernel boots.
688    ///
689    /// Repeated calls add (last write wins on key collision).
690    pub fn with_var(mut self, name: impl Into<String>, value: Value) -> Self {
691        self.initial_vars.insert(name.into(), value);
692        self
693    }
694
695    /// Replace the entire initial-vars map. All entries are marked exported.
696    pub fn with_initial_vars(mut self, vars: HashMap<String, Value>) -> Self {
697        self.initial_vars = vars;
698        self
699    }
700
701    /// Extend the initial-vars map with the given entries (last write wins).
702    pub fn with_vars(mut self, vars: HashMap<String, Value>) -> Self {
703        self.initial_vars.extend(vars);
704        self
705    }
706
707    /// Set the default per-request timeout (kernel-wide).
708    ///
709    /// Each `execute_with_options` call without an explicit timeout uses
710    /// this. On elapsed, the kernel cancels and returns exit code 124.
711    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
712        self.request_timeout = Some(timeout);
713        self
714    }
715
716    /// Set the SIGTERM-to-SIGKILL grace period for child kills.
717    pub fn with_kill_grace(mut self, grace: Duration) -> Self {
718        self.kill_grace = grace;
719        self
720    }
721
722    /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on external commands so a hard-killed
723    /// kaish process cannot orphan them (Linux only — read
724    /// [`Self::kill_children_on_parent_death`] for the tradeoff and the macOS
725    /// gap).
726    pub fn with_kill_children_on_parent_death(mut self, on: bool) -> Self {
727        self.kill_children_on_parent_death = on;
728        self
729    }
730
731    /// Adopt an embedder-owned [`JobManager`] instead of building a fresh one,
732    /// so background jobs outlive the kernel that started them. Read
733    /// [`Self::job_manager`] before sharing one manager between kernels that
734    /// are configured differently.
735    pub fn with_job_manager(mut self, jobs: Arc<JobManager>) -> Self {
736        self.job_manager = Some(jobs);
737        self
738    }
739
740    /// Cap VFS memory-resident bytes at `bytes` across all kernel-owned
741    /// `MemoryFs` mounts. A shared `ByteBudget` labeled `"vfs-memory"` is
742    /// created at kernel construction and passed to every `MemoryFs` the
743    /// kernel builds (see `setup_vfs` and `with_backend`).
744    ///
745    /// Writes that would exceed the cap fail loudly with `StorageFull` — an
746    /// in-band error a model reads and adapts to; fail loud over quietly eating
747    /// RAM. Use `without_vfs_budget` to remove the cap entirely.
748    pub fn with_vfs_budget(mut self, bytes: u64) -> Self {
749        self.vfs_budget_bytes = Some(bytes);
750        self
751    }
752
753    /// Remove the VFS memory budget — all `MemoryFs` mounts are unbounded.
754    ///
755    /// Use when the caller knows the workload and the default 64 MiB cap
756    /// (set by `KernelConfig::agent`) is too conservative.
757    pub fn without_vfs_budget(mut self) -> Self {
758        self.vfs_budget_bytes = None;
759        self
760    }
761
762    /// Enable or disable copy-on-write overlay mode.
763    ///
764    /// When `true`, the primary local filesystem mount is wrapped in an
765    /// `OverlayFs` so writes are virtual — the lower layer is never touched.
766    /// Incompatible with `VfsMountMode::NoLocal` (fails loudly at construction)
767    /// and `with_backend` kernels (same — the embedder controls the VFS).
768    pub fn with_overlay(mut self, overlay: bool) -> Self {
769        self.overlay = overlay;
770        self
771    }
772
773}
774
775
776/// Handle to an active overlay session, kept on the kernel and shared to
777/// `ExecContext` so the `kaish-vfs` builtin can reach the `OverlayFs`.
778///
779/// The `mount_path` is the VFS prefix the overlay was mounted under (e.g.
780/// `/home/user`); `commit_root` is the real filesystem path the overlay's
781/// lower is backed by (used as the target for `kaish-vfs commit`).
782#[cfg(all(feature = "localfs", feature = "overlay"))]
783#[derive(Clone)]
784pub struct OverlayHandle {
785    /// The mounted `OverlayFs`, Arc-shared so the builtin can call inspection
786    /// methods without holding a VfsRouter lock.
787    pub fs: Arc<OverlayFs>,
788    /// VFS path this overlay is mounted at (e.g. `/home/user`).
789    pub mount_path: PathBuf,
790    /// Real filesystem root to commit into. Same as the lower's root.
791    pub commit_root: PathBuf,
792}
793
794/// The Kernel (核) — executes kaish code.
795///
796/// This is the primary interface for running kaish commands. It owns all
797/// the runtime state: variables, tools, VFS, jobs, and persistence.
798pub struct Kernel {
799    /// Kernel name.
800    name: String,
801    /// Variable scope.
802    scope: RwLock<Scope>,
803    /// Tool registry.
804    tools: Arc<ToolRegistry>,
805    /// User-defined tools (from `tool name { body }` statements).
806    user_tools: RwLock<HashMap<String, ToolDef>>,
807    /// Virtual filesystem router.
808    vfs: Arc<VfsRouter>,
809    /// Background job manager.
810    jobs: Arc<JobManager>,
811    /// Pipeline runner.
812    runner: PipelineRunner,
813    /// Execution context (cwd, stdin, etc.).
814    exec_ctx: RwLock<ExecContext>,
815    /// Frontend-seeded variables (HOME/PATH/etc, from `KernelConfig::initial_vars`),
816    /// retained past construction so `reset()` can re-seed them into the fresh
817    /// scope instead of silently dropping them.
818    initial_vars: HashMap<String, Value>,
819    /// Whether to skip pre-execution validation.
820    skip_validation: bool,
821    /// When true, standalone external commands inherit stdio for real-time output.
822    interactive: bool,
823    /// Whether external command execution is allowed.
824    allow_external_commands: bool,
825    /// Shared memory budget for all kernel-owned `MemoryFs` mounts.
826    ///
827    /// `None` when `KernelConfig::vfs_budget_bytes` was `None` (unbounded).
828    /// `Some` is Arc-cloned into forks so all concurrent execution draws from
829    /// the same pool — a background job's writes reduce the same cap as
830    /// foreground writes, which is the correct behaviour.
831    vfs_budget: Option<Arc<kaish_vfs::ByteBudget>>,
832    /// Active overlay session handle, if this kernel was constructed with
833    /// `overlay: true`. Arc-shared so `ExecContext` (and thus the
834    /// `kaish-vfs` builtin) can inspect and mutate the overlay without
835    /// holding a kernel write lock. Propagated to forks via `fork_inner`
836    /// and `child_for_pipeline` so `kaish-vfs` works inside background
837    /// jobs, scatter workers, and pipeline stages.
838    #[cfg(all(feature = "localfs", feature = "overlay"))]
839    overlay_handle: Option<Arc<OverlayHandle>>,
840    /// Default per-request timeout (None = no default).
841    request_timeout: Option<Duration>,
842    /// SIGTERM-to-SIGKILL grace period for child kills.
843    kill_grace: Duration,
844    /// Receiver for the kernel stderr stream.
845    ///
846    /// Pipeline stages write to the corresponding `StderrStream` (set on ExecContext).
847    /// The kernel drains this after each statement in `execute_streaming`.
848    stderr_receiver: tokio::sync::Mutex<StderrReceiver>,
849    /// Cancellation token for interrupting execution (Ctrl-C).
850    ///
851    /// Protected by `std::sync::Mutex` (not tokio) because the SIGINT handler
852    /// needs sync access. Each `execute()` call gets a fresh child token;
853    /// `cancel()` cancels the current token and replaces it.
854    cancel_token: std::sync::Mutex<tokio_util::sync::CancellationToken>,
855    /// Per-call polled interrupt check (`ExecuteOptions::interrupt`),
856    /// installed for the duration of an `execute_with_options` call and
857    /// cleared on exit. Consulted by `is_cancelled()` so every existing
858    /// cancellation checkpoint gains interrupt awareness without new wiring.
859    /// std Mutex for the same sync-access reason as `cancel_token`.
860    interrupt: std::sync::Mutex<Option<std::sync::Arc<dyn Fn() -> bool + Send + Sync>>>,
861    /// Terminal state for job control (interactive mode only, Unix only).
862    #[cfg(all(unix, feature = "subprocess"))]
863    terminal_state: Option<Arc<crate::terminal::TerminalState>>,
864    /// Weak self-reference for handing out `Arc<dyn CommandDispatcher>`.
865    ///
866    /// Set by `into_arc()`. Allows builtins to re-dispatch inner commands
867    /// through the full Kernel resolution chain.
868    self_weak: std::sync::OnceLock<std::sync::Weak<Self>>,
869    /// Background job this kernel (a fork) is executing on behalf of, if any.
870    /// Set on the fork created by `execute_background` and inherited by all its
871    /// sub-forks (pipeline stages, scatter workers), so an external command
872    /// spawned anywhere under a background job can record its process group on
873    /// that job for `kill -<sig> %N`. `None` for foreground execution.
874    bg_job_id: Option<crate::scheduler::JobId>,
875    /// Serializes concurrent `execute()` / `execute_streaming()` callers on
876    /// this Kernel instance. Tokio's Mutex is fair (FIFO) and acts as the
877    /// queue. Background jobs, scatter workers, and concurrent pipeline
878    /// stages do NOT take this lock — they run against a *forked* Kernel
879    /// (see [`Kernel::fork`]) so they never contend with the foreground.
880    execute_lock: tokio::sync::Mutex<()>,
881    /// Current dynamic statement-engine re-entry depth — incremented on entry
882    /// to command substitution, a shell-function call, or a `.kai` source, and
883    /// decremented (via an RAII guard, so cancellation stays balanced) on exit.
884    /// Checked against [`MAX_RECURSION_DEPTH`] to turn a stack overflow into a
885    /// loud error (GH #46). Per-Kernel: a fork starts fresh at 0 because it
886    /// runs on its own stack. Atomic only for `Send`/`Sync`; within one Kernel
887    /// the recursion chain is single-threaded (top-level `execute` is
888    /// serialized by `execute_lock`; concurrency happens on forks).
889    recursion_depth: AtomicUsize,
890}
891
892/// RAII balance for [`Kernel::recursion_depth`]: increments on construction
893/// (in `enter_recursion`) and decrements on drop, so a cancelled or
894/// error-unwound re-entry can never leave the counter inflated (which would
895/// spuriously trip later, unrelated recursions).
896struct RecursionGuard<'a> {
897    counter: &'a AtomicUsize,
898}
899
900impl Drop for RecursionGuard<'_> {
901    fn drop(&mut self) {
902        self.counter.fetch_sub(1, Ordering::Relaxed);
903    }
904}
905
906/// Internal result of [`Kernel::setup_vfs`].
907struct VfsSetupResult {
908    vfs: VfsRouter,
909    budget: Option<Arc<ByteBudget>>,
910    #[cfg(all(feature = "localfs", feature = "overlay"))]
911    overlay_handle: Option<Arc<OverlayHandle>>,
912}
913
914impl Kernel {
915    /// Create a new kernel with the given configuration.
916    pub fn new(config: KernelConfig) -> Result<Self> {
917        let mut setup = Self::setup_vfs(&config)?;
918        // An embedder-supplied manager keeps `cmd &` jobs alive across kernels
919        // (see `KernelConfig::job_manager`); with none, this kernel owns its
920        // own job table exactly as before.
921        let jobs = config.job_manager.clone().unwrap_or_else(|| Arc::new(JobManager::new()));
922        // Mirror the cascade's SIGTERM->SIGKILL grace onto the manager so the
923        // kill builtin bounds its wait-for-death on the same number (GH #244).
924        jobs.set_kill_grace(config.kill_grace);
925
926        // Mount JobFs for job observability at /v/jobs
927        setup.vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
928
929        #[cfg(all(feature = "localfs", feature = "overlay"))]
930        let overlay_handle = setup.overlay_handle.take();
931
932        // Mode-based construction: the kernel owns its host mounts, so whether
933        // host side channels are allowed is decided by the VFS mode inside
934        // `assemble` (NoLocal forbids them).
935        let kernel = Self::assemble(config, setup.vfs, jobs, false, setup.budget, |_| {}, |vfs_ref, tools| {
936            ExecContext::with_vfs_and_tools(vfs_ref.clone(), tools.clone())
937        })?;
938
939        #[cfg(all(feature = "localfs", feature = "overlay"))]
940        {
941            let mut kernel = kernel;
942            kernel.overlay_handle = overlay_handle;
943            // Also set it on the ExecContext so builtins can access it.
944            if let Some(ref handle) = kernel.overlay_handle {
945                kernel.exec_ctx.get_mut().overlay_handle = Some(Arc::clone(handle));
946            }
947            return Ok(kernel);
948        }
949
950        #[allow(unreachable_code)]
951        Ok(kernel)
952    }
953
954    /// Set up VFS based on mount mode.
955    ///
956    /// Returns the router, the budget handle (if bounded), and an optional
957    /// overlay handle when `config.overlay` is true. The budget is Arc-shared:
958    /// every `MemoryFs` the kernel creates here holds a clone of the same
959    /// `Arc<ByteBudget>`, so the total charged against it is the sum of all
960    /// in-memory content across all kernel-owned memory mounts.
961    ///
962    /// # Errors
963    /// Returns `Err` if `config.overlay` is true and the mode is `NoLocal`
964    /// (overlay is meaningless when everything is already virtual — there is
965    /// no real lower layer to wrap). The caller (`Kernel::new`) propagates
966    /// this as an `anyhow::Error`.
967    fn setup_vfs(config: &KernelConfig) -> Result<VfsSetupResult> {
968        let mut vfs = VfsRouter::new();
969
970        // One budget for all memory mounts this kernel owns — labeled so the
971        // error message tells the user exactly which knob to raise.
972        let budget: Option<Arc<ByteBudget>> = config
973            .vfs_budget_bytes
974            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
975
976        /// Helper: construct a `MemoryFs` wired to `budget` if present.
977        fn mem(budget: &Option<Arc<ByteBudget>>) -> MemoryFs {
978            match budget {
979                Some(b) => MemoryFs::with_budget(Arc::clone(b)),
980                None => MemoryFs::new(),
981            }
982        }
983
984        // Overlay handle — populated below if config.overlay is true.
985        #[cfg(all(feature = "localfs", feature = "overlay"))]
986        let mut overlay_handle: Option<Arc<OverlayHandle>> = None;
987
988        match &config.vfs_mode {
989            #[cfg(feature = "localfs")]
990            VfsMountMode::Passthrough => {
991                #[cfg(feature = "overlay")]
992                if config.overlay {
993                    // Wrap "/" in an OverlayFs so writes are virtual.
994                    let lower = Arc::new(LocalFs::read_only(PathBuf::from("/")));
995                    let overlay_fs = Arc::new(match &budget {
996                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
997                        None => OverlayFs::over(lower),
998                    });
999                    let handle = Arc::new(OverlayHandle {
1000                        fs: Arc::clone(&overlay_fs),
1001                        mount_path: PathBuf::from("/"),
1002                        commit_root: PathBuf::from("/"),
1003                    });
1004                    vfs.mount_arc("/", overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
1005                    overlay_handle = Some(handle);
1006                } else {
1007                    // LocalFs at "/" — native paths work directly
1008                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
1009                }
1010                #[cfg(not(feature = "overlay"))]
1011                {
1012                    if config.overlay {
1013                        return Err(anyhow::anyhow!(
1014                            "overlay=true requires the `overlay` feature, but this build \
1015                             was compiled without it. Recompile with --features overlay \
1016                             (or the default feature set) to enable overlay mode."
1017                        ));
1018                    }
1019                    // LocalFs at "/" — native paths work directly
1020                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
1021                }
1022                // Memory for blobs
1023                vfs.mount("/v", mem(&budget));
1024            }
1025            #[cfg(feature = "localfs")]
1026            VfsMountMode::Sandboxed { root } => {
1027                // Memory at root for safety (catches paths outside sandbox).
1028                // Note: /tmp and the XDG runtime dir are LocalFs — writes
1029                // there escape the VFS budget and are NOT virtual. This is
1030                // intentional: /tmp interop with other processes matters more
1031                // than accounting for scratch files there.
1032                vfs.mount("/", mem(&budget));
1033                vfs.mount("/v", mem(&budget));
1034
1035                // Synthetic /dev: the host's real /dev isn't reachable here, so
1036                // /dev/null and /dev/zero are software-backed (see DevFs).
1037                vfs.mount("/dev", DevFs::new());
1038
1039                // Real /tmp for interop with other processes
1040                vfs.mount("/tmp", LocalFs::new(PathBuf::from("/tmp")));
1041
1042                // Mount XDG runtime dir for spill files and socket access
1043                let runtime = crate::paths::xdg_runtime_dir();
1044                if runtime.exists() {
1045                    let runtime_str = runtime.to_string_lossy().to_string();
1046                    vfs.mount(&runtime_str, LocalFs::new(runtime));
1047                }
1048
1049                // Resolve the sandbox root (defaults to $HOME)
1050                let local_root = root.clone().unwrap_or_else(|| {
1051                    std::env::var("HOME")
1052                        .map(PathBuf::from)
1053                        .unwrap_or_else(|_| PathBuf::from("/"))
1054                });
1055
1056                let mount_point = local_root.to_string_lossy().to_string();
1057
1058                #[cfg(feature = "overlay")]
1059                if config.overlay {
1060                    // Wrap the sandbox root in an OverlayFs.
1061                    let lower = Arc::new(LocalFs::read_only(local_root.clone()));
1062                    let overlay_fs = Arc::new(match &budget {
1063                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
1064                        None => OverlayFs::over(lower),
1065                    });
1066                    let handle = Arc::new(OverlayHandle {
1067                        fs: Arc::clone(&overlay_fs),
1068                        mount_path: PathBuf::from(&mount_point),
1069                        commit_root: local_root,
1070                    });
1071                    vfs.mount_arc(&mount_point, overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
1072                    overlay_handle = Some(handle);
1073                } else {
1074                    // Mount at the real path for transparent access
1075                    // e.g., /home/atobey → LocalFs("/home/atobey")
1076                    // so /home/atobey/src/kaish just works
1077                    vfs.mount(&mount_point, LocalFs::new(local_root));
1078                }
1079                #[cfg(not(feature = "overlay"))]
1080                {
1081                    if config.overlay {
1082                        return Err(anyhow::anyhow!(
1083                            "overlay=true requires the `overlay` feature, but this build \
1084                             was compiled without it. Recompile with --features overlay \
1085                             (or the default feature set) to enable overlay mode."
1086                        ));
1087                    }
1088                    // Mount at the real path for transparent access
1089                    vfs.mount(&mount_point, LocalFs::new(local_root));
1090                }
1091            }
1092            VfsMountMode::NoLocal => {
1093                if config.overlay {
1094                    return Err(anyhow::anyhow!(
1095                        "overlay=true is incompatible with VfsMountMode::NoLocal: \
1096                         everything is already virtual, there is no real lower layer \
1097                         to wrap. Use with_overlay(false) or switch to a Passthrough \
1098                         or Sandboxed VFS mode."
1099                    ));
1100                }
1101                // Pure memory mode — no local filesystem
1102                vfs.mount("/", mem(&budget));
1103                vfs.mount("/tmp", mem(&budget));
1104                vfs.mount("/v", mem(&budget));
1105                // Synthetic /dev so /dev/null and /dev/zero work hermetically.
1106                vfs.mount("/dev", DevFs::new());
1107            }
1108        }
1109
1110        Ok(VfsSetupResult {
1111            vfs,
1112            budget,
1113            #[cfg(all(feature = "localfs", feature = "overlay"))]
1114            overlay_handle,
1115        })
1116    }
1117
1118    /// Create a transient kernel (no persistence).
1119    pub fn transient() -> Result<Self> {
1120        Self::new(KernelConfig::transient())
1121    }
1122
1123    /// Create a kernel with a custom backend and `/v/*` virtual path support.
1124    ///
1125    /// This is the constructor for embedding kaish in other systems that provide
1126    /// their own storage backend (e.g., CRDT-backed storage in kaijutsu).
1127    ///
1128    /// A `VirtualOverlayBackend` routes paths automatically:
1129    /// - `/v/*` → Internal VFS (JobFs at `/v/jobs`, MemoryFs at `/v/blobs`)
1130    /// - `/dev` → DevFs (synthetic `/dev/null`, `/dev/zero`, `/dev/random`,
1131    ///   `/dev/urandom`) — kernel-owned so it works even when your backend is
1132    ///   read-only
1133    /// - Everything else → Your custom backend
1134    ///
1135    /// The optional `configure_vfs` closure lets you add additional virtual mounts
1136    /// (e.g., `/v/docs` for CRDT blocks) after the built-in mounts are set up.
1137    ///
1138    /// **Note:** The config's `vfs_mode` is ignored — all non-`/v/*` path routing
1139    /// is handled by your custom backend. The config is only used for `name`, `cwd`,
1140    /// `skip_validation`, and `interactive`.
1141    ///
1142    /// # Example
1143    ///
1144    /// ```ignore
1145    /// // Simple: default /v/* mounts only
1146    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |_| {})?;
1147    ///
1148    /// // With custom mounts
1149    /// let kernel = Kernel::with_backend(backend, config, |vfs| {
1150    ///     vfs.mount_arc("/v/docs", docs_fs);
1151    ///     vfs.mount_arc("/v/g", git_fs);
1152    /// }, |_| {})?;
1153    ///
1154    /// // With custom tools
1155    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |tools| {
1156    ///     tools.register(MyCustomTool::new());
1157    /// })?;
1158    /// ```
1159    pub fn with_backend(
1160        backend: Arc<dyn KernelBackend>,
1161        config: KernelConfig,
1162        configure_vfs: impl FnOnce(&mut VfsRouter),
1163        configure_tools: impl FnOnce(&mut ToolRegistry),
1164    ) -> Result<Self> {
1165        use crate::backend::VirtualOverlayBackend;
1166
1167        // overlay=true is incompatible with with_backend: the embedder controls
1168        // the VFS and the kernel cannot wrap it without bypassing the embedder's
1169        // semantics. Fail loudly rather than silently ignoring the flag.
1170        if config.overlay {
1171            return Err(anyhow::anyhow!(
1172                "overlay=true is incompatible with Kernel::with_backend: the embedder \
1173                 controls the VFS; the kernel cannot wrap it with an OverlayFs without \
1174                 bypassing the embedder's storage semantics. Use KernelConfig::with_overlay(false)."
1175            ));
1176        }
1177
1178        let mut vfs = VfsRouter::new();
1179        // See `Kernel::new` — the embedder's manager wins here too.
1180        let jobs = config.job_manager.clone().unwrap_or_else(|| Arc::new(JobManager::new()));
1181        // Mirror the cascade's SIGTERM->SIGKILL grace onto the manager so the
1182        // kill builtin bounds its wait-for-death on the same number (GH #244).
1183        jobs.set_kill_grace(config.kill_grace);
1184
1185        // Create the budget from config so `with_vfs_budget` / `without_vfs_budget`
1186        // work for `with_backend` callers too. The /v/blobs MemoryFs is the only
1187        // kernel-owned memory mount here — embedders own the rest of the VFS.
1188        let vfs_budget: Option<Arc<ByteBudget>> = config
1189            .vfs_budget_bytes
1190            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
1191
1192        vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
1193        let blobs_fs = match &vfs_budget {
1194            Some(b) => MemoryFs::with_budget(Arc::clone(b)),
1195            None => MemoryFs::new(),
1196        };
1197        vfs.mount("/v/blobs", blobs_fs);
1198
1199        // /dev/null and friends are software-backed (see DevFs) and must not
1200        // depend on the embedder's backend — a read-only embedder backend
1201        // (e.g. kaijutsu's read-only host root) would otherwise reject writes
1202        // to /dev/null as a filesystem error instead of discarding them.
1203        vfs.mount("/dev", DevFs::new());
1204
1205        // Let caller add custom mounts (e.g., /v/docs, /v/g)
1206        configure_vfs(&mut vfs);
1207
1208        // A custom-backend kernel owns no host mounts — the embedder supplies
1209        // the entire VFS — so any kernel write to a host filesystem via
1210        // `std::fs` (output spill, job output files) bypasses that VFS and its
1211        // read-only guarantees. Forbid host side channels unconditionally.
1212        Self::assemble(config, vfs, jobs, true, vfs_budget, configure_tools, |vfs_arc: &Arc<VfsRouter>, _: &Arc<ToolRegistry>| {
1213            let overlay: Arc<dyn KernelBackend> =
1214                Arc::new(VirtualOverlayBackend::new(backend, vfs_arc.clone()));
1215            ExecContext::with_backend(overlay)
1216        })
1217    }
1218
1219    /// Shared assembly: wires up tools, runner, scope, and ExecContext.
1220    ///
1221    /// The `make_ctx` closure receives the VFS and tools so backends that need
1222    /// them (like `LocalBackend::with_tools`) can capture them. Custom backends
1223    /// that already have their own storage can ignore these parameters.
1224    fn assemble(
1225        config: KernelConfig,
1226        mut vfs: VfsRouter,
1227        jobs: Arc<JobManager>,
1228        no_host_filesystem: bool,
1229        vfs_budget: Option<Arc<ByteBudget>>,
1230        configure_tools: impl FnOnce(&mut ToolRegistry),
1231        make_ctx: impl FnOnce(&Arc<VfsRouter>, &Arc<ToolRegistry>) -> ExecContext,
1232    ) -> Result<Self> {
1233        // A kernel with no host filesystem of its own must never write to one
1234        // through a side channel. Two paths bypass the VFS by going straight to
1235        // `std::fs`: output spill (`paths::spill_dir()` → host temp/cache) and
1236        // background-job output files (`Job::write_output_file` → host temp).
1237        // Both would punch through the isolation, so force them off:
1238        // in-memory truncation for spill, no host file for job output.
1239        //
1240        // This is true for a `NoLocal` kernel (mounts nothing) and for any
1241        // `with_backend` kernel (`no_host_filesystem` — the embedder owns the
1242        // VFS, so the kernel controls no host mounts and any host write is a
1243        // bypass). Overrides an explicit `SpillMode::Disk`, which is nonsensical
1244        // when there is no kernel-owned host filesystem to spill to.
1245        let no_host_side_channel =
1246            no_host_filesystem || matches!(config.vfs_mode, VfsMountMode::NoLocal);
1247
1248        let KernelConfig { name, cwd, skip_validation, interactive, ignore_config, mut output_limit, allow_external_commands, trash_enabled, errexit_enabled, initial_vars, request_timeout, kill_grace, kill_children_on_parent_death, .. } = config;
1249
1250        if no_host_side_channel {
1251            output_limit.set_spill_mode(crate::output_limit::SpillMode::Memory);
1252            jobs.set_persist_output_files(false);
1253        }
1254
1255        let mut tools = ToolRegistry::new();
1256        register_builtins(&mut tools);
1257        configure_tools(&mut tools);
1258        let tools = Arc::new(tools);
1259
1260        // Mount BuiltinFs so `ls /v/bin` lists builtins
1261        vfs.mount("/v/bin", BuiltinFs::new(tools.clone()));
1262
1263        let vfs = Arc::new(vfs);
1264
1265        let runner = PipelineRunner::new(tools.clone());
1266
1267        let (stderr_writer, stderr_receiver) = stderr_stream();
1268
1269        let mut exec_ctx = make_ctx(&vfs, &tools);
1270        let initial_cwd = cwd.clone();
1271        exec_ctx.set_cwd(cwd);
1272        exec_ctx.kill_children_on_parent_death = kill_children_on_parent_death;
1273        exec_ctx.set_job_manager(jobs.clone());
1274        exec_ctx.set_tool_schemas(tools.schemas());
1275        exec_ctx.set_tools(tools.clone());
1276        #[cfg(feature = "os-integration")]
1277        exec_ctx.set_trash_backend(Arc::new(crate::trash_system::SystemTrash));
1278        exec_ctx.stderr = Some(stderr_writer);
1279        exec_ctx.ignore_config = ignore_config;
1280        exec_ctx.output_limit = output_limit;
1281        exec_ctx.allow_external_commands = allow_external_commands;
1282        exec_ctx.vfs_budget = vfs_budget.clone();
1283
1284        Ok(Self {
1285            name,
1286            scope: RwLock::new({
1287                let mut scope = Scope::new();
1288                scope.set_pid(KERNEL_COUNTER.fetch_add(1, Ordering::Relaxed));
1289                // HOME is NOT read from the host env here — the kernel is
1290                // hermetic. Frontends (REPL, MCP) seed it via `initial_vars`
1291                // below (from `std::env::vars()`); a hermetic embedder leaves
1292                // `initial_vars` empty and gets no HOME (tilde stays literal).
1293                // Apply caller-supplied initial variables, all marked exported.
1294                // Frontends (REPL, MCP) populate this from std::env::vars()
1295                // for shell-like UX; embedders that want hermetic behavior
1296                // simply leave it empty.
1297                for (name, value) in initial_vars.clone() {
1298                    scope.set_exported(name, value);
1299                }
1300                scope.set_trash_enabled(trash_enabled);
1301                scope.set_error_exit(errexit_enabled);
1302                // `$PWD` before any `cd`. Seeded HERE, not just on `exec_ctx`:
1303                // this is the scope execution reads, and `exec_ctx.scope` is
1304                // overwritten from it at every dispatch, so a value written
1305                // only there never survives to be read.
1306                scope.set_global(
1307                    "PWD",
1308                    Value::String(initial_cwd.to_string_lossy().into_owned()),
1309                );
1310                // `$OLDPWD` is DROPPED rather than seeded. There is no previous
1311                // directory yet, and an inherited one describes the invoking
1312                // shell's history, not this session's — `cd -` already refuses
1313                // with "OLDPWD not set", and the variable must not contradict
1314                // it by naming a directory `cd -` will not go to.
1315                scope.remove("OLDPWD");
1316                scope
1317            }),
1318            initial_vars,
1319            tools,
1320            user_tools: RwLock::new(HashMap::new()),
1321            vfs,
1322            jobs,
1323            runner,
1324            exec_ctx: RwLock::new(exec_ctx),
1325            skip_validation,
1326            interactive,
1327            allow_external_commands,
1328            vfs_budget,
1329            request_timeout,
1330            kill_grace,
1331            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1332            cancel_token: std::sync::Mutex::new(tokio_util::sync::CancellationToken::new()),
1333            interrupt: std::sync::Mutex::new(None),
1334            #[cfg(all(unix, feature = "subprocess"))]
1335            terminal_state: None,
1336            self_weak: std::sync::OnceLock::new(),
1337            execute_lock: tokio::sync::Mutex::new(()),
1338            recursion_depth: AtomicUsize::new(0),
1339            bg_job_id: None,
1340            // Overlay handle is set by Kernel::new after assemble returns;
1341            // assemble itself doesn't know the handle (it's constructed in setup_vfs).
1342            // with_backend always has None (overlay=true is rejected above).
1343            #[cfg(all(feature = "localfs", feature = "overlay"))]
1344            overlay_handle: None,
1345        })
1346    }
1347
1348    /// Plan every statement of `source` without executing anything —
1349    /// [`plan_program`](crate::ast::plan::plan_program) as a method, so an
1350    /// embedder holding a kernel can pair the plans with `get_var` lookups
1351    /// against this kernel's live state.
1352    ///
1353    /// # Errors
1354    ///
1355    /// Returns the parse errors when `source` does not parse.
1356    pub fn plan_program(
1357        &self,
1358        source: &str,
1359    ) -> Result<Vec<crate::ast::plan::PlannedStatement>, Vec<crate::parser::ParseError>> {
1360        crate::ast::plan::plan_program(source)
1361    }
1362
1363    /// Expand one heredoc body against a scope the caller supplies —
1364    /// [`expand_fragment`](crate::fragment::expand_fragment) as a method.
1365    ///
1366    /// The scope is the caller's, not this kernel's: pair it with `get_var`
1367    /// when the session's values are the ones to judge against, and supply
1368    /// different values when they are not. Nothing executes, and a `$(…)` in
1369    /// the body comes back as a [`Hole`](kaish_types::plan::Hole) rather than
1370    /// running here.
1371    ///
1372    /// # Errors
1373    ///
1374    /// Returns a [`FragmentError`](crate::fragment::FragmentError) when the
1375    /// source does not parse, the address names no heredoc, or the body reads
1376    /// something the supplied scope does not carry.
1377    pub fn expand_fragment(
1378        &self,
1379        source: &str,
1380        addr: kaish_types::plan::FragmentAddr,
1381        scope: &[(String, Value)],
1382    ) -> Result<kaish_types::plan::Expansion, crate::fragment::FragmentError> {
1383        crate::fragment::expand_fragment(source, addr, scope)
1384    }
1385
1386    /// Get the kernel name.
1387    pub fn name(&self) -> &str {
1388        &self.name
1389    }
1390
1391    /// Wrap this Kernel in an Arc and initialize its self-reference.
1392    ///
1393    /// This enables the Kernel to hand out `Arc<dyn CommandDispatcher>` references
1394    /// to child contexts, allowing builtins like `timeout` to dispatch inner
1395    /// commands through the full resolution chain (user tools → builtins →
1396    /// .kai scripts → external commands).
1397    pub fn into_arc(self) -> Arc<Self> {
1398        let arc = Arc::new(self);
1399        let _ = arc.self_weak.set(Arc::downgrade(&arc));
1400        arc
1401    }
1402
1403    /// Fork a subsidiary kernel for concurrent execution.
1404    ///
1405    /// The fork is a fully-functional `Kernel` that:
1406    /// - **Snapshots** per-session state from the parent: scope (COW — cheap),
1407    ///   user-defined tools, cwd, aliases, ignore config, etc. Mutations on
1408    ///   the fork do NOT propagate back to the parent — matching bash
1409    ///   subshell / background-job semantics.
1410    /// - **Shares** read-mostly resources with the parent via `Arc`: the tool
1411    ///   registry, the VFS router, and the job manager. A job registered by
1412    ///   the fork is visible to the parent's `jobs` builtin, and the fork
1413    ///   sees the same VFS mounts.
1414    /// - **Owns** its own `stderr_receiver`, `cancel_token`, and
1415    ///   `execute_lock`. It is never the TTY owner, so `interactive` is
1416    ///   `false` and `terminal_state` is `None`.
1417    ///
1418    /// The returned Arc has its `self_weak` populated (via `into_arc`), so
1419    /// nested dispatch through `ctx.dispatcher` (e.g. the `timeout` builtin)
1420    /// routes through the fork itself, not the parent — which is essential
1421    /// for concurrency safety.
1422    ///
1423    /// Use this for **detached** background concurrency where the fork should
1424    /// survive parent cancellation: the `&` background-job operator and any
1425    /// other "fire and forget" worker. The fork gets a fresh, independent
1426    /// cancellation token.
1427    ///
1428    /// For foreground concurrency (scatter workers, concurrent pipeline
1429    /// stages, `$(...)` cmdsubs) where parent timeout/cancel must cascade
1430    /// into the fork's external children, use [`Self::fork_attached`].
1431    pub async fn fork(&self) -> Arc<Self> {
1432        self.fork_inner(tokio_util::sync::CancellationToken::new(), self.bg_job_id)
1433            .await
1434    }
1435
1436    /// Fork attached to the parent's cancellation.
1437    ///
1438    /// Same as [`Self::fork`] but the fork's `cancel_token` is a child of
1439    /// the parent's. When the parent cancels (request timeout, embedder
1440    /// `Kernel::cancel`, etc.), the fork's token also cancels, which in
1441    /// turn kills any external children spawned in the fork via the
1442    /// `wait_or_kill` / SIGTERM-grace-SIGKILL path.
1443    pub async fn fork_attached(&self) -> Arc<Self> {
1444        let child_token = {
1445            #[allow(clippy::expect_used)]
1446            let parent = self.cancel_token.lock().expect("cancel_token poisoned");
1447            parent.child_token()
1448        };
1449        self.fork_inner(child_token, self.bg_job_id).await
1450    }
1451
1452    /// Fork for a background job, stamping the job id so external commands
1453    /// spawned anywhere beneath it record their process groups on that job
1454    /// (for `kill -<sig> %N`). The caller owns `cancel` so it can also drive
1455    /// `JobManager::cancel`.
1456    pub async fn fork_for_background(
1457        &self,
1458        cancel: tokio_util::sync::CancellationToken,
1459        job_id: crate::scheduler::JobId,
1460    ) -> Arc<Self> {
1461        self.fork_inner(cancel, Some(job_id)).await
1462    }
1463
1464    /// Shared fork implementation. Caller decides the cancellation token and
1465    /// which background job (if any) this fork runs on behalf of.
1466    async fn fork_inner(
1467        &self,
1468        cancel: tokio_util::sync::CancellationToken,
1469        bg_job_id: Option<crate::scheduler::JobId>,
1470    ) -> Arc<Self> {
1471        let scope_snapshot = self.scope.read().await.clone();
1472        let user_tools_snapshot = self.user_tools.read().await.clone();
1473
1474        // Snapshot exec_ctx by cloning the cloneable fields, then override
1475        // the ones that should not carry over (stderr channel, dispatcher,
1476        // interactive flag, terminal state, cancel — set from `cancel` arg).
1477        let mut fork_ctx = {
1478            let parent_ctx = self.exec_ctx.read().await;
1479            parent_ctx.child_for_pipeline()
1480        };
1481        let (stderr_writer, stderr_receiver) = stderr_stream();
1482        fork_ctx.stderr = Some(stderr_writer);
1483        // Clear dispatcher; dispatch_command will repopulate it to point at
1484        // the fork on the first dispatch call.
1485        fork_ctx.dispatcher = None;
1486        fork_ctx.interactive = false;
1487        fork_ctx.cancel = cancel.clone();
1488        #[cfg(all(unix, feature = "subprocess"))]
1489        {
1490            fork_ctx.terminal_state = None;
1491        }
1492
1493        let fork = Self {
1494            name: format!("{}:fork", self.name),
1495            scope: RwLock::new(scope_snapshot),
1496            initial_vars: self.initial_vars.clone(),
1497            tools: Arc::clone(&self.tools),
1498            user_tools: RwLock::new(user_tools_snapshot),
1499            vfs: Arc::clone(&self.vfs),
1500            jobs: Arc::clone(&self.jobs),
1501            runner: self.runner.clone(),
1502            exec_ctx: RwLock::new(fork_ctx),
1503            skip_validation: self.skip_validation,
1504            // Forks are never the TTY owner — they run in the background.
1505            interactive: false,
1506            allow_external_commands: self.allow_external_commands,
1507            // Arc-clone the budget so the fork draws from the same pool as the
1508            // parent — background jobs and scatter workers count against the same
1509            // cap as foreground writes.
1510            vfs_budget: self.vfs_budget.clone(),
1511            request_timeout: self.request_timeout,
1512            kill_grace: self.kill_grace,
1513            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1514            cancel_token: std::sync::Mutex::new(cancel),
1515            interrupt: std::sync::Mutex::new(None),
1516            #[cfg(all(unix, feature = "subprocess"))]
1517            terminal_state: None,
1518            self_weak: std::sync::OnceLock::new(),
1519            execute_lock: tokio::sync::Mutex::new(()),
1520            // A fork runs on a fresh stack (spawned task) — its recursion
1521            // budget is independent of the parent's current depth (GH #46).
1522            recursion_depth: AtomicUsize::new(0),
1523            // A fork surfaces its own holds; the parent's slot stays put.
1524            bg_job_id,
1525            // Arc-clone the overlay handle so forks (background jobs, scatter
1526            // workers, pipeline stages) can reach the same overlay transaction
1527            // via `kaish-vfs status/diff/commit/reset`.
1528            #[cfg(all(feature = "localfs", feature = "overlay"))]
1529            overlay_handle: self.overlay_handle.clone(),
1530        };
1531
1532        fork.into_arc()
1533    }
1534
1535    /// Get an `Arc<dyn CommandDispatcher>` to this Kernel, if wrapped via `into_arc()`.
1536    ///
1537    /// Returns `None` if the Kernel was not wrapped, or if all strong references
1538    /// have been dropped (the `Weak` can no longer upgrade).
1539    pub fn dispatcher(&self) -> Option<Arc<dyn CommandDispatcher>> {
1540        self.self_weak
1541            .get()
1542            .and_then(|weak| weak.upgrade())
1543            .map(|arc| arc as Arc<dyn CommandDispatcher>)
1544    }
1545
1546    /// Initialize terminal state for interactive job control.
1547    ///
1548    /// Call this after kernel creation when running as an interactive REPL
1549    /// and stdin is a TTY. Sets up process groups and signal handling.
1550    #[cfg(all(unix, feature = "subprocess"))]
1551    pub fn init_terminal(&mut self) {
1552        if !self.interactive {
1553            return;
1554        }
1555        match crate::terminal::TerminalState::init() {
1556            Ok(state) => {
1557                let state = Arc::new(state);
1558                self.terminal_state = Some(state.clone());
1559                // Set on exec_ctx so builtins (fg, bg, kill) can access it
1560                self.exec_ctx.get_mut().terminal_state = Some(state);
1561                tracing::debug!("terminal job control initialized");
1562            }
1563            Err(e) => {
1564                tracing::warn!("failed to initialize terminal job control: {}", e);
1565            }
1566        }
1567    }
1568
1569    /// Replace or remove the trash backend used by `rm` and `kaish-trash`.
1570    ///
1571    /// The kernel installs the OS trash (`SystemTrash`) automatically when
1572    /// built with the `os-integration` feature. Embedders and tests can swap
1573    /// in a custom [`crate::trash::TrashBackend`], or pass `None` to remove
1574    /// it — with trash enabled but no backend present, `rm` fails loud
1575    /// rather than falling through to permanent delete.
1576    pub fn set_trash_backend(&mut self, backend: Option<Arc<dyn crate::trash::TrashBackend>>) {
1577        self.exec_ctx.get_mut().trash_backend = backend;
1578    }
1579
1580    /// Cancel the current execution.
1581    ///
1582    /// This cancels the current cancellation token, causing any execution
1583    /// loop to exit at the next checkpoint with exit code 130 (SIGINT).
1584    /// A fresh token is installed for the next `execute()` call.
1585    pub fn cancel(&self) {
1586        #[allow(clippy::expect_used)]
1587        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1588        token.cancel();
1589    }
1590
1591    /// Check if the current execution has been cancelled.
1592    ///
1593    /// Also the polling point for `ExecuteOptions::interrupt`: when the
1594    /// embedder's check reports true, the internal token fires here, so every
1595    /// call site of this method is an interrupt checkpoint for free.
1596    pub fn is_cancelled(&self) -> bool {
1597        let interrupted = {
1598            #[allow(clippy::expect_used)]
1599            let check = self.interrupt.lock().expect("interrupt poisoned");
1600            check.as_ref().is_some_and(|f| f())
1601        };
1602        if interrupted {
1603            self.cancel();
1604        }
1605        #[allow(clippy::expect_used)]
1606        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1607        token.is_cancelled()
1608    }
1609
1610    /// Reset the cancellation token (called at the start of each execute).
1611    fn reset_cancel(&self) -> tokio_util::sync::CancellationToken {
1612        #[allow(clippy::expect_used)]
1613        let mut token = self.cancel_token.lock().expect("cancel_token poisoned");
1614        if token.is_cancelled() {
1615            *token = tokio_util::sync::CancellationToken::new();
1616        }
1617        token.clone()
1618    }
1619
1620    /// Acquire the per-Kernel execute lock, warning on contention.
1621    ///
1622    /// Tokio's Mutex is fair (FIFO) so callers queue in arrival order. When
1623    /// the lock is already held, emit a warning so the silent serialization
1624    /// is observable in logs — if you need real parallelism, fork the kernel.
1625    async fn acquire_execute_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
1626        match self.execute_lock.try_lock() {
1627            Ok(guard) => guard,
1628            Err(_) => {
1629                tracing::warn!(
1630                    target: "kaish::kernel::concurrency",
1631                    kernel = %self.name,
1632                    "execute() contended — serializing concurrent caller; \
1633                     use Kernel::fork() for parallelism instead of sharing"
1634                );
1635                self.execute_lock.lock().await
1636            }
1637        }
1638    }
1639
1640    /// Execute kaish source code with default options.
1641    ///
1642    /// Equivalent to `execute_with_options(input, ExecuteOptions::default())`.
1643    /// Returns the result of the last statement executed.
1644    ///
1645    /// # Errors
1646    ///
1647    /// Returns [`KernelError`] when the program was rejected before running
1648    /// (a lex/parse failure or a validator rejection) or faulted while
1649    /// running. See [`KernelError::is_rejected`] to route on that
1650    /// distinction. A nonzero exit from the *script itself* — a failed
1651    /// command, `set -e` — is not an `Err`; it comes back as `Ok` with the
1652    /// exit code folded into the returned [`ExecResult`].
1653    pub async fn execute(&self, input: &str) -> Result<ExecResult, KernelError> {
1654        self.run_inner(input, ExecuteOptions::default(), None, None)
1655            .await
1656            .map_err(classify_execute_error)
1657    }
1658
1659    /// Argv-native peer of [`Self::execute`] — run one command whose arguments
1660    /// are **already tokenized**.
1661    ///
1662    /// `execute(&str)` is string-native: it lexes and parses its input. A caller
1663    /// that already holds OS/structured argv (a busybox-style multicall binary, a
1664    /// structured embedder like kaijutsu) would otherwise have to re-quote argv
1665    /// into a string just to have the lexer split it apart again — a round-trip
1666    /// that is lossy for typed values, since `to_argv()` stringifies
1667    /// [`Value::Bytes`]/[`Value::Json`]. `execute_argv` skips it.
1668    ///
1669    /// **Tokens are literal.** No glob expansion, no `$VAR` interpolation, no
1670    /// command substitution, no word splitting — the "single-quoted word"
1671    /// semantics taken to its end. `execute_argv("echo", &[Value::String("*.txt"
1672    /// .into())])` emits `*.txt`; it does not glob. (One shared-binder expansion
1673    /// does still apply, for consistency with the string door: a leading `~` is
1674    /// expanded against the session `HOME` — kaish expands `~` uniformly, so the
1675    /// two doors agree. Pass a pre-resolved path if you need it byte-literal.) A
1676    /// non-string `Value`
1677    /// (`Bytes`/`Json`/`Int`) lands directly in `ToolArgs.positional`, so typed
1678    /// data survives without a `to_argv()` round-trip. (Caveat: the two-layer
1679    /// clap arg model means a builtin that re-parses its own `to_argv()` still
1680    /// sees a stringified value; the typed-passthrough win fully lands only for
1681    /// builtins that read `args.positional` directly — the documented pattern.)
1682    ///
1683    /// This is a *peer*, not a subset: a command string can carry pipelines,
1684    /// `&&`/`||`, control flow and `$()` that have no argv encoding, so the two
1685    /// doors converge **late** (at the shared dispatch chain) rather than one
1686    /// wrapping the other. From argv classification onward `execute_argv` reuses
1687    /// the exact path a `Stmt::Command` takes — command resolution (aliases, user
1688    /// tools, `.kai` scripts, externals, backend tools), arg binding, and the
1689    /// `--json` transform — so an `ls --json` still applies output formatting. The kernel's
1690    /// pre-execution *syntax* validator does not run: argv has no shell syntax to
1691    /// validate (a tool's own `validate()`/clap parse still runs at dispatch).
1692    ///
1693    /// Concurrent callers serialize on the same execute lock as [`Self::execute`],
1694    /// and the kernel's configured `request_timeout` applies (a hung builtin or
1695    /// external is interrupted at the deadline with exit code 124, the same as the
1696    /// string door). There is no per-call options surface yet — a future
1697    /// `execute_argv_with_options` would carry per-call timeout/cancel/vars/cwd.
1698    ///
1699    /// # Errors
1700    ///
1701    /// Returns [`KernelError`], always [`KernelError::Execution`] — argv has
1702    /// no shell syntax to reject, so `execute_argv` never returns
1703    /// [`KernelError::Parse`] or [`KernelError::Validation`] (a tool's own
1704    /// `validate()`/clap parse at dispatch still surfaces here, as an
1705    /// execution failure).
1706    #[tracing::instrument(level = "info", skip(self, argv), fields(cmd = name, argc = argv.len()))]
1707    pub async fn execute_argv(&self, name: &str, argv: &[Value]) -> Result<ExecResult, KernelError> {
1708        let _guard = self.acquire_execute_lock().await;
1709        self.execute_argv_locked(name, argv).await.map_err(classify_execute_error)
1710    }
1711
1712    /// [`Self::execute_argv`]'s body, with the execute lock assumed **held**.
1713    async fn execute_argv_locked(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1714        // Fresh cancel surface for this call: `execute_pipeline` reads
1715        // `self.cancel_token`, so a stale cancelled token from a prior call must be
1716        // replaced first. The returned clone is the token the watchdog cancels on
1717        // an elapsed deadline (it shares state with what `execute_pipeline` reads),
1718        // cascading SIGTERM/SIGKILL to any external child.
1719        let cancel = self.reset_cancel();
1720
1721        // Honor the kernel-configured request timeout for parity with `execute`.
1722        let timeout = self.request_timeout;
1723        if timeout == Some(Duration::ZERO) {
1724            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1725        }
1726
1727        let command = crate::ast::Command {
1728            name: name.to_string(),
1729            args: argv_to_args(argv),
1730            redirects: Vec::new(),
1731        };
1732
1733        let pipeline = crate::ast::Pipeline {
1734            stages: vec![crate::ast::PipelineStage::Command(command)],
1735            background: false,
1736        };
1737        let work = async {
1738            let result = self.execute_pipeline(&pipeline).await?;
1739            // A gate raised while evaluating inside the dispatched tool — a
1740            // user tool body's `$(…)` — surfaces as this call's own held
1741            // result, and must not strand in the slot for the next serialized
1742            // call to mis-take.
1743            Ok(result)
1744        };
1745        let result = self.run_under_watchdog(timeout, &cancel, work).await?;
1746        self.update_last_result(&result).await;
1747        Ok(result)
1748    }
1749
1750    /// Run `work` under the movable-deadline watchdog for `timeout`, shared by the
1751    /// string door ([`Self::execute_with_options`]) and the argv door
1752    /// ([`Self::execute_argv`]).
1753    ///
1754    /// Mirrors the watchdog into `exec_ctx` (so a builtin can suspend the script
1755    /// clock via `ctx.patient`), and when a timeout is set, spawns the watchdog
1756    /// racing `cancel` — on an elapsed deadline `cancel` fires (cascading
1757    /// SIGTERM/SIGKILL to external children via `wait_or_kill`) and the result's
1758    /// code becomes 124. With `timeout == None`, runs `work` directly. Clears the
1759    /// watchdog handle from `exec_ctx` on the way out (a patient hold against a
1760    /// stale handle would silently suspend nothing). Callers must short-circuit a
1761    /// `Some(Duration::ZERO)` timeout (return 124 without spawning) before calling.
1762    async fn run_under_watchdog<F>(
1763        &self,
1764        timeout: Option<Duration>,
1765        cancel: &tokio_util::sync::CancellationToken,
1766        work: F,
1767    ) -> Result<ExecResult>
1768    where
1769        F: std::future::Future<Output = Result<ExecResult>>,
1770    {
1771        // Assigned unconditionally (clearing any stale handle); None without a timeout.
1772        let watchdog = timeout.map(|d| Arc::new(crate::watchdog::Watchdog::new(d)));
1773        {
1774            let mut ec = self.exec_ctx.write().await;
1775            ec.watchdog = watchdog.clone();
1776        }
1777
1778        let result = if let Some(d) = timeout {
1779            #[allow(clippy::expect_used)]
1780            let watchdog = watchdog.clone().expect("watchdog constructed when timeout is set");
1781            let elapsed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1782            let timer = tokio::spawn(watchdog.run(elapsed.clone(), cancel.clone()));
1783            let r = work.await;
1784            timer.abort();
1785            match r {
1786                Ok(mut res) => {
1787                    if elapsed.load(std::sync::atomic::Ordering::SeqCst) {
1788                        res.code = 124;
1789                        if res.err.is_empty() {
1790                            res.err =
1791                                ExecResult::terminate_diagnostic(format!("timeout: timed out after {:?}", d));
1792                        }
1793                    }
1794                    Ok(res)
1795                }
1796                Err(e) => Err(e),
1797            }
1798        } else {
1799            work.await
1800        };
1801
1802        // The timer task is gone (fired or aborted); drop the stale handle.
1803        {
1804            let mut ec = self.exec_ctx.write().await;
1805            ec.watchdog = None;
1806        }
1807        result
1808    }
1809
1810    /// Execute with per-call options. The primary entry point for embedders
1811    /// that don't need per-statement output streaming.
1812    ///
1813    /// `opts` carries timeout, transient vars overlay, optional cwd override,
1814    /// and optional embedder-owned cancellation token. See [`ExecuteOptions`]
1815    /// for semantics. For streaming, use [`Self::execute_with_options_streaming`].
1816    ///
1817    /// **Cancellation:** if `opts.cancel_token` is `Some`, it is *raced*
1818    /// against the kernel's internal token. Either firing cancels and kills
1819    /// external children. The embedder's token is read-only — kernel
1820    /// timeouts do NOT propagate into it. Distinguish via the returned
1821    /// `code`: 124 = timeout, 130 = cancellation.
1822    ///
1823    /// **Timeout:** `opts.timeout` overrides `KernelConfig::request_timeout`.
1824    /// `Some(Duration::ZERO)` returns 124 immediately without spawning.
1825    ///
1826    /// Concurrent callers on the same Kernel serialize on the kernel-wide
1827    /// execute lock. For true parallelism, call [`Kernel::fork`] (detached)
1828    /// or [`Kernel::fork_attached`] (cancellation cascades from this kernel).
1829    ///
1830    /// # Errors
1831    ///
1832    /// Returns [`KernelError`] when the program was rejected before running
1833    /// or faulted while running — see [`KernelError::is_rejected`].
1834    pub async fn execute_with_options(
1835        &self,
1836        input: &str,
1837        opts: ExecuteOptions,
1838    ) -> Result<ExecResult, KernelError> {
1839        self.run_inner(input, opts, None, None).await.map_err(classify_execute_error)
1840    }
1841
1842    /// Same as [`Self::execute_with_options`] but with a per-statement output
1843    /// callback. The callback fires after each top-level statement so the
1844    /// embedder (REPL, MCP streaming) can flush output incrementally.
1845    ///
1846    /// # Errors
1847    ///
1848    /// See [`Self::execute_with_options`].
1849    pub async fn execute_with_options_streaming(
1850        &self,
1851        input: &str,
1852        opts: ExecuteOptions,
1853        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1854    ) -> Result<ExecResult, KernelError> {
1855        self.run_inner(input, opts, None, Some(on_output)).await.map_err(classify_execute_error)
1856    }
1857
1858    /// Execute with a **lazy** standard input fed as a [`PipeReader`](crate::PipeReader).
1859    ///
1860    /// Unlike [`ExecuteOptions::with_stdin`] (a pre-read buffer), this never
1861    /// forces the input to be drained before execution: the reader seeds the
1862    /// first top-level command's `pipe_stdin`, and a command that does not read
1863    /// stdin (`echo`) returns without touching it. This is the seam a
1864    /// non-interactive frontend uses to forward an *open* process stdin without
1865    /// hanging on a pipe that never sends EOF (`sleep 10 | kaish -c 'echo hi'`).
1866    ///
1867    /// Embedders that already hold a complete buffer (text or binary) should
1868    /// prefer the simpler [`ExecuteOptions::with_stdin`] path instead.
1869    ///
1870    /// # Errors
1871    ///
1872    /// See [`Self::execute_with_options`].
1873    pub async fn execute_with_pipe_stdin(
1874        &self,
1875        input: &str,
1876        opts: ExecuteOptions,
1877        pipe_stdin: crate::scheduler::PipeReader,
1878    ) -> Result<ExecResult, KernelError> {
1879        self.run_inner(input, opts, Some(pipe_stdin), None).await.map_err(classify_execute_error)
1880    }
1881
1882    /// Streaming counterpart to [`Self::execute_with_pipe_stdin`] — the REPL
1883    /// `-c`/script frontend uses this to print output incrementally while
1884    /// feeding a lazy process-stdin pipe.
1885    ///
1886    /// # Errors
1887    ///
1888    /// See [`Self::execute_with_options`].
1889    pub async fn execute_with_pipe_stdin_streaming(
1890        &self,
1891        input: &str,
1892        opts: ExecuteOptions,
1893        pipe_stdin: crate::scheduler::PipeReader,
1894        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1895    ) -> Result<ExecResult, KernelError> {
1896        self.run_inner(input, opts, Some(pipe_stdin), Some(on_output))
1897            .await
1898            .map_err(classify_execute_error)
1899    }
1900
1901    /// Execute kaish source code with a transient overlay of exported variables.
1902    ///
1903    /// Deprecated thin wrapper over [`Self::execute_with_options`]. New code
1904    /// should use that method directly:
1905    /// `execute_with_options(input, ExecuteOptions::new().with_vars(vars))`.
1906    #[deprecated(note = "use Kernel::execute_with_options with ExecuteOptions::with_vars")]
1907    pub async fn execute_with_vars(
1908        &self,
1909        input: &str,
1910        vars: HashMap<String, Value>,
1911    ) -> Result<ExecResult, KernelError> {
1912        self.run_inner(input, ExecuteOptions::new().with_vars(vars), None, None)
1913            .await
1914            .map_err(classify_execute_error)
1915    }
1916
1917    /// Execute kaish source code with a per-statement callback.
1918    ///
1919    /// Deprecated thin wrapper. New code should use
1920    /// [`Self::execute_with_options_streaming`].
1921    #[deprecated(note = "use Kernel::execute_with_options_streaming")]
1922    pub async fn execute_streaming(
1923        &self,
1924        input: &str,
1925        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1926    ) -> Result<ExecResult, KernelError> {
1927        self.run_inner(input, ExecuteOptions::default(), None, Some(on_output))
1928            .await
1929            .map_err(classify_execute_error)
1930    }
1931
1932    /// Link embedder trace context, then run [`Self::execute_with_options_inner`].
1933    ///
1934    /// The `#[instrument]` execution span resolves its parent from the *current*
1935    /// OpenTelemetry context (see `tracing-opentelemetry`'s `parent_context`),
1936    /// captured when the span is first entered — not when the future is
1937    /// constructed. So a thread-local `attach()` scoped to construction is too
1938    /// early to be seen (the integration test confirms this). `with_context`
1939    /// re-attaches the embedder's context on *every* poll of the inner future,
1940    /// so the context is current at first-enter and survives runtime thread
1941    /// hops. With no embedder trace context, the future runs unwrapped.
1942    async fn run_inner(
1943        &self,
1944        input: &str,
1945        opts: ExecuteOptions,
1946        pipe_stdin: Option<crate::scheduler::PipeReader>,
1947        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1948    ) -> Result<ExecResult> {
1949        use opentelemetry::context::FutureExt;
1950
1951        // Capture the embedder's baggage before `opts` is consumed so it can be
1952        // echoed back onto the result on egress (see `merge_egress_baggage`).
1953        let embedder_baggage = opts.baggage.clone();
1954
1955        let result = match crate::telemetry::extract_parent(&opts) {
1956            Some(parent) => self
1957                .execute_with_options_inner(input, opts, pipe_stdin, on_output)
1958                .with_context(parent)
1959                .await,
1960            None => self.execute_with_options_inner(input, opts, pipe_stdin, on_output).await,
1961        };
1962
1963        result.map(|mut r| {
1964            crate::telemetry::merge_egress_baggage(&mut r, embedder_baggage);
1965            r
1966        })
1967    }
1968
1969    /// Shared body for `execute`, `execute_with_options(_streaming)`, and
1970    /// the deprecated wrappers. Owns the per-call cancel token, vars overlay,
1971    /// cwd override, and timeout race.
1972    #[tracing::instrument(level = "info", skip(self, opts, pipe_stdin, on_output), fields(input_len = input.len()))]
1973    async fn execute_with_options_inner(
1974        &self,
1975        input: &str,
1976        opts: ExecuteOptions,
1977        pipe_stdin: Option<crate::scheduler::PipeReader>,
1978        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1979    ) -> Result<ExecResult> {
1980        let _guard = self.acquire_execute_lock().await;
1981
1982        // Always reset to a fresh internal token; this is the kernel's own
1983        // cancel surface for embedders calling `Kernel::cancel()`. The
1984        // embedder-supplied `opts.cancel_token` is a *read-only input* — it
1985        // is NOT written into `self.cancel_token`, because doing so would
1986        // (a) leak the embedder's token past this call's lifetime,
1987        // (b) re-route a later `Kernel::cancel()` into the embedder's token,
1988        // (c) extend the token's lifetime via the kernel's strong clone.
1989        let internal = self.reset_cancel();
1990
1991        // Install the per-call polled interrupt for `is_cancelled()` to
1992        // consult. The guard clears it on every exit path — a stale check
1993        // must not outlive its call and fire into a later one.
1994        struct ClearInterrupt<'a>(&'a Kernel);
1995        impl Drop for ClearInterrupt<'_> {
1996            fn drop(&mut self) {
1997                if let Ok(mut slot) = self.0.interrupt.lock() {
1998                    *slot = None;
1999                }
2000            }
2001        }
2002        {
2003            #[allow(clippy::expect_used)]
2004            let mut slot = self.interrupt.lock().expect("interrupt poisoned");
2005            *slot = opts.interrupt.clone();
2006        }
2007        let _interrupt_guard = ClearInterrupt(self);
2008
2009        // Race the embedder token against the kernel's internal token via a
2010        // tracked watcher task. We hold the JoinHandle so we can abort the
2011        // task at function exit — otherwise it would wait forever for either
2012        // token to fire and leak per call.
2013        let (effective_cancel, watcher_handle): (
2014            tokio_util::sync::CancellationToken,
2015            Option<tokio::task::JoinHandle<()>>,
2016        ) = if let Some(ext) = opts.cancel_token {
2017            let combined = tokio_util::sync::CancellationToken::new();
2018            let combined_writer = combined.clone();
2019            let i = internal.clone();
2020            let handle = tokio::spawn(async move {
2021                tokio::select! {
2022                    _ = i.cancelled() => combined_writer.cancel(),
2023                    _ = ext.cancelled() => combined_writer.cancel(),
2024                }
2025            });
2026            (combined, Some(handle))
2027        } else {
2028            (internal, None)
2029        };
2030
2031        // Effective timeout: per-call wins over kernel-config default.
2032        let timeout = opts.timeout.or(self.request_timeout);
2033
2034        // ZERO timeout: return 124 immediately without spawning anything.
2035        if timeout == Some(Duration::ZERO) {
2036            if let Some(h) = watcher_handle {
2037                h.abort();
2038            }
2039            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
2040        }
2041
2042        // Apply per-call vars overlay (push frame + set_exported), wrapped in
2043        // an RAII guard so a panic inside `execute_streaming_inner` still
2044        // pops the frame and unexports the temporarily-exported names.
2045        struct VarsFrameGuard<'a> {
2046            kernel: &'a Kernel,
2047            newly_exported: Vec<String>,
2048        }
2049        impl Drop for VarsFrameGuard<'_> {
2050            fn drop(&mut self) {
2051                // Best-effort cleanup using try_write. The execute_lock held
2052                // throughout execute_with_options means there is no concurrent
2053                // foreground caller; forks have their own scope and won't
2054                // block this. blocking_write would deadlock the runtime when
2055                // called from a tokio worker thread, so we explicitly do NOT
2056                // fall back to it — if try_write fails (which we've never
2057                // seen in practice), log loudly and accept the leak rather
2058                // than deadlock the entire kernel.
2059                let Ok(mut scope) = self.kernel.scope.try_write() else {
2060                    tracing::error!(
2061                        "vars frame guard: scope lock unexpectedly busy; \
2062                         skipping pop_frame to avoid runtime deadlock — \
2063                         transient vars may leak"
2064                    );
2065                    return;
2066                };
2067                scope.pop_frame();
2068                for name in self.newly_exported.drain(..) {
2069                    scope.unexport(&name);
2070                }
2071            }
2072        }
2073
2074        // Per-call cwd override: save current cwd, set the new one, restore
2075        // on Drop so the kernel's persistent cwd doesn't leak between calls.
2076        // Same RAII pattern as VarsFrameGuard, same blocking_write trade-off.
2077        struct CwdGuard<'a> {
2078            kernel: &'a Kernel,
2079            saved: PathBuf,
2080        }
2081        impl Drop for CwdGuard<'_> {
2082            fn drop(&mut self) {
2083                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2084                    tracing::error!(
2085                        "cwd guard: exec_ctx lock unexpectedly busy; \
2086                         skipping cwd restore — kernel cwd may be wrong for next call"
2087                    );
2088                    return;
2089                };
2090                ec.cwd = std::mem::take(&mut self.saved);
2091            }
2092        }
2093        let _cwd_guard: Option<CwdGuard<'_>> = if let Some(new_cwd) = opts.cwd {
2094            let mut ec = self.exec_ctx.write().await;
2095            let saved = std::mem::replace(&mut ec.cwd, new_cwd);
2096            drop(ec);
2097            Some(CwdGuard { kernel: self, saved })
2098        } else {
2099            None
2100        };
2101
2102        // Per-call stdin: seed the persistent exec_ctx so the first top-level
2103        // command that reads stdin consumes it (it's `take()`n at dispatch).
2104        // Restore the prior value on Drop — normally `None`, so this also drops
2105        // any residual seed an stdin-less program never consumed, keeping it
2106        // from bleeding into the next call. Same RAII pattern as CwdGuard.
2107        struct StdinGuard<'a> {
2108            kernel: &'a Kernel,
2109            saved: Option<Vec<u8>>,
2110        }
2111        impl Drop for StdinGuard<'_> {
2112            fn drop(&mut self) {
2113                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2114                    tracing::error!(
2115                        "stdin guard: exec_ctx lock unexpectedly busy; \
2116                         skipping stdin restore — stale stdin may leak to next call"
2117                    );
2118                    return;
2119                };
2120                ec.stdin = self.saved.take();
2121            }
2122        }
2123        let _stdin_guard: Option<StdinGuard<'_>> = if let Some(stdin) = opts.stdin {
2124            let mut ec = self.exec_ctx.write().await;
2125            let saved = ec.stdin.replace(stdin);
2126            drop(ec);
2127            Some(StdinGuard { kernel: self, saved })
2128        } else {
2129            None
2130        };
2131
2132        // Per-call *lazy* stdin: a frontend-supplied `PipeReader` seeds the
2133        // persistent exec_ctx so the first stdin-reading command drains it (it's
2134        // `take()`n at pipeline build). The RAII guard restores the prior value
2135        // on Drop (normally `None`), so an unread reader doesn't bleed into the
2136        // next call. Mirrors `StdinGuard`; the reader is non-Clone, so it moves.
2137        struct PipeStdinGuard<'a> {
2138            kernel: &'a Kernel,
2139            saved: Option<crate::scheduler::PipeReader>,
2140        }
2141        impl Drop for PipeStdinGuard<'_> {
2142            fn drop(&mut self) {
2143                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2144                    tracing::error!(
2145                        "pipe stdin guard: exec_ctx lock unexpectedly busy; \
2146                         skipping restore — stale pipe stdin may leak to next call"
2147                    );
2148                    return;
2149                };
2150                ec.pipe_stdin = self.saved.take();
2151            }
2152        }
2153        let _pipe_stdin_guard: Option<PipeStdinGuard<'_>> = if let Some(reader) = pipe_stdin {
2154            let mut ec = self.exec_ctx.write().await;
2155            let saved = ec.pipe_stdin.replace(reader);
2156            drop(ec);
2157            Some(PipeStdinGuard { kernel: self, saved })
2158        } else {
2159            None
2160        };
2161
2162        let _vars_guard: Option<VarsFrameGuard<'_>> = if !opts.vars.is_empty() {
2163            let mut scope = self.scope.write().await;
2164            scope.push_frame();
2165            let mut newly = Vec::with_capacity(opts.vars.len());
2166            for (name, value) in opts.vars {
2167                if !scope.is_exported(&name) {
2168                    newly.push(name.clone());
2169                }
2170                scope.set_exported(name, value);
2171            }
2172            drop(scope);
2173            Some(VarsFrameGuard { kernel: self, newly_exported: newly })
2174        } else {
2175            None
2176        };
2177
2178        // Per-call errexit override (`ExecuteOptions::errexit`): save the
2179        // kernel's current errexit state, apply the override, restore the
2180        // saved value on Drop so it doesn't leak into the next call. `None`
2181        // leaves errexit exactly as the kernel already has it — no save, no
2182        // restore. Same RAII pattern as CwdGuard/StdinGuard.
2183        struct ErrexitGuard<'a> {
2184            kernel: &'a Kernel,
2185            saved: bool,
2186        }
2187        impl Drop for ErrexitGuard<'_> {
2188            fn drop(&mut self) {
2189                let Ok(mut scope) = self.kernel.scope.try_write() else {
2190                    tracing::error!(
2191                        "errexit guard: scope lock unexpectedly busy; \
2192                         skipping errexit restore — override may leak to next call"
2193                    );
2194                    return;
2195                };
2196                scope.set_error_exit(self.saved);
2197            }
2198        }
2199        let _errexit_guard: Option<ErrexitGuard<'_>> = if let Some(enabled) = opts.errexit {
2200            let mut scope = self.scope.write().await;
2201            // The RAW flag, not `error_exit_enabled()`: that one is false
2202            // while errexit is suppressed inside a `&&`/`||` left side, and
2203            // restoring from it would turn `set -e` off for good.
2204            let saved = scope.error_exit_flag();
2205            scope.set_error_exit(enabled);
2206            drop(scope);
2207            Some(ErrexitGuard { kernel: self, saved })
2208        } else {
2209            None
2210        };
2211
2212        // Sync the effective cancel into self.exec_ctx so try_execute_external
2213        // (which reads via self.cancel_token) sees cancellation. We also need
2214        // builtins to see it via ctx.cancel — handled in execute_command.
2215        // For simplicity here we mirror effective_cancel into self.cancel_token
2216        // for the duration of this call, then restore the internal token at
2217        // the end (so a later Kernel::cancel still hits our internal surface).
2218        {
2219            #[allow(clippy::expect_used)]
2220            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2221            *cur = effective_cancel.clone();
2222        }
2223
2224        // Run the script under the movable-deadline watchdog (shared with the
2225        // argv door). The watchdog task cancels `effective_cancel` on an elapsed
2226        // deadline; the cascade fires SIGTERM/SIGKILL on any external children via
2227        // the wait_or_kill discipline in try_execute_external. `Some(ZERO)` was
2228        // already handled by the early return above.
2229        let mut noop_cb: Box<dyn FnMut(&ExecResult) + Send> = Box::new(|_| {});
2230        let cb_ref: &mut (dyn FnMut(&ExecResult) + Send) = match on_output {
2231            Some(cb) => cb,
2232            None => &mut *noop_cb,
2233        };
2234
2235        let result = self
2236            .run_under_watchdog(timeout, &effective_cancel, self.execute_streaming_inner(input, cb_ref))
2237            .await;
2238
2239        // Restore self.cancel_token to a fresh, uncancelled token so the
2240        // embedder's view of `Kernel::cancel()` stays predictable on the
2241        // next call (it cancels the kernel's own token, not whatever was
2242        // left over from this call's combined token).
2243        {
2244            #[allow(clippy::expect_used)]
2245            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2246            *cur = tokio_util::sync::CancellationToken::new();
2247        }
2248
2249        // Tear down the embedder-token race watcher (if any). Leaving it
2250        // alive would idle forever waiting for tokens that may never fire.
2251        if let Some(h) = watcher_handle {
2252            h.abort();
2253        }
2254
2255        // VarsFrameGuard drops here on the success path and on early-return
2256        // paths above (error path included). Panic safety preserved.
2257        result
2258    }
2259
2260    /// The actual body of `execute_streaming`, run while holding the execute lock.
2261    ///
2262    /// Split out so internal kernel paths that are already under the lock can
2263    /// call this without deadlocking on re-entry. External callers must go
2264    /// through [`Self::execute_streaming`] so they acquire the lock.
2265    async fn execute_streaming_inner(
2266        &self,
2267        input: &str,
2268        on_output: &mut (dyn FnMut(&ExecResult) + Send),
2269    ) -> Result<ExecResult> {
2270        let program = parse(input).map_err(|errors| {
2271            let msg = errors
2272                .iter()
2273                .map(|e| e.format(input))
2274                .collect::<Vec<_>>()
2275                .join("\n");
2276            let message = format!("parse error:\n{}", msg);
2277            // Tagged so `classify_execute_error` can recover the structured
2278            // rejection at the public execute-surface boundary; every other
2279            // `?` in this function propagates a plain, untagged `anyhow::Error`.
2280            anyhow::Error::from(KernelError::Parse { errors, message })
2281        })?;
2282
2283        // AST display mode: show AST instead of executing
2284        {
2285            let scope = self.scope.read().await;
2286            if scope.show_ast() {
2287                let output = format!("{:#?}\n", program);
2288                return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
2289            }
2290        }
2291
2292        // Pre-execution validation. Most warnings stay trace-only (every
2293        // external command fires an `UndefinedCommand` warning), but a warning
2294        // whose code opts into agent surfacing is collected here and prepended
2295        // to the result's stderr at each return point below.
2296        let mut surfaced_warnings = String::new();
2297        if !self.skip_validation {
2298            // Catalog first: neither guard should ride the other's await, and
2299            // `validate()` is synchronous, so neither rides one after this.
2300            let catalog = { self.exec_ctx.read().await.tool_schemas.clone() };
2301            let user_tools = self.user_tools.read().await;
2302            let validator = Validator::new(&self.tools, &user_tools, &catalog);
2303            let issues = validator.validate(&program);
2304
2305            // Collect errors (warnings are logged but don't prevent execution)
2306            let errors: Vec<_> = issues
2307                .iter()
2308                .filter(|i| i.severity == Severity::Error)
2309                .collect();
2310
2311            if !errors.is_empty() {
2312                let error_msg = errors
2313                    .iter()
2314                    .map(|e| e.format(input))
2315                    .collect::<Vec<_>>()
2316                    .join("\n");
2317                let message = format!("validation failed:\n{}", error_msg);
2318                let issues: Vec<crate::validator::ValidationIssue> =
2319                    errors.into_iter().cloned().collect();
2320                // Tagged the same way as the parse rejection above.
2321                return Err(anyhow::Error::from(KernelError::Validation { issues, message }));
2322            }
2323
2324            // Log warnings via tracing (trace level to avoid noise); surface the
2325            // opted-in ones to the agent so the guidance is actually seen.
2326            for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
2327                tracing::trace!("validation: {}", warning.format(input));
2328                if warning.code.surfaces_to_agent() {
2329                    surfaced_warnings.push_str(&warning.format(input));
2330                    surfaced_warnings.push('\n');
2331                }
2332            }
2333        }
2334
2335        // Surface opted-in validation warnings to the streaming frontend once,
2336        // before any command output. The streaming consumer (`-c`, REPL) prints
2337        // per `on_output` and ignores the returned aggregate err; non-streaming
2338        // callers (`kernel.execute`) use a noop callback and read the aggregate
2339        // `result.err` (prepended at each return below). The two paths are
2340        // disjoint, so this prints the advisory exactly once on each.
2341        if !surfaced_warnings.is_empty() {
2342            let mut advisory = ExecResult::success("");
2343            advisory.err = surfaced_warnings.clone();
2344            on_output(&advisory);
2345        }
2346
2347        let mut result = ExecResult::success("");
2348
2349        // Reset cancellation token for this execution.
2350        let cancel = self.reset_cancel();
2351
2352        for stmt in program.statements.into_iter() {
2353            if matches!(stmt, Stmt::Empty) {
2354                continue;
2355            }
2356
2357            // Cancellation checkpoint
2358            if cancel.is_cancelled() {
2359                result.code = 130;
2360                return Ok(result);
2361            }
2362
2363            // The statement tap and gate (spec §C.6) — one of exactly two
2364            // sites. It runs before `execute_stmt_flow`, so a held statement
2365            // has run *nothing*: no substitution, no redirect opened, no
2366            let flow_result = self.execute_stmt_flow(&stmt).await;
2367            let flow = flow_result?;
2368
2369            // Drain any stderr written by pipeline stages during this statement.
2370            // This captures stderr from intermediate pipeline stages that would
2371            // otherwise be lost (only the last stage's result is returned).
2372            let drained_stderr = {
2373                let mut receiver = self.stderr_receiver.lock().await;
2374                receiver.drain_lossy()
2375            };
2376
2377            match flow {
2378                ControlFlow::Normal(mut r) => {
2379                    if !drained_stderr.is_empty() {
2380                        if !r.err.is_empty() && !r.err.ends_with('\n') {
2381                            r.err.push('\n');
2382                        }
2383                        // Prepend pipeline stderr before the last stage's stderr
2384                        let combined = format!("{}{}", drained_stderr, r.err);
2385                        r.err = combined;
2386                    }
2387                    on_output(&r);
2388                    // Carry the last statement's structured output for MCP TOON encoding.
2389                    // Must be done here (not in accumulate_result) because accumulate_result
2390                    // is also used in loops where per-iteration output would be wrong.
2391                    let last_output = r.output().cloned();
2392                    accumulate_result(&mut result, &r);
2393                    result.set_output(last_output);
2394                }
2395                ControlFlow::Exit { code, result: carried } => {
2396                    if !drained_stderr.is_empty() {
2397                        result.err.push_str(&drained_stderr);
2398                    }
2399                    // Output produced before the exit — e.g. by the loop the
2400                    // `exit` ran inside — arrives on the signal. Emit it like
2401                    // any other statement's, then let `code` decide the status.
2402                    on_output(&carried);
2403                    accumulate_result(&mut result, &carried);
2404                    result.code = code;
2405                    if !surfaced_warnings.is_empty() {
2406                        result.err = format!("{surfaced_warnings}{}", result.err);
2407                    }
2408                    return Ok(result);
2409                }
2410                ControlFlow::Return { mut value } => {
2411                    if !drained_stderr.is_empty() {
2412                        value.err = format!("{}{}", drained_stderr, value.err);
2413                    }
2414                    on_output(&value);
2415                    // A top-level `return` stops the script, like `exit` —
2416                    // it must not discard prior statements' accumulated
2417                    // output nor let execution continue past it.
2418                    accumulate_result(&mut result, &value);
2419                    if !surfaced_warnings.is_empty() {
2420                        result.err = format!("{surfaced_warnings}{}", result.err);
2421                    }
2422                    return Ok(result);
2423                }
2424                ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2425                    if !drained_stderr.is_empty() {
2426                        r.err = format!("{}{}", drained_stderr, r.err);
2427                    }
2428                    on_output(&r);
2429                    accumulate_result(&mut result, &r);
2430                }
2431            }
2432        }
2433
2434        if !surfaced_warnings.is_empty() {
2435            result.err = format!("{surfaced_warnings}{}", result.err);
2436        }
2437        Ok(result)
2438    }
2439
2440    /// Execute a single statement, returning control flow information.
2441    fn execute_stmt_flow<'a>(
2442        &'a self,
2443        stmt: &'a Stmt,
2444    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2445        // No per-statement span here: `execute_stmt_flow` is the largest future
2446        // on the recursion ring, and wrapping it in `Instrumented<Span>` carries
2447        // the span's state through every `.await` at every level, costing native
2448        // stack per level (GH #48). Coarser spans on the outer execute entries
2449        // remain. See item 3 of the #48 burndown.
2450        Box::pin(async move {
2451        match stmt {
2452            Stmt::Assignment(assign) => {
2453                // An assignment with no command name takes the exit status of
2454                // the last command substitution in its value, or 0 if there
2455                // was none (bash's rule, re-probed). Clear the note first so
2456                // a substitution from an earlier statement cannot leak in —
2457                // `false; x=5` must be 0, not stale.
2458                {
2459                    let mut scope = self.scope.write().await;
2460                    scope.clear_cmdsubst_code();
2461                }
2462                // Use async evaluator to support command substitution
2463                let value = self.eval_expr_async(&assign.value).await
2464                    .context("failed to evaluate assignment")?;
2465                let mut scope = self.scope.write().await;
2466                if assign.path.segments.len() == 1 {
2467                    // Plain `NAME=value` — no subscript, so `local` applies.
2468                    if assign.local {
2469                        // local: set in innermost (current function) frame
2470                        scope.set(assign.name(), value.clone());
2471                    } else {
2472                        // non-local: update existing or create in root frame
2473                        scope.set_global(assign.name(), value.clone());
2474                    }
2475                } else {
2476                    // Subscripted lvalue (`xs[0]=v`, `user[email]=v`, …): always
2477                    // mutates the existing root wherever it lives, so `local`
2478                    // has nothing to declare. See docs/LANGUAGE.md,
2479                    // "Assignment — bracket-path lvalues".
2480                    scope.walk_write(&assign.path, value.clone()).map_err(|e| match e {
2481                        PathError::UndefinedRoot(name) => anyhow::anyhow!(
2482                            "{name}: undefined — create it first, e.g. `{name}={{}}` or `{name}=[]`"
2483                        ),
2484                        PathError::Absence(msg) | PathError::Shape(msg) => anyhow::anyhow!(msg),
2485                    })?;
2486                }
2487                drop(scope);
2488
2489                // Assignments don't produce output (like sh), but they are a
2490                // command: they write `$?` and honor `set -e` (bash: `set -e;
2491                // x=$(false)` exits). The code is the last substitution's, or
2492                // 0 — this is what lets `x="$(cmd)" || x="FALLBACK"` fire.
2493                let subst_code = {
2494                    let mut scope = self.scope.write().await;
2495                    scope.take_cmdsubst_code()
2496                };
2497                let result = match subst_code {
2498                    None | Some(0) => ExecResult::success(""),
2499                    Some(code) => ExecResult::failure(code, ""),
2500                };
2501                self.update_last_result(&result).await;
2502                if !result.ok() {
2503                    let scope = self.scope.read().await;
2504                    if scope.error_exit_enabled() {
2505                        // `-e` aborts the statement list, but the reason the
2506                        // command died must survive with it — carry `result`
2507                        // (its `out`/`err`/`data`) into the Exit signal instead
2508                        // of `ControlFlow::exit_code`'s empty placeholder.
2509                        let code = result.code;
2510                        return Ok(ControlFlow::Exit { code, result });
2511                    }
2512                }
2513                Ok(ControlFlow::ok(result))
2514            }
2515            Stmt::Command(cmd) => {
2516                // Route single commands through execute_pipeline for a unified path.
2517                // This ensures all commands go through the dispatcher chain.
2518                let pipeline = crate::ast::Pipeline {
2519                    stages: vec![crate::ast::PipelineStage::Command(cmd.clone())],
2520                    background: false,
2521                };
2522                let result = Box::pin(self.execute_pipeline(&pipeline)).await?;
2523                self.update_last_result(&result).await;
2524
2525                // Check for error exit mode (set -e)
2526                if !result.ok() {
2527                    let scope = self.scope.read().await;
2528                    if scope.error_exit_enabled() {
2529                        // `-e` aborts the statement list, but the reason the
2530                        // command died must survive with it — carry `result`
2531                        // (its `out`/`err`/`data`) into the Exit signal instead
2532                        // of `ControlFlow::exit_code`'s empty placeholder.
2533                        let code = result.code;
2534                        return Ok(ControlFlow::Exit { code, result });
2535                    }
2536                }
2537
2538                Ok(ControlFlow::ok(result))
2539            }
2540            Stmt::Pipeline(pipeline) => {
2541                let result = Box::pin(self.execute_pipeline(pipeline)).await?;
2542                self.update_last_result(&result).await;
2543
2544                // Check for error exit mode (set -e)
2545                if !result.ok() {
2546                    let scope = self.scope.read().await;
2547                    if scope.error_exit_enabled() {
2548                        // `-e` aborts the statement list, but the reason the
2549                        // command died must survive with it — carry `result`
2550                        // (its `out`/`err`/`data`) into the Exit signal instead
2551                        // of `ControlFlow::exit_code`'s empty placeholder.
2552                        let code = result.code;
2553                        return Ok(ControlFlow::Exit { code, result });
2554                    }
2555                }
2556
2557                Ok(ControlFlow::ok(result))
2558            }
2559            Stmt::If(if_stmt) => {
2560                // The statement's result is built BEFORE the condition runs,
2561                // because a condition's own stdout is the first thing in it —
2562                // see `eval_condition_async`. (An `elif` is a nested `Stmt::If`
2563                // in `else_branch`, so it takes this same path.)
2564                let mut result = ExecResult::success("");
2565                let cond_value = self
2566                    .eval_condition_async(&if_stmt.condition, &mut result)
2567                    .await?;
2568
2569                let branch = if is_truthy(&cond_value) {
2570                    &if_stmt.then_branch
2571                } else {
2572                    if_stmt.else_branch.as_deref().unwrap_or(&[])
2573                };
2574
2575                for stmt in branch {
2576                    let flow = self.execute_stmt_flow(stmt).await?;
2577                    match flow {
2578                        ControlFlow::Normal(r) => {
2579                            // Drain BEFORE accumulating, as the `while` arm
2580                            // does: the stream holds the condition's stderr,
2581                            // which was written first and must read first.
2582                            // Appending `r.err` ahead of the drain put the
2583                            // branch's diagnostic before the condition's.
2584                            self.drain_stderr_into(&mut result).await;
2585                            accumulate_result(&mut result, &r);
2586                        }
2587                        mut other => {
2588                            self.drain_stderr_into(&mut result).await;
2589                            fold_block_output_into_flow(std::mem::take(&mut result), &mut other);
2590                            return Ok(other);
2591                        }
2592                    }
2593                }
2594                // A compound statement is a command: it writes `$?` whether or
2595                // not a body statement ran. Without this, `if false; then …; fi`
2596                // leaves the PREVIOUS statement's status visible to `$?` — a
2597                // failure that did not happen. Idempotent when a body did run:
2598                // the body's own arm already wrote the same code.
2599                self.update_last_result(&result).await;
2600                Ok(ControlFlow::ok(result))
2601            }
2602            Stmt::For(for_loop) => {
2603                // Evaluate all items and collect values for iteration
2604                // Use async evaluator to support command substitution like $(seq 1 5)
2605                let mut items: Vec<Value> = Vec::new();
2606                for item_expr in &for_loop.items {
2607                    // Glob expansion in for-loop items: `for f in *.txt`
2608                    if let Expr::GlobPattern(pattern) = item_expr {
2609                        let glob_enabled = {
2610                            let scope = self.scope.read().await;
2611                            scope.glob_enabled()
2612                        };
2613                        if glob_enabled {
2614                            let (paths, cwd) = {
2615                                let ctx = self.exec_ctx.read().await;
2616                                let paths = ctx.expand_glob(pattern).await
2617                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2618                                let cwd = ctx.resolve_path(".");
2619                                (paths, cwd)
2620                            };
2621                            if paths.is_empty() {
2622                                return Err(anyhow::anyhow!("no matches: {}", pattern));
2623                            }
2624                            for path in paths {
2625                                let display = if !pattern.starts_with('/') {
2626                                    path.strip_prefix(&cwd)
2627                                        .unwrap_or(&path)
2628                                        .to_string_lossy().into_owned()
2629                                } else {
2630                                    path.to_string_lossy().into_owned()
2631                                };
2632                                items.push(Value::String(display));
2633                            }
2634                            continue;
2635                        }
2636                    }
2637                    // Track whether this item came from $(cmd); that's the
2638                    // only position where multi-line stdout auto-splits per
2639                    // line. Arrays still spread element-by-element; bare
2640                    // $VAR is rejected upstream by validator E012. See
2641                    // docs/LANGUAGE.md.
2642                    let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2643                    let item = self.eval_expr_async(item_expr).await?;
2644                    match item {
2645                        // JSON arrays iterate over elements (preferred path
2646                        // when builtins emit .data — seq, jq, cut, find, …)
2647                        Value::Json(serde_json::Value::Array(arr)) => {
2648                            for elem in arr {
2649                                // Envelope-free: an element that happens to be
2650                                // envelope-shaped (e.g. from `fromjson`) is
2651                                // external data, not an internal bytes round-trip,
2652                                // so it must NOT be re-decoded to Value::Bytes.
2653                                items.push(json_to_value_no_envelope(elem));
2654                            }
2655                        }
2656                        // Strings from $(cmd): empty → 0 iterations,
2657                        // multi-line → split per line (trimming trailing
2658                        // newlines and per-line trailing \r), single-line
2659                        // → one iteration. Whitespace within a line is
2660                        // NOT split — the "$VAR with spaces just works"
2661                        // promise is preserved because this only fires
2662                        // in CommandSubst position.
2663                        Value::String(s) if from_command_subst => {
2664                            let trimmed = s.trim_end_matches(['\n', '\r']);
2665                            if trimmed.is_empty() {
2666                                continue;
2667                            }
2668                            if trimmed.contains('\n') {
2669                                for line in trimmed.split('\n') {
2670                                    let line = line.trim_end_matches('\r');
2671                                    items.push(Value::String(line.to_string()));
2672                                }
2673                            } else {
2674                                items.push(Value::String(trimmed.to_string()));
2675                            }
2676                        }
2677                        // Binary isn't iterable — fail loud rather than loop
2678                        // once over an opaque byte blob.
2679                        Value::Bytes(_) => {
2680                            anyhow::bail!(
2681                                "for: cannot iterate over binary data — decode it \
2682                                 (base64/xxd) first"
2683                            );
2684                        }
2685                        // Strings not from $(cmd) stay as one value.
2686                        other => items.push(other),
2687                    }
2688                }
2689
2690                let mut result = ExecResult::success("");
2691                {
2692                    let mut scope = self.scope.write().await;
2693                    scope.push_frame();
2694                }
2695
2696                'outer: for item in items {
2697                    // Cancellation checkpoint per iteration
2698                    if self.is_cancelled() {
2699                        {
2700                            let mut scope = self.scope.write().await;
2701                            scope.pop_frame();
2702                        }
2703                        result.code = 130;
2704                        self.update_last_result(&result).await;
2705                        return Ok(ControlFlow::ok(result));
2706                    }
2707                    {
2708                        let mut scope = self.scope.write().await;
2709                        scope.set(&for_loop.variable, item);
2710                    }
2711                    for stmt in &for_loop.body {
2712                        let mut flow = match self.execute_stmt_flow(stmt).await {
2713                            Ok(f) => f,
2714                            Err(e) => {
2715                                let mut scope = self.scope.write().await;
2716                                scope.pop_frame();
2717                                return Err(e);
2718                            }
2719                        };
2720                        self.drain_stderr_into(&mut result).await;
2721                        match &mut flow {
2722                            ControlFlow::Normal(r) => {
2723                                accumulate_result(&mut result, r);
2724                                if !r.ok() {
2725                                    let scope = self.scope.read().await;
2726                                    if scope.error_exit_enabled() {
2727                                        drop(scope);
2728                                        let mut scope = self.scope.write().await;
2729                                        scope.pop_frame();
2730                                        // `result` already carries `r`'s out/err
2731                                        // via accumulate_result above — hand it to
2732                                        // the Exit signal so `-e` still aborts the
2733                                        // loop but the reason survives.
2734                                        let code = r.code;
2735                                        return Ok(ControlFlow::Exit {
2736                                            code,
2737                                            result: std::mem::take(&mut result),
2738                                        });
2739                                    }
2740                                }
2741                            }
2742                            ControlFlow::Break { .. } => {
2743                                if flow.decrement_level() {
2744                                    accumulate_flow_output(&mut result, &flow);
2745                                    break 'outer;
2746                                }
2747                                fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2748                                let mut scope = self.scope.write().await;
2749                                scope.pop_frame();
2750                                return Ok(flow);
2751                            }
2752                            ControlFlow::Continue { .. } => {
2753                                if flow.decrement_level() {
2754                                    accumulate_flow_output(&mut result, &flow);
2755                                    continue 'outer;
2756                                }
2757                                fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2758                                let mut scope = self.scope.write().await;
2759                                scope.pop_frame();
2760                                return Ok(flow);
2761                            }
2762                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2763                                fold_block_output_into_flow(
2764                                    std::mem::take(&mut result),
2765                                    &mut flow,
2766                                );
2767                                let mut scope = self.scope.write().await;
2768                                scope.pop_frame();
2769                                return Ok(flow);
2770                            }
2771                        }
2772                    }
2773                }
2774
2775                {
2776                    let mut scope = self.scope.write().await;
2777                    scope.pop_frame();
2778                }
2779                // Zero iterations still writes `$?` — see the `Stmt::If` arm.
2780                // `for x in $(grep …)` with no matches must not leave grep's 1
2781                // standing as the loop's status.
2782                self.update_last_result(&result).await;
2783                Ok(ControlFlow::ok(result))
2784            }
2785            Stmt::While(while_loop) => {
2786                let mut result = ExecResult::success("");
2787
2788                'outer: loop {
2789                    // Evaluate condition - use async to support command substitution
2790                    // Cancellation checkpoint per iteration
2791                    if self.is_cancelled() {
2792                        result.code = 130;
2793                        self.update_last_result(&result).await;
2794                        return Ok(ControlFlow::ok(result));
2795                    }
2796
2797                    // Per iteration, so the condition's stdout interleaves with
2798                    // the body's rather than arriving in one block up front.
2799                    let cond_value = self
2800                        .eval_condition_async(&while_loop.condition, &mut result)
2801                        .await?;
2802
2803                    if !is_truthy(&cond_value) {
2804                        break;
2805                    }
2806
2807                    // Execute body
2808                    for stmt in &while_loop.body {
2809                        let mut flow = self.execute_stmt_flow(stmt).await?;
2810                        self.drain_stderr_into(&mut result).await;
2811                        match &mut flow {
2812                            ControlFlow::Normal(r) => {
2813                                accumulate_result(&mut result, r);
2814                                if !r.ok() {
2815                                    let scope = self.scope.read().await;
2816                                    if scope.error_exit_enabled() {
2817                                        // `result` already carries `r`'s out/err
2818                                        // via accumulate_result above — hand it to
2819                                        // the Exit signal so `-e` still aborts the
2820                                        // loop but the reason survives.
2821                                        let code = r.code;
2822                                        return Ok(ControlFlow::Exit {
2823                                            code,
2824                                            result: std::mem::take(&mut result),
2825                                        });
2826                                    }
2827                                }
2828                            }
2829                            ControlFlow::Break { .. } => {
2830                                if flow.decrement_level() {
2831                                    accumulate_flow_output(&mut result, &flow);
2832                                    break 'outer;
2833                                }
2834                                fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2835                                return Ok(flow);
2836                            }
2837                            ControlFlow::Continue { .. } => {
2838                                if flow.decrement_level() {
2839                                    accumulate_flow_output(&mut result, &flow);
2840                                    continue 'outer;
2841                                }
2842                                fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2843                                return Ok(flow);
2844                            }
2845                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2846                                fold_block_output_into_flow(
2847                                    std::mem::take(&mut result),
2848                                    &mut flow,
2849                                );
2850                                return Ok(flow);
2851                            }
2852                        }
2853                    }
2854                }
2855
2856                // A condition that is false on the first evaluation runs no
2857                // body — see the `Stmt::If` arm.
2858                self.update_last_result(&result).await;
2859                Ok(ControlFlow::ok(result))
2860            }
2861            Stmt::Case(case_stmt) => {
2862                // Evaluate the expression to match against. Text sink: a
2863                // `case $bin in ...)` pattern match on binary goes loud
2864                // rather than glob-matching against the `[binary: N bytes]`
2865                // placeholder (Decision E — same class as `==`/`in`).
2866                let match_value = {
2867                    let value = self.eval_expr_async(&case_stmt.expr).await?;
2868                    value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?
2869                };
2870
2871                // Try each branch until we find a match
2872                for branch in &case_stmt.branches {
2873                    let matched = branch.patterns.iter().any(|pattern| {
2874                        glob_match(pattern, &match_value)
2875                    });
2876
2877                    if matched {
2878                        // Execute the branch body
2879                        let mut result = ExecResult::success("");
2880                        for stmt in &branch.body {
2881                            let flow = self.execute_stmt_flow(stmt).await?;
2882                            match flow {
2883                                ControlFlow::Normal(r) => {
2884                                    accumulate_result(&mut result, &r);
2885                                    self.drain_stderr_into(&mut result).await;
2886                                }
2887                                mut other => {
2888                                    self.drain_stderr_into(&mut result).await;
2889                                    fold_block_output_into_flow(
2890                                        std::mem::take(&mut result),
2891                                        &mut other,
2892                                    );
2893                                    return Ok(other);
2894                                }
2895                            }
2896                        }
2897                        self.update_last_result(&result).await;
2898                        return Ok(ControlFlow::ok(result));
2899                    }
2900                }
2901
2902                // No match - return success with empty output (like sh), and
2903                // write it to `$?` — see the `Stmt::If` arm.
2904                let result = ExecResult::success("");
2905                self.update_last_result(&result).await;
2906                Ok(ControlFlow::ok(result))
2907            }
2908            Stmt::Break(levels) => {
2909                Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2910            }
2911            Stmt::Continue(levels) => {
2912                Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2913            }
2914            Stmt::Return(expr) => {
2915                // return [N] - N becomes the exit code, NOT stdout
2916                // Shell semantics: return sets exit code, doesn't produce output
2917                let result = if let Some(e) = expr {
2918                    let val = self.eval_expr_async(e).await?;
2919                    let code = crate::interpreter::value_to_exit_code(&val)
2920                        .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2921                    ExecResult::from_parts(code, String::new(), String::new(), None)
2922                } else {
2923                    ExecResult::success("")
2924                };
2925                Ok(ControlFlow::return_value(result))
2926            }
2927            Stmt::Exit(expr) => {
2928                let code = if let Some(e) = expr {
2929                    let val = self.eval_expr_async(e).await?;
2930                    crate::interpreter::value_to_exit_code(&val)
2931                        .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2932                } else {
2933                    0
2934                };
2935                Ok(ControlFlow::exit_code(code))
2936            }
2937            Stmt::ToolDef(tool_def) => {
2938                let mut user_tools = self.user_tools.write().await;
2939                user_tools.insert(tool_def.name.clone(), tool_def.clone());
2940                Ok(ControlFlow::ok(ExecResult::success("")))
2941            }
2942            Stmt::AndChain { left, right } => {
2943                // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2944                // Suppress errexit for the left side — && handles failure itself.
2945                {
2946                    let mut scope = self.scope.write().await;
2947                    scope.suppress_errexit();
2948                }
2949                let left_flow = match self.execute_stmt_flow(left).await {
2950                    Ok(f) => f,
2951                    Err(e) => {
2952                        let mut scope = self.scope.write().await;
2953                        scope.unsuppress_errexit();
2954                        return Err(e);
2955                    }
2956                };
2957                {
2958                    let mut scope = self.scope.write().await;
2959                    scope.unsuppress_errexit();
2960                }
2961                match left_flow {
2962                    ControlFlow::Normal(mut left_result) => {
2963                        self.drain_stderr_into(&mut left_result).await;
2964                        self.update_last_result(&left_result).await;
2965                        // Pending is not failure (spec §I.5) — see the
2966                        // `OrChain` twin. The stash check matters here for a
2967                        // hold swallowed into an apparent success below.
2968                        if left_result.ok() {
2969                            let right_flow = self.execute_stmt_flow(right).await?;
2970                            match right_flow {
2971                                ControlFlow::Normal(mut right_result) => {
2972                                    self.drain_stderr_into(&mut right_result).await;
2973                                    self.update_last_result(&right_result).await;
2974                                    let mut combined = left_result;
2975                                    accumulate_result(&mut combined, &right_result);
2976                                    Ok(ControlFlow::ok(combined))
2977                                }
2978                                mut other => {
2979                                    // The left side already ran and printed;
2980                                    // a signal out of the right side must not
2981                                    // unprint it.
2982                                    fold_block_output_into_flow(left_result, &mut other);
2983                                    Ok(other)
2984                                }
2985                            }
2986                        } else {
2987                            Ok(ControlFlow::ok(left_result))
2988                        }
2989                    }
2990                    _ => Ok(left_flow),
2991                }
2992            }
2993            Stmt::OrChain { left, right } => {
2994                // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
2995                // Suppress errexit for the left side — || handles failure itself.
2996                {
2997                    let mut scope = self.scope.write().await;
2998                    scope.suppress_errexit();
2999                }
3000                let left_flow = match self.execute_stmt_flow(left).await {
3001                    Ok(f) => f,
3002                    Err(e) => {
3003                        let mut scope = self.scope.write().await;
3004                        scope.unsuppress_errexit();
3005                        return Err(e);
3006                    }
3007                };
3008                {
3009                    let mut scope = self.scope.write().await;
3010                    scope.unsuppress_errexit();
3011                }
3012                match left_flow {
3013                    ControlFlow::Normal(mut left_result) => {
3014                        self.drain_stderr_into(&mut left_result).await;
3015                        self.update_last_result(&left_result).await;
3016                        // Pending is not failure (spec §I.5): a fallback
3017                        // written for failure must not run on a decision
3018                        // nobody has made yet — and running it would also
3019                        // overwrite the request in the accumulated result.
3020                        // The stash check covers a hold whose typed error a
3021                        // layer below already stringified out of the result.
3022                        // On a stash-based hold the returned `left_result` is
3023                        // that stringified failure, not the held result — the
3024                        // statement boundary discards it and surfaces the
3025                        // slot's result instead. Do not "fix" this by taking
3026                        // the slot here: only statement boundaries take it.
3027                        if !left_result.ok() {
3028                            let right_flow = self.execute_stmt_flow(right).await?;
3029                            match right_flow {
3030                                ControlFlow::Normal(mut right_result) => {
3031                                    self.drain_stderr_into(&mut right_result).await;
3032                                    self.update_last_result(&right_result).await;
3033                                    let mut combined = left_result;
3034                                    accumulate_result(&mut combined, &right_result);
3035                                    Ok(ControlFlow::ok(combined))
3036                                }
3037                                mut other => {
3038                                    // The left side already ran and printed;
3039                                    // a signal out of the right side must not
3040                                    // unprint it.
3041                                    fold_block_output_into_flow(left_result, &mut other);
3042                                    Ok(other)
3043                                }
3044                            }
3045                        } else {
3046                            Ok(ControlFlow::ok(left_result))
3047                        }
3048                    }
3049                    _ => Ok(left_flow), // Propagate non-normal flow
3050                }
3051            }
3052            Stmt::Test(test_expr) => {
3053                let is_true = self.eval_test_async(test_expr).await?;
3054                let result = if is_true {
3055                    ExecResult::success("")
3056                } else {
3057                    ExecResult::failure(1, "")
3058                };
3059                // A bare test writes `$?` and honors `set -e` like any command
3060                // (bash: `[[ 1 = 2 ]]; echo $?` → 1). `&&`/`||` operands stay
3061                // safe: the chain arms suppress errexit around their left side,
3062                // and `if`/`while` conditions evaluate as expressions, never
3063                // through this statement arm.
3064                self.update_last_result(&result).await;
3065                if !result.ok() {
3066                    let scope = self.scope.read().await;
3067                    if scope.error_exit_enabled() {
3068                        // `-e` aborts the statement list, but the reason the
3069                        // command died must survive with it — carry `result`
3070                        // (its `out`/`err`/`data`) into the Exit signal instead
3071                        // of `ControlFlow::exit_code`'s empty placeholder.
3072                        let code = result.code;
3073                        return Ok(ControlFlow::Exit { code, result });
3074                    }
3075                }
3076                Ok(ControlFlow::ok(result))
3077            }
3078            Stmt::EnvScoped { assignments, body } => {
3079                // Inline env prefix (`NAME=value ... command`): apply the
3080                // assignments as EXPORTED vars in a fresh frame so the command
3081                // — and its subprocess environment — sees them, then unwind so
3082                // they do NOT persist (bash-style command-scoped env). Values
3083                // evaluate left-to-right with earlier ones already in scope, so
3084                // `A=1 B=$A cmd` works.
3085                {
3086                    let mut scope = self.scope.write().await;
3087                    scope.push_frame();
3088                }
3089                let mut prior_export: Vec<(String, bool)> =
3090                    Vec::with_capacity(assignments.len());
3091                let mut setup_err: Option<anyhow::Error> = None;
3092                for assign in assignments {
3093                    match self.eval_expr_async(&assign.value).await {
3094                        Ok(value) => {
3095                            let mut scope = self.scope.write().await;
3096                            prior_export
3097                                .push((assign.name().to_string(), scope.is_exported(assign.name())));
3098                            scope.set_exported(assign.name(), value);
3099                        }
3100                        Err(e) => {
3101                            setup_err = Some(e);
3102                            break;
3103                        }
3104                    }
3105                }
3106
3107                let flow = if setup_err.is_none() {
3108                    self.execute_stmt_flow(body).await
3109                } else {
3110                    Ok(ControlFlow::ok(ExecResult::success("")))
3111                };
3112
3113                // Unwind the env frame and restore export marks unconditionally
3114                // (names that were not exported before must not stay exported).
3115                {
3116                    let mut scope = self.scope.write().await;
3117                    scope.pop_frame();
3118                    for (name, was_exported) in &prior_export {
3119                        if !*was_exported {
3120                            scope.unexport(name);
3121                        }
3122                    }
3123                }
3124
3125                match setup_err {
3126                    Some(e) => Err(e),
3127                    None => flow,
3128                }
3129            }
3130            Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
3131        }
3132        })
3133    }
3134
3135    /// Build a boxed per-command `ExecContext` snapshot from the persistent
3136    /// kernel state (`ec`/`scope`, both already locked by the caller).
3137    ///
3138    /// Sync on purpose: the ~30 field clones live in this transient frame rather
3139    /// than a coroutine slot, and the result is `Box`ed so only an 8-byte pointer
3140    /// — not the 960-byte struct — rides the dispatch await at every recursion
3141    /// level (GH #48, item 2). `pipeline_position` and `cancel` are the only
3142    /// per-site differences (the pipeline runner uses the kernel's own cancel
3143    /// token and forces `Only`; the per-command dispatch inherits `ec`'s), so
3144    /// they're parameters; every other field is snapshotted identically.
3145    fn snapshot_exec_ctx(
3146        &self,
3147        ec: &ExecContext,
3148        scope: &Scope,
3149        pipeline_position: PipelinePosition,
3150        cancel: tokio_util::sync::CancellationToken,
3151    ) -> Box<ExecContext> {
3152        Box::new(ExecContext {
3153            backend: ec.backend.clone(),
3154            scope: scope.clone(),
3155            cwd: ec.cwd.clone(),
3156            prev_cwd: ec.prev_cwd.clone(),
3157            stdin: ec.stdin.clone(),
3158            stdin_data: ec.stdin_data.clone(),
3159            stdin_data_rx: None,
3160            pipe_stdin: None,
3161            pipe_stdout: None,
3162            stderr: ec.stderr.clone(),
3163            tool_schemas: ec.tool_schemas.clone(),
3164            tools: ec.tools.clone(),
3165            job_manager: ec.job_manager.clone(),
3166            pipeline_position,
3167            interactive: self.interactive,
3168            // The kernel-wide setting; a snapshot inherits it like `interactive`.
3169            kill_children_on_parent_death: ec.kill_children_on_parent_death,
3170            aliases: ec.aliases.clone(),
3171            ignore_config: ec.ignore_config.clone(),
3172            output_limit: ec.output_limit.clone(),
3173            allow_external_commands: self.allow_external_commands,
3174            trash_backend: ec.trash_backend.clone(),
3175            #[cfg(all(unix, feature = "subprocess"))]
3176            terminal_state: ec.terminal_state.clone(),
3177            dispatcher: self.dispatcher(),
3178            cancel,
3179            output_format: None,
3180            vfs_budget: self.vfs_budget.clone(),
3181            watchdog: ec.watchdog.clone(),
3182            #[cfg(all(feature = "localfs", feature = "overlay"))]
3183            overlay_handle: self.overlay_handle.clone(),
3184            // Correlate this command's requests with the background job it
3185            // runs for, if any — the ONE place `job_id` is stamped.
3186            // A replay correlation belongs to exactly one dispatch. Moved
3187            // (not cloned) out of the parent context at the dispatch seam —
3188            // see the stdin hand-off below, which takes it under the same
3189            // write lock — so the gate this snapshot reaches is the only one
3190            // that can adopt it.
3191            // A forked or backgrounded execution keeps its parenthood: a
3192            // gate reached from inside a gated statement is nested under it
3193            // (spec §A.7).
3194        })
3195    }
3196
3197    /// Execute a pipeline.
3198    async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
3199        if pipeline.stages.is_empty() {
3200            return Ok(ExecResult::success(""));
3201        }
3202
3203        // Handle background execution (`&` operator)
3204        if pipeline.background {
3205            return self.execute_background(pipeline).await;
3206        }
3207
3208        // All commands go through the runner with the Kernel as dispatcher.
3209        // This is the single execution path — no fast path for single commands.
3210        //
3211        // IMPORTANT: We snapshot exec_ctx into a local context and release the
3212        // lock before running. This prevents deadlocks when dispatch_command
3213        // is called from within the pipeline and recursively triggers another
3214        // pipeline (e.g., via user-defined tools).
3215        let (mut ctx, has_pipe_stdin) = {
3216            let ec = self.exec_ctx.read().await;
3217            let scope = self.scope.read().await;
3218            // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
3219            // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
3220            // the consume-once block below, so note its presence here.
3221            let has_pipe_stdin = ec.pipe_stdin.is_some();
3222            // The pipeline runner drives stage 0 with the first stage's stdin
3223            // seeded from any frontend-supplied input (`ExecuteOptions::stdin`,
3224            // e.g. `printf … | kaish -c sort`) unless a redirect already set it,
3225            // and uses the kernel's own cancel token so a `cancel()` reaches the
3226            // stages. See `snapshot_exec_ctx` for why the snapshot is boxed.
3227            let cancel = {
3228                #[allow(clippy::expect_used)]
3229                let token = self.cancel_token.lock().expect("cancel_token poisoned");
3230                token.clone()
3231            };
3232            (self.snapshot_exec_ctx(&ec, &scope, PipelinePosition::Only, cancel), has_pipe_stdin)
3233        }; // locks released
3234
3235        // Consume-once: move/clear the seeded stdin sources from the persistent
3236        // exec_ctx now that this pipeline's ctx owns them, so a later statement
3237        // in the same call (`cat ; cat`) does not re-receive them — matching
3238        // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
3239        // (the ctx above was built with `pipe_stdin: None`).
3240        if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
3241            let mut ec = self.exec_ctx.write().await;
3242            ctx.pipe_stdin = ec.pipe_stdin.take();
3243            ec.stdin = None;
3244            ec.stdin_data = None;
3245        }
3246
3247        // Park the enclosing command's write end and sideband receiver here for
3248        // the duration. `ec` is one shared slot and the snapshot above zeroes
3249        // both, so a nested dispatch — `$(…)` in a command's own arguments, a
3250        // function body, a `source`d file — overwrites whatever is left in it.
3251        // `echo $(echo sub) | cat` printed nothing at exit 0;
3252        // `seq 1 3 | jq -c $(echo .)` fell back to reading the pipe as text.
3253        //
3254        // Here rather than at each re-entering caller: this is the one path
3255        // they all take. The shared slot is the actual defect — threading a
3256        // ctx through the interpreter would retire this whole dance.
3257        {
3258            let mut ec = self.exec_ctx.write().await;
3259            ctx.pipe_stdout = ec.pipe_stdout.take();
3260            ctx.stdin_data_rx = ec.stdin_data_rx.take();
3261        }
3262
3263        let mut result = self.runner.run(&pipeline.stages, &mut ctx, self).await;
3264
3265        // `set -o pipefail`: the pipeline answers with the RIGHTMOST non-zero
3266        // stage, not the first. bash's `set -o pipefail; (exit 3) | (exit 4) |
3267        // true` is 4, and reading the first non-zero would have said 3.
3268        //
3269        // Applied BEFORE the spill contract so a spilled pipeline still reports
3270        // 3 and keeps the pipefail status as `original_code`, rather than the
3271        // last stage's — the spill remap is about output size and should
3272        // override whatever the pipeline decided its status was, not race it.
3273        //
3274        // Read back from the scope the runner just wrote rather than threaded
3275        // separately: PIPESTATUS is the one record of what each stage did, so
3276        // the mode and the variable cannot disagree about the same pipeline.
3277        if ctx.scope.pipefail_enabled() {
3278            if let Some(code) = ctx.scope.pipestatus_rightmost_failure() {
3279                result.code = code;
3280            }
3281        }
3282
3283        // Post-hoc spill check + exit-3 remap (catches builtins and fast
3284        // external commands; also catches a ring overflow that already
3285        // flipped `did_spill` even when the limit itself is disabled, GH
3286        // #191). This is the shared contract every execution surface must
3287        // apply — see `apply_spill_contract`'s doc comment (GH #212).
3288        crate::output_limit::apply_spill_contract(&mut result, &ctx.output_limit).await;
3289
3290        // Sync changes back from context
3291        {
3292            let mut ec = self.exec_ctx.write().await;
3293            ec.cwd = ctx.cwd.clone();
3294            ec.prev_cwd = ctx.prev_cwd.clone();
3295            ec.aliases = ctx.aliases.clone();
3296            ec.ignore_config = ctx.ignore_config.clone();
3297            ec.output_limit = ctx.output_limit.clone();
3298            // Unconsumed stdin goes back to the session, or it dies here with
3299            // `ctx`. A partial read (`read` takes one line) leaves the rest
3300            // split across two places: the bytes it over-read sit in `stdin`,
3301            // and the pipe still holds everything past them. Dropping the
3302            // reader discards that tail with no error — `read x; wc -c` over
3303            // 100 KiB counted 8187 bytes and said nothing.
3304            //
3305            // A multi-stage pipeline reaches here with the remainder already
3306            // returned by `run_pipeline`'s join, so this carries the
3307            // single-command and the pipeline case alike.
3308            ec.stdin = ctx.stdin.take();
3309            ec.pipe_stdin = ctx.pipe_stdin.take();
3310            // The parked handles go home. Stages get writers the runner owns,
3311            // so what is here is what was carried in.
3312            ec.pipe_stdout = ctx.pipe_stdout.take();
3313            ec.stdin_data_rx = ctx.stdin_data_rx.take();
3314        }
3315        {
3316            let mut scope = self.scope.write().await;
3317            *scope = ctx.scope.clone();
3318        }
3319
3320        Ok(result)
3321    }
3322
3323    /// Execute a pipeline in the background.
3324    ///
3325    /// The command is spawned as a tokio task and registered with the
3326    /// JobManager. The job is observable via `/v/jobs/{id}/status`,
3327    /// `/v/jobs/{id}/command`, and — while it is
3328    /// still running — `/v/jobs/{id}/stdout` and `/stderr`.
3329    ///
3330    /// GH #240 removed those two nodes because they filled once, at
3331    /// completion, while the docs promised a live stream. They are back on
3332    /// the terms the docs always claimed: `try_execute_external` tees each
3333    /// 8 KiB chunk into the job's stream as the child emits it. See
3334    /// `Job::stdout_stream` for exactly which bytes reach them.
3335    ///
3336    /// Returns immediately with a job ID like "[1]".
3337    #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.stages.len()))]
3338    async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
3339        use tokio::sync::oneshot;
3340
3341        // Format the command for display in /v/jobs/{id}/command
3342        let command_str = self.format_pipeline(pipeline);
3343
3344        // Create channel for result notification
3345        let (tx, rx) = oneshot::channel();
3346
3347        // Register with JobManager to get job ID and create VFS entries
3348        let job_id = self.jobs.register(command_str.clone(), rx).await;
3349
3350        // Fork the kernel for this background job. The fork snapshots the
3351        // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
3352        // while sharing the job manager, VFS, and tool registry. The fork's
3353        // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
3354        // is available here — something BackendDispatcher couldn't provide.
3355        //
3356        // The fork gets its own cancellation token (recorded on the job so
3357        // `kill %N` can stop the job — including a pure-builtin job with no OS
3358        // process group) and is stamped with the job id so any external
3359        // command it spawns records its process group for `kill -<sig> %N`.
3360        let cancel = tokio_util::sync::CancellationToken::new();
3361        self.jobs.set_cancel_token(job_id, cancel.clone()).await;
3362        let jobs = self.jobs.clone();
3363        let fork = self.fork_for_background(cancel, job_id).await;
3364        let runner = self.runner.clone();
3365        let stages = pipeline.stages.clone();
3366
3367        // Snapshot the fork's exec_ctx for the spawned task. We have to do
3368        // this before tokio::spawn because the fork's exec_ctx is behind a
3369        // tokio RwLock and we want the spawned task to own its ctx.
3370        let mut bg_ctx = {
3371            let ec = fork.exec_ctx.read().await;
3372            ec.child_for_pipeline()
3373        };
3374        bg_ctx.scope = fork.scope.read().await.clone();
3375        // The fork's dispatcher points at the fork itself; set it here so
3376        // builtins inside the background task (e.g. timeout) re-dispatch
3377        // through the fork, not the parent.
3378        bg_ctx.dispatcher = fork.dispatcher();
3379
3380        // Spawn the background task. Propagate the embedder's trace context
3381        // across the spawn boundary so the job's spans stay in the same trace.
3382        tokio::spawn(crate::telemetry::bind_current_context(async move {
3383            // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
3384            // gives us that (Kernel implements CommandDispatcher).
3385            let mut result = runner.run(&stages, &mut bg_ctx, fork.as_ref()).await;
3386
3387            // A background task is its own statement boundary. Pipeline stages
3388            // and command substitutions flush stderr to the fork's stderr
3389            // channel exactly as they would in the foreground, but the
3390            // statement-boundary drains live in `Kernel::execute`, which this
3391            // task never runs. Drain here, or the job's stderr never reaches
3392            // its result: a substitution's failure reason is lost and
3393            // `/v/jobs/{id}/stderr` stays empty.
3394            fork.drain_stderr_into(&mut result).await;
3395
3396            // Apply the same spill/exit-3 contract the foreground path gets
3397            // (`execute_pipeline`'s `apply_spill_contract` call) — without
3398            // this, a background job whose output overflows the capture ring
3399            // or trips the output limit reports the child's ORIGINAL exit
3400            // code to JobManager, so `[N] done:0`/`Job::status()` silently
3401            // read success even though the output was capped (GH #212).
3402            crate::output_limit::apply_spill_contract(&mut result, &bg_ctx.output_limit).await;
3403
3404            // Close out `/v/jobs/{id}/stdout`/`stderr`: a stream the external
3405            // drain tasks already fed live is left alone (re-writing the
3406            // aggregate would duplicate every byte), an untouched one takes
3407            // the captured result, and both close. Before `tx.send`, so a
3408            // reader that observes a terminal `status` also observes a
3409            // finished stream — never a `done:0` job whose output is still
3410            // arriving.
3411            jobs.finalize_streams(job_id, &result).await;
3412
3413            // Send result to JobManager (ignore error if receiver dropped)
3414            let _ = tx.send(result);
3415        }));
3416
3417        // The announcement is a shell message, not command output: bash writes
3418        // it to stderr, and stdout stays clean so `$(cmd &)` captures no shell
3419        // metadata. Terminated like every kaish diagnostic (#363).
3420        let mut announcement = ExecResult::success("");
3421        announcement.err = ExecResult::terminate_diagnostic(format!("[{job_id}]"));
3422        Ok(announcement)
3423    }
3424
3425    /// Format a pipeline as a command string for display.
3426    fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
3427        pipeline
3428            .stages
3429            .iter()
3430            .map(|stage| {
3431                let cmd = match stage {
3432                    crate::ast::PipelineStage::Command(cmd) => cmd,
3433                    // A compound stage renders through the plan renderer,
3434                    // which already knows every statement form.
3435                    crate::ast::PipelineStage::Compound(stmt) => {
3436                        return crate::ast::plan::render_stmt(stmt)
3437                    }
3438                };
3439                let mut parts = vec![cmd.name.clone()];
3440                for arg in &cmd.args {
3441                    match arg {
3442                        Arg::Positional(expr) => {
3443                            parts.push(self.format_expr(expr));
3444                        }
3445                        Arg::Named { key, value } => {
3446                            parts.push(format!("--{}={}", key, self.format_expr(value)));
3447                        }
3448                        Arg::WordAssign { key, value } => {
3449                            parts.push(format!("{}={}", key, self.format_expr(value)));
3450                        }
3451                        Arg::ShortFlag(name) => {
3452                            parts.push(format!("-{}", name));
3453                        }
3454                        Arg::LongFlag(name) => {
3455                            parts.push(format!("--{}", name));
3456                        }
3457                        Arg::DoubleDash => {
3458                            parts.push("--".to_string());
3459                        }
3460                    }
3461                }
3462                parts.join(" ")
3463            })
3464            .collect::<Vec<_>>()
3465            .join(" | ")
3466    }
3467
3468    /// Format an expression as a string for display.
3469    fn format_expr(&self, expr: &Expr) -> String {
3470        match expr {
3471            Expr::Literal(Value::String(s)) => {
3472                if s.contains(' ') || s.contains('"') {
3473                    format!("'{}'", s.replace('\'', "\\'"))
3474                } else {
3475                    s.clone()
3476                }
3477            }
3478            Expr::Literal(Value::Int(i)) => i.to_string(),
3479            Expr::Literal(Value::Float(f)) => f.to_string(),
3480            Expr::Literal(Value::Bool(b)) => b.to_string(),
3481            Expr::Literal(Value::Null) => "null".to_string(),
3482            Expr::VarRef(path) => {
3483                let mut name = String::new();
3484                for (i, seg) in path.segments.iter().enumerate() {
3485                    match seg {
3486                        crate::ast::VarSegment::Field(f) => {
3487                            if i > 0 {
3488                                name.push('.');
3489                            }
3490                            name.push_str(f);
3491                        }
3492                        crate::ast::VarSegment::Index(idx) => name.push_str(&format!("[{idx}]")),
3493                        crate::ast::VarSegment::Key(k) => name.push_str(&format!("[{k}]")),
3494                        crate::ast::VarSegment::Dynamic(v) => name.push_str(&format!("[${v}]")),
3495                        crate::ast::VarSegment::Slice(a, b) => name.push_str(&format!(
3496                            "[{}:{}]",
3497                            a.map(|n| n.to_string()).unwrap_or_default(),
3498                            b.map(|n| n.to_string()).unwrap_or_default()
3499                        )),
3500                    }
3501                }
3502                format!("${{{}}}", name)
3503            }
3504            Expr::Interpolated(_) => "\"...\"".to_string(),
3505            Expr::HereDocBody { .. } => "<<heredoc".to_string(),
3506            _ => "...".to_string(),
3507        }
3508    }
3509
3510    /// Execute a single command.
3511    async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
3512        self.execute_command_depth(name, args, 0).await
3513    }
3514
3515    async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
3516        // Dispatch breadcrumb instead of an `#[instrument]` span: this is the
3517        // most-recursed function on the ring, so wrapping its future in
3518        // `Instrumented<Span>` (plus the `err` recorder) cost native stack at
3519        // every level (GH #48, item 3). A `trace!` event records the command name
3520        // without living in the future.
3521        tracing::trace!(command = %name, alias_depth, "dispatch");
3522        // Special built-ins. `SpecialForm::from_name` is the single source of
3523        // truth (shared with `classify_command` via `is_runtime_special_form`),
3524        // and this match on the enum is *exhaustive* — adding a special-form is a
3525        // compile error until both the name mapping and the behavior here are
3526        // updated. A name that is not a special-form falls through to alias /
3527        // `/v/bin/` / user-tool / builtin / `PATH` resolution unchanged.
3528        if let Some(form) = crate::validator::SpecialForm::from_name(name) {
3529            return match form {
3530                crate::validator::SpecialForm::True => Ok(ExecResult::success("")),
3531                crate::validator::SpecialForm::False => Ok(ExecResult::failure(1, "")),
3532                crate::validator::SpecialForm::Source => Box::pin(self.execute_source(args)).await,
3533            };
3534        }
3535
3536        // Alias expansion (with recursion limit)
3537        if alias_depth < 10 {
3538            let alias_value = {
3539                let ctx = self.exec_ctx.read().await;
3540                ctx.aliases.get(name).cloned()
3541            };
3542            if let Some(alias_val) = alias_value {
3543                // Split alias value into command + args
3544                let parts: Vec<&str> = alias_val.split_whitespace().collect();
3545                if let Some((alias_cmd, alias_args)) = parts.split_first() {
3546                    let mut new_args: Vec<Arg> = alias_args
3547                        .iter()
3548                        .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
3549                        .collect();
3550                    new_args.extend_from_slice(args);
3551                    return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
3552                }
3553            }
3554        }
3555
3556        // Handle /v/bin/ prefix — dispatch to builtins via virtual path
3557        if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
3558            return match self.tools.get(builtin_name) {
3559                Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
3560                None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
3561            };
3562        }
3563
3564        // Check user-defined tools first
3565        {
3566            let user_tools = self.user_tools.read().await;
3567            if let Some(tool_def) = user_tools.get(name) {
3568                let tool_def = tool_def.clone();
3569                drop(user_tools);
3570                return Box::pin(self.execute_user_tool(tool_def, args)).await;
3571            }
3572        }
3573
3574        // Look up builtin tool
3575        let tool = match self.tools.get(name) {
3576            Some(t) => t,
3577            None => {
3578                // Try executing as .kai script from PATH
3579                if let Some(result) = Box::pin(self.try_execute_script(name, args)).await? {
3580                    return Ok(result);
3581                }
3582                // Try executing as external command from PATH — boxed because its
3583                // future is the heaviest branch here (holds a `tokio::process::Command`,
3584                // argv, the child's stdio streams, and kill/reap drop guards); leaving
3585                // it inline fattens every `execute_command_depth` frame on the recursion
3586                // ring even when the command is a builtin.
3587                //
3588                // A refusal (compiled without `subprocess`, or configured off)
3589                // does NOT return here — a backend-registered tool of the same
3590                // name is a separate capability and still gets a chance below.
3591                // `unavailable` carries the reason forward so the final "nothing
3592                // claimed this name" message can name it, instead of the
3593                // fallthrough re-deriving the wrong "command not found".
3594                let mut unavailable = None;
3595                match Box::pin(self.try_execute_external(name, args)).await? {
3596                    ExternalCommandOutcome::Ran(result) => return Ok(*result),
3597                    ExternalCommandOutcome::NotFound => {}
3598                    ExternalCommandOutcome::Unavailable(reason) => unavailable = Some(reason),
3599                }
3600
3601                // Try backend-registered tools (embedder engines, etc.)
3602                // Look up tool schema for positional→named mapping.
3603                // Clone backend and drop read lock before awaiting (may involve network I/O).
3604                // Backend tools expect named JSON params, so enable positional mapping.
3605                let backend = self.exec_ctx.read().await.backend.clone();
3606                let tool_schema = backend
3607                    .get_tool(name)
3608                    .await
3609                    .unwrap_or_else(|e| {
3610                        // Schema lookup failing just means positionals won't
3611                        // get name-mapped below — `call_tool` is still
3612                        // attempted. Trace it so the degradation is visible
3613                        // rather than silently swallowed.
3614                        tracing::debug!("backend get_tool error for {name}: {e}");
3615                        None
3616                    })
3617                    .map(|t| {
3618                    let mut s = t.schema;
3619                    // Flat backend/MCP tools expect named JSON params, so map
3620                    // bare positionals onto named params. Subcommand-aware tools
3621                    // route positionals through the subcommand path and declare
3622                    // map_positionals per leaf (kj keeps it false so it re-parses
3623                    // the argv with its own clap) — don't blanket-override them.
3624                    if s.subcommands.is_empty() {
3625                        s.map_positionals = true;
3626                    }
3627                    s
3628                });
3629                let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
3630                let mut ctx = self.exec_ctx.write().await;
3631                {
3632                    let scope = self.scope.read().await;
3633                    ctx.scope = scope.clone();
3634                }
3635                let backend = ctx.backend.clone();
3636                match backend.call_tool(name, tool_args, &mut *ctx).await {
3637                    Ok(tool_result) => {
3638                        let mut scope = self.scope.write().await;
3639                        *scope = ctx.scope.clone();
3640                        // Preserve every field (data/content_type/baggage,
3641                        // not just stdout text) — this is the embedder seam:
3642                        // `x=$(embedder_tool)` and structured iteration over
3643                        // its result depend on `.data` surviving the crossing
3644                        // back into the kernel.
3645                        let mut result = ExecResult::from(tool_result);
3646                        // The same rule the builtin dispatch applies, applied
3647                        // here too — this seam returns before it. Without this
3648                        // an embedder tool that hands back a value got its
3649                        // `$(…)` capture stringified, which is precisely what
3650                        // the comment above promises does not happen.
3651                        let data_is_only_output = result.data.is_some()
3652                            && !result.has_output()
3653                            && result.text_out().is_empty();
3654                        result.data_is_value |= data_is_only_output
3655                            || tool_schema
3656                                .as_ref()
3657                                .is_some_and(|s| s.typed_substitution);
3658                        return Ok(result);
3659                    }
3660                    Err(BackendError::ToolNotFound(_)) => {
3661                        // The backend confirms no such tool exists — fall
3662                        // through to "command not found" below.
3663                    }
3664                    Err(e) => {
3665                        // The tool was found (dispatch reached real
3666                        // execution) but running it failed — a genuine
3667                        // execution error, not "command not found". Surface
3668                        // it loudly instead of masking it as exit-127.
3669                        return Ok(ExecResult::failure(1, format!("{}: {}", name, e)));
3670                    }
3671                }
3672
3673                return Ok(match unavailable {
3674                    Some(reason) => external_commands_unavailable_error(name, reason),
3675                    None => ExecResult::failure(127, format!("command not found: {}", name)),
3676                });
3677            }
3678        };
3679
3680        // Build arguments (async to support command substitution, schema-aware
3681        // for flag values), then decide `--help` and `owns_output` — all three
3682        // read the tool's schema and nothing after this block does, so the whole
3683        // schema borrow is scoped here and cannot ride the `tool.execute` await
3684        // below (GH #48, item 7).
3685        let (tool_args, wants_help, owns_output, raw_argv, typed_substitution) = {
3686            // Prefer the kernel's schema catalog over `tool.schema()`: for a
3687            // clap-derived builtin, `schema()` rebuilds the entire clap
3688            // `Command` and reflects it into a fresh `ToolSchema` — ~34
3689            // allocations per command, 18% of all allocations in the GH #48
3690            // many-small-commands profile — to produce exactly what the catalog
3691            // already holds. The catalog is seeded from this same registry in
3692            // `Kernel::assemble` and is name-sorted, so this is a binary search
3693            // with no allocation at all. `owned` covers a tool the catalog
3694            // doesn't list (registered after assembly, or whose schema name
3695            // differs from its dispatch name): the fallback calls the same
3696            // `schema()` and is equivalent, just not free.
3697            let catalog = { self.exec_ctx.read().await.tool_schemas.clone() };
3698            let owned;
3699            let schema: &crate::tools::ToolSchema =
3700                match catalog.binary_search_by(|s| s.name.as_str().cmp(name)) {
3701                    Ok(i) => &catalog[i],
3702                    Err(_) => {
3703                        owned = tool.schema();
3704                        &owned
3705                    }
3706                };
3707
3708            let tool_args = self.build_args_async(args, Some(schema)).await?;
3709
3710            // --help / -h: show the generic whole-tool help, unless either the tool's
3711            // root schema claims that flag OR the tool owns its output. Owned-output
3712            // tools re-parse their own argv and route their own `--help` — including
3713            // leaf/subcommand help — through their internal (clap) parser, so the root
3714            // schema can't express "this leaf claims help" and intercepting here would
3715            // render top-level help and return before `execute()` ever sees the
3716            // request (#51). Pass it through and let the tool render its own help.
3717            let schema_claims = |flag: &str| -> bool {
3718                let bare = flag.trim_start_matches('-');
3719                schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
3720            };
3721            let wants_help = !schema.owns_output
3722                && ((tool_args.flags.contains("help") && !schema_claims("help"))
3723                    || (tool_args.flags.contains("h") && !schema_claims("-h")));
3724
3725            (tool_args, wants_help, schema.owns_output, schema.raw_argv, schema.typed_substitution)
3726        };
3727
3728        if wants_help {
3729            let help_topic = crate::help::HelpTopic::Tool(name.to_string());
3730            let ctx = self.exec_ctx.read().await;
3731            let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
3732            return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
3733        }
3734
3735        // Snapshot exec_ctx into a local context and release the write lock
3736        // before calling tool.execute. Holding the write across tool execution
3737        // would deadlock any builtin that re-dispatches through ctx.dispatcher
3738        // (timeout, scatter) — the inner dispatch_command needs its own
3739        // exec_ctx.write() and would block forever.
3740        let mut ctx = {
3741            let ec = self.exec_ctx.write().await;
3742            let scope = self.scope.read().await;
3743            // Inherit `ec.pipeline_position` and `ec.cancel` (the latter set by
3744            // dispatch_command from the runner's ctx.cancel, so a builtin-swapped
3745            // child token — e.g. timeout's — reaches the spawned external via
3746            // wait_or_kill; it falls back to the kernel's own token on a
3747            // non-dispatch path). See `snapshot_exec_ctx` for the boxing rationale.
3748            self.snapshot_exec_ctx(&ec, &scope, ec.pipeline_position, ec.cancel.clone())
3749        }; // both locks released — tool.execute can re-dispatch safely
3750
3751        // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3752        // semantics): take() so a later dispatch doesn't see stale stdin.
3753        // Done after the snapshot above so we hold the write briefly.
3754        {
3755            let mut ec = self.exec_ctx.write().await;
3756            ctx.stdin = ec.stdin.take();
3757            ctx.stdin_data = ec.stdin_data.take();
3758            ctx.stdin_data_rx = ec.stdin_data_rx.take();
3759            ctx.pipe_stdin = ec.pipe_stdin.take();
3760            ctx.pipe_stdout = ec.pipe_stdout.take();
3761            // Same take-don't-clone discipline as stdin, and for the same
3762            // reason: these belong to exactly one dispatch, and a copy left
3763            // behind would let the next command adopt it.
3764        }
3765
3766        // Honor --json before the builtin runs so its setting survives a clap
3767        // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3768        // --json on the floor when `try_parse_from` returns Err early).
3769        // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3770        GlobalFlags::apply_from_args(&tool_args, raw_argv, &mut *ctx);
3771
3772        let mut result = tool.execute(tool_args, &mut *ctx).await;
3773        // A command substitution binds `.data` only when it is the result's
3774        // VALUE. `--json` and the pipeline sideband read `.data` either way,
3775        // so this marks the ONE consumer whose answer is a matter of taste.
3776        //
3777        // Two ways to qualify. A tool that printed NOTHING has only its data,
3778        // so that data is trivially its value — this is `fromjson`'s shape and
3779        // an out-of-tree tool's, and it needs no declaration, which is what
3780        // keeps this from breaking embedder tools. A tool that printed text
3781        // AND attached data has to say which one it means, because the two
3782        // readings are equally defensible: `jq` means the data, `cut` means
3783        // the text it printed.
3784        let data_is_only_output = result.data.is_some()
3785            && !result.has_output()
3786            && result.text_out().is_empty();
3787        // OR, never assign: a wrapper that re-dispatches an inner command
3788        // returns the INNER result, and stamping it from the wrapper's own
3789        // schema erased what the inner tool declared —
3790        // `$(timeout 5 fromjson '[1,2]')` bound text while `$(fromjson …)`
3791        // bound a list. A result arrives here fresh from `tool.execute`, so a
3792        // marker already set is one the tool or its inner dispatch meant.
3793        result.data_is_value |= typed_substitution || data_is_only_output;
3794
3795        // Sync mutations back. Tools may have changed scope (set/cd),
3796        // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3797        // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3798        // hands them back to the pipeline runner — the runner uses
3799        // stage_ctx.pipe_stdout to write the result to the next stage when
3800        // the tool itself didn't take and write to it.
3801        {
3802            let mut scope = self.scope.write().await;
3803            *scope = ctx.scope.clone();
3804        }
3805        {
3806            let mut ec = self.exec_ctx.write().await;
3807            ec.cwd = ctx.cwd;
3808            ec.prev_cwd = ctx.prev_cwd;
3809            ec.aliases = ctx.aliases;
3810            // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3811            // mutate the runtime output limit; without this sync the change is
3812            // dropped here and never reaches dispatch_command's read-back, so
3813            // it would not survive past the current statement.
3814            ec.output_limit = ctx.output_limit.clone();
3815            // Same for `kaish-ignore` (add/clear/defaults/scope): this field
3816            // was missing from this sync, so every runtime ignore mutation
3817            // silently died at the end of its own statement — including the
3818            // documented `kaish-ignore add .gitignore` rc-file recipe.
3819            ec.ignore_config = ctx.ignore_config.clone();
3820            ec.pipe_stdin = ctx.pipe_stdin.take();
3821            ec.pipe_stdout = ctx.pipe_stdout.take();
3822            // What a partial read left behind goes back too: `read` takes one
3823            // line and keeps the rest, and that remainder belongs to the next
3824            // reader. Without this it dies with the tool's context and
3825            // `read x; read y` loses the second line.
3826            ec.stdin = ctx.stdin.take();
3827            // The sideband is stdin in typed form and returns by the same
3828            // rule; taken in above, an unconsumed value would die here.
3829            ec.stdin_data = ctx.stdin_data.take();
3830            ec.stdin_data_rx = ctx.stdin_data_rx.take();
3831        }
3832
3833        // Builtins parse --json via the GlobalFlags flatten in their clap
3834        // struct and write ctx.output_format. The kernel applies it — unless the
3835        // tool owns its own output (renders --json itself), in which case we
3836        // leave its bytes untouched.
3837        let result = finalize_output(result, ctx.output_format, owns_output);
3838
3839        Ok(result)
3840    }
3841
3842    /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3843    /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3844    /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3845    /// unexpanded rather than leaking the host home directory.
3846    async fn scope_home(&self) -> Option<String> {
3847        match self.scope.read().await.get("HOME") {
3848            Some(Value::String(s)) => Some(s.clone()),
3849            _ => None,
3850        }
3851    }
3852
3853    /// Build tool arguments from AST args.
3854    ///
3855    /// Uses async evaluation to support command substitution in arguments.
3856    /// Delegates to the shared `bind_tool_args` core (GH #188): this method
3857    /// now only supplies the evaluator — `self` implements `ArgValueSource`
3858    /// against the kernel's own session state (full recursion through the
3859    /// async pipeline, real glob expansion, tilde expansion). Before this,
3860    /// `bind_tool_args`'s flag/positional-binding logic was duplicated by a
3861    /// reduced sync twin (`scheduler::pipeline::build_tool_args`, used by
3862    /// scatter/gather's own option parsing and the `#[cfg(test)]`
3863    /// `BackendDispatcher`) that could — and did — drift from this method,
3864    /// the same drift-class GH #133 fixed for the external-command spawn
3865    /// sites. Now both paths call the one `bind_tool_args` core, differing
3866    /// only in which `ArgValueSource` they hand it.
3867    async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3868        bind_tool_args(args, schema, self).await
3869    }
3870
3871    /// Build arguments as flat string list for external commands.
3872    ///
3873    /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3874    /// this preserves the original flag format as strings for external commands:
3875    /// - `-l` stays as `-l`
3876    /// - `--verbose` stays as `--verbose`
3877    /// - `key=value` stays as `key=value`
3878    ///
3879    /// This is what external commands expect in their argv.
3880    #[cfg(feature = "subprocess")]
3881    async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3882        let mut argv = Vec::new();
3883        let home = self.scope_home().await;
3884        for arg in args {
3885            match arg {
3886                Arg::Positional(expr) => {
3887                    // Glob expansion for external commands
3888                    if let Expr::GlobPattern(pattern) = expr {
3889                        let glob_enabled = {
3890                            let scope = self.scope.read().await;
3891                            scope.glob_enabled()
3892                        };
3893                        if glob_enabled {
3894                            let (paths, cwd) = {
3895                                let ctx = self.exec_ctx.read().await;
3896                                let paths = ctx.expand_glob(pattern).await
3897                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3898                                let cwd = ctx.resolve_path(".");
3899                                (paths, cwd)
3900                            };
3901                            if paths.is_empty() {
3902                                return Err(anyhow::anyhow!("no matches: {}", pattern));
3903                            }
3904                            for path in paths {
3905                                let display = if !pattern.starts_with('/') {
3906                                    path.strip_prefix(&cwd)
3907                                        .unwrap_or(&path)
3908                                        .to_string_lossy().into_owned()
3909                                } else {
3910                                    path.to_string_lossy().into_owned()
3911                                };
3912                                argv.push(display);
3913                            }
3914                            continue;
3915                        }
3916                    }
3917                    let value = self.eval_expr_async(expr).await?;
3918                    // Decision D: a bare collection can't cross the external
3919                    // process boundary as an argv element — refuse rather than
3920                    // silently JSON-serializing it. A quoted `"$x"` already
3921                    // reduced to a `Value::String` above (via `Expr::Interpolated`),
3922                    // so only a live, un-interpolated `$x` trips this.
3923                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &value) {
3924                        return Err(anyhow::anyhow!(msg));
3925                    }
3926                    let value = apply_tilde_expansion(value, home.as_deref());
3927                    // External-command argv is a text sink: a bare `$BIN` binary
3928                    // word goes loud, never the `[binary: N bytes]` placeholder.
3929                    argv.push(value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?);
3930                }
3931                Arg::Named { key, value } => {
3932                    let val = self.eval_expr_async(value).await?;
3933                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3934                        return Err(anyhow::anyhow!(msg));
3935                    }
3936                    let val = apply_tilde_expansion(val, home.as_deref());
3937                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3938                    argv.push(format!("--{key}={val_str}"));
3939                }
3940                Arg::WordAssign { key, value } => {
3941                    let val = self.eval_expr_async(value).await?;
3942                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3943                        return Err(anyhow::anyhow!(msg));
3944                    }
3945                    let val = apply_tilde_expansion(val, home.as_deref());
3946                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3947                    argv.push(format!("{key}={val_str}"));
3948                }
3949                Arg::ShortFlag(name) => {
3950                    // Preserve original format: -l, -la (combined flags)
3951                    argv.push(format!("-{}", name));
3952                }
3953                Arg::LongFlag(name) => {
3954                    // Preserve original format: --verbose
3955                    argv.push(format!("--{}", name));
3956                }
3957                Arg::DoubleDash => {
3958                    // Preserve the -- marker
3959                    argv.push("--".to_string());
3960                }
3961            }
3962        }
3963        Ok(argv)
3964    }
3965
3966    /// Async expression evaluator that supports command substitution.
3967    ///
3968    /// This is used for contexts where expressions may contain `$(...)` command
3969    /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
3970    /// Evaluate an `if`/`while` condition, folding whatever its commands print
3971    /// into `out`.
3972    ///
3973    /// A condition's stdout belongs to the enclosing statement, the same rule
3974    /// `Expr::Command` already applies to its stderr. [`Self::eval_expr_async`]
3975    /// returns only a `Value`, so those bytes had nowhere to go and
3976    /// `if echo COND; then echo BODY; fi` printed only `BODY`. Handing them
3977    /// back to the caller keeps the decision there: the `if`/`while` arm folds
3978    /// them into the statement's own result, and every consumer of that result
3979    /// — a pipe, a `$(…)` capture, a redirect — carries them without learning
3980    /// what a condition is. A shared slot on `ExecContext` would have done it
3981    /// the other way; that is the pattern GH #369 exists to remove.
3982    ///
3983    /// Only the forms that can hold a command in STATEMENT position are handled
3984    /// here. Everything else is [`Self::eval_expr_async`]'s, `$(…)` above all:
3985    /// a substitution's stdout IS its value, so folding it in as well would
3986    /// print it twice.
3987    fn eval_condition_async<'a>(
3988        &'a self,
3989        expr: &'a Expr,
3990        out: &'a mut ExecResult,
3991    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
3992        Box::pin(async move {
3993            match expr {
3994                Expr::Command(cmd) => {
3995                    let mut result = self.execute_command(&cmd.name, &cmd.args).await?;
3996                    self.emit_cmdsubst_stderr(&result.err).await;
3997                    // Truthiness comes from the command's OWN code, read before
3998                    // the spill contract can remap it. A capped `if seq 1
3999                    // 100000` succeeded; only its output was too big to keep,
4000                    // and reading the remapped 3 would send it to `else`.
4001                    let truthy = result.code == 0;
4002                    // Carrying the stdout made this arm one of the surfaces
4003                    // that produce a raw `ExecResult`, and it reaches
4004                    // `execute_command` below the pipeline layer that applies
4005                    // the contract — so it applies it here, as
4006                    // `apply_spill_contract`'s "ONE seam" note requires.
4007                    // Without this a condition handed back its full output with
4008                    // no limit at all.
4009                    let limit = self.exec_ctx.read().await.output_limit.clone();
4010                    crate::output_limit::apply_spill_contract(&mut result, &limit).await;
4011                    push_stdout_of(out, &result);
4012                    Ok(Value::Bool(truthy))
4013                }
4014                // Short-circuits exactly as the `eval_expr_async` arm does, and
4015                // yields the operand's own value rather than a coerced bool. A
4016                // side that short-circuits never runs, so it prints nothing.
4017                Expr::BinaryOp { left, op, right } => {
4018                    let left_val = self.eval_condition_async(left, &mut *out).await?;
4019                    let short_circuits = match op {
4020                        BinaryOp::And => !is_truthy(&left_val),
4021                        BinaryOp::Or => is_truthy(&left_val),
4022                    };
4023                    if short_circuits {
4024                        return Ok(left_val);
4025                    }
4026                    self.eval_condition_async(right, out).await
4027                }
4028                // The negated command still RUNS, so its output belongs to the
4029                // statement exactly as an un-negated one's does. Routing this
4030                // through `eval_expr_async` would drop it.
4031                Expr::Not(inner) => {
4032                    let value = self.eval_condition_async(inner, out).await?;
4033                    Ok(Value::Bool(!is_truthy(&value)))
4034                }
4035                other => self.eval_expr_async(other).await,
4036            }
4037        })
4038    }
4039
4040    fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
4041        Box::pin(async move {
4042        match expr {
4043            Expr::Not(inner) => {
4044                let value = self.eval_expr_async(inner).await?;
4045                Ok(Value::Bool(!is_truthy(&value)))
4046            }
4047            Expr::Literal(value) => Ok(value.clone()),
4048            Expr::VarRef(path) => {
4049                let scope = self.scope.read().await;
4050                match scope.resolve_path(path) {
4051                    Ok(v) => Ok(v),
4052                    Err(PathError::UndefinedRoot(_)) => {
4053                        Err(anyhow::anyhow!("undefined variable"))
4054                    }
4055                    Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
4056                        Err(anyhow::anyhow!(msg))
4057                    }
4058                }
4059            }
4060            Expr::Interpolated(parts) => {
4061                let mut result = String::new();
4062                for part in parts {
4063                    result.push_str(&self.eval_string_part_async(part).await?);
4064                }
4065                Ok(Value::String(result))
4066            }
4067            Expr::HereDocBody { parts, strip_tabs } => {
4068                // Assemble part-by-part so `<<-` tab stripping applies to the
4069                // literal source, not to tabs from a `$var` value (bash strips
4070                // source-line tabs before parameter expansion).
4071                let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
4072                for sp in parts {
4073                    match &sp.part {
4074                        StringPart::Literal(s) => asm.push_literal(s),
4075                        other => {
4076                            asm.push_interpolated(&self.eval_string_part_async(other).await?)
4077                        }
4078                    }
4079                }
4080                Ok(Value::String(asm.into_string()))
4081            }
4082            Expr::BinaryOp { left, op, right } => match op {
4083                BinaryOp::And => {
4084                    let left_val = self.eval_expr_async(left).await?;
4085                    if !is_truthy(&left_val) {
4086                        return Ok(left_val);
4087                    }
4088                    self.eval_expr_async(right).await
4089                }
4090                BinaryOp::Or => {
4091                    let left_val = self.eval_expr_async(left).await?;
4092                    if is_truthy(&left_val) {
4093                        return Ok(left_val);
4094                    }
4095                    self.eval_expr_async(right).await
4096                }
4097            },
4098            Expr::CommandSubst(stmts) => {
4099                // Snapshot scope, cwd, and session config before running —
4100                // only output escapes, not side effects like `cd`, variable
4101                // assignments, or config mutations (`kaish-ignore`,
4102                // `kaish-output-limit`, `alias`/`unalias`) — matching how
4103                // every other execution context (background forks, scatter
4104                // workers) already isolates mutations (GH #139).
4105                // Boxed: this ~470 B scope snapshot is held across the nested
4106                // `$(…)` recursion await below, so inlining it grows every
4107                // command-substitution level's future (GH #48, item 4).
4108                let saved_scope = Box::new(self.scope.read().await.clone());
4109                let saved_ec = {
4110                    let ec = self.exec_ctx.read().await;
4111                    (
4112                        ec.cwd.clone(),
4113                        ec.prev_cwd.clone(),
4114                        ec.aliases.clone(),
4115                        ec.ignore_config.clone(),
4116                        ec.output_limit.clone(),
4117                    )
4118                };
4119
4120                // Capture result without `?` — restore state unconditionally
4121                let run_result = self.execute_block_capturing(stmts).await;
4122
4123                // Restore scope and cwd regardless of success/failure
4124                {
4125                    let mut scope = self.scope.write().await;
4126                    *scope = *saved_scope;
4127                    if let Ok(ref r) = run_result {
4128                        scope.set_last_result(r.clone());
4129                        scope.note_cmdsubst_code(r.code);
4130                    }
4131                }
4132                {
4133                    let mut ec = self.exec_ctx.write().await;
4134                    let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4135                    ec.cwd = cwd;
4136                    ec.prev_cwd = prev_cwd;
4137                    ec.aliases = aliases;
4138                    ec.ignore_config = ignore_config;
4139                    ec.output_limit = output_limit;
4140                }
4141
4142                // A substitution's stderr belongs to the enclosing statement,
4143                // never to its value. Emit it before the value is built.
4144                if let Ok(ref r) = run_result {
4145                    self.emit_cmdsubst_stderr(&r.err).await;
4146                }
4147
4148                // Now propagate the error
4149                let result = run_result?;
4150
4151                // A held body stops the enclosing statement before its
4152                // missing output is used (spec §I.5) — the request rides up
4153                // as a typed error the statement loop converts back into a
4154                // held result, and is stashed for the boundary in case an
4155                // intermediate catch stringifies the error.
4156
4157                // A binary result is preserved as bytes — never lossy-decoded to
4158                // a string. No trailing-newline trim (every byte is significant).
4159                if let Some(bytes) = result.out_bytes() {
4160                    Ok(Value::Bytes(bytes.to_vec()))
4161                // Prefer structured data (enables `for i in $(cmd)` iteration)
4162                } else if let Some(data) = &result.data {
4163                    Ok(data.clone())
4164                } else if let Some(output) = result.output() {
4165                    // Flat non-text node lists (glob, ls, tree) → iterable array
4166                    if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
4167                        let items: Vec<serde_json::Value> = output.root.iter()
4168                            .map(|n| serde_json::Value::String(n.display_name().to_string()))
4169                            .collect();
4170                        Ok(Value::Json(serde_json::Value::Array(items)))
4171                    } else {
4172                        // Strip trailing newlines only (POSIX command-subst),
4173                        // not all trailing whitespace — spaces/tabs are
4174                        // significant. Use the exact same trim as the quoted
4175                        // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
4176                        // `trim_end_matches('\n')`) so bare and quoted command
4177                        // substitution agree.
4178                        Ok(Value::String(
4179                            result.text_out().trim_end_matches('\n').to_string(),
4180                        ))
4181                    }
4182                } else {
4183                    // Otherwise return stdout as single string (NO implicit splitting)
4184                    Ok(Value::String(
4185                        result.text_out().trim_end_matches('\n').to_string(),
4186                    ))
4187                }
4188            }
4189            Expr::Test(test_expr) => {
4190                Ok(Value::Bool(self.eval_test_async(test_expr).await?))
4191            }
4192            Expr::Positional(n) => {
4193                let scope = self.scope.read().await;
4194                match scope.get_positional(*n) {
4195                    Some(s) => Ok(Value::String(s.to_string())),
4196                    None => Ok(Value::String(String::new())),
4197                }
4198            }
4199            Expr::AllArgs => {
4200                let scope = self.scope.read().await;
4201                Ok(Value::String(scope.all_args().join(" ")))
4202            }
4203            Expr::ArgCount => {
4204                let scope = self.scope.read().await;
4205                Ok(Value::Int(scope.arg_count() as i64))
4206            }
4207            Expr::VarLength(path) => {
4208                let scope = self.scope.read().await;
4209                crate::interpreter::resolve_length(&scope, path)
4210                    .map(Value::Int)
4211                    .map_err(|msg| anyhow::anyhow!(msg))
4212            }
4213            Expr::VarWithDefault { path, default } => {
4214                // Resolve inside a scoped guard so the lock is released before the
4215                // recursive default evaluation.
4216                let resolved = {
4217                    let scope = self.scope.read().await;
4218                    crate::interpreter::resolve_default(&scope, path)
4219                        .map_err(|msg| anyhow::anyhow!(msg))?
4220                };
4221                match resolved {
4222                    Some(value) => Ok(value),
4223                    None => self.eval_string_parts_async(default).await.map(Value::String),
4224                }
4225            }
4226            Expr::Arithmetic(expr_str) => {
4227                let scope = self.scope.read().await;
4228                crate::arithmetic::eval_arithmetic(expr_str, &scope)
4229                    .map(Value::Int)
4230                    .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
4231            }
4232            Expr::Command(cmd) => {
4233                // A command in expression position — an `if`/`while`
4234                // condition, or a side of `&&`/`||` inside one. Its VALUE is
4235                // the exit code's truthiness, but its OUTPUT belongs to the
4236                // enclosing statement, exactly as `Expr::CommandSubst` says of
4237                // a substitution's stderr. Dropping the `ExecResult` here made
4238                // `if cat /nonexistent; then …` print nothing at all, so every
4239                // condition that failed for a reason failed silently.
4240                let result = self.execute_command(&cmd.name, &cmd.args).await?;
4241                self.emit_cmdsubst_stderr(&result.err).await;
4242                Ok(Value::Bool(result.code == 0))
4243            }
4244            Expr::LastExitCode => {
4245                let scope = self.scope.read().await;
4246                Ok(Value::Int(scope.last_result().code))
4247            }
4248            Expr::CurrentPid => {
4249                let scope = self.scope.read().await;
4250                Ok(Value::Int(scope.pid() as i64))
4251            }
4252            Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
4253            Expr::ListLiteral(elems) => {
4254                // Spread must itself be a list — a scalar/record spread is a
4255                // loud error, never silently coerced or dropped (mirrors the
4256                // sync `Evaluator::eval_list_literal`; wording shared via
4257                // `spread_non_list_message` so the two paths can't diverge).
4258                let mut out = Vec::with_capacity(elems.len());
4259                for elem in elems {
4260                    match elem {
4261                        ListElem::Item(e) => {
4262                            let value = self.eval_expr_async(e).await?;
4263                            out.push(crate::interpreter::value_to_json(&value));
4264                        }
4265                        ListElem::Spread(e) => {
4266                            let value = self.eval_expr_async(e).await?;
4267                            match value {
4268                                Value::Json(serde_json::Value::Array(items)) => out.extend(items),
4269                                other => return Err(anyhow::anyhow!(spread_non_list_message(&other))),
4270                            }
4271                        }
4272                    }
4273                }
4274                Ok(Value::Json(serde_json::Value::Array(out)))
4275            }
4276            Expr::RecordLiteral(entries) => {
4277                // Insertion order preserved (workspace serde_json has
4278                // `preserve_order`); a duplicate key keeps the last value
4279                // written, matching plain map-insert semantics.
4280                let mut map = serde_json::Map::new();
4281                for entry in entries {
4282                    let key = match &entry.key {
4283                        RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
4284                        // `{"$k": v}` resolves like any double-quoted string
4285                        // (used to silently create a literal "$k" key).
4286                        RecordKey::Interpolated(parts) => {
4287                            self.eval_string_parts_async(parts).await?
4288                        }
4289                    };
4290                    let value = self.eval_expr_async(&entry.value).await?;
4291                    map.insert(key, crate::interpreter::value_to_json(&value));
4292                }
4293                Ok(Value::Json(serde_json::Value::Object(map)))
4294            }
4295        }
4296        })
4297    }
4298
4299    /// Async helper to evaluate multiple StringParts into a single string.
4300    fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4301        Box::pin(async move {
4302            let mut result = String::new();
4303            for part in parts {
4304                result.push_str(&self.eval_string_part_async(part).await?);
4305            }
4306            Ok(result)
4307        })
4308    }
4309
4310    /// Async helper to evaluate a StringPart.
4311    /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
4312    /// through the VFS backend instead of using raw `std::path`.
4313    fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
4314        Box::pin(async move {
4315            match test_expr {
4316                TestExpr::FileTest { op, path } => {
4317                    let path_value = self.eval_expr_async(path).await?;
4318                    // Expand `~` against the session HOME before stat'ing, the
4319                    // same way argv positionals do — otherwise `[[ -f ~/x ]]`
4320                    // stats the literal `~/x` and is always false.
4321                    let home = self.scope_home().await;
4322                    let path_value = apply_tilde_expansion(path_value, home.as_deref());
4323                    // A binary `[[ -f $bin ]]` operand goes loud rather than
4324                    // silently stat'ing a file literally named
4325                    // `[binary: N bytes]` (the same path-positional guard
4326                    // builtins like `stat`/`cp` use).
4327                    let path_str = crate::interpreter::value_to_text_sink_named(&path_value, "a path")
4328                        .map_err(|e| anyhow::anyhow!("{e}"))?;
4329                    // The empty path names no file. Resolving it lands on the
4330                    // working directory, so `[[ -e "" ]]` answered true — bash
4331                    // says false, and so does every reading of "does this file
4332                    // exist". Same guard as `test`'s `file_test`; the two
4333                    // spellings of a file test must not disagree about a path,
4334                    // and `test_compound_tests` pins that they agree.
4335                    if path_str.is_empty() {
4336                        return Ok(false);
4337                    }
4338                    // Resolve against the *session* cwd, not the process cwd, so a
4339                    // relative `[[ -f rel ]]` honors `cd` and agrees with the
4340                    // VFS-aware `test` builtin (GH #101). Backend stats a raw
4341                    // relative path against the process cwd otherwise.
4342                    let (resolved, backend) = {
4343                        let ctx = self.exec_ctx.read().await;
4344                        (ctx.resolve_path(&path_str), ctx.backend.clone())
4345                    };
4346                    let entry = backend.stat(&resolved).await.ok();
4347                    Ok(match op {
4348                        FileTestOp::Exists => entry.is_some(),
4349                        FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
4350                        FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
4351                        FileTestOp::Readable => entry.as_ref().is_some_and(|e| {
4352                            e.permissions.is_none_or(|p| p & 0o444 != 0)
4353                        }),
4354                        FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
4355                            e.permissions.is_none_or(|p| p & 0o222 != 0)
4356                        }),
4357                        FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
4358                            e.permissions.is_some_and(|p| p & 0o111 != 0)
4359                        }),
4360                    })
4361                }
4362                TestExpr::StringTest { op, value } => match op {
4363                    crate::ast::StringTestOp::IsEmpty | crate::ast::StringTestOp::IsNonEmpty => {
4364                        let val = self.eval_expr_async(value).await?;
4365                        // Decision E: a collection operand is a loud Shape error
4366                        // here too — must not diverge from the sync path in
4367                        // interpreter/eval.rs (shared `scalar_test_operand_error`).
4368                        let symbol = match op {
4369                            crate::ast::StringTestOp::IsEmpty => "-z",
4370                            crate::ast::StringTestOp::IsNonEmpty => "-n",
4371                            crate::ast::StringTestOp::IsList
4372                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
4373                        };
4374                        if let Some(msg) = crate::interpreter::scalar_test_operand_error(symbol, &val) {
4375                            anyhow::bail!(msg);
4376                        }
4377                        let s = value_to_string(&val);
4378                        Ok(match op {
4379                            crate::ast::StringTestOp::IsEmpty => s.is_empty(),
4380                            crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
4381                            crate::ast::StringTestOp::IsList
4382                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
4383                        })
4384                    }
4385                    // Shape guard: propagates eval errors like -z/-n (a bare
4386                    // `$unset` is an undefined-variable error, not a silent
4387                    // false). A defined-but-wrong-shaped value is false. Must
4388                    // not diverge from the sync path in interpreter/eval.rs.
4389                    crate::ast::StringTestOp::IsList | crate::ast::StringTestOp::IsRecord => {
4390                        let val = self.eval_expr_async(value).await?;
4391                        Ok(op.matches_shape(&val))
4392                    }
4393                },
4394                TestExpr::Comparison { left, op, right } => {
4395                    // Evaluate operands async (handles $(cmd)), then compare sync
4396                    let left_val = self.eval_expr_async(left).await?;
4397                    let right_val = self.eval_expr_async(right).await?;
4398                    let resolved = TestExpr::Comparison {
4399                        left: Box::new(Expr::Literal(left_val)),
4400                        op: *op,
4401                        right: Box::new(Expr::Literal(right_val)),
4402                    };
4403                    let expr = Expr::Test(Box::new(resolved));
4404                    let mut scope = self.scope.write().await;
4405                    let value = eval_expr(&expr, &mut scope)
4406                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4407                    Ok(value_to_bool(&value))
4408                }
4409                TestExpr::And { left, right } => {
4410                    if !self.eval_test_async(left).await? {
4411                        Ok(false)
4412                    } else {
4413                        self.eval_test_async(right).await
4414                    }
4415                }
4416                TestExpr::Or { left, right } => {
4417                    if self.eval_test_async(left).await? {
4418                        Ok(true)
4419                    } else {
4420                        self.eval_test_async(right).await
4421                    }
4422                }
4423                TestExpr::Not { expr } => {
4424                    Ok(!self.eval_test_async(expr).await?)
4425                }
4426                TestExpr::In { left, right } => {
4427                    let left_val = self.eval_expr_async(left).await?;
4428                    let right_val = self.eval_expr_async(right).await?;
4429                    let resolved = TestExpr::In {
4430                        left: Box::new(Expr::Literal(left_val)),
4431                        right: Box::new(Expr::Literal(right_val)),
4432                    };
4433                    let expr = Expr::Test(Box::new(resolved));
4434                    let mut scope = self.scope.write().await;
4435                    let value = eval_expr(&expr, &mut scope)
4436                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4437                    Ok(value_to_bool(&value))
4438                }
4439                TestExpr::NotIn { left, right } => {
4440                    let left_val = self.eval_expr_async(left).await?;
4441                    let right_val = self.eval_expr_async(right).await?;
4442                    let resolved = TestExpr::NotIn {
4443                        left: Box::new(Expr::Literal(left_val)),
4444                        right: Box::new(Expr::Literal(right_val)),
4445                    };
4446                    let expr = Expr::Test(Box::new(resolved));
4447                    let mut scope = self.scope.write().await;
4448                    let value = eval_expr(&expr, &mut scope)
4449                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4450                    Ok(value_to_bool(&value))
4451                }
4452            }
4453        })
4454    }
4455
4456    fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4457        Box::pin(async move {
4458            match part {
4459                StringPart::Literal(s) => Ok(s.clone()),
4460                StringPart::Var(path) => {
4461                    let scope = self.scope.read().await;
4462                    match scope.resolve_path(path) {
4463                        // Text sink: binary goes loud, never the placeholder —
4464                        // a `b=$(cat blob)` capture holds real bytes; splicing
4465                        // `[binary: N bytes]` into "$b" would be silent loss.
4466                        Ok(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4467                        // Unset vars expand to empty; loud path errors surface.
4468                        Err(PathError::UndefinedRoot(_)) => Ok(String::new()),
4469                        Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
4470                            Err(anyhow::anyhow!(msg))
4471                        }
4472                    }
4473                }
4474                StringPart::VarWithDefault { path, default } => {
4475                    let resolved = {
4476                        let scope = self.scope.read().await;
4477                        crate::interpreter::resolve_default(&scope, path)
4478                            .map_err(|msg| anyhow::anyhow!(msg))?
4479                    };
4480                    match resolved {
4481                        Some(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4482                        None => self.eval_string_parts_async(default).await,
4483                    }
4484                }
4485            StringPart::VarLength(path) => {
4486                let scope = self.scope.read().await;
4487                crate::interpreter::resolve_length(&scope, path)
4488                    .map(|n| n.to_string())
4489                    .map_err(|msg| anyhow::anyhow!(msg))
4490            }
4491            StringPart::Positional(n) => {
4492                let scope = self.scope.read().await;
4493                match scope.get_positional(*n) {
4494                    Some(s) => Ok(s.to_string()),
4495                    None => Ok(String::new()),
4496                }
4497            }
4498            StringPart::AllArgs => {
4499                let scope = self.scope.read().await;
4500                Ok(scope.all_args().join(" "))
4501            }
4502            StringPart::ArgCount => {
4503                let scope = self.scope.read().await;
4504                Ok(scope.arg_count().to_string())
4505            }
4506            StringPart::Arithmetic(expr) => {
4507                // Loud on purpose (GH #183): this used to be `Err(_) =>
4508                // Ok(String::new())`, silently splicing in "" for e.g.
4509                // `"$((1/0))"` — `echo "value: $((1/0))"` printed "value: "
4510                // at exit 0 instead of failing. Matches the bare (non-string)
4511                // `Expr::Arithmetic` arm above, which already propagates.
4512                let scope = self.scope.read().await;
4513                crate::arithmetic::eval_arithmetic(expr, &scope)
4514                    .map(|value| value.to_string())
4515                    .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
4516            }
4517            StringPart::CommandSubst(stmts) => {
4518                // Snapshot scope, cwd, and session config — command
4519                // substitution in strings must not leak side effects (e.g.,
4520                // `"dir: $(cd /; pwd)"` must not change cwd, and
4521                // `"$(kaish-ignore clear)"` must not change the session's
4522                // ignore config) — matching how every other execution
4523                // context (background forks, scatter workers) already
4524                // isolates mutations (GH #139).
4525                // Boxed: this ~470 B scope snapshot is held across the nested
4526                // `$(…)` recursion await below, so inlining it grows every
4527                // command-substitution level's future (GH #48, item 4).
4528                let saved_scope = Box::new(self.scope.read().await.clone());
4529                let saved_ec = {
4530                    let ec = self.exec_ctx.read().await;
4531                    (
4532                        ec.cwd.clone(),
4533                        ec.prev_cwd.clone(),
4534                        ec.aliases.clone(),
4535                        ec.ignore_config.clone(),
4536                        ec.output_limit.clone(),
4537                    )
4538                };
4539
4540                // Capture result without `?` — restore state unconditionally
4541                let run_result = self.execute_block_capturing(stmts).await;
4542
4543                // Restore scope and cwd regardless of success/failure
4544                {
4545                    let mut scope = self.scope.write().await;
4546                    *scope = *saved_scope;
4547                    if let Ok(ref r) = run_result {
4548                        scope.set_last_result(r.clone());
4549                        scope.note_cmdsubst_code(r.code);
4550                    }
4551                }
4552                {
4553                    let mut ec = self.exec_ctx.write().await;
4554                    let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4555                    ec.cwd = cwd;
4556                    ec.prev_cwd = prev_cwd;
4557                    ec.aliases = aliases;
4558                    ec.ignore_config = ignore_config;
4559                    ec.output_limit = output_limit;
4560                }
4561
4562                // A substitution's stderr belongs to the enclosing statement,
4563                // never to its value. Emit it before the value is built.
4564                if let Ok(ref r) = run_result {
4565                    self.emit_cmdsubst_stderr(&r.err).await;
4566                }
4567
4568                // Now propagate the error
4569                let result = run_result?;
4570
4571                // A held body stops the enclosing statement before its
4572                // missing output is spliced in (spec §I.5) — same conversion
4573                // and stash as the bare `$(…)` arm.
4574
4575                // Embedding binary into a string is a text context: fail loud
4576                // rather than splice in U+FFFD garbage.
4577                match result.try_text_out() {
4578                    // Text wins when present — unchanged behavior.
4579                    Ok(s) if !s.is_empty() => Ok(s.trim_end_matches('\n').to_string()),
4580                    // `.out` is empty: a builtin/tool that set only structured
4581                    // `.data` must not silently evaporate to "" (SILENT DATA
4582                    // LOSS). Render it the same way a bare `"$x"`
4583                    // collection-valued variable renders — compact JSON for
4584                    // lists/records, plain form for scalars — by reusing
4585                    // `value_to_string` (the exact `StringPart::Var` helper
4586                    // above) so `"$(cmd)"` and `x=$(cmd); "$x"` display
4587                    // identically. No trailing-newline trim here: that's a
4588                    // text-path artifact, not applicable to a freshly
4589                    // rendered JSON/scalar string.
4590                    Ok(_) => match &result.data {
4591                        Some(data) => Ok(value_to_string(data)),
4592                        None => Ok(String::new()),
4593                    },
4594                    Err(e) => anyhow::bail!(
4595                        "command substitution in a string produced binary data ({e}) — \
4596                         pipe through base64/xxd"
4597                    ),
4598                }
4599            }
4600            StringPart::LastExitCode => {
4601                let scope = self.scope.read().await;
4602                Ok(scope.last_result().code.to_string())
4603            }
4604            StringPart::CurrentPid => {
4605                let scope = self.scope.read().await;
4606                Ok(scope.pid().to_string())
4607            }
4608        }
4609        })
4610    }
4611
4612    /// Update the last result in scope.
4613    async fn update_last_result(&self, result: &ExecResult) {
4614        let mut scope = self.scope.write().await;
4615        scope.set_last_result(result.clone());
4616    }
4617
4618    /// Drain accumulated pipeline stderr into a result.
4619    ///
4620    /// Called after each sub-statement inside control structures (`if`, `for`,
4621    /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
4622    /// than batching until the entire structure finishes.
4623    async fn drain_stderr_into(&self, result: &mut ExecResult) {
4624        let drained = {
4625            let mut receiver = self.stderr_receiver.lock().await;
4626            receiver.drain_lossy()
4627        };
4628        if !drained.is_empty() {
4629            if !result.err.is_empty() && !result.err.ends_with('\n') {
4630                result.err.push('\n');
4631            }
4632            result.err.push_str(&drained);
4633        }
4634    }
4635
4636    /// Execute a user-defined function with local variable scoping.
4637    ///
4638    /// Functions push a new scope frame for local variables. Variables declared
4639    /// with `local` are scoped to the function; other assignments modify outer
4640    /// scopes (or create in root if new).
4641    async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4642        let _depth = self.enter_recursion("a shell function")?;
4643
4644        // 1. Build function args from AST args (async to support command substitution)
4645        let tool_args = self.build_args_async(args, None).await?;
4646
4647        // 2. Push a new scope frame for local variables
4648        {
4649            let mut scope = self.scope.write().await;
4650            scope.push_frame();
4651        }
4652
4653        // 3. Save current positional parameters and set new ones for this function
4654        let saved_positional = {
4655            let mut scope = self.scope.write().await;
4656            let saved = scope.save_positional();
4657
4658            // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4659            let positional_args: Vec<String> = tool_args.positional
4660                .iter()
4661                .map(value_to_string)
4662                .collect();
4663            scope.set_positional(&def.name, positional_args);
4664
4665            saved
4666        };
4667
4668        // 3. Execute body statements with control flow handling
4669        // Accumulate output across statements (like sh)
4670        // Accumulate stdout as raw bytes so a binary-producing statement in a
4671        // function body survives instead of being lossy-decoded here.
4672        let mut accumulated_out: Vec<u8> = Vec::new();
4673        let mut accumulated_err = String::new();
4674        let mut last_code = 0i64;
4675        let mut last_data: Option<Value> = None;
4676
4677        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4678            match r.out_bytes() {
4679                Some(b) => buf.extend_from_slice(b),
4680                None => buf.extend_from_slice(r.text_out().as_bytes()),
4681            }
4682        }
4683
4684        // Track execution error for propagation after cleanup
4685        let mut exec_error: Option<anyhow::Error> = None;
4686        let mut exit_code: Option<i64> = None;
4687
4688        for stmt in &def.body {
4689            match self.execute_stmt_flow(stmt).await {
4690                Ok(flow) => {
4691                    // Drain pipeline stderr after each sub-statement.
4692                    let drained = {
4693                        let mut receiver = self.stderr_receiver.lock().await;
4694                        receiver.drain_lossy()
4695                    };
4696                    if !drained.is_empty() {
4697                        accumulated_err.push_str(&drained);
4698                    }
4699
4700                    match flow {
4701                        ControlFlow::Normal(r) => {
4702                            push_out(&mut accumulated_out, &r);
4703                            accumulated_err.push_str(&r.err);
4704                            last_code = r.code;
4705                            // A structured VIEW of printed text does not escape as the
4706                    // substitution's value — `$(cut -f2 f)` is the text `cut`
4707                    // printed, the same as `$(awk '{print $2}' f)`.
4708                    last_data = if r.data_is_value { r.data } else { None };
4709                        }
4710                        ControlFlow::Return { value } => {
4711                            push_out(&mut accumulated_out, &value);
4712                            accumulated_err.push_str(&value.err);
4713                            last_code = value.code;
4714                            last_data = if value.data_is_value { value.data } else { None };
4715                            break;
4716                        }
4717                        ControlFlow::Exit { code, result: r } => {
4718                            push_out(&mut accumulated_out, &r);
4719                            accumulated_err.push_str(&r.err);
4720                            exit_code = Some(code);
4721                            break;
4722                        }
4723                        ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4724                            push_out(&mut accumulated_out, &r);
4725                            accumulated_err.push_str(&r.err);
4726                            last_code = r.code;
4727                            last_data = if r.data_is_value { r.data } else { None };
4728                        }
4729                    }
4730                }
4731                Err(e) => {
4732                    exec_error = Some(e);
4733                    break;
4734                }
4735            }
4736        }
4737
4738        // 4. Pop scope frame and restore original positional parameters (unconditionally)
4739        {
4740            let mut scope = self.scope.write().await;
4741            scope.pop_frame();
4742            scope.set_positional(saved_positional.0, saved_positional.1);
4743        }
4744
4745        // 5. Propagate error or exit after cleanup
4746        if let Some(e) = exec_error {
4747            return Err(e);
4748        }
4749        let code = exit_code.unwrap_or(last_code);
4750        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4751        result.err = accumulated_err;
4752        // Whatever survived the gate above IS a value, so the result says so
4753        // and a further `$( )` around this one keeps it typed.
4754        result.data_is_value = last_data.is_some();
4755        result.data = last_data;
4756        Ok(result)
4757    }
4758
4759    fn enter_recursion(&self, what: &str) -> Result<RecursionGuard<'_>> {
4760        let depth = self.recursion_depth.fetch_add(1, Ordering::Relaxed) + 1;
4761        let guard = RecursionGuard { counter: &self.recursion_depth };
4762        if depth > MAX_RECURSION_DEPTH {
4763            return Err(anyhow::anyhow!(
4764                "maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded in {what} — \
4765                 a runaway or mutually recursive script (deeply nested $(…), or \
4766                 functions/scripts that call each other without a base case) was \
4767                 stopped before it could overflow the stack"
4768            ));
4769        }
4770        Ok(guard)
4771    }
4772
4773    /// Hand a finished command substitution's stderr to the kernel's stderr
4774    /// stream.
4775    ///
4776    /// bash gives `$(…)` the shell's own fd 2, so a substitution's stderr goes
4777    /// straight to the terminal and is never captured alongside its stdout.
4778    /// kaish runs the block captured, so the equivalent is to write the block's
4779    /// stderr to the same channel pipeline stages use: the enclosing
4780    /// statement's drain folds it into that statement's `err`, ahead of the
4781    /// statement's own output. `x=$(cat /nope)` kept the exit code and lost the
4782    /// reason until this existed.
4783    ///
4784    /// Nesting composes without a stack. Each level drains at its own statement
4785    /// boundary, so an inner substitution's stderr is already inside the outer
4786    /// block's result by the time this runs for the outer one — which is why it
4787    /// is written exactly once, here, rather than also accumulated by callers.
4788    /// Hand a nested command's stderr to the enclosing statement.
4789    ///
4790    /// Two callers, one rule: a command substitution's stderr is not part of
4791    /// its value, and a condition command's stderr is not part of its
4792    /// truthiness. Both belong to the statement the author wrote.
4793    async fn emit_cmdsubst_stderr(&self, err: &str) {
4794        if err.is_empty() {
4795            return;
4796        }
4797        // Terminate the chunk. Builtins are inconsistent about a trailing
4798        // newline (`cat`'s failure message has none), and two substitutions in
4799        // one statement would otherwise concatenate into a single unreadable
4800        // line: `x="$(cat /a)$(cat /b)"` produced both messages run together.
4801        // The statement drain already normalizes this boundary the same way
4802        // when it joins drained stderr to a statement's own.
4803        let terminated;
4804        let err = if err.ends_with('\n') {
4805            err
4806        } else {
4807            terminated = format!("{err}\n");
4808            &terminated
4809        };
4810        match self.exec_ctx.read().await.stderr.as_ref() {
4811            Some(stream) => stream.write_str(err),
4812            // The kernel seeds this stream in both `new` and `fork`, so it is
4813            // always present on the kernel's own context; the `Option` exists
4814            // for tool contexts built elsewhere. If it is ever absent there is
4815            // no channel to carry the bytes and no drain to collect them, which
4816            // is the same condition under which every pipeline stage's stderr
4817            // is dropped — so record it rather than failing an interactive
4818            // shell over it.
4819            None => tracing::warn!("command substitution stderr dropped: no stderr stream"),
4820        }
4821    }
4822
4823    async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4824        let _depth = self.enter_recursion("command substitution")?;
4825        // Accumulate stdout as raw bytes so a binary-producing statement
4826        // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4827        // caller can preserve it. The final result is text iff valid UTF-8.
4828        let mut accumulated_out: Vec<u8> = Vec::new();
4829        let mut accumulated_err = String::new();
4830        let mut last_code = 0i64;
4831        let mut last_data: Option<Value> = None;
4832
4833        // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4834        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4835            match r.out_bytes() {
4836                Some(b) => buf.extend_from_slice(b),
4837                None => buf.extend_from_slice(r.text_out().as_bytes()),
4838            }
4839        }
4840
4841        for stmt in stmts {
4842            let flow = self.execute_stmt_flow(stmt).await?;
4843
4844            // Drain pipeline stderr after each sub-statement (incremental, like
4845            // the control-structure and function-body executors).
4846            let drained = {
4847                let mut receiver = self.stderr_receiver.lock().await;
4848                receiver.drain_lossy()
4849            };
4850            if !drained.is_empty() {
4851                accumulated_err.push_str(&drained);
4852            }
4853
4854            match flow {
4855                ControlFlow::Normal(r)
4856                | ControlFlow::Break { result: r, .. }
4857                | ControlFlow::Continue { result: r, .. } => {
4858                    push_out(&mut accumulated_out, &r);
4859                    accumulated_err.push_str(&r.err);
4860                    last_code = r.code;
4861                    last_data = if r.data_is_value { r.data } else { None };
4862                }
4863                ControlFlow::Return { value } => {
4864                    push_out(&mut accumulated_out, &value);
4865                    accumulated_err.push_str(&value.err);
4866                    last_code = value.code;
4867                    last_data = if value.data_is_value { value.data } else { None };
4868                    break;
4869                }
4870                ControlFlow::Exit { code, result: r } => {
4871                    push_out(&mut accumulated_out, &r);
4872                    accumulated_err.push_str(&r.err);
4873                    last_code = code;
4874                    break;
4875                }
4876            }
4877        }
4878
4879        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4880        result.err = accumulated_err;
4881        // Whatever survived the gate above IS a value, so the result says so
4882        // and a further `$( )` around this one keeps it typed.
4883        result.data_is_value = last_data.is_some();
4884        result.data = last_data;
4885        Ok(result)
4886    }
4887
4888    /// Execute the `source` / `.` command to include and run a script.
4889    ///
4890    /// Unlike regular tool execution, `source` executes in the CURRENT scope,
4891    /// allowing the sourced script to set variables and modify shell state.
4892    async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
4893        // `source`/`.` is the fourth dynamic re-entry point: it runs the
4894        // sourced file's statements inline via `execute_stmt_flow`, so a file
4895        // that sources itself recurses unbounded just like a runaway function
4896        // (GH #46). It's intercepted as a special form *before* the other
4897        // guarded paths, so it needs its own guard.
4898        let _depth = self.enter_recursion("source")?;
4899
4900        // Get the file path from the first positional argument
4901        let tool_args = self.build_args_async(args, None).await?;
4902        let path = match tool_args.positional.first() {
4903            Some(Value::String(s)) => s.clone(),
4904            Some(v) => value_to_string(v),
4905            None => {
4906                return Ok(ExecResult::failure(1, "source: missing filename"));
4907            }
4908        };
4909
4910        // Resolve path relative to cwd
4911        let full_path = {
4912            let ctx = self.exec_ctx.read().await;
4913            if path.starts_with('/') {
4914                std::path::PathBuf::from(&path)
4915            } else {
4916                ctx.cwd.join(&path)
4917            }
4918        };
4919
4920        // Read file content via backend
4921        let content = {
4922            let ctx = self.exec_ctx.read().await;
4923            match ctx.backend.read(&full_path, None).await {
4924                Ok(bytes) => {
4925                    String::from_utf8(bytes).map_err(|e| {
4926                        anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
4927                    })?
4928                }
4929                Err(e) => {
4930                    return Ok(ExecResult::failure(
4931                        1,
4932                        format!("source: {}: {}", path, e),
4933                    ));
4934                }
4935            }
4936        };
4937
4938        // Parse the content
4939        let program = match crate::parser::parse(&content) {
4940            Ok(p) => p,
4941            Err(errors) => {
4942                let msg = errors
4943                    .iter()
4944                    .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
4945                    .collect::<Vec<_>>()
4946                    .join("\n");
4947                return Ok(ExecResult::failure(1, format!("source: {}", msg)));
4948            }
4949        };
4950
4951        // Execute each statement in the CURRENT scope (not isolated), accumulating
4952        // stdout/stderr across statements like `execute_user_tool` — a sourced
4953        // script's earlier statements must not be silently dropped in favor of
4954        // just the last one.
4955        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4956            match r.out_bytes() {
4957                Some(b) => buf.extend_from_slice(b),
4958                None => buf.extend_from_slice(r.text_out().as_bytes()),
4959            }
4960        }
4961
4962        let mut accumulated_out: Vec<u8> = Vec::new();
4963        let mut accumulated_err = String::new();
4964        let mut last_code = 0i64;
4965        let mut last_data: Option<Value> = None;
4966
4967        for stmt in program.statements {
4968            if matches!(stmt, crate::ast::Stmt::Empty) {
4969                continue;
4970            }
4971
4972            match self.execute_stmt_flow(&stmt).await {
4973                Ok(flow) => {
4974                    let drained = {
4975                        let mut receiver = self.stderr_receiver.lock().await;
4976                        receiver.drain_lossy()
4977                    };
4978                    if !drained.is_empty() {
4979                        accumulated_err.push_str(&drained);
4980                    }
4981                    match flow {
4982                        ControlFlow::Normal(r) => {
4983                            push_out(&mut accumulated_out, &r);
4984                            accumulated_err.push_str(&r.err);
4985                            last_code = r.code;
4986                            last_data = if r.data_is_value { r.data.clone() } else { None };
4987                            self.update_last_result(&r).await;
4988                        }
4989                        ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
4990                            return Err(anyhow::anyhow!(
4991                                "source: {}: unexpected break/continue outside loop",
4992                                path
4993                            ));
4994                        }
4995                        ControlFlow::Return { value } => {
4996                            push_out(&mut accumulated_out, &value);
4997                            accumulated_err.push_str(&value.err);
4998                            let mut result = ExecResult::success_text_or_bytes(accumulated_out)
4999                                .with_code(value.code);
5000                            result.err = accumulated_err;
5001                            result.data = value.data;
5002                            return Ok(result);
5003                        }
5004                        ControlFlow::Exit { code, result: r } => {
5005                            push_out(&mut accumulated_out, &r);
5006                            accumulated_err.push_str(&r.err);
5007                            let mut result =
5008                                ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
5009                            result.err = accumulated_err;
5010                            // Whatever survived the gate above IS a value, so the result says so
5011        // and a further `$( )` around this one keeps it typed.
5012        result.data_is_value = last_data.is_some();
5013        result.data = last_data;
5014                            return Ok(result);
5015                        }
5016                    }
5017                }
5018                Err(e) => {
5019                    return Err(e.context(format!("source: {}", path)));
5020                }
5021            }
5022        }
5023
5024        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
5025        result.err = accumulated_err;
5026        // Whatever survived the gate above IS a value, so the result says so
5027        // and a further `$( )` around this one keeps it typed.
5028        result.data_is_value = last_data.is_some();
5029        result.data = last_data;
5030        Ok(result)
5031    }
5032
5033    /// Try to execute a script from PATH directories.
5034    ///
5035    /// Searches PATH for `{name}.kai` files and executes them in isolated scope
5036    /// (like user-defined tools). Returns None if no script is found.
5037    async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
5038        // Held across the PATH probe *and* body execution: a `.kai` sourcing a
5039        // `.kai` re-enters here, and that nesting is what must be bounded (#46).
5040        // A non-script command pays only a transient, balanced increment during
5041        // the probe before falling through to the external path.
5042        let _depth = self.enter_recursion("a .kai script")?;
5043
5044        // Get PATH from scope (default to "/bin")
5045        let path_value = {
5046            let scope = self.scope.read().await;
5047            scope
5048                .get("PATH")
5049                .map(value_to_string)
5050                .unwrap_or_else(|| "/bin".to_string())
5051        };
5052
5053        // Search PATH directories for script
5054        for dir in path_value.split(':') {
5055            if dir.is_empty() {
5056                continue;
5057            }
5058
5059            // Build script path: {dir}/{name}.kai
5060            let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
5061
5062            // Check if script exists
5063            let exists = {
5064                let ctx = self.exec_ctx.read().await;
5065                ctx.backend.exists(&script_path).await
5066            };
5067
5068            if !exists {
5069                continue;
5070            }
5071
5072            // Read script content
5073            let content = {
5074                let ctx = self.exec_ctx.read().await;
5075                match ctx.backend.read(&script_path, None).await {
5076                    Ok(bytes) => match String::from_utf8(bytes) {
5077                        Ok(s) => s,
5078                        Err(e) => {
5079                            return Ok(Some(ExecResult::failure(
5080                                1,
5081                                format!("{}: invalid UTF-8: {}", script_path.display(), e),
5082                            )));
5083                        }
5084                    },
5085                    Err(e) => {
5086                        return Ok(Some(ExecResult::failure(
5087                            1,
5088                            format!("{}: {}", script_path.display(), e),
5089                        )));
5090                    }
5091                }
5092            };
5093
5094            // Parse the script
5095            let program = match crate::parser::parse(&content) {
5096                Ok(p) => p,
5097                Err(errors) => {
5098                    let msg = errors
5099                        .iter()
5100                        .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
5101                        .collect::<Vec<_>>()
5102                        .join("\n");
5103                    return Ok(Some(ExecResult::failure(1, msg)));
5104                }
5105            };
5106
5107            // Build tool_args from args (async for command substitution support)
5108            let tool_args = self.build_args_async(args, None).await?;
5109
5110            // Create isolated scope (like user tools). The trash rail and
5111            // errexit are NOT session state a script may shed: a `.kai`
5112            // script starting from a blank scope would otherwise overwrite
5113            // and delete without the recovery net `set -o trash` promised,
5114            // or run past a gating failure the caller turned errexit on for.
5115            // Carry both.
5116            let mut isolated_scope = Scope::new();
5117            {
5118                let scope = self.scope.read().await;
5119                isolated_scope.set_pid(scope.pid());
5120                isolated_scope.set_trash_enabled(scope.trash_enabled());
5121                isolated_scope.set_trash_max_size(scope.trash_max_size());
5122                isolated_scope.set_error_exit(scope.error_exit_enabled());
5123            }
5124
5125            // Set up positional parameters ($0 = script name, $1, $2, ... = args)
5126            let positional_args: Vec<String> = tool_args.positional
5127                .iter()
5128                .map(value_to_string)
5129                .collect();
5130            isolated_scope.set_positional(name, positional_args);
5131
5132            // Save current scope and swap with isolated scope
5133            let original_scope = {
5134                let mut scope = self.scope.write().await;
5135                std::mem::replace(&mut *scope, isolated_scope)
5136            };
5137
5138            // Execute script statements — accumulate stdout/stderr across
5139            // statements like `execute_user_tool`, rather than keeping only the
5140            // last one's result.
5141            fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
5142                match r.out_bytes() {
5143                    Some(b) => buf.extend_from_slice(b),
5144                    None => buf.extend_from_slice(r.text_out().as_bytes()),
5145                }
5146            }
5147
5148            let mut accumulated_out: Vec<u8> = Vec::new();
5149            let mut accumulated_err = String::new();
5150            let mut last_code = 0i64;
5151            let mut last_data: Option<Value> = None;
5152            let mut exec_error: Option<anyhow::Error> = None;
5153            let mut exit_code: Option<i64> = None;
5154
5155            for stmt in program.statements {
5156                if matches!(stmt, crate::ast::Stmt::Empty) {
5157                    continue;
5158                }
5159
5160                match self.execute_stmt_flow(&stmt).await {
5161                    Ok(flow) => {
5162                        let drained = {
5163                            let mut receiver = self.stderr_receiver.lock().await;
5164                            receiver.drain_lossy()
5165                        };
5166                        if !drained.is_empty() {
5167                            accumulated_err.push_str(&drained);
5168                        }
5169                        match flow {
5170                            ControlFlow::Normal(r) => {
5171                                push_out(&mut accumulated_out, &r);
5172                                accumulated_err.push_str(&r.err);
5173                                last_code = r.code;
5174                                last_data = if r.data_is_value { r.data } else { None };
5175                            }
5176                            ControlFlow::Return { value } => {
5177                                push_out(&mut accumulated_out, &value);
5178                                accumulated_err.push_str(&value.err);
5179                                last_code = value.code;
5180                                last_data = if value.data_is_value { value.data } else { None };
5181                                break;
5182                            }
5183                            ControlFlow::Exit { code, result: r } => {
5184                                push_out(&mut accumulated_out, &r);
5185                                accumulated_err.push_str(&r.err);
5186                                exit_code = Some(code);
5187                                break;
5188                            }
5189                            ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
5190                                push_out(&mut accumulated_out, &r);
5191                                accumulated_err.push_str(&r.err);
5192                                last_code = r.code;
5193                                last_data = if r.data_is_value { r.data } else { None };
5194                            }
5195                        }
5196                    }
5197                    Err(e) => {
5198                        exec_error = Some(e);
5199                        break;
5200                    }
5201                }
5202            }
5203
5204            // Restore original scope unconditionally
5205            {
5206                let mut scope = self.scope.write().await;
5207                *scope = original_scope;
5208            }
5209
5210            // Propagate error or exit after cleanup
5211            if let Some(e) = exec_error {
5212                return Err(e.context(format!("script: {}", script_path.display())));
5213            }
5214            let code = exit_code.unwrap_or(last_code);
5215            let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
5216            result.err = accumulated_err;
5217            // Whatever survived the gate above IS a value, so the result says so
5218        // and a further `$( )` around this one keeps it typed.
5219        result.data_is_value = last_data.is_some();
5220        result.data = last_data;
5221            return Ok(Some(result));
5222        }
5223
5224        // No script found
5225        Ok(None)
5226    }
5227
5228    /// Try to execute an external command from PATH.
5229    ///
5230    /// This is the fallback when no builtin or user-defined tool matches.
5231    /// External commands receive a clean argv (flags preserved in their original format).
5232    ///
5233    /// # Requirements
5234    /// - Command must be found in PATH
5235    /// - Current working directory must be on a real filesystem (not virtual like /v)
5236    ///
5237    /// # Returns
5238    /// - [`ExternalCommandOutcome::Ran`] if a command was resolved and run (any exit code)
5239    /// - [`ExternalCommandOutcome::NotFound`] if nothing on PATH matches — the
5240    ///   caller should still try a backend-registered tool of the same name
5241    /// - [`ExternalCommandOutcome::Unavailable`] if kaish will not attempt a
5242    ///   PATH lookup or spawn at all; a backend tool of the same name is a
5243    ///   separate capability and is still tried by the caller
5244    /// - `Err` on execution errors
5245    #[cfg(not(feature = "subprocess"))]
5246    async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<ExternalCommandOutcome> {
5247        Ok(ExternalCommandOutcome::Unavailable(ExternalCommandsUnavailable::NotCompiled))
5248    }
5249
5250    /// Try to execute an external command from PATH.
5251    #[cfg(feature = "subprocess")]
5252    async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<ExternalCommandOutcome> {
5253        if !self.allow_external_commands {
5254            return Ok(ExternalCommandOutcome::Unavailable(ExternalCommandsUnavailable::ConfiguredOff));
5255        }
5256        Ok(match Box::pin(self.try_execute_external_on_path(name, args)).await? {
5257            Some(result) => ExternalCommandOutcome::Ran(Box::new(result)),
5258            None => ExternalCommandOutcome::NotFound,
5259        })
5260    }
5261
5262    /// The actual PATH lookup + spawn, once the caller has confirmed external
5263    /// commands are allowed at all. Unchanged from before the disabled/
5264    /// not-compiled cases were split out — still `Option`-shaped: `None`
5265    /// means "bare name, nothing on PATH", the one case where the caller
5266    /// should keep looking elsewhere.
5267    #[cfg(feature = "subprocess")]
5268    #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
5269    async fn try_execute_external_on_path(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
5270        // Read the cancel token from `self.exec_ctx`, which `dispatch_command`
5271        // populates from the inbound ctx.cancel on every dispatch. This is
5272        // what makes the `timeout` builtin's swapped child token reach the
5273        // wait_or_kill discipline below — reading `self.cancel_token` would
5274        // give the kernel-wide token and miss the timeout's child cascade.
5275        let cancel = {
5276            let ec = self.exec_ctx.read().await;
5277            ec.cancel.clone()
5278        };
5279        let kill_grace = self.kill_grace;
5280
5281        // Get the shell's cwd and its real filesystem location, if any. A
5282        // `None` real path means the cwd is virtual (a CoW overlay, an
5283        // in-memory VFS mount, `/dev`, …) — there's nowhere for a child OS
5284        // process to run. Don't bail out here: a bare command name that isn't
5285        // in PATH at all is a genuine "not found" regardless of cwd, and the
5286        // virtual-cwd error would blame the wrong thing for that case. Once
5287        // the command actually resolves, `real_cwd` is checked again below
5288        // and the honest reason is given then (issue #181).
5289        let (cwd, real_cwd) = {
5290            let ctx = self.exec_ctx.read().await;
5291            (ctx.cwd.clone(), ctx.backend.resolve_real_path(&ctx.cwd))
5292        };
5293
5294        let executable = if name.contains('/') {
5295            // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
5296            let resolved = if std::path::Path::new(name).is_absolute() {
5297                std::path::PathBuf::from(name)
5298            } else {
5299                match &real_cwd {
5300                    Some(real_cwd) => real_cwd.join(name),
5301                    // A relative path can't be resolved without a real cwd to
5302                    // join against, so we can't even tell whether it would
5303                    // exist — name the actual blocker instead of a
5304                    // misleading "No such file or directory".
5305                    None => return Ok(Some(virtual_cwd_error(name, &cwd))),
5306                }
5307            };
5308            if !resolved.exists() {
5309                return Ok(Some(ExecResult::failure(
5310                    127,
5311                    format!("{}: No such file or directory", name),
5312                )));
5313            }
5314            if !resolved.is_file() {
5315                return Ok(Some(ExecResult::failure(
5316                    126,
5317                    format!("{}: Is a directory", name),
5318                )));
5319            }
5320            #[cfg(unix)]
5321            {
5322                use std::os::unix::fs::PermissionsExt;
5323                let mode = std::fs::metadata(&resolved)
5324                    .map(|m| m.permissions().mode())
5325                    .unwrap_or(0);
5326                if mode & 0o111 == 0 {
5327                    return Ok(Some(ExecResult::failure(
5328                        126,
5329                        format!("{}: Permission denied", name),
5330                    )));
5331                }
5332            }
5333            resolved.to_string_lossy().into_owned()
5334        } else {
5335            // Get PATH from scope only. The kernel never reads OS env: a
5336            // frontend that wants host PATH seeds it via initial_vars (the REPL
5337            // does, with os_env_vars()). No PATH in scope → nothing resolves.
5338            let path_var = {
5339                let scope = self.scope.read().await;
5340                scope.get("PATH").map(value_to_string).unwrap_or_default()
5341            };
5342
5343            // Resolve command in PATH
5344            match resolve_in_path(name, &path_var) {
5345                Some(path) => path,
5346                None => return Ok(None), // Not found - let caller handle error
5347            }
5348        };
5349
5350        // The executable resolved — found in PATH, or a path that exists and
5351        // is executable — but there's still nowhere to run it without a real
5352        // cwd to spawn the child process in.
5353        let real_cwd = match real_cwd {
5354            Some(p) => p,
5355            None => return Ok(Some(virtual_cwd_error(name, &cwd))),
5356        };
5357
5358        tracing::debug!(executable = %executable, "resolved external command");
5359
5360        // Build flat argv (preserves flag format)
5361        let argv = self.build_args_flat(args).await?;
5362
5363        // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
5364        // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
5365        // byte vector. Take both out under the lock but do NOT drain here — a
5366        // pipe read can block on its producer (a still-running upstream stage),
5367        // so draining before spawn would serialize the pipeline (deadlocking
5368        // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
5369        // `set_stdin` clears `pipe_stdin`, so a redirect-set buffer and a pipe
5370        // are mutually exclusive in practice; prefer the pipe.
5371        let (pipe_stdin, stdin_bytes) = {
5372            let mut ctx = self.exec_ctx.write().await;
5373            (ctx.pipe_stdin.take(), ctx.take_stdin())
5374        };
5375        let has_stdin = pipe_stdin.is_some() || stdin_bytes.is_some();
5376
5377        // Build and spawn the command
5378        use tokio::process::Command;
5379
5380        let mut cmd = Command::new(&executable);
5381        cmd.args(&argv);
5382        cmd.current_dir(&real_cwd);
5383
5384        // Hermetic env: child sees only kaish's exported vars, not the kaish
5385        // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
5386        // populate it via KernelConfig::initial_vars at construction.
5387        cmd.env_clear();
5388        {
5389            let scope = self.scope.read().await;
5390            let exported = scope.exported_vars();
5391            // A structured value can't cross the process boundary; refuse rather
5392            // than silently JSON-serialize it into the child's environment.
5393            if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
5394                return Err(anyhow::anyhow!(msg));
5395            }
5396            for (var_name, value) in exported {
5397                // Binary can't cross the process boundary as an env var value
5398                // either — loud, not the `[binary: N bytes]` placeholder
5399                // silently exported in its place (kept in sync with
5400                // dispatch.rs::try_external and env.rs::execute_with_env).
5401                let value_str = crate::interpreter::value_to_text_sink_named(
5402                    &value,
5403                    "an exported environment variable value",
5404                )
5405                .map_err(|e| anyhow::anyhow!("{e}"))?;
5406                cmd.env(var_name, value_str);
5407            }
5408        }
5409
5410        // Handle stdin
5411        cmd.stdin(if has_stdin {
5412            std::process::Stdio::piped()
5413        } else if self.interactive {
5414            std::process::Stdio::inherit()
5415        } else {
5416            std::process::Stdio::null()
5417        });
5418
5419        // In interactive mode, standalone or last-in-pipeline commands inherit
5420        // the terminal's stdout/stderr so output streams in real-time.
5421        // First/middle commands must capture stdout for the pipe — same as bash.
5422        let pipeline_position = {
5423            let ctx = self.exec_ctx.read().await;
5424            ctx.pipeline_position
5425        };
5426        let inherit_output = self.interactive
5427            && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
5428
5429        if inherit_output {
5430            cmd.stdout(std::process::Stdio::inherit());
5431            cmd.stderr(std::process::Stdio::inherit());
5432        } else {
5433            cmd.stdout(std::process::Stdio::piped());
5434            cmd.stderr(std::process::Stdio::piped());
5435        }
5436
5437        // On Unix, always put the child in its own process group so cancellation
5438        // can `killpg` the whole tree (the child plus any grandchildren).
5439        // Restoring default tty-related signal handlers stays gated on
5440        // job-control mode — those only matter when the child has a controlling
5441        // terminal.
5442        #[cfg(unix)]
5443        {
5444            let restore_jc_signals = self.terminal_state.is_some() && inherit_output;
5445            // Read before the fork: the child compares `getppid()` against it to
5446            // catch a parent that died inside the fork/prctl window.
5447            let kill_on_parent_death = {
5448                let ec = self.exec_ctx.read().await;
5449                ec.kill_children_on_parent_death
5450            };
5451            let parent_pid = std::process::id();
5452            // SAFETY: setpgid, prctl, getppid, and sigaction(SIG_DFL) are all
5453            // async-signal-safe per POSIX; safe to call between fork and exec.
5454            #[allow(unsafe_code)]
5455            unsafe {
5456                cmd.pre_exec(move || {
5457                    // Own process group — for kill scope.
5458                    nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
5459                        .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
5460                    if kill_on_parent_death {
5461                        crate::dispatch::arm_parent_death_signal(parent_pid)?;
5462                    }
5463                    if restore_jc_signals {
5464                        use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
5465                        let mut sa: nix::libc::sigaction = std::mem::zeroed();
5466                        sa.sa_sigaction = SIG_DFL;
5467                        if sigaction(SIGTSTP, &sa, std::ptr::null_mut()) != 0 {
5468                            return Err(std::io::Error::last_os_error());
5469                        }
5470                        if sigaction(SIGTTOU, &sa, std::ptr::null_mut()) != 0 {
5471                            return Err(std::io::Error::last_os_error());
5472                        }
5473                        if sigaction(SIGTTIN, &sa, std::ptr::null_mut()) != 0 {
5474                            return Err(std::io::Error::last_os_error());
5475                        }
5476                        if sigaction(SIGINT, &sa, std::ptr::null_mut()) != 0 {
5477                            return Err(std::io::Error::last_os_error());
5478                        }
5479                    }
5480                    Ok(())
5481                });
5482            }
5483        }
5484
5485        // Backstop for kill on drop in case our explicit kill path is bypassed
5486        // (panic, early return, etc) on the **capture** wait path. We do NOT
5487        // set this on the JC inherit path: that uses sync `waitpid` outside
5488        // tokio's view of the child, so on drop tokio would try to kill an
5489        // already-reaped (possibly-reused) PID. The JC path has its own
5490        // cancel handling via the side-task watcher.
5491        let in_jc_inherit_path = inherit_output && self.terminal_state.is_some();
5492        if !in_jc_inherit_path {
5493            cmd.kill_on_drop(true);
5494        }
5495
5496        // Spawn the process. Capture a `KillTarget` immediately so cancel/
5497        // timeout paths can deliver signals via pidfd (Linux ≥ 5.3) — bound
5498        // to this process's generation, immune to PID reuse if the OS reaps
5499        // the child before our kill syscalls fire.
5500        let mut child = match cmd.spawn() {
5501            Ok(child) => child,
5502            Err(e) => {
5503                return Ok(Some(ExecResult::failure(
5504                    127,
5505                    format!("{}: {}", name, e),
5506                )));
5507            }
5508        };
5509        let kill_target = crate::pidfd::KillTarget::from_child(&child);
5510
5511        // If this external runs on behalf of a background job, record its
5512        // process group on the job so `kill -<sig> %N` can signal the real
5513        // process directly (STOP/CONT/USR1/…, not just terminate). The child
5514        // did `setpgid(0, 0)` in pre_exec, so its PGID equals its PID.
5515        if let Some(job_id) = self.bg_job_id
5516            && let Some(pid) = child.id()
5517        {
5518            self.jobs.add_pgid(job_id, pid).await;
5519        }
5520
5521        // Same seam, for output: a background job's streams outlive this one
5522        // command, so the drain tasks below tee into them and the job closes
5523        // them itself. This is what makes `/v/jobs/{id}/stdout` grow while a
5524        // `cargo build &` is still building (GH #240 removed the node rather
5525        // than wire this tee; the tee is the half that was missing).
5526        let job_streams = match self.bg_job_id {
5527            Some(job_id) => self.jobs.streams(job_id).await,
5528            None => None,
5529        };
5530
5531        // Feed stdin. A streaming `pipe_stdin` is copied to the child by a
5532        // detached task (bounded memory, no pre-drain) so an upstream stage and
5533        // this child run concurrently — and a child that never reads stdin (or
5534        // is killed) just breaks the copy, which stops. A buffered byte vector
5535        // is written verbatim (no text detour), so binary stdin survives.
5536        let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = pipe_stdin {
5537            child.stdin.take().map(|mut child_stdin| {
5538                // A buffered prefix and a live pipe are one stream, not two
5539                // candidates. After `read x`, the bytes `read` over-read sit in
5540                // the buffer and the rest is still in the pipe; picking the pipe
5541                // and dropping the buffer would silently skip the front of the
5542                // child's input.
5543                let prefix = stdin_bytes;
5544                tokio::spawn(async move {
5545                    use tokio::io::{AsyncReadExt, AsyncWriteExt};
5546                    if let Some(data) = prefix
5547                        && child_stdin.write_all(&data).await.is_err()
5548                    {
5549                        return; // child closed stdin; dropping it signals EOF
5550                    }
5551                    let mut buf = [0u8; 8192];
5552                    loop {
5553                        match pipe_in.read(&mut buf).await {
5554                            Ok(0) => break, // EOF
5555                            Ok(n) => {
5556                                if child_stdin.write_all(&buf[..n]).await.is_err() {
5557                                    break; // child closed stdin
5558                                }
5559                            }
5560                            Err(_) => break,
5561                        }
5562                    }
5563                    // Dropping child_stdin signals EOF to the child.
5564                })
5565            })
5566        } else if let Some(data) = stdin_bytes {
5567            // Write the buffered bytes from a detached task too — NOT inline.
5568            // An inline write blocks once the stdin pipe fills, and the output
5569            // drain hasn't spawned yet, so a child that emits a lot before
5570            // consuming all its input (every pipe buffer full) deadlocks. A
5571            // write error here is normal, not a failure: a child that closes
5572            // stdin early (e.g. `head`) breaks the pipe. Dropping child_stdin
5573            // signals EOF.
5574            child.stdin.take().map(|mut child_stdin| {
5575                tokio::spawn(async move {
5576                    use tokio::io::AsyncWriteExt;
5577                    let _ = child_stdin.write_all(&data).await;
5578                })
5579            })
5580        } else {
5581            None
5582        };
5583
5584        // Abort the stdin-copy task on EVERY exit path (the capture path, both
5585        // interactive `inherit_output` returns, and any early error return).
5586        // Once the child is reaped the copy has nothing left to deliver; if it
5587        // were left parked on `pipe_in.read()` it would leak and hold the
5588        // upstream pipe reader open. A drop guard is the single place that
5589        // covers all returns — explicit per-return aborts were error-prone (an
5590        // earlier version missed the two inherit_output returns).
5591        struct AbortStdinCopyOnDrop(Option<tokio::task::JoinHandle<()>>);
5592        impl Drop for AbortStdinCopyOnDrop {
5593            fn drop(&mut self) {
5594                if let Some(t) = self.0.take() {
5595                    t.abort();
5596                }
5597            }
5598        }
5599        let _stdin_copy_guard = AbortStdinCopyOnDrop(stdin_task);
5600
5601        if inherit_output {
5602            // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
5603            #[cfg(unix)]
5604            if let Some(ref term) = self.terminal_state {
5605                let child_id = child.id().unwrap_or(0);
5606                let pid = nix::unistd::Pid::from_raw(child_id as i32);
5607                let pgid = pid; // child is its own pgid leader
5608
5609                // Give the terminal to the child's process group
5610                if let Err(e) = term.give_terminal_to(pgid) {
5611                    tracing::warn!("failed to give terminal to child: {}", e);
5612                }
5613
5614                let term_clone = term.clone();
5615                let cmd_name = name.to_string();
5616                let cmd_display = format!("{} {}", name, argv.join(" "));
5617                let jobs = self.jobs.clone();
5618
5619                // Side task that watches for cancellation while the blocking
5620                // waitpid runs. On cancel, it SIGTERMs the process group, waits
5621                // the grace period, then SIGKILLs. The blocking waitpid returns
5622                // when the child dies. AbortOnDrop guard cancels the watcher
5623                // on the success path so it doesn't keep running after wait
5624                // returns naturally.
5625                //
5626                // `wait_complete` shrinks the PID-reuse race: the watcher
5627                // checks it before each kill syscall and bails out if
5628                // wait_for_foreground has already reaped the child. This
5629                // doesn't fully eliminate the race (atomic load + kill is
5630                // not atomic with the OS reap+reuse), but narrows the window
5631                // to nanoseconds — enough to be ignorable in practice.
5632                let wait_complete = std::sync::Arc::new(
5633                    std::sync::atomic::AtomicBool::new(false)
5634                );
5635                let cancel_watcher = {
5636                    let cancel = cancel.clone();
5637                    let wc = wait_complete.clone();
5638                    // Ownership transfer: the JC path's sync wait inside
5639                    // block_in_place owns the child's reaping, so the
5640                    // cancel_watcher drives the kill side via KillTarget
5641                    // (pidfd-bound on Linux). When kill_target is None
5642                    // (older kernel + open failure, or non-Linux), falls
5643                    // through to the older PID-based path the closure
5644                    // captures from `pid`.
5645                    let target = kill_target.as_ref().map(|t| {
5646                        // Re-borrow the components we need into Owned-ish form
5647                        // so the spawned task is 'static. We can't move
5648                        // KillTarget directly because try_execute_external
5649                        // still uses it after the spawn — but on the JC path
5650                        // there is no further use after the watcher spawn,
5651                        // so a clone-of-pid + owned None pidfd is safe.
5652                        // Simpler: signal via the existing target by cloning
5653                        // a fresh pidfd; the original keeps its handle.
5654                        // Pidfd is just an OwnedFd — not Clone — so do it
5655                        // by re-opening from the pid. Fall back if reopen
5656                        // fails (race already reaped → best-effort kill).
5657                        crate::pidfd::KillTarget::from_pid(t.pid())
5658                    });
5659                    tokio::spawn(async move {
5660                        cancel.cancelled().await;
5661                        if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5662                        use nix::sys::signal::Signal;
5663                        if let Some(t) = &target {
5664                            t.signal(Signal::SIGTERM);
5665                            t.signal_pg(Signal::SIGTERM);
5666                        } else {
5667                            let _ = nix::sys::signal::kill(pid, Signal::SIGTERM);
5668                            let _ = nix::sys::signal::killpg(pid, Signal::SIGTERM);
5669                        }
5670                        if kill_grace > Duration::ZERO {
5671                            tokio::time::sleep(kill_grace).await;
5672                            if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5673                        }
5674                        if let Some(t) = &target {
5675                            t.signal(Signal::SIGKILL);
5676                            t.signal_pg(Signal::SIGKILL);
5677                        } else {
5678                            let _ = nix::sys::signal::kill(pid, Signal::SIGKILL);
5679                            let _ = nix::sys::signal::killpg(pid, Signal::SIGKILL);
5680                        }
5681                    })
5682                };
5683                struct AbortOnDrop(tokio::task::JoinHandle<()>);
5684                impl Drop for AbortOnDrop {
5685                    fn drop(&mut self) {
5686                        self.0.abort();
5687                    }
5688                }
5689                let _watcher_guard = AbortOnDrop(cancel_watcher);
5690
5691                let wait_complete_setter = wait_complete.clone();
5692                let code = tokio::task::block_in_place(move || {
5693                    let result = term_clone.wait_for_foreground(pid);
5694                    // Mark wait done before the watcher might fire.
5695                    wait_complete_setter.store(true, std::sync::atomic::Ordering::SeqCst);
5696
5697                    // Always reclaim the terminal
5698                    if let Err(e) = term_clone.reclaim_terminal() {
5699                        tracing::warn!("failed to reclaim terminal: {}", e);
5700                    }
5701
5702                    match result {
5703                        crate::terminal::WaitResult::Exited(code) => code as i64,
5704                        crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
5705                        crate::terminal::WaitResult::Stopped(_sig) => {
5706                            // Register as a stopped job
5707                            let rt = tokio::runtime::Handle::current();
5708                            let job_id = rt.block_on(jobs.register_stopped(
5709                                cmd_display,
5710                                child_id,
5711                                child_id, // pgid = pid for group leader
5712                            ));
5713                            eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
5714                            148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
5715                        }
5716                    }
5717                });
5718
5719                return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
5720            }
5721
5722            // Non-job-control path with inherited stdio.
5723            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5724                Ok(s) => s,
5725                Err(e) => {
5726                    return Ok(Some(ExecResult::failure(
5727                        1,
5728                        format!("{}: failed to wait: {}", name, e),
5729                    )));
5730                }
5731            };
5732
5733            let code = exit_code_from_status(&status);
5734
5735            // stdout/stderr already went to the terminal
5736            Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
5737        } else {
5738            // Capture output via bounded streams
5739            let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5740            let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5741
5742            let stdout_pipe = child.stdout.take();
5743            let stderr_pipe = child.stderr.take();
5744
5745            let stdout_clone = stdout_stream.clone();
5746            let stderr_clone = stderr_stream.clone();
5747
5748            // Only the stage whose stdout *is* the job's stdout tees: in
5749            // `a | b`, `a`'s bytes are `b`'s stdin, and teeing them would put
5750            // the pipeline's intermediate data into the node alongside its
5751            // real output. stderr has no such routing — every stage's stderr
5752            // is the job's stderr — so it tees from any position.
5753            let stdout_tee = job_streams.as_ref().and_then(|s| {
5754                matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last)
5755                    .then(|| s.stdout.clone())
5756            });
5757            let stderr_tee = job_streams.as_ref().map(|s| s.stderr.clone());
5758
5759            let stdout_task = stdout_pipe.map(|pipe| {
5760                tokio::spawn(async move {
5761                    drain_to_stream_teed(pipe, stdout_clone, stdout_tee).await;
5762                })
5763            });
5764
5765            let stderr_task = stderr_pipe.map(|pipe| {
5766                tokio::spawn(async move {
5767                    drain_to_stream_teed(pipe, stderr_clone, stderr_tee).await;
5768                })
5769            });
5770
5771            let cancelled_before_wait = cancel.is_cancelled();
5772            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5773                Ok(s) => s,
5774                Err(e) => {
5775                    // stdin-copy task is aborted by `_stdin_copy_guard` on return.
5776                    if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5777                    if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5778                    return Ok(Some(ExecResult::failure(
5779                        1,
5780                        format!("{}: failed to wait: {}", name, e),
5781                    )));
5782                }
5783            };
5784
5785            // On cancel, abort the drain tasks (the child's pipes are gone;
5786            // late output is lost but predictable death beats partial capture).
5787            // On normal exit, await drains so we don't lose buffered output.
5788            if cancelled_before_wait || cancel.is_cancelled() {
5789                if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5790                if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5791            } else {
5792                if let Some(task) = stdout_task {
5793                    // Ignore join error — the drain task logs its own errors
5794                    let _ = task.await;
5795                }
5796                if let Some(task) = stderr_task {
5797                    let _ = task.await;
5798                }
5799            }
5800
5801            let code = exit_code_from_status(&status);
5802
5803            // Read stdout as RAW bytes: text if valid UTF-8, else a Bytes
5804            // result, so `curl url`, `curl url > file.bin`, etc. keep binary
5805            // intact. stderr stays text. See docs/binary-data.md.
5806            let stdout = stdout_stream.read().await;
5807            let mut stderr = stderr_stream.read_string().await;
5808            let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
5809
5810            // Both streams are fixed-size rings regardless of `ctx.output_limit`
5811            // (that machinery only runs post-hoc, in `execute_pipeline`, and only
5812            // when enabled). With the limit disabled — the repl/embedded/test
5813            // default — an overflow here used to be silent: `write` evicted the
5814            // oldest bytes and bumped `bytes_evicted`, but nothing ever read that
5815            // counter, so a >10MB stdout reported clean success with its head
5816            // quietly gone (GH #191). Surface it loudly instead.
5817            if stderr_stream.has_overflowed().await {
5818                let stats = stderr_stream.stats().await;
5819                stderr = format!("{}{stderr}", stats.overflow_marker("stderr"));
5820            }
5821            if stdout_stream.has_overflowed().await {
5822                // The marker goes in stderr, never prepended into `result`'s
5823                // stdout payload: stdout may be binary
5824                // (`success_text_or_bytes` yields a `Bytes` result for
5825                // non-UTF-8 data — e.g. `curl` fetching a >10MB binary), and
5826                // string-formatting a marker into it would lossily reinterpret
5827                // bytes as text, introducing a SECOND, different kind of
5828                // corruption on top of the eviction itself.
5829                //
5830                // Only stdout overflow flips `did_spill` — exit-code integrity
5831                // tracks stdout, matching the enabled-limit path's contract
5832                // (stderr overflow alone doesn't remap the exit code).
5833                let stats = stdout_stream.stats().await;
5834                stderr = format!("{}{stderr}", stats.overflow_marker("stdout"));
5835                result.did_spill = true;
5836            }
5837            result.err = stderr;
5838            Ok(Some(result))
5839        }
5840    }
5841
5842    // --- Variable Access ---
5843
5844    /// Get a variable value.
5845    pub async fn get_var(&self, name: &str) -> Option<Value> {
5846        let scope = self.scope.read().await;
5847        scope.get(name).cloned()
5848    }
5849
5850    /// Check if error-exit mode is enabled (for testing).
5851    #[cfg(test)]
5852    pub async fn error_exit_enabled(&self) -> bool {
5853        let scope = self.scope.read().await;
5854        scope.error_exit_enabled()
5855    }
5856
5857    /// Set a variable value.
5858    pub async fn set_var(&self, name: &str, value: Value) {
5859        let mut scope = self.scope.write().await;
5860        scope.set(name.to_string(), value);
5861    }
5862
5863    /// Set positional parameters ($0 script name and $1-$9 args).
5864    pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
5865        let mut scope = self.scope.write().await;
5866        scope.set_positional(script_name, args);
5867    }
5868
5869    /// List all variables.
5870    pub async fn list_vars(&self) -> Vec<(String, Value)> {
5871        let scope = self.scope.read().await;
5872        scope.all()
5873    }
5874
5875    /// List exported variables (name, value), sorted by name. These are the
5876    /// vars a child process would see (see `dispatch`'s hermetic env build).
5877    pub async fn exported_vars(&self) -> Vec<(String, Value)> {
5878        let scope = self.scope.read().await;
5879        scope.exported_vars()
5880    }
5881
5882    // --- CWD ---
5883
5884    /// Get current working directory.
5885    pub async fn cwd(&self) -> PathBuf {
5886        self.exec_ctx.read().await.cwd.clone()
5887    }
5888
5889    /// Set current working directory.
5890    pub async fn set_cwd(&self, path: PathBuf) {
5891        let mut ctx = self.exec_ctx.write().await;
5892        ctx.set_cwd(path);
5893    }
5894
5895    /// Set the working directory only if `path` resolves to a directory in the
5896    /// kernel's backend — the same namespace `cd` validates against. Unlike a
5897    /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
5898    /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
5899    /// disappeared. Returns whether the cwd was changed.
5900    pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5901        // Clone the backend Arc out before the stat so we never hold the
5902        // exec_ctx lock across the await.
5903        let backend = self.exec_ctx.read().await.backend.clone();
5904        let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5905        if is_dir {
5906            self.exec_ctx.write().await.set_cwd(path);
5907        }
5908        is_dir
5909    }
5910
5911    // --- Last Result ---
5912
5913    /// Get the last result ($?).
5914    pub async fn last_result(&self) -> ExecResult {
5915        let scope = self.scope.read().await;
5916        scope.last_result().clone()
5917    }
5918
5919    // --- Tools ---
5920
5921    /// Check if a user-defined function exists.
5922    pub async fn has_function(&self, name: &str) -> bool {
5923        self.user_tools.read().await.contains_key(name)
5924    }
5925
5926    /// Get available tool schemas.
5927    pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5928        self.tools.schemas()
5929    }
5930
5931    /// Classify how the kernel will resolve a command name.
5932    ///
5933    /// This is the supported, single source of truth for command resolution that
5934    /// embedders should call instead of re-deriving the rules. Walk a parsed
5935    /// script (`kaish_kernel::parser::parse` → `Stmt::Command` nodes) and call
5936    /// this per command name to bucket each into builtin / user-function /
5937    /// special-form / dynamic / external — for example a consent gate that blocks
5938    /// a script until external commands are approved.
5939    ///
5940    /// The classification mirrors the interpreter's real resolution order
5941    /// (`execute_command_depth`): special-forms (`true`/`false`/`source`/`.`)
5942    /// short-circuit first, then **aliases are expanded** (bounded recursion,
5943    /// re-checking special-forms each step, exactly as execution does), then user
5944    /// functions (which shadow builtins), then builtins, then a `PATH` lookup. A
5945    /// name that is a variable or command-substitution expansion (`$cmd`,
5946    /// `$(pick)`, `${x}`) classifies as [`CommandKind::Dynamic`] because it can't
5947    /// be resolved statically.
5948    ///
5949    /// Aliases are resolved against the kernel's current alias table, so an
5950    /// `alias cat=/bin/something` makes `cat` classify as `External` — the same
5951    /// thing it would actually run. The safe direction of any residual imprecision
5952    /// is `External`/`Dynamic`, never a false "internal": the `/v/bin/` prefix and
5953    /// `.kai`/backend-tool resolution are reported `External` even though some of
5954    /// those resolve in-process, so a consent gate over-gates rather than letting
5955    /// a `PATH` escape slip through.
5956    pub async fn classify_command(&self, name: &str) -> CommandKind {
5957        // Resolve the command head the way `execute_command_depth` does: a
5958        // special-form short-circuits before any alias lookup, otherwise expand
5959        // aliases (bounded, recursive) and re-check from the top. A dynamic name
5960        // can't be resolved at all.
5961        let mut name = name.to_string();
5962        let mut alias_depth = 0u8;
5963        loop {
5964            if !crate::validator::is_static_command_name(&name) {
5965                return CommandKind::Dynamic;
5966            }
5967            if crate::validator::is_runtime_special_form(&name) {
5968                return CommandKind::Special;
5969            }
5970            if alias_depth >= 10 {
5971                break;
5972            }
5973            let alias_value = {
5974                let ctx = self.exec_ctx.read().await;
5975                ctx.aliases.get(&name).cloned()
5976            };
5977            // Expand to the alias's head command. An empty alias value (no head)
5978            // is ignored by execution, so resolution continues with this name.
5979            match alias_value
5980                .as_deref()
5981                .and_then(|v| v.split_whitespace().next())
5982            {
5983                Some(head) => {
5984                    name = head.to_string();
5985                    alias_depth += 1;
5986                }
5987                None => break,
5988            }
5989        }
5990
5991        let is_user_tool = self.user_tools.read().await.contains_key(&name);
5992        let is_builtin = self.tools.contains(&name);
5993        crate::validator::classify_command_name(&name, is_builtin, is_user_tool)
5994    }
5995
5996    // --- Jobs ---
5997
5998    /// Get job manager.
5999    pub fn jobs(&self) -> Arc<JobManager> {
6000        self.jobs.clone()
6001    }
6002
6003    // --- VFS ---
6004
6005    /// Get VFS router.
6006    pub fn vfs(&self) -> Arc<VfsRouter> {
6007        self.vfs.clone()
6008    }
6009
6010    // --- State ---
6011
6012    /// Reset kernel to initial state.
6013    ///
6014    /// Clears in-memory variables and resets cwd to root. History is not
6015    /// cleared (it persists across resets). The kernel's `$$` identity, the
6016    /// trash-on-delete configuration, the current errexit state, and any
6017    /// frontend-seeded `initial_vars` (HOME/PATH/etc, from `KernelConfig`)
6018    /// are re-applied to the fresh scope rather than silently reverting to
6019    /// defaults — an embedder that opted into trash or errexit must not find
6020    /// either quietly disabled after a `reset()` between requests.
6021    ///
6022    /// **Background jobs are untouched** (GH #245) — `reset()` is a scope/cwd
6023    /// reset, not a session boundary for `&`. A job started before `reset()`
6024    /// keeps running, stays in `jobs`, and the job ID counter keeps counting
6025    /// up. An embedder treating `reset()` as "new session" (a fresh MCP
6026    /// conversation reusing one kernel, say) inherits every job the previous
6027    /// conversation backgrounded — call [`Self::cancel_all_jobs`] first if
6028    /// that inheritance is not wanted.
6029    pub async fn reset(&self) -> Result<()> {
6030        {
6031            let mut scope = self.scope.write().await;
6032            let pid = scope.pid();
6033            let trash_enabled = scope.trash_enabled();
6034            let errexit_enabled = scope.error_exit_enabled();
6035            let mut fresh = Scope::new();
6036            fresh.set_pid(pid);
6037            for (name, value) in self.initial_vars.clone() {
6038                fresh.set_exported(name, value);
6039            }
6040            // The pin travels with the policy it pins — a `reset()` between
6041            // requests that dropped it would hand the next request an
6042            // unpinned session (spec §F.3 item 3).
6043            fresh.set_trash_enabled(trash_enabled);
6044            // Same reasoning as trash: an embedder relying on errexit for a
6045            // gating decision must not find it quietly disabled after a
6046            // `reset()` between requests.
6047            fresh.set_error_exit(errexit_enabled);
6048            // `reset()` puts the session back at `/`, so `$PWD` says so.
6049            // `initial_vars` can carry an inherited `PWD` from the invoking
6050            // environment, which would otherwise survive the reset and name a
6051            // directory this session is no longer in. `$OLDPWD` goes for the
6052            // same reason a fresh kernel has none: there is no previous
6053            // directory, and `cd -` refuses.
6054            fresh.set_global("PWD", Value::String("/".to_string()));
6055            fresh.remove("OLDPWD");
6056            *scope = fresh;
6057        }
6058        {
6059            let mut ctx = self.exec_ctx.write().await;
6060            ctx.cwd = PathBuf::from("/");
6061            ctx.prev_cwd = None;
6062        }
6063        Ok(())
6064    }
6065
6066    /// Trip the cancellation token of every tracked background job (`&`) —
6067    /// whether or not `shutdown` follows.
6068    ///
6069    /// This is the same lever `kill %N` uses: a *running* job's in-process
6070    /// future exits at its next checkpoint, and any external children it
6071    /// spawned get the SIGTERM→SIGKILL cascade; it then stays tracked with
6072    /// status `Killed` once it unwinds. For an already-finished job the
6073    /// token trip is a no-op — its future has already resolved and the job
6074    /// keeps reporting its terminal status. This only
6075    /// *starts* cancellation, it does not wait (pair with
6076    /// [`JobManager::wait`]/`wait_all` if the caller needs to block on the
6077    /// unwind, bounded as [`Self::shutdown`] does).
6078    ///
6079    /// A job registered by an embedder via [`JobManager::register`] with no
6080    /// cancel token attached has no lever to cancel — silently skipped here,
6081    /// same as `kill %N`'s own "no cancellation token" case.
6082    ///
6083    /// Returns how many jobs a token was actually tripped for.
6084    pub async fn cancel_all_jobs(&self) -> usize {
6085        let ids = self.jobs.list_ids().await;
6086        let mut cancelled = 0;
6087        for id in ids {
6088            if self.jobs.mark_killed_and_cancel(id, false).await {
6089                cancelled += 1;
6090            }
6091        }
6092        cancelled
6093    }
6094
6095    /// Shut down the kernel.
6096    ///
6097    /// Cancels every tracked background job ([`Self::cancel_all_jobs`]), then
6098    /// waits up to `kill_grace + 3s` **per job** — the same bound `kill %N`
6099    /// gives a single target (GH #244) — for it to actually unwind. The
6100    /// waits are sequential, so the worst case is additive: N jobs that all
6101    /// ignore cancellation block shutdown for N × (kill_grace + 3s). Jobs
6102    /// that unwind promptly (the normal case) cost only their own unwind
6103    /// time. Before this fix `shutdown` called `wait_all()` with no timeout
6104    /// at all: `sleep 3600 &` then `shutdown()` blocked for an hour (GH #245).
6105    ///
6106    /// A job that has not unwound by its deadline is abandoned: logged via
6107    /// `tracing::warn!` and left running detached until the tokio runtime
6108    /// itself goes away. There is no further lever once `shutdown()` has
6109    /// returned — this method does not hang, but it also does not guarantee
6110    /// every job actually stopped.
6111    ///
6112    /// Takes `&self`, not owned `self` — an embedder holding `Arc<Kernel>`
6113    /// (e.g. `kaish-client`'s `EmbeddedClient`) can call this without
6114    /// `Arc::try_unwrap`, since the work here only touches the shared
6115    /// `Arc<JobManager>`, never kernel state that would need exclusive
6116    /// ownership.
6117    pub async fn shutdown(&self) -> Result<()> {
6118        let ids = self.jobs.list_ids().await;
6119        self.cancel_all_jobs().await;
6120
6121        let bound = self.jobs.kill_grace() + Duration::from_secs(3);
6122        for id in ids {
6123            if tokio::time::timeout(bound, self.jobs.wait(id)).await.is_err() {
6124                tracing::warn!(
6125                    job_id = %id,
6126                    bound_secs = bound.as_secs_f64(),
6127                    "kernel shutdown: job did not exit within the grace period after \
6128                     cancellation — abandoning it"
6129                );
6130            }
6131        }
6132        Ok(())
6133    }
6134
6135    /// Run a compound statement that occupies a pipeline stage.
6136    ///
6137    /// Same ctx↔exec_ctx sync as `dispatch_command`, with one deliberate
6138    /// difference: the stage's pipe writer stays behind with the runner. The
6139    /// statement buffers — its whole output comes back in the `ExecResult` and
6140    /// the runner writes it to the pipe once. Handing the writer down instead
6141    /// would give it to whichever nested command grabbed the slot first, and
6142    /// every later iteration would write nowhere.
6143    ///
6144    /// Streaming a stage would mean threading a writer through nested
6145    /// statement execution, which is the shared-slot machinery GH #369 is
6146    /// about. Revisit once the interpreter takes a ctx parameter.
6147    async fn dispatch_statement(&self, stmt: &Stmt, ctx: &mut ExecContext) -> Result<ExecResult> {
6148        if let Some(d) = self.dispatcher() {
6149            ctx.dispatcher = Some(d);
6150        }
6151
6152        // 1. Sync ctx → self internals
6153        {
6154            let mut scope = self.scope.write().await;
6155            *scope = ctx.scope.clone();
6156        }
6157        {
6158            let mut ec = self.exec_ctx.write().await;
6159            ec.cwd = ctx.cwd.clone();
6160            ec.prev_cwd = ctx.prev_cwd.clone();
6161            ec.stdin = ctx.stdin.take();
6162            ec.stdin_data = ctx.stdin_data.take();
6163            ec.stdin_data_rx = ctx.stdin_data_rx.take();
6164            ec.pipe_stdin = ctx.pipe_stdin.take();
6165            // The writer is NOT handed over — see this function's doc comment.
6166            // Clearing the slot keeps a writer left by an earlier dispatch from
6167            // catching the first command inside the loop body.
6168            ec.pipe_stdout = None;
6169            if let Some(stderr) = ctx.stderr.clone() {
6170                ec.stderr = Some(stderr);
6171            }
6172            ec.aliases = ctx.aliases.clone();
6173            ec.ignore_config = ctx.ignore_config.clone();
6174            ec.output_limit = ctx.output_limit.clone();
6175            ec.pipeline_position = ctx.pipeline_position;
6176            ec.cancel = ctx.cancel.clone();
6177            ec.watchdog = ctx.watchdog.clone();
6178        }
6179
6180        // 2. Run the statement. A stage is its own execution unit, so a
6181        // `break`, `continue`, `return`, or `exit` that reaches the top of the
6182        // statement stops here rather than escaping into the enclosing script —
6183        // the same boundary bash draws by running each stage in a subshell.
6184        // Whatever output the statement produced before the signal still comes
6185        // back and still reaches the pipe.
6186        let result = match self.execute_stmt_flow(stmt).await? {
6187            ControlFlow::Normal(result)
6188            | ControlFlow::Break { result, .. }
6189            | ControlFlow::Continue { result, .. }
6190            | ControlFlow::Return { value: result } => result,
6191            ControlFlow::Exit { code, mut result } => {
6192                result.code = code;
6193                result
6194            }
6195        };
6196
6197        // 3. Sync self → ctx
6198        {
6199            let scope = self.scope.read().await;
6200            ctx.scope = scope.clone();
6201        }
6202        {
6203            let mut ec = self.exec_ctx.write().await;
6204            ctx.cwd = ec.cwd.clone();
6205            ctx.prev_cwd = ec.prev_cwd.clone();
6206            ctx.aliases = ec.aliases.clone();
6207            ctx.ignore_config = ec.ignore_config.clone();
6208            ctx.output_limit = ec.output_limit.clone();
6209            ctx.pipe_stdin = ec.pipe_stdin.take();
6210            ctx.stdin = ec.stdin.take();
6211            ctx.stdin_data = ec.stdin_data.take();
6212            ctx.stdin_data_rx = ec.stdin_data_rx.take();
6213        }
6214
6215        Ok(result)
6216    }
6217
6218    /// Dispatch a single command using the full resolution chain.
6219    ///
6220    /// This is the core of `CommandDispatcher` — it syncs state between the
6221    /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
6222    /// then delegates to `execute_command` for the actual dispatch.
6223    ///
6224    /// State flow:
6225    /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
6226    /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
6227    /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
6228    async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
6229        // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
6230        // its inner command via ctx.dispatcher) routes through THIS kernel,
6231        // not a stale parent. Critical for forks: the fork's builtins must
6232        // use the fork's dispatcher, not the parent's.
6233        if let Some(d) = self.dispatcher() {
6234            ctx.dispatcher = Some(d);
6235        }
6236
6237        // 1. Sync ctx → self internals
6238        {
6239            let mut scope = self.scope.write().await;
6240            *scope = ctx.scope.clone();
6241        }
6242        {
6243            let mut ec = self.exec_ctx.write().await;
6244            ec.cwd = ctx.cwd.clone();
6245            ec.prev_cwd = ctx.prev_cwd.clone();
6246            ec.stdin = ctx.stdin.take();
6247            ec.stdin_data = ctx.stdin_data.take();
6248            // The structured-data sideband receiver (set by the concurrent
6249            // pipeline runner on the stage ctx) must reach the tool's snapshot
6250            // too — same reason as the pipe endpoints below. Without this a
6251            // pipeline consumer never sees the producer's `.data`.
6252            ec.stdin_data_rx = ctx.stdin_data_rx.take();
6253            // Streaming pipe endpoints and kernel stderr must flow to the
6254            // tool via self.exec_ctx — execute_command reads that, not the
6255            // passed-in ctx. Without moving these, concurrent pipeline
6256            // stages dispatched via a fork get pipe_stdin = None and
6257            // silently read nothing.
6258            ec.pipe_stdin = ctx.pipe_stdin.take();
6259            ec.pipe_stdout = ctx.pipe_stdout.take();
6260            if let Some(stderr) = ctx.stderr.clone() {
6261                ec.stderr = Some(stderr);
6262            }
6263            ec.aliases = ctx.aliases.clone();
6264            ec.ignore_config = ctx.ignore_config.clone();
6265            ec.output_limit = ctx.output_limit.clone();
6266            ec.pipeline_position = ctx.pipeline_position;
6267            // Sync the cancel token from ctx → ec. Builtins like `timeout`
6268            // swap ctx.cancel to a derived child token before re-dispatching;
6269            // execute_command's snapshot reads ec.cancel (kept aligned by
6270            // this sync), so try_execute_external sees the right token.
6271            ec.cancel = ctx.cancel.clone();
6272            // Same alignment for the watchdog: a fork dispatching through its
6273            // own kernel must hand the shared script clock to the snapshot so
6274            // patient holds in forked stages suspend the right timer.
6275            ec.watchdog = ctx.watchdog.clone();
6276        }
6277
6278        // 2. Execute via the full dispatch chain
6279        let result = self.execute_command(&cmd.name, &cmd.args).await?;
6280
6281        // 3. Sync self → ctx
6282        {
6283            let scope = self.scope.read().await;
6284            ctx.scope = scope.clone();
6285        }
6286        {
6287            let mut ec = self.exec_ctx.write().await;
6288            ctx.cwd = ec.cwd.clone();
6289            ctx.prev_cwd = ec.prev_cwd.clone();
6290            ctx.aliases = ec.aliases.clone();
6291            ctx.ignore_config = ec.ignore_config.clone();
6292            ctx.output_limit = ec.output_limit.clone();
6293            // Return any pipe endpoints that the tool didn't consume.
6294            // `take()` here keeps the fork's exec_ctx in a clean state for
6295            // the next dispatch — these are per-command and shouldn't leak
6296            // between calls.
6297            ctx.pipe_stdin = ec.pipe_stdin.take();
6298            ctx.pipe_stdout = ec.pipe_stdout.take();
6299            // Unconsumed buffered stdin comes back the same way, and for a
6300            // sharper reason than symmetry: a partial read (`read` takes one
6301            // line) leaves its remainder in `ec`, and the caller's own
6302            // end-of-statement sync writes `ctx.stdin` back over `ec.stdin`.
6303            // Without this the caller writes its stale `None` over the
6304            // remainder and the rest of the stream is gone.
6305            ctx.stdin = ec.stdin.take();
6306            // The sideband rides home with stdin, same rule.
6307            ctx.stdin_data = ec.stdin_data.take();
6308            ctx.stdin_data_rx = ec.stdin_data_rx.take();
6309            // Same take-don't-clone discipline as stdin, and for the same
6310            // reason: these belong to exactly one dispatch, and a copy left
6311            // behind would let the next command adopt it.
6312        }
6313
6314        Ok(result)
6315    }
6316}
6317
6318/// Evaluates a single AST expression on behalf of [`bind_tool_args`], the one
6319/// shared arg-binding core behind both `Kernel::build_args_async`
6320/// (production: full recursion through the async pipeline, command
6321/// substitution, real glob expansion) and the reduced sync evaluator behind
6322/// scatter/gather's own option parsing and the `#[cfg(test)]`
6323/// `BackendDispatcher` (`scheduler::pipeline::build_tool_args`'s
6324/// `SyncEvalSource`). GH #188 closes the drift class between those two
6325/// callers: the flag/positional-binding logic (this file's `bind_tool_args`)
6326/// is now the ONLY implementation; only expression evaluation, which is
6327/// capability-bound (recursing into command substitution needs a live async
6328/// pipeline the reduced context doesn't have), still has two providers.
6329#[async_trait]
6330pub(crate) trait ArgValueSource: Send + Sync {
6331    /// Evaluate `expr` to a `Value`. `Ok(None)` means "not representable by
6332    /// this evaluator" — the reduced sync evaluator's bash-compatible
6333    /// "coalesce" convention for an unset bare variable, or an expression
6334    /// form it doesn't support (a binary op) — and the caller drops the
6335    /// argument the same way an unset bare variable always has. The real
6336    /// (Kernel) evaluator never returns `Ok(None)`: it can always fully
6337    /// evaluate.
6338    async fn eval(&self, expr: &Expr) -> Result<Option<Value>>;
6339
6340    /// Expand a bare glob-pattern positional to display strings, or `None`
6341    /// if this evaluator doesn't expand globs here (disabled, or the reduced
6342    /// sync context, which never has — matching its documented "no
6343    /// filesystem walk before worker forks" limit). `bind_tool_args` falls
6344    /// back to `eval` (which hands back the pattern text as a literal
6345    /// string) when this returns `None`. An enabled expansion that matches
6346    /// nothing is a genuine error, not `Ok(None)`.
6347    async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>>;
6348
6349    /// Session `HOME`, for tilde expansion. `None` disables tilde expansion
6350    /// — the reduced sync evaluator's existing behavior (it never expanded
6351    /// `~`).
6352    async fn home(&self) -> Option<String>;
6353}
6354
6355#[async_trait]
6356impl ArgValueSource for Kernel {
6357    async fn eval(&self, expr: &Expr) -> Result<Option<Value>> {
6358        Ok(Some(self.eval_expr_async(expr).await?))
6359    }
6360
6361    async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>> {
6362        let glob_enabled = self.scope.read().await.glob_enabled();
6363        if !glob_enabled {
6364            return Ok(None);
6365        }
6366        let (paths, cwd) = {
6367            let ctx = self.exec_ctx.read().await;
6368            let paths = ctx
6369                .expand_glob(pattern)
6370                .await
6371                .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
6372            let cwd = ctx.resolve_path(".");
6373            (paths, cwd)
6374        };
6375        if paths.is_empty() {
6376            anyhow::bail!("no matches: {}", pattern);
6377        }
6378        let display = paths
6379            .into_iter()
6380            .map(|path| {
6381                if !pattern.starts_with('/') {
6382                    path.strip_prefix(&cwd)
6383                        .unwrap_or(&path)
6384                        .to_string_lossy()
6385                        .into_owned()
6386                } else {
6387                    path.to_string_lossy().into_owned()
6388                }
6389            })
6390            .collect();
6391        Ok(Some(display))
6392    }
6393
6394    async fn home(&self) -> Option<String> {
6395        self.scope_home().await
6396    }
6397}
6398
6399/// Pull `consumes` positional args after a non-bool flag and stash them on
6400/// `tool_args.named` under the canonical param name. Shared core behind
6401/// [`bind_tool_args`]'s `ShortFlag`/`LongFlag` value-flag arms — see that
6402/// function's doc comment for the unification story (GH #188).
6403///
6404/// - `consumes == 1` (non-repeatable) keeps the historical contract: a
6405///   single scalar value (last write wins on the rare duplicate).
6406/// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
6407///   inside `named[canonical] = Value::Json(Array(...))`, preserving
6408///   invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
6409///   a repeated single-value flag must keep every value, not silently drop
6410///   all but the last (a "no silent corruption" violation).
6411/// - `consumes > 1` accumulates each occurrence as an inner
6412///   `serde_json::Value::Array` inside `named[canonical] =
6413///   Value::Json(Array(...))`, preserving invocation order. This is the
6414///   shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
6415///
6416/// Errors loudly if the flag is missing required positionals — matches
6417/// kaish's "no silent fallback" posture and mirrors real jq, which errors on
6418/// `--arg NAME` with no value. A reduced evaluator's `Ok(None)` (a value it
6419/// can't represent — Kernel's evaluator never returns this) falls back to a
6420/// bare flag on the FIRST occurrence, matching the pre-#188 sync twin's
6421/// unset-bare-var "coalesce" convention; mid-accumulation it's a genuine
6422/// error rather than a silently-partial array.
6423#[allow(clippy::too_many_arguments)]
6424async fn consume_flag_positionals(
6425    source: &dyn ArgValueSource,
6426    home: Option<&str>,
6427    args: &[Arg],
6428    flag_name: &str,
6429    canonical: &str,
6430    consumes: usize,
6431    repeatable: bool,
6432    positional_indices: &[usize],
6433    consumed: &mut std::collections::HashSet<usize>,
6434    current_idx: usize,
6435    tool_args: &mut ToolArgs,
6436) -> Result<()> {
6437    let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
6438    for _ in 0..consumes.max(1) {
6439        // A `key=value` (WordAssign) token is consumable only by a
6440        // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
6441        // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
6442        // filter` would reassemble `x=1` into the first slot and steal the
6443        // filter into the second. Multi-value flags take plain positionals.
6444        let allow_word_assign = consumes <= 1;
6445        let next_pos = positional_indices
6446            .iter()
6447            .find(|idx| {
6448                **idx > current_idx
6449                    && !consumed.contains(idx)
6450                    && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
6451            })
6452            .copied();
6453        match next_pos {
6454            Some(pos_idx) => match &args[pos_idx] {
6455                Arg::Positional(expr) => match source.eval(expr).await? {
6456                    Some(value) => {
6457                        let value = apply_tilde_expansion(value, home);
6458                        collected.push(value);
6459                        consumed.insert(pos_idx);
6460                    }
6461                    None if collected.is_empty() => {
6462                        tool_args.flags.insert(flag_name.to_string());
6463                        return Ok(());
6464                    }
6465                    None => anyhow::bail!(
6466                        "--{flag_name}: could not evaluate argument {} in this context",
6467                        collected.len() + 1
6468                    ),
6469                },
6470                // `-v a=1`: reassemble the `key=value` token as the flag's
6471                // scalar value (see `positional_indices` construction).
6472                Arg::WordAssign { key, value } => match source.eval(value).await? {
6473                    Some(val) => {
6474                        let val = apply_tilde_expansion(val, home);
6475                        // Loud on binary (GH #116): `-v a=$BIN` must not silently
6476                        // reassemble the `[binary: N bytes]` placeholder into the
6477                        // flag's value — same text-sink boundary as the primary
6478                        // sinks fixed in #93 item 1.
6479                        let val_str = crate::interpreter::value_to_text_sink_named(
6480                            &val,
6481                            "a key=value argument",
6482                        )
6483                        .map_err(|e| anyhow::anyhow!("{e}"))?;
6484                        collected.push(Value::String(format!("{key}={val_str}")));
6485                        consumed.insert(pos_idx);
6486                    }
6487                    None if collected.is_empty() => {
6488                        tool_args.flags.insert(flag_name.to_string());
6489                        return Ok(());
6490                    }
6491                    None => anyhow::bail!(
6492                        "--{flag_name}: could not evaluate argument {} in this context",
6493                        collected.len() + 1
6494                    ),
6495                },
6496                _ => {}
6497            },
6498            None => {
6499                if consumes <= 1 && collected.is_empty() {
6500                    // Back-compat: a flag with no follow-up positional
6501                    // becomes a bare flag. `--path` with nothing after
6502                    // lands in `flags`, same as before this refactor.
6503                    tool_args.flags.insert(flag_name.to_string());
6504                    return Ok(());
6505                }
6506                anyhow::bail!(
6507                    "--{flag_name} requires {consumes} argument{}, got {}",
6508                    if consumes == 1 { "" } else { "s" },
6509                    collected.len()
6510                );
6511            }
6512        }
6513    }
6514
6515    if consumes <= 1 {
6516        if let Some(v) = collected.pop() {
6517            if repeatable {
6518                push_repeatable_value(tool_args, flag_name, canonical, v)?;
6519            } else {
6520                tool_args.named.insert(canonical.to_string(), v);
6521            }
6522        }
6523        return Ok(());
6524    }
6525
6526    // Multi-consume: accumulate under named[canonical] as array-of-arrays.
6527    let occ: Vec<serde_json::Value> = collected
6528        .iter()
6529        .map(|v| flag_value_to_json(canonical, v))
6530        .collect::<Result<Vec<_>>>()?;
6531    let entry = tool_args
6532        .named
6533        .entry(canonical.to_string())
6534        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
6535    if let Value::Json(serde_json::Value::Array(outer)) = entry {
6536        outer.push(serde_json::Value::Array(occ));
6537    } else {
6538        anyhow::bail!(
6539            "--{flag_name}: named[{canonical}] already holds a non-array value"
6540        );
6541    }
6542    Ok(())
6543}
6544
6545/// Build `ToolArgs` from AST `Arg`s — the single arg-binding implementation
6546/// (GH #188) shared by `Kernel::build_args_async` (production) and the
6547/// reduced sync path (`scheduler::pipeline::build_tool_args`, used by
6548/// scatter/gather's own option parsing and the `#[cfg(test)]`
6549/// `BackendDispatcher`). The two differ only in the [`ArgValueSource`] they
6550/// pass: Kernel's evaluates full expressions (including `$(...)` command
6551/// substitution) and expands real globs/tilde; the reduced one can't recurse
6552/// into the async pipeline this early (scatter/gather's own flags bind
6553/// before any worker forks) so it evaluates a smaller expression subset and
6554/// never expands globs/tilde — see `SyncEvalSource` in `scheduler::pipeline`.
6555///
6556/// If a schema is provided, uses it to determine argument types:
6557/// - For `--flag` where schema says type is non-bool: consume next
6558///   positional(s) as value(s) (`consumes`/`repeatable`-aware).
6559/// - For `--flag` where schema says type is bool (or unknown): treat as a
6560///   boolean flag.
6561///
6562/// This enables natural shell syntax like `mcp_tool --query "test" --limit 10`.
6563pub(crate) async fn bind_tool_args(
6564    args: &[Arg],
6565    schema: Option<&crate::tools::ToolSchema>,
6566    source: &dyn ArgValueSource,
6567) -> Result<ToolArgs> {
6568    let mut tool_args = ToolArgs::new();
6569    let home = source.home().await;
6570
6571    // A glob-passthrough tool (`glob`) consumes patterns as data: skip
6572    // argv glob expansion so the pattern reaches the tool as written —
6573    // otherwise `glob **/*.rs` binds the first *matching path* as its
6574    // pattern. The eval fallback turns `Expr::GlobPattern` into its
6575    // literal string.
6576    let glob_passthrough = schema.is_some_and(|s| s.glob_passthrough);
6577
6578    // Verbatim: the tool owns its grammar, so it gets every word in source
6579    // order and nothing in `positional`/`named`. The typed split below is
6580    // set-shaped and drops the order and multiplicity a clap subcommand tree
6581    // needs, which no inversion can recover.
6582    //
6583    // `--json` is still the kernel's, so it is lifted into `flags` wherever it
6584    // sits and `apply_from_args` handles it as it does for a typed tool —
6585    // unless the tool owns its output, in which case the kernel renders
6586    // nothing and the flag has to reach the tool's own argv instead. Lifting it
6587    // there would strip it from the words AND skip rendering, so asking for
6588    // JSON would do nothing at all.
6589    if schema.is_some_and(|s| matches!(s.arg_binding, crate::tools::ArgBinding::Verbatim)) {
6590        let lift_global_flags = !schema.is_some_and(|s| s.owns_output);
6591        let mut words: Vec<Value> = Vec::new();
6592        let mut past_double_dash = false;
6593        for arg in args {
6594            match arg {
6595                Arg::Positional(expr) => {
6596                    let glob = if let Expr::GlobPattern(p) = expr {
6597                        (!glob_passthrough).then(|| p.clone())
6598                    } else {
6599                        None
6600                    };
6601                    let expanded = match &glob {
6602                        Some(pattern) => source.expand_glob(pattern).await?,
6603                        None => None,
6604                    };
6605                    match expanded {
6606                        Some(paths) => {
6607                            for path in paths {
6608                                words.push(Value::String(path));
6609                            }
6610                        }
6611                        None => {
6612                            // Nothing evaluated means no word, matching the
6613                            // typed path's `if let Some(value)`.
6614                            if let Some(value) = source.eval(expr).await? {
6615                                words.push(apply_tilde_expansion(value, home.as_deref()));
6616                            }
6617                        }
6618                    }
6619                }
6620                Arg::ShortFlag(name) => words.push(Value::String(format!("-{name}"))),
6621                Arg::LongFlag(name) => {
6622                    if lift_global_flags
6623                        && !past_double_dash
6624                        && crate::tools::is_global_output_flag(name)
6625                    {
6626                        tool_args.flags.insert(name.clone());
6627                        continue;
6628                    }
6629                    words.push(Value::String(format!("--{name}")));
6630                }
6631                Arg::Named { key, value } => {
6632                    let val = source.eval(value).await?.ok_or_else(|| {
6633                        anyhow::anyhow!("verbatim --key=value could not be evaluated in this context")
6634                    })?;
6635                    let val = apply_tilde_expansion(val, home.as_deref());
6636                    if lift_global_flags
6637                        && !past_double_dash
6638                        && crate::tools::is_global_output_flag(key)
6639                    {
6640                        // Removed from the words whether or not it is on: the
6641                        // tool must not meet the kernel's flag in any form.
6642                        if global_flag_value_is_truthy(&val) {
6643                            tool_args.flags.insert(key.clone());
6644                        }
6645                        continue;
6646                    }
6647                    // Loud on binary (GH #116): reassembling `--k=$BIN` as text
6648                    // hands the tool a placeholder that looks like data. A bare
6649                    // binary word is fine — it stays typed.
6650                    let val_str = crate::interpreter::value_to_text_sink_named(
6651                        &val,
6652                        "a --key=value argument",
6653                    )
6654                    .map_err(|e| anyhow::anyhow!("{e}"))?;
6655                    words.push(Value::String(format!("--{key}={val_str}")));
6656                }
6657                Arg::WordAssign { key, value } => {
6658                    let val = source.eval(value).await?.ok_or_else(|| {
6659                        anyhow::anyhow!("verbatim key=value could not be evaluated in this context")
6660                    })?;
6661                    let val = apply_tilde_expansion(val, home.as_deref());
6662                    let val_str = crate::interpreter::value_to_text_sink_named(
6663                        &val,
6664                        "a key=value argument",
6665                    )
6666                    .map_err(|e| anyhow::anyhow!("{e}"))?;
6667                    words.push(Value::String(format!("{key}={val_str}")));
6668                }
6669                Arg::DoubleDash => {
6670                    past_double_dash = true;
6671                    words.push(Value::String("--".to_string()));
6672                }
6673            }
6674        }
6675        tool_args.words = Some(words);
6676        return Ok(tool_args);
6677    }
6678
6679    // Raw-argv fast path (POSIX `test`): bind every argument to `positional`
6680    // in source order with types preserved — operators (`-f`, `=`, `!`) as
6681    // strings, operands keeping their `Value` — leaving `flags`/`named`
6682    // empty. A position-sensitive command needs the *true* argv: an operand
6683    // that looks like a flag (`test $x = -n`, `test 0 -gt -5`) must not be
6684    // hoisted into the unordered flag set the normal binder splits into.
6685    // Globs still expand and `~` still resolves, matching normal positional
6686    // binding — so `test -f *.rs` errors on too many args, not a literal
6687    // pattern stat.
6688    if schema.is_some_and(|s| s.raw_argv) {
6689        for arg in args {
6690            match arg {
6691                Arg::Positional(expr) => {
6692                    let glob = if let Expr::GlobPattern(p) = expr {
6693                        (!glob_passthrough).then(|| p.clone())
6694                    } else {
6695                        None
6696                    };
6697                    if let Some(pattern) = glob {
6698                        match source.expand_glob(&pattern).await? {
6699                            Some(paths) => {
6700                                for path in paths {
6701                                    tool_args.positional.push(Value::String(path));
6702                                }
6703                            }
6704                            None => {
6705                                let value = source.eval(expr).await?.ok_or_else(|| {
6706                                    anyhow::anyhow!(
6707                                        "raw-argv positional could not be evaluated in this context"
6708                                    )
6709                                })?;
6710                                let value = apply_tilde_expansion(value, home.as_deref());
6711                                tool_args.positional.push(value);
6712                            }
6713                        }
6714                    } else {
6715                        let value = source.eval(expr).await?.ok_or_else(|| {
6716                            anyhow::anyhow!(
6717                                "raw-argv positional could not be evaluated in this context"
6718                            )
6719                        })?;
6720                        let value = apply_tilde_expansion(value, home.as_deref());
6721                        tool_args.positional.push(value);
6722                    }
6723                }
6724                Arg::ShortFlag(name) => {
6725                    tool_args.positional.push(Value::String(format!("-{name}")));
6726                }
6727                Arg::LongFlag(name) => {
6728                    tool_args.positional.push(Value::String(format!("--{name}")));
6729                }
6730                Arg::Named { key, value } => {
6731                    let val = source.eval(value).await?.ok_or_else(|| {
6732                        anyhow::anyhow!("raw-argv --key=value could not be evaluated in this context")
6733                    })?;
6734                    let val = apply_tilde_expansion(val, home.as_deref());
6735                    // Loud on binary (GH #116): `test --k=$BIN` must not
6736                    // silently reassemble the placeholder into the raw-argv
6737                    // positional stream `test` binds against.
6738                    let val_str = crate::interpreter::value_to_text_sink_named(
6739                        &val,
6740                        "a --key=value argument",
6741                    )
6742                    .map_err(|e| anyhow::anyhow!("{e}"))?;
6743                    tool_args
6744                        .positional
6745                        .push(Value::String(format!("--{key}={val_str}")));
6746                }
6747                Arg::WordAssign { key, value } => {
6748                    let val = source.eval(value).await?.ok_or_else(|| {
6749                        anyhow::anyhow!("raw-argv key=value could not be evaluated in this context")
6750                    })?;
6751                    let val = apply_tilde_expansion(val, home.as_deref());
6752                    // Loud on binary (GH #116): same reasoning as the Named
6753                    // arm above, for the bare `key=value` raw-argv form.
6754                    let val_str = crate::interpreter::value_to_text_sink_named(
6755                        &val,
6756                        "a key=value argument",
6757                    )
6758                    .map_err(|e| anyhow::anyhow!("{e}"))?;
6759                    tool_args
6760                        .positional
6761                        .push(Value::String(format!("{key}={val_str}")));
6762                }
6763                Arg::DoubleDash => {
6764                    tool_args.positional.push(Value::String("--".to_string()));
6765                }
6766            }
6767        }
6768        return Ok(tool_args);
6769    }
6770
6771    // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
6772    // schemas; pick the leaf the leading positionals route to and bind
6773    // flags against *its* params. Flat tools return the root. select_leaf
6774    // errors (fail loud) if a computed positional sits where a subcommand
6775    // selector is required.
6776    let leaf = match schema {
6777        Some(s) => Some(select_leaf(s, args)?),
6778        None => None,
6779    };
6780    // Bind against the leaf's params, but MERGE the root schema's params on
6781    // top as "global" flags: a value-flag declared at the tool's top level
6782    // (e.g. kj's `--confirm <token>`) must bind at every leaf, including when
6783    // it trails the subcommand path (`kj context retag a b --confirm <n>`).
6784    // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
6785    // merge is a harmless no-op.
6786    let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
6787    if let Some(l) = leaf {
6788        param_lookup.extend(schema_param_lookup(l));
6789    }
6790    // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
6791    // not the leaf — it's a property of the command, not the subcommand.
6792    let accepts_word_assign = schema
6793        .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
6794        .unwrap_or(false);
6795
6796    // Track which positional indices have been consumed as flag values
6797    let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
6798    let mut past_double_dash = false;
6799
6800    // Indices a value-flag may consume as its value. Positionals always
6801    // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
6802    // itself treat `key=value` as an assignment (everything but
6803    // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
6804    // `-v`, rather than skipping it and grabbing the next positional (the
6805    // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
6806    // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
6807    let positional_indices: Vec<usize> = args
6808        .iter()
6809        .enumerate()
6810        .filter_map(|(i, a)| {
6811            let consumable = matches!(a, Arg::Positional(_))
6812                || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
6813            consumable.then_some(i)
6814        })
6815        .collect();
6816
6817    let mut i = 0;
6818    while i < args.len() {
6819        match &args[i] {
6820            Arg::DoubleDash => {
6821                past_double_dash = true;
6822            }
6823            Arg::Positional(expr) => {
6824                if !consumed.contains(&i) {
6825                    // Glob expansion: bare glob patterns expand to matching files
6826                    if let Expr::GlobPattern(pattern) = expr {
6827                        if !glob_passthrough {
6828                            if let Some(paths) = source.expand_glob(pattern).await? {
6829                                for path in paths {
6830                                    tool_args.positional.push(Value::String(path));
6831                                }
6832                                i += 1;
6833                                continue;
6834                            }
6835                        }
6836                    }
6837                    if let Some(value) = source.eval(expr).await? {
6838                        let value = apply_tilde_expansion(value, home.as_deref());
6839                        tool_args.positional.push(value);
6840                    }
6841                }
6842            }
6843            Arg::Named { key, value } => {
6844                if let Some(val) = source.eval(value).await? {
6845                    let val = apply_tilde_expansion(val, home.as_deref());
6846                    // Past `--` this is data, not a flag: one operand spelled
6847                    // `--key=value`, the same collapse the `WordAssign` arm
6848                    // below does for `A=1` (GH #189). The value still expands.
6849                    if past_double_dash {
6850                        let val_str = crate::interpreter::value_to_text_sink_named(
6851                            &val,
6852                            "a --key=value operand after `--`",
6853                        )
6854                        .map_err(|e| anyhow::anyhow!("{e}"))?;
6855                        tool_args
6856                            .positional
6857                            .push(Value::String(format!("--{key}={val_str}")));
6858                        i += 1;
6859                        continue;
6860                    }
6861                    // The kernel's own `--json=VALUE`, decided here and never
6862                    // placed in `named`. Left there it reaches the builtin's
6863                    // clap parser as a value on a `bool` field, whose `SetTrue`
6864                    // action rejects every spelling but `true`/`false` — so
6865                    // `seq --json=1` exited 2 while the raw-argv and verbatim
6866                    // binders were quietly accepting the same word. One rule,
6867                    // asked in one place, for all three.
6868                    if !past_double_dash && crate::tools::is_global_output_flag(key) {
6869                        if global_flag_value_is_truthy(&val) {
6870                            tool_args.flags.insert(key.clone());
6871                        }
6872                        i += 1;
6873                        continue;
6874                    }
6875                    // A repeatable flag in `--flag=value` form must accumulate too,
6876                    // not overwrite — otherwise `--expression=A --expression=B`
6877                    // would silently keep only B, and mixing with the `-e` space
6878                    // form would clobber the array. Route it through the same
6879                    // accumulator the space form uses.
6880                    let is_declared_value_flag = param_lookup
6881                        .get(key.as_str())
6882                        .is_some_and(|(_, typ, ..)| !is_bool_type(typ));
6883                    if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
6884                        push_repeatable_value(&mut tool_args, key, canonical, val)?;
6885                    } else if matches!(val, Value::Bool(_)) && !is_declared_value_flag {
6886                        // Flagify at bind time (GH #189): `--flag=true`/
6887                        // `--flag=false` binds the same way the bare
6888                        // `--flag`/its absence already do (true → flag
6889                        // presence, false → dropped) instead of landing in
6890                        // `named` as a literal `Value::Bool` that a clap
6891                        // `bool` field's `SetTrue` action rejects
6892                        // (`seq --json=true` used to exit 2 with a clap
6893                        // parse error). Covers both a schema-declared bool
6894                        // param AND an undeclared flag — `--json` itself is
6895                        // deliberately excluded from every builtin's schema
6896                        // (`clap_schema::is_skipped`), so this is what makes
6897                        // `--json=true` work universally instead of only for
6898                        // the builtins that happen to call
6899                        // `ToolArgs::flagify_bool_named` themselves. A
6900                        // declared VALUE-taking flag's own `=true` literal
6901                        // (`spawn --command=true`) is excluded by
6902                        // `is_declared_value_flag` and still falls to
6903                        // `named` below.
6904                        if let Value::Bool(true) = val {
6905                            tool_args.flags.insert(key.clone());
6906                        }
6907                        // Value::Bool(false): absent == false, nothing to insert.
6908                    } else {
6909                        tool_args.named.insert(key.clone(), val);
6910                    }
6911                }
6912            }
6913            Arg::WordAssign { key, value } => {
6914                // Already pulled in as a preceding value-flag's argument
6915                // (`awk -v a=1`); don't also emit it as a positional.
6916                if consumed.contains(&i) {
6917                    i += 1;
6918                    continue;
6919                }
6920                if let Some(val) = source.eval(value).await? {
6921                    let val = apply_tilde_expansion(val, home.as_deref());
6922                    // Past `--`, EVERY token is raw data — including for
6923                    // export/alias, whose `key=value` is normally a shell
6924                    // assignment (GH #189). `export -- A=1` must bind `A=1`
6925                    // as a literal positional, not silently re-enter the
6926                    // named-assignment path `past_double_dash` exists to
6927                    // suppress for flags right above this arm.
6928                    if accepts_word_assign && !past_double_dash {
6929                        tool_args.named.insert(key.clone(), val);
6930                    } else {
6931                        // Stringify "key=value" and pass as a positional.
6932                        // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
6933                        // Loud on binary (GH #116): `cat foo=$BIN`/`dd if=$BIN`
6934                        // must not silently become a path/operand literally named
6935                        // `foo=[binary: N bytes]`.
6936                        let val_str = crate::interpreter::value_to_text_sink_named(
6937                            &val,
6938                            "a key=value argument",
6939                        )
6940                        .map_err(|e| anyhow::anyhow!("{e}"))?;
6941                        tool_args.positional.push(Value::String(format!("{key}={val_str}")));
6942                    }
6943                }
6944            }
6945            Arg::ShortFlag(name) => {
6946                if past_double_dash {
6947                    tool_args.positional.push(Value::String(format!("-{name}")));
6948                } else if name.len() == 1 {
6949                    let flag_name = name.as_str();
6950                    let lookup = param_lookup.get(flag_name);
6951
6952                    // Same ambiguity guard as the `LongFlag` arm below (GH
6953                    // #189 item 4): an undeclared short flag immediately
6954                    // followed by an unconsumed positional under a
6955                    // map_positionals (backend/MCP) schema is exactly as
6956                    // ambiguous as the long-flag case — kaish can't tell a
6957                    // space-form value (`-t explorer`) from a bool flag
6958                    // sitting before a real positional (`-f file.txt`).
6959                    // Unlike `--flag`, there is no `-f=value` escape hatch to
6960                    // suggest: a glued `-f=val` is two tokens with a dangling
6961                    // `=` that the parser's no-token-pasting guard already
6962                    // rejects — the only fix is declaring the flag.
6963                    let ambiguous_value = (lookup.is_none()
6964                        && leaf.is_some_and(|s| s.map_positionals)
6965                        && !consumed.contains(&(i + 1)))
6966                        .then(|| match args.get(i + 1) {
6967                            Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
6968                                Some(s.clone())
6969                            }
6970                            Some(Arg::Positional(_)) => Some("VALUE".to_string()),
6971                            _ => None,
6972                        })
6973                        .flatten();
6974                    if let Some(val) = ambiguous_value {
6975                        let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
6976                        anyhow::bail!(
6977                            "{tool}: -{name} is not a declared flag, so the \
6978                             space-separated value ({val:?}) would be silently \
6979                             dropped. Have {tool} declare -{name} in its schema \
6980                             (short flags have no -{name}=value form to fall \
6981                             back on)."
6982                        );
6983                    }
6984
6985                    let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
6986
6987                    if is_bool {
6988                        tool_args.flags.insert(flag_name.to_string());
6989                    } else {
6990                        // Non-bool: consume `consumes` positionals as value(s)
6991                        let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
6992                        let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
6993                        let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
6994                        consume_flag_positionals(
6995                            source,
6996                            home.as_deref(),
6997                            args,
6998                            name,
6999                            canonical,
7000                            consumes,
7001                            repeatable,
7002                            &positional_indices,
7003                            &mut consumed,
7004                            i,
7005                            &mut tool_args,
7006                        )
7007                        .await?;
7008                    }
7009                } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
7010                    // Multi-char short flag matches a schema param (POSIX style: -name value)
7011                    if is_bool_type(typ) {
7012                        tool_args.flags.insert(canonical.to_string());
7013                    } else {
7014                        consume_flag_positionals(
7015                            source,
7016                            home.as_deref(),
7017                            args,
7018                            name,
7019                            canonical,
7020                            consumes,
7021                            repeatable,
7022                            &positional_indices,
7023                            &mut consumed,
7024                            i,
7025                            &mut tool_args,
7026                        )
7027                        .await?;
7028                    }
7029                } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
7030                    .get(&name[..1])
7031                    .filter(|(_, typ, ..)| !is_bool_type(typ))
7032                {
7033                    // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
7034                    // `grep -A1`, `sed -e1d`. The first char is a declared
7035                    // value-taking short flag, so the rest of the token is its
7036                    // value — the coreutils idiom. The lexer's flag char class is
7037                    // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
7038                    // (safe to slice) and the tail is a plain literal.
7039                    bind_glued_short_value(
7040                        &mut tool_args,
7041                        &name[..1],
7042                        canonical,
7043                        consumes,
7044                        repeatable,
7045                        name[1..].to_string(),
7046                    )?;
7047                } else {
7048                    // Multi-char combined short flags. Bool flags stack
7049                    // (`-la`), but the FIRST value-taking flag reached
7050                    // consumes the rest of the token as its glued value
7051                    // (`-ivC3` → C=3) or, if it is the last char, the next
7052                    // positional (`grep -ivC 3` → C=3). Before this, a
7053                    // trailing value-flag was silently treated as a bool,
7054                    // stranding its argument as a stray positional (arity
7055                    // error). Undeclared/bool chars stay bare flags, so a
7056                    // schemaless tool keeps the old all-boolean behavior.
7057                    // The first char being value-taking is handled by the
7058                    // glued arm above, so it never reaches here. The flag
7059                    // char class is ASCII, so byte indexing is char indexing
7060                    // (no `Vec<char>` allocation needed).
7061                    let bytes = name.as_bytes();
7062                    let mut p = 0;
7063                    while p < bytes.len() {
7064                        let key = &name[p..p + 1];
7065                        match param_lookup.get(key) {
7066                            Some(&(canonical, typ, consumes, repeatable))
7067                                if !is_bool_type(typ) =>
7068                            {
7069                                let glued = name[p + 1..].to_string();
7070                                if glued.is_empty() {
7071                                    // Value flag is the last char: take the
7072                                    // next positional. `consume_flag_positionals`
7073                                    // respects `consumes`.
7074                                    consume_flag_positionals(
7075                                        source,
7076                                        home.as_deref(),
7077                                        args,
7078                                        key,
7079                                        canonical,
7080                                        consumes,
7081                                        repeatable,
7082                                        &positional_indices,
7083                                        &mut consumed,
7084                                        i,
7085                                        &mut tool_args,
7086                                    )
7087                                    .await?;
7088                                } else {
7089                                    bind_glued_short_value(
7090                                        &mut tool_args,
7091                                        key,
7092                                        canonical,
7093                                        consumes,
7094                                        repeatable,
7095                                        glued,
7096                                    )?;
7097                                }
7098                                break;
7099                            }
7100                            _ => {
7101                                tool_args.flags.insert(key.to_string());
7102                                p += 1;
7103                            }
7104                        }
7105                    }
7106                }
7107            }
7108            Arg::LongFlag(name) => {
7109                if past_double_dash {
7110                    tool_args.positional.push(Value::String(format!("--{name}")));
7111                } else {
7112                    let lookup = param_lookup.get(name.as_str());
7113                    // An *undeclared* long flag under a `map_positionals`
7114                    // (backend/MCP) schema that is immediately followed by an
7115                    // unconsumed positional is ambiguous: kaish can't tell the
7116                    // space-form value (`--type explorer`) from a bool flag
7117                    // before a real positional (`--force file.txt`). Defaulting
7118                    // to bool here silently divorces the value and misroutes it
7119                    // — a privilege-escalation-by-typo against deny-by-default
7120                    // embedders. Fail loud instead of guessing.
7121                    let ambiguous_value = (lookup.is_none()
7122                        && leaf.is_some_and(|s| s.map_positionals)
7123                        && !consumed.contains(&(i + 1)))
7124                        .then(|| match args.get(i + 1) {
7125                            // Echo a concrete value for a copy-pasteable fix
7126                            // when it's a plain literal; fall back to VALUE.
7127                            Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
7128                                Some(s.clone())
7129                            }
7130                            Some(Arg::Positional(_)) => Some("VALUE".to_string()),
7131                            _ => None,
7132                        })
7133                        .flatten();
7134                    if let Some(val) = ambiguous_value {
7135                        let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
7136                        anyhow::bail!(
7137                            "{tool}: --{name} is not a declared flag, so the \
7138                             space-separated value would be silently dropped. \
7139                             Use --{name}={val}, or have {tool} declare --{name} \
7140                             in its schema."
7141                        );
7142                    }
7143                    let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
7144
7145                    if is_bool {
7146                        tool_args.flags.insert(name.clone());
7147                    } else {
7148                        let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
7149                        let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
7150                        let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
7151                        consume_flag_positionals(
7152                            source,
7153                            home.as_deref(),
7154                            args,
7155                            name,
7156                            canonical,
7157                            consumes,
7158                            repeatable,
7159                            &positional_indices,
7160                            &mut consumed,
7161                            i,
7162                            &mut tool_args,
7163                        )
7164                        .await?;
7165                    }
7166                }
7167            }
7168        }
7169        i += 1;
7170    }
7171
7172    // Map remaining positionals to unfilled non-bool schema params (in order).
7173    // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
7174    // Positionals that appeared after `--` are never mapped (they're raw data).
7175    // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
7176    // Keyed off the routed leaf so a subcommand tool maps against the active
7177    // leaf's params (kj leaves keep map_positionals=false → block skipped).
7178    if let Some(schema) = leaf.filter(|s| s.map_positionals) {
7179        let pre_dash_count = if past_double_dash {
7180            let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
7181            positional_indices.iter()
7182                .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
7183                .count()
7184        } else {
7185            tool_args.positional.len()
7186        };
7187
7188        let mut remaining = Vec::new();
7189        let mut positional_iter = tool_args.positional.drain(..).enumerate();
7190
7191        for param in &schema.params {
7192            if tool_args.named.contains_key(&param.name) || tool_args.flags.contains(&param.name) {
7193                continue;
7194            }
7195            if is_bool_type(&param.param_type) {
7196                continue;
7197            }
7198            loop {
7199                match positional_iter.next() {
7200                    Some((idx, val)) if idx < pre_dash_count => {
7201                        tool_args.named.insert(param.name.clone(), val);
7202                        break;
7203                    }
7204                    Some((_, val)) => {
7205                        remaining.push(val);
7206                    }
7207                    None => break,
7208                }
7209            }
7210        }
7211
7212        remaining.extend(positional_iter.map(|(_, v)| v));
7213        tool_args.positional = remaining;
7214    }
7215
7216    Ok(tool_args)
7217}
7218
7219#[async_trait]
7220impl CommandDispatcher for Kernel {
7221    /// Dispatch a command through the Kernel's full resolution chain.
7222    ///
7223    /// This is the single path for all command execution when called from
7224    /// the pipeline runner. It provides the full dispatch chain:
7225    /// user tools → builtins → .kai scripts → external commands → backend tools.
7226    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
7227        self.dispatch_command(cmd, ctx).await
7228    }
7229
7230    /// Run a compound pipeline stage through the kernel's statement executor.
7231    async fn dispatch_stmt(&self, stmt: &Stmt, ctx: &mut ExecContext) -> Result<ExecResult> {
7232        self.dispatch_statement(stmt, ctx).await
7233    }
7234
7235    /// Evaluate an expression through the kernel's async chain, including
7236    /// command substitution. Delegates to `eval_expr_async`, which snapshots
7237    /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
7238    /// only command output escapes. The `ctx` is unused here because the
7239    /// kernel evaluates against its own session state (a fork carries the
7240    /// pipeline stage's snapshot); var refs resolve against that scope.
7241    async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
7242        self.eval_expr_async(expr).await
7243    }
7244
7245    /// Produce a forked dispatcher with independent mutable state (detached).
7246    ///
7247    /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
7248    /// recursing into the trait method we're defining) and coerces the
7249    /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
7250    async fn fork(&self) -> Arc<dyn CommandDispatcher> {
7251        let fork: Arc<Kernel> = Kernel::fork(self).await;
7252        fork
7253    }
7254
7255    /// Produce a forked dispatcher with cancellation cascading from this kernel.
7256    async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
7257        let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
7258        fork
7259    }
7260}
7261
7262/// Apply the requested output format to a builtin's result, unless the tool
7263/// owns its own output — and even then, only on success.
7264///
7265/// `format` is `ctx.output_format` (set from `--json`). `owns_output` means
7266/// "this tool renders its own bespoke SUCCESS envelope" (scatter/gather's
7267/// JSONL/array rendering), not "never touch this tool's bytes" — scatter and
7268/// gather never render a structured error themselves, so a failure
7269/// (`ExecResult::failure(code, msg)`, plain text, no `.data`/`.output`) was
7270/// never "already rendered" by the tool. Skipping `apply_output_format` on
7271/// that path just leaked the raw diagnostic under `--json` instead of the
7272/// uniform `{"error","code"}` envelope every other builtin's failure gets
7273/// (kaibo review finding on merged PR #215, confirmed pre-existing for the
7274/// whole owns_output error-path class). Gating the skip on `result.ok()`
7275/// keeps the intentional success-path opt-out while closing that gap.
7276fn finalize_output(
7277    result: ExecResult,
7278    format: Option<crate::interpreter::OutputFormat>,
7279    owns_output: bool,
7280) -> ExecResult {
7281    match format {
7282        Some(_) if owns_output && result.ok() => result,
7283        Some(format) => apply_output_format(result, format),
7284        None => result,
7285    }
7286}
7287
7288/// Accumulate output from one result into another.
7289///
7290/// Appends stdout and stderr verbatim and updates the exit code to match the
7291/// new result. Used to preserve output from multiple statements, loop
7292/// iterations, and command chains. No separator is inserted between outputs —
7293/// each command's output concatenates raw, matching bash (`printf a; printf b`
7294/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
7295/// when a command emits its own, as `echo` does).
7296/// Append `new`'s stdout to `accumulated`'s, and nothing else.
7297///
7298/// Split out of [`accumulate_result`] because a condition carries only its
7299/// stdout up to the enclosing statement — its exit code is the `if`/`while`'s
7300/// answer, not the statement's status. Sharing the append keeps the two
7301/// callers from drifting on the byte handling below.
7302fn push_stdout_of(accumulated: &mut ExecResult, new: &ExecResult) {
7303    // Materialize lazy OutputData into .out before accumulating.
7304    // Without this, the first command's output stays in .output while
7305    // the second's text gets appended to .out, losing the first.
7306    accumulated.materialize();
7307    match new.out_bytes() {
7308        // A binary result must not be lossy-decoded by text_out(): concatenate
7309        // raw bytes so the combined output stays binary (this is the path every
7310        // top-level statement's result flows through). See docs/binary-data.md.
7311        Some(new_bytes) => {
7312            let mut combined: Vec<u8> = match accumulated.out_bytes() {
7313                Some(b) => b.to_vec(),
7314                None => accumulated.text_out().into_owned().into_bytes(),
7315            };
7316            combined.extend_from_slice(new_bytes);
7317            accumulated.set_out_bytes(combined);
7318        }
7319        None => accumulated.push_out(&new.text_out()),
7320    }
7321}
7322
7323fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
7324    push_stdout_of(accumulated, new);
7325    accumulated.err.push_str(&new.err);
7326    accumulated.code = new.code;
7327    // The marker travels WITH the data, always. Copying one without the other
7328    // is wrong in both directions: a compound statement ending in `fromjson`
7329    // lost its value (`$(if true; then fromjson '[1,2]'; fi)` bound text), and
7330    // a chain whose LEFT side was typed kept that marker over the right side's
7331    // data (`$(fromjson '[1,2]' && cut -f2 f)` bound `["b"]` typed — the very
7332    // bug this mechanism exists to fix, re-entering through a side door).
7333    accumulated.data = new.data.clone();
7334    accumulated.data_is_value = new.data_is_value;
7335    // OR, not assign. `did_spill` is a fact about the OUTPUT — this block's
7336    // text was truncated — and an ordinary statement running afterwards does
7337    // not untruncate it. Assigning let `seq …; echo after` report
7338    // `did_spill: false` with output still missing, telling an embedder asking
7339    // "did I get everything" the wrong thing. The exit CODE is a separate
7340    // question and still belongs to the last statement, as in any shell.
7341    accumulated.did_spill |= new.did_spill;
7342    // `original_code` is only meaningful alongside a spill, so it follows the
7343    // same rule: keep the first one rather than letting a later clean
7344    // statement's `None` erase the code the spill replaced.
7345    if accumulated.original_code.is_none() {
7346        accumulated.original_code = new.original_code;
7347    }
7348    accumulated.content_type = new.content_type.clone();
7349    accumulated.baggage.clone_from(&new.baggage);
7350}
7351
7352/// Fold a block's accumulated output into a signal that is leaving the block.
7353///
7354/// Any block that builds up a result — a loop body, an `if`/`case` branch, the
7355/// left side of a `&&`/`||` chain — hands that result back when it finishes.
7356/// When `break`/`continue`/`return`/`exit` leaves early instead, the signal
7357/// replaces the result on the way up, so output printed before the signal
7358/// would otherwise be discarded. Leaving early stops the block; it does not
7359/// unprint what already ran. The block's output comes first (it ran before the
7360/// signal was raised), then the signal's already-carried output.
7361fn fold_block_output_into_flow(block_output: ExecResult, flow: &mut ControlFlow) {
7362    let carried = match flow {
7363        ControlFlow::Break { result, .. }
7364        | ControlFlow::Continue { result, .. }
7365        | ControlFlow::Exit { result, .. } => result,
7366        ControlFlow::Return { value } => value,
7367        ControlFlow::Normal(_) => return,
7368    };
7369    let mut merged = block_output;
7370    accumulate_result(&mut merged, carried);
7371    *carried = merged;
7372}
7373
7374/// Accumulate the output a break/continue signal carried (from inner loops it
7375/// propagated through) into the loop that finally handles it, so it survives
7376/// into that loop's result.
7377fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
7378    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
7379        // `break`/`continue` carry no value of their own, and a signal that
7380        // produced nothing must not erase what the body already produced —
7381        // leaving a loop early stops it, it does not unmake its output. Same
7382        // reasoning as `fold_block_output_into_flow`'s, one step further:
7383        // `$(while true; do fromjson '[5]'; break; done)` bound text because
7384        // the empty `break` result overwrote the body's value.
7385        let carried = (accumulated.data.take(), accumulated.data_is_value);
7386        accumulate_result(accumulated, result);
7387        if result.data.is_none() {
7388            (accumulated.data, accumulated.data_is_value) = carried;
7389        }
7390    }
7391}
7392
7393/// Check if a value is truthy.
7394fn is_truthy(value: &Value) -> bool {
7395    match value {
7396        Value::Null => false,
7397        Value::Bool(b) => *b,
7398        Value::Int(i) => *i != 0,
7399        Value::Float(f) => *f != 0.0,
7400        Value::String(s) => !s.is_empty(),
7401        Value::Json(json) => match json {
7402            serde_json::Value::Null => false,
7403            serde_json::Value::Array(arr) => !arr.is_empty(),
7404            serde_json::Value::Object(obj) => !obj.is_empty(),
7405            serde_json::Value::Bool(b) => *b,
7406            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
7407            serde_json::Value::String(s) => !s.is_empty(),
7408        },
7409        Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
7410    }
7411}
7412
7413/// Apply tilde expansion to a value.
7414///
7415/// Only string values starting with `~` are expanded. `home` is the session
7416/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
7417/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
7418fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
7419    match value {
7420        Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
7421        _ => value,
7422    }
7423}
7424
7425/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
7426/// how the lexer tokenizes the equivalent minimally-quoted command string —
7427/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
7428/// (`build_args_async`) verbatim instead of carrying a parallel one that could
7429/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
7430/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
7431///
7432/// Classification matches the lexer's word classes:
7433/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
7434///   the binder's `past_double_dash` arms, exactly as for the string door).
7435/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
7436/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
7437///   (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
7438///   they fall through to a positional, not a flag).
7439/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
7440///   binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
7441///   `key=value` positional, per the command's word-assign allowlist).
7442/// - everything else → a literal [`Arg::Positional`].
7443///
7444/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
7445/// positional — it can never be a flag — and rides through as-is. That is the
7446/// typed passthrough the string-native door cannot offer.
7447pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
7448    argv.iter().map(classify_argv_token).collect()
7449}
7450
7451fn classify_argv_token(token: &Value) -> Arg {
7452    let Value::String(s) = token else {
7453        return Arg::Positional(Expr::Literal(token.clone()));
7454    };
7455
7456    if s == "--" {
7457        return Arg::DoubleDash;
7458    }
7459
7460    // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
7461    // long-flag words — the lexer now tokenizes each as one `DoubleDashBare`
7462    // literal word (GH #137), matching this classifier's own literal
7463    // fallback — so they fall through to a literal positional rather than a
7464    // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
7465    if let Some(rest) = s.strip_prefix("--") {
7466        if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
7467            return match rest.split_once('=') {
7468                Some((key, val)) => Arg::Named {
7469                    key: key.to_string(),
7470                    value: Expr::Literal(Value::String(val.to_string())),
7471                },
7472                None => Arg::LongFlag(rest.to_string()),
7473            };
7474        }
7475    } else if let Some(rest) = s.strip_prefix('-') {
7476        // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
7477        // token carrying any other char — notably `=` (`-k=v` is a parse error in
7478        // the string door) — or a leading digit (`-1` lexes as a number) is not a
7479        // short-flag word, so it falls through to a literal positional instead of
7480        // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
7481        if is_short_flag_body(rest) {
7482            return Arg::ShortFlag(rest.to_string());
7483        }
7484    }
7485
7486    if let Some((key, val)) = s.split_once('=') {
7487        if is_shell_identifier(key) {
7488            return Arg::WordAssign {
7489                key: key.to_string(),
7490                value: Expr::Literal(Value::String(val.to_string())),
7491            };
7492        }
7493    }
7494
7495    Arg::Positional(Expr::Literal(Value::String(s.clone())))
7496}
7497
7498/// A short-flag word: a leading ASCII letter, then only ASCII
7499/// letters/digits/`-` (the lexer's base `-[a-zA-Z][a-zA-Z0-9-]*` regex) or `:`
7500/// (which `merge_flag_metachar_adjacent` glues onto a `ShortFlag` for the
7501/// `awk -F:` idiom). `-la`, `-A1`, `-a:` qualify; `-1` (a number), `-k=v`
7502/// (`=` is the assignment operator — a parse error in the string door), and
7503/// any non-ASCII tail (never produced by the lexer, and not safe for the
7504/// combined-short-flag binder's byte-index slicing) do not, so they fall
7505/// through to a literal positional instead of a malformed `ShortFlag`.
7506fn is_short_flag_body(s: &str) -> bool {
7507    s.starts_with(|c: char| c.is_ascii_alphabetic())
7508        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
7509}
7510
7511/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
7512fn is_shell_identifier(s: &str) -> bool {
7513    let mut chars = s.chars();
7514    match chars.next() {
7515        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
7516        _ => return false,
7517    }
7518    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
7519}
7520
7521/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
7522/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
7523/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
7524/// must keep every value, not silently drop all but the last. Used by every flag
7525/// surface that can carry the same flag twice — the space form
7526/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
7527/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
7528/// ordered array.
7529/// Flatten a bound flag value to JSON, going LOUD on binary.
7530///
7531/// The accumulating flag forms (`jq --arg NAME VAL`, `sed -e EXPR -e EXPR`)
7532/// store their values as `serde_json::Value` rather than keeping the kaish
7533/// `Value`, and [`value_to_json`](crate::interpreter::value_to_json) renders a
7534/// `Value::Bytes` as the base64 envelope
7535/// (`{"_type":"bytes","encoding":"base64",…}`). That envelope is an internal
7536/// wire form, not the user's data: bound into `--arg x`, the tool sees the
7537/// envelope's literal JSON *text* where the bytes should be and reports
7538/// success, which is silent corruption (GH #223). Binary stops here instead,
7539/// with the same wording and exit 1 as every other text sink.
7540///
7541/// Valid-UTF-8 bytes coerce to their text, matching
7542/// [`value_to_text_sink_named`](crate::interpreter::value_to_text_sink_named);
7543/// in practice `Value::Bytes` only ever holds non-UTF-8, so this errors
7544/// whenever binary reaches a flag value. Gating on the kaish `Value` (not on
7545/// the envelope's JSON shape) is what keeps an envelope-shaped record the user
7546/// actually built — `fromjson '{"_type":"bytes",…}'` — a plain record: kaish
7547/// never sniffs JSON to decide a type.
7548fn flag_value_to_json(canonical: &str, v: &Value) -> Result<serde_json::Value> {
7549    match v {
7550        Value::Bytes(_) => crate::interpreter::value_to_text_sink_named(
7551            v,
7552            &format!("the value of the {canonical} flag"),
7553        )
7554        .map(serde_json::Value::String)
7555        .map_err(|e| anyhow::anyhow!("{e}")),
7556        other => Ok(crate::interpreter::value_to_json(other)),
7557    }
7558}
7559
7560pub(crate) fn push_repeatable_value(
7561    tool_args: &mut ToolArgs,
7562    flag_name: &str,
7563    canonical: &str,
7564    v: Value,
7565) -> anyhow::Result<()> {
7566    let occ = flag_value_to_json(canonical, &v)?;
7567    let entry = tool_args
7568        .named
7569        .entry(canonical.to_string())
7570        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
7571    if let Value::Json(serde_json::Value::Array(items)) = entry {
7572        items.push(occ);
7573        Ok(())
7574    } else {
7575        anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
7576    }
7577}
7578
7579/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
7580/// is one token, so it carries a single value: a repeatable flag accumulates
7581/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
7582/// first-char glued arm and the combined-bundle arm so the two can't drift on
7583/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
7584/// is a loud error, not a silent single-value bind.
7585pub(crate) fn bind_glued_short_value(
7586    tool_args: &mut ToolArgs,
7587    flag_name: &str,
7588    canonical: &str,
7589    consumes: usize,
7590    repeatable: bool,
7591    value: String,
7592) -> anyhow::Result<()> {
7593    if consumes > 1 {
7594        anyhow::bail!(
7595            "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
7596        );
7597    }
7598    if repeatable {
7599        push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
7600    } else {
7601        tool_args
7602            .named
7603            .insert(canonical.to_string(), Value::String(value));
7604        Ok(())
7605    }
7606}
7607
7608/// Map a child's exit status to a shell-style exit code.
7609///
7610/// `ExitStatus::code()` is `None` when the process died from a signal rather
7611/// than exiting normally; in that case this maps to POSIX's `128 + signal`
7612/// convention (SIGKILL → 137, SIGTERM → 143, …) instead of losing the signal
7613/// number. Shared by both external-command spawn sites — production
7614/// (`try_execute_external`, below) and the test-only twin
7615/// (`dispatch.rs::BackendDispatcher::try_external`) — so they can't drift on
7616/// this mapping again (GH #133 item 1).
7617#[cfg(feature = "subprocess")]
7618pub(crate) fn exit_code_from_status(status: &std::process::ExitStatus) -> i64 {
7619    status.code().unwrap_or_else(|| {
7620        #[cfg(unix)]
7621        {
7622            use std::os::unix::process::ExitStatusExt;
7623            128 + status.signal().unwrap_or(0)
7624        }
7625        #[cfg(not(unix))]
7626        {
7627            -1
7628        }
7629    }) as i64
7630}
7631
7632/// Wait for a child to exit, killing it if `cancel` fires first.
7633///
7634/// `target` carries a Linux pidfd (when available) for race-free direct-child
7635/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
7636/// parameter is ignored and we use tokio's cross-platform `start_kill`.
7637#[cfg(all(unix, feature = "subprocess"))]
7638pub(crate) async fn wait_or_kill(
7639    child: &mut tokio::process::Child,
7640    target: Option<&crate::pidfd::KillTarget>,
7641    cancel: &tokio_util::sync::CancellationToken,
7642    grace: Duration,
7643) -> std::io::Result<std::process::ExitStatus> {
7644    tokio::select! {
7645        biased;
7646        status = child.wait() => status,
7647        _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
7648    }
7649}
7650
7651#[cfg(all(not(unix), feature = "subprocess"))]
7652pub(crate) async fn wait_or_kill(
7653    child: &mut tokio::process::Child,
7654    _target: Option<&()>,
7655    cancel: &tokio_util::sync::CancellationToken,
7656    _grace: Duration,
7657) -> std::io::Result<std::process::ExitStatus> {
7658    tokio::select! {
7659        biased;
7660        status = child.wait() => status,
7661        _ = cancel.cancelled() => {
7662            let _ = child.start_kill();
7663            child.wait().await
7664        }
7665    }
7666}
7667
7668/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
7669///
7670/// Direct-child kill goes through `target.signal()`, which on Linux uses a
7671/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
7672/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
7673#[cfg(all(unix, feature = "subprocess"))]
7674pub(crate) async fn kill_with_grace(
7675    child: &mut tokio::process::Child,
7676    target: Option<&crate::pidfd::KillTarget>,
7677    grace: Duration,
7678) -> std::io::Result<std::process::ExitStatus> {
7679    use nix::sys::signal::Signal;
7680
7681    if let Some(t) = target {
7682        t.signal(Signal::SIGTERM);
7683        t.signal_pg(Signal::SIGTERM);
7684        if grace > Duration::ZERO
7685            && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
7686        {
7687            return status;
7688        }
7689        t.signal(Signal::SIGKILL);
7690        t.signal_pg(Signal::SIGKILL);
7691    }
7692    child.wait().await
7693}
7694
7695#[cfg(test)]
7696#[allow(clippy::unwrap_used, clippy::expect_used)]
7697mod argv_classify_tests {
7698    use super::*;
7699
7700    /// A normalized, comparable view of one `Arg` representing its *logical
7701    /// argument* (what the command observably receives), not its exact AST shape:
7702    ///
7703    /// - Value-bearing arms compare by *stringified* value, so the parser's
7704    ///   number coercion (`-1`→`Int(-1)`) vs the classifier's literal
7705    ///   (`String("-1")`) count as the same argument.
7706    /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
7707    ///   For every command except the `export`/`alias` allowlist, a bareword
7708    ///   `key=value` is stringified straight back to a `"key=value"` positional
7709    ///   (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
7710    ///   converge observably even when they disagree on the AST tag — e.g. the
7711    ///   lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
7712    ///   `WordAssign`, where the classifier (bash-correctly) makes a positional.
7713    ///   The genuine `WordAssign` *detection* on a real identifier LHS is pinned
7714    ///   separately by `classifies_each_word_class`.
7715    ///
7716    /// Returns `None` for shapes we deliberately don't compare:
7717    /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
7718    /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
7719    ///   lex to `Int`, dropping the literal text, where the classifier keeps the
7720    ///   string. That divergence is *intentional* — `execute_argv` preserves a
7721    ///   literal numeric string (pass `Value::Int` for a number), the string door
7722    ///   can only guess — so the property skips it rather than demanding the
7723    ///   classifier replicate a lossy coercion. Numeric edges are pinned exactly
7724    ///   by `classifies_each_word_class`.
7725    fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
7726        // Only a *string*-valued literal is comparable; a coerced number is not.
7727        let lit = |e: &Expr| match e {
7728            Expr::Literal(Value::String(s)) => Some(s.clone()),
7729            _ => None,
7730        };
7731        Some(match arg {
7732            Arg::DoubleDash => ("dash", String::new(), String::new()),
7733            Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
7734            Arg::LongFlag(s) => ("long", s.clone(), String::new()),
7735            Arg::Positional(e) => ("pos", String::new(), lit(e)?),
7736            Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
7737            Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
7738        })
7739    }
7740
7741    /// Classify a single string token the way `execute_argv` would.
7742    fn classify(token: &str) -> Arg {
7743        classify_argv_token(&Value::String(token.to_string()))
7744    }
7745
7746    #[test]
7747    fn classifies_each_word_class() {
7748        assert_eq!(classify("--"), Arg::DoubleDash);
7749        assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
7750        assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
7751        assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
7752        assert_eq!(
7753            classify("--key=value"),
7754            Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
7755        );
7756        assert_eq!(
7757            classify("NAME=val"),
7758            Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
7759        );
7760        // Digits after the first flag char are ordinary (kept verbatim).
7761        assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
7762        assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
7763        // Leading-digit dash is a number to the lexer, not a flag → positional.
7764        assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
7765        // Numeric strings keep their literal text — `execute_argv` does NOT
7766        // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
7767        // who wants a number passes `Value::Int`; a string stays the string.
7768        assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
7769        assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
7770        // A lone dash (stdin convention) is a positional, not a flag.
7771        assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
7772        // Non-identifier LHS is not an assignment.
7773        assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
7774        assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
7775    }
7776
7777    #[test]
7778    fn typed_values_pass_through_as_literal_positionals() {
7779        // The whole point of the `&[Value]` signature: a non-string value is a
7780        // literal positional carrying the *exact* value, never stringified and
7781        // never flag-interpreted.
7782        let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
7783        assert_eq!(
7784            classify_argv_token(&bytes),
7785            Arg::Positional(Expr::Literal(bytes.clone()))
7786        );
7787        let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
7788        assert_eq!(
7789            classify_argv_token(&json),
7790            Arg::Positional(Expr::Literal(json.clone()))
7791        );
7792        // An integer token that *looks* like a flag is still a positional value
7793        // (only strings are inspected for a leading dash).
7794        assert_eq!(
7795            classify_argv_token(&Value::Int(-9)),
7796            Arg::Positional(Expr::Literal(Value::Int(-9)))
7797        );
7798    }
7799
7800    #[test]
7801    fn double_dash_only_matches_exactly() {
7802        // `--` is the marker; `--x` is a long flag. `---` is not a flag word
7803        // (the lexer lexes it as one `DoubleDashBare` literal word, GH #137);
7804        // as a single argv token here it's likewise literal.
7805        assert_eq!(classify("--"), Arg::DoubleDash);
7806        assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
7807        assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
7808    }
7809
7810    #[test]
7811    fn malformed_flag_words_fall_back_to_literal_positionals() {
7812        // A token that isn't a well-formed flag word must NOT be silently misbound
7813        // into the arg binder (house rule: loud/visible over silent-wrong). Each
7814        // of these is a parse error or different tokenization in the string door,
7815        // so the argv door keeps them as literal positionals.
7816        let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
7817        // `=` is not in the short-flag char class (`-k=v` parse-errors in the
7818        // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
7819        assert_eq!(classify("-k=v"), pos("-k=v"));
7820        assert_eq!(classify("-="), pos("-="));
7821        // Empty long-flag key.
7822        assert_eq!(classify("--=v"), pos("--=v"));
7823        // `--` followed by a non-letter is not a long flag.
7824        assert_eq!(classify("--1"), pos("--1"));
7825        // A bare dash and a number-dash are positionals (covered above too).
7826        assert_eq!(classify("-"), pos("-"));
7827        assert_eq!(classify("-9"), pos("-9"));
7828        // A non-ASCII tail is not part of the lexer's short-flag char class
7829        // (`-[a-zA-Z][a-zA-Z0-9-]*`, plus the `:` the metachar-merge pass
7830        // absorbs) — classifying it as `ShortFlag` would hand the combined
7831        // short-flag binder a byte string it (correctly, for real ASCII flag
7832        // words) slices by *byte* index, panicking on a multi-byte char
7833        // boundary. Fall back to a literal positional instead.
7834        assert_eq!(classify("-lé"), pos("-lé"));
7835        assert_eq!(classify("-é"), pos("-é"));
7836    }
7837
7838    #[tokio::test]
7839    async fn non_ascii_short_flag_bundle_does_not_panic() {
7840        // Regression: `execute_argv`'s combined-short-flag loop assumed the
7841        // flag body was ASCII (safe to byte-slice) because the lexer's
7842        // grammar guarantees that on the *string* door. The argv door's
7843        // classifier let a non-ASCII tail through as `ShortFlag`, so
7844        // `execute_argv("ls", &["-lé"])` sliced mid-codepoint and panicked.
7845        let kernel = Kernel::transient().expect("failed to create kernel");
7846        let result = kernel
7847            .execute_argv("ls", &[Value::String("-lé".into())])
7848            .await
7849            .expect("execute_argv must not panic on a non-ASCII short-flag token");
7850        // Not a well-formed flag word, so it's a literal positional — `ls`
7851        // then reports it as a missing path rather than mangling flags.
7852        assert_ne!(result.code, 0);
7853    }
7854
7855    proptest::proptest! {
7856        /// The core correctness claim: the classifier mirrors the lexer/parser
7857        /// on metacharacter-free tokens. For any such single token, the `Arg`
7858        /// the classifier produces matches the one the real parser produces for
7859        /// the equivalent one-word command — so `execute_argv` reusing the
7860        /// string door's binder is sound. (First proptest in the workspace.)
7861        #[test]
7862        fn classifier_matches_parser_on_clean_tokens(
7863            // No digits: this property tests the *classification* boundary
7864            // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
7865            // positional), not numeric coercion. The lexer coerces digit runs to
7866            // `Int`/`Float` and drops the literal text (even inside a colon-merged
7867            // word: `00:` → `0:`); the classifier intentionally preserves the raw
7868            // string. Those numeric edges are pinned exactly by the unit tests.
7869            // Non-ASCII is a word character now, so the generator has to
7870            // reach it — an ASCII-only strategy tests a shrinking slice of
7871            // what the classifier actually sees.
7872            token in "[a-zA-Z_=./@:+\\-\u{00e9}\u{540d}\u{1f600}]{1,8}"
7873        ) {
7874            let parsed = match parse(&format!("cmd {token}")) {
7875                Ok(p) => p,
7876                Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
7877            };
7878            let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
7879                return Ok(());
7880            };
7881            // Only compare when the token lexed as exactly one argument.
7882            let [arg] = cmd.args.as_slice() else { return Ok(()); };
7883
7884            let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
7885                return Ok(()); // a non-literal parsed Expr we don't model — skip
7886            };
7887            proptest::prop_assert_eq!(
7888                ours, theirs,
7889                "classifier diverged from parser on token {:?}", token
7890            );
7891        }
7892    }
7893}
7894
7895#[cfg(all(test, feature = "subprocess"))]
7896#[allow(clippy::expect_used)]
7897mod tests {
7898    use super::*;
7899
7900    #[tokio::test]
7901    async fn test_kernel_transient() {
7902        let kernel = Kernel::transient().expect("failed to create kernel");
7903        assert_eq!(kernel.name(), "transient");
7904    }
7905
7906    #[tokio::test]
7907    async fn test_kernel_execute_echo() {
7908        let kernel = Kernel::transient().expect("failed to create kernel");
7909        let result = kernel.execute("echo hello").await.expect("execution failed");
7910        assert!(result.ok());
7911        assert_eq!(result.text_out().trim(), "hello");
7912    }
7913
7914    #[tokio::test]
7915    async fn test_multiple_statements_accumulate_output() {
7916        let kernel = Kernel::transient().expect("failed to create kernel");
7917        let result = kernel
7918            .execute("echo one\necho two\necho three")
7919            .await
7920            .expect("execution failed");
7921        assert!(result.ok());
7922        // Should have all three outputs separated by newlines
7923        assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
7924        assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
7925        assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
7926    }
7927
7928    #[tokio::test]
7929    async fn test_and_chain_accumulates_output() {
7930        let kernel = Kernel::transient().expect("failed to create kernel");
7931        let result = kernel
7932            .execute("echo first && echo second")
7933            .await
7934            .expect("execution failed");
7935        assert!(result.ok());
7936        assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
7937        assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
7938    }
7939
7940    #[tokio::test]
7941    async fn test_for_loop_accumulates_output() {
7942        let kernel = Kernel::transient().expect("failed to create kernel");
7943        let result = kernel
7944            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
7945            .await
7946            .expect("execution failed");
7947        assert!(result.ok());
7948        assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
7949        assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
7950        assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
7951    }
7952
7953    #[tokio::test]
7954    async fn test_while_loop_accumulates_output() {
7955        let kernel = Kernel::transient().expect("failed to create kernel");
7956        let result = kernel
7957            .execute(r#"
7958                N=3
7959                while [[ ${N} -gt 0 ]]; do
7960                    echo "N=${N}"
7961                    N=$((N - 1))
7962                done
7963            "#)
7964            .await
7965            .expect("execution failed");
7966        assert!(result.ok());
7967        assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
7968        assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
7969        assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
7970    }
7971
7972    #[tokio::test]
7973    async fn test_kernel_set_var() {
7974        let kernel = Kernel::transient().expect("failed to create kernel");
7975
7976        kernel.execute("X=42").await.expect("set failed");
7977
7978        let value = kernel.get_var("X").await;
7979        assert_eq!(value, Some(Value::Int(42)));
7980    }
7981
7982    #[tokio::test]
7983    async fn test_kernel_var_expansion() {
7984        let kernel = Kernel::transient().expect("failed to create kernel");
7985
7986        kernel.execute("NAME=\"world\"").await.expect("set failed");
7987        let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
7988
7989        assert!(result.ok());
7990        assert_eq!(result.text_out().trim(), "hello world");
7991    }
7992
7993    #[tokio::test]
7994    async fn test_kernel_last_result() {
7995        let kernel = Kernel::transient().expect("failed to create kernel");
7996
7997        kernel.execute("echo test").await.expect("echo failed");
7998
7999        let last = kernel.last_result().await;
8000        assert!(last.ok());
8001        assert_eq!(last.text_out().trim(), "test");
8002    }
8003
8004    #[tokio::test]
8005    async fn test_kernel_tool_not_found() {
8006        let kernel = Kernel::transient().expect("failed to create kernel");
8007
8008        let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
8009        assert!(!result.ok());
8010        assert_eq!(result.code, 127);
8011        assert!(result.err.contains("command not found"));
8012    }
8013
8014    #[tokio::test]
8015    async fn backend_tool_data_content_type_and_baggage_survive_into_exec_result() {
8016        // The embedder seam: a backend-registered tool (kaijutsu, an MCP
8017        // engine, …) returns a `ToolResult` with structured `data` — this
8018        // must reach the caller's `ExecResult` intact so `x=$(embedder_tool)`
8019        // and `for r in $(embedder_tool)` see the typed value, not just
8020        // stdout text.
8021        use crate::backend::testing::MockBackend;
8022        use crate::backend::ToolResult;
8023        let (mock, _calls) = MockBackend::new();
8024        let backend = mock.with_tool_result(|_name| {
8025            let mut baggage = std::collections::BTreeMap::new();
8026            baggage.insert("trace_id".to_string(), "abc123".to_string());
8027            // ToolResult is #[non_exhaustive] (GH #93 item 3/hygiene pass) —
8028            // construct via with_data + the with_* setters, not a struct literal.
8029            Ok(ToolResult::with_data("", serde_json::json!({"key": "value"}))
8030                .with_content_type("application/json")
8031                .with_baggage(baggage))
8032        });
8033        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
8034        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
8035            .expect("with_backend kernel");
8036
8037        let result = kernel
8038            .execute("embedder_tool")
8039            .await
8040            .expect("execution failed");
8041        assert!(result.ok(), "backend tool call should succeed: {result:?}");
8042        assert_eq!(
8043            result.data,
8044            Some(Value::Json(serde_json::json!({"key": "value"}))),
8045            "backend tool's structured data must survive into ExecResult, not be dropped"
8046        );
8047        assert_eq!(
8048            result.content_type.as_deref(),
8049            Some("application/json"),
8050            "backend tool's content_type must survive into ExecResult"
8051        );
8052        assert_eq!(
8053            result.baggage.get("trace_id").map(String::as_str),
8054            Some("abc123"),
8055            "backend tool's baggage must survive into ExecResult"
8056        );
8057    }
8058
8059    #[tokio::test]
8060    async fn backend_tool_execution_error_is_not_reported_as_command_not_found() {
8061        // A backend tool that IS found but fails during execution (`Io`,
8062        // `PermissionDenied`, …) must surface its real error, not get
8063        // misreported as exit-127 "command not found" — that masks a genuine
8064        // failure as a lookup miss.
8065        use crate::backend::testing::MockBackend;
8066        let (mock, _calls) = MockBackend::new();
8067        let backend = mock.with_tool_result(|_name| Err(BackendError::Io("disk exploded".to_string())));
8068        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
8069        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
8070            .expect("with_backend kernel");
8071
8072        let result = kernel
8073            .execute("embedder_tool")
8074            .await
8075            .expect("execution failed");
8076        assert_ne!(result.code, 127, "a real execution error must not look like command-not-found: {result:?}");
8077        assert!(!result.ok());
8078        assert!(
8079            result.err.contains("disk exploded"),
8080            "the real backend error must be visible, not masked: {result:?}"
8081        );
8082    }
8083
8084    #[tokio::test]
8085    async fn disabled_external_commands_still_resolve_a_backend_tool() {
8086        // Regression guard for the kaijutsu shape: a read-only shell sets
8087        // `allow_external_commands: false` (no host subprocess exec) but
8088        // still registers its own backend tools — e.g. a sandboxed `curl`
8089        // that reads the network without touching a host binary or the VFS.
8090        // Refusing external commands must NOT short-circuit the
8091        // backend-tool lookup that runs after it. If it did, that `curl`
8092        // would stop resolving and fail with a message claiming it isn't
8093        // available — the exact wrong belief this whole fix exists to
8094        // prevent, just relocated one layer down. Nothing stops a future
8095        // "simplification" of the disabled bail into a terminal branch;
8096        // this test is what catches that.
8097        use crate::backend::testing::MockBackend;
8098        let (mock, calls) = MockBackend::new();
8099        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(mock);
8100        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
8101            .expect("with_backend kernel");
8102        assert!(!kernel.allow_external_commands, "isolated() must keep external commands off for this guard to mean anything");
8103
8104        let result = kernel.execute("curl").await.expect("execution failed");
8105        assert!(
8106            result.ok(),
8107            "a backend-registered tool must still run with external commands disabled: {result:?}"
8108        );
8109        assert_eq!(
8110            calls.load(std::sync::atomic::Ordering::SeqCst),
8111            1,
8112            "the backend tool must actually have been invoked, not just assumed"
8113        );
8114    }
8115
8116    #[tokio::test]
8117    async fn test_external_command_true() {
8118        // Use REPL config for passthrough filesystem access
8119        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
8120
8121        // /bin/true should be available on any Unix system
8122        let result = kernel.execute("true").await.expect("execution failed");
8123        // This should use the builtin true, which returns 0
8124        assert!(result.ok(), "true should succeed: {:?}", result);
8125    }
8126
8127    #[tokio::test]
8128    async fn test_external_command_basic() {
8129        // Use REPL config for passthrough filesystem access
8130        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
8131
8132        // Test with /bin/echo which is external
8133        // Note: kaish has a builtin echo, so this will use the builtin
8134        // Let's test with a command that's not a builtin
8135        // Actually, let's just test that PATH resolution works by checking the PATH var
8136        let path_var = std::env::var("PATH").unwrap_or_default();
8137        eprintln!("System PATH: {}", path_var);
8138
8139        // Set PATH in kernel to ensure it's available
8140        kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
8141
8142        // Now try an external command like /usr/bin/env
8143        // But env is also a builtin... let's try uname
8144        let result = kernel.execute("uname").await.expect("execution failed");
8145        eprintln!("uname result: {:?}", result);
8146        // uname should succeed if external commands work
8147        assert!(result.ok() || result.code == 127, "uname: {:?}", result);
8148    }
8149
8150    #[tokio::test]
8151    async fn test_kernel_reset() {
8152        let kernel = Kernel::transient().expect("failed to create kernel");
8153
8154        kernel.execute("X=1").await.expect("set failed");
8155        assert!(kernel.get_var("X").await.is_some());
8156
8157        kernel.reset().await.expect("reset failed");
8158        assert!(kernel.get_var("X").await.is_none());
8159    }
8160
8161    #[tokio::test]
8162    async fn test_kernel_reset_preserves_pid_and_initial_vars() {
8163        let kernel = Kernel::new(KernelConfig::transient().with_var("HOME", Value::String("/home/probe".into())))
8164            .expect("failed to create kernel");
8165
8166        let pid_before = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
8167        assert_eq!(kernel.get_var("HOME").await, Some(Value::String("/home/probe".into())));
8168
8169        kernel.reset().await.expect("reset failed");
8170
8171        let pid_after = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
8172        assert_eq!(pid_before, pid_after, "$$ must stay stable across reset(), not silently renumber");
8173        assert_eq!(
8174            kernel.get_var("HOME").await,
8175            Some(Value::String("/home/probe".into())),
8176            "frontend-seeded initial vars (HOME/PATH) must survive reset(), not silently vanish"
8177        );
8178    }
8179
8180    #[tokio::test]
8181    async fn test_kernel_cwd() {
8182        let kernel = Kernel::transient().expect("failed to create kernel");
8183
8184        // Transient kernel uses sandboxed mode with cwd=$HOME
8185        let cwd = kernel.cwd().await;
8186        let home = std::env::var("HOME")
8187            .map(PathBuf::from)
8188            .unwrap_or_else(|_| PathBuf::from("/"));
8189        assert_eq!(cwd, home);
8190
8191        kernel.set_cwd(PathBuf::from("/tmp")).await;
8192        assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
8193    }
8194
8195    #[tokio::test]
8196    async fn test_kernel_list_vars() {
8197        let kernel = Kernel::transient().expect("failed to create kernel");
8198
8199        kernel.execute("A=1").await.ok();
8200        kernel.execute("B=2").await.ok();
8201
8202        let vars = kernel.list_vars().await;
8203        assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
8204        assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
8205    }
8206
8207    #[tokio::test]
8208    async fn test_is_truthy() {
8209        assert!(!is_truthy(&Value::Null));
8210        assert!(!is_truthy(&Value::Bool(false)));
8211        assert!(is_truthy(&Value::Bool(true)));
8212        assert!(!is_truthy(&Value::Int(0)));
8213        assert!(is_truthy(&Value::Int(1)));
8214        assert!(!is_truthy(&Value::String("".into())));
8215        assert!(is_truthy(&Value::String("x".into())));
8216    }
8217
8218    #[tokio::test]
8219    async fn test_jq_in_pipeline() {
8220        let kernel = Kernel::transient().expect("failed to create kernel");
8221        // kaish uses double quotes only; escape inner quotes
8222        let result = kernel
8223            .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
8224            .await
8225            .expect("execution failed");
8226        assert!(result.ok(), "jq pipeline failed: {}", result.err);
8227        assert_eq!(result.text_out().trim(), "Alice");
8228    }
8229
8230    #[tokio::test]
8231    async fn test_user_defined_tool() {
8232        let kernel = Kernel::transient().expect("failed to create kernel");
8233
8234        // Define a function
8235        kernel
8236            .execute(r#"greet() { echo "Hello, $1!" }"#)
8237            .await
8238            .expect("function definition failed");
8239
8240        // Call the function
8241        let result = kernel
8242            .execute(r#"greet "World""#)
8243            .await
8244            .expect("function call failed");
8245
8246        assert!(result.ok(), "greet failed: {}", result.err);
8247        assert_eq!(result.text_out().trim(), "Hello, World!");
8248    }
8249
8250    #[tokio::test]
8251    async fn test_user_tool_positional_args() {
8252        let kernel = Kernel::transient().expect("failed to create kernel");
8253
8254        // Define a function with positional param
8255        kernel
8256            .execute(r#"greet() { echo "Hi $1" }"#)
8257            .await
8258            .expect("function definition failed");
8259
8260        // Call with positional argument
8261        let result = kernel
8262            .execute(r#"greet "Amy""#)
8263            .await
8264            .expect("function call failed");
8265
8266        assert!(result.ok(), "greet failed: {}", result.err);
8267        assert_eq!(result.text_out().trim(), "Hi Amy");
8268    }
8269
8270    #[tokio::test]
8271    async fn test_function_shared_scope() {
8272        let kernel = Kernel::transient().expect("failed to create kernel");
8273
8274        // Set a variable in parent scope
8275        kernel
8276            .execute(r#"SECRET="hidden""#)
8277            .await
8278            .expect("set failed");
8279
8280        // Define a function that accesses and modifies parent variable
8281        kernel
8282            .execute(r#"access_parent() {
8283                echo "${SECRET}"
8284                SECRET="modified"
8285            }"#)
8286            .await
8287            .expect("function definition failed");
8288
8289        // Call the function - it SHOULD see SECRET (shared scope like sh)
8290        let result = kernel.execute("access_parent").await.expect("function call failed");
8291
8292        // Function should have access to parent scope
8293        assert!(
8294            result.text_out().contains("hidden"),
8295            "Function should access parent scope, got: {}",
8296            result.text_out()
8297        );
8298
8299        // Function should have modified the parent variable
8300        let secret = kernel.get_var("SECRET").await;
8301        assert_eq!(
8302            secret,
8303            Some(Value::String("modified".into())),
8304            "Function should modify parent scope"
8305        );
8306    }
8307
8308    #[tokio::test]
8309    #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
8310    async fn test_exec_builtin() {
8311        let kernel = Kernel::transient().expect("failed to create kernel");
8312        // argv is now a space-separated string or JSON array string
8313        let result = kernel
8314            .execute(r#"exec command="/bin/echo" argv="hello world""#)
8315            .await
8316            .expect("exec failed");
8317
8318        assert!(result.ok(), "exec failed: {}", result.err);
8319        assert_eq!(result.text_out().trim(), "hello world");
8320    }
8321
8322    #[tokio::test]
8323    async fn test_while_false_never_runs() {
8324        let kernel = Kernel::transient().expect("failed to create kernel");
8325
8326        // A while loop with false condition should never run
8327        let result = kernel
8328            .execute(r#"
8329                while false; do
8330                    echo "should not run"
8331                done
8332            "#)
8333            .await
8334            .expect("while false failed");
8335
8336        assert!(result.ok());
8337        assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
8338    }
8339
8340    #[tokio::test]
8341    async fn test_while_string_comparison() {
8342        let kernel = Kernel::transient().expect("failed to create kernel");
8343
8344        // Set a flag
8345        kernel.execute(r#"FLAG="go""#).await.expect("set failed");
8346
8347        // Use string comparison as condition (shell-compatible [[ ]] syntax)
8348        // Note: Put echo last so we can check the output
8349        let result = kernel
8350            .execute(r#"
8351                while [[ ${FLAG} == "go" ]]; do
8352                    FLAG="stop"
8353                    echo "running"
8354                done
8355            "#)
8356            .await
8357            .expect("while with string cmp failed");
8358
8359        assert!(result.ok());
8360        assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
8361
8362        // Verify flag was changed
8363        let flag = kernel.get_var("FLAG").await;
8364        assert_eq!(flag, Some(Value::String("stop".into())));
8365    }
8366
8367    #[tokio::test]
8368    async fn test_while_numeric_comparison() {
8369        let kernel = Kernel::transient().expect("failed to create kernel");
8370
8371        // Test > comparison (shell-compatible [[ ]] with -gt)
8372        kernel.execute("N=5").await.expect("set failed");
8373
8374        // Note: Put echo last so we can check the output
8375        let result = kernel
8376            .execute(r#"
8377                while [[ ${N} -gt 3 ]]; do
8378                    N=3
8379                    echo "N was greater"
8380                done
8381            "#)
8382            .await
8383            .expect("while with > failed");
8384
8385        assert!(result.ok());
8386        assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
8387    }
8388
8389    #[tokio::test]
8390    async fn test_break_in_while_loop() {
8391        let kernel = Kernel::transient().expect("failed to create kernel");
8392
8393        let result = kernel
8394            .execute(r#"
8395                I=0
8396                while true; do
8397                    I=1
8398                    echo "before break"
8399                    break
8400                    echo "after break"
8401                done
8402            "#)
8403            .await
8404            .expect("while with break failed");
8405
8406        assert!(result.ok());
8407        assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
8408        assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
8409
8410        // Verify we exited the loop
8411        let i = kernel.get_var("I").await;
8412        assert_eq!(i, Some(Value::Int(1)));
8413    }
8414
8415    #[tokio::test]
8416    async fn test_continue_in_while_loop() {
8417        let kernel = Kernel::transient().expect("failed to create kernel");
8418
8419        // Test continue in a while loop where variables persist
8420        // We use string state transition: "start" -> "middle" -> "end"
8421        // continue on "middle" should skip to next iteration
8422        // Shell-compatible: use [[ ]] for comparisons
8423        let result = kernel
8424            .execute(r#"
8425                STATE="start"
8426                AFTER_CONTINUE="no"
8427                while [[ ${STATE} != "done" ]]; do
8428                    if [[ ${STATE} == "start" ]]; then
8429                        STATE="middle"
8430                        continue
8431                        AFTER_CONTINUE="yes"
8432                    fi
8433                    if [[ ${STATE} == "middle" ]]; then
8434                        STATE="done"
8435                    fi
8436                done
8437            "#)
8438            .await
8439            .expect("while with continue failed");
8440
8441        assert!(result.ok());
8442
8443        // STATE should be "done" (we completed the loop)
8444        let state = kernel.get_var("STATE").await;
8445        assert_eq!(state, Some(Value::String("done".into())));
8446
8447        // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
8448        let after = kernel.get_var("AFTER_CONTINUE").await;
8449        assert_eq!(after, Some(Value::String("no".into())));
8450    }
8451
8452    #[tokio::test]
8453    async fn test_break_with_level() {
8454        let kernel = Kernel::transient().expect("failed to create kernel");
8455
8456        // Nested loop with break 2 to exit both loops
8457        // We verify by checking OUTER value:
8458        // - If break 2 works, OUTER stays at 1 (set before for loop)
8459        // - If break 2 fails, OUTER becomes 2 (set after for loop)
8460        let result = kernel
8461            .execute(r#"
8462                OUTER=0
8463                while true; do
8464                    OUTER=1
8465                    for X in "1 2"; do
8466                        break 2
8467                    done
8468                    OUTER=2
8469                done
8470            "#)
8471            .await
8472            .expect("nested break failed");
8473
8474        assert!(result.ok());
8475
8476        // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
8477        let outer = kernel.get_var("OUTER").await;
8478        assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
8479    }
8480
8481    #[tokio::test]
8482    async fn test_return_from_tool() {
8483        let kernel = Kernel::transient().expect("failed to create kernel");
8484
8485        // Define a function that returns early
8486        kernel
8487            .execute(r#"early_return() {
8488                if [[ $1 == 1 ]]; then
8489                    return 42
8490                fi
8491                echo "not returned"
8492            }"#)
8493            .await
8494            .expect("function definition failed");
8495
8496        // Call with arg=1 should return with exit code 42
8497        // (POSIX shell behavior: return N sets exit code, doesn't output N)
8498        let result = kernel
8499            .execute("early_return 1")
8500            .await
8501            .expect("function call failed");
8502
8503        // Exit code should be 42 (non-zero, so not ok())
8504        assert_eq!(result.code, 42);
8505        // Output should be empty (we returned before echo)
8506        assert!(result.text_out().is_empty());
8507    }
8508
8509    #[tokio::test]
8510    async fn test_return_without_value() {
8511        let kernel = Kernel::transient().expect("failed to create kernel");
8512
8513        // Define a function that returns without a value
8514        kernel
8515            .execute(r#"early_exit() {
8516                if [[ $1 == "stop" ]]; then
8517                    return
8518                fi
8519                echo "continued"
8520            }"#)
8521            .await
8522            .expect("function definition failed");
8523
8524        // Call with arg="stop" should return early
8525        let result = kernel
8526            .execute(r#"early_exit "stop""#)
8527            .await
8528            .expect("function call failed");
8529
8530        assert!(result.ok());
8531        assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
8532    }
8533
8534    #[tokio::test]
8535    async fn test_exit_stops_execution() {
8536        let kernel = Kernel::transient().expect("failed to create kernel");
8537
8538        // exit should stop further execution
8539        kernel
8540            .execute(r#"
8541                BEFORE="yes"
8542                exit 0
8543                AFTER="yes"
8544            "#)
8545            .await
8546            .expect("execution failed");
8547
8548        // BEFORE should be set, AFTER should not
8549        let before = kernel.get_var("BEFORE").await;
8550        assert_eq!(before, Some(Value::String("yes".into())));
8551
8552        let after = kernel.get_var("AFTER").await;
8553        assert!(after.is_none(), "AFTER should not be set after exit");
8554    }
8555
8556    #[tokio::test]
8557    async fn test_exit_with_code() {
8558        let kernel = Kernel::transient().expect("failed to create kernel");
8559
8560        // exit with code should propagate the exit code
8561        let result = kernel
8562            .execute("exit 42")
8563            .await
8564            .expect("exit failed");
8565
8566        assert_eq!(result.code, 42);
8567        assert!(result.text_out().is_empty(), "exit should not produce stdout");
8568    }
8569
8570    #[tokio::test]
8571    async fn test_set_e_stops_on_failure() {
8572        let kernel = Kernel::transient().expect("failed to create kernel");
8573
8574        // Enable error-exit mode
8575        kernel.execute("set -e").await.expect("set -e failed");
8576
8577        // Run a sequence where the middle command fails
8578        kernel
8579            .execute(r#"
8580                STEP1="done"
8581                false
8582                STEP2="done"
8583            "#)
8584            .await
8585            .expect("execution failed");
8586
8587        // STEP1 should be set, but STEP2 should NOT be set (exit on false)
8588        let step1 = kernel.get_var("STEP1").await;
8589        assert_eq!(step1, Some(Value::String("done".into())));
8590
8591        let step2 = kernel.get_var("STEP2").await;
8592        assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
8593    }
8594
8595    #[tokio::test]
8596    async fn test_set_plus_e_disables_error_exit() {
8597        let kernel = Kernel::transient().expect("failed to create kernel");
8598
8599        // Enable then disable error-exit mode
8600        kernel.execute("set -e").await.expect("set -e failed");
8601        kernel.execute("set +e").await.expect("set +e failed");
8602
8603        // Now failure should NOT stop execution
8604        kernel
8605            .execute(r#"
8606                STEP1="done"
8607                false
8608                STEP2="done"
8609            "#)
8610            .await
8611            .expect("execution failed");
8612
8613        // Both should be set since +e disables error exit
8614        let step1 = kernel.get_var("STEP1").await;
8615        assert_eq!(step1, Some(Value::String("done".into())));
8616
8617        let step2 = kernel.get_var("STEP2").await;
8618        assert_eq!(step2, Some(Value::String("done".into())));
8619    }
8620
8621    #[tokio::test]
8622    async fn test_set_euo_pipefail_is_a_working_prelude() {
8623        let kernel = Kernel::transient().expect("failed to create kernel");
8624
8625        // `set -euo pipefail` is muscle memory for a lot of script authors.
8626        // kaish implements -e and pipefail, and silently ignores the bare -u
8627        // (no fixed set to check it against). It used to FAIL on -o pipefail,
8628        // which mattered well beyond tidiness: an embedder whose exit status
8629        // is a policy decision — kaijutsu gates a tool call on it — read a
8630        // habitual first line as a deny.
8631        let result = kernel
8632            .execute("set -euo pipefail")
8633            .await
8634            .expect("set -euo pipefail failed");
8635        assert!(result.ok(), "the prelude must succeed, err={}", result.err);
8636
8637        // Both options really took effect, not just parsed.
8638        let result = kernel.execute("set -o").await.expect("set -o failed");
8639        let text = result.text_out();
8640        for row in text.lines() {
8641            if row.contains("pipefail") {
8642                assert!(row.contains("on"), "pipefail should read on: {row}");
8643            }
8644        }
8645
8646        // -e is live: the statement after a failure never runs.
8647        kernel
8648            .execute(r#"
8649                BEFORE="yes"
8650                false
8651                AFTER="yes"
8652            "#)
8653            .await
8654            .ok();
8655
8656        let after = kernel.get_var("AFTER").await;
8657        assert!(after.is_none(), "-e should be enabled by the prelude");
8658    }
8659
8660    #[tokio::test]
8661    async fn test_set_no_args_shows_settings() {
8662        let kernel = Kernel::transient().expect("failed to create kernel");
8663
8664        // Enable -e
8665        kernel.execute("set -e").await.expect("set -e failed");
8666
8667        // Call set with no args to see settings
8668        let result = kernel.execute("set").await.expect("set failed");
8669
8670        assert!(result.ok());
8671        assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
8672    }
8673
8674    #[tokio::test]
8675    async fn test_set_e_in_pipeline() {
8676        let kernel = Kernel::transient().expect("failed to create kernel");
8677
8678        kernel.execute("set -e").await.expect("set -e failed");
8679
8680        // Pipeline failure should trigger exit
8681        kernel
8682            .execute(r#"
8683                BEFORE="yes"
8684                false | cat
8685                AFTER="yes"
8686            "#)
8687            .await
8688            .ok();
8689
8690        let before = kernel.get_var("BEFORE").await;
8691        assert_eq!(before, Some(Value::String("yes".into())));
8692
8693        // AFTER should not be set if pipeline failure triggers exit
8694        // Note: The exit code of a pipeline is the exit code of the last command
8695        // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
8696        // To test pipeline failure, we need the last command to fail.
8697    }
8698
8699    #[tokio::test]
8700    async fn test_set_e_with_and_chain() {
8701        let kernel = Kernel::transient().expect("failed to create kernel");
8702
8703        kernel.execute("set -e").await.expect("set -e failed");
8704
8705        // Commands in && chain should not trigger -e on the first failure
8706        // because && explicitly handles the error
8707        kernel
8708            .execute(r#"
8709                RESULT="initial"
8710                false && RESULT="chained"
8711                RESULT="continued"
8712            "#)
8713            .await
8714            .ok();
8715
8716        // In bash, commands in && don't trigger -e. The chain handles the failure.
8717        // Our implementation may differ - let's verify current behavior.
8718        let result = kernel.get_var("RESULT").await;
8719        // If we follow bash semantics, RESULT should be "continued"
8720        // If we trigger -e on the false, RESULT stays "initial"
8721        assert!(result.is_some(), "RESULT should be set");
8722    }
8723
8724    #[tokio::test]
8725    async fn test_set_e_exits_in_for_loop() {
8726        let kernel = Kernel::transient().expect("failed to create kernel");
8727
8728        kernel.execute("set -e").await.expect("set -e failed");
8729
8730        kernel
8731            .execute(r#"
8732                REACHED="no"
8733                for x in 1 2 3; do
8734                    false
8735                    REACHED="yes"
8736                done
8737            "#)
8738            .await
8739            .ok();
8740
8741        // With set -e, false should trigger exit; REACHED should remain "no"
8742        let reached = kernel.get_var("REACHED").await;
8743        assert_eq!(reached, Some(Value::String("no".into())),
8744            "set -e should exit on failure in for loop body");
8745    }
8746
8747    #[tokio::test]
8748    async fn test_for_loop_continues_without_set_e() {
8749        let kernel = Kernel::transient().expect("failed to create kernel");
8750
8751        // Without set -e, for loop should continue normally
8752        kernel
8753            .execute(r#"
8754                COUNT=0
8755                for x in 1 2 3; do
8756                    false
8757                    COUNT=$((COUNT + 1))
8758                done
8759            "#)
8760            .await
8761            .ok();
8762
8763        let count = kernel.get_var("COUNT").await;
8764        // Arithmetic produces Int values; accept either Int or String representation
8765        let count_val = match &count {
8766            Some(Value::Int(n)) => *n,
8767            Some(Value::String(s)) => s.parse().unwrap_or(-1),
8768            _ => -1,
8769        };
8770        assert_eq!(count_val, 3,
8771            "without set -e, loop should complete all iterations (got {:?})", count);
8772    }
8773
8774    // ═══════════════════════════════════════════════════════════════════════════
8775    // Source Tests
8776    // ═══════════════════════════════════════════════════════════════════════════
8777
8778    #[tokio::test]
8779    async fn test_source_sets_variables() {
8780        let kernel = Kernel::transient().expect("failed to create kernel");
8781
8782        // Write a script to the VFS
8783        kernel
8784            .execute(r#"write "/test.kai" 'FOO="bar"'"#)
8785            .await
8786            .expect("write failed");
8787
8788        // Source the script
8789        let result = kernel
8790            .execute(r#"source "/test.kai""#)
8791            .await
8792            .expect("source failed");
8793
8794        assert!(result.ok(), "source should succeed");
8795
8796        // Variable should be set in current scope
8797        let foo = kernel.get_var("FOO").await;
8798        assert_eq!(foo, Some(Value::String("bar".into())));
8799    }
8800
8801    #[tokio::test]
8802    async fn test_source_with_dot_alias() {
8803        let kernel = Kernel::transient().expect("failed to create kernel");
8804
8805        // Write a script to the VFS
8806        kernel
8807            .execute(r#"write "/vars.kai" 'X=42'"#)
8808            .await
8809            .expect("write failed");
8810
8811        // Source using . alias
8812        let result = kernel
8813            .execute(r#". "/vars.kai""#)
8814            .await
8815            .expect(". failed");
8816
8817        assert!(result.ok(), ". should succeed");
8818
8819        // Variable should be set in current scope
8820        let x = kernel.get_var("X").await;
8821        assert_eq!(x, Some(Value::Int(42)));
8822    }
8823
8824    #[tokio::test]
8825    async fn test_source_not_found() {
8826        let kernel = Kernel::transient().expect("failed to create kernel");
8827
8828        // Try to source a non-existent file
8829        let result = kernel
8830            .execute(r#"source "/nonexistent.kai""#)
8831            .await
8832            .expect("source should not fail with error");
8833
8834        assert!(!result.ok(), "source of non-existent file should fail");
8835        assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
8836    }
8837
8838    #[tokio::test]
8839    async fn test_source_missing_filename() {
8840        let kernel = Kernel::transient().expect("failed to create kernel");
8841
8842        // Call source with no arguments
8843        let result = kernel
8844            .execute("source")
8845            .await
8846            .expect("source should not fail with error");
8847
8848        assert!(!result.ok(), "source without filename should fail");
8849        assert!(result.err.contains("missing filename"), "error should mention missing filename");
8850    }
8851
8852    #[tokio::test]
8853    async fn test_source_executes_multiple_statements() {
8854        let kernel = Kernel::transient().expect("failed to create kernel");
8855
8856        // Write a script with multiple statements
8857        kernel
8858            .execute(r#"write "/multi.kai" 'A=1
8859B=2
8860C=3'"#)
8861            .await
8862            .expect("write failed");
8863
8864        // Source it
8865        kernel
8866            .execute(r#"source "/multi.kai""#)
8867            .await
8868            .expect("source failed");
8869
8870        // All variables should be set
8871        assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
8872        assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
8873        assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
8874    }
8875
8876    #[tokio::test]
8877    async fn test_source_can_define_functions() {
8878        let kernel = Kernel::transient().expect("failed to create kernel");
8879
8880        // Write a script that defines a function
8881        kernel
8882            .execute(r#"write "/functions.kai" 'greet() {
8883    echo "Hello, $1!"
8884}'"#)
8885            .await
8886            .expect("write failed");
8887
8888        // Source it
8889        kernel
8890            .execute(r#"source "/functions.kai""#)
8891            .await
8892            .expect("source failed");
8893
8894        // Use the defined function
8895        let result = kernel
8896            .execute(r#"greet "World""#)
8897            .await
8898            .expect("greet failed");
8899
8900        assert!(result.ok());
8901        assert!(result.text_out().contains("Hello, World!"));
8902    }
8903
8904    #[tokio::test]
8905    async fn test_source_inherits_error_exit() {
8906        let kernel = Kernel::transient().expect("failed to create kernel");
8907
8908        // Enable error exit
8909        kernel.execute("set -e").await.expect("set -e failed");
8910
8911        // Write a script that has a failure
8912        kernel
8913            .execute(r#"write "/fail.kai" 'BEFORE="yes"
8914false
8915AFTER="yes"'"#)
8916            .await
8917            .expect("write failed");
8918
8919        // Source it (should exit on false due to set -e)
8920        kernel
8921            .execute(r#"source "/fail.kai""#)
8922            .await
8923            .ok();
8924
8925        // BEFORE should be set, AFTER should NOT be set due to error exit
8926        let before = kernel.get_var("BEFORE").await;
8927        assert_eq!(before, Some(Value::String("yes".into())));
8928
8929        // Note: This test depends on whether error exit is checked within source
8930        // Currently our implementation checks per-statement in the main kernel
8931    }
8932
8933    // ═══════════════════════════════════════════════════════════════════════════
8934    // set -e with && / || chains
8935    // ═══════════════════════════════════════════════════════════════════════════
8936
8937    #[tokio::test]
8938    async fn test_set_e_and_chain_left_fails() {
8939        // set -e; false && echo hi; REACHED=1 → REACHED should be set
8940        let kernel = Kernel::transient().expect("failed to create kernel");
8941        kernel.execute("set -e").await.expect("set -e failed");
8942
8943        kernel
8944            .execute("false && echo hi; REACHED=1")
8945            .await
8946            .expect("execution failed");
8947
8948        let reached = kernel.get_var("REACHED").await;
8949        assert_eq!(
8950            reached,
8951            Some(Value::Int(1)),
8952            "set -e should not trigger on left side of &&"
8953        );
8954    }
8955
8956    #[tokio::test]
8957    async fn test_set_e_and_chain_right_fails() {
8958        // set -e; true && false; REACHED=1 → REACHED should NOT be set
8959        let kernel = Kernel::transient().expect("failed to create kernel");
8960        kernel.execute("set -e").await.expect("set -e failed");
8961
8962        kernel
8963            .execute("true && false; REACHED=1")
8964            .await
8965            .expect("execution failed");
8966
8967        let reached = kernel.get_var("REACHED").await;
8968        assert!(
8969            reached.is_none(),
8970            "set -e should trigger when right side of && fails"
8971        );
8972    }
8973
8974    #[tokio::test]
8975    async fn test_set_e_or_chain_recovers() {
8976        // set -e; false || echo recovered; REACHED=1 → REACHED should be set
8977        let kernel = Kernel::transient().expect("failed to create kernel");
8978        kernel.execute("set -e").await.expect("set -e failed");
8979
8980        kernel
8981            .execute("false || echo recovered; REACHED=1")
8982            .await
8983            .expect("execution failed");
8984
8985        let reached = kernel.get_var("REACHED").await;
8986        assert_eq!(
8987            reached,
8988            Some(Value::Int(1)),
8989            "set -e should not trigger when || recovers the failure"
8990        );
8991    }
8992
8993    #[tokio::test]
8994    async fn test_set_e_or_chain_both_fail() {
8995        // set -e; false || false; REACHED=1 → REACHED should NOT be set
8996        let kernel = Kernel::transient().expect("failed to create kernel");
8997        kernel.execute("set -e").await.expect("set -e failed");
8998
8999        kernel
9000            .execute("false || false; REACHED=1")
9001            .await
9002            .expect("execution failed");
9003
9004        let reached = kernel.get_var("REACHED").await;
9005        assert!(
9006            reached.is_none(),
9007            "set -e should trigger when || chain ultimately fails"
9008        );
9009    }
9010
9011    // ═══════════════════════════════════════════════════════════════════════════
9012    // Cancellation Tests
9013    // ═══════════════════════════════════════════════════════════════════════════
9014
9015    /// Helper: schedule a cancel after a delay from a background thread.
9016    /// Uses std::thread because cancel() is sync and Kernel is not Send.
9017    fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
9018        let k = Arc::clone(kernel);
9019        std::thread::spawn(move || {
9020            std::thread::sleep(delay);
9021            k.cancel();
9022        });
9023    }
9024
9025    #[tokio::test]
9026    async fn test_cancel_interrupts_for_loop() {
9027        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
9028
9029        // Schedule cancel after a short delay from a background OS thread
9030        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
9031
9032        // #149: a bare `X=$i` body has no await point, so the for-loop's
9033        // cancellation checkpoint (checked once per iteration, see the
9034        // `Stmt::For` arm above) never gets a chance to run mid-body — under
9035        // host load, 100_000 trivial iterations could complete and return
9036        // before the background thread's 10ms sleep ever elapsed, racing a
9037        // natural exit-0 completion against the scheduled cancel. Rather than
9038        // widen the margin (there's no bound on how slow "under load" can be),
9039        // make completion deterministically impossible inside the test
9040        // window: `sleep` is a real interruptible await point (it races
9041        // `tokio::time::sleep` against the same cancellation token — see
9042        // `tools/builtin/sleep.rs`), so a per-iteration sleep both gives
9043        // cancellation somewhere to land almost immediately AND, at enough
9044        // iterations, makes natural completion take far longer than the
9045        // bounded wait below. The outer timeout is the "must not hang CI if
9046        // cancellation is broken" backstop: it fails loudly well before the
9047        // loop could ever finish on its own.
9048        const ITERATIONS: u32 = 2000;
9049        const PER_ITERATION_SLEEP_SECS: f64 = 0.05;
9050        let bound = std::time::Duration::from_secs(10);
9051        let script = format!("for i in $(seq 1 {ITERATIONS}); do X=$i; sleep {PER_ITERATION_SLEEP_SECS}; done");
9052
9053        let result = tokio::time::timeout(bound, kernel.execute(&script))
9054            .await
9055            .unwrap_or_else(|_| {
9056                panic!(
9057                    "for-loop did not return within {bound:?} — cancellation support looks \
9058                     broken (an uncancelled loop needs ~{:.0}s to finish on its own, far \
9059                     longer than this bound)",
9060                    ITERATIONS as f64 * PER_ITERATION_SLEEP_SECS
9061                )
9062            })
9063            .expect("execute failed");
9064
9065        assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
9066
9067        // The loop variable should be set to something well short of the full
9068        // iteration count — i.e. cancellation landed long before the loop
9069        // could complete on its own.
9070        let x = kernel.get_var("X").await;
9071        if let Some(Value::Int(n)) = x {
9072            assert!(
9073                n < i64::from(ITERATIONS),
9074                "loop should have been interrupted before finishing, got X={n}"
9075            );
9076        }
9077    }
9078
9079    #[tokio::test]
9080    async fn test_cancel_interrupts_while_loop() {
9081        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
9082        kernel.execute("COUNT=0").await.expect("init failed");
9083
9084        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
9085
9086        let result = kernel
9087            .execute("while true; do COUNT=$((COUNT + 1)); done")
9088            .await
9089            .expect("execute failed");
9090
9091        assert_eq!(result.code, 130);
9092
9093        let count = kernel.get_var("COUNT").await;
9094        if let Some(Value::Int(n)) = count {
9095            assert!(n > 0, "loop should have run at least once");
9096        }
9097    }
9098
9099    #[tokio::test]
9100    async fn test_reset_after_cancel() {
9101        // After cancellation, the next execute() should work normally
9102        let kernel = Kernel::transient().expect("failed to create kernel");
9103        kernel.cancel(); // cancel with nothing running
9104
9105        let result = kernel.execute("echo hello").await.expect("execute failed");
9106        assert!(result.ok(), "execute after cancel should succeed");
9107        assert_eq!(result.text_out().trim(), "hello");
9108    }
9109
9110    #[tokio::test]
9111    async fn test_cancel_interrupts_statement_sequence() {
9112        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
9113
9114        // Schedule cancel after the first statement runs but before sleep finishes
9115        schedule_cancel(&kernel, std::time::Duration::from_millis(50));
9116
9117        let result = kernel
9118            .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
9119            .await
9120            .expect("execute failed");
9121
9122        assert_eq!(result.code, 130);
9123
9124        // STEP should be 1 (set before sleep), not 2 or 3
9125        let step = kernel.get_var("STEP").await;
9126        assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
9127    }
9128
9129    // ═══════════════════════════════════════════════════════════════════════════
9130    // Case Statement Tests
9131    // ═══════════════════════════════════════════════════════════════════════════
9132
9133    #[tokio::test]
9134    async fn test_case_simple_match() {
9135        let kernel = Kernel::transient().expect("failed to create kernel");
9136
9137        let result = kernel
9138            .execute(r#"
9139                case "hello" in
9140                    hello) echo "matched hello" ;;
9141                    world) echo "matched world" ;;
9142                esac
9143            "#)
9144            .await
9145            .expect("case failed");
9146
9147        assert!(result.ok());
9148        assert_eq!(result.text_out().trim(), "matched hello");
9149    }
9150
9151    #[tokio::test]
9152    async fn test_case_wildcard_match() {
9153        let kernel = Kernel::transient().expect("failed to create kernel");
9154
9155        let result = kernel
9156            .execute(r#"
9157                case "main.rs" in
9158                    *.py) echo "Python" ;;
9159                    *.rs) echo "Rust" ;;
9160                    *) echo "Unknown" ;;
9161                esac
9162            "#)
9163            .await
9164            .expect("case failed");
9165
9166        assert!(result.ok());
9167        assert_eq!(result.text_out().trim(), "Rust");
9168    }
9169
9170    #[tokio::test]
9171    async fn test_case_default_match() {
9172        let kernel = Kernel::transient().expect("failed to create kernel");
9173
9174        let result = kernel
9175            .execute(r#"
9176                case "unknown.xyz" in
9177                    *.py) echo "Python" ;;
9178                    *.rs) echo "Rust" ;;
9179                    *) echo "Default" ;;
9180                esac
9181            "#)
9182            .await
9183            .expect("case failed");
9184
9185        assert!(result.ok());
9186        assert_eq!(result.text_out().trim(), "Default");
9187    }
9188
9189    #[tokio::test]
9190    async fn test_case_no_match() {
9191        let kernel = Kernel::transient().expect("failed to create kernel");
9192
9193        // Case with no default branch and no match
9194        let result = kernel
9195            .execute(r#"
9196                case "nope" in
9197                    "yes") echo "yes" ;;
9198                    "no") echo "no" ;;
9199                esac
9200            "#)
9201            .await
9202            .expect("case failed");
9203
9204        assert!(result.ok());
9205        assert!(result.text_out().is_empty(), "no match should produce empty output");
9206    }
9207
9208    #[tokio::test]
9209    async fn test_case_with_variable() {
9210        let kernel = Kernel::transient().expect("failed to create kernel");
9211
9212        kernel.execute(r#"LANG="rust""#).await.expect("set failed");
9213
9214        let result = kernel
9215            .execute(r#"
9216                case ${LANG} in
9217                    python) echo "snake" ;;
9218                    rust) echo "crab" ;;
9219                    go) echo "gopher" ;;
9220                esac
9221            "#)
9222            .await
9223            .expect("case failed");
9224
9225        assert!(result.ok());
9226        assert_eq!(result.text_out().trim(), "crab");
9227    }
9228
9229    #[tokio::test]
9230    async fn test_case_multiple_patterns() {
9231        let kernel = Kernel::transient().expect("failed to create kernel");
9232
9233        let result = kernel
9234            .execute(r#"
9235                case "yes" in
9236                    "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
9237                    "n"|"no"|"N"|"NO") echo "negative" ;;
9238                esac
9239            "#)
9240            .await
9241            .expect("case failed");
9242
9243        assert!(result.ok());
9244        assert_eq!(result.text_out().trim(), "affirmative");
9245    }
9246
9247    #[tokio::test]
9248    async fn test_case_glob_question_mark() {
9249        let kernel = Kernel::transient().expect("failed to create kernel");
9250
9251        let result = kernel
9252            .execute(r#"
9253                case "test1" in
9254                    test?) echo "matched test?" ;;
9255                    *) echo "default" ;;
9256                esac
9257            "#)
9258            .await
9259            .expect("case failed");
9260
9261        assert!(result.ok());
9262        assert_eq!(result.text_out().trim(), "matched test?");
9263    }
9264
9265    #[tokio::test]
9266    async fn test_case_char_class() {
9267        let kernel = Kernel::transient().expect("failed to create kernel");
9268
9269        let result = kernel
9270            .execute(r#"
9271                case "Yes" in
9272                    [Yy]*) echo "yes-like" ;;
9273                    [Nn]*) echo "no-like" ;;
9274                esac
9275            "#)
9276            .await
9277            .expect("case failed");
9278
9279        assert!(result.ok());
9280        assert_eq!(result.text_out().trim(), "yes-like");
9281    }
9282
9283    // ═══════════════════════════════════════════════════════════════════════════
9284    // Cat Stdin Tests
9285    // ═══════════════════════════════════════════════════════════════════════════
9286
9287    #[tokio::test]
9288    async fn test_cat_from_pipeline() {
9289        let kernel = Kernel::transient().expect("failed to create kernel");
9290
9291        let result = kernel
9292            .execute(r#"echo "piped text" | cat"#)
9293            .await
9294            .expect("cat pipeline failed");
9295
9296        assert!(result.ok(), "cat failed: {}", result.err);
9297        assert_eq!(result.text_out().trim(), "piped text");
9298    }
9299
9300    #[tokio::test]
9301    async fn test_cat_from_pipeline_multiline() {
9302        let kernel = Kernel::transient().expect("failed to create kernel");
9303
9304        let result = kernel
9305            .execute(r#"echo "line1\nline2" | cat -n"#)
9306            .await
9307            .expect("cat pipeline failed");
9308
9309        assert!(result.ok(), "cat failed: {}", result.err);
9310        assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
9311    }
9312
9313    // ═══════════════════════════════════════════════════════════════════════════
9314    // Heredoc Tests
9315    // ═══════════════════════════════════════════════════════════════════════════
9316
9317    #[tokio::test]
9318    async fn test_heredoc_basic() {
9319        let kernel = Kernel::transient().expect("failed to create kernel");
9320
9321        let result = kernel
9322            .execute("cat <<EOF\nhello\nEOF")
9323            .await
9324            .expect("heredoc failed");
9325
9326        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
9327        assert_eq!(result.text_out().trim(), "hello");
9328    }
9329
9330    #[tokio::test]
9331    async fn test_arithmetic_in_string() {
9332        let kernel = Kernel::transient().expect("failed to create kernel");
9333
9334        let result = kernel
9335            .execute(r#"echo "result: $((1 + 2))""#)
9336            .await
9337            .expect("arithmetic in string failed");
9338
9339        assert!(result.ok(), "echo failed: {}", result.err);
9340        assert_eq!(result.text_out().trim(), "result: 3");
9341    }
9342
9343    #[tokio::test]
9344    async fn test_heredoc_multiline() {
9345        let kernel = Kernel::transient().expect("failed to create kernel");
9346
9347        let result = kernel
9348            .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
9349            .await
9350            .expect("heredoc failed");
9351
9352        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
9353        assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
9354        assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
9355        assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
9356    }
9357
9358    #[tokio::test]
9359    async fn test_heredoc_variable_expansion() {
9360        // Bug N: unquoted heredoc should expand variables
9361        let kernel = Kernel::transient().expect("failed to create kernel");
9362
9363        kernel.execute("GREETING=hello").await.expect("set var");
9364
9365        let result = kernel
9366            .execute("cat <<EOF\n$GREETING world\nEOF")
9367            .await
9368            .expect("heredoc expansion failed");
9369
9370        assert!(result.ok(), "heredoc expansion failed: {}", result.err);
9371        assert_eq!(result.text_out().trim(), "hello world");
9372    }
9373
9374    #[tokio::test]
9375    async fn test_heredoc_quoted_no_expansion() {
9376        // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
9377        let kernel = Kernel::transient().expect("failed to create kernel");
9378
9379        kernel.execute("GREETING=hello").await.expect("set var");
9380
9381        let result = kernel
9382            .execute("cat <<'EOF'\n$GREETING world\nEOF")
9383            .await
9384            .expect("quoted heredoc failed");
9385
9386        assert!(result.ok(), "quoted heredoc failed: {}", result.err);
9387        assert_eq!(result.text_out().trim(), "$GREETING world");
9388    }
9389
9390    #[tokio::test]
9391    async fn test_heredoc_default_value_expansion() {
9392        // Bug N: ${VAR:-default} should expand in unquoted heredocs
9393        let kernel = Kernel::transient().expect("failed to create kernel");
9394
9395        let result = kernel
9396            .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
9397            .await
9398            .expect("heredoc default expansion failed");
9399
9400        assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
9401        assert_eq!(result.text_out().trim(), "fallback");
9402    }
9403
9404    // ═══════════════════════════════════════════════════════════════════════════
9405    // Read Builtin Tests
9406    // ═══════════════════════════════════════════════════════════════════════════
9407
9408    #[tokio::test]
9409    async fn test_read_from_pipeline() {
9410        let kernel = Kernel::transient().expect("failed to create kernel");
9411
9412        // Pipe input to read
9413        let result = kernel
9414            .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
9415            .await
9416            .expect("read pipeline failed");
9417
9418        assert!(result.ok(), "read failed: {}", result.err);
9419        assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
9420    }
9421
9422    #[tokio::test]
9423    async fn test_read_multiple_vars_from_pipeline() {
9424        let kernel = Kernel::transient().expect("failed to create kernel");
9425
9426        let result = kernel
9427            .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
9428            .await
9429            .expect("read pipeline failed");
9430
9431        assert!(result.ok(), "read failed: {}", result.err);
9432        assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
9433    }
9434
9435    // ═══════════════════════════════════════════════════════════════════════════
9436    // Shell-Style Function Tests
9437    // ═══════════════════════════════════════════════════════════════════════════
9438
9439    #[tokio::test]
9440    async fn test_posix_function_with_positional_params() {
9441        let kernel = Kernel::transient().expect("failed to create kernel");
9442
9443        // Define POSIX-style function
9444        kernel
9445            .execute(r#"greet() { echo "Hello, $1!" }"#)
9446            .await
9447            .expect("function definition failed");
9448
9449        // Call the function
9450        let result = kernel
9451            .execute(r#"greet "Amy""#)
9452            .await
9453            .expect("function call failed");
9454
9455        assert!(result.ok(), "greet failed: {}", result.err);
9456        assert_eq!(result.text_out().trim(), "Hello, Amy!");
9457    }
9458
9459    #[tokio::test]
9460    async fn test_posix_function_multiple_args() {
9461        let kernel = Kernel::transient().expect("failed to create kernel");
9462
9463        // Define function using $1 and $2
9464        kernel
9465            .execute(r#"add_greeting() { echo "$1 $2!" }"#)
9466            .await
9467            .expect("function definition failed");
9468
9469        // Call the function
9470        let result = kernel
9471            .execute(r#"add_greeting "Hello" "World""#)
9472            .await
9473            .expect("function call failed");
9474
9475        assert!(result.ok(), "function failed: {}", result.err);
9476        assert_eq!(result.text_out().trim(), "Hello World!");
9477    }
9478
9479    #[tokio::test]
9480    async fn test_bash_function_with_positional_params() {
9481        let kernel = Kernel::transient().expect("failed to create kernel");
9482
9483        // Define bash-style function (function keyword, no parens)
9484        kernel
9485            .execute(r#"function greet { echo "Hi $1" }"#)
9486            .await
9487            .expect("function definition failed");
9488
9489        // Call the function
9490        let result = kernel
9491            .execute(r#"greet "Bob""#)
9492            .await
9493            .expect("function call failed");
9494
9495        assert!(result.ok(), "greet failed: {}", result.err);
9496        assert_eq!(result.text_out().trim(), "Hi Bob");
9497    }
9498
9499    #[tokio::test]
9500    async fn test_shell_function_with_all_args() {
9501        let kernel = Kernel::transient().expect("failed to create kernel");
9502
9503        // Define function using $@ (all args)
9504        kernel
9505            .execute(r#"echo_all() { echo "args: $@" }"#)
9506            .await
9507            .expect("function definition failed");
9508
9509        // Call with multiple args
9510        let result = kernel
9511            .execute(r#"echo_all "a" "b" "c""#)
9512            .await
9513            .expect("function call failed");
9514
9515        assert!(result.ok(), "function failed: {}", result.err);
9516        assert_eq!(result.text_out().trim(), "args: a b c");
9517    }
9518
9519    #[tokio::test]
9520    async fn test_shell_function_with_arg_count() {
9521        let kernel = Kernel::transient().expect("failed to create kernel");
9522
9523        // Define function using $# (arg count)
9524        kernel
9525            .execute(r#"count_args() { echo "count: $#" }"#)
9526            .await
9527            .expect("function definition failed");
9528
9529        // Call with three args
9530        let result = kernel
9531            .execute(r#"count_args "x" "y" "z""#)
9532            .await
9533            .expect("function call failed");
9534
9535        assert!(result.ok(), "function failed: {}", result.err);
9536        assert_eq!(result.text_out().trim(), "count: 3");
9537    }
9538
9539    #[tokio::test]
9540    async fn test_shell_function_shared_scope() {
9541        let kernel = Kernel::transient().expect("failed to create kernel");
9542
9543        // Set a variable in parent scope
9544        kernel
9545            .execute(r#"PARENT_VAR="visible""#)
9546            .await
9547            .expect("set failed");
9548
9549        // Define shell function that reads and writes parent variable
9550        kernel
9551            .execute(r#"modify_parent() {
9552                echo "saw: ${PARENT_VAR}"
9553                PARENT_VAR="changed by function"
9554            }"#)
9555            .await
9556            .expect("function definition failed");
9557
9558        // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
9559        let result = kernel.execute("modify_parent").await.expect("function failed");
9560
9561        assert!(
9562            result.text_out().contains("visible"),
9563            "Shell function should access parent scope, got: {}",
9564            result.text_out()
9565        );
9566
9567        // Parent variable should be modified
9568        let var = kernel.get_var("PARENT_VAR").await;
9569        assert_eq!(
9570            var,
9571            Some(Value::String("changed by function".into())),
9572            "Shell function should modify parent scope"
9573        );
9574    }
9575
9576    // ═══════════════════════════════════════════════════════════════════════════
9577    // Script Execution via PATH Tests
9578    // ═══════════════════════════════════════════════════════════════════════════
9579
9580    #[tokio::test]
9581    async fn test_script_execution_from_path() {
9582        let kernel = Kernel::transient().expect("failed to create kernel");
9583
9584        // Create /bin directory and script
9585        kernel.execute(r#"mkdir "/bin""#).await.ok();
9586        kernel
9587            .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
9588            .await
9589            .expect("write script failed");
9590
9591        // Set PATH to /bin
9592        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
9593
9594        // Call script by name (without .kai extension)
9595        let result = kernel
9596            .execute("hello")
9597            .await
9598            .expect("script execution failed");
9599
9600        assert!(result.ok(), "script failed: {}", result.err);
9601        assert_eq!(result.text_out().trim(), "Hello from script!");
9602    }
9603
9604    #[tokio::test]
9605    async fn test_script_with_args() {
9606        let kernel = Kernel::transient().expect("failed to create kernel");
9607
9608        // Create script that uses positional params
9609        kernel.execute(r#"mkdir "/bin""#).await.ok();
9610        kernel
9611            .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
9612            .await
9613            .expect("write script failed");
9614
9615        // Set PATH
9616        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
9617
9618        // Call script with arg
9619        let result = kernel
9620            .execute(r#"greet "World""#)
9621            .await
9622            .expect("script execution failed");
9623
9624        assert!(result.ok(), "script failed: {}", result.err);
9625        assert_eq!(result.text_out().trim(), "Hello, World!");
9626    }
9627
9628    #[tokio::test]
9629    async fn test_script_not_found() {
9630        let kernel = Kernel::transient().expect("failed to create kernel");
9631
9632        // Set empty PATH
9633        kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
9634
9635        // Call non-existent script
9636        let result = kernel
9637            .execute("noscript")
9638            .await
9639            .expect("execution failed");
9640
9641        assert!(!result.ok(), "should fail with command not found");
9642        assert_eq!(result.code, 127);
9643        assert!(result.err.contains("command not found"));
9644    }
9645
9646    #[tokio::test]
9647    async fn test_script_path_search_order() {
9648        let kernel = Kernel::transient().expect("failed to create kernel");
9649
9650        // Create two directories with same-named script
9651        // Note: using "myscript" not "test" to avoid conflict with test builtin
9652        kernel.execute(r#"mkdir "/first""#).await.ok();
9653        kernel.execute(r#"mkdir "/second""#).await.ok();
9654        kernel
9655            .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
9656            .await
9657            .expect("write failed");
9658        kernel
9659            .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
9660            .await
9661            .expect("write failed");
9662
9663        // Set PATH with first before second
9664        kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
9665
9666        // Should find first one
9667        let result = kernel
9668            .execute("myscript")
9669            .await
9670            .expect("script execution failed");
9671
9672        assert!(result.ok(), "script failed: {}", result.err);
9673        assert_eq!(result.text_out().trim(), "from first");
9674    }
9675
9676    // ═══════════════════════════════════════════════════════════════════════════
9677    // Special Variable Tests ($?, $$, unset vars)
9678    // ═══════════════════════════════════════════════════════════════════════════
9679
9680    #[tokio::test]
9681    async fn test_last_exit_code_success() {
9682        let kernel = Kernel::transient().expect("failed to create kernel");
9683
9684        // true exits with 0
9685        let result = kernel.execute("true; echo $?").await.expect("execution failed");
9686        assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
9687    }
9688
9689    #[tokio::test]
9690    async fn test_last_exit_code_failure() {
9691        let kernel = Kernel::transient().expect("failed to create kernel");
9692
9693        // false exits with 1
9694        let result = kernel.execute("false; echo $?").await.expect("execution failed");
9695        assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
9696    }
9697
9698    #[tokio::test]
9699    async fn test_current_pid() {
9700        let kernel = Kernel::transient().expect("failed to create kernel");
9701
9702        let result = kernel.execute("echo $$").await.expect("execution failed");
9703        // PID should be a positive number
9704        let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
9705        assert!(pid > 0, "PID should be positive");
9706    }
9707
9708    #[tokio::test]
9709    async fn test_unset_variable_expands_to_empty() {
9710        let kernel = Kernel::transient().expect("failed to create kernel");
9711
9712        // Unset variable in interpolation should be empty
9713        let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
9714        assert_eq!(result.text_out().trim(), "prefix::suffix");
9715    }
9716
9717    #[tokio::test]
9718    async fn test_eq_ne_operators() {
9719        let kernel = Kernel::transient().expect("failed to create kernel");
9720
9721        // Test -eq operator
9722        let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
9723        assert_eq!(result.text_out().trim(), "eq works");
9724
9725        // Test -ne operator
9726        let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
9727        assert_eq!(result.text_out().trim(), "ne works");
9728
9729        // Test -eq with different values
9730        let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
9731        assert_eq!(result.text_out().trim(), "correct");
9732    }
9733
9734    #[tokio::test]
9735    async fn test_escaped_dollar_in_string() {
9736        let kernel = Kernel::transient().expect("failed to create kernel");
9737
9738        // \$ should produce literal $
9739        let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
9740        assert_eq!(result.text_out().trim(), "$100");
9741    }
9742
9743    #[tokio::test]
9744    async fn test_special_vars_in_interpolation() {
9745        let kernel = Kernel::transient().expect("failed to create kernel");
9746
9747        // Test $? in string interpolation
9748        let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
9749        assert_eq!(result.text_out().trim(), "exit: 0");
9750
9751        // Test $$ in string interpolation
9752        let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
9753        assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
9754        let text = result.text_out();
9755        let pid_part = text.trim().strip_prefix("pid: ").unwrap();
9756        let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
9757    }
9758
9759    // ═══════════════════════════════════════════════════════════════════════════
9760    // Command Substitution Tests
9761    // ═══════════════════════════════════════════════════════════════════════════
9762
9763    #[tokio::test]
9764    async fn test_command_subst_assignment() {
9765        let kernel = Kernel::transient().expect("failed to create kernel");
9766
9767        // Command substitution in assignment
9768        let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
9769        assert_eq!(result.text_out().trim(), "hello");
9770    }
9771
9772    #[tokio::test]
9773    async fn test_command_subst_with_args() {
9774        let kernel = Kernel::transient().expect("failed to create kernel");
9775
9776        // Command substitution with string argument
9777        let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
9778        assert_eq!(result.text_out().trim(), "a b c");
9779    }
9780
9781    #[tokio::test]
9782    async fn test_command_subst_nested_vars() {
9783        let kernel = Kernel::transient().expect("failed to create kernel");
9784
9785        // Variables inside command substitution
9786        let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
9787        assert_eq!(result.text_out().trim(), "hello world");
9788    }
9789
9790    #[tokio::test]
9791    async fn test_background_job_basic() {
9792        use std::time::Duration;
9793
9794        let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
9795
9796        // Run a simple background command, redirecting its output to a
9797        // memory-backed file. `/v/jobs/{id}/stdout` would work too (and is
9798        // live); the redirect is what this test asserts on.
9799        let result = kernel.execute("echo hello > /tmp/basic_out.txt &").await.expect("execution failed");
9800        assert!(result.ok(), "background command should succeed: {}", result.err);
9801        assert!(result.err.contains("[1]"), "announcement rides stderr: {:?}", result.err);
9802
9803        // Give the job time to complete
9804        tokio::time::sleep(Duration::from_millis(100)).await;
9805
9806        // Check job status
9807        let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
9808        assert!(status.ok(), "status should succeed: {}", status.err);
9809        assert!(
9810            status.text_out().contains("done:") || status.text_out().contains("running"),
9811            "should have valid status: {}",
9812            status.text_out()
9813        );
9814
9815        // Check the redirected output
9816        let stdout = kernel.execute("cat /tmp/basic_out.txt").await.expect("output check failed");
9817        assert!(stdout.ok());
9818        assert!(stdout.text_out().contains("hello"));
9819    }
9820
9821    #[tokio::test]
9822    async fn test_heredoc_piped_to_command() {
9823        // Bug 4: heredoc content should pipe through to next command
9824        let kernel = Kernel::transient().expect("kernel");
9825        let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
9826        assert!(result.ok(), "heredoc | cat failed: {}", result.err);
9827        assert_eq!(result.text_out().trim(), "hello world");
9828    }
9829
9830    /// A transient kernel paired with a real, auto-cleaning tempdir. The
9831    /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
9832    /// tests need actual files on disk. Hold the returned `TempDir` for the
9833    /// test's lifetime: it removes the directory tree on drop — including on
9834    /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
9835    /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
9836    /// as a string for interpolation into scripts.
9837    fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
9838        let kernel = Kernel::transient().expect("kernel");
9839        let tmp = tempfile::tempdir().expect("tempdir");
9840        let dir = tmp.path().display().to_string();
9841        (kernel, tmp, dir)
9842    }
9843
9844    #[tokio::test]
9845    async fn test_for_loop_glob_iterates() {
9846        // Bug 1: for F in $(glob ...) should iterate per file, not once
9847        let (kernel, _tmp, dir) = transient_with_tempdir();
9848        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9849        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
9850        let result = kernel.execute(&format!(r#"
9851            N=0
9852            for F in $(glob "{dir}/*.txt"); do
9853                N=$((N + 1))
9854            done
9855            echo $N
9856        "#)).await.unwrap();
9857        assert!(result.ok(), "for glob failed: {}", result.err);
9858        assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
9859    }
9860
9861    #[tokio::test]
9862    async fn test_bare_glob_expansion_echo() {
9863        let (kernel, _tmp, dir) = transient_with_tempdir();
9864        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9865        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
9866        kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
9867        kernel.execute(&format!("cd {dir}")).await.unwrap();
9868        let result = kernel.execute("echo *.txt").await.unwrap();
9869        assert!(result.ok(), "echo *.txt failed: {}", result.err);
9870        let out = result.text_out();
9871        let out = out.trim();
9872        // Should contain both .txt files (order may vary)
9873        assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
9874        assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
9875        assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
9876    }
9877
9878    #[tokio::test]
9879    async fn test_bare_glob_no_matches_errors() {
9880        let (kernel, _tmp, dir) = transient_with_tempdir();
9881        kernel.execute(&format!("cd {dir}")).await.unwrap();
9882        let result = kernel.execute("echo *.nonexistent").await;
9883        match &result {
9884            Ok(exec) => {
9885                // No-match glob should produce a non-zero exit code
9886                assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
9887                assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
9888            }
9889            Err(e) => {
9890                assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
9891            }
9892        }
9893    }
9894
9895    #[tokio::test]
9896    async fn test_bare_glob_disabled_with_set() {
9897        let (kernel, _tmp, dir) = transient_with_tempdir();
9898        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9899        kernel.execute(&format!("cd {dir}")).await.unwrap();
9900        // Disable glob expansion
9901        kernel.execute("set +o glob").await.unwrap();
9902        let result = kernel.execute("echo *.txt").await.unwrap();
9903        // With glob disabled, *.txt should be passed as literal string
9904        assert!(result.ok(), "echo should succeed: {}", result.err);
9905        assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
9906    }
9907
9908    #[tokio::test]
9909    async fn test_bare_glob_quoted_not_expanded() {
9910        let (kernel, _tmp, dir) = transient_with_tempdir();
9911        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9912        kernel.execute(&format!("cd {dir}")).await.unwrap();
9913        // Quoted globs should NOT expand
9914        let result = kernel.execute("echo \"*.txt\"").await.unwrap();
9915        assert!(result.ok(), "echo should succeed: {}", result.err);
9916        assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
9917    }
9918
9919    #[tokio::test]
9920    async fn test_bare_glob_for_loop() {
9921        let (kernel, _tmp, dir) = transient_with_tempdir();
9922        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9923        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
9924        kernel.execute(&format!("cd {dir}")).await.unwrap();
9925        let result = kernel.execute(r#"
9926            N=0
9927            for f in *.txt; do
9928                N=$((N + 1))
9929            done
9930            echo $N
9931        "#).await.unwrap();
9932        assert!(result.ok(), "for loop failed: {}", result.err);
9933        assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
9934    }
9935
9936    #[tokio::test]
9937    async fn test_glob_in_assignment_is_literal() {
9938        let kernel = Kernel::transient().expect("kernel");
9939        let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
9940        assert!(result.ok());
9941        assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
9942    }
9943
9944    #[tokio::test]
9945    async fn test_glob_in_test_expr_is_literal() {
9946        let kernel = Kernel::transient().expect("kernel");
9947        let result = kernel.execute(r#"
9948            if [[ *.txt == "*.txt" ]]; then
9949                echo "match"
9950            else
9951                echo "no"
9952            fi
9953        "#).await.unwrap();
9954        assert!(result.ok());
9955        assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
9956    }
9957
9958    #[tokio::test]
9959    async fn test_command_subst_echo_not_iterable() {
9960        // Regression guard: $(echo "a b c") must remain a single string
9961        let kernel = Kernel::transient().expect("kernel");
9962        let result = kernel.execute(r#"
9963            N=0
9964            for X in $(echo "a b c"); do N=$((N + 1)); done
9965            echo $N
9966        "#).await.unwrap();
9967        assert!(result.ok());
9968        assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
9969    }
9970
9971    // -- accumulate_result / newline tests --
9972
9973    #[test]
9974    fn test_accumulate_preserves_own_newlines() {
9975        // Outputs concatenate verbatim — a command's own trailing newline is
9976        // kept, none is invented.
9977        let mut acc = ExecResult::success("line1\n");
9978        let new = ExecResult::success("line2\n");
9979        accumulate_result(&mut acc, &new);
9980        assert_eq!(&*acc.text_out(), "line1\nline2\n");
9981        assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
9982    }
9983
9984    #[test]
9985    fn test_accumulate_inserts_no_separator() {
9986        // No artificial separator: `printf a; printf b` style concatenates to
9987        // `ab`, matching bash (regression for the 2026-06-09 finding).
9988        let mut acc = ExecResult::success("line1");
9989        let new = ExecResult::success("line2");
9990        accumulate_result(&mut acc, &new);
9991        assert_eq!(&*acc.text_out(), "line1line2");
9992    }
9993
9994    #[test]
9995    fn test_accumulate_empty_into_nonempty() {
9996        let mut acc = ExecResult::success("");
9997        let new = ExecResult::success("hello\n");
9998        accumulate_result(&mut acc, &new);
9999        assert_eq!(&*acc.text_out(), "hello\n");
10000    }
10001
10002    #[test]
10003    fn test_accumulate_nonempty_into_empty() {
10004        let mut acc = ExecResult::success("hello\n");
10005        let new = ExecResult::success("");
10006        accumulate_result(&mut acc, &new);
10007        assert_eq!(&*acc.text_out(), "hello\n");
10008    }
10009
10010    #[test]
10011    fn test_accumulate_stderr_no_double_newlines() {
10012        let mut acc = ExecResult::failure(1, "err1\n");
10013        let new = ExecResult::failure(1, "err2\n");
10014        accumulate_result(&mut acc, &new);
10015        assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
10016    }
10017
10018    #[tokio::test]
10019    async fn test_multiple_echo_no_blank_lines() {
10020        let kernel = Kernel::transient().expect("kernel");
10021        let result = kernel
10022            .execute("echo one\necho two\necho three")
10023            .await
10024            .expect("execution failed");
10025        assert!(result.ok());
10026        assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
10027    }
10028
10029    #[tokio::test]
10030    async fn test_for_loop_no_blank_lines() {
10031        let kernel = Kernel::transient().expect("kernel");
10032        let result = kernel
10033            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
10034            .await
10035            .expect("execution failed");
10036        assert!(result.ok());
10037        assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
10038    }
10039
10040    #[tokio::test]
10041    async fn test_for_command_subst_no_blank_lines() {
10042        let kernel = Kernel::transient().expect("kernel");
10043        let result = kernel
10044            .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
10045            .await
10046            .expect("execution failed");
10047        assert!(result.ok());
10048        assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
10049    }
10050
10051    // ------------------------------------------------------------------
10052    // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
10053    // ------------------------------------------------------------------
10054
10055    /// Helper: a throwaway schema with one `--pair` param declared as
10056    /// consuming two positionals per occurrence. Modelled after what
10057    /// jq_native will declare for `--arg` / `--argjson`.
10058    fn multi_consume_schema() -> crate::tools::ToolSchema {
10059        use crate::tools::{ParamSchema, ToolSchema};
10060        ToolSchema::new("test", "multi-consume smoke")
10061            .param(
10062                ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
10063                    .consumes(2),
10064            )
10065    }
10066
10067    fn pos(s: &str) -> Arg {
10068        Arg::Positional(Expr::Literal(Value::String(s.to_string())))
10069    }
10070
10071    #[tokio::test]
10072    async fn build_args_multi_consume_single_occurrence() {
10073        let kernel = Kernel::transient().expect("kernel");
10074        let schema = multi_consume_schema();
10075        // Simulates:  test --pair NAME VALUE filter
10076        let args = vec![
10077            Arg::LongFlag("pair".into()),
10078            pos("NAME"),
10079            pos("VALUE"),
10080            pos("filter"),
10081        ];
10082        let built = kernel
10083            .build_args_async(&args, Some(&schema))
10084            .await
10085            .expect("build_args should succeed");
10086
10087        // `--pair` + its two positionals are consumed into named["pair"],
10088        // which becomes an outer array of one inner 2-element array.
10089        let pair = built.named.get("pair").expect("named[pair] missing");
10090        match pair {
10091            Value::Json(serde_json::Value::Array(occurrences)) => {
10092                assert_eq!(occurrences.len(), 1, "expected one occurrence");
10093                match &occurrences[0] {
10094                    serde_json::Value::Array(values) => {
10095                        assert_eq!(values.len(), 2, "pair must have 2 values");
10096                        assert_eq!(values[0], serde_json::Value::String("NAME".into()));
10097                        assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
10098                    }
10099                    other => panic!("expected inner array, got {other:?}"),
10100                }
10101            }
10102            other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
10103        }
10104
10105        // The un-consumed positional ("filter") remains in `positional`.
10106        assert_eq!(built.positional.len(), 1);
10107        assert_eq!(built.positional[0], Value::String("filter".into()));
10108    }
10109    #[tokio::test]
10110    async fn build_args_multi_consume_two_occurrences_accumulate() {
10111        let kernel = Kernel::transient().expect("kernel");
10112        let schema = multi_consume_schema();
10113        // Simulates:  test --pair A 1 --pair B 2 filter
10114        let args = vec![
10115            Arg::LongFlag("pair".into()),
10116            pos("A"),
10117            pos("1"),
10118            Arg::LongFlag("pair".into()),
10119            pos("B"),
10120            pos("2"),
10121            pos("filter"),
10122        ];
10123        let built = kernel
10124            .build_args_async(&args, Some(&schema))
10125            .await
10126            .expect("build_args should succeed");
10127
10128        let pair = built.named.get("pair").expect("named[pair] missing");
10129        match pair {
10130            Value::Json(serde_json::Value::Array(occurrences)) => {
10131                assert_eq!(occurrences.len(), 2, "expected two occurrences");
10132                // Preserved in invocation order.
10133                match &occurrences[0] {
10134                    serde_json::Value::Array(values) => {
10135                        assert_eq!(values[0], serde_json::Value::String("A".into()));
10136                        assert_eq!(values[1], serde_json::Value::String("1".into()));
10137                    }
10138                    other => panic!("expected inner array, got {other:?}"),
10139                }
10140                match &occurrences[1] {
10141                    serde_json::Value::Array(values) => {
10142                        assert_eq!(values[0], serde_json::Value::String("B".into()));
10143                        assert_eq!(values[1], serde_json::Value::String("2".into()));
10144                    }
10145                    other => panic!("expected inner array, got {other:?}"),
10146                }
10147            }
10148            other => panic!("expected Json(Array(...)), got {other:?}"),
10149        }
10150    }
10151
10152    // ── undeclared space-form flag under map_positionals (kj --type val) ──
10153    //
10154    // A backend/MCP tool whose schema does NOT declare a flag must not let
10155    // `--flag value` (space form) silently divorce the value: that was a
10156    // privilege-escalation-by-typo against kaijutsu.
10157    // kaish fails loud rather than guessing.
10158
10159    use crate::tools::{ParamSchema, ToolSchema};
10160
10161    /// Backend-style schema (map_positionals) declaring only a `name`
10162    /// positional — `--type` is intentionally undeclared.
10163    fn kj_like_schema() -> ToolSchema {
10164        ToolSchema::new("kj", "incomplete backend schema")
10165            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
10166            .with_positional_mapping()
10167    }
10168
10169    #[tokio::test]
10170    async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
10171        let kernel = Kernel::transient().expect("kernel");
10172        let schema = kj_like_schema();
10173        // kj context create exp --type explorer
10174        let args = vec![
10175            pos("context"),
10176            pos("create"),
10177            pos("exp"),
10178            Arg::LongFlag("type".into()),
10179            pos("explorer"),
10180        ];
10181        let err = kernel
10182            .build_args_async(&args, Some(&schema))
10183            .await
10184            .expect_err("undeclared --type with a space value must fail loud");
10185        let msg = err.to_string();
10186        assert!(msg.contains("--type"), "message should name the flag: {msg}");
10187        assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
10188        assert!(msg.contains("kj"), "message should name the tool: {msg}");
10189    }
10190
10191    #[tokio::test]
10192    async fn build_args_declared_space_flag_still_binds() {
10193        let kernel = Kernel::transient().expect("kernel");
10194        // Same tool, but now the schema DECLARES --type as a string param.
10195        let schema = ToolSchema::new("kj", "complete schema")
10196            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
10197            .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
10198            .with_positional_mapping();
10199        let args = vec![
10200            pos("exp"),
10201            Arg::LongFlag("type".into()),
10202            pos("explorer"),
10203        ];
10204        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10205        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
10206    }
10207
10208    #[tokio::test]
10209    async fn build_args_equals_form_binds_for_undeclared_flag() {
10210        let kernel = Kernel::transient().expect("kernel");
10211        let schema = kj_like_schema();
10212        // The unambiguous `=` form must keep working even when undeclared.
10213        let args = vec![
10214            pos("exp"),
10215            Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
10216        ];
10217        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10218        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
10219    }
10220
10221    #[tokio::test]
10222    async fn build_args_undeclared_bool_flag_at_end_is_ok() {
10223        let kernel = Kernel::transient().expect("kernel");
10224        let schema = kj_like_schema();
10225        // No positional follows --force → unambiguously a bare flag.
10226        let args = vec![pos("exp"), Arg::LongFlag("force".into())];
10227        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10228        assert!(built.flags.contains("force"));
10229    }
10230
10231    #[tokio::test]
10232    async fn build_args_undeclared_flag_before_another_flag_is_ok() {
10233        let kernel = Kernel::transient().expect("kernel");
10234        let schema = kj_like_schema();
10235        // --verbose is followed by a flag, not a positional → not ambiguous.
10236        let args = vec![
10237            Arg::LongFlag("verbose".into()),
10238            Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
10239        ];
10240        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10241        assert!(built.flags.contains("verbose"));
10242    }
10243
10244    #[tokio::test]
10245    async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
10246        let kernel = Kernel::transient().expect("kernel");
10247        // Builtins set map_positionals=false; the ambiguity guard must not
10248        // fire there (clap validates their flags separately).
10249        let schema = ToolSchema::new("frobnicate", "builtin-style")
10250            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
10251        let args = vec![Arg::LongFlag("frob".into()), pos("value")];
10252        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10253        assert!(built.flags.contains("frob"));
10254    }
10255
10256    // ── GH #189 item 4: the short-flag half of the same ambiguity guard ──
10257    //
10258    // The long-flag guard above was closed by GH #188; an undeclared SHORT
10259    // flag under a map_positionals schema was still silently defaulting to
10260    // bare bool, divorcing a space-form value (`kj -t explorer`) exactly the
10261    // same way the long-flag case used to.
10262
10263    #[tokio::test]
10264    async fn build_args_undeclared_short_space_flag_errors_under_map_positionals() {
10265        let kernel = Kernel::transient().expect("kernel");
10266        let schema = kj_like_schema();
10267        // kj exp -t explorer
10268        let args = vec![pos("exp"), Arg::ShortFlag("t".into()), pos("explorer")];
10269        let err = kernel
10270            .build_args_async(&args, Some(&schema))
10271            .await
10272            .expect_err("undeclared -t with a space value must fail loud");
10273        let msg = err.to_string();
10274        assert!(msg.contains("-t"), "message should name the flag: {msg}");
10275        assert!(msg.contains("kj"), "message should name the tool: {msg}");
10276    }
10277
10278    #[tokio::test]
10279    async fn build_args_undeclared_short_space_flag_ok_for_builtin_schema() {
10280        let kernel = Kernel::transient().expect("kernel");
10281        // Builtins set map_positionals=false; the ambiguity guard must not
10282        // fire there.
10283        let schema = ToolSchema::new("frobnicate", "builtin-style")
10284            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
10285        let args = vec![Arg::ShortFlag("t".into()), pos("value")];
10286        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10287        assert!(built.flags.contains("t"));
10288    }
10289
10290    // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
10291    //
10292    // A tool exposing a subcommand tree binds flags against the *routed leaf's*
10293    // params, not the root's. The subcommand-path positionals stay positional
10294    // (kj re-parses them with its own clap), and a value flag declared only on
10295    // a deep leaf still binds in space form.
10296
10297    /// kj → context (alias ctx) → create{--type value, --force bool}.
10298    /// map_positionals defaults false on every node (builtin/kj style).
10299    fn kj_tree_schema() -> ToolSchema {
10300        ToolSchema::new("kj", "subcommand tool").subcommand(
10301            ToolSchema::new("context", "context ops")
10302                .with_command_aliases(["ctx"])
10303                .subcommand(
10304                    ToolSchema::new("create", "create context")
10305                        .param(ParamSchema::new("type", "string").with_aliases(["t"]))
10306                        .param(ParamSchema::new("force", "bool")),
10307                ),
10308        )
10309    }
10310
10311    #[tokio::test]
10312    async fn build_args_binds_deep_leaf_value_flag_space_form() {
10313        let kernel = Kernel::transient().expect("kernel");
10314        let schema = kj_tree_schema();
10315        // kj context create --type explorer
10316        let args = vec![
10317            pos("context"),
10318            pos("create"),
10319            Arg::LongFlag("type".into()),
10320            pos("explorer"),
10321        ];
10322        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
10323        // --type (declared only on the create leaf) binds in space form.
10324        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
10325        // The subcommand path survives as positionals for kj to re-parse.
10326        let positionals: Vec<&str> = built
10327            .positional
10328            .iter()
10329            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
10330            .collect();
10331        assert_eq!(positionals, vec!["context", "create"]);
10332    }
10333
10334    #[tokio::test]
10335    async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
10336        let kernel = Kernel::transient().expect("kernel");
10337        let schema = kj_tree_schema();
10338        // kj context create --force somearg  → --force is a leaf bool flag,
10339        // it must NOT consume `somearg`.
10340        let args = vec![
10341            pos("context"),
10342            pos("create"),
10343            Arg::LongFlag("force".into()),
10344            pos("somearg"),
10345        ];
10346        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
10347        assert!(built.flags.contains("force"), "force should be a bare flag");
10348        let positionals: Vec<&str> = built
10349            .positional
10350            .iter()
10351            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
10352            .collect();
10353        assert_eq!(positionals, vec!["context", "create", "somearg"]);
10354    }
10355
10356    #[tokio::test]
10357    async fn build_args_alias_routed_leaf_binds_value_flag() {
10358        let kernel = Kernel::transient().expect("kernel");
10359        let schema = kj_tree_schema();
10360        // kj ctx create -t explorer  → command alias + short flag alias.
10361        let args = vec![
10362            pos("ctx"),
10363            pos("create"),
10364            Arg::ShortFlag("t".into()),
10365            pos("explorer"),
10366        ];
10367        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
10368        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
10369    }
10370
10371    #[tokio::test]
10372    async fn build_args_computed_subcommand_selector_fails_loud() {
10373        let kernel = Kernel::transient().expect("kernel");
10374        let schema = kj_tree_schema();
10375        // kj $(echo context) — routing can't see the value; fail loud.
10376        let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
10377            crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
10378        )]))];
10379        let err = kernel
10380            .build_args_async(&args, Some(&schema))
10381            .await
10382            .expect_err("computed subcommand selector must error");
10383        assert!(
10384            err.to_string().contains("subcommand name is required"),
10385            "got: {err}"
10386        );
10387    }
10388
10389    // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
10390
10391    #[test]
10392    fn finalize_output_renders_when_kernel_owns_it() {
10393        use crate::interpreter::{OutputData, OutputFormat};
10394        let r = ExecResult::with_output(OutputData::text("RAW"));
10395        let out = finalize_output(r, Some(OutputFormat::Json), false);
10396        // Kernel renders the typed OutputData → JSON; text is no longer bare.
10397        assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
10398    }
10399
10400    #[test]
10401    fn finalize_output_skips_when_tool_owns_output_and_succeeds() {
10402        use crate::interpreter::{OutputData, OutputFormat};
10403        let r = ExecResult::with_output(OutputData::text("RAW"));
10404        let out = finalize_output(r, Some(OutputFormat::Json), true);
10405        // owns_output + success: the tool already rendered; kernel leaves bytes
10406        // untouched.
10407        assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
10408    }
10409
10410    #[test]
10411    fn finalize_output_renders_owns_output_failure() {
10412        // scatter/gather (the only owns_output tools) never render their own
10413        // JSONL/array on a FAILURE path — their error returns are plain-text
10414        // `ExecResult::failure(code, msg)`, identical in shape to any other
10415        // builtin's. owns_output means "the tool already rendered its own
10416        // SUCCESS output", not "never touch this tool's bytes" — a failure
10417        // must still get the uniform --json error envelope like every other
10418        // builtin (kaibo review finding on merged PR #215; confirmed
10419        // pre-existing for scatter/gather's whole error-path class, including
10420        // the clap-parse-failure path).
10421        use crate::interpreter::OutputFormat;
10422        let r = ExecResult::failure(2, "scatter: unexpected argument '--nope'");
10423        let out = finalize_output(r, Some(OutputFormat::Json), true);
10424        let parsed: serde_json::Value =
10425            serde_json::from_str(&out.text_out()).expect("--json must always parse as JSON");
10426        assert_eq!(parsed["error"], "scatter: unexpected argument '--nope'");
10427        assert_eq!(parsed["code"], 2);
10428    }
10429
10430    #[test]
10431    fn finalize_output_no_format_is_noop() {
10432        use crate::interpreter::OutputData;
10433        let r = ExecResult::with_output(OutputData::text("RAW"));
10434        let out = finalize_output(r, None, false);
10435        assert_eq!(out.text_out(), "RAW");
10436    }
10437
10438    // ── initial_vars + execute_with_vars + hermetic env ───────────────────
10439
10440    #[tokio::test]
10441    async fn test_initial_vars_set_and_exported() {
10442        let config = KernelConfig::transient()
10443            .with_var("INIT_FOO", Value::String("bar".into()));
10444        let kernel = Kernel::new(config).expect("failed to create kernel");
10445
10446        assert_eq!(
10447            kernel.get_var("INIT_FOO").await,
10448            Some(Value::String("bar".into()))
10449        );
10450        assert!(
10451            kernel.scope.read().await.is_exported("INIT_FOO"),
10452            "initial_vars entries must be marked exported"
10453        );
10454    }
10455
10456    #[tokio::test]
10457    async fn test_execute_with_vars_overlay_visible() {
10458        let kernel = Kernel::transient().expect("failed to create kernel");
10459        let mut overlay = HashMap::new();
10460        overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
10461
10462        let result = kernel
10463            .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
10464            .await
10465            .expect("execute failed");
10466
10467        assert!(result.ok());
10468        assert_eq!(result.text_out().trim(), "yes");
10469    }
10470
10471    #[tokio::test]
10472    async fn test_execute_with_vars_overlay_cleanup() {
10473        let kernel = Kernel::transient().expect("failed to create kernel");
10474        let mut overlay = HashMap::new();
10475        overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
10476
10477        kernel
10478            .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
10479            .await
10480            .expect("execute failed");
10481
10482        assert_eq!(kernel.get_var("EPHEMERAL").await, None);
10483        assert!(
10484            !kernel.scope.read().await.is_exported("EPHEMERAL"),
10485            "overlay-only export must be cleared on return"
10486        );
10487    }
10488
10489    #[tokio::test]
10490    async fn test_execute_with_vars_does_not_clobber_existing_export() {
10491        let kernel = Kernel::transient().expect("failed to create kernel");
10492        kernel
10493            .execute("export OUTER=outer")
10494            .await
10495            .expect("export failed");
10496
10497        let mut overlay = HashMap::new();
10498        overlay.insert("OUTER".to_string(), Value::String("inner".into()));
10499        let result = kernel
10500            .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
10501            .await
10502            .expect("execute failed");
10503        assert_eq!(result.text_out().trim(), "inner");
10504
10505        assert_eq!(
10506            kernel.get_var("OUTER").await,
10507            Some(Value::String("outer".into())),
10508            "outer value must reappear after pop"
10509        );
10510        assert!(
10511            kernel.scope.read().await.is_exported("OUTER"),
10512            "outer export must survive overlay"
10513        );
10514    }
10515
10516    #[tokio::test]
10517    async fn test_execute_with_vars_inner_assignment_is_local() {
10518        let kernel = Kernel::transient().expect("failed to create kernel");
10519        let mut overlay = HashMap::new();
10520        overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
10521
10522        // Variable assignment inside a single statement uses set() (innermost
10523        // frame), not set_global() — this matches bash function-local semantics.
10524        // We explicitly use `local FOO=...` style by relying on the pushed
10525        // frame; the assignment in the script body modifies the same frame.
10526        let result = kernel
10527            .execute_with_options(
10528                r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
10529                ExecuteOptions::new().with_vars(overlay),
10530            )
10531            .await
10532            .expect("execute failed");
10533        assert!(result.ok());
10534
10535        // After the call the frame is popped, so LOCAL_FOO is gone regardless
10536        // of how the script reassigned it.
10537        assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
10538    }
10539
10540    #[tokio::test]
10541    async fn test_external_command_sees_exported_var() {
10542        let kernel = Kernel::transient().expect("failed to create kernel");
10543        // PATH must be in scope to resolve the external `printenv` — the kernel
10544        // never falls back to OS PATH. Seeding it via a scope assignment mirrors
10545        // what a frontend does through initial_vars.
10546        let path = std::env::var("PATH").unwrap_or_default();
10547        let result = kernel
10548            .execute(&format!(
10549                "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
10550            ))
10551            .await
10552            .expect("execute failed");
10553
10554        assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
10555        assert_eq!(result.text_out().trim(), "bar");
10556    }
10557
10558    #[tokio::test]
10559    async fn test_external_command_does_not_see_unexported_var() {
10560        let kernel = Kernel::transient().expect("failed to create kernel");
10561
10562        // Set without exporting; printenv must not see it (exit code != 0,
10563        // empty stdout per printenv semantics).
10564        let result = kernel
10565            .execute("EXT_BAR=hidden; printenv EXT_BAR")
10566            .await
10567            .expect("execute failed");
10568
10569        assert!(!result.ok(), "printenv should fail when var is unexported");
10570        assert!(
10571            result.text_out().trim().is_empty(),
10572            "no stdout when var is missing, got: {}",
10573            result.text_out()
10574        );
10575    }
10576
10577    #[tokio::test]
10578    async fn test_external_command_does_not_see_os_env() {
10579        // The kernel is hermetic: it never reads std::env::vars() and only
10580        // exports what it has been told to export. Cargo always sets PATH for
10581        // tests, so PATH is reliably present in the OS env — but a transient
10582        // kernel doesn't seed it into initial_vars, so `printenv PATH` from
10583        // inside the kernel must fail.
10584        assert!(
10585            std::env::var_os("PATH").is_some(),
10586            "test precondition: cargo should set PATH"
10587        );
10588
10589        let kernel = Kernel::transient().expect("failed to create kernel");
10590        let result = kernel
10591            .execute("printenv PATH")
10592            .await
10593            .expect("execute failed");
10594
10595        assert!(
10596            !result.ok(),
10597            "printenv PATH must fail in hermetic kernel, got stdout={:?}",
10598            result.text_out()
10599        );
10600        assert!(
10601            result.text_out().trim().is_empty(),
10602            "no PATH in subprocess env, got stdout={:?}",
10603            result.text_out()
10604        );
10605    }
10606
10607    #[tokio::test]
10608    async fn test_execute_with_vars_overlay_reaches_subprocess() {
10609        let kernel = Kernel::transient().expect("failed to create kernel");
10610        let mut overlay = HashMap::new();
10611        overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
10612        // PATH in the overlay so the external `printenv` resolves (no OS fallback).
10613        overlay.insert(
10614            "PATH".to_string(),
10615            Value::String(std::env::var("PATH").unwrap_or_default()),
10616        );
10617
10618        let result = kernel
10619            .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
10620            .await
10621            .expect("execute failed");
10622
10623        assert!(
10624            result.ok(),
10625            "printenv should succeed: code={} stdout={:?} stderr={:?}",
10626            result.code,
10627            result.text_out(),
10628            result.err
10629        );
10630        assert_eq!(result.text_out().trim(), "subproc");
10631    }
10632
10633    #[tokio::test]
10634    async fn test_classify_command_builtin() {
10635        let kernel = Kernel::transient().expect("failed to create kernel");
10636        assert_eq!(kernel.classify_command("cat").await, CommandKind::Builtin);
10637        assert_eq!(kernel.classify_command("grep").await, CommandKind::Builtin);
10638    }
10639
10640    #[tokio::test]
10641    async fn test_classify_command_special_forms() {
10642        let kernel = Kernel::transient().expect("failed to create kernel");
10643        for name in ["true", "false", "source", "."] {
10644            assert_eq!(
10645                kernel.classify_command(name).await,
10646                CommandKind::Special,
10647                "{name} should be a special-form",
10648            );
10649        }
10650    }
10651
10652    #[tokio::test]
10653    async fn test_classify_command_dynamic() {
10654        let kernel = Kernel::transient().expect("failed to create kernel");
10655        assert_eq!(kernel.classify_command("$cmd").await, CommandKind::Dynamic);
10656        assert_eq!(
10657            kernel.classify_command("$(pick)").await,
10658            CommandKind::Dynamic
10659        );
10660    }
10661
10662    #[tokio::test]
10663    async fn test_classify_command_external() {
10664        let kernel = Kernel::transient().expect("failed to create kernel");
10665        // Not a builtin, user function, or special-form → escapes to PATH.
10666        assert_eq!(
10667            kernel.classify_command("definitely_not_a_kaish_builtin").await,
10668            CommandKind::External
10669        );
10670        // `readonly` is *not* a kaish special-form despite the validator's
10671        // warning heuristic — at runtime it resolves to an external command, so
10672        // a consent gate must see it as External (regression guard against the
10673        // validator/runtime divergence).
10674        assert_eq!(
10675            kernel.classify_command("readonly").await,
10676            CommandKind::External
10677        );
10678        assert!(kernel.classify_command("readonly").await.escapes_kernel());
10679    }
10680
10681    #[tokio::test]
10682    async fn test_classify_command_user_tool_shadows_builtin() {
10683        let kernel = Kernel::transient().expect("failed to create kernel");
10684        kernel
10685            .execute(r#"greet() { echo "hi" }"#)
10686            .await
10687            .expect("function definition failed");
10688        assert_eq!(
10689            kernel.classify_command("greet").await,
10690            CommandKind::UserTool
10691        );
10692
10693        // A user function named after a builtin classifies as UserTool, matching
10694        // the interpreter's user-tools-first resolution.
10695        kernel
10696            .execute(r#"cat() { echo "shadowed" }"#)
10697            .await
10698            .expect("function definition failed");
10699        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
10700    }
10701
10702    #[tokio::test]
10703    async fn test_classify_command_alias_to_external_is_external() {
10704        let kernel = Kernel::transient().expect("failed to create kernel");
10705        // An alias whose head is an external binary must NOT report as the
10706        // builtin it shadows — execution expands the alias, so a consent gate
10707        // would otherwise be told an external command is internal.
10708        kernel
10709            .execute("alias cat='/usr/bin/whatever'")
10710            .await
10711            .expect("alias failed");
10712        assert_eq!(kernel.classify_command("cat").await, CommandKind::External);
10713        assert!(kernel.classify_command("cat").await.escapes_kernel());
10714    }
10715
10716    #[tokio::test]
10717    async fn test_classify_command_alias_to_builtin() {
10718        let kernel = Kernel::transient().expect("failed to create kernel");
10719        kernel.execute("alias g=grep").await.expect("alias failed");
10720        assert_eq!(kernel.classify_command("g").await, CommandKind::Builtin);
10721    }
10722
10723    #[tokio::test]
10724    async fn test_classify_command_alias_to_special_form() {
10725        let kernel = Kernel::transient().expect("failed to create kernel");
10726        kernel.execute("alias t=true").await.expect("alias failed");
10727        assert_eq!(kernel.classify_command("t").await, CommandKind::Special);
10728    }
10729
10730    #[tokio::test]
10731    async fn test_classify_command_braced_var_is_dynamic() {
10732        let kernel = Kernel::transient().expect("failed to create kernel");
10733        // The string API can be handed a `${VAR}` head; it must not be mistaken
10734        // for an external named literally "${VAR}".
10735        assert_eq!(
10736            kernel.classify_command("${CMD}").await,
10737            CommandKind::Dynamic
10738        );
10739    }
10740
10741    /// Drift guard: `classify_command` must agree with what the executor
10742    /// (`execute_command_depth`) actually resolves. The classifier duplicates the
10743    /// interpreter's resolution rules (special-form set, user-tools-before-builtins
10744    /// precedence, alias expansion); without this test those copies could diverge
10745    /// silently — the exact failure class `classify_command` exists to prevent,
10746    /// just moved inside the kernel. Each case asserts the classification AND
10747    /// observes the real resolution, so a future change to one side without the
10748    /// other fails here.
10749    #[tokio::test]
10750    async fn classify_command_matches_executor() {
10751        let kernel = Kernel::transient().expect("failed to create kernel");
10752
10753        // (1) Special-forms. `SpecialForm::from_name` is the single source of
10754        // truth: classify reports Special via it, and the executor matches the
10755        // enum exhaustively, so const↔behavior parity is compile-enforced (a new
10756        // form won't build until both sides handle it). This test pins the other
10757        // half — that each form classifies Special AND actually short-circuits at
10758        // runtime rather than escaping to `PATH`. Every form is executed (not just
10759        // `true`/`false`): an external miss in this PATH-less kernel would be exit
10760        // 127, so a non-127 result that matches the form's own behavior proves the
10761        // short-circuit fired.
10762        for name in ["true", "false", "source", "."] {
10763            assert_eq!(
10764                kernel.classify_command(name).await,
10765                CommandKind::Special,
10766                "{name} should classify Special",
10767            );
10768        }
10769        assert_eq!(kernel.execute("true").await.expect("run true").code, 0);
10770        assert_eq!(kernel.execute("false").await.expect("run false").code, 1);
10771        // `source`/`.` short-circuit to execute_source, which (no filename) fails
10772        // with its own message — exit 1, never the 127 of an unresolved external.
10773        for name in ["source", "."] {
10774            let r = kernel.execute(name).await.expect("run source form");
10775            assert_ne!(r.code, 127, "{name} fell through to PATH instead of source");
10776            assert!(
10777                r.err.contains("source: missing filename"),
10778                "{name} did not route to execute_source: {:?}",
10779                r.err,
10780            );
10781        }
10782
10783        // (2) Builtin: classify Builtin AND the executor runs the builtin.
10784        assert_eq!(kernel.classify_command("echo").await, CommandKind::Builtin);
10785        let r = kernel.execute("echo hi").await.expect("run echo");
10786        assert!(r.ok() && r.text_out().trim() == "hi", "echo builtin didn't run");
10787
10788        // (3) User function shadows a builtin: classify UserTool AND the executor
10789        // runs the function body, not the `cat` builtin.
10790        kernel
10791            .execute(r#"cat() { echo SHADOWED }"#)
10792            .await
10793            .expect("define cat()");
10794        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
10795        let r = kernel.execute("cat").await.expect("run shadowed cat");
10796        assert_eq!(
10797            r.text_out().trim(),
10798            "SHADOWED",
10799            "executor ran the builtin instead of the shadowing function",
10800        );
10801
10802        // (4) Alias whose head is external: classify External AND the executor
10803        // resolves through the alias to a missing external (not a builtin).
10804        kernel
10805            .execute("alias x='/nonexistent/binary'")
10806            .await
10807            .expect("define alias x");
10808        assert_eq!(kernel.classify_command("x").await, CommandKind::External);
10809        let r = kernel.execute("x").await.expect("run alias x");
10810        assert!(
10811            !r.ok(),
10812            "alias to a missing external should fail, not resolve internally",
10813        );
10814    }
10815}