Skip to main content

harn_cli/commands/run/
mod.rs

1use std::collections::HashSet;
2use std::fs;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::process;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::Instant;
9
10use crate::commands::time::{self, RunTiming};
11use crate::package;
12use crate::skill_loader::{
13    canonicalize_cli_dirs, emit_loader_warnings, install_skills_global, load_skills,
14    SkillLoaderInputs,
15};
16use harn_parser::DiagnosticSeverity;
17
18mod chunk_loading;
19pub(crate) mod environment;
20mod eval_source;
21mod explain_cost;
22pub mod harnpack;
23mod interrupts;
24pub mod json_events;
25mod lifecycle;
26mod llm_mock;
27mod manifest_runtime;
28mod mcp_serve;
29mod reporting;
30pub(crate) mod sandbox;
31
32pub(crate) use self::chunk_loading::{
33    compile_or_load_chunk_for_run, compile_or_load_chunk_with_timing, LoadedChunk,
34};
35use self::chunk_loading::{parse_source_for_run, typecheck_with_imports};
36pub(crate) use self::environment::{EnvironmentPolicyArg, EnvironmentPolicyConfig};
37use self::eval_source::create_eval_temp_file;
38pub(crate) use self::eval_source::prepare_eval_temp_file;
39#[cfg(test)]
40use self::eval_source::{eval_source_for_code, split_eval_header};
41use self::harnpack::{HarnpackError, HarnpackRunOptions, PreparedHarnpack};
42use self::interrupts::{
43    install_signal_shutdown_handler, start_run_deadline_watchdog, RunDeadlineGuard,
44};
45use self::json_events::NdjsonEmitter;
46pub use self::lifecycle::RunProfileOptions;
47use self::lifecycle::{RunExecution, TerminalRun};
48pub use self::llm_mock::*;
49pub(crate) use self::manifest_runtime::connect_mcp_servers;
50pub(crate) use self::mcp_serve::{
51    resolve_card_source, run_file_mcp_serve, RunFileAppServe, RunFileMcpServeHttp,
52    RunFileMcpServeMode,
53};
54use self::reporting::{
55    append_run_provenance_event, emit_run_attestation, emit_run_aux_for_exit,
56    exit_code_from_return_value, now_ms, render_and_persist_profile_rollup,
57    run_summary_llm_snapshot,
58};
59pub(crate) use self::reporting::{
60    render_trace_summary, run_aux_options_from_args, run_control_options_from_args,
61};
62pub use self::reporting::{
63    RunAuxOptions, RunControlOptions, RunJsonOptions, RunJsonSink, RunJsonSinkTarget,
64    RunPhaseOptions, RunRusageOptions, RunSummaryOptions, RUN_PHASE_SCHEMA_VERSION,
65    RUN_RUSAGE_SCHEMA_VERSION, RUN_SUMMARY_SCHEMA_VERSION,
66};
67#[cfg(test)]
68use self::sandbox::default_run_capability_policy;
69pub use self::sandbox::RunSandboxOptions;
70use self::sandbox::{
71    default_run_workspace_root, install_run_sandbox_scope, run_sandbox_attestation,
72};
73
74/// Core builtins that are never denied, even when using `--allow`.
75const CORE_BUILTINS: &[&str] = &[
76    "println",
77    "print",
78    "log",
79    "type_of",
80    "to_string",
81    "to_int",
82    "to_float",
83    "len",
84    "assert",
85    "assert_eq",
86    "assert_ne",
87    "json_parse",
88    "json_stringify",
89    "runtime_context",
90    "task_current",
91    "runtime_context_values",
92    "runtime_context_get",
93    "runtime_context_set",
94    "runtime_context_clear",
95];
96
97/// Build the set of denied builtin names from `--deny` or `--allow` flags.
98///
99/// - `--deny a,b,c` denies exactly those names.
100/// - `--allow a,b,c` denies everything *except* the listed names and the core builtins.
101pub(crate) fn build_denied_builtins(
102    deny_csv: Option<&str>,
103    allow_csv: Option<&str>,
104) -> HashSet<String> {
105    if let Some(csv) = deny_csv {
106        csv.split(',')
107            .map(|s| s.trim().to_string())
108            .filter(|s| !s.is_empty())
109            .collect()
110    } else if let Some(csv) = allow_csv {
111        // With --allow, we mark every registered stdlib builtin as denied
112        // *except* those in the allow list and the core builtins.
113        let allowed: HashSet<String> = csv
114            .split(',')
115            .map(|s| s.trim().to_string())
116            .filter(|s| !s.is_empty())
117            .collect();
118        let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
119
120        // Create a temporary VM with stdlib registered to enumerate all builtin names.
121        let mut tmp = harn_vm::Vm::new();
122        harn_vm::register_vm_stdlib(&mut tmp);
123        harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
124        harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
125
126        tmp.builtin_names()
127            .into_iter()
128            .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
129            .collect()
130    } else {
131        HashSet::new()
132    }
133}
134
135#[derive(Clone, Debug, Default, PartialEq, Eq)]
136pub struct RunAttestationOptions {
137    pub receipt_out: Option<PathBuf>,
138    pub agent_id: Option<String>,
139}
140
141#[derive(Clone)]
142pub struct RunInterruptTokens {
143    pub cancel_token: Arc<AtomicBool>,
144    pub signal_token: Arc<Mutex<Option<String>>>,
145}
146
147struct ExecuteRunInputs<'a> {
148    path: &'a str,
149    trace: bool,
150    denied_builtins: HashSet<String>,
151    script_argv: Vec<String>,
152    skill_dirs_raw: Vec<String>,
153    llm_mock_mode: CliLlmMockMode,
154    attestation: Option<RunAttestationOptions>,
155    profile: RunProfileOptions,
156    sandbox: RunSandboxOptions,
157    interrupt_tokens: Option<RunInterruptTokens>,
158    json: Option<JsonRunSession>,
159    aux: RunAuxOptions,
160    timing: Option<&'a mut RunTiming>,
161    harnpack: HarnpackRunOptions,
162    defer_project_handlers: bool,
163}
164
165/// Captured outcome of an in-process `execute_run` invocation. Tests use this
166/// instead of spawning the `harn` binary; the binary entry point translates
167/// it into real stdout/stderr writes + `process::exit`.
168#[derive(Clone, Debug, Default)]
169pub struct RunOutcome {
170    pub stdout: String,
171    pub stderr: String,
172    pub exit_code: i32,
173}
174
175pub(crate) async fn run_file(
176    path: &str,
177    trace: bool,
178    denied_builtins: HashSet<String>,
179    script_argv: Vec<String>,
180    llm_mock_mode: CliLlmMockMode,
181    attestation: Option<RunAttestationOptions>,
182    profile: RunProfileOptions,
183) {
184    let exit_code = run_file_with_skill_dirs(
185        path,
186        trace,
187        denied_builtins,
188        script_argv,
189        Vec::new(),
190        llm_mock_mode,
191        attestation,
192        profile,
193        RunSandboxOptions::default(),
194        None,
195        RunAuxOptions::default(),
196        RunControlOptions::default(),
197        HarnpackRunOptions::default(),
198    )
199    .await;
200    if exit_code != 0 {
201        process::exit(exit_code);
202    }
203}
204
205pub(crate) fn run_explain_cost_file_with_skill_dirs(path: &str) -> i32 {
206    let outcome = execute_explain_cost(path);
207    if !outcome.stderr.is_empty() {
208        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
209    }
210    if !outcome.stdout.is_empty() {
211        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
212    }
213    outcome.exit_code
214}
215
216#[allow(clippy::too_many_arguments)]
217pub(crate) async fn run_file_with_skill_dirs(
218    path: &str,
219    trace: bool,
220    denied_builtins: HashSet<String>,
221    script_argv: Vec<String>,
222    skill_dirs_raw: Vec<String>,
223    llm_mock_mode: CliLlmMockMode,
224    attestation: Option<RunAttestationOptions>,
225    profile: RunProfileOptions,
226    sandbox: RunSandboxOptions,
227    json: Option<RunJsonOptions>,
228    aux: RunAuxOptions,
229    control: RunControlOptions,
230    harnpack: HarnpackRunOptions,
231) -> i32 {
232    // Graceful shutdown: flush run records before exit on SIGINT/SIGTERM.
233    let interrupt_tokens = install_signal_shutdown_handler();
234    let deadline_guard = control
235        .timeout
236        .map(|timeout| start_run_deadline_watchdog(timeout, interrupt_tokens.clone()));
237
238    let _stdout_passthrough = StdoutPassthroughGuard::enable();
239    let json_session = json.map(|options| {
240        JsonRunSession::new(options, Box::new(io::stdout()) as Box<dyn io::Write + Send>)
241    });
242    let outcome = execute_run_inner(ExecuteRunInputs {
243        path,
244        trace,
245        denied_builtins,
246        script_argv,
247        skill_dirs_raw,
248        llm_mock_mode,
249        attestation,
250        profile,
251        sandbox,
252        interrupt_tokens: Some(interrupt_tokens.clone()),
253        json: json_session,
254        aux,
255        timing: None,
256        harnpack,
257        defer_project_handlers: control.defer_project_handlers,
258    })
259    .await;
260    if let Some(guard) = &deadline_guard {
261        guard.finish();
262    }
263
264    // `harn run` streams normal program stdout during execution. Any stdout
265    // left here came from older capture paths, so flush it after diagnostics.
266    if !outcome.stderr.is_empty() {
267        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
268    }
269    if !outcome.stdout.is_empty() {
270        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
271    }
272
273    let mut exit_code = outcome.exit_code;
274    if deadline_guard
275        .as_ref()
276        .is_some_and(RunDeadlineGuard::timed_out)
277        || (exit_code != 0 && interrupt_tokens.cancel_token.load(Ordering::SeqCst))
278    {
279        exit_code = 124;
280    }
281    exit_code
282}
283
284#[allow(clippy::too_many_arguments)]
285pub(crate) async fn run_resume_with_skill_dirs(
286    target: &str,
287    trace: bool,
288    denied_builtins: HashSet<String>,
289    resume_argv: Vec<String>,
290    skill_dirs_raw: Vec<String>,
291    llm_mock_mode: CliLlmMockMode,
292    attestation: Option<RunAttestationOptions>,
293    profile: RunProfileOptions,
294    sandbox: RunSandboxOptions,
295    json: Option<RunJsonOptions>,
296    aux: RunAuxOptions,
297    control: RunControlOptions,
298) -> i32 {
299    let source = r#"import { resume_agent, wait_agent } from "std/agent/workers"
300
301pipeline main(harness: Harness) {
302  const input = if len(argv) > 1 {
303    argv[1]
304  } else {
305    nil
306  }
307  const handle = resume_agent(harness.agent, argv[0], input, true)
308  return wait_agent(harness.agent, handle)
309}
310"#;
311    let tmp = match create_eval_temp_file() {
312        Ok(tmp) => tmp,
313        Err(error) => {
314            eprintln!("error: {error}");
315            return 1;
316        }
317    };
318    let tmp_path = tmp.path().to_path_buf();
319    if let Err(error) = fs::write(&tmp_path, source) {
320        eprintln!("error: failed to write temp file for --resume: {error}");
321        return 1;
322    }
323    let mut argv = Vec::with_capacity(resume_argv.len() + 1);
324    argv.push(target.to_string());
325    argv.extend(resume_argv);
326    let tmp_str = tmp_path.to_string_lossy().into_owned();
327    run_file_with_skill_dirs(
328        &tmp_str,
329        trace,
330        denied_builtins,
331        argv,
332        skill_dirs_raw,
333        llm_mock_mode,
334        attestation,
335        profile,
336        sandbox,
337        json,
338        aux,
339        control,
340        HarnpackRunOptions::default(),
341    )
342    .await
343}
344
345pub fn execute_explain_cost(path: &str) -> RunOutcome {
346    let stdout = String::new();
347    let mut stderr = String::new();
348
349    let source = match fs::read_to_string(path) {
350        Ok(source) => source,
351        Err(error) => {
352            stderr.push_str(&format!("Error reading {path}: {error}\n"));
353            return RunOutcome {
354                stdout,
355                stderr,
356                exit_code: 1,
357            };
358        }
359    };
360    let program = match parse_source_for_run(path, &source, &mut stderr) {
361        Some(program) => program,
362        None => {
363            return RunOutcome {
364                stdout,
365                stderr,
366                exit_code: 1,
367            };
368        }
369    };
370
371    let mut had_type_error = false;
372    let type_diagnostics = match typecheck_with_imports(&program, Path::new(path), &source) {
373        Ok(diagnostics) => diagnostics,
374        Err(error) => {
375            stderr.push_str(&format!("error: {error}\n"));
376            return RunOutcome {
377                stdout,
378                stderr,
379                exit_code: 1,
380            };
381        }
382    };
383    for diag in &type_diagnostics {
384        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
385        if matches!(diag.severity, DiagnosticSeverity::Error) {
386            had_type_error = true;
387        }
388        stderr.push_str(&rendered);
389    }
390    if had_type_error {
391        return RunOutcome {
392            stdout,
393            stderr,
394            exit_code: 1,
395        };
396    }
397
398    let extensions = package::load_runtime_extensions(Path::new(path));
399    package::install_runtime_extensions(&extensions);
400    RunOutcome {
401        stdout: explain_cost::render_explain_cost(path, &program),
402        stderr,
403        exit_code: 0,
404    }
405}
406
407pub(crate) struct StdoutPassthroughGuard {
408    previous: bool,
409}
410
411impl StdoutPassthroughGuard {
412    pub(crate) fn enable() -> Self {
413        Self {
414            previous: harn_vm::set_stdout_passthrough(true),
415        }
416    }
417}
418
419impl Drop for StdoutPassthroughGuard {
420    fn drop(&mut self) {
421        harn_vm::set_stdout_passthrough(self.previous);
422    }
423}
424
425// User-facing copy on Ctrl-C. We want the operator to know that a brief
426// pause after the first signal is expected (the VM rewinds the active
427// instruction, drops in-flight async ops like a hanging Ollama request,
428// and unwinds frames before the runtime exits) so they don't reflexively
429// reach for a second Ctrl-C and force-kill the process. The "Ctrl-C
430// again to force-exit" hint is load-bearing — earlier runs of harn
431// released to the fleet showed operators routinely double-tapping the
432// shortcut and losing the chance to inspect the error trace.
433/// In-process equivalent of `run_file_with_skill_dirs`. Returns the captured
434/// stdout, stderr, and what exit code the binary entry would have used,
435/// instead of writing to real stdout/stderr or calling `process::exit`.
436///
437/// Tests should call this directly. The `harn run` binary path wraps it.
438pub async fn execute_run(
439    path: &str,
440    trace: bool,
441    denied_builtins: HashSet<String>,
442    script_argv: Vec<String>,
443    skill_dirs_raw: Vec<String>,
444    llm_mock_mode: CliLlmMockMode,
445    attestation: Option<RunAttestationOptions>,
446    profile: RunProfileOptions,
447) -> RunOutcome {
448    crate::ensure_builtin_signatures_installed();
449    execute_run_with_harnpack_and_sandbox_options(
450        path,
451        trace,
452        denied_builtins,
453        script_argv,
454        skill_dirs_raw,
455        llm_mock_mode,
456        attestation,
457        profile,
458        RunSandboxOptions::default(),
459        HarnpackRunOptions::default(),
460    )
461    .await
462}
463
464/// [`execute_run`] with an explicit sandbox policy override for in-process
465/// callers whose source path is intentionally outside the workspace they
466/// operate on.
467#[allow(clippy::too_many_arguments)]
468pub async fn execute_run_with_sandbox_options(
469    path: &str,
470    trace: bool,
471    denied_builtins: HashSet<String>,
472    script_argv: Vec<String>,
473    skill_dirs_raw: Vec<String>,
474    llm_mock_mode: CliLlmMockMode,
475    attestation: Option<RunAttestationOptions>,
476    profile: RunProfileOptions,
477    sandbox: RunSandboxOptions,
478) -> RunOutcome {
479    execute_run_with_harnpack_and_sandbox_options(
480        path,
481        trace,
482        denied_builtins,
483        script_argv,
484        skill_dirs_raw,
485        llm_mock_mode,
486        attestation,
487        profile,
488        sandbox,
489        HarnpackRunOptions::default(),
490    )
491    .await
492}
493
494/// [`execute_run`] for callers that want to opt-in to the `.harnpack`
495/// verify-replay-execute path. Used by `harn run <bundle.harnpack>`
496/// integration tests and by the binary entry once it has parsed the
497/// `--allow-unsigned` / `--dry-run-verify` flags.
498#[allow(clippy::too_many_arguments)]
499pub async fn execute_run_with_harnpack_options(
500    path: &str,
501    trace: bool,
502    denied_builtins: HashSet<String>,
503    script_argv: Vec<String>,
504    skill_dirs_raw: Vec<String>,
505    llm_mock_mode: CliLlmMockMode,
506    attestation: Option<RunAttestationOptions>,
507    profile: RunProfileOptions,
508    harnpack: HarnpackRunOptions,
509) -> RunOutcome {
510    execute_run_with_harnpack_and_sandbox_options(
511        path,
512        trace,
513        denied_builtins,
514        script_argv,
515        skill_dirs_raw,
516        llm_mock_mode,
517        attestation,
518        profile,
519        RunSandboxOptions::default(),
520        harnpack,
521    )
522    .await
523}
524
525#[allow(clippy::too_many_arguments)]
526async fn execute_run_with_harnpack_and_sandbox_options(
527    path: &str,
528    trace: bool,
529    denied_builtins: HashSet<String>,
530    script_argv: Vec<String>,
531    skill_dirs_raw: Vec<String>,
532    llm_mock_mode: CliLlmMockMode,
533    attestation: Option<RunAttestationOptions>,
534    profile: RunProfileOptions,
535    sandbox: RunSandboxOptions,
536    harnpack: HarnpackRunOptions,
537) -> RunOutcome {
538    execute_run_inner(ExecuteRunInputs {
539        path,
540        trace,
541        denied_builtins,
542        script_argv,
543        skill_dirs_raw,
544        llm_mock_mode,
545        attestation,
546        profile,
547        sandbox,
548        interrupt_tokens: None,
549        json: None,
550        aux: RunAuxOptions::default(),
551        timing: None,
552        harnpack,
553        defer_project_handlers: false,
554    })
555    .await
556}
557
558/// `execute_run` variant for `--json` mode. Returns once the run is
559/// complete; the NDJSON event stream — including the terminal `result`
560/// or `error` event — has already been written to `out` and flushed.
561/// `out` must be `Send` because the run-event sink may be called from
562/// any worker thread the VM spawns.
563#[allow(clippy::too_many_arguments)]
564pub async fn execute_run_json(
565    path: &str,
566    trace: bool,
567    denied_builtins: HashSet<String>,
568    script_argv: Vec<String>,
569    skill_dirs_raw: Vec<String>,
570    llm_mock_mode: CliLlmMockMode,
571    attestation: Option<RunAttestationOptions>,
572    profile: RunProfileOptions,
573    out: Box<dyn io::Write + Send>,
574    options: RunJsonOptions,
575) -> RunOutcome {
576    execute_run_inner(ExecuteRunInputs {
577        path,
578        trace,
579        denied_builtins,
580        script_argv,
581        skill_dirs_raw,
582        llm_mock_mode,
583        attestation,
584        profile,
585        sandbox: RunSandboxOptions::default(),
586        interrupt_tokens: None,
587        json: Some(JsonRunSession::new(options, out)),
588        aux: RunAuxOptions::default(),
589        timing: None,
590        harnpack: HarnpackRunOptions::default(),
591        defer_project_handlers: false,
592    })
593    .await
594}
595
596/// Run a `.harn` file with the default builtin/argv set and record
597/// phase timings into `timing`. Used by `harn time run` so the
598/// instrumented run shares the exact code path as plain `harn run`.
599pub(crate) async fn execute_run_with_timing(
600    path: &str,
601    script_argv: Vec<String>,
602    timing: Option<&mut RunTiming>,
603    sandbox: RunSandboxOptions,
604) -> RunOutcome {
605    execute_run_inner(ExecuteRunInputs {
606        path,
607        trace: false,
608        denied_builtins: HashSet::new(),
609        script_argv,
610        skill_dirs_raw: Vec::new(),
611        llm_mock_mode: CliLlmMockMode::Off,
612        attestation: None,
613        profile: RunProfileOptions::default(),
614        sandbox,
615        interrupt_tokens: None,
616        json: None,
617        aux: RunAuxOptions::default(),
618        timing,
619        harnpack: HarnpackRunOptions::default(),
620        defer_project_handlers: false,
621    })
622    .await
623}
624
625/// Directory that anchors the entry script's source-relative and `@asset`
626/// resolution.
627///
628/// Returns the script's parent directory, or the current working directory
629/// when the path is a bare filename (empty parent) — e.g. `cd project &&
630/// harn run main.harn`. The old code skipped setting the source dir in that
631/// case, which left the resting thread-local source dir unset (`None`). A
632/// dependency provider-connector contract load during `harn run` startup then
633/// repointed the thread-local at a dependency generation's `src` and, because the
634/// restore-on-return path is a no-op over an unset baseline, left it there —
635/// so the entry pipeline's first `render("@alias/...")` resolved against the
636/// dependency's `harn.toml` instead of the project root. Always establishing
637/// the entry dir keeps that resolution anchored on the project.
638fn entry_source_dir(path: &str) -> std::path::PathBuf {
639    match std::path::Path::new(path).parent() {
640        Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
641        _ => std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
642    }
643}
644
645// See [`compile_or_load_chunk_with_timing`] for why `as_deref_mut` is
646// the intentional reborrow pattern here.
647#[allow(clippy::needless_option_as_deref)]
648async fn execute_run_inner(inputs: ExecuteRunInputs<'_>) -> RunOutcome {
649    let mut inputs = inputs;
650    let json_session = inputs.json.take();
651    let Some(json_session) = json_session else {
652        return execute_run_inner_scoped(inputs, None).await;
653    };
654    let sink = json_session.sink();
655    harn_vm::run_events::scope(sink, execute_run_inner_scoped(inputs, Some(json_session))).await
656}
657
658async fn execute_run_inner_scoped(
659    inputs: ExecuteRunInputs<'_>,
660    json_session: Option<JsonRunSession>,
661) -> RunOutcome {
662    let ExecuteRunInputs {
663        path,
664        trace,
665        denied_builtins,
666        script_argv,
667        skill_dirs_raw,
668        llm_mock_mode,
669        attestation,
670        profile,
671        sandbox,
672        interrupt_tokens,
673        json: _,
674        aux,
675        timing,
676        harnpack,
677        defer_project_handlers,
678    } = inputs;
679    let RunAuxOptions {
680        summary,
681        phase,
682        rusage,
683    } = aux;
684    let run_started = Instant::now();
685    let cpu_started_ms = rusage.as_ref().map(|_| time::cpu_ms());
686    let mut owned_timing = if timing.is_none() && (phase.is_some() || rusage.is_some()) {
687        Some(RunTiming::default())
688    } else {
689        None
690    };
691    let mut timing = timing.or(owned_timing.as_mut());
692
693    let mut stderr = String::new();
694    let mut stdout = String::new();
695
696    // `.harnpack` preflight: verify signature + replay archive into the
697    // content-addressed cache before we touch the chunk loader. The
698    // outcome path (entrypoint inside the unpacked tree) replaces the
699    // CLI-supplied `path` for everything below.
700    let owned_run_path: String;
701    let resolved_path: &str = if harnpack::looks_like_harnpack(Path::new(path)) {
702        let outcome = match harnpack::prepare_harnpack(Path::new(path), &harnpack, &mut stderr) {
703            Ok(prepared) => prepared,
704            Err(err) => {
705                return finalize_harnpack_error(
706                    stderr,
707                    json_session,
708                    summary.as_ref(),
709                    phase.as_ref(),
710                    rusage.as_ref(),
711                    run_started,
712                    err,
713                );
714            }
715        };
716        harn_vm::run_events::emit(harn_vm::run_events::RunEvent::PackRun {
717            bundle_hash: outcome.bundle_hash.clone(),
718            signature_verified: outcome.signature_verified,
719            key_id: outcome.key_id.clone(),
720            cache_hit: outcome.cache_hit,
721            dry_run_verify: harnpack.dry_run_verify,
722        });
723        if harnpack.dry_run_verify {
724            return finalize_harnpack_dry_run(
725                stderr,
726                json_session,
727                summary.as_ref(),
728                phase.as_ref(),
729                rusage.as_ref(),
730                run_started,
731                cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
732                &outcome,
733            );
734        }
735        owned_run_path = outcome.entrypoint_path.to_string_lossy().into_owned();
736        owned_run_path.as_str()
737    } else {
738        path
739    };
740
741    let Some(LoadedChunk {
742        source,
743        chunk,
744        link_table,
745    }) = compile_or_load_chunk_with_timing(resolved_path, &mut stderr, timing.as_deref_mut())
746    else {
747        let message = stderr.clone();
748        return finalize_run_error(
749            stdout,
750            stderr,
751            json_session,
752            summary.as_ref(),
753            phase.as_ref(),
754            rusage.as_ref(),
755            run_started,
756            None,
757            timing.as_deref(),
758            0,
759            cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
760            "compile_error",
761            message,
762        );
763    };
764    let path = resolved_path;
765
766    let setup_start = Instant::now();
767    if trace || summary.is_some() {
768        harn_vm::llm::enable_tracing();
769    }
770    if profile.is_enabled() || phase.is_some() {
771        harn_vm::tracing::set_tracing_enabled(true);
772    }
773    // Per-builtin recording is only paid for when a profile is asked for: the
774    // categorical buckets fold every non-LLM, non-tool builtin into
775    // `residual`, which cannot name what a slow run is waiting on. The guard
776    // lives for the rest of this function, so recording ends with the run on
777    // every exit path below.
778    let _builtin_profile_guard = profile.is_enabled().then(harn_vm::builtin_profile::enable);
779    if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
780        stderr.push_str(&format!("error: {error}\n"));
781        time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
782        return finalize_run_error(
783            stdout,
784            stderr,
785            json_session,
786            summary.as_ref(),
787            phase.as_ref(),
788            rusage.as_ref(),
789            run_started,
790            None,
791            timing.as_deref(),
792            0,
793            cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
794            "llm_mock_install",
795            error,
796        );
797    }
798
799    let mut vm = harn_vm::Vm::new();
800    vm.set_graph_link_table(link_table);
801    if let Some(timing) = timing.as_deref_mut() {
802        timing.module_phases = Some(vm.enable_module_phase_timing());
803    }
804    if let Some(interrupt_tokens) = interrupt_tokens {
805        vm.install_interrupt_signal_token(interrupt_tokens.signal_token);
806        vm.install_cancel_token(interrupt_tokens.cancel_token);
807    }
808    harn_vm::register_vm_stdlib(&mut vm);
809    crate::install_default_hostlib(&mut vm);
810    let source_parent = std::path::Path::new(path)
811        .parent()
812        .unwrap_or(std::path::Path::new("."));
813    // Metadata/store rooted at harn.toml when present; source dir otherwise.
814    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
815    let store_base = project_root.as_deref().unwrap_or(source_parent);
816    let sandbox_root = sandbox
817        .workspace_root
818        .clone()
819        .unwrap_or_else(|| default_run_workspace_root(project_root.as_deref(), source_parent));
820    let _sandbox_scope = install_run_sandbox_scope(&sandbox, &sandbox_root, &mut stderr);
821
822    // Launch the session's environment policy so this run's
823    // subprocesses build their environment through the closed allowlist +
824    // grants resolver (harn#4992). A launch failure — a missing launcher
825    // variable, or a grant on an isolated policy — fails the run loudly rather
826    // than silently dropping the credential. Held for the run's duration; on
827    // drop the ambient environment policy is cleared.
828    let (_environment_scope, environment_policy, grant_receipts) =
829        match environment::launch_scope(&sandbox.environment, &mut stderr) {
830            Ok(launched) => launched,
831            Err(error) => {
832                stderr.push_str(&format!("error: {error}\n"));
833                time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
834                let code = error.code();
835                return finalize_run_error(
836                    stdout,
837                    stderr,
838                    json_session,
839                    summary.as_ref(),
840                    phase.as_ref(),
841                    rusage.as_ref(),
842                    run_started,
843                    None,
844                    timing.as_deref(),
845                    0,
846                    cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
847                    code,
848                    error.to_string(),
849                );
850            }
851        };
852
853    let attestation_started_at_ms = now_ms();
854    let attestation_log = if attestation.is_some() {
855        Some(harn_vm::event_log::install_memory_for_current_thread(256))
856    } else {
857        None
858    };
859    if let Some(log) = attestation_log.as_ref() {
860        append_run_provenance_event(
861            log,
862            "started",
863            serde_json::json!({
864                "pipeline": path,
865                "argv": &script_argv,
866                "project_root": store_base.display().to_string(),
867                "sandbox": run_sandbox_attestation(&sandbox),
868                "environment_policy": environment_policy.as_str(),
869                // Non-secret grant receipts; never the granted value.
870                "environment_grants": &grant_receipts,
871            }),
872        )
873        .await;
874    }
875    harn_vm::register_store_builtins(&mut vm, store_base);
876    harn_vm::register_metadata_builtins(&mut vm, store_base);
877    let pipeline_name = std::path::Path::new(path)
878        .file_stem()
879        .and_then(|s| s.to_str())
880        .unwrap_or("default");
881    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
882    vm.set_source_info(path, &source);
883    let defer_manifest_handlers = defer_project_handlers || !denied_builtins.is_empty();
884    if !denied_builtins.is_empty() {
885        vm.set_denied_builtins(denied_builtins);
886    }
887    if let Some(ref root) = project_root {
888        vm.set_project_root(root);
889    }
890
891    // Establish the entry script's directory as the resting source dir. When
892    // `path` is a bare filename (empty parent) — e.g. `cd project && harn run
893    // main.harn` — anchor on the current working directory instead of skipping,
894    // so the resting source dir is never left unset. See `entry_source_dir`.
895    vm.set_source_dir(&entry_source_dir(path));
896
897    // Load filesystem + manifest skills before the pipeline runs so
898    // `skills` is populated with a pre-discovered registry (see #73).
899    let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
900    let loaded = load_skills(&SkillLoaderInputs {
901        cli_dirs,
902        source_path: Some(std::path::PathBuf::from(path)),
903    });
904    emit_loader_warnings(&loaded.loader_warnings);
905    install_skills_global(&mut vm, &loaded);
906
907    // `harn run script.harn -- a b c` yields `argv == ["a", "b", "c"]`.
908    // Always set so scripts can rely on `len(argv)`.
909    let argv_values: Vec<harn_vm::VmValue> = script_argv
910        .iter()
911        .map(|s| harn_vm::VmValue::String(arcstr::ArcStr::from(s.as_str())))
912        .collect();
913    vm.set_global(
914        "argv",
915        harn_vm::VmValue::List(std::sync::Arc::new(argv_values)),
916    );
917
918    // Install the script's `Harness` capability handle so the auto-call
919    // emitted by `Compiler::compile()` for `fn main(harness: Harness)`
920    // entrypoints can read it.
921    let runtime_harness =
922        match crate::default_harness_for_manifest_or_base_dir(Path::new(path), store_base) {
923            Ok(harness) => harness,
924            Err(error) => {
925                stderr.push_str(&format!(
926                    "error: failed to configure harness secret provider: {error}\n"
927                ));
928                time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
929                return finalize_run_error(
930                    stdout,
931                    stderr,
932                    json_session,
933                    summary.as_ref(),
934                    phase.as_ref(),
935                    rusage.as_ref(),
936                    run_started,
937                    None,
938                    timing.as_deref(),
939                    0,
940                    cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
941                    "harness_secret_provider",
942                    error,
943                );
944            }
945        };
946    vm.set_harness(runtime_harness);
947
948    // An explicit allow/deny policy belongs to the requested target. Defer
949    // unrelated manifest handler graphs until they actually fire under this VM.
950    if let Err(error) = manifest_runtime::install_manifest_runtime(
951        Path::new(path),
952        &mut vm,
953        defer_manifest_handlers,
954    )
955    .await
956    {
957        stderr.push_str(&format!(
958            "error: failed to install {}: {error}\n",
959            error.label()
960        ));
961        time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
962        return finalize_run_error(
963            stdout,
964            stderr,
965            json_session,
966            summary.as_ref(),
967            phase.as_ref(),
968            rusage.as_ref(),
969            run_started,
970            None,
971            timing.as_deref(),
972            0,
973            cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
974            error.phase(),
975            error.to_string(),
976        );
977    }
978
979    let local = tokio::task::LocalSet::new();
980    time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
981    let main_start = Instant::now();
982    // Re-anchor the entry source dir immediately before executing the entry
983    // pipeline. The manifest/dependency setup above (provider-connector
984    // contract loads, hook-handler module loads) transiently repoints the
985    // thread-local source dir and does not guarantee it is restored to the
986    // entry's dir — a dependency provider connector under
987    // a dependency generation would otherwise leave the entry pipeline's first
988    // `render("@alias/...")` resolving against the dependency's `harn.toml`.
989    vm.set_source_dir(&entry_source_dir(path));
990    let execution = local
991        .run_until(async {
992            match vm.execute(&chunk).await {
993                Ok(value) => RunExecution::Terminal(TerminalRun::Returned(value)),
994                Err(error) => match error.process_exit_code() {
995                    Some(code) => RunExecution::Terminal(TerminalRun::ProcessExited(code)),
996                    None => RunExecution::Failed(vm.format_runtime_error(&error)),
997                },
998            }
999        })
1000        .await;
1001    let output = vm.output();
1002    if let Some(t) = timing.as_deref_mut() {
1003        t.run_main = main_start.elapsed();
1004    }
1005    if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
1006        stderr.push_str(&format!("error: {error}\n"));
1007        let profile_rollup = if profile.is_enabled() {
1008            Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1009        } else {
1010            None
1011        };
1012        return finalize_run_error(
1013            stdout,
1014            stderr,
1015            json_session,
1016            summary.as_ref(),
1017            phase.as_ref(),
1018            rusage.as_ref(),
1019            run_started,
1020            profile_rollup.as_ref(),
1021            timing.as_deref(),
1022            harn_vm::tracing::peek_spans().len() as u64,
1023            cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1024            "llm_mock_record",
1025            error,
1026        );
1027    }
1028
1029    // Always drain any captured stderr accumulated during execution.
1030    let buffered_stderr = harn_vm::take_stderr_buffer();
1031    stderr.push_str(&buffered_stderr);
1032
1033    let exit_code = match &execution {
1034        RunExecution::Terminal(terminal) => terminal.exit_code(),
1035        RunExecution::Failed(_) => 1,
1036    };
1037
1038    if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
1039        if let Err(error) = emit_run_attestation(
1040            log,
1041            path,
1042            store_base,
1043            attestation_started_at_ms,
1044            exit_code,
1045            options,
1046            &mut stderr,
1047        )
1048        .await
1049        {
1050            stderr.push_str(&format!(
1051                "error: failed to emit provenance receipt: {error}\n"
1052            ));
1053            let profile_rollup = if profile.is_enabled() {
1054                Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1055            } else {
1056                None
1057            };
1058            return finalize_run_error(
1059                stdout,
1060                stderr,
1061                json_session,
1062                summary.as_ref(),
1063                phase.as_ref(),
1064                rusage.as_ref(),
1065                run_started,
1066                profile_rollup.as_ref(),
1067                timing.as_deref(),
1068                harn_vm::tracing::peek_spans().len() as u64,
1069                cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1070                "attestation",
1071                error,
1072            );
1073        }
1074        harn_vm::event_log::reset_active_event_log();
1075    }
1076
1077    match execution {
1078        RunExecution::Terminal(terminal) => {
1079            stdout.push_str(output);
1080            let main_events = harn_vm::tracing::peek_spans().len() as u64;
1081            let cpu_ms_total = cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start));
1082            let profile_rollup = if profile.is_enabled() {
1083                Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1084            } else {
1085                None
1086            };
1087            let summary_llm = summary.as_ref().map(|_| run_summary_llm_snapshot());
1088            if trace {
1089                stderr.push_str(&render_trace_summary());
1090            }
1091            if let Some(profile_rollup) = profile_rollup.as_ref() {
1092                if let Err(error) =
1093                    render_and_persist_profile_rollup(&profile, profile_rollup, &mut stderr)
1094                {
1095                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1096                }
1097            }
1098            if let Some(diagnostic) = terminal.nonzero_return_diagnostic() {
1099                stderr.push_str(&diagnostic);
1100            }
1101            let aux_emission = emit_run_aux_for_exit(
1102                summary.as_ref(),
1103                phase.as_ref(),
1104                rusage.as_ref(),
1105                run_started,
1106                exit_code,
1107                profile_rollup.as_ref(),
1108                summary_llm,
1109                timing.as_deref(),
1110                main_events,
1111                cpu_ms_total,
1112                json_session.is_some(),
1113                &mut stderr,
1114            );
1115            if let Some(session) = json_session {
1116                if let Some(error) = aux_emission.error {
1117                    let mut outcome = session.finalize_error(
1118                        "run_aux",
1119                        format!("failed to emit auxiliary run JSON: {error}"),
1120                        1,
1121                    );
1122                    outcome.stderr = aux_emission.stderr;
1123                    return outcome;
1124                }
1125                let value = terminal.json_value();
1126                let mut outcome = session.finalize_result(value, aux_emission.exit_code);
1127                outcome.stderr = aux_emission.stderr;
1128                return outcome;
1129            }
1130            RunOutcome {
1131                stdout,
1132                stderr,
1133                exit_code: aux_emission.exit_code,
1134            }
1135        }
1136        RunExecution::Failed(rendered_error) => {
1137            stderr.push_str(&rendered_error);
1138            let main_events = harn_vm::tracing::peek_spans().len() as u64;
1139            let cpu_ms_total = cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start));
1140            let profile_rollup = if profile.is_enabled() {
1141                Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1142            } else {
1143                None
1144            };
1145            if let Some(profile_rollup) = profile_rollup.as_ref() {
1146                if let Err(error) =
1147                    render_and_persist_profile_rollup(&profile, profile_rollup, &mut stderr)
1148                {
1149                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1150                }
1151            }
1152            let aux_emission = emit_run_aux_for_exit(
1153                summary.as_ref(),
1154                phase.as_ref(),
1155                rusage.as_ref(),
1156                run_started,
1157                1,
1158                profile_rollup.as_ref(),
1159                None,
1160                timing.as_deref(),
1161                main_events,
1162                cpu_ms_total,
1163                json_session.is_some(),
1164                &mut stderr,
1165            );
1166            if let Some(session) = json_session {
1167                let mut outcome =
1168                    session.finalize_error("runtime", rendered_error, aux_emission.exit_code);
1169                outcome.stderr = aux_emission.stderr;
1170                return outcome;
1171            }
1172            RunOutcome {
1173                stdout,
1174                stderr,
1175                exit_code: aux_emission.exit_code,
1176            }
1177        }
1178    }
1179}
1180
1181/// State for a single `harn run --json` invocation. `execute_run_inner`
1182/// attaches its sink to the run's ambient execution scope, including setup and
1183/// terminal handling, so every observable event stays in this stream.
1184///
1185/// `finalize_result` / `finalize_error` emit the terminal event and
1186/// build a [`RunOutcome`] whose stdout/stderr captured-buffer fields
1187/// stay **empty** — the canonical stream is on `out`.
1188/// `outcome.exit_code` still carries the process exit code so the
1189/// binary entry can `process::exit(...)`.
1190struct JsonRunSession {
1191    emitter: self::json_events::NdjsonEmitter,
1192}
1193
1194impl JsonRunSession {
1195    fn new(options: RunJsonOptions, out: Box<dyn io::Write + Send>) -> Self {
1196        Self {
1197            emitter: NdjsonEmitter::new(out, options.quiet),
1198        }
1199    }
1200
1201    fn sink(&self) -> Arc<dyn harn_vm::run_events::RunEventSink> {
1202        self.emitter.sink()
1203    }
1204
1205    fn finalize_result(self, value: serde_json::Value, exit_code: i32) -> RunOutcome {
1206        self.emitter.emit_result(value, exit_code);
1207        RunOutcome {
1208            stdout: String::new(),
1209            stderr: String::new(),
1210            exit_code,
1211        }
1212    }
1213
1214    fn finalize_error(
1215        self,
1216        code: impl Into<String>,
1217        message: impl Into<String>,
1218        exit_code: i32,
1219    ) -> RunOutcome {
1220        self.emitter.emit_error(code, message);
1221        RunOutcome {
1222            stdout: String::new(),
1223            stderr: String::new(),
1224            exit_code,
1225        }
1226    }
1227}
1228
1229#[allow(clippy::too_many_arguments)]
1230fn finalize_run_error(
1231    stdout: String,
1232    mut stderr: String,
1233    json_session: Option<JsonRunSession>,
1234    summary: Option<&RunSummaryOptions>,
1235    phase: Option<&RunPhaseOptions>,
1236    rusage: Option<&RunRusageOptions>,
1237    started: Instant,
1238    profile: Option<&harn_vm::profile::RunProfile>,
1239    timing: Option<&RunTiming>,
1240    main_events: u64,
1241    cpu_ms_total: Option<u64>,
1242    code: impl Into<String>,
1243    message: impl Into<String>,
1244) -> RunOutcome {
1245    let aux_emission = emit_run_aux_for_exit(
1246        summary,
1247        phase,
1248        rusage,
1249        started,
1250        1,
1251        profile,
1252        None,
1253        timing,
1254        main_events,
1255        cpu_ms_total,
1256        json_session.is_some(),
1257        &mut stderr,
1258    );
1259    if let Some(session) = json_session {
1260        let mut outcome = session.finalize_error(code, message, aux_emission.exit_code);
1261        outcome.stderr = aux_emission.stderr;
1262        return outcome;
1263    }
1264    RunOutcome {
1265        stdout,
1266        stderr,
1267        exit_code: aux_emission.exit_code,
1268    }
1269}
1270
1271/// Translate a preflight failure into either the `--json` error event
1272/// stream or a plain stderr message plus exit-code 1. Keeps the
1273/// `.harnpack` verify path's error reporting consistent with the rest
1274/// of `harn run`.
1275fn finalize_harnpack_error(
1276    mut stderr: String,
1277    json_session: Option<JsonRunSession>,
1278    summary: Option<&RunSummaryOptions>,
1279    phase: Option<&RunPhaseOptions>,
1280    rusage: Option<&RunRusageOptions>,
1281    started: Instant,
1282    err: HarnpackError,
1283) -> RunOutcome {
1284    let code = err.code;
1285    let message = err.message;
1286    stderr.push_str(&format!("error: {message}\n"));
1287    finalize_run_error(
1288        String::new(),
1289        stderr,
1290        json_session,
1291        summary,
1292        phase,
1293        rusage,
1294        started,
1295        None,
1296        None,
1297        0,
1298        None,
1299        code,
1300        message,
1301    )
1302}
1303
1304/// Successful `--dry-run-verify` path. Reports the bundle hash and
1305/// signature outcome on stderr (since stdout belongs to the script) and
1306/// emits a terminal `result` event when `--json` is active so consumers
1307/// see the run complete.
1308fn finalize_harnpack_dry_run(
1309    mut stderr: String,
1310    json_session: Option<JsonRunSession>,
1311    summary_options: Option<&RunSummaryOptions>,
1312    phase_options: Option<&RunPhaseOptions>,
1313    rusage_options: Option<&RunRusageOptions>,
1314    started: Instant,
1315    cpu_ms_total: Option<u64>,
1316    prepared: &PreparedHarnpack,
1317) -> RunOutcome {
1318    let summary = format!(
1319        "[harn] harnpack verify ok: bundle_hash={}, signature_verified={}, cache_hit={}\n",
1320        prepared.bundle_hash, prepared.signature_verified, prepared.cache_hit
1321    );
1322    stderr.push_str(&summary);
1323    let aux_emission = emit_run_aux_for_exit(
1324        summary_options,
1325        phase_options,
1326        rusage_options,
1327        started,
1328        0,
1329        None,
1330        None,
1331        None,
1332        0,
1333        cpu_ms_total,
1334        json_session.is_some(),
1335        &mut stderr,
1336    );
1337    if let Some(session) = json_session {
1338        if let Some(error) = aux_emission.error {
1339            let mut outcome = session.finalize_error(
1340                "run_aux",
1341                format!("failed to emit auxiliary run JSON: {error}"),
1342                1,
1343            );
1344            outcome.stderr = aux_emission.stderr;
1345            return outcome;
1346        }
1347        let value = serde_json::json!({
1348            "bundle_hash": prepared.bundle_hash,
1349            "signature_verified": prepared.signature_verified,
1350            "key_id": prepared.key_id,
1351            "cache_hit": prepared.cache_hit,
1352            "dry_run_verify": true,
1353        });
1354        let mut outcome = session.finalize_result(value, aux_emission.exit_code);
1355        outcome.stderr = aux_emission.stderr;
1356        return outcome;
1357    }
1358    RunOutcome {
1359        stdout: String::new(),
1360        stderr,
1361        exit_code: aux_emission.exit_code,
1362    }
1363}
1364
1365fn render_return_value_error(value: &harn_vm::VmValue) -> String {
1366    let harn_vm::VmValue::EnumVariant(enum_variant) = value else {
1367        return String::new();
1368    };
1369    if !enum_variant.is_variant("Result", "Err") {
1370        return String::new();
1371    }
1372    let rendered = enum_variant
1373        .fields
1374        .first()
1375        .map(|p| p.display())
1376        .unwrap_or_default();
1377    if rendered.is_empty() {
1378        "error\n".to_string()
1379    } else if rendered.ends_with('\n') {
1380        rendered
1381    } else {
1382        format!("{rendered}\n")
1383    }
1384}
1385
1386pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1387    use notify::{Event, EventKind, RecursiveMode, Watcher};
1388
1389    let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1390        eprintln!("Error: {e}");
1391        process::exit(1);
1392    });
1393    let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1394
1395    eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1396    run_file(
1397        path,
1398        false,
1399        denied_builtins.clone(),
1400        Vec::new(),
1401        CliLlmMockMode::Off,
1402        None,
1403        RunProfileOptions::default(),
1404    )
1405    .await;
1406
1407    let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1408    let _watcher = {
1409        let tx = tx.clone();
1410        let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1411            if let Ok(event) = res {
1412                if matches!(
1413                    event.kind,
1414                    EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1415                ) {
1416                    let has_harn = event
1417                        .paths
1418                        .iter()
1419                        .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1420                    if has_harn {
1421                        let _ = tx.blocking_send(());
1422                    }
1423                }
1424            }
1425        })
1426        .unwrap_or_else(|e| {
1427            eprintln!("Error setting up file watcher: {e}");
1428            process::exit(1);
1429        });
1430        watcher
1431            .watch(watch_dir, RecursiveMode::Recursive)
1432            .unwrap_or_else(|e| {
1433                eprintln!("Error watching directory: {e}");
1434                process::exit(1);
1435            });
1436        watcher // keep alive
1437    };
1438
1439    eprintln!(
1440        "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
1441        watch_dir.display()
1442    );
1443
1444    loop {
1445        rx.recv().await;
1446        // Debounce: let bursts of events settle for 200ms before re-running.
1447        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1448        while rx.try_recv().is_ok() {}
1449
1450        eprintln!();
1451        eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
1452        run_file(
1453            path,
1454            false,
1455            denied_builtins.clone(),
1456            Vec::new(),
1457            CliLlmMockMode::Off,
1458            None,
1459            RunProfileOptions::default(),
1460        )
1461        .await;
1462    }
1463}
1464
1465#[cfg(test)]
1466mod tests;