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, MCP)
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//! │  │              │  │    MCP, user)│  │                  │  │
18//! │  └──────────────┘  └──────────────┘  └──────────────────┘  │
19//! │  ┌──────────────────────────────┐  ┌──────────────────┐    │
20//! │  │  JobManager (background)     │  │  ExecResult ($?) │    │
21//! │  └──────────────────────────────┘  └──────────────────┘    │
22//! └────────────────────────────────────────────────────────────┘
23//! ```
24
25use std::collections::HashMap;
26use std::path::PathBuf;
27use std::sync::Arc;
28
29use anyhow::{Context, Result};
30use tokio::sync::RwLock;
31
32use async_trait::async_trait;
33
34use crate::ast::{Arg, Command, Expr, Stmt, StringPart, ToolDef, Value, BinaryOp};
35use crate::backend::{BackendError, KernelBackend};
36use kaish_glob::glob_match;
37use crate::dispatch::{CommandDispatcher, PipelinePosition};
38use crate::interpreter::{apply_output_format, eval_expr, expand_tilde, json_to_value, value_to_string, ControlFlow, ExecResult, Scope};
39use crate::parser::parse;
40use crate::scheduler::{drain_to_stream, is_bool_type, schema_param_lookup, stderr_stream, BoundedStream, JobManager, PipelineRunner, StderrReceiver, DEFAULT_STREAM_MAX_SIZE};
41use crate::tools::{extract_output_format, register_builtins, resolve_in_path, ExecContext, ToolArgs, ToolRegistry};
42use crate::validator::{Severity, Validator};
43use crate::vfs::{BuiltinFs, JobFs, LocalFs, MemoryFs, VfsRouter};
44
45/// VFS mount mode determines how the local filesystem is exposed.
46///
47/// Different modes trade off convenience vs. security:
48/// - `Passthrough` gives native path access (best for human REPL use)
49/// - `Sandboxed` restricts access to a subtree (safer for agents)
50/// - `NoLocal` provides complete isolation (tests, pure memory mode)
51#[derive(Debug, Clone)]
52pub enum VfsMountMode {
53    /// LocalFs at "/" — native paths work directly.
54    ///
55    /// Full filesystem access. Use for human-operated REPL sessions where
56    /// native paths like `/home/user/project` should just work.
57    ///
58    /// Mounts:
59    /// - `/` → LocalFs("/")
60    /// - `/v` → MemoryFs (blob storage)
61    Passthrough,
62
63    /// Transparent sandbox — paths look native but access is restricted.
64    ///
65    /// The local filesystem is mounted at its real path (e.g., `/home/user`),
66    /// so `/home/user/src/project` just works. But paths outside the sandbox
67    /// root are not accessible.
68    ///
69    /// **Note:** This only restricts VFS (builtin) operations. External commands
70    /// bypass the sandbox entirely — see [`KernelConfig::allow_external_commands`].
71    ///
72    /// Mounts:
73    /// - `/` → MemoryFs (catches paths outside sandbox)
74    /// - `{root}` → LocalFs(root)  (e.g., `/home/user` → LocalFs)
75    /// - `/tmp` → LocalFs("/tmp")
76    /// - `/v` → MemoryFs (blob storage)
77    Sandboxed {
78        /// Root path for local filesystem. Defaults to `$HOME`.
79        /// Can be restricted further, e.g., `~/src`.
80        root: Option<PathBuf>,
81    },
82
83    /// No local filesystem. Memory only.
84    ///
85    /// Complete isolation — no access to the host filesystem.
86    /// Useful for tests or pure sandboxed execution.
87    ///
88    /// Mounts:
89    /// - `/` → MemoryFs
90    /// - `/tmp` → MemoryFs
91    /// - `/v` → MemoryFs
92    NoLocal,
93}
94
95impl Default for VfsMountMode {
96    fn default() -> Self {
97        VfsMountMode::Sandboxed { root: None }
98    }
99}
100
101/// Configuration for kernel initialization.
102#[derive(Debug, Clone)]
103pub struct KernelConfig {
104    /// Name of this kernel (for identification).
105    pub name: String,
106
107    /// VFS mount mode — controls how local filesystem is exposed.
108    pub vfs_mode: VfsMountMode,
109
110    /// Initial working directory (VFS path).
111    pub cwd: PathBuf,
112
113    /// Whether to skip pre-execution validation.
114    ///
115    /// When false (default), scripts are validated before execution to catch
116    /// errors early. Set to true to skip validation for performance or to
117    /// allow dynamic/external commands.
118    pub skip_validation: bool,
119
120    /// When true, standalone external commands inherit stdio for real-time output.
121    ///
122    /// Set by script runner and REPL for human-visible output.
123    /// Not set by MCP server (output must be captured for structured responses).
124    pub interactive: bool,
125
126    /// Ignore file configuration for file-walking tools.
127    pub ignore_config: crate::ignore_config::IgnoreConfig,
128
129    /// Output size limit configuration for agent safety.
130    pub output_limit: crate::output_limit::OutputLimitConfig,
131
132    /// Whether external command execution (PATH lookup, `exec`, `spawn`) is allowed.
133    ///
134    /// When `true` (default), commands not found as builtins are resolved via PATH
135    /// and executed as child processes. When `false`, only kaish builtins and
136    /// backend-registered tools (MCP) are available.
137    ///
138    /// **Security:** External commands bypass the VFS sandbox entirely — they see
139    /// the real filesystem, network, and environment. Set to `false` when running
140    /// untrusted input.
141    pub allow_external_commands: bool,
142
143    /// Enable confirmation latch for dangerous operations (set -o latch).
144    ///
145    /// When enabled, destructive operations like `rm` require nonce confirmation.
146    /// Can also be enabled at runtime with `set -o latch` or via `KAISH_LATCH=1`.
147    pub latch_enabled: bool,
148
149    /// Enable trash-on-delete for rm (set -o trash).
150    ///
151    /// When enabled, small files are moved to freedesktop.org Trash instead of
152    /// being permanently deleted. Can also be enabled at runtime with `set -o trash`
153    /// or via `KAISH_TRASH=1`.
154    pub trash_enabled: bool,
155
156    /// Shared nonce store for cross-request confirmation latch.
157    ///
158    /// When `Some`, the kernel uses this store instead of creating a fresh one.
159    /// This allows nonces issued in one MCP `execute()` call to be validated
160    /// in a subsequent call. When `None` (default), a fresh store is created.
161    pub nonce_store: Option<crate::nonce::NonceStore>,
162}
163
164/// Get the default sandbox root ($HOME).
165fn default_sandbox_root() -> PathBuf {
166    std::env::var("HOME")
167        .map(PathBuf::from)
168        .unwrap_or_else(|_| PathBuf::from("/"))
169}
170
171impl Default for KernelConfig {
172    fn default() -> Self {
173        let home = default_sandbox_root();
174        Self {
175            name: "default".to_string(),
176            vfs_mode: VfsMountMode::Sandboxed { root: None },
177            cwd: home,
178            skip_validation: false,
179            interactive: false,
180            ignore_config: crate::ignore_config::IgnoreConfig::none(),
181            output_limit: crate::output_limit::OutputLimitConfig::none(),
182            allow_external_commands: true,
183            latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
184            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
185            nonce_store: None,
186        }
187    }
188}
189
190impl KernelConfig {
191    /// Create a transient kernel config (sandboxed, for temporary use).
192    pub fn transient() -> Self {
193        let home = default_sandbox_root();
194        Self {
195            name: "transient".to_string(),
196            vfs_mode: VfsMountMode::Sandboxed { root: None },
197            cwd: home,
198            skip_validation: false,
199            interactive: false,
200            ignore_config: crate::ignore_config::IgnoreConfig::none(),
201            output_limit: crate::output_limit::OutputLimitConfig::none(),
202            allow_external_commands: true,
203            latch_enabled: false,
204            trash_enabled: false,
205            nonce_store: None,
206        }
207    }
208
209    /// Create a kernel config with the given name (sandboxed by default).
210    pub fn named(name: &str) -> Self {
211        let home = default_sandbox_root();
212        Self {
213            name: name.to_string(),
214            vfs_mode: VfsMountMode::Sandboxed { root: None },
215            cwd: home,
216            skip_validation: false,
217            interactive: false,
218            ignore_config: crate::ignore_config::IgnoreConfig::none(),
219            output_limit: crate::output_limit::OutputLimitConfig::none(),
220            allow_external_commands: true,
221            latch_enabled: false,
222            trash_enabled: false,
223            nonce_store: None,
224        }
225    }
226
227    /// Create a REPL config with passthrough filesystem access.
228    ///
229    /// Native paths like `/home/user/project` work directly.
230    /// The cwd is set to the actual current working directory.
231    pub fn repl() -> Self {
232        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
233        Self {
234            name: "repl".to_string(),
235            vfs_mode: VfsMountMode::Passthrough,
236            cwd,
237            skip_validation: false,
238            interactive: false,
239            ignore_config: crate::ignore_config::IgnoreConfig::none(),
240            output_limit: crate::output_limit::OutputLimitConfig::none(),
241            allow_external_commands: true,
242            latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
243            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
244            nonce_store: None,
245        }
246    }
247
248    /// Create an MCP server config with sandboxed filesystem access.
249    ///
250    /// Local filesystem is accessible at its real path (e.g., `/home/user`),
251    /// but sandboxed to `$HOME`. Paths outside the sandbox are not accessible
252    /// through builtins. External commands still access the real filesystem —
253    /// use `.with_allow_external_commands(false)` to block them.
254    pub fn mcp() -> Self {
255        let home = default_sandbox_root();
256        Self {
257            name: "mcp".to_string(),
258            vfs_mode: VfsMountMode::Sandboxed { root: None },
259            cwd: home,
260            skip_validation: false,
261            interactive: false,
262            ignore_config: crate::ignore_config::IgnoreConfig::mcp(),
263            output_limit: crate::output_limit::OutputLimitConfig::mcp(),
264            allow_external_commands: true,
265            latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
266            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
267            nonce_store: None,
268        }
269    }
270
271    /// Create an MCP server config with a custom sandbox root.
272    ///
273    /// Use this to restrict access to a subdirectory like `~/src`.
274    pub fn mcp_with_root(root: PathBuf) -> Self {
275        Self {
276            name: "mcp".to_string(),
277            vfs_mode: VfsMountMode::Sandboxed { root: Some(root.clone()) },
278            cwd: root,
279            skip_validation: false,
280            interactive: false,
281            ignore_config: crate::ignore_config::IgnoreConfig::mcp(),
282            output_limit: crate::output_limit::OutputLimitConfig::mcp(),
283            allow_external_commands: true,
284            latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
285            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
286            nonce_store: None,
287        }
288    }
289
290    /// Create a config with no local filesystem (memory only).
291    ///
292    /// Complete isolation: no local filesystem and external commands are disabled.
293    /// Useful for tests or pure sandboxed execution.
294    pub fn isolated() -> Self {
295        Self {
296            name: "isolated".to_string(),
297            vfs_mode: VfsMountMode::NoLocal,
298            cwd: PathBuf::from("/"),
299            skip_validation: false,
300            interactive: false,
301            ignore_config: crate::ignore_config::IgnoreConfig::none(),
302            output_limit: crate::output_limit::OutputLimitConfig::none(),
303            allow_external_commands: false,
304            latch_enabled: false,
305            trash_enabled: false,
306            nonce_store: None,
307        }
308    }
309
310    /// Set the VFS mount mode.
311    pub fn with_vfs_mode(mut self, mode: VfsMountMode) -> Self {
312        self.vfs_mode = mode;
313        self
314    }
315
316    /// Set the initial working directory.
317    pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
318        self.cwd = cwd;
319        self
320    }
321
322    /// Skip pre-execution validation.
323    pub fn with_skip_validation(mut self, skip: bool) -> Self {
324        self.skip_validation = skip;
325        self
326    }
327
328    /// Enable interactive mode (external commands inherit stdio).
329    pub fn with_interactive(mut self, interactive: bool) -> Self {
330        self.interactive = interactive;
331        self
332    }
333
334    /// Set the ignore file configuration.
335    pub fn with_ignore_config(mut self, config: crate::ignore_config::IgnoreConfig) -> Self {
336        self.ignore_config = config;
337        self
338    }
339
340    /// Set the output limit configuration.
341    pub fn with_output_limit(mut self, config: crate::output_limit::OutputLimitConfig) -> Self {
342        self.output_limit = config;
343        self
344    }
345
346    /// Set whether external command execution is allowed.
347    ///
348    /// When `false`, commands not found as builtins produce "command not found"
349    /// instead of searching PATH. The `exec` and `spawn` builtins also return
350    /// errors. Use this to prevent VFS sandbox bypass via external binaries.
351    pub fn with_allow_external_commands(mut self, allow: bool) -> Self {
352        self.allow_external_commands = allow;
353        self
354    }
355
356    /// Enable or disable confirmation latch at startup.
357    pub fn with_latch(mut self, enabled: bool) -> Self {
358        self.latch_enabled = enabled;
359        self
360    }
361
362    /// Enable or disable trash-on-delete at startup.
363    pub fn with_trash(mut self, enabled: bool) -> Self {
364        self.trash_enabled = enabled;
365        self
366    }
367
368    /// Use a shared nonce store for cross-request confirmation latch.
369    ///
370    /// Pass a `NonceStore` that outlives individual kernel instances so nonces
371    /// issued in one MCP `execute()` call can be validated in subsequent calls.
372    pub fn with_nonce_store(mut self, store: crate::nonce::NonceStore) -> Self {
373        self.nonce_store = Some(store);
374        self
375    }
376}
377
378/// The Kernel (核) — executes kaish code.
379///
380/// This is the primary interface for running kaish commands. It owns all
381/// the runtime state: variables, tools, VFS, jobs, and persistence.
382pub struct Kernel {
383    /// Kernel name.
384    name: String,
385    /// Variable scope.
386    scope: RwLock<Scope>,
387    /// Tool registry.
388    tools: Arc<ToolRegistry>,
389    /// User-defined tools (from `tool name { body }` statements).
390    user_tools: RwLock<HashMap<String, ToolDef>>,
391    /// Virtual filesystem router.
392    vfs: Arc<VfsRouter>,
393    /// Background job manager.
394    jobs: Arc<JobManager>,
395    /// Pipeline runner.
396    runner: PipelineRunner,
397    /// Execution context (cwd, stdin, etc.).
398    exec_ctx: RwLock<ExecContext>,
399    /// Whether to skip pre-execution validation.
400    skip_validation: bool,
401    /// When true, standalone external commands inherit stdio for real-time output.
402    interactive: bool,
403    /// Whether external command execution is allowed.
404    allow_external_commands: bool,
405    /// Receiver for the kernel stderr stream.
406    ///
407    /// Pipeline stages write to the corresponding `StderrStream` (set on ExecContext).
408    /// The kernel drains this after each statement in `execute_streaming`.
409    stderr_receiver: tokio::sync::Mutex<StderrReceiver>,
410    /// Cancellation token for interrupting execution (Ctrl-C).
411    ///
412    /// Protected by `std::sync::Mutex` (not tokio) because the SIGINT handler
413    /// needs sync access. Each `execute()` call gets a fresh child token;
414    /// `cancel()` cancels the current token and replaces it.
415    cancel_token: std::sync::Mutex<tokio_util::sync::CancellationToken>,
416    /// Terminal state for job control (interactive mode only, Unix only).
417    #[cfg(unix)]
418    terminal_state: Option<Arc<crate::terminal::TerminalState>>,
419}
420
421impl Kernel {
422    /// Create a new kernel with the given configuration.
423    pub fn new(config: KernelConfig) -> Result<Self> {
424        let mut vfs = Self::setup_vfs(&config);
425        let jobs = Arc::new(JobManager::new());
426
427        // Mount JobFs for job observability at /v/jobs
428        vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
429
430        Self::assemble(config, vfs, jobs, |vfs_ref, tools| {
431            ExecContext::with_vfs_and_tools(vfs_ref.clone(), tools.clone())
432        })
433    }
434
435    /// Set up VFS based on mount mode.
436    fn setup_vfs(config: &KernelConfig) -> VfsRouter {
437        let mut vfs = VfsRouter::new();
438
439        match &config.vfs_mode {
440            VfsMountMode::Passthrough => {
441                // LocalFs at "/" — native paths work directly
442                vfs.mount("/", LocalFs::new(PathBuf::from("/")));
443                // Memory for blobs
444                vfs.mount("/v", MemoryFs::new());
445            }
446            VfsMountMode::Sandboxed { root } => {
447                // Memory at root for safety (catches paths outside sandbox)
448                vfs.mount("/", MemoryFs::new());
449                vfs.mount("/v", MemoryFs::new());
450
451                // Real /tmp for interop with other processes
452                vfs.mount("/tmp", LocalFs::new(PathBuf::from("/tmp")));
453
454                // Mount XDG runtime dir for spill files and socket access
455                let runtime = crate::paths::xdg_runtime_dir();
456                if runtime.exists() {
457                    let runtime_str = runtime.to_string_lossy().to_string();
458                    vfs.mount(&runtime_str, LocalFs::new(runtime));
459                }
460
461                // Resolve the sandbox root (defaults to $HOME)
462                let local_root = root.clone().unwrap_or_else(|| {
463                    std::env::var("HOME")
464                        .map(PathBuf::from)
465                        .unwrap_or_else(|_| PathBuf::from("/"))
466                });
467
468                // Mount at the real path for transparent access
469                // e.g., /home/atobey → LocalFs("/home/atobey")
470                // so /home/atobey/src/kaish just works
471                let mount_point = local_root.to_string_lossy().to_string();
472                vfs.mount(&mount_point, LocalFs::new(local_root));
473            }
474            VfsMountMode::NoLocal => {
475                // Pure memory mode — no local filesystem
476                vfs.mount("/", MemoryFs::new());
477                vfs.mount("/tmp", MemoryFs::new());
478                vfs.mount("/v", MemoryFs::new());
479            }
480        }
481
482        vfs
483    }
484
485    /// Create a transient kernel (no persistence).
486    pub fn transient() -> Result<Self> {
487        Self::new(KernelConfig::transient())
488    }
489
490    /// Create a kernel with a custom backend and `/v/*` virtual path support.
491    ///
492    /// This is the constructor for embedding kaish in other systems that provide
493    /// their own storage backend (e.g., CRDT-backed storage in kaijutsu).
494    ///
495    /// A `VirtualOverlayBackend` routes paths automatically:
496    /// - `/v/*` → Internal VFS (JobFs at `/v/jobs`, MemoryFs at `/v/blobs`)
497    /// - Everything else → Your custom backend
498    ///
499    /// The optional `configure_vfs` closure lets you add additional virtual mounts
500    /// (e.g., `/v/docs` for CRDT blocks) after the built-in mounts are set up.
501    ///
502    /// **Note:** The config's `vfs_mode` is ignored — all non-`/v/*` path routing
503    /// is handled by your custom backend. The config is only used for `name`, `cwd`,
504    /// `skip_validation`, and `interactive`.
505    ///
506    /// # Example
507    ///
508    /// ```ignore
509    /// // Simple: default /v/* mounts only
510    /// let kernel = Kernel::with_backend(backend, config, |_| {})?;
511    ///
512    /// // With custom mounts
513    /// let kernel = Kernel::with_backend(backend, config, |vfs| {
514    ///     vfs.mount_arc("/v/docs", docs_fs);
515    ///     vfs.mount_arc("/v/g", git_fs);
516    /// })?;
517    /// ```
518    pub fn with_backend(
519        backend: Arc<dyn KernelBackend>,
520        config: KernelConfig,
521        configure_vfs: impl FnOnce(&mut VfsRouter),
522    ) -> Result<Self> {
523        use crate::backend::VirtualOverlayBackend;
524
525        let mut vfs = VfsRouter::new();
526        let jobs = Arc::new(JobManager::new());
527
528        vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
529        vfs.mount("/v/blobs", MemoryFs::new());
530
531        // Let caller add custom mounts (e.g., /v/docs, /v/g)
532        configure_vfs(&mut vfs);
533
534        Self::assemble(config, vfs, jobs, |vfs_arc: &Arc<VfsRouter>, _: &Arc<ToolRegistry>| {
535            let overlay: Arc<dyn KernelBackend> =
536                Arc::new(VirtualOverlayBackend::new(backend, vfs_arc.clone()));
537            ExecContext::with_backend(overlay)
538        })
539    }
540
541    /// Shared assembly: wires up tools, runner, scope, and ExecContext.
542    ///
543    /// The `make_ctx` closure receives the VFS and tools so backends that need
544    /// them (like `LocalBackend::with_tools`) can capture them. Custom backends
545    /// that already have their own storage can ignore these parameters.
546    fn assemble(
547        config: KernelConfig,
548        mut vfs: VfsRouter,
549        jobs: Arc<JobManager>,
550        make_ctx: impl FnOnce(&Arc<VfsRouter>, &Arc<ToolRegistry>) -> ExecContext,
551    ) -> Result<Self> {
552        let KernelConfig { name, cwd, skip_validation, interactive, ignore_config, output_limit, allow_external_commands, latch_enabled, trash_enabled, nonce_store, .. } = config;
553
554        let mut tools = ToolRegistry::new();
555        register_builtins(&mut tools);
556        let tools = Arc::new(tools);
557
558        // Mount BuiltinFs so `ls /v/bin` lists builtins
559        vfs.mount("/v/bin", BuiltinFs::new(tools.clone()));
560
561        let vfs = Arc::new(vfs);
562
563        let runner = PipelineRunner::new(tools.clone());
564
565        let (stderr_writer, stderr_receiver) = stderr_stream();
566
567        let mut exec_ctx = make_ctx(&vfs, &tools);
568        exec_ctx.set_cwd(cwd);
569        exec_ctx.set_job_manager(jobs.clone());
570        exec_ctx.set_tool_schemas(tools.schemas());
571        exec_ctx.set_tools(tools.clone());
572        exec_ctx.stderr = Some(stderr_writer);
573        exec_ctx.ignore_config = ignore_config;
574        exec_ctx.output_limit = output_limit;
575        exec_ctx.allow_external_commands = allow_external_commands;
576        if let Some(store) = nonce_store {
577            exec_ctx.nonce_store = store;
578        }
579
580        Ok(Self {
581            name,
582            scope: RwLock::new({
583                let mut scope = Scope::new();
584                if let Ok(home) = std::env::var("HOME") {
585                    scope.set("HOME", Value::String(home));
586                }
587                scope.set_latch_enabled(latch_enabled);
588                scope.set_trash_enabled(trash_enabled);
589                scope
590            }),
591            tools,
592            user_tools: RwLock::new(HashMap::new()),
593            vfs,
594            jobs,
595            runner,
596            exec_ctx: RwLock::new(exec_ctx),
597            skip_validation,
598            interactive,
599            allow_external_commands,
600            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
601            cancel_token: std::sync::Mutex::new(tokio_util::sync::CancellationToken::new()),
602            #[cfg(unix)]
603            terminal_state: None,
604        })
605    }
606
607    /// Get the kernel name.
608    pub fn name(&self) -> &str {
609        &self.name
610    }
611
612    /// Initialize terminal state for interactive job control.
613    ///
614    /// Call this after kernel creation when running as an interactive REPL
615    /// and stdin is a TTY. Sets up process groups and signal handling.
616    #[cfg(unix)]
617    pub fn init_terminal(&mut self) {
618        if !self.interactive {
619            return;
620        }
621        match crate::terminal::TerminalState::init() {
622            Ok(state) => {
623                let state = Arc::new(state);
624                self.terminal_state = Some(state.clone());
625                // Set on exec_ctx so builtins (fg, bg, kill) can access it
626                self.exec_ctx.get_mut().terminal_state = Some(state);
627                tracing::debug!("terminal job control initialized");
628            }
629            Err(e) => {
630                tracing::warn!("failed to initialize terminal job control: {}", e);
631            }
632        }
633    }
634
635    /// Cancel the current execution.
636    ///
637    /// This cancels the current cancellation token, causing any execution
638    /// loop to exit at the next checkpoint with exit code 130 (SIGINT).
639    /// A fresh token is installed for the next `execute()` call.
640    pub fn cancel(&self) {
641        #[allow(clippy::expect_used)]
642        let token = self.cancel_token.lock().expect("cancel_token poisoned");
643        token.cancel();
644    }
645
646    /// Check if the current execution has been cancelled.
647    pub fn is_cancelled(&self) -> bool {
648        #[allow(clippy::expect_used)]
649        let token = self.cancel_token.lock().expect("cancel_token poisoned");
650        token.is_cancelled()
651    }
652
653    /// Reset the cancellation token (called at the start of each execute).
654    fn reset_cancel(&self) -> tokio_util::sync::CancellationToken {
655        #[allow(clippy::expect_used)]
656        let mut token = self.cancel_token.lock().expect("cancel_token poisoned");
657        if token.is_cancelled() {
658            *token = tokio_util::sync::CancellationToken::new();
659        }
660        token.clone()
661    }
662
663    /// Execute kaish source code.
664    ///
665    /// Returns the result of the last statement executed.
666    pub async fn execute(&self, input: &str) -> Result<ExecResult> {
667        self.execute_streaming(input, &mut |_| {}).await
668    }
669
670    /// Execute kaish source code with a per-statement callback.
671    ///
672    /// Each statement's result is passed to `on_output` as it completes,
673    /// allowing callers to flush output incrementally (e.g., print builtin
674    /// output immediately rather than buffering until the script finishes).
675    ///
676    /// External commands in interactive mode already stream to the terminal
677    /// via `Stdio::inherit()`, so the callback mainly handles builtins.
678    #[tracing::instrument(level = "info", skip(self, on_output), fields(input_len = input.len()))]
679    pub async fn execute_streaming(
680        &self,
681        input: &str,
682        on_output: &mut dyn FnMut(&ExecResult),
683    ) -> Result<ExecResult> {
684        let program = parse(input).map_err(|errors| {
685            let msg = errors
686                .iter()
687                .map(|e| e.to_string())
688                .collect::<Vec<_>>()
689                .join("; ");
690            anyhow::anyhow!("parse error: {}", msg)
691        })?;
692
693        // AST display mode: show AST instead of executing
694        {
695            let scope = self.scope.read().await;
696            if scope.show_ast() {
697                let output = format!("{:#?}\n", program);
698                return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
699            }
700        }
701
702        // Pre-execution validation
703        if !self.skip_validation {
704            let user_tools = self.user_tools.read().await;
705            let validator = Validator::new(&self.tools, &user_tools);
706            let issues = validator.validate(&program);
707
708            // Collect errors (warnings are logged but don't prevent execution)
709            let errors: Vec<_> = issues
710                .iter()
711                .filter(|i| i.severity == Severity::Error)
712                .collect();
713
714            if !errors.is_empty() {
715                let error_msg = errors
716                    .iter()
717                    .map(|e| e.format(input))
718                    .collect::<Vec<_>>()
719                    .join("\n");
720                return Err(anyhow::anyhow!("validation failed:\n{}", error_msg));
721            }
722
723            // Log warnings via tracing (trace level to avoid noise)
724            for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
725                tracing::trace!("validation: {}", warning.format(input));
726            }
727        }
728
729        let mut result = ExecResult::success("");
730
731        // Reset cancellation token for this execution.
732        let cancel = self.reset_cancel();
733
734        for stmt in program.statements {
735            if matches!(stmt, Stmt::Empty) {
736                continue;
737            }
738
739            // Cancellation checkpoint
740            if cancel.is_cancelled() {
741                result.code = 130;
742                return Ok(result);
743            }
744
745            let flow = self.execute_stmt_flow(&stmt).await?;
746
747            // Drain any stderr written by pipeline stages during this statement.
748            // This captures stderr from intermediate pipeline stages that would
749            // otherwise be lost (only the last stage's result is returned).
750            let drained_stderr = {
751                let mut receiver = self.stderr_receiver.lock().await;
752                receiver.drain_lossy()
753            };
754
755            match flow {
756                ControlFlow::Normal(mut r) => {
757                    if !drained_stderr.is_empty() {
758                        if !r.err.is_empty() && !r.err.ends_with('\n') {
759                            r.err.push('\n');
760                        }
761                        // Prepend pipeline stderr before the last stage's stderr
762                        let combined = format!("{}{}", drained_stderr, r.err);
763                        r.err = combined;
764                    }
765                    on_output(&r);
766                    // Carry the last statement's structured output for MCP TOON encoding.
767                    // Must be done here (not in accumulate_result) because accumulate_result
768                    // is also used in loops where per-iteration output would be wrong.
769                    let last_output = r.output.clone();
770                    accumulate_result(&mut result, &r);
771                    result.output = last_output;
772                }
773                ControlFlow::Exit { code } => {
774                    if !drained_stderr.is_empty() {
775                        result.err.push_str(&drained_stderr);
776                    }
777                    result.code = code;
778                    return Ok(result);
779                }
780                ControlFlow::Return { mut value } => {
781                    if !drained_stderr.is_empty() {
782                        value.err = format!("{}{}", drained_stderr, value.err);
783                    }
784                    on_output(&value);
785                    result = value;
786                }
787                ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
788                    if !drained_stderr.is_empty() {
789                        r.err = format!("{}{}", drained_stderr, r.err);
790                    }
791                    on_output(&r);
792                    result = r;
793                }
794            }
795        }
796
797        Ok(result)
798    }
799
800    /// Execute a single statement, returning control flow information.
801    fn execute_stmt_flow<'a>(
802        &'a self,
803        stmt: &'a Stmt,
804    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
805        use tracing::Instrument;
806        let span = tracing::debug_span!("execute_stmt_flow", stmt_type = %stmt.kind_name());
807        Box::pin(async move {
808        match stmt {
809            Stmt::Assignment(assign) => {
810                // Use async evaluator to support command substitution
811                let value = self.eval_expr_async(&assign.value).await
812                    .context("failed to evaluate assignment")?;
813                let mut scope = self.scope.write().await;
814                if assign.local {
815                    // local: set in innermost (current function) frame
816                    scope.set(&assign.name, value.clone());
817                } else {
818                    // non-local: update existing or create in root frame
819                    scope.set_global(&assign.name, value.clone());
820                }
821                drop(scope);
822
823                // Assignments don't produce output (like sh)
824                Ok(ControlFlow::ok(ExecResult::success("")))
825            }
826            Stmt::Command(cmd) => {
827                // Route single commands through execute_pipeline for a unified path.
828                // This ensures all commands go through the dispatcher chain.
829                let pipeline = crate::ast::Pipeline {
830                    commands: vec![cmd.clone()],
831                    background: false,
832                };
833                let result = self.execute_pipeline(&pipeline).await?;
834                self.update_last_result(&result).await;
835
836                // Check for error exit mode (set -e)
837                if !result.ok() {
838                    let scope = self.scope.read().await;
839                    if scope.error_exit_enabled() {
840                        return Ok(ControlFlow::exit_code(result.code));
841                    }
842                }
843
844                Ok(ControlFlow::ok(result))
845            }
846            Stmt::Pipeline(pipeline) => {
847                let result = self.execute_pipeline(pipeline).await?;
848                self.update_last_result(&result).await;
849
850                // Check for error exit mode (set -e)
851                if !result.ok() {
852                    let scope = self.scope.read().await;
853                    if scope.error_exit_enabled() {
854                        return Ok(ControlFlow::exit_code(result.code));
855                    }
856                }
857
858                Ok(ControlFlow::ok(result))
859            }
860            Stmt::If(if_stmt) => {
861                // Use async evaluator to support command substitution in conditions
862                let cond_value = self.eval_expr_async(&if_stmt.condition).await?;
863
864                let branch = if is_truthy(&cond_value) {
865                    &if_stmt.then_branch
866                } else {
867                    if_stmt.else_branch.as_deref().unwrap_or(&[])
868                };
869
870                let mut result = ExecResult::success("");
871                for stmt in branch {
872                    let flow = self.execute_stmt_flow(stmt).await?;
873                    match flow {
874                        ControlFlow::Normal(r) => {
875                            accumulate_result(&mut result, &r);
876                            self.drain_stderr_into(&mut result).await;
877                        }
878                        other => {
879                            self.drain_stderr_into(&mut result).await;
880                            return Ok(other);
881                        }
882                    }
883                }
884                Ok(ControlFlow::ok(result))
885            }
886            Stmt::For(for_loop) => {
887                // Evaluate all items and collect values for iteration
888                // Use async evaluator to support command substitution like $(seq 1 5)
889                let mut items: Vec<Value> = Vec::new();
890                for item_expr in &for_loop.items {
891                    let item = self.eval_expr_async(item_expr).await?;
892                    // NO implicit word splitting - arrays iterate, strings stay whole
893                    match &item {
894                        // JSON arrays iterate over elements
895                        Value::Json(serde_json::Value::Array(arr)) => {
896                            for elem in arr {
897                                items.push(json_to_value(elem.clone()));
898                            }
899                        }
900                        // Strings are ONE value - no splitting!
901                        // Use $(split "$VAR") for explicit splitting
902                        Value::String(_) => {
903                            items.push(item);
904                        }
905                        // Other values as-is
906                        _ => items.push(item),
907                    }
908                }
909
910                let mut result = ExecResult::success("");
911                {
912                    let mut scope = self.scope.write().await;
913                    scope.push_frame();
914                }
915
916                'outer: for item in items {
917                    // Cancellation checkpoint per iteration
918                    if self.is_cancelled() {
919                        let mut scope = self.scope.write().await;
920                        scope.pop_frame();
921                        result.code = 130;
922                        return Ok(ControlFlow::ok(result));
923                    }
924                    {
925                        let mut scope = self.scope.write().await;
926                        scope.set(&for_loop.variable, item);
927                    }
928                    for stmt in &for_loop.body {
929                        let mut flow = self.execute_stmt_flow(stmt).await?;
930                        self.drain_stderr_into(&mut result).await;
931                        match &mut flow {
932                            ControlFlow::Normal(r) => {
933                                accumulate_result(&mut result, r);
934                                if !r.ok() {
935                                    let scope = self.scope.read().await;
936                                    if scope.error_exit_enabled() {
937                                        drop(scope);
938                                        let mut scope = self.scope.write().await;
939                                        scope.pop_frame();
940                                        return Ok(ControlFlow::exit_code(r.code));
941                                    }
942                                }
943                            }
944                            ControlFlow::Break { .. } => {
945                                if flow.decrement_level() {
946                                    break 'outer;
947                                }
948                                let mut scope = self.scope.write().await;
949                                scope.pop_frame();
950                                return Ok(flow);
951                            }
952                            ControlFlow::Continue { .. } => {
953                                if flow.decrement_level() {
954                                    continue 'outer;
955                                }
956                                let mut scope = self.scope.write().await;
957                                scope.pop_frame();
958                                return Ok(flow);
959                            }
960                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
961                                let mut scope = self.scope.write().await;
962                                scope.pop_frame();
963                                return Ok(flow);
964                            }
965                        }
966                    }
967                }
968
969                {
970                    let mut scope = self.scope.write().await;
971                    scope.pop_frame();
972                }
973                Ok(ControlFlow::ok(result))
974            }
975            Stmt::While(while_loop) => {
976                let mut result = ExecResult::success("");
977
978                'outer: loop {
979                    // Evaluate condition - use async to support command substitution
980                    // Cancellation checkpoint per iteration
981                    if self.is_cancelled() {
982                        result.code = 130;
983                        return Ok(ControlFlow::ok(result));
984                    }
985
986                    let cond_value = self.eval_expr_async(&while_loop.condition).await?;
987
988                    if !is_truthy(&cond_value) {
989                        break;
990                    }
991
992                    // Execute body
993                    for stmt in &while_loop.body {
994                        let mut flow = self.execute_stmt_flow(stmt).await?;
995                        self.drain_stderr_into(&mut result).await;
996                        match &mut flow {
997                            ControlFlow::Normal(r) => {
998                                accumulate_result(&mut result, r);
999                                if !r.ok() {
1000                                    let scope = self.scope.read().await;
1001                                    if scope.error_exit_enabled() {
1002                                        return Ok(ControlFlow::exit_code(r.code));
1003                                    }
1004                                }
1005                            }
1006                            ControlFlow::Break { .. } => {
1007                                if flow.decrement_level() {
1008                                    break 'outer;
1009                                }
1010                                return Ok(flow);
1011                            }
1012                            ControlFlow::Continue { .. } => {
1013                                if flow.decrement_level() {
1014                                    continue 'outer;
1015                                }
1016                                return Ok(flow);
1017                            }
1018                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
1019                                return Ok(flow);
1020                            }
1021                        }
1022                    }
1023                }
1024
1025                Ok(ControlFlow::ok(result))
1026            }
1027            Stmt::Case(case_stmt) => {
1028                // Evaluate the expression to match against
1029                let match_value = {
1030                    let mut scope = self.scope.write().await;
1031                    let value = eval_expr(&case_stmt.expr, &mut scope)?;
1032                    value_to_string(&value)
1033                };
1034
1035                // Try each branch until we find a match
1036                for branch in &case_stmt.branches {
1037                    let matched = branch.patterns.iter().any(|pattern| {
1038                        glob_match(pattern, &match_value)
1039                    });
1040
1041                    if matched {
1042                        // Execute the branch body
1043                        let mut result = ExecResult::success("");
1044                        for stmt in &branch.body {
1045                            let flow = self.execute_stmt_flow(stmt).await?;
1046                            match flow {
1047                                ControlFlow::Normal(r) => {
1048                                    accumulate_result(&mut result, &r);
1049                                    self.drain_stderr_into(&mut result).await;
1050                                }
1051                                other => {
1052                                    self.drain_stderr_into(&mut result).await;
1053                                    return Ok(other);
1054                                }
1055                            }
1056                        }
1057                        return Ok(ControlFlow::ok(result));
1058                    }
1059                }
1060
1061                // No match - return success with empty output (like sh)
1062                Ok(ControlFlow::ok(ExecResult::success("")))
1063            }
1064            Stmt::Break(levels) => {
1065                Ok(ControlFlow::break_n(levels.unwrap_or(1)))
1066            }
1067            Stmt::Continue(levels) => {
1068                Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
1069            }
1070            Stmt::Return(expr) => {
1071                // return [N] - N becomes the exit code, NOT stdout
1072                // Shell semantics: return sets exit code, doesn't produce output
1073                let result = if let Some(e) = expr {
1074                    let mut scope = self.scope.write().await;
1075                    let val = eval_expr(e, &mut scope)?;
1076                    // Convert value to exit code
1077                    let code = match val {
1078                        Value::Int(n) => n,
1079                        Value::Bool(b) => if b { 0 } else { 1 },
1080                        _ => 0,
1081                    };
1082                    ExecResult {
1083                        code,
1084                        out: String::new(),
1085                        err: String::new(),
1086                        data: None,
1087                        output: None,
1088                    }
1089                } else {
1090                    ExecResult::success("")
1091                };
1092                Ok(ControlFlow::return_value(result))
1093            }
1094            Stmt::Exit(expr) => {
1095                let code = if let Some(e) = expr {
1096                    let mut scope = self.scope.write().await;
1097                    let val = eval_expr(e, &mut scope)?;
1098                    match val {
1099                        Value::Int(n) => n,
1100                        _ => 0,
1101                    }
1102                } else {
1103                    0
1104                };
1105                Ok(ControlFlow::exit_code(code))
1106            }
1107            Stmt::ToolDef(tool_def) => {
1108                let mut user_tools = self.user_tools.write().await;
1109                user_tools.insert(tool_def.name.clone(), tool_def.clone());
1110                Ok(ControlFlow::ok(ExecResult::success("")))
1111            }
1112            Stmt::AndChain { left, right } => {
1113                // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
1114                // Suppress errexit for the left side — && handles failure itself.
1115                {
1116                    let mut scope = self.scope.write().await;
1117                    scope.suppress_errexit();
1118                }
1119                let left_flow = self.execute_stmt_flow(left).await?;
1120                {
1121                    let mut scope = self.scope.write().await;
1122                    scope.unsuppress_errexit();
1123                }
1124                match left_flow {
1125                    ControlFlow::Normal(mut left_result) => {
1126                        self.drain_stderr_into(&mut left_result).await;
1127                        self.update_last_result(&left_result).await;
1128                        if left_result.ok() {
1129                            let right_flow = self.execute_stmt_flow(right).await?;
1130                            match right_flow {
1131                                ControlFlow::Normal(mut right_result) => {
1132                                    self.drain_stderr_into(&mut right_result).await;
1133                                    self.update_last_result(&right_result).await;
1134                                    let mut combined = left_result;
1135                                    accumulate_result(&mut combined, &right_result);
1136                                    Ok(ControlFlow::ok(combined))
1137                                }
1138                                other => Ok(other),
1139                            }
1140                        } else {
1141                            Ok(ControlFlow::ok(left_result))
1142                        }
1143                    }
1144                    _ => Ok(left_flow),
1145                }
1146            }
1147            Stmt::OrChain { left, right } => {
1148                // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
1149                // Suppress errexit for the left side — || handles failure itself.
1150                {
1151                    let mut scope = self.scope.write().await;
1152                    scope.suppress_errexit();
1153                }
1154                let left_flow = self.execute_stmt_flow(left).await?;
1155                {
1156                    let mut scope = self.scope.write().await;
1157                    scope.unsuppress_errexit();
1158                }
1159                match left_flow {
1160                    ControlFlow::Normal(mut left_result) => {
1161                        self.drain_stderr_into(&mut left_result).await;
1162                        self.update_last_result(&left_result).await;
1163                        if !left_result.ok() {
1164                            let right_flow = self.execute_stmt_flow(right).await?;
1165                            match right_flow {
1166                                ControlFlow::Normal(mut right_result) => {
1167                                    self.drain_stderr_into(&mut right_result).await;
1168                                    self.update_last_result(&right_result).await;
1169                                    let mut combined = left_result;
1170                                    accumulate_result(&mut combined, &right_result);
1171                                    Ok(ControlFlow::ok(combined))
1172                                }
1173                                other => Ok(other),
1174                            }
1175                        } else {
1176                            Ok(ControlFlow::ok(left_result))
1177                        }
1178                    }
1179                    _ => Ok(left_flow), // Propagate non-normal flow
1180                }
1181            }
1182            Stmt::Test(test_expr) => {
1183                // Evaluate the test expression by wrapping in Expr::Test
1184                let expr = crate::ast::Expr::Test(Box::new(test_expr.clone()));
1185                let mut scope = self.scope.write().await;
1186                let value = eval_expr(&expr, &mut scope)?;
1187                drop(scope);
1188                let is_true = match value {
1189                    crate::ast::Value::Bool(b) => b,
1190                    _ => false,
1191                };
1192                if is_true {
1193                    Ok(ControlFlow::ok(ExecResult::success("")))
1194                } else {
1195                    Ok(ControlFlow::ok(ExecResult::failure(1, "")))
1196                }
1197            }
1198            Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
1199        }
1200        }.instrument(span))
1201    }
1202
1203    /// Execute a pipeline.
1204    #[tracing::instrument(level = "debug", skip(self, pipeline), fields(background = pipeline.background, command_count = pipeline.commands.len()))]
1205    async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
1206        if pipeline.commands.is_empty() {
1207            return Ok(ExecResult::success(""));
1208        }
1209
1210        // Handle background execution (`&` operator)
1211        if pipeline.background {
1212            return self.execute_background(pipeline).await;
1213        }
1214
1215        // All commands go through the runner with the Kernel as dispatcher.
1216        // This is the single execution path — no fast path for single commands.
1217        //
1218        // IMPORTANT: We snapshot exec_ctx into a local context and release the
1219        // lock before running. This prevents deadlocks when dispatch_command
1220        // is called from within the pipeline and recursively triggers another
1221        // pipeline (e.g., via user-defined tools).
1222        let mut ctx = {
1223            let ec = self.exec_ctx.read().await;
1224            let scope = self.scope.read().await;
1225            ExecContext {
1226                backend: ec.backend.clone(),
1227                scope: scope.clone(),
1228                cwd: ec.cwd.clone(),
1229                prev_cwd: ec.prev_cwd.clone(),
1230                stdin: None,
1231                stdin_data: None,
1232                pipe_stdin: None,
1233                pipe_stdout: None,
1234                stderr: ec.stderr.clone(),
1235                tool_schemas: ec.tool_schemas.clone(),
1236                tools: ec.tools.clone(),
1237                job_manager: ec.job_manager.clone(),
1238                pipeline_position: PipelinePosition::Only,
1239                interactive: self.interactive,
1240                aliases: ec.aliases.clone(),
1241                ignore_config: ec.ignore_config.clone(),
1242                output_limit: ec.output_limit.clone(),
1243                allow_external_commands: self.allow_external_commands,
1244                nonce_store: ec.nonce_store.clone(),
1245                #[cfg(unix)]
1246                terminal_state: ec.terminal_state.clone(),
1247            }
1248        }; // locks released
1249
1250        let mut result = self.runner.run(&pipeline.commands, &mut ctx, self).await;
1251
1252        // Post-hoc spill check (catches builtins and fast external commands)
1253        if ctx.output_limit.is_enabled() {
1254            let _ = crate::output_limit::spill_if_needed(&mut result, &ctx.output_limit).await;
1255        }
1256
1257        // Sync changes back from context
1258        {
1259            let mut ec = self.exec_ctx.write().await;
1260            ec.cwd = ctx.cwd.clone();
1261            ec.prev_cwd = ctx.prev_cwd.clone();
1262            ec.aliases = ctx.aliases.clone();
1263            ec.ignore_config = ctx.ignore_config.clone();
1264            ec.output_limit = ctx.output_limit.clone();
1265        }
1266        {
1267            let mut scope = self.scope.write().await;
1268            *scope = ctx.scope.clone();
1269        }
1270
1271        Ok(result)
1272    }
1273
1274    /// Execute a pipeline in the background.
1275    ///
1276    /// The command is spawned as a tokio task, registered with the JobManager,
1277    /// and its output is captured via BoundedStreams. The job is observable via
1278    /// `/v/jobs/{id}/stdout`, `/v/jobs/{id}/stderr`, and `/v/jobs/{id}/status`.
1279    ///
1280    /// Returns immediately with a job ID like "[1]".
1281    #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.commands.len()))]
1282    async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
1283        use tokio::sync::oneshot;
1284
1285        // Format the command for display in /v/jobs/{id}/command
1286        let command_str = self.format_pipeline(pipeline);
1287
1288        // Create bounded streams for output capture
1289        let stdout = Arc::new(BoundedStream::default_size());
1290        let stderr = Arc::new(BoundedStream::default_size());
1291
1292        // Create channel for result notification
1293        let (tx, rx) = oneshot::channel();
1294
1295        // Register with JobManager to get job ID and create VFS entries
1296        let job_id = self.jobs.register_with_streams(
1297            command_str.clone(),
1298            rx,
1299            stdout.clone(),
1300            stderr.clone(),
1301        ).await;
1302
1303        // Clone state needed for the spawned task
1304        let runner = self.runner.clone();
1305        let commands = pipeline.commands.clone();
1306        let backend = {
1307            let ctx = self.exec_ctx.read().await;
1308            ctx.backend.clone()
1309        };
1310        let scope = {
1311            let scope = self.scope.read().await;
1312            scope.clone()
1313        };
1314        let cwd = {
1315            let ctx = self.exec_ctx.read().await;
1316            ctx.cwd.clone()
1317        };
1318        let tools = self.tools.clone();
1319        let tool_schemas = self.tools.schemas();
1320        let allow_ext = self.allow_external_commands;
1321
1322        // Spawn the background task
1323        tokio::spawn(async move {
1324            // Create execution context for the background job
1325            // It inherits env vars and cwd from the parent context
1326            let mut bg_ctx = ExecContext::with_backend(backend);
1327            bg_ctx.scope = scope;
1328            bg_ctx.cwd = cwd;
1329            bg_ctx.set_tools(tools.clone());
1330            bg_ctx.set_tool_schemas(tool_schemas);
1331            bg_ctx.allow_external_commands = allow_ext;
1332
1333            // Use BackendDispatcher for background jobs (builtins only).
1334            // Full Kernel dispatch requires Arc<Kernel> — planned for a future phase.
1335            let dispatcher = crate::dispatch::BackendDispatcher::new(tools);
1336
1337            // Execute the pipeline
1338            let result = runner.run(&commands, &mut bg_ctx, &dispatcher).await;
1339
1340            // Write output to streams
1341            if !result.out.is_empty() {
1342                stdout.write(result.out.as_bytes()).await;
1343            }
1344            if !result.err.is_empty() {
1345                stderr.write(result.err.as_bytes()).await;
1346            }
1347
1348            // Close streams
1349            stdout.close().await;
1350            stderr.close().await;
1351
1352            // Send result to JobManager (ignore error if receiver dropped)
1353            let _ = tx.send(result);
1354        });
1355
1356        Ok(ExecResult::success(format!("[{}]", job_id)))
1357    }
1358
1359    /// Format a pipeline as a command string for display.
1360    fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
1361        pipeline.commands
1362            .iter()
1363            .map(|cmd| {
1364                let mut parts = vec![cmd.name.clone()];
1365                for arg in &cmd.args {
1366                    match arg {
1367                        Arg::Positional(expr) => {
1368                            parts.push(self.format_expr(expr));
1369                        }
1370                        Arg::Named { key, value } => {
1371                            parts.push(format!("{}={}", key, self.format_expr(value)));
1372                        }
1373                        Arg::ShortFlag(name) => {
1374                            parts.push(format!("-{}", name));
1375                        }
1376                        Arg::LongFlag(name) => {
1377                            parts.push(format!("--{}", name));
1378                        }
1379                        Arg::DoubleDash => {
1380                            parts.push("--".to_string());
1381                        }
1382                    }
1383                }
1384                parts.join(" ")
1385            })
1386            .collect::<Vec<_>>()
1387            .join(" | ")
1388    }
1389
1390    /// Format an expression as a string for display.
1391    fn format_expr(&self, expr: &Expr) -> String {
1392        match expr {
1393            Expr::Literal(Value::String(s)) => {
1394                if s.contains(' ') || s.contains('"') {
1395                    format!("'{}'", s.replace('\'', "\\'"))
1396                } else {
1397                    s.clone()
1398                }
1399            }
1400            Expr::Literal(Value::Int(i)) => i.to_string(),
1401            Expr::Literal(Value::Float(f)) => f.to_string(),
1402            Expr::Literal(Value::Bool(b)) => b.to_string(),
1403            Expr::Literal(Value::Null) => "null".to_string(),
1404            Expr::VarRef(path) => {
1405                let name = path.segments.iter()
1406                    .map(|seg| match seg {
1407                        crate::ast::VarSegment::Field(f) => f.clone(),
1408                    })
1409                    .collect::<Vec<_>>()
1410                    .join(".");
1411                format!("${{{}}}", name)
1412            }
1413            Expr::Interpolated(_) => "\"...\"".to_string(),
1414            _ => "...".to_string(),
1415        }
1416    }
1417
1418    /// Execute a single command.
1419    async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
1420        self.execute_command_depth(name, args, 0).await
1421    }
1422
1423    #[tracing::instrument(level = "info", skip(self, args, alias_depth), fields(command = %name), err)]
1424    async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
1425        // Special built-ins
1426        match name {
1427            "true" => return Ok(ExecResult::success("")),
1428            "false" => return Ok(ExecResult::failure(1, "")),
1429            "source" | "." => return self.execute_source(args).await,
1430            _ => {}
1431        }
1432
1433        // Alias expansion (with recursion limit)
1434        if alias_depth < 10 {
1435            let alias_value = {
1436                let ctx = self.exec_ctx.read().await;
1437                ctx.aliases.get(name).cloned()
1438            };
1439            if let Some(alias_val) = alias_value {
1440                // Split alias value into command + args
1441                let parts: Vec<&str> = alias_val.split_whitespace().collect();
1442                if let Some((alias_cmd, alias_args)) = parts.split_first() {
1443                    let mut new_args: Vec<Arg> = alias_args
1444                        .iter()
1445                        .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
1446                        .collect();
1447                    new_args.extend_from_slice(args);
1448                    return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
1449                }
1450            }
1451        }
1452
1453        // Handle /v/bin/ prefix — dispatch to builtins via virtual path
1454        if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
1455            return match self.tools.get(builtin_name) {
1456                Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
1457                None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
1458            };
1459        }
1460
1461        // Check user-defined tools first
1462        {
1463            let user_tools = self.user_tools.read().await;
1464            if let Some(tool_def) = user_tools.get(name) {
1465                let tool_def = tool_def.clone();
1466                drop(user_tools);
1467                return self.execute_user_tool(tool_def, args).await;
1468            }
1469        }
1470
1471        // Look up builtin tool
1472        let tool = match self.tools.get(name) {
1473            Some(t) => t,
1474            None => {
1475                // Try executing as .kai script from PATH
1476                if let Some(result) = self.try_execute_script(name, args).await? {
1477                    return Ok(result);
1478                }
1479                // Try executing as external command from PATH
1480                if let Some(result) = self.try_execute_external(name, args).await? {
1481                    return Ok(result);
1482                }
1483
1484                // Try backend-registered tools (embedder engines, MCP tools, etc.)
1485                // Look up tool schema for positional→named mapping.
1486                // Clone backend and drop read lock before awaiting (may involve network I/O).
1487                // Backend tools expect named JSON params, so enable positional mapping.
1488                let backend = self.exec_ctx.read().await.backend.clone();
1489                let tool_schema = backend.get_tool(name).await.ok().flatten().map(|t| {
1490                    let mut s = t.schema;
1491                    s.map_positionals = true;
1492                    s
1493                });
1494                let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
1495                let mut ctx = self.exec_ctx.write().await;
1496                {
1497                    let scope = self.scope.read().await;
1498                    ctx.scope = scope.clone();
1499                }
1500                let backend = ctx.backend.clone();
1501                match backend.call_tool(name, tool_args, &mut ctx).await {
1502                    Ok(tool_result) => {
1503                        let mut scope = self.scope.write().await;
1504                        *scope = ctx.scope.clone();
1505                        let mut exec = ExecResult::from_output(
1506                            tool_result.code as i64, tool_result.stdout, tool_result.stderr,
1507                        );
1508                        exec.output = tool_result.output;
1509                        return Ok(exec);
1510                    }
1511                    Err(BackendError::ToolNotFound(_)) => {
1512                        // Fall through to "command not found"
1513                    }
1514                    Err(e) => {
1515                        // Backend dispatch is last-resort lookup — if it fails
1516                        // for any reason, the command simply doesn't exist.
1517                        tracing::debug!("backend error for {name}: {e}");
1518                    }
1519                }
1520
1521                return Ok(ExecResult::failure(127, format!("command not found: {}", name)));
1522            }
1523        };
1524
1525        // Build arguments (async to support command substitution, schema-aware for flag values)
1526        let schema = tool.schema();
1527        let mut tool_args = self.build_args_async(args, Some(&schema)).await?;
1528        let output_format = extract_output_format(&mut tool_args, Some(&schema));
1529
1530        // --help / -h: show help unless the tool's schema claims that flag
1531        let schema_claims = |flag: &str| -> bool {
1532            let bare = flag.trim_start_matches('-');
1533            schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
1534        };
1535        let wants_help =
1536            (tool_args.flags.contains("help") && !schema_claims("help"))
1537            || (tool_args.flags.contains("h") && !schema_claims("-h"));
1538        if wants_help {
1539            let help_topic = crate::help::HelpTopic::Tool(name.to_string());
1540            let ctx = self.exec_ctx.read().await;
1541            let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
1542            return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
1543        }
1544
1545        // Execute
1546        let mut ctx = self.exec_ctx.write().await;
1547        {
1548            let scope = self.scope.read().await;
1549            ctx.scope = scope.clone();
1550        }
1551
1552        let result = tool.execute(tool_args, &mut ctx).await;
1553
1554        // Sync scope changes back (e.g., from cd)
1555        {
1556            let mut scope = self.scope.write().await;
1557            *scope = ctx.scope.clone();
1558        }
1559
1560        let result = match output_format {
1561            Some(format) => apply_output_format(result, format),
1562            None => result,
1563        };
1564
1565        Ok(result)
1566    }
1567
1568    /// Build tool arguments from AST args.
1569    ///
1570    /// Uses async evaluation to support command substitution in arguments.
1571    async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
1572        let mut tool_args = ToolArgs::new();
1573        let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
1574
1575        // Track which positional indices have been consumed as flag values
1576        let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
1577        let mut past_double_dash = false;
1578
1579        // Find positional arg indices for flag value consumption
1580        let positional_indices: Vec<usize> = args.iter().enumerate()
1581            .filter_map(|(i, a)| matches!(a, Arg::Positional(_)).then_some(i))
1582            .collect();
1583
1584        let mut i = 0;
1585        while i < args.len() {
1586            match &args[i] {
1587                Arg::DoubleDash => {
1588                    past_double_dash = true;
1589                }
1590                Arg::Positional(expr) => {
1591                    if !consumed.contains(&i) {
1592                        let value = self.eval_expr_async(expr).await?;
1593                        let value = apply_tilde_expansion(value);
1594                        tool_args.positional.push(value);
1595                    }
1596                }
1597                Arg::Named { key, value } => {
1598                    let val = self.eval_expr_async(value).await?;
1599                    let val = apply_tilde_expansion(val);
1600                    tool_args.named.insert(key.clone(), val);
1601                }
1602                Arg::ShortFlag(name) => {
1603                    if past_double_dash {
1604                        tool_args.positional.push(Value::String(format!("-{name}")));
1605                    } else if name.len() == 1 {
1606                        let flag_name = name.as_str();
1607                        let lookup = param_lookup.get(flag_name);
1608                        let is_bool = lookup.map(|(_, typ)| is_bool_type(typ)).unwrap_or(true);
1609
1610                        if is_bool {
1611                            tool_args.flags.insert(flag_name.to_string());
1612                        } else {
1613                            // Non-bool: consume next positional as value
1614                            let canonical = lookup.map(|(name, _)| *name).unwrap_or(flag_name);
1615                            let next_pos = positional_indices.iter()
1616                                .find(|idx| **idx > i && !consumed.contains(idx));
1617
1618                            if let Some(&pos_idx) = next_pos {
1619                                if let Arg::Positional(expr) = &args[pos_idx] {
1620                                    let value = self.eval_expr_async(expr).await?;
1621                                    let value = apply_tilde_expansion(value);
1622                                    tool_args.named.insert(canonical.to_string(), value);
1623                                    consumed.insert(pos_idx);
1624                                }
1625                            } else {
1626                                tool_args.flags.insert(flag_name.to_string());
1627                            }
1628                        }
1629                    } else {
1630                        // Multi-char combined flags like -la: always boolean
1631                        for c in name.chars() {
1632                            tool_args.flags.insert(c.to_string());
1633                        }
1634                    }
1635                }
1636                Arg::LongFlag(name) => {
1637                    if past_double_dash {
1638                        tool_args.positional.push(Value::String(format!("--{name}")));
1639                    } else {
1640                        let lookup = param_lookup.get(name.as_str());
1641                        let is_bool = lookup.map(|(_, typ)| is_bool_type(typ)).unwrap_or(true);
1642
1643                        if is_bool {
1644                            tool_args.flags.insert(name.clone());
1645                        } else {
1646                            let canonical = lookup.map(|(name, _)| *name).unwrap_or(name.as_str());
1647                            let next_pos = positional_indices.iter()
1648                                .find(|idx| **idx > i && !consumed.contains(idx));
1649
1650                            if let Some(&pos_idx) = next_pos {
1651                                if let Arg::Positional(expr) = &args[pos_idx] {
1652                                    let value = self.eval_expr_async(expr).await?;
1653                                    let value = apply_tilde_expansion(value);
1654                                    tool_args.named.insert(canonical.to_string(), value);
1655                                    consumed.insert(pos_idx);
1656                                }
1657                            } else {
1658                                tool_args.flags.insert(name.clone());
1659                            }
1660                        }
1661                    }
1662                }
1663            }
1664            i += 1;
1665        }
1666
1667        // Map remaining positionals to unfilled non-bool schema params (in order).
1668        // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
1669        // Positionals that appeared after `--` are never mapped (they're raw data).
1670        // Only for MCP/external tools (map_positionals=true). Builtins handle their own positionals.
1671        if let Some(schema) = schema.filter(|s| s.map_positionals) {
1672            let pre_dash_count = if past_double_dash {
1673                let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
1674                positional_indices.iter()
1675                    .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
1676                    .count()
1677            } else {
1678                tool_args.positional.len()
1679            };
1680
1681            let mut remaining = Vec::new();
1682            let mut positional_iter = tool_args.positional.drain(..).enumerate();
1683
1684            for param in &schema.params {
1685                if tool_args.named.contains_key(&param.name) || tool_args.flags.contains(&param.name) {
1686                    continue;
1687                }
1688                if is_bool_type(&param.param_type) {
1689                    continue;
1690                }
1691                loop {
1692                    match positional_iter.next() {
1693                        Some((idx, val)) if idx < pre_dash_count => {
1694                            tool_args.named.insert(param.name.clone(), val);
1695                            break;
1696                        }
1697                        Some((_, val)) => {
1698                            remaining.push(val);
1699                        }
1700                        None => break,
1701                    }
1702                }
1703            }
1704
1705            remaining.extend(positional_iter.map(|(_, v)| v));
1706            tool_args.positional = remaining;
1707        }
1708
1709        Ok(tool_args)
1710    }
1711
1712    /// Build arguments as flat string list for external commands.
1713    ///
1714    /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
1715    /// this preserves the original flag format as strings for external commands:
1716    /// - `-l` stays as `-l`
1717    /// - `--verbose` stays as `--verbose`
1718    /// - `key=value` stays as `key=value`
1719    ///
1720    /// This is what external commands expect in their argv.
1721    async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
1722        let mut argv = Vec::new();
1723        for arg in args {
1724            match arg {
1725                Arg::Positional(expr) => {
1726                    let value = self.eval_expr_async(expr).await?;
1727                    let value = apply_tilde_expansion(value);
1728                    argv.push(value_to_string(&value));
1729                }
1730                Arg::Named { key, value } => {
1731                    let val = self.eval_expr_async(value).await?;
1732                    let val = apply_tilde_expansion(val);
1733                    argv.push(format!("{}={}", key, value_to_string(&val)));
1734                }
1735                Arg::ShortFlag(name) => {
1736                    // Preserve original format: -l, -la (combined flags)
1737                    argv.push(format!("-{}", name));
1738                }
1739                Arg::LongFlag(name) => {
1740                    // Preserve original format: --verbose
1741                    argv.push(format!("--{}", name));
1742                }
1743                Arg::DoubleDash => {
1744                    // Preserve the -- marker
1745                    argv.push("--".to_string());
1746                }
1747            }
1748        }
1749        Ok(argv)
1750    }
1751
1752    /// Async expression evaluator that supports command substitution.
1753    ///
1754    /// This is used for contexts where expressions may contain `$(...)` command
1755    /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
1756    fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
1757        Box::pin(async move {
1758        match expr {
1759            Expr::Literal(value) => Ok(value.clone()),
1760            Expr::VarRef(path) => {
1761                let scope = self.scope.read().await;
1762                scope.resolve_path(path)
1763                    .ok_or_else(|| anyhow::anyhow!("undefined variable"))
1764            }
1765            Expr::Interpolated(parts) => {
1766                let mut result = String::new();
1767                for part in parts {
1768                    result.push_str(&self.eval_string_part_async(part).await?);
1769                }
1770                Ok(Value::String(result))
1771            }
1772            Expr::BinaryOp { left, op, right } => {
1773                match op {
1774                    BinaryOp::And => {
1775                        let left_val = self.eval_expr_async(left).await?;
1776                        if !is_truthy(&left_val) {
1777                            return Ok(left_val);
1778                        }
1779                        self.eval_expr_async(right).await
1780                    }
1781                    BinaryOp::Or => {
1782                        let left_val = self.eval_expr_async(left).await?;
1783                        if is_truthy(&left_val) {
1784                            return Ok(left_val);
1785                        }
1786                        self.eval_expr_async(right).await
1787                    }
1788                    _ => {
1789                        // For other operators, fall back to sync eval
1790                        let mut scope = self.scope.write().await;
1791                        eval_expr(expr, &mut scope).map_err(|e| anyhow::anyhow!("{}", e))
1792                    }
1793                }
1794            }
1795            Expr::CommandSubst(pipeline) => {
1796                // Snapshot scope+cwd before running — only output escapes,
1797                // not side effects like `cd` or variable assignments.
1798                let saved_scope = { self.scope.read().await.clone() };
1799                let saved_cwd = {
1800                    let ec = self.exec_ctx.read().await;
1801                    (ec.cwd.clone(), ec.prev_cwd.clone())
1802                };
1803
1804                // Capture result without `?` — restore state unconditionally
1805                let run_result = self.execute_pipeline(pipeline).await;
1806
1807                // Restore scope and cwd regardless of success/failure
1808                {
1809                    let mut scope = self.scope.write().await;
1810                    *scope = saved_scope;
1811                    if let Ok(ref r) = run_result {
1812                        scope.set_last_result(r.clone());
1813                    }
1814                }
1815                {
1816                    let mut ec = self.exec_ctx.write().await;
1817                    ec.cwd = saved_cwd.0;
1818                    ec.prev_cwd = saved_cwd.1;
1819                }
1820
1821                // Now propagate the error
1822                let result = run_result?;
1823
1824                // Prefer structured data (enables `for i in $(cmd)` iteration)
1825                if let Some(data) = &result.data {
1826                    Ok(data.clone())
1827                } else if let Some(ref output) = result.output {
1828                    // Flat non-text node lists (glob, ls, tree) → iterable array
1829                    if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
1830                        let items: Vec<serde_json::Value> = output.root.iter()
1831                            .map(|n| serde_json::Value::String(n.display_name().to_string()))
1832                            .collect();
1833                        Ok(Value::Json(serde_json::Value::Array(items)))
1834                    } else {
1835                        Ok(Value::String(result.out.trim_end().to_string()))
1836                    }
1837                } else {
1838                    // Otherwise return stdout as single string (NO implicit splitting)
1839                    Ok(Value::String(result.out.trim_end().to_string()))
1840                }
1841            }
1842            Expr::Test(test_expr) => {
1843                // For test expressions, use the sync evaluator
1844                let expr = Expr::Test(test_expr.clone());
1845                let mut scope = self.scope.write().await;
1846                eval_expr(&expr, &mut scope).map_err(|e| anyhow::anyhow!("{}", e))
1847            }
1848            Expr::Positional(n) => {
1849                let scope = self.scope.read().await;
1850                match scope.get_positional(*n) {
1851                    Some(s) => Ok(Value::String(s.to_string())),
1852                    None => Ok(Value::String(String::new())),
1853                }
1854            }
1855            Expr::AllArgs => {
1856                let scope = self.scope.read().await;
1857                Ok(Value::String(scope.all_args().join(" ")))
1858            }
1859            Expr::ArgCount => {
1860                let scope = self.scope.read().await;
1861                Ok(Value::Int(scope.arg_count() as i64))
1862            }
1863            Expr::VarLength(name) => {
1864                let scope = self.scope.read().await;
1865                match scope.get(name) {
1866                    Some(value) => Ok(Value::Int(value_to_string(value).len() as i64)),
1867                    None => Ok(Value::Int(0)),
1868                }
1869            }
1870            Expr::VarWithDefault { name, default } => {
1871                let scope = self.scope.read().await;
1872                let use_default = match scope.get(name) {
1873                    Some(value) => value_to_string(value).is_empty(),
1874                    None => true,
1875                };
1876                drop(scope); // Release the lock before recursive evaluation
1877                if use_default {
1878                    // Evaluate the default parts (supports nested expansions)
1879                    self.eval_string_parts_async(default).await.map(Value::String)
1880                } else {
1881                    let scope = self.scope.read().await;
1882                    scope.get(name).cloned().ok_or_else(|| anyhow::anyhow!("variable '{}' not found", name))
1883                }
1884            }
1885            Expr::Arithmetic(expr_str) => {
1886                let scope = self.scope.read().await;
1887                crate::arithmetic::eval_arithmetic(expr_str, &scope)
1888                    .map(Value::Int)
1889                    .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
1890            }
1891            Expr::Command(cmd) => {
1892                // Execute command and return boolean based on exit code
1893                let result = self.execute_command(&cmd.name, &cmd.args).await?;
1894                Ok(Value::Bool(result.code == 0))
1895            }
1896            Expr::LastExitCode => {
1897                let scope = self.scope.read().await;
1898                Ok(Value::Int(scope.last_result().code))
1899            }
1900            Expr::CurrentPid => {
1901                let scope = self.scope.read().await;
1902                Ok(Value::Int(scope.pid() as i64))
1903            }
1904        }
1905        })
1906    }
1907
1908    /// Async helper to evaluate multiple StringParts into a single string.
1909    fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
1910        Box::pin(async move {
1911            let mut result = String::new();
1912            for part in parts {
1913                result.push_str(&self.eval_string_part_async(part).await?);
1914            }
1915            Ok(result)
1916        })
1917    }
1918
1919    /// Async helper to evaluate a StringPart.
1920    fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
1921        Box::pin(async move {
1922            match part {
1923                StringPart::Literal(s) => Ok(s.clone()),
1924                StringPart::Var(path) => {
1925                    let scope = self.scope.read().await;
1926                    match scope.resolve_path(path) {
1927                        Some(value) => Ok(value_to_string(&value)),
1928                        None => Ok(String::new()), // Unset vars expand to empty
1929                    }
1930                }
1931                StringPart::VarWithDefault { name, default } => {
1932                    let scope = self.scope.read().await;
1933                    let use_default = match scope.get(name) {
1934                        Some(value) => value_to_string(value).is_empty(),
1935                        None => true,
1936                    };
1937                    drop(scope); // Release lock before recursive evaluation
1938                    if use_default {
1939                        // Evaluate the default parts (supports nested expansions)
1940                        self.eval_string_parts_async(default).await
1941                    } else {
1942                        let scope = self.scope.read().await;
1943                        Ok(value_to_string(scope.get(name).ok_or_else(|| anyhow::anyhow!("variable '{}' not found", name))?))
1944                    }
1945                }
1946            StringPart::VarLength(name) => {
1947                let scope = self.scope.read().await;
1948                match scope.get(name) {
1949                    Some(value) => Ok(value_to_string(value).len().to_string()),
1950                    None => Ok("0".to_string()),
1951                }
1952            }
1953            StringPart::Positional(n) => {
1954                let scope = self.scope.read().await;
1955                match scope.get_positional(*n) {
1956                    Some(s) => Ok(s.to_string()),
1957                    None => Ok(String::new()),
1958                }
1959            }
1960            StringPart::AllArgs => {
1961                let scope = self.scope.read().await;
1962                Ok(scope.all_args().join(" "))
1963            }
1964            StringPart::ArgCount => {
1965                let scope = self.scope.read().await;
1966                Ok(scope.arg_count().to_string())
1967            }
1968            StringPart::Arithmetic(expr) => {
1969                let scope = self.scope.read().await;
1970                match crate::arithmetic::eval_arithmetic(expr, &scope) {
1971                    Ok(value) => Ok(value.to_string()),
1972                    Err(_) => Ok(String::new()),
1973                }
1974            }
1975            StringPart::CommandSubst(pipeline) => {
1976                // Snapshot scope+cwd — command substitution in strings must
1977                // not leak side effects (e.g., `"dir: $(cd /; pwd)"` must not change cwd).
1978                let saved_scope = { self.scope.read().await.clone() };
1979                let saved_cwd = {
1980                    let ec = self.exec_ctx.read().await;
1981                    (ec.cwd.clone(), ec.prev_cwd.clone())
1982                };
1983
1984                // Capture result without `?` — restore state unconditionally
1985                let run_result = self.execute_pipeline(pipeline).await;
1986
1987                // Restore scope and cwd regardless of success/failure
1988                {
1989                    let mut scope = self.scope.write().await;
1990                    *scope = saved_scope;
1991                    if let Ok(ref r) = run_result {
1992                        scope.set_last_result(r.clone());
1993                    }
1994                }
1995                {
1996                    let mut ec = self.exec_ctx.write().await;
1997                    ec.cwd = saved_cwd.0;
1998                    ec.prev_cwd = saved_cwd.1;
1999                }
2000
2001                // Now propagate the error
2002                let result = run_result?;
2003
2004                Ok(result.out.trim_end_matches('\n').to_string())
2005            }
2006            StringPart::LastExitCode => {
2007                let scope = self.scope.read().await;
2008                Ok(scope.last_result().code.to_string())
2009            }
2010            StringPart::CurrentPid => {
2011                let scope = self.scope.read().await;
2012                Ok(scope.pid().to_string())
2013            }
2014        }
2015        })
2016    }
2017
2018    /// Update the last result in scope.
2019    async fn update_last_result(&self, result: &ExecResult) {
2020        let mut scope = self.scope.write().await;
2021        scope.set_last_result(result.clone());
2022    }
2023
2024    /// Drain accumulated pipeline stderr into a result.
2025    ///
2026    /// Called after each sub-statement inside control structures (`if`, `for`,
2027    /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
2028    /// than batching until the entire structure finishes.
2029    async fn drain_stderr_into(&self, result: &mut ExecResult) {
2030        let drained = {
2031            let mut receiver = self.stderr_receiver.lock().await;
2032            receiver.drain_lossy()
2033        };
2034        if !drained.is_empty() {
2035            if !result.err.is_empty() && !result.err.ends_with('\n') {
2036                result.err.push('\n');
2037            }
2038            result.err.push_str(&drained);
2039        }
2040    }
2041
2042    /// Execute a user-defined function with local variable scoping.
2043    ///
2044    /// Functions push a new scope frame for local variables. Variables declared
2045    /// with `local` are scoped to the function; other assignments modify outer
2046    /// scopes (or create in root if new).
2047    async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
2048        // 1. Build function args from AST args (async to support command substitution)
2049        let tool_args = self.build_args_async(args, None).await?;
2050
2051        // 2. Push a new scope frame for local variables
2052        {
2053            let mut scope = self.scope.write().await;
2054            scope.push_frame();
2055        }
2056
2057        // 3. Save current positional parameters and set new ones for this function
2058        let saved_positional = {
2059            let mut scope = self.scope.write().await;
2060            let saved = scope.save_positional();
2061
2062            // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
2063            let positional_args: Vec<String> = tool_args.positional
2064                .iter()
2065                .map(value_to_string)
2066                .collect();
2067            scope.set_positional(&def.name, positional_args);
2068
2069            saved
2070        };
2071
2072        // 3. Execute body statements with control flow handling
2073        // Accumulate output across statements (like sh)
2074        let mut accumulated_out = String::new();
2075        let mut accumulated_err = String::new();
2076        let mut last_code = 0i64;
2077        let mut last_data: Option<Value> = None;
2078
2079        // Track execution error for propagation after cleanup
2080        let mut exec_error: Option<anyhow::Error> = None;
2081        let mut exit_code: Option<i64> = None;
2082
2083        for stmt in &def.body {
2084            match self.execute_stmt_flow(stmt).await {
2085                Ok(flow) => {
2086                    // Drain pipeline stderr after each sub-statement.
2087                    let drained = {
2088                        let mut receiver = self.stderr_receiver.lock().await;
2089                        receiver.drain_lossy()
2090                    };
2091                    if !drained.is_empty() {
2092                        accumulated_err.push_str(&drained);
2093                    }
2094
2095                    match flow {
2096                        ControlFlow::Normal(r) => {
2097                            accumulated_out.push_str(&r.out);
2098                            accumulated_err.push_str(&r.err);
2099                            last_code = r.code;
2100                            last_data = r.data;
2101                        }
2102                        ControlFlow::Return { value } => {
2103                            accumulated_out.push_str(&value.out);
2104                            accumulated_err.push_str(&value.err);
2105                            last_code = value.code;
2106                            last_data = value.data;
2107                            break;
2108                        }
2109                        ControlFlow::Exit { code } => {
2110                            exit_code = Some(code);
2111                            break;
2112                        }
2113                        ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
2114                            accumulated_out.push_str(&r.out);
2115                            accumulated_err.push_str(&r.err);
2116                            last_code = r.code;
2117                            last_data = r.data;
2118                        }
2119                    }
2120                }
2121                Err(e) => {
2122                    exec_error = Some(e);
2123                    break;
2124                }
2125            }
2126        }
2127
2128        // 4. Pop scope frame and restore original positional parameters (unconditionally)
2129        {
2130            let mut scope = self.scope.write().await;
2131            scope.pop_frame();
2132            scope.set_positional(saved_positional.0, saved_positional.1);
2133        }
2134
2135        // 5. Propagate error or exit after cleanup
2136        if let Some(e) = exec_error {
2137            return Err(e);
2138        }
2139        if let Some(code) = exit_code {
2140            return Ok(ExecResult {
2141                code,
2142                out: accumulated_out,
2143                err: accumulated_err,
2144                data: last_data,
2145                output: None,
2146            });
2147        }
2148
2149        Ok(ExecResult {
2150            code: last_code,
2151            out: accumulated_out,
2152            err: accumulated_err,
2153            data: last_data,
2154            output: None,
2155        })
2156    }
2157
2158    /// Execute the `source` / `.` command to include and run a script.
2159    ///
2160    /// Unlike regular tool execution, `source` executes in the CURRENT scope,
2161    /// allowing the sourced script to set variables and modify shell state.
2162    async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
2163        // Get the file path from the first positional argument
2164        let tool_args = self.build_args_async(args, None).await?;
2165        let path = match tool_args.positional.first() {
2166            Some(Value::String(s)) => s.clone(),
2167            Some(v) => value_to_string(v),
2168            None => {
2169                return Ok(ExecResult::failure(1, "source: missing filename"));
2170            }
2171        };
2172
2173        // Resolve path relative to cwd
2174        let full_path = {
2175            let ctx = self.exec_ctx.read().await;
2176            if path.starts_with('/') {
2177                std::path::PathBuf::from(&path)
2178            } else {
2179                ctx.cwd.join(&path)
2180            }
2181        };
2182
2183        // Read file content via backend
2184        let content = {
2185            let ctx = self.exec_ctx.read().await;
2186            match ctx.backend.read(&full_path, None).await {
2187                Ok(bytes) => {
2188                    String::from_utf8(bytes).map_err(|e| {
2189                        anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
2190                    })?
2191                }
2192                Err(e) => {
2193                    return Ok(ExecResult::failure(
2194                        1,
2195                        format!("source: {}: {}", path, e),
2196                    ));
2197                }
2198            }
2199        };
2200
2201        // Parse the content
2202        let program = match crate::parser::parse(&content) {
2203            Ok(p) => p,
2204            Err(errors) => {
2205                let msg = errors
2206                    .iter()
2207                    .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
2208                    .collect::<Vec<_>>()
2209                    .join("\n");
2210                return Ok(ExecResult::failure(1, format!("source: {}", msg)));
2211            }
2212        };
2213
2214        // Execute each statement in the CURRENT scope (not isolated)
2215        let mut result = ExecResult::success("");
2216        for stmt in program.statements {
2217            if matches!(stmt, crate::ast::Stmt::Empty) {
2218                continue;
2219            }
2220
2221            match self.execute_stmt_flow(&stmt).await {
2222                Ok(flow) => {
2223                    self.drain_stderr_into(&mut result).await;
2224                    match flow {
2225                        ControlFlow::Normal(r) => {
2226                            result = r.clone();
2227                            self.update_last_result(&r).await;
2228                        }
2229                        ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
2230                            return Err(anyhow::anyhow!(
2231                                "source: {}: unexpected break/continue outside loop",
2232                                path
2233                            ));
2234                        }
2235                        ControlFlow::Return { value } => {
2236                            return Ok(value);
2237                        }
2238                        ControlFlow::Exit { code } => {
2239                            result.code = code;
2240                            return Ok(result);
2241                        }
2242                    }
2243                }
2244                Err(e) => {
2245                    return Err(e.context(format!("source: {}", path)));
2246                }
2247            }
2248        }
2249
2250        Ok(result)
2251    }
2252
2253    /// Try to execute a script from PATH directories.
2254    ///
2255    /// Searches PATH for `{name}.kai` files and executes them in isolated scope
2256    /// (like user-defined tools). Returns None if no script is found.
2257    async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
2258        // Get PATH from scope (default to "/bin")
2259        let path_value = {
2260            let scope = self.scope.read().await;
2261            scope
2262                .get("PATH")
2263                .map(value_to_string)
2264                .unwrap_or_else(|| "/bin".to_string())
2265        };
2266
2267        // Search PATH directories for script
2268        for dir in path_value.split(':') {
2269            if dir.is_empty() {
2270                continue;
2271            }
2272
2273            // Build script path: {dir}/{name}.kai
2274            let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
2275
2276            // Check if script exists
2277            let exists = {
2278                let ctx = self.exec_ctx.read().await;
2279                ctx.backend.exists(&script_path).await
2280            };
2281
2282            if !exists {
2283                continue;
2284            }
2285
2286            // Read script content
2287            let content = {
2288                let ctx = self.exec_ctx.read().await;
2289                match ctx.backend.read(&script_path, None).await {
2290                    Ok(bytes) => match String::from_utf8(bytes) {
2291                        Ok(s) => s,
2292                        Err(e) => {
2293                            return Ok(Some(ExecResult::failure(
2294                                1,
2295                                format!("{}: invalid UTF-8: {}", script_path.display(), e),
2296                            )));
2297                        }
2298                    },
2299                    Err(e) => {
2300                        return Ok(Some(ExecResult::failure(
2301                            1,
2302                            format!("{}: {}", script_path.display(), e),
2303                        )));
2304                    }
2305                }
2306            };
2307
2308            // Parse the script
2309            let program = match crate::parser::parse(&content) {
2310                Ok(p) => p,
2311                Err(errors) => {
2312                    let msg = errors
2313                        .iter()
2314                        .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
2315                        .collect::<Vec<_>>()
2316                        .join("\n");
2317                    return Ok(Some(ExecResult::failure(1, msg)));
2318                }
2319            };
2320
2321            // Build tool_args from args (async for command substitution support)
2322            let tool_args = self.build_args_async(args, None).await?;
2323
2324            // Create isolated scope (like user tools)
2325            let mut isolated_scope = Scope::new();
2326
2327            // Set up positional parameters ($0 = script name, $1, $2, ... = args)
2328            let positional_args: Vec<String> = tool_args.positional
2329                .iter()
2330                .map(value_to_string)
2331                .collect();
2332            isolated_scope.set_positional(name, positional_args);
2333
2334            // Save current scope and swap with isolated scope
2335            let original_scope = {
2336                let mut scope = self.scope.write().await;
2337                std::mem::replace(&mut *scope, isolated_scope)
2338            };
2339
2340            // Execute script statements — track outcome for cleanup
2341            let mut result = ExecResult::success("");
2342            let mut exec_error: Option<anyhow::Error> = None;
2343            let mut exit_code: Option<i64> = None;
2344
2345            for stmt in program.statements {
2346                if matches!(stmt, crate::ast::Stmt::Empty) {
2347                    continue;
2348                }
2349
2350                match self.execute_stmt_flow(&stmt).await {
2351                    Ok(flow) => {
2352                        match flow {
2353                            ControlFlow::Normal(r) => result = r,
2354                            ControlFlow::Return { value } => {
2355                                result = value;
2356                                break;
2357                            }
2358                            ControlFlow::Exit { code } => {
2359                                exit_code = Some(code);
2360                                break;
2361                            }
2362                            ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
2363                                result = r;
2364                            }
2365                        }
2366                    }
2367                    Err(e) => {
2368                        exec_error = Some(e);
2369                        break;
2370                    }
2371                }
2372            }
2373
2374            // Restore original scope unconditionally
2375            {
2376                let mut scope = self.scope.write().await;
2377                *scope = original_scope;
2378            }
2379
2380            // Propagate error or exit after cleanup
2381            if let Some(e) = exec_error {
2382                return Err(e.context(format!("script: {}", script_path.display())));
2383            }
2384            if let Some(code) = exit_code {
2385                result.code = code;
2386                return Ok(Some(result));
2387            }
2388
2389            return Ok(Some(result));
2390        }
2391
2392        // No script found
2393        Ok(None)
2394    }
2395
2396    /// Try to execute an external command from PATH.
2397    ///
2398    /// This is the fallback when no builtin or user-defined tool matches.
2399    /// External commands receive a clean argv (flags preserved in their original format).
2400    ///
2401    /// # Requirements
2402    /// - Command must be found in PATH
2403    /// - Current working directory must be on a real filesystem (not virtual like /v)
2404    ///
2405    /// # Returns
2406    /// - `Ok(Some(result))` if command was found and executed
2407    /// - `Ok(None)` if command was not found in PATH
2408    /// - `Err` on execution errors
2409    #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
2410    async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
2411        if !self.allow_external_commands {
2412            return Ok(None);
2413        }
2414
2415        // Get real working directory for relative path resolution and child cwd.
2416        // If the CWD is virtual (no real filesystem path), skip external command
2417        // execution entirely — return None so the dispatch can fall through to
2418        // backend-registered tools.
2419        let real_cwd = {
2420            let ctx = self.exec_ctx.read().await;
2421            match ctx.backend.resolve_real_path(&ctx.cwd) {
2422                Some(p) => p,
2423                None => return Ok(None),
2424            }
2425        };
2426
2427        let executable = if name.contains('/') {
2428            // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
2429            let resolved = if std::path::Path::new(name).is_absolute() {
2430                std::path::PathBuf::from(name)
2431            } else {
2432                real_cwd.join(name)
2433            };
2434            if !resolved.exists() {
2435                return Ok(Some(ExecResult::failure(
2436                    127,
2437                    format!("{}: No such file or directory", name),
2438                )));
2439            }
2440            if !resolved.is_file() {
2441                return Ok(Some(ExecResult::failure(
2442                    126,
2443                    format!("{}: Is a directory", name),
2444                )));
2445            }
2446            #[cfg(unix)]
2447            {
2448                use std::os::unix::fs::PermissionsExt;
2449                let mode = std::fs::metadata(&resolved)
2450                    .map(|m| m.permissions().mode())
2451                    .unwrap_or(0);
2452                if mode & 0o111 == 0 {
2453                    return Ok(Some(ExecResult::failure(
2454                        126,
2455                        format!("{}: Permission denied", name),
2456                    )));
2457                }
2458            }
2459            resolved.to_string_lossy().into_owned()
2460        } else {
2461            // Get PATH from scope or environment
2462            let path_var = {
2463                let scope = self.scope.read().await;
2464                scope
2465                    .get("PATH")
2466                    .map(value_to_string)
2467                    .unwrap_or_else(|| std::env::var("PATH").unwrap_or_default())
2468            };
2469
2470            // Resolve command in PATH
2471            match resolve_in_path(name, &path_var) {
2472                Some(path) => path,
2473                None => return Ok(None), // Not found - let caller handle error
2474            }
2475        };
2476
2477        tracing::debug!(executable = %executable, "resolved external command");
2478
2479        // Build flat argv (preserves flag format)
2480        let argv = self.build_args_flat(args).await?;
2481
2482        // Get stdin if available
2483        let stdin_data = {
2484            let mut ctx = self.exec_ctx.write().await;
2485            ctx.take_stdin()
2486        };
2487
2488        // Build and spawn the command
2489        use tokio::process::Command;
2490
2491        let mut cmd = Command::new(&executable);
2492        cmd.args(&argv);
2493        cmd.current_dir(&real_cwd);
2494
2495        // Handle stdin
2496        cmd.stdin(if stdin_data.is_some() {
2497            std::process::Stdio::piped()
2498        } else if self.interactive {
2499            std::process::Stdio::inherit()
2500        } else {
2501            std::process::Stdio::null()
2502        });
2503
2504        // In interactive mode, standalone or last-in-pipeline commands inherit
2505        // the terminal's stdout/stderr so output streams in real-time.
2506        // First/middle commands must capture stdout for the pipe — same as bash.
2507        let pipeline_position = {
2508            let ctx = self.exec_ctx.read().await;
2509            ctx.pipeline_position
2510        };
2511        let inherit_output = self.interactive
2512            && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
2513
2514        if inherit_output {
2515            cmd.stdout(std::process::Stdio::inherit());
2516            cmd.stderr(std::process::Stdio::inherit());
2517        } else {
2518            cmd.stdout(std::process::Stdio::piped());
2519            cmd.stderr(std::process::Stdio::piped());
2520        }
2521
2522        // On Unix with job control, put child in its own process group
2523        // and restore default signal handlers (shell ignores SIGTSTP etc.
2524        // but children should respond to them normally).
2525        #[cfg(unix)]
2526        if self.terminal_state.is_some() && inherit_output {
2527            // SAFETY: setpgid and sigaction(SIG_DFL) are async-signal-safe per POSIX
2528            #[allow(unsafe_code)]
2529            unsafe {
2530                cmd.pre_exec(|| {
2531                    // Own process group
2532                    nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
2533                        .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
2534                    // Restore default signal handlers for job control signals
2535                    use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
2536                    let mut sa: nix::libc::sigaction = std::mem::zeroed();
2537                    sa.sa_sigaction = SIG_DFL;
2538                    sigaction(SIGTSTP, &sa, std::ptr::null_mut());
2539                    sigaction(SIGTTOU, &sa, std::ptr::null_mut());
2540                    sigaction(SIGTTIN, &sa, std::ptr::null_mut());
2541                    sigaction(SIGINT, &sa, std::ptr::null_mut());
2542                    Ok(())
2543                });
2544            }
2545        }
2546
2547        // Spawn the process
2548        let mut child = match cmd.spawn() {
2549            Ok(child) => child,
2550            Err(e) => {
2551                return Ok(Some(ExecResult::failure(
2552                    127,
2553                    format!("{}: {}", name, e),
2554                )));
2555            }
2556        };
2557
2558        // Write stdin if present
2559        if let Some(data) = stdin_data
2560            && let Some(mut stdin) = child.stdin.take()
2561        {
2562            use tokio::io::AsyncWriteExt;
2563            if let Err(e) = stdin.write_all(data.as_bytes()).await {
2564                return Ok(Some(ExecResult::failure(
2565                    1,
2566                    format!("{}: failed to write stdin: {}", name, e),
2567                )));
2568            }
2569            // Drop stdin to signal EOF
2570        }
2571
2572        if inherit_output {
2573            // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
2574            #[cfg(unix)]
2575            if let Some(ref term) = self.terminal_state {
2576                let child_id = child.id().unwrap_or(0);
2577                let pid = nix::unistd::Pid::from_raw(child_id as i32);
2578                let pgid = pid; // child is its own pgid leader
2579
2580                // Give the terminal to the child's process group
2581                if let Err(e) = term.give_terminal_to(pgid) {
2582                    tracing::warn!("failed to give terminal to child: {}", e);
2583                }
2584
2585                let term_clone = term.clone();
2586                let cmd_name = name.to_string();
2587                let cmd_display = format!("{} {}", name, argv.join(" "));
2588                let jobs = self.jobs.clone();
2589
2590                let code = tokio::task::block_in_place(move || {
2591                    let result = term_clone.wait_for_foreground(pid);
2592
2593                    // Always reclaim the terminal
2594                    if let Err(e) = term_clone.reclaim_terminal() {
2595                        tracing::warn!("failed to reclaim terminal: {}", e);
2596                    }
2597
2598                    match result {
2599                        crate::terminal::WaitResult::Exited(code) => code as i64,
2600                        crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
2601                        crate::terminal::WaitResult::Stopped(_sig) => {
2602                            // Register as a stopped job
2603                            let rt = tokio::runtime::Handle::current();
2604                            let job_id = rt.block_on(jobs.register_stopped(
2605                                cmd_display,
2606                                child_id,
2607                                child_id, // pgid = pid for group leader
2608                            ));
2609                            eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
2610                            148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
2611                        }
2612                    }
2613                });
2614
2615                return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
2616            }
2617
2618            // Non-job-control path: simple wait
2619            let status = match child.wait().await {
2620                Ok(s) => s,
2621                Err(e) => {
2622                    return Ok(Some(ExecResult::failure(
2623                        1,
2624                        format!("{}: failed to wait: {}", name, e),
2625                    )));
2626                }
2627            };
2628
2629            let code = status.code().unwrap_or_else(|| {
2630                #[cfg(unix)]
2631                {
2632                    use std::os::unix::process::ExitStatusExt;
2633                    128 + status.signal().unwrap_or(0)
2634                }
2635                #[cfg(not(unix))]
2636                {
2637                    -1
2638                }
2639            }) as i64;
2640
2641            // stdout/stderr already went to the terminal
2642            Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
2643        } else {
2644            // Capture output via bounded streams
2645            let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
2646            let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
2647
2648            let stdout_pipe = child.stdout.take();
2649            let stderr_pipe = child.stderr.take();
2650
2651            let stdout_clone = stdout_stream.clone();
2652            let stderr_clone = stderr_stream.clone();
2653
2654            let stdout_task = stdout_pipe.map(|pipe| {
2655                tokio::spawn(async move {
2656                    drain_to_stream(pipe, stdout_clone).await;
2657                })
2658            });
2659
2660            let stderr_task = stderr_pipe.map(|pipe| {
2661                tokio::spawn(async move {
2662                    drain_to_stream(pipe, stderr_clone).await;
2663                })
2664            });
2665
2666            let status = match child.wait().await {
2667                Ok(s) => s,
2668                Err(e) => {
2669                    return Ok(Some(ExecResult::failure(
2670                        1,
2671                        format!("{}: failed to wait: {}", name, e),
2672                    )));
2673                }
2674            };
2675
2676            if let Some(task) = stdout_task {
2677                // Ignore join error — the drain task logs its own errors
2678                let _ = task.await;
2679            }
2680            if let Some(task) = stderr_task {
2681                let _ = task.await;
2682            }
2683
2684            let code = status.code().unwrap_or_else(|| {
2685                #[cfg(unix)]
2686                {
2687                    use std::os::unix::process::ExitStatusExt;
2688                    128 + status.signal().unwrap_or(0)
2689                }
2690                #[cfg(not(unix))]
2691                {
2692                    -1
2693                }
2694            }) as i64;
2695
2696            let stdout = stdout_stream.read_string().await;
2697            let stderr = stderr_stream.read_string().await;
2698
2699            Ok(Some(ExecResult::from_output(code, stdout, stderr)))
2700        }
2701    }
2702
2703    // --- Variable Access ---
2704
2705    /// Get a variable value.
2706    pub async fn get_var(&self, name: &str) -> Option<Value> {
2707        let scope = self.scope.read().await;
2708        scope.get(name).cloned()
2709    }
2710
2711    /// Check if error-exit mode is enabled (for testing).
2712    #[cfg(test)]
2713    pub async fn error_exit_enabled(&self) -> bool {
2714        let scope = self.scope.read().await;
2715        scope.error_exit_enabled()
2716    }
2717
2718    /// Set a variable value.
2719    pub async fn set_var(&self, name: &str, value: Value) {
2720        let mut scope = self.scope.write().await;
2721        scope.set(name.to_string(), value);
2722    }
2723
2724    /// Set positional parameters ($0 script name and $1-$9 args).
2725    pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
2726        let mut scope = self.scope.write().await;
2727        scope.set_positional(script_name, args);
2728    }
2729
2730    /// List all variables.
2731    pub async fn list_vars(&self) -> Vec<(String, Value)> {
2732        let scope = self.scope.read().await;
2733        scope.all()
2734    }
2735
2736    // --- CWD ---
2737
2738    /// Get current working directory.
2739    pub async fn cwd(&self) -> PathBuf {
2740        self.exec_ctx.read().await.cwd.clone()
2741    }
2742
2743    /// Set current working directory.
2744    pub async fn set_cwd(&self, path: PathBuf) {
2745        let mut ctx = self.exec_ctx.write().await;
2746        ctx.set_cwd(path);
2747    }
2748
2749    // --- Last Result ---
2750
2751    /// Get the last result ($?).
2752    pub async fn last_result(&self) -> ExecResult {
2753        let scope = self.scope.read().await;
2754        scope.last_result().clone()
2755    }
2756
2757    // --- Tools ---
2758
2759    /// Check if a user-defined function exists.
2760    pub async fn has_function(&self, name: &str) -> bool {
2761        self.user_tools.read().await.contains_key(name)
2762    }
2763
2764    /// Get available tool schemas.
2765    pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
2766        self.tools.schemas()
2767    }
2768
2769    // --- Jobs ---
2770
2771    /// Get job manager.
2772    pub fn jobs(&self) -> Arc<JobManager> {
2773        self.jobs.clone()
2774    }
2775
2776    // --- VFS ---
2777
2778    /// Get VFS router.
2779    pub fn vfs(&self) -> Arc<VfsRouter> {
2780        self.vfs.clone()
2781    }
2782
2783    // --- State ---
2784
2785    /// Reset kernel to initial state.
2786    ///
2787    /// Clears in-memory variables and resets cwd to root.
2788    /// History is not cleared (it persists across resets).
2789    pub async fn reset(&self) -> Result<()> {
2790        {
2791            let mut scope = self.scope.write().await;
2792            *scope = Scope::new();
2793        }
2794        {
2795            let mut ctx = self.exec_ctx.write().await;
2796            ctx.cwd = PathBuf::from("/");
2797        }
2798        Ok(())
2799    }
2800
2801    /// Shutdown the kernel.
2802    pub async fn shutdown(self) -> Result<()> {
2803        // Wait for all background jobs
2804        self.jobs.wait_all().await;
2805        Ok(())
2806    }
2807
2808    /// Dispatch a single command using the full resolution chain.
2809    ///
2810    /// This is the core of `CommandDispatcher` — it syncs state between the
2811    /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
2812    /// then delegates to `execute_command` for the actual dispatch.
2813    ///
2814    /// State flow:
2815    /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
2816    /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
2817    /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
2818    async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
2819        // 1. Sync ctx → self internals
2820        {
2821            let mut scope = self.scope.write().await;
2822            *scope = ctx.scope.clone();
2823        }
2824        {
2825            let mut ec = self.exec_ctx.write().await;
2826            ec.cwd = ctx.cwd.clone();
2827            ec.prev_cwd = ctx.prev_cwd.clone();
2828            ec.stdin = ctx.stdin.take();
2829            ec.stdin_data = ctx.stdin_data.take();
2830            ec.aliases = ctx.aliases.clone();
2831            ec.ignore_config = ctx.ignore_config.clone();
2832            ec.output_limit = ctx.output_limit.clone();
2833            ec.pipeline_position = ctx.pipeline_position;
2834        }
2835
2836        // 2. Execute via the full dispatch chain
2837        let result = self.execute_command(&cmd.name, &cmd.args).await?;
2838
2839        // 3. Sync self → ctx
2840        {
2841            let scope = self.scope.read().await;
2842            ctx.scope = scope.clone();
2843        }
2844        {
2845            let ec = self.exec_ctx.read().await;
2846            ctx.cwd = ec.cwd.clone();
2847            ctx.prev_cwd = ec.prev_cwd.clone();
2848            ctx.aliases = ec.aliases.clone();
2849            ctx.ignore_config = ec.ignore_config.clone();
2850            ctx.output_limit = ec.output_limit.clone();
2851        }
2852
2853        Ok(result)
2854    }
2855}
2856
2857#[async_trait]
2858impl CommandDispatcher for Kernel {
2859    /// Dispatch a command through the Kernel's full resolution chain.
2860    ///
2861    /// This is the single path for all command execution when called from
2862    /// the pipeline runner. It provides the full dispatch chain:
2863    /// user tools → builtins → .kai scripts → external commands → backend tools.
2864    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
2865        self.dispatch_command(cmd, ctx).await
2866    }
2867}
2868
2869/// Accumulate output from one result into another.
2870///
2871/// This appends stdout and stderr (with newlines as separators) and updates
2872/// the exit code to match the new result. Used to preserve output from
2873/// multiple statements, loop iterations, and command chains.
2874fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
2875    if !accumulated.out.is_empty() && !new.out.is_empty() && !accumulated.out.ends_with('\n') {
2876        accumulated.out.push('\n');
2877    }
2878    accumulated.out.push_str(&new.out);
2879    if !accumulated.err.is_empty() && !new.err.is_empty() && !accumulated.err.ends_with('\n') {
2880        accumulated.err.push('\n');
2881    }
2882    accumulated.err.push_str(&new.err);
2883    accumulated.code = new.code;
2884    accumulated.data = new.data.clone();
2885}
2886
2887/// Check if a value is truthy.
2888fn is_truthy(value: &Value) -> bool {
2889    match value {
2890        Value::Null => false,
2891        Value::Bool(b) => *b,
2892        Value::Int(i) => *i != 0,
2893        Value::Float(f) => *f != 0.0,
2894        Value::String(s) => !s.is_empty(),
2895        Value::Json(json) => match json {
2896            serde_json::Value::Null => false,
2897            serde_json::Value::Array(arr) => !arr.is_empty(),
2898            serde_json::Value::Object(obj) => !obj.is_empty(),
2899            serde_json::Value::Bool(b) => *b,
2900            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
2901            serde_json::Value::String(s) => !s.is_empty(),
2902        },
2903        Value::Blob(_) => true, // Blob references are always truthy
2904    }
2905}
2906
2907/// Apply tilde expansion to a value.
2908///
2909/// Only string values starting with `~` are expanded.
2910fn apply_tilde_expansion(value: Value) -> Value {
2911    match value {
2912        Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s)),
2913        _ => value,
2914    }
2915}
2916
2917#[cfg(test)]
2918mod tests {
2919    use super::*;
2920
2921    #[tokio::test]
2922    async fn test_kernel_transient() {
2923        let kernel = Kernel::transient().expect("failed to create kernel");
2924        assert_eq!(kernel.name(), "transient");
2925    }
2926
2927    #[tokio::test]
2928    async fn test_kernel_execute_echo() {
2929        let kernel = Kernel::transient().expect("failed to create kernel");
2930        let result = kernel.execute("echo hello").await.expect("execution failed");
2931        assert!(result.ok());
2932        assert_eq!(result.out.trim(), "hello");
2933    }
2934
2935    #[tokio::test]
2936    async fn test_multiple_statements_accumulate_output() {
2937        let kernel = Kernel::transient().expect("failed to create kernel");
2938        let result = kernel
2939            .execute("echo one\necho two\necho three")
2940            .await
2941            .expect("execution failed");
2942        assert!(result.ok());
2943        // Should have all three outputs separated by newlines
2944        assert!(result.out.contains("one"), "missing 'one': {}", result.out);
2945        assert!(result.out.contains("two"), "missing 'two': {}", result.out);
2946        assert!(result.out.contains("three"), "missing 'three': {}", result.out);
2947    }
2948
2949    #[tokio::test]
2950    async fn test_and_chain_accumulates_output() {
2951        let kernel = Kernel::transient().expect("failed to create kernel");
2952        let result = kernel
2953            .execute("echo first && echo second")
2954            .await
2955            .expect("execution failed");
2956        assert!(result.ok());
2957        assert!(result.out.contains("first"), "missing 'first': {}", result.out);
2958        assert!(result.out.contains("second"), "missing 'second': {}", result.out);
2959    }
2960
2961    #[tokio::test]
2962    async fn test_for_loop_accumulates_output() {
2963        let kernel = Kernel::transient().expect("failed to create kernel");
2964        let result = kernel
2965            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
2966            .await
2967            .expect("execution failed");
2968        assert!(result.ok());
2969        assert!(result.out.contains("item: a"), "missing 'item: a': {}", result.out);
2970        assert!(result.out.contains("item: b"), "missing 'item: b': {}", result.out);
2971        assert!(result.out.contains("item: c"), "missing 'item: c': {}", result.out);
2972    }
2973
2974    #[tokio::test]
2975    async fn test_while_loop_accumulates_output() {
2976        let kernel = Kernel::transient().expect("failed to create kernel");
2977        let result = kernel
2978            .execute(r#"
2979                N=3
2980                while [[ ${N} -gt 0 ]]; do
2981                    echo "N=${N}"
2982                    N=$((N - 1))
2983                done
2984            "#)
2985            .await
2986            .expect("execution failed");
2987        assert!(result.ok());
2988        assert!(result.out.contains("N=3"), "missing 'N=3': {}", result.out);
2989        assert!(result.out.contains("N=2"), "missing 'N=2': {}", result.out);
2990        assert!(result.out.contains("N=1"), "missing 'N=1': {}", result.out);
2991    }
2992
2993    #[tokio::test]
2994    async fn test_kernel_set_var() {
2995        let kernel = Kernel::transient().expect("failed to create kernel");
2996
2997        kernel.execute("X=42").await.expect("set failed");
2998
2999        let value = kernel.get_var("X").await;
3000        assert_eq!(value, Some(Value::Int(42)));
3001    }
3002
3003    #[tokio::test]
3004    async fn test_kernel_var_expansion() {
3005        let kernel = Kernel::transient().expect("failed to create kernel");
3006
3007        kernel.execute("NAME=\"world\"").await.expect("set failed");
3008        let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
3009
3010        assert!(result.ok());
3011        assert_eq!(result.out.trim(), "hello world");
3012    }
3013
3014    #[tokio::test]
3015    async fn test_kernel_last_result() {
3016        let kernel = Kernel::transient().expect("failed to create kernel");
3017
3018        kernel.execute("echo test").await.expect("echo failed");
3019
3020        let last = kernel.last_result().await;
3021        assert!(last.ok());
3022        assert_eq!(last.out.trim(), "test");
3023    }
3024
3025    #[tokio::test]
3026    async fn test_kernel_tool_not_found() {
3027        let kernel = Kernel::transient().expect("failed to create kernel");
3028
3029        let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
3030        assert!(!result.ok());
3031        assert_eq!(result.code, 127);
3032        assert!(result.err.contains("command not found"));
3033    }
3034
3035    #[tokio::test]
3036    async fn test_external_command_true() {
3037        // Use REPL config for passthrough filesystem access
3038        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
3039
3040        // /bin/true should be available on any Unix system
3041        let result = kernel.execute("true").await.expect("execution failed");
3042        // This should use the builtin true, which returns 0
3043        assert!(result.ok(), "true should succeed: {:?}", result);
3044    }
3045
3046    #[tokio::test]
3047    async fn test_external_command_basic() {
3048        // Use REPL config for passthrough filesystem access
3049        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
3050
3051        // Test with /bin/echo which is external
3052        // Note: kaish has a builtin echo, so this will use the builtin
3053        // Let's test with a command that's not a builtin
3054        // Actually, let's just test that PATH resolution works by checking the PATH var
3055        let path_var = std::env::var("PATH").unwrap_or_default();
3056        eprintln!("System PATH: {}", path_var);
3057
3058        // Set PATH in kernel to ensure it's available
3059        kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
3060
3061        // Now try an external command like /usr/bin/env
3062        // But env is also a builtin... let's try uname
3063        let result = kernel.execute("uname").await.expect("execution failed");
3064        eprintln!("uname result: {:?}", result);
3065        // uname should succeed if external commands work
3066        assert!(result.ok() || result.code == 127, "uname: {:?}", result);
3067    }
3068
3069    #[tokio::test]
3070    async fn test_kernel_reset() {
3071        let kernel = Kernel::transient().expect("failed to create kernel");
3072
3073        kernel.execute("X=1").await.expect("set failed");
3074        assert!(kernel.get_var("X").await.is_some());
3075
3076        kernel.reset().await.expect("reset failed");
3077        assert!(kernel.get_var("X").await.is_none());
3078    }
3079
3080    #[tokio::test]
3081    async fn test_kernel_cwd() {
3082        let kernel = Kernel::transient().expect("failed to create kernel");
3083
3084        // Transient kernel uses sandboxed mode with cwd=$HOME
3085        let cwd = kernel.cwd().await;
3086        let home = std::env::var("HOME")
3087            .map(PathBuf::from)
3088            .unwrap_or_else(|_| PathBuf::from("/"));
3089        assert_eq!(cwd, home);
3090
3091        kernel.set_cwd(PathBuf::from("/tmp")).await;
3092        assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
3093    }
3094
3095    #[tokio::test]
3096    async fn test_kernel_list_vars() {
3097        let kernel = Kernel::transient().expect("failed to create kernel");
3098
3099        kernel.execute("A=1").await.ok();
3100        kernel.execute("B=2").await.ok();
3101
3102        let vars = kernel.list_vars().await;
3103        assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
3104        assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
3105    }
3106
3107    #[tokio::test]
3108    async fn test_is_truthy() {
3109        assert!(!is_truthy(&Value::Null));
3110        assert!(!is_truthy(&Value::Bool(false)));
3111        assert!(is_truthy(&Value::Bool(true)));
3112        assert!(!is_truthy(&Value::Int(0)));
3113        assert!(is_truthy(&Value::Int(1)));
3114        assert!(!is_truthy(&Value::String("".into())));
3115        assert!(is_truthy(&Value::String("x".into())));
3116    }
3117
3118    #[tokio::test]
3119    async fn test_jq_in_pipeline() {
3120        let kernel = Kernel::transient().expect("failed to create kernel");
3121        // kaish uses double quotes only; escape inner quotes
3122        let result = kernel
3123            .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
3124            .await
3125            .expect("execution failed");
3126        assert!(result.ok(), "jq pipeline failed: {}", result.err);
3127        assert_eq!(result.out.trim(), "Alice");
3128    }
3129
3130    #[tokio::test]
3131    async fn test_user_defined_tool() {
3132        let kernel = Kernel::transient().expect("failed to create kernel");
3133
3134        // Define a function
3135        kernel
3136            .execute(r#"greet() { echo "Hello, $1!" }"#)
3137            .await
3138            .expect("function definition failed");
3139
3140        // Call the function
3141        let result = kernel
3142            .execute(r#"greet "World""#)
3143            .await
3144            .expect("function call failed");
3145
3146        assert!(result.ok(), "greet failed: {}", result.err);
3147        assert_eq!(result.out.trim(), "Hello, World!");
3148    }
3149
3150    #[tokio::test]
3151    async fn test_user_tool_positional_args() {
3152        let kernel = Kernel::transient().expect("failed to create kernel");
3153
3154        // Define a function with positional param
3155        kernel
3156            .execute(r#"greet() { echo "Hi $1" }"#)
3157            .await
3158            .expect("function definition failed");
3159
3160        // Call with positional argument
3161        let result = kernel
3162            .execute(r#"greet "Amy""#)
3163            .await
3164            .expect("function call failed");
3165
3166        assert!(result.ok(), "greet failed: {}", result.err);
3167        assert_eq!(result.out.trim(), "Hi Amy");
3168    }
3169
3170    #[tokio::test]
3171    async fn test_function_shared_scope() {
3172        let kernel = Kernel::transient().expect("failed to create kernel");
3173
3174        // Set a variable in parent scope
3175        kernel
3176            .execute(r#"SECRET="hidden""#)
3177            .await
3178            .expect("set failed");
3179
3180        // Define a function that accesses and modifies parent variable
3181        kernel
3182            .execute(r#"access_parent() {
3183                echo "${SECRET}"
3184                SECRET="modified"
3185            }"#)
3186            .await
3187            .expect("function definition failed");
3188
3189        // Call the function - it SHOULD see SECRET (shared scope like sh)
3190        let result = kernel.execute("access_parent").await.expect("function call failed");
3191
3192        // Function should have access to parent scope
3193        assert!(
3194            result.out.contains("hidden"),
3195            "Function should access parent scope, got: {}",
3196            result.out
3197        );
3198
3199        // Function should have modified the parent variable
3200        let secret = kernel.get_var("SECRET").await;
3201        assert_eq!(
3202            secret,
3203            Some(Value::String("modified".into())),
3204            "Function should modify parent scope"
3205        );
3206    }
3207
3208    #[tokio::test]
3209    async fn test_exec_builtin() {
3210        let kernel = Kernel::transient().expect("failed to create kernel");
3211        // argv is now a space-separated string or JSON array string
3212        let result = kernel
3213            .execute(r#"exec command="/bin/echo" argv="hello world""#)
3214            .await
3215            .expect("exec failed");
3216
3217        assert!(result.ok(), "exec failed: {}", result.err);
3218        assert_eq!(result.out.trim(), "hello world");
3219    }
3220
3221    #[tokio::test]
3222    async fn test_while_false_never_runs() {
3223        let kernel = Kernel::transient().expect("failed to create kernel");
3224
3225        // A while loop with false condition should never run
3226        let result = kernel
3227            .execute(r#"
3228                while false; do
3229                    echo "should not run"
3230                done
3231            "#)
3232            .await
3233            .expect("while false failed");
3234
3235        assert!(result.ok());
3236        assert!(result.out.is_empty(), "while false should not execute body: {}", result.out);
3237    }
3238
3239    #[tokio::test]
3240    async fn test_while_string_comparison() {
3241        let kernel = Kernel::transient().expect("failed to create kernel");
3242
3243        // Set a flag
3244        kernel.execute(r#"FLAG="go""#).await.expect("set failed");
3245
3246        // Use string comparison as condition (shell-compatible [[ ]] syntax)
3247        // Note: Put echo last so we can check the output
3248        let result = kernel
3249            .execute(r#"
3250                while [[ ${FLAG} == "go" ]]; do
3251                    FLAG="stop"
3252                    echo "running"
3253                done
3254            "#)
3255            .await
3256            .expect("while with string cmp failed");
3257
3258        assert!(result.ok());
3259        assert!(result.out.contains("running"), "should have run once: {}", result.out);
3260
3261        // Verify flag was changed
3262        let flag = kernel.get_var("FLAG").await;
3263        assert_eq!(flag, Some(Value::String("stop".into())));
3264    }
3265
3266    #[tokio::test]
3267    async fn test_while_numeric_comparison() {
3268        let kernel = Kernel::transient().expect("failed to create kernel");
3269
3270        // Test > comparison (shell-compatible [[ ]] with -gt)
3271        kernel.execute("N=5").await.expect("set failed");
3272
3273        // Note: Put echo last so we can check the output
3274        let result = kernel
3275            .execute(r#"
3276                while [[ ${N} -gt 3 ]]; do
3277                    N=3
3278                    echo "N was greater"
3279                done
3280            "#)
3281            .await
3282            .expect("while with > failed");
3283
3284        assert!(result.ok());
3285        assert!(result.out.contains("N was greater"), "should have run once: {}", result.out);
3286    }
3287
3288    #[tokio::test]
3289    async fn test_break_in_while_loop() {
3290        let kernel = Kernel::transient().expect("failed to create kernel");
3291
3292        let result = kernel
3293            .execute(r#"
3294                I=0
3295                while true; do
3296                    I=1
3297                    echo "before break"
3298                    break
3299                    echo "after break"
3300                done
3301            "#)
3302            .await
3303            .expect("while with break failed");
3304
3305        assert!(result.ok());
3306        assert!(result.out.contains("before break"), "should see before break: {}", result.out);
3307        assert!(!result.out.contains("after break"), "should not see after break: {}", result.out);
3308
3309        // Verify we exited the loop
3310        let i = kernel.get_var("I").await;
3311        assert_eq!(i, Some(Value::Int(1)));
3312    }
3313
3314    #[tokio::test]
3315    async fn test_continue_in_while_loop() {
3316        let kernel = Kernel::transient().expect("failed to create kernel");
3317
3318        // Test continue in a while loop where variables persist
3319        // We use string state transition: "start" -> "middle" -> "end"
3320        // continue on "middle" should skip to next iteration
3321        // Shell-compatible: use [[ ]] for comparisons
3322        let result = kernel
3323            .execute(r#"
3324                STATE="start"
3325                AFTER_CONTINUE="no"
3326                while [[ ${STATE} != "done" ]]; do
3327                    if [[ ${STATE} == "start" ]]; then
3328                        STATE="middle"
3329                        continue
3330                        AFTER_CONTINUE="yes"
3331                    fi
3332                    if [[ ${STATE} == "middle" ]]; then
3333                        STATE="done"
3334                    fi
3335                done
3336            "#)
3337            .await
3338            .expect("while with continue failed");
3339
3340        assert!(result.ok());
3341
3342        // STATE should be "done" (we completed the loop)
3343        let state = kernel.get_var("STATE").await;
3344        assert_eq!(state, Some(Value::String("done".into())));
3345
3346        // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
3347        let after = kernel.get_var("AFTER_CONTINUE").await;
3348        assert_eq!(after, Some(Value::String("no".into())));
3349    }
3350
3351    #[tokio::test]
3352    async fn test_break_with_level() {
3353        let kernel = Kernel::transient().expect("failed to create kernel");
3354
3355        // Nested loop with break 2 to exit both loops
3356        // We verify by checking OUTER value:
3357        // - If break 2 works, OUTER stays at 1 (set before for loop)
3358        // - If break 2 fails, OUTER becomes 2 (set after for loop)
3359        let result = kernel
3360            .execute(r#"
3361                OUTER=0
3362                while true; do
3363                    OUTER=1
3364                    for X in "1 2"; do
3365                        break 2
3366                    done
3367                    OUTER=2
3368                done
3369            "#)
3370            .await
3371            .expect("nested break failed");
3372
3373        assert!(result.ok());
3374
3375        // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
3376        let outer = kernel.get_var("OUTER").await;
3377        assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
3378    }
3379
3380    #[tokio::test]
3381    async fn test_return_from_tool() {
3382        let kernel = Kernel::transient().expect("failed to create kernel");
3383
3384        // Define a function that returns early
3385        kernel
3386            .execute(r#"early_return() {
3387                if [[ $1 == 1 ]]; then
3388                    return 42
3389                fi
3390                echo "not returned"
3391            }"#)
3392            .await
3393            .expect("function definition failed");
3394
3395        // Call with arg=1 should return with exit code 42
3396        // (POSIX shell behavior: return N sets exit code, doesn't output N)
3397        let result = kernel
3398            .execute("early_return 1")
3399            .await
3400            .expect("function call failed");
3401
3402        // Exit code should be 42 (non-zero, so not ok())
3403        assert_eq!(result.code, 42);
3404        // Output should be empty (we returned before echo)
3405        assert!(result.out.is_empty());
3406    }
3407
3408    #[tokio::test]
3409    async fn test_return_without_value() {
3410        let kernel = Kernel::transient().expect("failed to create kernel");
3411
3412        // Define a function that returns without a value
3413        kernel
3414            .execute(r#"early_exit() {
3415                if [[ $1 == "stop" ]]; then
3416                    return
3417                fi
3418                echo "continued"
3419            }"#)
3420            .await
3421            .expect("function definition failed");
3422
3423        // Call with arg="stop" should return early
3424        let result = kernel
3425            .execute(r#"early_exit "stop""#)
3426            .await
3427            .expect("function call failed");
3428
3429        assert!(result.ok());
3430        assert!(result.out.is_empty() || result.out.trim().is_empty());
3431    }
3432
3433    #[tokio::test]
3434    async fn test_exit_stops_execution() {
3435        let kernel = Kernel::transient().expect("failed to create kernel");
3436
3437        // exit should stop further execution
3438        kernel
3439            .execute(r#"
3440                BEFORE="yes"
3441                exit 0
3442                AFTER="yes"
3443            "#)
3444            .await
3445            .expect("execution failed");
3446
3447        // BEFORE should be set, AFTER should not
3448        let before = kernel.get_var("BEFORE").await;
3449        assert_eq!(before, Some(Value::String("yes".into())));
3450
3451        let after = kernel.get_var("AFTER").await;
3452        assert!(after.is_none(), "AFTER should not be set after exit");
3453    }
3454
3455    #[tokio::test]
3456    async fn test_exit_with_code() {
3457        let kernel = Kernel::transient().expect("failed to create kernel");
3458
3459        // exit with code should propagate the exit code
3460        let result = kernel
3461            .execute("exit 42")
3462            .await
3463            .expect("exit failed");
3464
3465        assert_eq!(result.code, 42);
3466        assert!(result.out.is_empty(), "exit should not produce stdout");
3467    }
3468
3469    #[tokio::test]
3470    async fn test_set_e_stops_on_failure() {
3471        let kernel = Kernel::transient().expect("failed to create kernel");
3472
3473        // Enable error-exit mode
3474        kernel.execute("set -e").await.expect("set -e failed");
3475
3476        // Run a sequence where the middle command fails
3477        kernel
3478            .execute(r#"
3479                STEP1="done"
3480                false
3481                STEP2="done"
3482            "#)
3483            .await
3484            .expect("execution failed");
3485
3486        // STEP1 should be set, but STEP2 should NOT be set (exit on false)
3487        let step1 = kernel.get_var("STEP1").await;
3488        assert_eq!(step1, Some(Value::String("done".into())));
3489
3490        let step2 = kernel.get_var("STEP2").await;
3491        assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
3492    }
3493
3494    #[tokio::test]
3495    async fn test_set_plus_e_disables_error_exit() {
3496        let kernel = Kernel::transient().expect("failed to create kernel");
3497
3498        // Enable then disable error-exit mode
3499        kernel.execute("set -e").await.expect("set -e failed");
3500        kernel.execute("set +e").await.expect("set +e failed");
3501
3502        // Now failure should NOT stop execution
3503        kernel
3504            .execute(r#"
3505                STEP1="done"
3506                false
3507                STEP2="done"
3508            "#)
3509            .await
3510            .expect("execution failed");
3511
3512        // Both should be set since +e disables error exit
3513        let step1 = kernel.get_var("STEP1").await;
3514        assert_eq!(step1, Some(Value::String("done".into())));
3515
3516        let step2 = kernel.get_var("STEP2").await;
3517        assert_eq!(step2, Some(Value::String("done".into())));
3518    }
3519
3520    #[tokio::test]
3521    async fn test_set_ignores_unknown_options() {
3522        let kernel = Kernel::transient().expect("failed to create kernel");
3523
3524        // Bash idiom: set -euo pipefail (we support -e, ignore the rest)
3525        let result = kernel
3526            .execute("set -e -u -o pipefail")
3527            .await
3528            .expect("set with unknown options failed");
3529
3530        assert!(result.ok(), "set should succeed with unknown options");
3531
3532        // -e should still be enabled
3533        kernel
3534            .execute(r#"
3535                BEFORE="yes"
3536                false
3537                AFTER="yes"
3538            "#)
3539            .await
3540            .ok();
3541
3542        let after = kernel.get_var("AFTER").await;
3543        assert!(after.is_none(), "-e should be enabled despite unknown options");
3544    }
3545
3546    #[tokio::test]
3547    async fn test_set_no_args_shows_settings() {
3548        let kernel = Kernel::transient().expect("failed to create kernel");
3549
3550        // Enable -e
3551        kernel.execute("set -e").await.expect("set -e failed");
3552
3553        // Call set with no args to see settings
3554        let result = kernel.execute("set").await.expect("set failed");
3555
3556        assert!(result.ok());
3557        assert!(result.out.contains("set -e"), "should show -e is enabled: {}", result.out);
3558    }
3559
3560    #[tokio::test]
3561    async fn test_set_e_in_pipeline() {
3562        let kernel = Kernel::transient().expect("failed to create kernel");
3563
3564        kernel.execute("set -e").await.expect("set -e failed");
3565
3566        // Pipeline failure should trigger exit
3567        kernel
3568            .execute(r#"
3569                BEFORE="yes"
3570                false | cat
3571                AFTER="yes"
3572            "#)
3573            .await
3574            .ok();
3575
3576        let before = kernel.get_var("BEFORE").await;
3577        assert_eq!(before, Some(Value::String("yes".into())));
3578
3579        // AFTER should not be set if pipeline failure triggers exit
3580        // Note: The exit code of a pipeline is the exit code of the last command
3581        // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
3582        // To test pipeline failure, we need the last command to fail.
3583    }
3584
3585    #[tokio::test]
3586    async fn test_set_e_with_and_chain() {
3587        let kernel = Kernel::transient().expect("failed to create kernel");
3588
3589        kernel.execute("set -e").await.expect("set -e failed");
3590
3591        // Commands in && chain should not trigger -e on the first failure
3592        // because && explicitly handles the error
3593        kernel
3594            .execute(r#"
3595                RESULT="initial"
3596                false && RESULT="chained"
3597                RESULT="continued"
3598            "#)
3599            .await
3600            .ok();
3601
3602        // In bash, commands in && don't trigger -e. The chain handles the failure.
3603        // Our implementation may differ - let's verify current behavior.
3604        let result = kernel.get_var("RESULT").await;
3605        // If we follow bash semantics, RESULT should be "continued"
3606        // If we trigger -e on the false, RESULT stays "initial"
3607        assert!(result.is_some(), "RESULT should be set");
3608    }
3609
3610    #[tokio::test]
3611    async fn test_set_e_exits_in_for_loop() {
3612        let kernel = Kernel::transient().expect("failed to create kernel");
3613
3614        kernel.execute("set -e").await.expect("set -e failed");
3615
3616        kernel
3617            .execute(r#"
3618                REACHED="no"
3619                for x in 1 2 3; do
3620                    false
3621                    REACHED="yes"
3622                done
3623            "#)
3624            .await
3625            .ok();
3626
3627        // With set -e, false should trigger exit; REACHED should remain "no"
3628        let reached = kernel.get_var("REACHED").await;
3629        assert_eq!(reached, Some(Value::String("no".into())),
3630            "set -e should exit on failure in for loop body");
3631    }
3632
3633    #[tokio::test]
3634    async fn test_for_loop_continues_without_set_e() {
3635        let kernel = Kernel::transient().expect("failed to create kernel");
3636
3637        // Without set -e, for loop should continue normally
3638        kernel
3639            .execute(r#"
3640                COUNT=0
3641                for x in 1 2 3; do
3642                    false
3643                    COUNT=$((COUNT + 1))
3644                done
3645            "#)
3646            .await
3647            .ok();
3648
3649        let count = kernel.get_var("COUNT").await;
3650        // Arithmetic produces Int values; accept either Int or String representation
3651        let count_val = match &count {
3652            Some(Value::Int(n)) => *n,
3653            Some(Value::String(s)) => s.parse().unwrap_or(-1),
3654            _ => -1,
3655        };
3656        assert_eq!(count_val, 3,
3657            "without set -e, loop should complete all iterations (got {:?})", count);
3658    }
3659
3660    // ═══════════════════════════════════════════════════════════════════════════
3661    // Source Tests
3662    // ═══════════════════════════════════════════════════════════════════════════
3663
3664    #[tokio::test]
3665    async fn test_source_sets_variables() {
3666        let kernel = Kernel::transient().expect("failed to create kernel");
3667
3668        // Write a script to the VFS
3669        kernel
3670            .execute(r#"write "/test.kai" 'FOO="bar"'"#)
3671            .await
3672            .expect("write failed");
3673
3674        // Source the script
3675        let result = kernel
3676            .execute(r#"source "/test.kai""#)
3677            .await
3678            .expect("source failed");
3679
3680        assert!(result.ok(), "source should succeed");
3681
3682        // Variable should be set in current scope
3683        let foo = kernel.get_var("FOO").await;
3684        assert_eq!(foo, Some(Value::String("bar".into())));
3685    }
3686
3687    #[tokio::test]
3688    async fn test_source_with_dot_alias() {
3689        let kernel = Kernel::transient().expect("failed to create kernel");
3690
3691        // Write a script to the VFS
3692        kernel
3693            .execute(r#"write "/vars.kai" 'X=42'"#)
3694            .await
3695            .expect("write failed");
3696
3697        // Source using . alias
3698        let result = kernel
3699            .execute(r#". "/vars.kai""#)
3700            .await
3701            .expect(". failed");
3702
3703        assert!(result.ok(), ". should succeed");
3704
3705        // Variable should be set in current scope
3706        let x = kernel.get_var("X").await;
3707        assert_eq!(x, Some(Value::Int(42)));
3708    }
3709
3710    #[tokio::test]
3711    async fn test_source_not_found() {
3712        let kernel = Kernel::transient().expect("failed to create kernel");
3713
3714        // Try to source a non-existent file
3715        let result = kernel
3716            .execute(r#"source "/nonexistent.kai""#)
3717            .await
3718            .expect("source should not fail with error");
3719
3720        assert!(!result.ok(), "source of non-existent file should fail");
3721        assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
3722    }
3723
3724    #[tokio::test]
3725    async fn test_source_missing_filename() {
3726        let kernel = Kernel::transient().expect("failed to create kernel");
3727
3728        // Call source with no arguments
3729        let result = kernel
3730            .execute("source")
3731            .await
3732            .expect("source should not fail with error");
3733
3734        assert!(!result.ok(), "source without filename should fail");
3735        assert!(result.err.contains("missing filename"), "error should mention missing filename");
3736    }
3737
3738    #[tokio::test]
3739    async fn test_source_executes_multiple_statements() {
3740        let kernel = Kernel::transient().expect("failed to create kernel");
3741
3742        // Write a script with multiple statements
3743        kernel
3744            .execute(r#"write "/multi.kai" 'A=1
3745B=2
3746C=3'"#)
3747            .await
3748            .expect("write failed");
3749
3750        // Source it
3751        kernel
3752            .execute(r#"source "/multi.kai""#)
3753            .await
3754            .expect("source failed");
3755
3756        // All variables should be set
3757        assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
3758        assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
3759        assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
3760    }
3761
3762    #[tokio::test]
3763    async fn test_source_can_define_functions() {
3764        let kernel = Kernel::transient().expect("failed to create kernel");
3765
3766        // Write a script that defines a function
3767        kernel
3768            .execute(r#"write "/functions.kai" 'greet() {
3769    echo "Hello, $1!"
3770}'"#)
3771            .await
3772            .expect("write failed");
3773
3774        // Source it
3775        kernel
3776            .execute(r#"source "/functions.kai""#)
3777            .await
3778            .expect("source failed");
3779
3780        // Use the defined function
3781        let result = kernel
3782            .execute(r#"greet "World""#)
3783            .await
3784            .expect("greet failed");
3785
3786        assert!(result.ok());
3787        assert!(result.out.contains("Hello, World!"));
3788    }
3789
3790    #[tokio::test]
3791    async fn test_source_inherits_error_exit() {
3792        let kernel = Kernel::transient().expect("failed to create kernel");
3793
3794        // Enable error exit
3795        kernel.execute("set -e").await.expect("set -e failed");
3796
3797        // Write a script that has a failure
3798        kernel
3799            .execute(r#"write "/fail.kai" 'BEFORE="yes"
3800false
3801AFTER="yes"'"#)
3802            .await
3803            .expect("write failed");
3804
3805        // Source it (should exit on false due to set -e)
3806        kernel
3807            .execute(r#"source "/fail.kai""#)
3808            .await
3809            .ok();
3810
3811        // BEFORE should be set, AFTER should NOT be set due to error exit
3812        let before = kernel.get_var("BEFORE").await;
3813        assert_eq!(before, Some(Value::String("yes".into())));
3814
3815        // Note: This test depends on whether error exit is checked within source
3816        // Currently our implementation checks per-statement in the main kernel
3817    }
3818
3819    // ═══════════════════════════════════════════════════════════════════════════
3820    // set -e with && / || chains
3821    // ═══════════════════════════════════════════════════════════════════════════
3822
3823    #[tokio::test]
3824    async fn test_set_e_and_chain_left_fails() {
3825        // set -e; false && echo hi; REACHED=1 → REACHED should be set
3826        let kernel = Kernel::transient().expect("failed to create kernel");
3827        kernel.execute("set -e").await.expect("set -e failed");
3828
3829        kernel
3830            .execute("false && echo hi; REACHED=1")
3831            .await
3832            .expect("execution failed");
3833
3834        let reached = kernel.get_var("REACHED").await;
3835        assert_eq!(
3836            reached,
3837            Some(Value::Int(1)),
3838            "set -e should not trigger on left side of &&"
3839        );
3840    }
3841
3842    #[tokio::test]
3843    async fn test_set_e_and_chain_right_fails() {
3844        // set -e; true && false; REACHED=1 → REACHED should NOT be set
3845        let kernel = Kernel::transient().expect("failed to create kernel");
3846        kernel.execute("set -e").await.expect("set -e failed");
3847
3848        kernel
3849            .execute("true && false; REACHED=1")
3850            .await
3851            .expect("execution failed");
3852
3853        let reached = kernel.get_var("REACHED").await;
3854        assert!(
3855            reached.is_none(),
3856            "set -e should trigger when right side of && fails"
3857        );
3858    }
3859
3860    #[tokio::test]
3861    async fn test_set_e_or_chain_recovers() {
3862        // set -e; false || echo recovered; REACHED=1 → REACHED should be set
3863        let kernel = Kernel::transient().expect("failed to create kernel");
3864        kernel.execute("set -e").await.expect("set -e failed");
3865
3866        kernel
3867            .execute("false || echo recovered; REACHED=1")
3868            .await
3869            .expect("execution failed");
3870
3871        let reached = kernel.get_var("REACHED").await;
3872        assert_eq!(
3873            reached,
3874            Some(Value::Int(1)),
3875            "set -e should not trigger when || recovers the failure"
3876        );
3877    }
3878
3879    #[tokio::test]
3880    async fn test_set_e_or_chain_both_fail() {
3881        // set -e; false || false; REACHED=1 → REACHED should NOT be set
3882        let kernel = Kernel::transient().expect("failed to create kernel");
3883        kernel.execute("set -e").await.expect("set -e failed");
3884
3885        kernel
3886            .execute("false || false; REACHED=1")
3887            .await
3888            .expect("execution failed");
3889
3890        let reached = kernel.get_var("REACHED").await;
3891        assert!(
3892            reached.is_none(),
3893            "set -e should trigger when || chain ultimately fails"
3894        );
3895    }
3896
3897    // ═══════════════════════════════════════════════════════════════════════════
3898    // Cancellation Tests
3899    // ═══════════════════════════════════════════════════════════════════════════
3900
3901    /// Helper: schedule a cancel after a delay from a background thread.
3902    /// Uses std::thread because cancel() is sync and Kernel is not Send.
3903    fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
3904        let k = Arc::clone(kernel);
3905        std::thread::spawn(move || {
3906            std::thread::sleep(delay);
3907            k.cancel();
3908        });
3909    }
3910
3911    #[tokio::test]
3912    async fn test_cancel_interrupts_for_loop() {
3913        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
3914
3915        // Schedule cancel after a short delay from a background OS thread
3916        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
3917
3918        let result = kernel
3919            .execute("for i in $(seq 1 100000); do X=$i; done")
3920            .await
3921            .expect("execute failed");
3922
3923        assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
3924
3925        // The loop variable should be set to something < 100000
3926        let x = kernel.get_var("X").await;
3927        if let Some(Value::Int(n)) = x {
3928            assert!(n < 100000, "loop should have been interrupted before finishing, got X={n}");
3929        }
3930    }
3931
3932    #[tokio::test]
3933    async fn test_cancel_interrupts_while_loop() {
3934        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
3935        kernel.execute("COUNT=0").await.expect("init failed");
3936
3937        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
3938
3939        let result = kernel
3940            .execute("while true; do COUNT=$((COUNT + 1)); done")
3941            .await
3942            .expect("execute failed");
3943
3944        assert_eq!(result.code, 130);
3945
3946        let count = kernel.get_var("COUNT").await;
3947        if let Some(Value::Int(n)) = count {
3948            assert!(n > 0, "loop should have run at least once");
3949        }
3950    }
3951
3952    #[tokio::test]
3953    async fn test_reset_after_cancel() {
3954        // After cancellation, the next execute() should work normally
3955        let kernel = Kernel::transient().expect("failed to create kernel");
3956        kernel.cancel(); // cancel with nothing running
3957
3958        let result = kernel.execute("echo hello").await.expect("execute failed");
3959        assert!(result.ok(), "execute after cancel should succeed");
3960        assert_eq!(result.out.trim(), "hello");
3961    }
3962
3963    #[tokio::test]
3964    async fn test_cancel_interrupts_statement_sequence() {
3965        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
3966
3967        // Schedule cancel after the first statement runs but before sleep finishes
3968        schedule_cancel(&kernel, std::time::Duration::from_millis(50));
3969
3970        let result = kernel
3971            .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
3972            .await
3973            .expect("execute failed");
3974
3975        assert_eq!(result.code, 130);
3976
3977        // STEP should be 1 (set before sleep), not 2 or 3
3978        let step = kernel.get_var("STEP").await;
3979        assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
3980    }
3981
3982    // ═══════════════════════════════════════════════════════════════════════════
3983    // Case Statement Tests
3984    // ═══════════════════════════════════════════════════════════════════════════
3985
3986    #[tokio::test]
3987    async fn test_case_simple_match() {
3988        let kernel = Kernel::transient().expect("failed to create kernel");
3989
3990        let result = kernel
3991            .execute(r#"
3992                case "hello" in
3993                    hello) echo "matched hello" ;;
3994                    world) echo "matched world" ;;
3995                esac
3996            "#)
3997            .await
3998            .expect("case failed");
3999
4000        assert!(result.ok());
4001        assert_eq!(result.out.trim(), "matched hello");
4002    }
4003
4004    #[tokio::test]
4005    async fn test_case_wildcard_match() {
4006        let kernel = Kernel::transient().expect("failed to create kernel");
4007
4008        let result = kernel
4009            .execute(r#"
4010                case "main.rs" in
4011                    "*.py") echo "Python" ;;
4012                    "*.rs") echo "Rust" ;;
4013                    "*") echo "Unknown" ;;
4014                esac
4015            "#)
4016            .await
4017            .expect("case failed");
4018
4019        assert!(result.ok());
4020        assert_eq!(result.out.trim(), "Rust");
4021    }
4022
4023    #[tokio::test]
4024    async fn test_case_default_match() {
4025        let kernel = Kernel::transient().expect("failed to create kernel");
4026
4027        let result = kernel
4028            .execute(r#"
4029                case "unknown.xyz" in
4030                    "*.py") echo "Python" ;;
4031                    "*.rs") echo "Rust" ;;
4032                    "*") echo "Default" ;;
4033                esac
4034            "#)
4035            .await
4036            .expect("case failed");
4037
4038        assert!(result.ok());
4039        assert_eq!(result.out.trim(), "Default");
4040    }
4041
4042    #[tokio::test]
4043    async fn test_case_no_match() {
4044        let kernel = Kernel::transient().expect("failed to create kernel");
4045
4046        // Case with no default branch and no match
4047        let result = kernel
4048            .execute(r#"
4049                case "nope" in
4050                    "yes") echo "yes" ;;
4051                    "no") echo "no" ;;
4052                esac
4053            "#)
4054            .await
4055            .expect("case failed");
4056
4057        assert!(result.ok());
4058        assert!(result.out.is_empty(), "no match should produce empty output");
4059    }
4060
4061    #[tokio::test]
4062    async fn test_case_with_variable() {
4063        let kernel = Kernel::transient().expect("failed to create kernel");
4064
4065        kernel.execute(r#"LANG="rust""#).await.expect("set failed");
4066
4067        let result = kernel
4068            .execute(r#"
4069                case ${LANG} in
4070                    python) echo "snake" ;;
4071                    rust) echo "crab" ;;
4072                    go) echo "gopher" ;;
4073                esac
4074            "#)
4075            .await
4076            .expect("case failed");
4077
4078        assert!(result.ok());
4079        assert_eq!(result.out.trim(), "crab");
4080    }
4081
4082    #[tokio::test]
4083    async fn test_case_multiple_patterns() {
4084        let kernel = Kernel::transient().expect("failed to create kernel");
4085
4086        let result = kernel
4087            .execute(r#"
4088                case "yes" in
4089                    "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
4090                    "n"|"no"|"N"|"NO") echo "negative" ;;
4091                esac
4092            "#)
4093            .await
4094            .expect("case failed");
4095
4096        assert!(result.ok());
4097        assert_eq!(result.out.trim(), "affirmative");
4098    }
4099
4100    #[tokio::test]
4101    async fn test_case_glob_question_mark() {
4102        let kernel = Kernel::transient().expect("failed to create kernel");
4103
4104        let result = kernel
4105            .execute(r#"
4106                case "test1" in
4107                    "test?") echo "matched test?" ;;
4108                    "*") echo "default" ;;
4109                esac
4110            "#)
4111            .await
4112            .expect("case failed");
4113
4114        assert!(result.ok());
4115        assert_eq!(result.out.trim(), "matched test?");
4116    }
4117
4118    #[tokio::test]
4119    async fn test_case_char_class() {
4120        let kernel = Kernel::transient().expect("failed to create kernel");
4121
4122        let result = kernel
4123            .execute(r#"
4124                case "Yes" in
4125                    "[Yy]*") echo "yes-like" ;;
4126                    "[Nn]*") echo "no-like" ;;
4127                esac
4128            "#)
4129            .await
4130            .expect("case failed");
4131
4132        assert!(result.ok());
4133        assert_eq!(result.out.trim(), "yes-like");
4134    }
4135
4136    // ═══════════════════════════════════════════════════════════════════════════
4137    // Cat Stdin Tests
4138    // ═══════════════════════════════════════════════════════════════════════════
4139
4140    #[tokio::test]
4141    async fn test_cat_from_pipeline() {
4142        let kernel = Kernel::transient().expect("failed to create kernel");
4143
4144        let result = kernel
4145            .execute(r#"echo "piped text" | cat"#)
4146            .await
4147            .expect("cat pipeline failed");
4148
4149        assert!(result.ok(), "cat failed: {}", result.err);
4150        assert_eq!(result.out.trim(), "piped text");
4151    }
4152
4153    #[tokio::test]
4154    async fn test_cat_from_pipeline_multiline() {
4155        let kernel = Kernel::transient().expect("failed to create kernel");
4156
4157        let result = kernel
4158            .execute(r#"echo "line1\nline2" | cat -n"#)
4159            .await
4160            .expect("cat pipeline failed");
4161
4162        assert!(result.ok(), "cat failed: {}", result.err);
4163        assert!(result.out.contains("1\t"), "output: {}", result.out);
4164    }
4165
4166    // ═══════════════════════════════════════════════════════════════════════════
4167    // Heredoc Tests
4168    // ═══════════════════════════════════════════════════════════════════════════
4169
4170    #[tokio::test]
4171    async fn test_heredoc_basic() {
4172        let kernel = Kernel::transient().expect("failed to create kernel");
4173
4174        let result = kernel
4175            .execute("cat <<EOF\nhello\nEOF")
4176            .await
4177            .expect("heredoc failed");
4178
4179        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
4180        assert_eq!(result.out.trim(), "hello");
4181    }
4182
4183    #[tokio::test]
4184    async fn test_arithmetic_in_string() {
4185        let kernel = Kernel::transient().expect("failed to create kernel");
4186
4187        let result = kernel
4188            .execute(r#"echo "result: $((1 + 2))""#)
4189            .await
4190            .expect("arithmetic in string failed");
4191
4192        assert!(result.ok(), "echo failed: {}", result.err);
4193        assert_eq!(result.out.trim(), "result: 3");
4194    }
4195
4196    #[tokio::test]
4197    async fn test_heredoc_multiline() {
4198        let kernel = Kernel::transient().expect("failed to create kernel");
4199
4200        let result = kernel
4201            .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
4202            .await
4203            .expect("heredoc failed");
4204
4205        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
4206        assert!(result.out.contains("line1"), "output: {}", result.out);
4207        assert!(result.out.contains("line2"), "output: {}", result.out);
4208        assert!(result.out.contains("line3"), "output: {}", result.out);
4209    }
4210
4211    #[tokio::test]
4212    async fn test_heredoc_variable_expansion() {
4213        // Bug N: unquoted heredoc should expand variables
4214        let kernel = Kernel::transient().expect("failed to create kernel");
4215
4216        kernel.execute("GREETING=hello").await.expect("set var");
4217
4218        let result = kernel
4219            .execute("cat <<EOF\n$GREETING world\nEOF")
4220            .await
4221            .expect("heredoc expansion failed");
4222
4223        assert!(result.ok(), "heredoc expansion failed: {}", result.err);
4224        assert_eq!(result.out.trim(), "hello world");
4225    }
4226
4227    #[tokio::test]
4228    async fn test_heredoc_quoted_no_expansion() {
4229        // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
4230        let kernel = Kernel::transient().expect("failed to create kernel");
4231
4232        kernel.execute("GREETING=hello").await.expect("set var");
4233
4234        let result = kernel
4235            .execute("cat <<'EOF'\n$GREETING world\nEOF")
4236            .await
4237            .expect("quoted heredoc failed");
4238
4239        assert!(result.ok(), "quoted heredoc failed: {}", result.err);
4240        assert_eq!(result.out.trim(), "$GREETING world");
4241    }
4242
4243    #[tokio::test]
4244    async fn test_heredoc_default_value_expansion() {
4245        // Bug N: ${VAR:-default} should expand in unquoted heredocs
4246        let kernel = Kernel::transient().expect("failed to create kernel");
4247
4248        let result = kernel
4249            .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
4250            .await
4251            .expect("heredoc default expansion failed");
4252
4253        assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
4254        assert_eq!(result.out.trim(), "fallback");
4255    }
4256
4257    // ═══════════════════════════════════════════════════════════════════════════
4258    // Read Builtin Tests
4259    // ═══════════════════════════════════════════════════════════════════════════
4260
4261    #[tokio::test]
4262    async fn test_read_from_pipeline() {
4263        let kernel = Kernel::transient().expect("failed to create kernel");
4264
4265        // Pipe input to read
4266        let result = kernel
4267            .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
4268            .await
4269            .expect("read pipeline failed");
4270
4271        assert!(result.ok(), "read failed: {}", result.err);
4272        assert!(result.out.contains("Hello, Alice"), "output: {}", result.out);
4273    }
4274
4275    #[tokio::test]
4276    async fn test_read_multiple_vars_from_pipeline() {
4277        let kernel = Kernel::transient().expect("failed to create kernel");
4278
4279        let result = kernel
4280            .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
4281            .await
4282            .expect("read pipeline failed");
4283
4284        assert!(result.ok(), "read failed: {}", result.err);
4285        assert!(result.out.contains("John is 42"), "output: {}", result.out);
4286    }
4287
4288    // ═══════════════════════════════════════════════════════════════════════════
4289    // Shell-Style Function Tests
4290    // ═══════════════════════════════════════════════════════════════════════════
4291
4292    #[tokio::test]
4293    async fn test_posix_function_with_positional_params() {
4294        let kernel = Kernel::transient().expect("failed to create kernel");
4295
4296        // Define POSIX-style function
4297        kernel
4298            .execute(r#"greet() { echo "Hello, $1!" }"#)
4299            .await
4300            .expect("function definition failed");
4301
4302        // Call the function
4303        let result = kernel
4304            .execute(r#"greet "Amy""#)
4305            .await
4306            .expect("function call failed");
4307
4308        assert!(result.ok(), "greet failed: {}", result.err);
4309        assert_eq!(result.out.trim(), "Hello, Amy!");
4310    }
4311
4312    #[tokio::test]
4313    async fn test_posix_function_multiple_args() {
4314        let kernel = Kernel::transient().expect("failed to create kernel");
4315
4316        // Define function using $1 and $2
4317        kernel
4318            .execute(r#"add_greeting() { echo "$1 $2!" }"#)
4319            .await
4320            .expect("function definition failed");
4321
4322        // Call the function
4323        let result = kernel
4324            .execute(r#"add_greeting "Hello" "World""#)
4325            .await
4326            .expect("function call failed");
4327
4328        assert!(result.ok(), "function failed: {}", result.err);
4329        assert_eq!(result.out.trim(), "Hello World!");
4330    }
4331
4332    #[tokio::test]
4333    async fn test_bash_function_with_positional_params() {
4334        let kernel = Kernel::transient().expect("failed to create kernel");
4335
4336        // Define bash-style function (function keyword, no parens)
4337        kernel
4338            .execute(r#"function greet { echo "Hi $1" }"#)
4339            .await
4340            .expect("function definition failed");
4341
4342        // Call the function
4343        let result = kernel
4344            .execute(r#"greet "Bob""#)
4345            .await
4346            .expect("function call failed");
4347
4348        assert!(result.ok(), "greet failed: {}", result.err);
4349        assert_eq!(result.out.trim(), "Hi Bob");
4350    }
4351
4352    #[tokio::test]
4353    async fn test_shell_function_with_all_args() {
4354        let kernel = Kernel::transient().expect("failed to create kernel");
4355
4356        // Define function using $@ (all args)
4357        kernel
4358            .execute(r#"echo_all() { echo "args: $@" }"#)
4359            .await
4360            .expect("function definition failed");
4361
4362        // Call with multiple args
4363        let result = kernel
4364            .execute(r#"echo_all "a" "b" "c""#)
4365            .await
4366            .expect("function call failed");
4367
4368        assert!(result.ok(), "function failed: {}", result.err);
4369        assert_eq!(result.out.trim(), "args: a b c");
4370    }
4371
4372    #[tokio::test]
4373    async fn test_shell_function_with_arg_count() {
4374        let kernel = Kernel::transient().expect("failed to create kernel");
4375
4376        // Define function using $# (arg count)
4377        kernel
4378            .execute(r#"count_args() { echo "count: $#" }"#)
4379            .await
4380            .expect("function definition failed");
4381
4382        // Call with three args
4383        let result = kernel
4384            .execute(r#"count_args "x" "y" "z""#)
4385            .await
4386            .expect("function call failed");
4387
4388        assert!(result.ok(), "function failed: {}", result.err);
4389        assert_eq!(result.out.trim(), "count: 3");
4390    }
4391
4392    #[tokio::test]
4393    async fn test_shell_function_shared_scope() {
4394        let kernel = Kernel::transient().expect("failed to create kernel");
4395
4396        // Set a variable in parent scope
4397        kernel
4398            .execute(r#"PARENT_VAR="visible""#)
4399            .await
4400            .expect("set failed");
4401
4402        // Define shell function that reads and writes parent variable
4403        kernel
4404            .execute(r#"modify_parent() {
4405                echo "saw: ${PARENT_VAR}"
4406                PARENT_VAR="changed by function"
4407            }"#)
4408            .await
4409            .expect("function definition failed");
4410
4411        // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
4412        let result = kernel.execute("modify_parent").await.expect("function failed");
4413
4414        assert!(
4415            result.out.contains("visible"),
4416            "Shell function should access parent scope, got: {}",
4417            result.out
4418        );
4419
4420        // Parent variable should be modified
4421        let var = kernel.get_var("PARENT_VAR").await;
4422        assert_eq!(
4423            var,
4424            Some(Value::String("changed by function".into())),
4425            "Shell function should modify parent scope"
4426        );
4427    }
4428
4429    // ═══════════════════════════════════════════════════════════════════════════
4430    // Script Execution via PATH Tests
4431    // ═══════════════════════════════════════════════════════════════════════════
4432
4433    #[tokio::test]
4434    async fn test_script_execution_from_path() {
4435        let kernel = Kernel::transient().expect("failed to create kernel");
4436
4437        // Create /bin directory and script
4438        kernel.execute(r#"mkdir "/bin""#).await.ok();
4439        kernel
4440            .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
4441            .await
4442            .expect("write script failed");
4443
4444        // Set PATH to /bin
4445        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
4446
4447        // Call script by name (without .kai extension)
4448        let result = kernel
4449            .execute("hello")
4450            .await
4451            .expect("script execution failed");
4452
4453        assert!(result.ok(), "script failed: {}", result.err);
4454        assert_eq!(result.out.trim(), "Hello from script!");
4455    }
4456
4457    #[tokio::test]
4458    async fn test_script_with_args() {
4459        let kernel = Kernel::transient().expect("failed to create kernel");
4460
4461        // Create script that uses positional params
4462        kernel.execute(r#"mkdir "/bin""#).await.ok();
4463        kernel
4464            .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
4465            .await
4466            .expect("write script failed");
4467
4468        // Set PATH
4469        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
4470
4471        // Call script with arg
4472        let result = kernel
4473            .execute(r#"greet "World""#)
4474            .await
4475            .expect("script execution failed");
4476
4477        assert!(result.ok(), "script failed: {}", result.err);
4478        assert_eq!(result.out.trim(), "Hello, World!");
4479    }
4480
4481    #[tokio::test]
4482    async fn test_script_not_found() {
4483        let kernel = Kernel::transient().expect("failed to create kernel");
4484
4485        // Set empty PATH
4486        kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
4487
4488        // Call non-existent script
4489        let result = kernel
4490            .execute("noscript")
4491            .await
4492            .expect("execution failed");
4493
4494        assert!(!result.ok(), "should fail with command not found");
4495        assert_eq!(result.code, 127);
4496        assert!(result.err.contains("command not found"));
4497    }
4498
4499    #[tokio::test]
4500    async fn test_script_path_search_order() {
4501        let kernel = Kernel::transient().expect("failed to create kernel");
4502
4503        // Create two directories with same-named script
4504        // Note: using "myscript" not "test" to avoid conflict with test builtin
4505        kernel.execute(r#"mkdir "/first""#).await.ok();
4506        kernel.execute(r#"mkdir "/second""#).await.ok();
4507        kernel
4508            .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
4509            .await
4510            .expect("write failed");
4511        kernel
4512            .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
4513            .await
4514            .expect("write failed");
4515
4516        // Set PATH with first before second
4517        kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
4518
4519        // Should find first one
4520        let result = kernel
4521            .execute("myscript")
4522            .await
4523            .expect("script execution failed");
4524
4525        assert!(result.ok(), "script failed: {}", result.err);
4526        assert_eq!(result.out.trim(), "from first");
4527    }
4528
4529    // ═══════════════════════════════════════════════════════════════════════════
4530    // Special Variable Tests ($?, $$, unset vars)
4531    // ═══════════════════════════════════════════════════════════════════════════
4532
4533    #[tokio::test]
4534    async fn test_last_exit_code_success() {
4535        let kernel = Kernel::transient().expect("failed to create kernel");
4536
4537        // true exits with 0
4538        let result = kernel.execute("true; echo $?").await.expect("execution failed");
4539        assert!(result.out.contains("0"), "expected 0, got: {}", result.out);
4540    }
4541
4542    #[tokio::test]
4543    async fn test_last_exit_code_failure() {
4544        let kernel = Kernel::transient().expect("failed to create kernel");
4545
4546        // false exits with 1
4547        let result = kernel.execute("false; echo $?").await.expect("execution failed");
4548        assert!(result.out.contains("1"), "expected 1, got: {}", result.out);
4549    }
4550
4551    #[tokio::test]
4552    async fn test_current_pid() {
4553        let kernel = Kernel::transient().expect("failed to create kernel");
4554
4555        let result = kernel.execute("echo $$").await.expect("execution failed");
4556        // PID should be a positive number
4557        let pid: u32 = result.out.trim().parse().expect("PID should be a number");
4558        assert!(pid > 0, "PID should be positive");
4559    }
4560
4561    #[tokio::test]
4562    async fn test_unset_variable_expands_to_empty() {
4563        let kernel = Kernel::transient().expect("failed to create kernel");
4564
4565        // Unset variable in interpolation should be empty
4566        let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
4567        assert_eq!(result.out.trim(), "prefix::suffix");
4568    }
4569
4570    #[tokio::test]
4571    async fn test_eq_ne_operators() {
4572        let kernel = Kernel::transient().expect("failed to create kernel");
4573
4574        // Test -eq operator
4575        let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
4576        assert_eq!(result.out.trim(), "eq works");
4577
4578        // Test -ne operator
4579        let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
4580        assert_eq!(result.out.trim(), "ne works");
4581
4582        // Test -eq with different values
4583        let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
4584        assert_eq!(result.out.trim(), "correct");
4585    }
4586
4587    #[tokio::test]
4588    async fn test_escaped_dollar_in_string() {
4589        let kernel = Kernel::transient().expect("failed to create kernel");
4590
4591        // \$ should produce literal $
4592        let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
4593        assert_eq!(result.out.trim(), "$100");
4594    }
4595
4596    #[tokio::test]
4597    async fn test_special_vars_in_interpolation() {
4598        let kernel = Kernel::transient().expect("failed to create kernel");
4599
4600        // Test $? in string interpolation
4601        let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
4602        assert_eq!(result.out.trim(), "exit: 0");
4603
4604        // Test $$ in string interpolation
4605        let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
4606        assert!(result.out.starts_with("pid: "), "unexpected output: {}", result.out);
4607        let pid_part = result.out.trim().strip_prefix("pid: ").unwrap();
4608        let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
4609    }
4610
4611    // ═══════════════════════════════════════════════════════════════════════════
4612    // Command Substitution Tests
4613    // ═══════════════════════════════════════════════════════════════════════════
4614
4615    #[tokio::test]
4616    async fn test_command_subst_assignment() {
4617        let kernel = Kernel::transient().expect("failed to create kernel");
4618
4619        // Command substitution in assignment
4620        let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
4621        assert_eq!(result.out.trim(), "hello");
4622    }
4623
4624    #[tokio::test]
4625    async fn test_command_subst_with_args() {
4626        let kernel = Kernel::transient().expect("failed to create kernel");
4627
4628        // Command substitution with string argument
4629        let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
4630        assert_eq!(result.out.trim(), "a b c");
4631    }
4632
4633    #[tokio::test]
4634    async fn test_command_subst_nested_vars() {
4635        let kernel = Kernel::transient().expect("failed to create kernel");
4636
4637        // Variables inside command substitution
4638        let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
4639        assert_eq!(result.out.trim(), "hello world");
4640    }
4641
4642    #[tokio::test]
4643    async fn test_background_job_basic() {
4644        use std::time::Duration;
4645
4646        let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
4647
4648        // Run a simple background command
4649        let result = kernel.execute("echo hello &").await.expect("execution failed");
4650        assert!(result.ok(), "background command should succeed: {}", result.err);
4651        assert!(result.out.contains("[1]"), "should return job ID: {}", result.out);
4652
4653        // Give the job time to complete
4654        tokio::time::sleep(Duration::from_millis(100)).await;
4655
4656        // Check job status
4657        let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
4658        assert!(status.ok(), "status should succeed: {}", status.err);
4659        assert!(
4660            status.out.contains("done:") || status.out.contains("running"),
4661            "should have valid status: {}",
4662            status.out
4663        );
4664
4665        // Check stdout
4666        let stdout = kernel.execute("cat /v/jobs/1/stdout").await.expect("stdout check failed");
4667        assert!(stdout.ok());
4668        assert!(stdout.out.contains("hello"));
4669    }
4670
4671    #[tokio::test]
4672    async fn test_heredoc_piped_to_command() {
4673        // Bug 4: heredoc content should pipe through to next command
4674        let kernel = Kernel::transient().expect("kernel");
4675        let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
4676        assert!(result.ok(), "heredoc | cat failed: {}", result.err);
4677        assert_eq!(result.out.trim(), "hello world");
4678    }
4679
4680    #[tokio::test]
4681    async fn test_for_loop_glob_iterates() {
4682        // Bug 1: for F in $(glob ...) should iterate per file, not once
4683        let kernel = Kernel::transient().expect("kernel");
4684        let dir = format!("/tmp/kaish_test_glob_{}", std::process::id());
4685        kernel.execute(&format!("mkdir -p {dir}")).await.unwrap();
4686        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
4687        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
4688        let result = kernel.execute(&format!(r#"
4689            N=0
4690            for F in $(glob "{dir}/*.txt"); do
4691                N=$((N + 1))
4692            done
4693            echo $N
4694        "#)).await.unwrap();
4695        assert!(result.ok(), "for glob failed: {}", result.err);
4696        assert_eq!(result.out.trim(), "2", "Should iterate 2 files, got: {}", result.out);
4697        kernel.execute(&format!("rm {dir}/a.txt")).await.unwrap();
4698        kernel.execute(&format!("rm {dir}/b.txt")).await.unwrap();
4699    }
4700
4701    #[tokio::test]
4702    async fn test_command_subst_echo_not_iterable() {
4703        // Regression guard: $(echo "a b c") must remain a single string
4704        let kernel = Kernel::transient().expect("kernel");
4705        let result = kernel.execute(r#"
4706            N=0
4707            for X in $(echo "a b c"); do N=$((N + 1)); done
4708            echo $N
4709        "#).await.unwrap();
4710        assert!(result.ok());
4711        assert_eq!(result.out.trim(), "1", "echo should be one item: {}", result.out);
4712    }
4713
4714    // -- accumulate_result / newline tests --
4715
4716    #[test]
4717    fn test_accumulate_no_double_newlines() {
4718        // When output already ends with \n, accumulate should not add another
4719        let mut acc = ExecResult::success("line1\n");
4720        let new = ExecResult::success("line2\n");
4721        accumulate_result(&mut acc, &new);
4722        assert_eq!(acc.out, "line1\nline2\n");
4723        assert!(!acc.out.contains("\n\n"), "should not have double newlines: {:?}", acc.out);
4724    }
4725
4726    #[test]
4727    fn test_accumulate_adds_separator_when_needed() {
4728        // When output does NOT end with \n, accumulate adds one
4729        let mut acc = ExecResult::success("line1");
4730        let new = ExecResult::success("line2");
4731        accumulate_result(&mut acc, &new);
4732        assert_eq!(acc.out, "line1\nline2");
4733    }
4734
4735    #[test]
4736    fn test_accumulate_empty_into_nonempty() {
4737        let mut acc = ExecResult::success("");
4738        let new = ExecResult::success("hello\n");
4739        accumulate_result(&mut acc, &new);
4740        assert_eq!(acc.out, "hello\n");
4741    }
4742
4743    #[test]
4744    fn test_accumulate_nonempty_into_empty() {
4745        let mut acc = ExecResult::success("hello\n");
4746        let new = ExecResult::success("");
4747        accumulate_result(&mut acc, &new);
4748        assert_eq!(acc.out, "hello\n");
4749    }
4750
4751    #[test]
4752    fn test_accumulate_stderr_no_double_newlines() {
4753        let mut acc = ExecResult::failure(1, "err1\n");
4754        let new = ExecResult::failure(1, "err2\n");
4755        accumulate_result(&mut acc, &new);
4756        assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
4757    }
4758
4759    #[tokio::test]
4760    async fn test_multiple_echo_no_blank_lines() {
4761        let kernel = Kernel::transient().expect("kernel");
4762        let result = kernel
4763            .execute("echo one\necho two\necho three")
4764            .await
4765            .expect("execution failed");
4766        assert!(result.ok());
4767        assert_eq!(result.out, "one\ntwo\nthree\n");
4768    }
4769
4770    #[tokio::test]
4771    async fn test_for_loop_no_blank_lines() {
4772        let kernel = Kernel::transient().expect("kernel");
4773        let result = kernel
4774            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
4775            .await
4776            .expect("execution failed");
4777        assert!(result.ok());
4778        assert_eq!(result.out, "item: a\nitem: b\nitem: c\n");
4779    }
4780
4781    #[tokio::test]
4782    async fn test_for_command_subst_no_blank_lines() {
4783        let kernel = Kernel::transient().expect("kernel");
4784        let result = kernel
4785            .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
4786            .await
4787            .expect("execution failed");
4788        assert!(result.ok());
4789        assert_eq!(result.out, "n=1\nn=2\nn=3\n");
4790    }
4791
4792}